Repository: sveltejs/svelte (JavaScript)
Date: 2019-09-11 22:27:48+00:00
Task type: Bug Fix

Issue:
CSS classes starting with `-` is always unused and `--` throws 
CSS allow classes starting with `-`, but the svelte compiler flags them as unused [REPL](https://svelte.technology/repl?version=2.13.2&gist=f146227d9270080eea32d537a70376a8)

With double `--` it's even worse [REPL](https://svelte.technology/repl?version=2.13.2&gist=1e3dc7df3352f78a0b0645f18c511503)


Issue discussion:
The 'identifier is expected' exception that's thrown in the second example is thrown by the underlying `css-tree` library we're using for CSS parsing. According to [this SO answer](https://stackoverflow.com/a/449000), beginning a class name with a double hyphen is actually illegal according to the spec. I don't know how we want to handle that. We could see whether a later version of css-tree allows this, or we could just not worry about it, since it's apparently illegal.

The issue with class names beginning with a single hyphen does seem to be an issue on our end though.

edit: There is a highly upvoted comment on that answer though that says "The W3C says that the use of a leading '-' or '_' should be reserved for vendor-specific CSS extensions" - I don't know whether that affects what we want to do here. _Probably_ not?
The problem is that `/\b-foo\b/.test('-foo')` is false.

When testing whether a selector with a class matches against an element, we are using the same implementation as when we are checking whether an attribute matches according to the `~=` operator. (I don't know whether this is correct in all cases.) The way we are doing _that_ is by wrapping it in `\b` and turning it into a regex. (I also don't know whether this is correct in all cases.) But at least one of those two is incorrect, because it results in testing `/\b-foo\b/` against the string `-foo`, which does not match, and the selector is seen as unused.

Edit: Checking on what `~=` means precisely, and it sounds like that probably is an acceptable thing to do for class matching. So this means the issue is using `\b` word boundaries to implement them, which don't do the right thing in certain cases, e.g. with a leading hyphen.
Reworking the handling of all of the operators in Selector.ts to not use regular expressions is probably the best way to do this, which will also fix other weird edge cases with selectors being interpreted as regular expressions. For example, the selector `[title='[']` throws an error during compilation because we are trying to construct the regex `^[$`.
Circling back on this a little bit -

What I have now is in [this branch](https://github.com/sveltejs/svelte/compare/master...Conduitry:gh-1710). It's failing a test though (and it's breaking some other things that are not currently tested for) because the class/etc names in selectors are no longer getting unescaped, and so are incorrectly seen as not matching the appropriate elements. I'll need to look into exactly what sorts of escaping can happen in those.

What we're doing currently is just treating them as regexes and sort of taking care of the `\` unescaping that way, but that's dangerous and the source of some of the other stuff I mentioned above,

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # Svelte changelog
 
+## Unreleased
+
+* Fix edge cases in matching selectors against elements ([#1710](https://github.com/sveltejs/svelte/issues/1710))
+
 ## 3.12.1
 
 * Escape `@` symbols in props, again ([#3545](https://github.com/sveltejs/svelte/issues/3545))
diff --git a/src/compiler/compile/css/Selector.ts b/src/compiler/compile/css/Selector.ts
--- a/src/compiler/compile/css/Selector.ts
+++ b/src/compiler/compile/css/Selector.ts
@@ -138,8 +138,9 @@ function apply_selector(stylesheet: Stylesheet, blocks: Block[], node: Node, sta
 
 	while (i--) {
 		const selector = block.selectors[i];
+		const name = typeof selector.name === 'string' && selector.name.replace(/\\(.)/g, '$1');
 
-		if (selector.type === 'PseudoClassSelector' && selector.name === 'global') {
+		if (selector.type === 'PseudoClassSelector' && name === 'global') {
 			// TODO shouldn't see this here... maybe we should enforce that :global(...)
 			// cannot be sandwiched between non-global selectors?
 			return false;
@@ -150,11 +151,11 @@ function apply_selector(stylesheet: Stylesheet, blocks: Block[], node: Node, sta
 		}
 
 		if (selector.type === 'ClassSelector') {
-			if (!attribute_matches(node, 'class', selector.name, '~=', false) && !class_matches(node, selector.name)) return false;
+			if (!attribute_matches(node, 'class', name, '~=', false) && !node.classes.some(c => c.name === name)) return false;
 		}
 
 		else if (selector.type === 'IdSelector') {
-			if (!attribute_matches(node, 'id', selector.name, '=', false)) return false;
+			if (!attribute_matches(node, 'id', name, '=', false)) return false;
 		}
 
 		else if (selector.type === 'AttributeSelector') {
@@ -162,8 +163,7 @@ function apply_selector(stylesheet: Stylesheet, blocks: Block[], node: Node, sta
 		}
 
 		else if (selector.type === 'TypeSelector') {
-			// remove toLowerCase() in v2, when uppercase elements will be forbidden
-			if (node.name.toLowerCase() !== selector.name.toLowerCase() && selector.name !== '*') return false;
+			if (node.name.toLowerCase() !== name.toLowerCase() && name !== '*') return false;
 		}
 
 		else {
@@ -206,14 +206,21 @@ function apply_selector(stylesheet: Stylesheet, blocks: Block[], node: Node, sta
 	return true;
 }
 
-const operators = {
-	'=' : (value: string, flags: string) => new RegExp(`^${value}$`, flags),
-	'~=': (value: string, flags: string) => new RegExp(`\\b${value}\\b`, flags),
-	'|=': (value: string, flags: string) => new RegExp(`^${value}(-.+)?$`, flags),
-	'^=': (value: string, flags: string) => new RegExp(`^${value}`, flags),
-	'$=': (value: string, flags: string) => new RegExp(`${value}$`, flags),
-	'*=': (value: string, flags: string) => new RegExp(value, flags)
-};
+function test_attribute(operator, expected_value, case_insensitive, value) {
+	if (case_insensitive) {
+		expected_value = expected_value.toLowerCase();
+		value = value.toLowerCase();
+	}
+	switch (operator) {
+		case '=': return value === expected_value;
+		case '~=': return ` ${value} `.includes(` ${expected_value} `);
+		case '|=': return `${value}-`.startsWith(`${expected_value}-`);
+		case '^=': return value.startsWith(expected_value);
+		case '$=': return value.endsWith(expected_value);
+		case '*=': return value.includes(expected_value);
+		default: throw new Error(`this shouldn't happen`);
+	}
+}
 
 function attribute_matches(node: Node, name: string, expected_value: string, operator: string, case_insensitive: boolean) {
 	const spread = node.attributes.find(attr => attr.type === 'Spread');
@@ -227,29 +234,22 @@ function attribute_matches(node: Node, name: string, expected_value: string, ope
 	if (attr.chunks.length > 1) return true;
 	if (!expected_value) return true;
 
-	const pattern = operators[operator](expected_value, case_insensitive ? 'i' : '');
 	const value = attr.chunks[0];
 
 	if (!value) return false;
-	if (value.type === 'Text') return pattern.test(value.data);
+	if (value.type === 'Text') return test_attribute(operator, expected_value, case_insensitive, value.data);
 
 	const possible_values = new Set();
 	gather_possible_values(value.node, possible_values);
 	if (possible_values.has(UNKNOWN)) return true;
 
-	for (const x of Array.from(possible_values)) { // TypeScript for-of is slightly unlike JS
-		if (pattern.test(x)) return true;
+	for (const value of possible_values) {
+		if (test_attribute(operator, expected_value, case_insensitive, value)) return true;
 	}
 
 	return false;
 }
 
-function class_matches(node, name: string) {
-	return node.classes.some((class_directive) => {
-		return new RegExp(`\\b${name}\\b`).test(class_directive.name);
-	});
-}
-
 function unquote(value: Node) {
 	if (value.type === 'Identifier') return value.name;
 	const str = value.value;


Test patch:
diff --git a/test/css/samples/weird-selectors/expected.css b/test/css/samples/weird-selectors/expected.css
new file mode 100644
--- /dev/null
+++ b/test/css/samples/weird-selectors/expected.css
@@ -0,0 +1 @@
+.-foo.svelte-xyz{color:red}[title='['].svelte-xyz{color:blue}
\ No newline at end of file
diff --git a/test/css/samples/weird-selectors/input.svelte b/test/css/samples/weird-selectors/input.svelte
new file mode 100644
--- /dev/null
+++ b/test/css/samples/weird-selectors/input.svelte
@@ -0,0 +1,12 @@
+<div class='-foo'>foo</div>
+
+<div title='['>bar</div>
+
+<style>
+	.-foo {
+		color: red;
+	}
+	[title='['] {
+		color: blue;
+	}
+</style>