From 8582b5a5c000e5b9c285beae0f7ec8a1ea5c2c59 Mon Sep 17 00:00:00 2001 From: Kristofer Baxter Date: Tue, 1 Sep 2026 10:44:28 -0700 Subject: [PATCH 1/5] feat: container queries, style() and scroll-state() Adds scopes for `@container`: the container name, size features, logical operators, `style()` queries and `scroll-state()` queries, including grouped and boolean forms. The body carries `meta.at-rule.container.body.css` and both of its braces are scoped, matching the other block at-rules. A query in parentheses may itself be a parenthesised query, so the groups nest rather than close at the first bracket, and `` is parsed so a bracket inside a string is not read as one. A `` is parsed recursively, so `not`, grouped queries and ranges are not read as a declaration list. A feature value is a component value, so a function may stand in for a keyword. `style()` holds a ``, where a `;` nested in brackets or a balanced block is part of the value. Its declaration-value region therefore ends only at `}` or `)`, so `style(--x: [a;b])` keeps the whole value. The container name is a ``, so it may begin with an escape. Only `none`, `and`, `not` and `or` are reserved, so `style` and `scroll-state` are names in their own right and only a bracket straight after either one makes it a query function. The query is optional, so a name may run into the body, and the prelude is a comma-separated list, so a name may start at a comma. The cost of a prelude is linear in its length: doubling a 12.5 KB prelude twice roughly doubles the time each step, and 50 KB takes under half a second, which is far past any real stylesheet. An unknown query function is `` wherever a query is valid, not only inside parentheses. A `)` separates the identifier that follows it, so `)and(` needs no whitespace. A style range may be written without spaces, value first, or chained, and its custom property name stops at the comparison. A size container feature takes a length or a ratio, and only `orientation` takes a keyword, so the values `@media` accepts are not query values here: `@container (width: fullscreen)` reads `fullscreen` as no more than an identifier. A malformed prelude leaks its header scope to the rest of the file, which is what `@media` already does. `@container` has no semicolon form, so `@container nonsense;` leaks with the rest of them. --- grammars/css.cson | 492 ++++++++++++++++++++++++++++++++++++ spec/css-spec.mjs | 618 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1110 insertions(+) diff --git a/grammars/css.cson b/grammars/css.cson index 039f54b..acba11c 100644 --- a/grammars/css.cson +++ b/grammars/css.cson @@ -644,6 +644,47 @@ } ] }, + { + # @container + 'begin': '(?i)(?=@container([\\s({;]|/\\*|$))' + 'end': '(?<=})(?!\\G)' + 'patterns': [ + { + 'begin': '(?i)\\G(@)container' + 'beginCaptures': + '0': + 'name': 'keyword.control.at-rule.container.css' + '1': + 'name': 'punctuation.definition.keyword.css' + 'end': '(?=\\s*[{;])' + 'name': 'meta.at-rule.container.header.css' + 'patterns': [ + { + 'include': '#container-query' + } + ] + } + { + 'begin': '{' + 'beginCaptures': + '0': + 'name': 'punctuation.section.container.begin.bracket.curly.css' + 'end': '}' + 'endCaptures': + '0': + 'name': 'punctuation.section.container.end.bracket.curly.css' + 'name': 'meta.at-rule.container.body.css' + 'patterns': [ + { + 'include': '#rule-list-innards' + } + { + 'include': '$self' + } + ] + } + ] + } { # Single line @custom-at-rule terminated with `;`, such as # @my-rule foo bar; @@ -1463,6 +1504,457 @@ (?=\\s|\\)|$) ''' 'name': 'support.constant.property-value.css' + 'container-query': + 'begin': '\\G' + 'end': '(?=\\s*[{;])' + 'patterns': [ + { + 'include': '#comment-block' + } + { + # Optional container name preceding the query, e.g. @container card (…) + # A `` is a ``, so it may begin with an + # escape. The name is immediately followed by the container condition, + # so anything not in that position is part of the condition instead. + 'captures': + '1': + 'name': 'variable.parameter.container-name.css' + 'patterns': [ + { + 'include': '#escapes' + } + ] + 'match': '''(?xi) + (?<=^|\\s|\\*/|,) + (?!(?:none|and|or|not)(?![\\w-])) # The four names the spec reserves + ( + (?:[-a-zA-Z_] | [^\\x00-\\x7F] # First letter + |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\n\\f]?|[^\\n]) + ) + (?:[-a-zA-Z0-9_] | [^\\x00-\\x7F] # Remainder of identifier + |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\n\\f]?|[^\\n]) + )* + ) + (?![\\w(-]) # Not a function call + (?= # A query, the body or the end + \\s*(?:/\\*(?:[^*]|\\*[^/])*\\*/\\s*)* + (?:\\(|(?i:style|scroll-state)\\s*\\(|\\{|$) + ) + ''' + } + { + 'include': '#commas' + } + { + 'match': '(?i)(?<=\\s|^|\\*/|\\))and(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.and.container.css' + } + { + 'match': '(?i)(?<=\\s|^|\\*/|\\))or(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.or.container.css' + } + { + 'match': '(?i)(?<=\\s|^|\\*/|\\))not(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.not.container.css' + } + { + # style() queries, e.g. @container style(--theme: dark) + 'begin': '(?i)(?`, in which a `{` is legal. + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.section.function.end.bracket.round.css' + 'name': 'meta.function.style.css' + 'patterns': [ + { + 'include': '#style-query-innards' + } + ] + } + { + # Scroll-state queries, e.g. @container scroll-state(stuck: top) + 'begin': '(?i)(?` here too, not + # just inside a parenthesised query. + 'include': '#container-general-enclosed' + } + ] + 'style-query-innards': + # A `` is a recursive grammar: `not`, `and`, `or`, groups in + # parentheses, a declaration, or a declaration-value range. + 'patterns': [ + { + 'match': '(?i)(?<=\\s|^|\\*/|\\(|\\))and(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.and.container.css' + } + { + 'match': '(?i)(?<=\\s|^|\\*/|\\(|\\))or(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.or.container.css' + } + { + 'match': '(?i)(?<=\\s|^|\\*/|\\(|\\))not(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.not.container.css' + } + { + 'include': '#style-in-parens' + } + { + 'include': '#container-general-enclosed' + } + { + # A style feature may be a range, e.g. style(--level >= 2), which is + # not a declaration and so must not reach #rule-list-innards. + 'begin': '''(?x) \\G + (?= + # A range holds a comparison before any `:`, which is what + # separates it from a declaration. Anchoring at \\G keeps this + # lookahead from being attempted at every character. + (?: [^:;{}()<>=] | \\([^()]*\\) )* + (?:>=|<=|=|<|>) + ) + ''' + 'end': '(?=[)}])' + 'patterns': [ + { + 'match': '--[^\\s;{}()<>=]+' + 'name': 'variable.css' + } + { + 'match': '>=|<=|=|<|>' + 'name': 'keyword.operator.comparison.css' + } + { + 'include': '#property-values' + } + ] + } + { + # Local copy of the declaration-value region from #rule-list-innards, + # which also ends at `;`. A style() query holds a + # ``, where a `;` nested in brackets or a + # balanced block belongs to the value, so this copy ends only at + # `}` or `)` and keeps the value whole. + 'begin': '(:)\\s*' + 'beginCaptures': + '1': + 'name': 'punctuation.separator.key-value.css' + 'end': '\\s*(?=[})])' + 'contentName': 'meta.property-value.css' + 'patterns': [ + { + 'include': '#property-values' + } + ] + } + { + 'include': '#rule-list-innards' + } + ] + 'style-in-parens': + 'begin': '\\(' + 'beginCaptures': + '0': + 'name': 'punctuation.definition.parameters.begin.bracket.round.css' + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.definition.parameters.end.bracket.round.css' + 'patterns': [ + { + 'include': '#style-query-innards' + } + ] + 'container-query-in-parens': + 'begin': '\\(' + 'beginCaptures': + '0': + 'name': 'punctuation.definition.parameters.begin.bracket.round.css' + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.definition.parameters.end.bracket.round.css' + 'patterns': [ + { + # A query in parentheses may itself be a parenthesised query, so the + # groups have to nest rather than close at the first `)`. + 'include': '#container-query-in-parens' + } + { + 'include': '#size-container-features' + } + { + 'match': '(?i)(?<=\\s|^|\\*/|\\(|\\))and(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.and.container.css' + } + { + 'match': '(?i)(?<=\\s|^|\\*/|\\(|\\))or(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.or.container.css' + } + { + 'match': '(?i)(?<=\\s|^|\\*/|\\(|\\))not(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.not.container.css' + } + { + 'match': ':' + 'name': 'punctuation.separator.key-value.css' + } + { + 'match': '>=|<=|=|<|>' + 'name': 'keyword.operator.comparison.css' + } + { + 'captures': + '1': + 'name': 'constant.numeric.css' + '2': + 'name': 'keyword.operator.arithmetic.css' + '3': + 'name': 'constant.numeric.css' + 'match': '(\\d+)\\s*(/)\\s*(\\d+)' + 'name': 'meta.ratio.css' + } + { + # A size container feature takes a length or a ratio, and only + # `orientation` takes a keyword, so the media feature values do + # not belong here. + 'match': '(?i)(?<=^|\\s|:|\\*/)(?:portrait|landscape)(?=\\s|\\)|$)' + 'name': 'support.constant.property-value.css' + } + { + 'include': '#numeric-values' + } + { + 'include': '#comment-block' + } + { + 'include': '#functions' + } + { + 'include': '#container-general-enclosed' + } + { + 'include': '#string' + } + ] + 'container-general-enclosed': + # `` lets an unknown function appear in a query. Its + # contents only have to be balanced, so they are parsed for nesting and + # for strings, which may hold an unmatched bracket. + # The function name is a CSS identifier, so it may contain escapes and + # non-ASCII characters. Matching only the ASCII tail would start a + # function part way through a name such as `f\\6f o(…)`. + 'begin': '''(?x) + (?<:=]|\\)|/\\*) # Terminates cleanly + ''' + 'scroll-state-condition': + 'patterns': [ + { + 'include': '#comment-block' + } + { + 'begin': '\\(' + 'beginCaptures': + '0': + 'name': 'punctuation.definition.parameters.begin.bracket.round.css' + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.definition.parameters.end.bracket.round.css' + 'patterns': [ + { + 'include': '#scroll-state-condition' + } + ] + } + { + 'match': '(?i)(?<=\\s|^|\\*/|\\(|\\))and(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.and.container.css' + } + { + 'match': '(?i)(?<=\\s|^|\\*/|\\(|\\))or(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.or.container.css' + } + { + 'match': '(?i)(?<=\\s|^|\\*/|\\(|\\))not(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.not.container.css' + } + { + 'include': '#container-general-enclosed' + } + { + 'include': '#string' + } + { + 'begin': '(?i)(? 10px) {}').tokens; + assert.deepStrictEqual(tokens[4], { + scopes: ['source.css', 'meta.at-rule.container.header.css'], + value: 'widthish ' + }); + }); + + it('scopes both braces of a @container body', function () { + // The opening brace is covered by the omitted-prelude test, but + // nothing pinned the closing one, so it could be renamed without + // any test failing. Every other at-rule with a body asserts both. + var tokens = testGrammar.tokenizeLine('@container (width > 1px) { .x { color: red; } }').tokens; + assert.deepStrictEqual(tokens[12], { + scopes: [ + 'source.css', + 'meta.at-rule.container.body.css', + 'punctuation.section.container.begin.bracket.curly.css' + ], + value: '{' + }); + assert.deepStrictEqual(tokens[27], { + scopes: [ + 'source.css', + 'meta.at-rule.container.body.css', + 'punctuation.section.container.end.bracket.curly.css' + ], + value: '}' + }); + }); + + it('scopes both parentheses of a grouped scroll-state() condition', function () { + // The inner group and the function call close with different + // punctuation scopes; neither closing parenthesis was pinned. + var tokens = testGrammar.tokenizeLine('@container scroll-state((stuck: top)) {}').tokens; + assert.deepStrictEqual(tokens[10], { + scopes: [ + 'source.css', + 'meta.at-rule.container.header.css', + 'meta.function.scroll-state.css', + 'punctuation.definition.parameters.end.bracket.round.css' + ], + value: ')' + }); + assert.deepStrictEqual(tokens[11], { + scopes: [ + 'source.css', + 'meta.at-rule.container.header.css', + 'meta.function.scroll-state.css', + 'punctuation.section.function.end.bracket.round.css' + ], + value: ')' + }); + }); + + it('supports comments between scroll-state conditions', function () { + var tokens = testGrammar.tokenizeLine('@container scroll-state(stuck: top/* c */and (snapped: x)) {').tokens; + var head = ['source.css', 'meta.at-rule.container.header.css', 'meta.function.scroll-state.css', 'comment.block.css']; + assert.deepStrictEqual(tokens.find(x => x.value === '/*').scopes, head.concat(['punctuation.definition.comment.begin.css'])); + assert.deepStrictEqual(tokens.find(x => x.value === ' c ').scopes, head); + assert.deepStrictEqual(tokens.find(x => x.value === '*/').scopes, head.concat(['punctuation.definition.comment.end.css'])); + assert.deepStrictEqual(tokens.find(x => x.value === 'and').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.scroll-state.css', 'keyword.operator.logical.and.container.css']); + assert.deepStrictEqual(tokens.find(x => x.value === 'snapped').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.scroll-state.css', 'support.type.property-name.container.css']); + assert.deepStrictEqual(tokens.find(x => x.value === 'x').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.scroll-state.css', 'support.constant.property-value.css']); + }); + + it('tokenizes @container logical operators', function () { + var tokens; + tokens = testGrammar.tokenizeLine('@container (min-width: 400px) and (max-width: 900px) {').tokens; + var and = tokens.find(t => t.value === 'and'); + assert.deepStrictEqual(and.scopes, ['source.css', 'meta.at-rule.container.header.css', 'keyword.operator.logical.and.container.css']); + }); + + it('tokenizes @container scroll-state() queries', function () { + var tokens = testGrammar.tokenizeLine('@container scroll-state(stuck: block-start) {').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'scroll-state').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.scroll-state.css', 'support.function.scroll-state.css']); + assert.deepStrictEqual(tokens.find(x => x.value === 'stuck').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.scroll-state.css', 'support.type.property-name.container.css']); + assert.deepStrictEqual(tokens.find(x => x.value === 'block-start').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.scroll-state.css', 'support.constant.property-value.css']); + }); + + it('tokenizes @container style() queries', function () { + var tokens; + tokens = testGrammar.tokenizeLine('@container style(--theme: dark) {').tokens; + assert.deepStrictEqual(tokens[3], { scopes: ['source.css', 'meta.at-rule.container.header.css', 'meta.function.style.css', 'support.function.style.css'], value: 'style' }); + }); + + it('tokenizes @container with a container name', function () { + var tokens; + tokens = testGrammar.tokenizeLine('@container card (min-width: 400px) { .x { color: red; } }').tokens; + assert.deepStrictEqual(tokens[0], { scopes: ['source.css', 'meta.at-rule.container.header.css', 'keyword.control.at-rule.container.css', 'punctuation.definition.keyword.css'], value: '@' }); + assert.deepStrictEqual(tokens[1], { scopes: ['source.css', 'meta.at-rule.container.header.css', 'keyword.control.at-rule.container.css'], value: 'container' }); + assert.deepStrictEqual(tokens[3], { scopes: ['source.css', 'meta.at-rule.container.header.css', 'variable.parameter.container-name.css'], value: 'card' }); + assert.deepStrictEqual(tokens[5], { scopes: ['source.css', 'meta.at-rule.container.header.css', 'punctuation.definition.parameters.begin.bracket.round.css'], value: '(' }); + assert.deepStrictEqual(tokens[6], { scopes: ['source.css', 'meta.at-rule.container.header.css', 'support.type.property-name.container.css'], value: 'min-width' }); + }); + + it('tokenizes grouped @container scroll-state() conditions', function () { + var tokens = testGrammar.tokenizeLine('@container scroll-state((stuck: top) or (scrolled: block-start)) {').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'or').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.scroll-state.css', 'keyword.operator.logical.or.container.css']); + assert.deepStrictEqual(tokens.find(x => x.value === 'scrolled').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.scroll-state.css', 'support.type.property-name.container.css']); + assert.deepStrictEqual(tokens.find(x => x.value === 'block-start').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.scroll-state.css', 'support.constant.property-value.css']); + }); + + it('tokenizes scroll-state features in boolean context', function () { + var tokens = testGrammar.tokenizeLine('@container scroll-state((stuck) or snapped) {').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'stuck').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.scroll-state.css', 'support.type.property-name.container.css']); + assert.deepStrictEqual(tokens.find(x => x.value === 'snapped').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.scroll-state.css', 'support.type.property-name.container.css']); + }); + + it('uses distinct size and scroll-state feature grammars', function () { + var tokens = testGrammar.tokenizeLine('@container (width > 10px) {').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'width').scopes, ['source.css', 'meta.at-rule.container.header.css', 'support.type.property-name.container.css']); + + tokens = testGrammar.tokenizeLine('@container (stuck: top) {').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'stuck').scopes, ['source.css', 'meta.at-rule.container.header.css']); + + tokens = testGrammar.tokenizeLine('@container scroll-state(width: 10px) {').tokens; + assert.deepStrictEqual(tokens.find(x => x.value.includes('width')).scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.scroll-state.css']); + }); + + it('uses values associated with each scroll-state feature', function () { + var tokens = testGrammar.tokenizeLine('@container scroll-state((stuck: top) and (snapped: both) and (scrollable: inline)) {').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'top').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.scroll-state.css', 'support.constant.property-value.css']); + assert.deepStrictEqual(tokens.find(x => x.value === 'both').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.scroll-state.css', 'support.constant.property-value.css']); + assert.deepStrictEqual(tokens.find(x => x.value === 'inline').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.scroll-state.css', 'support.constant.property-value.css']); + + tokens = testGrammar.tokenizeLine('@container scroll-state(stuck: x) {').tokens; + assert.deepStrictEqual(tokens.find(x => x.value.trim() === 'x').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.scroll-state.css']); + }); + + it('keeps a prelude that is merely continued on the next line intact', function () { + // A prelude may legally span lines until its `{`, so a bare + // unterminated one must keep running -- exactly as an unterminated + // `@media` or `@supports` prelude has always done. Only a `{` that + // ends the line is treated as evidence of a mistake. Asserted so + // that a future change to this behaviour is a deliberate one. + var lines = testGrammar.tokenizeLines('@container style(--theme: dark\n.after { color: red; }'); + assert.deepStrictEqual(lines[1].find(t => t.value.trim() === '.after').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.style.css', 'meta.property-value.css']); + }); + + it('keeps a legal balanced block inside a condition in the header', function () { + // `` is `( ? )` and `` admits + // a balanced block, so this must stay inside the header, matching what + // an `@media` prelude already does. + var tokens = testGrammar.tokenizeLine('@container ({ future }) {').tokens; + var future = tokens.find(t => t.value.includes('future')); + assert.ok(future, '`future` missing'); + assert.ok(future.scopes.includes('meta.at-rule.container.header.css'), + 'left the header: ' + future.scopes.join(' ')); + }); + + it('keeps a balanced block in a style() query value inside the query', function () { + // A custom property value may legally contain a `{...}` block, so the + // braces belong to the value and the query continues past them. + ['@container style(--x: {a})', '@container style(--grid: {display:grid})', '@container style(--x: {a; b})'].forEach(function (prelude) { + var lines = testGrammar.tokenizeLines(prelude + ' {\n.z { color: red; }\n}'); + // The braces must stay inside the value as a group, not be + // released to the enclosing query. + assert.deepStrictEqual(lines[0].find(t => t.value === '{').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.style.css', 'meta.property-value.css', 'punctuation.section.group.begin.bracket.curly.css'], prelude); + assert.deepStrictEqual(lines[0].filter(t => t.value === '}').pop().scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.style.css', 'meta.property-value.css', 'punctuation.section.group.end.bracket.curly.css'], prelude); + assert.deepStrictEqual(lines[0].find(t => t.value === ')').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.style.css', 'punctuation.section.function.end.bracket.round.css'], prelude); + assert.deepStrictEqual(lines[1].find(t => t.value === 'z').scopes, ['source.css', 'meta.at-rule.container.body.css', 'meta.selector.css', 'entity.other.attribute-name.class.css'], prelude); + }); + }); + + it('keeps a balanced block in a style() query value across lines', function () { + // The same block spanning lines must not be mistaken for the + // container body; the rule after the query still belongs to it. + var lines = testGrammar.tokenizeLines('@container style(--x: {\n color: red;\n}) { .z { color: blue; } }'); + assert.deepStrictEqual(lines[2].find(t => t.value === 'z').scopes, ['source.css', 'meta.at-rule.container.body.css', 'meta.selector.css', 'entity.other.attribute-name.class.css']); + assert.deepStrictEqual(lines[2].find(t => t.value === 'blue').scopes, ['source.css', 'meta.at-rule.container.body.css', 'meta.property-list.css', 'meta.property-value.css', 'support.constant.color.w3c-standard-color-name.css']); + }); + + it('keeps a semicolon or brace that the query itself closes', function () { + // `` is `( ? )` and `` + // permits a top-level `;`, while `` permits one + // inside any balanced block. Neither ends the query, so forward + // compatible syntax keeps its scopes. + [ + ['@container (future; syntax)', 'meta.at-rule.container.header.css'], + ['@container (future { syntax })', 'meta.at-rule.container.header.css'], + ['@container style(--x: [a;b])', 'meta.at-rule.container.header.css'], + ['@container style(--x: (a;b))', 'meta.at-rule.container.header.css'], + ['@container style(foo(a;b))', 'meta.at-rule.container.header.css'], + ['@container style((foo;bar))', 'meta.at-rule.container.header.css'] + ].forEach(function (probe) { + var lines = testGrammar.tokenizeLines(probe[0] + ' {\n.z { color: red; }\n}'); + var close = lines[0].filter(t => t.value === ')').pop(); + assert.ok(close.scopes.indexOf(probe[1]) !== -1, probe[0] + ' -> ) left the header: ' + close.scopes.join('|')); + assert.deepStrictEqual(lines[1].find(t => t.value === 'z').scopes, ['source.css', 'meta.at-rule.container.body.css', 'meta.selector.css', 'entity.other.attribute-name.class.css'], probe[0]); + }); + }); + + it('keeps a style() query that is cut off by a brace inside the query', function () { + // `style()` wraps ``, which may legally contain a + // balanced `{...}` block, so a `{` here stays part of the query. + var lines = testGrammar.tokenizeLines('@container style(--theme: dark{\n.after { color: red; }\n}'); + assert.ok(lines[0].find(t => t.value === '{').scopes.includes('meta.at-rule.container.header.css')); + }); + + it('keeps a brace that the query itself closes, in every region', function () { + // One case per region where `` or `` + // admits a balanced block. A `{` closed later on the same line + // belongs to the query, not to the rule that follows it. + [ + ['@container style({a})', 'container'], + ['@container style(--x: {a})', 'container'], + ['@container scroll-state({a})', 'container'], + ['@container scroll-state(stuck: {a})', 'container'], + ['@container scroll-state(snapped: {a})', 'container'], + ['@container scroll-state(scrollable: {a})', 'container'] + ].forEach(function (probe) { + var lines = testGrammar.tokenizeLines(probe[0] + ' {\n.z { color: red; }\n}'); + var close = lines[0].filter(t => t.value === ')').pop(); + assert.ok(close.scopes.indexOf('meta.at-rule.' + probe[1] + '.header.css') !== -1, probe[0] + ' -> ) left the header: ' + close.scopes.join('|')); + assert.deepStrictEqual(lines[1].find(t => t.value === 'z').scopes, ['source.css', 'meta.at-rule.' + probe[1] + '.body.css', 'meta.selector.css', 'entity.other.attribute-name.class.css'], probe[0]); + }); + }); + + it('scopes a comma between container conditions', function () { + var tokens = testGrammar.tokenizeLine('@container card (width > 30em), style(--large: true) {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === ',').scopes, ['source.css', 'meta.at-rule.container.header.css', 'punctuation.separator.list.comma.css']); + }); + + it('keeps an escape inside the container name it begins', function () { + // `\63 ard` is one custom-ident spelling `card`, so the escape and + // the rest of the identifier belong to the same name. + var tokens = testGrammar.tokenizeLine('@container \\63 ard (width > 1px) {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === '\\63').scopes, ['source.css', 'meta.at-rule.container.header.css', 'variable.parameter.container-name.css', 'constant.character.escape.codepoint.css']); + assert.deepStrictEqual(tokens.find(x => x.value === ' ard').scopes, ['source.css', 'meta.at-rule.container.header.css', 'variable.parameter.container-name.css']); + }); + + it('does not take an identifier for a container name unless the condition follows', function () { + // Without this the name matcher runs at every identifier in the + // prelude, which is both wrong and quadratic. + var tokens = testGrammar.tokenizeLine('@container one two (width > 1px) {}').tokens; + assert.strictEqual(tokens.filter(x => x.scopes.includes('variable.parameter.container-name.css')).length, 1); + assert.deepStrictEqual(tokens.find(x => x.scopes.includes('variable.parameter.container-name.css')).value, 'two'); + }); + + it('nests a query in parentheses instead of closing at the first bracket', function () { + var tokens = testGrammar.tokenizeLine('@container ((width > 1px) and (height > 2px)) {}').tokens; + var parens = tokens.filter(x => x.value === '(' || x.value === ')'); + assert.strictEqual(parens.length, 6); + parens.forEach(function (t) { + assert.ok(t.scopes.includes(t.value === '(' ? 'punctuation.definition.parameters.begin.bracket.round.css' : 'punctuation.definition.parameters.end.bracket.round.css'), JSON.stringify(t)); + }); + assert.deepStrictEqual(tokens.find(x => x.value === 'and').scopes, ['source.css', 'meta.at-rule.container.header.css', 'keyword.operator.logical.and.container.css']); + }); + + it('does not let a bracket inside a string close a general-enclosed query', function () { + // `` only has to be balanced, and a `)` in a + // string is not a bracket. + var tokens = testGrammar.tokenizeLine('@container (future(")")) {}').tokens; + var inString = tokens.filter(x => x.value === ')' && x.scopes.includes('string.quoted.double.css')); + assert.strictEqual(inString.length, 1); + assert.deepStrictEqual(tokens[tokens.length - 1].scopes, ['source.css', 'meta.at-rule.container.body.css', 'punctuation.section.container.end.bracket.curly.css']); + }); + + it('tokenizes a function in a scroll-state feature value', function () { + // A feature value is a component value, so `var()` may stand in + // for the keyword, and its `)` is not the query's. + var tokens = testGrammar.tokenizeLine('@container scroll-state(scrollable: var(--edge, bottom)) {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === '--edge').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.scroll-state.css', 'meta.function.variable.css', 'variable.argument.css']); + assert.deepStrictEqual(tokens[tokens.length - 1].scopes, ['source.css', 'meta.at-rule.container.body.css', 'punctuation.section.container.end.bracket.curly.css']); + }); + + it('tokenizes a negated style() query', function () { + var tokens = testGrammar.tokenizeLine('@container style(not (--theme: dark)) {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'not').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.style.css', 'keyword.operator.logical.not.container.css']); + assert.deepStrictEqual(tokens.find(x => x.value === '--theme').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.style.css', 'variable.css']); + }); + + it('tokenizes a style() range query', function () { + // A range is not a declaration, so it must not be read as one. + var tokens = testGrammar.tokenizeLine('@container style(--level >= 2) {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === '>=').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.style.css', 'keyword.operator.comparison.css']); + assert.deepStrictEqual(tokens.find(x => x.value === '2').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.style.css', 'constant.numeric.css']); + }); + + it('groups style() queries in parentheses', function () { + var tokens = testGrammar.tokenizeLine('@container style((--a: 1) and (--b: 2)) {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'and').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.style.css', 'keyword.operator.logical.and.container.css']); + assert.strictEqual(tokens.filter(x => x.scopes.includes('punctuation.definition.parameters.begin.bracket.round.css')).length, 2); + }); + + it('leaks a semicolon-terminated container rule, which has no such form', function () { + // @container has no semicolon form, so `@container nonsense;` is + // malformed and must leak like any other malformed prelude. The + // test for a leak is that the line below tokenizes differently + // from the same line on its own; matching means the grammar + // recovered, which is what this must not do. + var scopesOf = ts => ts.map(t => t.scopes.join(',')).join('|'); + var clean = scopesOf(testGrammar.tokenizeLine('.after { color: red; }').tokens); + ['@container nonsense;', '@container (width > 1px{', '@container style(--x: y{'].forEach(function (bad) { + var lines = testGrammar.tokenizeLines(bad + '\n.after { color: red; }'); + assert.notStrictEqual(scopesOf(lines[1]), clean, 'recovered after: ' + bad); + }); + }); + + it('scopes a style range written without spaces, value first, or chained', function () { + [ + ['@container style(--level>=2) {}', '>='], + ['@container style(2 < --level) {}', '<'], + ['@container style(1 < --level < 3) {}', '<'] + ].forEach(function (pair) { + var tokens = testGrammar.tokenizeLine(pair[0]).tokens; + var op = tokens.find(x => x.value.trim() === pair[1]); + assert.deepStrictEqual(op.scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.style.css', 'keyword.operator.comparison.css'], pair[0]); + }); + var name = testGrammar.tokenizeLine('@container style(--level>=2) {}').tokens.find(x => x.value === '--level'); + assert.ok(name, 'the custom property name must not swallow the operator'); + }); + + it('keeps a declaration whose value holds a bracketed semicolon out of the range rule', function () { + var tokens = testGrammar.tokenizeLine('@container style(--x: [a;b]) {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === '--x').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.style.css', 'variable.css']); + assert.deepStrictEqual(tokens.find(x => x.value === '[a;b]').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.style.css', 'meta.property-value.css']); + assert.ok(!tokens.some(x => x.scopes.includes('punctuation.terminator.rule.css')), 'the bracketed semicolon ended the declaration'); + assert.ok(!tokens.some(x => x.scopes.includes('meta.selector.css')), 'the bracketed semicolon started a selector'); + }); + + it('takes a bare declaration directly in the container body', function () { + var body = ['source.css', 'meta.at-rule.container.body.css']; + var tokens = testGrammar.tokenizeLine('@container (width>1px) { --x: y; }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === '--x').scopes, body.concat(['variable.css'])); + assert.deepStrictEqual(tokens.find(x => x.value === ':').scopes, body.concat(['punctuation.separator.key-value.css'])); + assert.deepStrictEqual(tokens.find(x => x.value === 'y').scopes, body.concat(['meta.property-value.css', 'support.constant.property-value.css'])); + assert.deepStrictEqual(tokens.find(x => x.value === ';').scopes, body.concat(['punctuation.terminator.rule.css'])); + }); + + it('keeps a bracket inside a scroll-state string out of the balancing', function () { + var head = ['source.css', 'meta.at-rule.container.header.css', 'meta.function.scroll-state.css', 'string.quoted.double.css']; + var tokens = testGrammar.tokenizeLine('@container scroll-state("a)b") {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'a)b').scopes, head); + // The closing bracket of the string must not end the function. + assert.deepStrictEqual(tokens.filter(x => x.scopes.includes('punctuation.section.function.end.bracket.round.css')).length, 1); + assert.deepStrictEqual(tokens.find(x => x.value === '{').scopes, ['source.css', 'meta.at-rule.container.body.css', 'punctuation.section.container.begin.bracket.curly.css']); + }); + + it('parses comments, escapes, strings, numbers and functions in every container context', function () { + [ + ["@container (width>1px) { a { color: red; } }", "color", ["source.css","meta.at-rule.container.body.css","meta.property-list.css","meta.property-name.css","support.type.property-name.css"]], + ["@container /*c*/ card (width>1px) {}", "c", ["source.css","meta.at-rule.container.header.css","comment.block.css"]], + ["@container ca\\72 d (width>1px) {}", "\\72", ["source.css","meta.at-rule.container.header.css","variable.parameter.container-name.css","constant.character.escape.codepoint.css"]], + ["@container style(/*c*/ --x: 1) {}", "c", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","comment.block.css"]], + ["@container style(--x /*c*/ > 1) {}", "c", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","comment.block.css"]], + ["@container style(--x: 1 /*c*/) {}", "c", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","meta.property-value.css","comment.block.css"]], + ["@container (orientation: landscape) {}", "landscape", ["source.css","meta.at-rule.container.header.css","support.constant.property-value.css"]], + ["@container (/*c*/ width>1px) {}", "c", ["source.css","meta.at-rule.container.header.css","comment.block.css"]], + ["@container (width > calc(1px + 2px)) {}", "calc", ["source.css","meta.at-rule.container.header.css","meta.function.calc.css","support.function.calc.css"]], + ["@container (foo: \"a)b\") {}", "a)b", ["source.css","meta.at-rule.container.header.css","string.quoted.double.css"]], + ["@container foo(bar(baz)) {}", "bar", ["source.css","meta.at-rule.container.header.css","meta.function.misc.css","meta.function.misc.css","support.function.misc.css"]], + ["@container foo(/*c*/) {}", "c", ["source.css","meta.at-rule.container.header.css","meta.function.misc.css","comment.block.css"]], + ["@container foo(1px) {}", "1", ["source.css","meta.at-rule.container.header.css","meta.function.misc.css","constant.numeric.css"]], + ["@container scroll-state(/*c*/ stuck: top) {}", "c", ["source.css","meta.at-rule.container.header.css","meta.function.scroll-state.css","comment.block.css"]], + ["@container scroll-state(future(x)) {}", "future", ["source.css","meta.at-rule.container.header.css","meta.function.scroll-state.css","meta.function.misc.css","support.function.misc.css"]], + ["@container scroll-state(future(\"a)b\")) {}", "a)b", ["source.css","meta.at-rule.container.header.css","meta.function.scroll-state.css","meta.function.misc.css","string.quoted.double.css"]], + ["@container scroll-state(stuck: calc(1px)) {}", "calc", ["source.css","meta.at-rule.container.header.css","meta.function.scroll-state.css","meta.function.calc.css","support.function.calc.css"]], + ["@container scroll-state(snapped: /*c*/ x) {}", "c", ["source.css","meta.at-rule.container.header.css","meta.function.scroll-state.css","comment.block.css"]], + ["@container scroll-state(snapped: calc(1px)) {}", "calc", ["source.css","meta.at-rule.container.header.css","meta.function.scroll-state.css","meta.function.calc.css","support.function.calc.css"]], + ["@container scroll-state(scrollable: /*c*/ x) {}", "c", ["source.css","meta.at-rule.container.header.css","meta.function.scroll-state.css","comment.block.css"]], + ].forEach(function (c) { + var token = testGrammar.tokenizeLine(c[0]).tokens.find(x => x.value === c[1]); + assert.ok(token, c[1] + ' not found in ' + c[0]); + assert.deepStrictEqual(token.scopes, c[2], c[0]); + }); + }); + + it('marks the punctuation and separators of every container query context', function () { + [ + ["@container style(--x: red) {}", [ + ["(", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","punctuation.section.function.begin.bracket.round.css"]], + ["--x", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","variable.css"]], + [":", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","punctuation.separator.key-value.css"]], + [")", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","punctuation.section.function.end.bracket.round.css"]], + ]], + ["@container style(--x >= 2) {}", [ + ["(", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","punctuation.section.function.begin.bracket.round.css"]], + ["--x", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","variable.css"]], + [">=", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","keyword.operator.comparison.css"]], + [")", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","punctuation.section.function.end.bracket.round.css"]], + ]], + ["@container style((--a: 1) or (--b: 2)) {}", [ + ["(", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","punctuation.section.function.begin.bracket.round.css"]], + ["(", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","punctuation.definition.parameters.begin.bracket.round.css"]], + ["--a", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","variable.css"]], + [":", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","punctuation.separator.key-value.css"]], + [")", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","punctuation.definition.parameters.end.bracket.round.css"]], + ["(", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","punctuation.definition.parameters.begin.bracket.round.css"]], + ["--b", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","variable.css"]], + [":", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","punctuation.separator.key-value.css"]], + [")", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","punctuation.definition.parameters.end.bracket.round.css"]], + [")", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","punctuation.section.function.end.bracket.round.css"]], + ]], + ["@container style(not (--a: 1)) {}", [ + ["(", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","punctuation.section.function.begin.bracket.round.css"]], + ["(", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","punctuation.definition.parameters.begin.bracket.round.css"]], + ["--a", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","variable.css"]], + [":", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","punctuation.separator.key-value.css"]], + [")", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","punctuation.definition.parameters.end.bracket.round.css"]], + [")", ["source.css","meta.at-rule.container.header.css","meta.function.style.css","punctuation.section.function.end.bracket.round.css"]], + ]], + ["@container (width: 1px) {}", [ + ["(", ["source.css","meta.at-rule.container.header.css","punctuation.definition.parameters.begin.bracket.round.css"]], + [":", ["source.css","meta.at-rule.container.header.css","punctuation.separator.key-value.css"]], + [")", ["source.css","meta.at-rule.container.header.css","punctuation.definition.parameters.end.bracket.round.css"]], + ]], + ["@container (width >= 1px) {}", [ + ["(", ["source.css","meta.at-rule.container.header.css","punctuation.definition.parameters.begin.bracket.round.css"]], + [">=", ["source.css","meta.at-rule.container.header.css","keyword.operator.comparison.css"]], + [")", ["source.css","meta.at-rule.container.header.css","punctuation.definition.parameters.end.bracket.round.css"]], + ]], + ["@container foo(bar) {}", [ + ["(", ["source.css","meta.at-rule.container.header.css","meta.function.misc.css","punctuation.section.function.begin.bracket.round.css"]], + [")", ["source.css","meta.at-rule.container.header.css","meta.function.misc.css","punctuation.section.function.end.bracket.round.css"]], + ]], + ["@container scroll-state(stuck: top) {}", [ + ["(", ["source.css","meta.at-rule.container.header.css","meta.function.scroll-state.css","punctuation.section.function.begin.bracket.round.css"]], + [":", ["source.css","meta.at-rule.container.header.css","meta.function.scroll-state.css","punctuation.separator.key-value.css"]], + [")", ["source.css","meta.at-rule.container.header.css","meta.function.scroll-state.css","punctuation.section.function.end.bracket.round.css"]], + ]], + ["@container scroll-state(snapped: x) {}", [ + ["(", ["source.css","meta.at-rule.container.header.css","meta.function.scroll-state.css","punctuation.section.function.begin.bracket.round.css"]], + [":", ["source.css","meta.at-rule.container.header.css","meta.function.scroll-state.css","punctuation.separator.key-value.css"]], + [")", ["source.css","meta.at-rule.container.header.css","meta.function.scroll-state.css","punctuation.section.function.end.bracket.round.css"]], + ]], + ["@container scroll-state(scrollable: x) {}", [ + ["(", ["source.css","meta.at-rule.container.header.css","meta.function.scroll-state.css","punctuation.section.function.begin.bracket.round.css"]], + [":", ["source.css","meta.at-rule.container.header.css","meta.function.scroll-state.css","punctuation.separator.key-value.css"]], + [")", ["source.css","meta.at-rule.container.header.css","meta.function.scroll-state.css","punctuation.section.function.end.bracket.round.css"]], + ]], + ["@container scroll-state((stuck: top)) {}", [ + ["(", ["source.css","meta.at-rule.container.header.css","meta.function.scroll-state.css","punctuation.section.function.begin.bracket.round.css"]], + ["(", ["source.css","meta.at-rule.container.header.css","meta.function.scroll-state.css","punctuation.definition.parameters.begin.bracket.round.css"]], + [":", ["source.css","meta.at-rule.container.header.css","meta.function.scroll-state.css","punctuation.separator.key-value.css"]], + [")", ["source.css","meta.at-rule.container.header.css","meta.function.scroll-state.css","punctuation.definition.parameters.end.bracket.round.css"]], + [")", ["source.css","meta.at-rule.container.header.css","meta.function.scroll-state.css","punctuation.section.function.end.bracket.round.css"]], + ]], + ].forEach(function (c) { + var line = c[0]; + var tokens = testGrammar.tokenizeLine(line).tokens + .filter(x => /^(?:[(:)]|--\w+|>=)$/.test(x.value.trim())); + assert.strictEqual(tokens.length, c[1].length, line); + tokens.forEach(function (t, i) { + assert.strictEqual(t.value.trim(), c[1][i][0], line); + assert.deepStrictEqual(t.scopes, c[1][i][1], line + ' @' + i); + }); + }); + }); + + it('tokenizes every size container feature, with and without a range prefix', function () { + var head = ['source.css', 'meta.at-rule.container.header.css']; + ['width', 'height', 'inline-size', 'block-size', 'aspect-ratio', 'orientation'].forEach(function (feature) { + ['', 'min-', 'max-'].forEach(function (prefix) { + // `orientation` is a discrete feature, so it takes no range prefix. + if (prefix && feature === 'orientation') return; + var name = prefix + feature; + var tokens = testGrammar.tokenizeLine('@container (' + name + ': 1px) {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === name).scopes, head.concat(['support.type.property-name.container.css']), name); + }); + }); + }); + + it('tokenizes every scroll-state feature value', function () { + var head = ['source.css', 'meta.at-rule.container.header.css', 'meta.function.scroll-state.css']; + var physical = ['none', 'top', 'right', 'bottom', 'left', 'inline-start', 'inline-end', 'block-start', 'block-end']; + var logical = ['none', 'x', 'y', 'block', 'inline', 'both']; + var cases = [ + ['stuck', physical], + ['snapped', logical], + ['scrollable', physical.concat(['x', 'y', 'block', 'inline'])], + ['scrolled', physical.concat(['x', 'y', 'block', 'inline'])] + ]; + cases.forEach(function (pair) { + pair[1].forEach(function (value) { + var tokens = testGrammar.tokenizeLine('@container scroll-state(' + pair[0] + ': ' + value + ') {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === pair[0]).scopes, head.concat(['support.type.property-name.container.css']), pair[0]); + assert.deepStrictEqual(tokens.find(x => x.value === value).scopes, head.concat(['support.constant.property-value.css']), pair[0] + ': ' + value); + }); + }); + }); + + it('tokenizes an aspect-ratio range as a ratio', function () { + var head = ['source.css', 'meta.at-rule.container.header.css', 'meta.ratio.css']; + var tokens = testGrammar.tokenizeLine('@container (aspect-ratio > 16/9) {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === '16').scopes, head.concat(['constant.numeric.css'])); + assert.deepStrictEqual(tokens.find(x => x.value === '9').scopes, head.concat(['constant.numeric.css'])); + assert.deepStrictEqual(tokens.find(x => x.value === '/').scopes, head.concat(['keyword.operator.arithmetic.css'])); + }); + + it('reads the logical operators in any case in every container context', function () { + var head = ['source.css', 'meta.at-rule.container.header.css']; + [ + ['@container (width>1px) AND (height>1px) {}', 'AND', 'and', head], + ['@container (width>1px) OR (height>1px) {}', 'OR', 'or', head], + ['@container NOT (width>1px) {}', 'NOT', 'not', head], + ['@container style((--a:1) AND (--b:2)) {}', 'AND', 'and', head.concat(['meta.function.style.css'])], + ['@container style((--a:1) OR (--b:2)) {}', 'OR', 'or', head.concat(['meta.function.style.css'])], + ['@container style(NOT (--x:1)) {}', 'NOT', 'not', head.concat(['meta.function.style.css'])], + ['@container scroll-state((stuck: top) AND (snapped: x)) {}', 'AND', 'and', head.concat(['meta.function.scroll-state.css'])], + ['@container scroll-state((stuck: top) OR (snapped: x)) {}', 'OR', 'or', head.concat(['meta.function.scroll-state.css'])], + ['@container scroll-state(NOT (stuck: top)) {}', 'NOT', 'not', head.concat(['meta.function.scroll-state.css'])], + ['@container ((width>1px) AND (height>1px)) {}', 'AND', 'and', head], + ['@container ((width>1px) OR (height>1px)) {}', 'OR', 'or', head], + ['@container (NOT (width>1px)) {}', 'NOT', 'not', head] + ].forEach(function (c) { + var tokens = testGrammar.tokenizeLine(c[0]).tokens; + // The scope name stays lowercase whatever the source case. + assert.deepStrictEqual(tokens.find(x => x.value === c[1]).scopes, c[3].concat(['keyword.operator.logical.' + c[2] + '.container.css']), c[0]); + }); + }); + + it('does not read a reserved word as a container name', function () { + // The spec reserves these four names, and no others. + ['none', 'and', 'or', 'not'].forEach(function (word) { + var tokens = testGrammar.tokenizeLine('@container ' + word + ' (width > 1px) {}').tokens; + assert.ok(!tokens.some(x => x.scopes.includes('variable.parameter.container-name.css')), word + ' was read as a container name'); + }); + var tokens = testGrammar.tokenizeLine('@container card (width > 1px) {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'card').scopes, ['source.css', 'meta.at-rule.container.header.css', 'variable.parameter.container-name.css']); + }); + + it('reads a query function name as a container name when no bracket follows', function () { + // `style` and `scroll-state` are ordinary custom identifiers. Only an + // immediately following bracket makes either one a query function. + ['style', 'scroll-state'].forEach(function (word) { + var name = testGrammar.tokenizeLine('@container ' + word + ' (width > 1px) {}').tokens; + assert.deepStrictEqual(name.find(x => x.value === word).scopes, ['source.css', 'meta.at-rule.container.header.css', 'variable.parameter.container-name.css'], word + ' was not read as a container name'); + }); + var style = testGrammar.tokenizeLine('@container style(--theme: dark) {}').tokens; + assert.deepStrictEqual(style.find(x => x.value === 'style').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.style.css', 'support.function.style.css']); + var scrollState = testGrammar.tokenizeLine('@container scroll-state(stuck: top) {}').tokens; + assert.deepStrictEqual(scrollState.find(x => x.value === 'scroll-state').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.scroll-state.css', 'support.function.scroll-state.css']); + }); + + it('reads a container name that no query follows', function () { + // The query is optional, so a name may run straight into the body. + var tokens = testGrammar.tokenizeLine('@container card {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'card').scopes, ['source.css', 'meta.at-rule.container.header.css', 'variable.parameter.container-name.css']); + var comment = testGrammar.tokenizeLine('@container card /* c */ {}').tokens; + assert.deepStrictEqual(comment.find(x => x.value === 'card').scopes, ['source.css', 'meta.at-rule.container.header.css', 'variable.parameter.container-name.css']); + }); + + it('reads a container name that follows a comma directly', function () { + // The prelude is a comma-separated list, so a name may begin at a comma. + var tokens = testGrammar.tokenizeLine('@container a (width > 1px),b (height > 1px) {}').tokens; + var head = ['source.css', 'meta.at-rule.container.header.css', 'variable.parameter.container-name.css']; + assert.deepStrictEqual(tokens.find(x => x.value === 'a').scopes, head); + assert.deepStrictEqual(tokens.find(x => x.value === 'b').scopes, head); + }); + + it('matches the at-rule and both query functions whatever their case', function () { + var atRule = testGrammar.tokenizeLine('@CONTAINER (width > 1px) {}').tokens; + assert.deepStrictEqual(atRule.find(x => x.value === 'CONTAINER').scopes, ['source.css', 'meta.at-rule.container.header.css', 'keyword.control.at-rule.container.css']); + var style = testGrammar.tokenizeLine('@container STYLE(--theme: dark) {}').tokens; + assert.deepStrictEqual(style.find(x => x.value === 'STYLE').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.style.css', 'support.function.style.css']); + var scrollState = testGrammar.tokenizeLine('@container SCROLL-STATE(stuck: top) {}').tokens; + assert.deepStrictEqual(scrollState.find(x => x.value === 'SCROLL-STATE').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.scroll-state.css', 'support.function.scroll-state.css']); + }); + + it('reads an escaped general-enclosed function name as one name', function () { + // `f\6f o` spells `foo`, so the function name runs across the escape. + var tokens = testGrammar.tokenizeLine('@container f\\6f o(bar) {}').tokens; + var head = ['source.css', 'meta.at-rule.container.header.css', 'meta.function.misc.css', 'support.function.misc.css']; + assert.deepStrictEqual(tokens.find(x => x.value === 'f').scopes, head); + assert.deepStrictEqual(tokens.find(x => x.value === '\\6f').scopes, head.concat(['constant.character.escape.codepoint.css'])); + assert.deepStrictEqual(tokens.find(x => x.value === ' o').scopes, head); + }); + + it('takes an unknown query function for general-enclosed wherever it is valid', function () { + ['@container future(foo) {}', '@container (future(foo)) {}', '@container style(future(foo)) {}'].forEach(function (line) { + var tokens = testGrammar.tokenizeLine(line).tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'future').scopes.slice(-1), ['support.function.misc.css'], line); + }); + }); + + it('takes a keyword only where a size container feature accepts one', function () { + // A size container feature takes a length or a ratio. Only + // `orientation` takes a keyword, so the media feature values that + // `@media` accepts are not query values here. + ['@container (orientation: portrait) {', '@container card (orientation: landscape) {'].forEach(function (line) { + var tokens = testGrammar.tokenizeLine(line).tokens; + var token = tokens.find(x => x.value === 'portrait' || x.value === 'landscape'); + assert.deepStrictEqual(token.scopes, [ + 'source.css', 'meta.at-rule.container.header.css', 'support.constant.property-value.css' + ], line); + }); + [ + ['@container (width: fullscreen) {', 'fullscreen'], + ['@container (width > interlace) {', 'interlace'], + ['@container (min-width: coarse) {', 'coarse'], + ['@container scroll-state(stuck: portrait) {', 'portrait'] + ].forEach(function (pair) { + var tokens = testGrammar.tokenizeLine(pair[0]).tokens; + assert.ok(!tokens.some(t => t.value === pair[1] && t.scopes.includes('support.constant.property-value.css')), + pair[1] + ' read as a query value in: ' + pair[0]); + }); + }); + + it('reads a logical operator that follows a closing parenthesis', function () { + // A `)` already separates the identifier, so no space is required. + [ + ['@container (width>1px)and(height>1px) {}', 'and', 'keyword.operator.logical.and.container.css'], + ['@container style((--a:1)and(--b:2)) {}', 'and', 'keyword.operator.logical.and.container.css'], + ['@container scroll-state((stuck)or(snapped)) {}', 'or', 'keyword.operator.logical.or.container.css'] + ].forEach(function (t) { + var tokens = testGrammar.tokenizeLine(t[0]).tokens; + assert.deepStrictEqual(tokens.find(x => x.value === t[1]).scopes.slice(-1), [t[2]], t[0]); + }); + }); + + }); }); From af19d50d175ae895e25cc4c0eae049eec005f10e Mon Sep 17 00:00:00 2001 From: Kristofer Baxter Date: Tue, 1 Sep 2026 10:44:35 -0700 Subject: [PATCH 2/5] feat: the property, scope and starting-style at-rules Adds scopes for `@property` descriptors and its custom property name, `@scope` preludes as selectors with the scope root and the scoping limit distinguished, and the `@starting-style` body. Block at-rules whose prelude is omitted entirely open their body correctly. A `@scope` prelude holds a selector list, so its parentheses balance. `@scope (:host(.a)) to (.b)` scopes as a selector rather than closing the prelude at the inner bracket. The scoping limit keyword is an identifier, so `t\6f` and `\t\o` are both recognised as `to`. A hexadecimal escape is at most six digits, so `\00000074o` is not. A custom property name ending in a hexadecimal escape consumes the whitespace that terminates it, so the name is not cut short. `@scope` takes a declaration written straight into its body, without a selector around it, and so does a `@starting-style` nested inside a style rule, so a declaration in either position scopes as one. A top-level `@starting-style` holds style rules, and the test uses the nested form for that reason. An unclosed bracket in a `@scope` prelude, as in `@scope (:host(.a){`, carries the prelude scope further than `main` does. It leaks rather than recovering, which is the direction this grammar wants, but it is a difference and worth knowing about. --- grammars/css.cson | 226 +++++++++++++++++++++++++ spec/css-spec.mjs | 413 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 639 insertions(+) diff --git a/grammars/css.cson b/grammars/css.cson index acba11c..b9ab1e4 100644 --- a/grammars/css.cson +++ b/grammars/css.cson @@ -685,6 +685,214 @@ } ] } + { + # @scope + 'begin': '(?i)(?=@scope([\\s({]|/\\*|$))' + 'end': '(?<=})(?!\\G)' + 'patterns': [ + { + 'begin': '(?i)\\G(@)scope' + 'beginCaptures': + '0': + 'name': 'keyword.control.at-rule.scope.css' + '1': + 'name': 'punctuation.definition.keyword.css' + 'end': '(?=\\s*[{;])' + 'name': 'meta.at-rule.scope.header.css' + 'patterns': [ + { + 'include': '#comment-block' + } + { + # `to ` introduces the scoping limit. The + # parenthesised selector before it is the scope root, so + # nesting the limit here keeps the two distinguishable. + # `to` is an identifier, so either letter may be written + # as an escape. + 'begin': '''(?xi) (?<=\\s|^|\\)|\\*/) + ( + (?:t|\\\\(?:0{0,4}(?:74|54)[ \\t\\f]?|t)) + (?:o|\\\\(?:0{0,4}(?:6f|4f)[ \\t\\f]?|o)) + ) + (?=\\s|\\(|/\\*|$) + ''' + 'beginCaptures': + '1': + 'name': 'keyword.operator.logical.scope.css' + 'end': '(?=\\s*[{;])' + 'patterns': [ + { + 'include': '#comment-block' + } + { + 'begin': '\\(' + 'beginCaptures': + '0': + 'name': 'punctuation.definition.parameters.begin.bracket.round.css' + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.definition.parameters.end.bracket.round.css' + 'name': 'meta.scope.limit.css' + 'patterns': [ + { + 'include': '#selector-innards' + } + { + 'include': '#balanced-selector-parens' + } + ] + } + ] + } + { + 'begin': '\\(' + 'beginCaptures': + '0': + 'name': 'punctuation.definition.parameters.begin.bracket.round.css' + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.definition.parameters.end.bracket.round.css' + 'name': 'meta.scope.start.css' + 'patterns': [ + { + 'include': '#selector-innards' + } + { + 'include': '#balanced-selector-parens' + } + ] + } + ] + } + { + 'begin': '{' + 'beginCaptures': + '0': + 'name': 'punctuation.section.scope.begin.bracket.curly.css' + 'end': '}' + 'endCaptures': + '0': + 'name': 'punctuation.section.scope.end.bracket.curly.css' + 'name': 'meta.at-rule.scope.body.css' + 'patterns': [ + { + 'include': '#rule-list-innards' + } + { + 'include': '$self' + } + ] + } + ] + } + { + # @starting-style + 'begin': '(?i)(?=@starting-style([\\s{]|/\\*|$))' + 'end': '(?<=})(?!\\G)' + 'patterns': [ + { + 'begin': '(?i)\\G(@)starting-style' + 'beginCaptures': + '0': + 'name': 'keyword.control.at-rule.starting-style.css' + '1': + 'name': 'punctuation.definition.keyword.css' + 'end': '(?=\\s*[{;])' + 'name': 'meta.at-rule.starting-style.header.css' + 'patterns': [ + { + 'include': '#comment-block' + } + ] + } + { + 'begin': '{' + 'beginCaptures': + '0': + 'name': 'punctuation.section.starting-style.begin.bracket.curly.css' + 'end': '}' + 'endCaptures': + '0': + 'name': 'punctuation.section.starting-style.end.bracket.curly.css' + 'name': 'meta.at-rule.starting-style.body.css' + 'patterns': [ + { + 'include': '#rule-list-innards' + } + { + 'include': '$self' + } + ] + } + ] + } + { + # @property + 'begin': '(?i)(?=@property([\\s{]|/\\*|$))' + 'end': '(?<=})(?!\\G)' + 'patterns': [ + { + 'begin': '(?i)\\G(@)property' + 'beginCaptures': + '0': + 'name': 'keyword.control.at-rule.property.css' + '1': + 'name': 'punctuation.definition.keyword.css' + 'end': '(?=\\s*[{;])' + 'name': 'meta.at-rule.property.header.css' + 'patterns': [ + { + 'include': '#comment-block' + } + { + # `@property #` registers each name in + # the list with the same descriptors. + 'include': '#commas' + } + { + 'captures': + '0': + 'patterns': [ + { + 'include': '#escapes' + } + ] + # A hexadecimal escape is terminated by any single + # whitespace character, not only a space or a tab. + 'match': '''(?x) (? t.value === ' foo'); + assert.ok(tail && tail.scopes.includes('variable.css'), 'the escape terminator and what follows it stay part of the name'); + }); + + it('keeps a prelude that legally spans lines in the header', function () { + // `` permits a top-level `;` and `` + // permits one inside any balanced block, so a prelude whose `)` + // only arrives on a later line still holds together. + [ + '@container (future;\n syntax)', + '@container style(--x: [a;\n b])', + '@container style(--x: (a;\n b))', + '@container style(foo(a;\n b))', + '@container (min-width: 1px) and\n (max-width: 9px)', + '@scope (.a) to\n (.b)' + ].forEach(function (prelude) { + var lines = testGrammar.tokenizeLines(prelude + ' {\n.z { color: red; }\n}'); + var close = lines[1].filter(t => t.value === ')').pop(); + assert.ok(close.scopes.some(s => s.startsWith('meta.at-rule.')), prelude + ' -> ) left the header: ' + close.scopes.join('|')); + assert.deepStrictEqual(lines[2].find(t => t.value === 'z').scopes, ['source.css', 'meta.at-rule.container.body.css', 'meta.selector.css', 'entity.other.attribute-name.class.css'].map(function (s) { + return prelude.startsWith('@scope') ? s.replace('container', 'scope') : s; + }), prelude); + }); + }); + + it('keeps scoping the closing parenthesis of a well-formed prelude', function () { + var tokens = testGrammar.tokenizeLine('@container (width > 400px) {').tokens; + assert.deepStrictEqual(tokens.find(t => t.value === ')').scopes, ['source.css', 'meta.at-rule.container.header.css', 'punctuation.definition.parameters.end.bracket.round.css']); + tokens = testGrammar.tokenizeLine('@container style(--theme: dark) {').tokens; + assert.deepStrictEqual(tokens.find(t => t.value === ')').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.style.css', 'punctuation.section.function.end.bracket.round.css']); + tokens = testGrammar.tokenizeLine('@container scroll-state(stuck: top) {').tokens; + assert.deepStrictEqual(tokens.find(t => t.value === ')').scopes, ['source.css', 'meta.at-rule.container.header.css', 'meta.function.scroll-state.css', 'punctuation.section.function.end.bracket.round.css']); + tokens = testGrammar.tokenizeLine('@scope (.a) to (.b) {').tokens; + assert.deepStrictEqual(tokens.find(t => t.value === ')').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'meta.scope.start.css', 'punctuation.definition.parameters.end.bracket.round.css']); + assert.deepStrictEqual(tokens.filter(t => t.value === ')')[1].scopes, ['source.css', 'meta.at-rule.scope.header.css', 'meta.scope.limit.css', 'punctuation.definition.parameters.end.bracket.round.css']); + }); + + it('parses the body of a @starting-style rule', function () { + // The only existing assertion was on the at-keyword, so the whole + // body could be emptied without a test failing. + var tokens = testGrammar.tokenizeLine('@starting-style { .z { opacity: 0; } }').tokens; + assert.deepStrictEqual(tokens[3], { + scopes: [ + 'source.css', + 'meta.at-rule.starting-style.body.css', + 'punctuation.section.starting-style.begin.bracket.curly.css' + ], + value: '{' + }); + assert.deepStrictEqual(tokens[6], { + scopes: [ + 'source.css', + 'meta.at-rule.starting-style.body.css', + 'meta.selector.css', + 'entity.other.attribute-name.class.css' + ], + value: 'z' + }); + assert.deepStrictEqual(tokens[10], { + scopes: [ + 'source.css', + 'meta.at-rule.starting-style.body.css', + 'meta.property-list.css', + 'meta.property-name.css', + 'support.type.property-name.css' + ], + value: 'opacity' + }); + assert.deepStrictEqual(tokens[18], { + scopes: [ + 'source.css', + 'meta.at-rule.starting-style.body.css', + 'punctuation.section.starting-style.end.bracket.curly.css' + ], + value: '}' + }); + }); + + it('scopes @property descriptors and the custom property name', function () { + var lines; + lines = testGrammar.tokenizeLines('@property --my-color {\n syntax: "";\n inherits: false;\n}\n.after { color: red; }'); + assert.deepStrictEqual(lines[0][1], { scopes: ['source.css', 'meta.at-rule.property.header.css', 'keyword.control.at-rule.property.css'], value: 'property' }); + assert.deepStrictEqual(lines[0][3], { scopes: ['source.css', 'meta.at-rule.property.header.css', 'variable.css'], value: '--my-color' }); + assert.deepStrictEqual(lines[1][1], { scopes: ['source.css', 'meta.at-rule.property.body.css', 'meta.property-name.css', 'support.type.property-name.css'], value: 'syntax' }); + assert.deepStrictEqual(lines[2][1], { scopes: ['source.css', 'meta.at-rule.property.body.css', 'meta.property-name.css', 'support.type.property-name.css'], value: 'inherits' }); + // The generic at-rule fallback already closed this block correctly, so + // this only guards the dedicated rule against regressing that behaviour. + assert.deepStrictEqual(lines[4][0], { scopes: ['source.css', 'meta.selector.css', 'entity.other.attribute-name.class.css', 'punctuation.definition.entity.css'], value: '.' }); + assert.deepStrictEqual(lines[4][1], { scopes: ['source.css', 'meta.selector.css', 'entity.other.attribute-name.class.css'], value: 'after' }); + }); + + it('scopes the closing brace of a @scope body', function () { + var tokens = testGrammar.tokenizeLine('@scope (.a) { .x { color: red; } }').tokens; + assert.deepStrictEqual(tokens[23], { + scopes: [ + 'source.css', + 'meta.at-rule.scope.body.css', + 'punctuation.section.scope.end.bracket.curly.css' + ], + value: '}' + }); + }); + + it('tokenizes @property descriptor values and closes its body', function () { + // The existing test asserted the descriptor names but none of + // their values, so `initial-value` and the generic value path + // could both be removed without a failure. + var lines = testGrammar.tokenizeLines('@property --x {\n syntax: "*";\n inherits: false;\n initial-value: red;\n}'); + assert.deepStrictEqual(lines[1][5], { + scopes: [ + 'source.css', + 'meta.at-rule.property.body.css', + 'meta.property-value.css', + 'string.quoted.double.css' + ], + value: '*' + }); + assert.deepStrictEqual(lines[2][4], { + scopes: [ + 'source.css', + 'meta.at-rule.property.body.css', + 'meta.property-value.css' + ], + value: 'false' + }); + assert.deepStrictEqual(lines[3][1], { + scopes: [ + 'source.css', + 'meta.at-rule.property.body.css', + 'meta.property-name.css', + 'support.type.property-name.css' + ], + value: 'initial-value' + }); + assert.deepStrictEqual(lines[3][4], { + scopes: [ + 'source.css', + 'meta.at-rule.property.body.css', + 'meta.property-value.css', + 'support.constant.color.w3c-standard-color-name.css' + ], + value: 'red' + }); + assert.deepStrictEqual(lines[4][0], { + scopes: [ + 'source.css', + 'meta.at-rule.property.body.css', + 'punctuation.section.property-list.end.bracket.curly.css' + ], + value: '}' + }); + }); + + it('tokenizes @property names beginning with a digit after the required dashes', function () { + var tokens = testGrammar.tokenizeLine('@property --4-grid-columns {').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === '--4-grid-columns').scopes, ['source.css', 'meta.at-rule.property.header.css', 'variable.css']); + }); + + it('tokenizes @scope preludes as selectors', function () { + var tokens; + tokens = testGrammar.tokenizeLine('@scope (.a) to (.b) {').tokens; + assert.deepStrictEqual(tokens[1], { scopes: ['source.css', 'meta.at-rule.scope.header.css', 'keyword.control.at-rule.scope.css'], value: 'scope' }); + // The selector before `to` is the scope root and the one after it is + // the scoping limit, so the two carry different scopes. + assert.deepStrictEqual(tokens[4], { scopes: ['source.css', 'meta.at-rule.scope.header.css', 'meta.scope.start.css', 'entity.other.attribute-name.class.css', 'punctuation.definition.entity.css'], value: '.' }); + assert.deepStrictEqual(tokens[5], { scopes: ['source.css', 'meta.at-rule.scope.header.css', 'meta.scope.start.css', 'entity.other.attribute-name.class.css'], value: 'a' }); + var to = tokens.find(t => t.value === 'to'); + assert.deepStrictEqual(to.scopes, ['source.css', 'meta.at-rule.scope.header.css', 'keyword.operator.logical.scope.css']); + assert.deepStrictEqual(tokens.find(t => t.value === 'b').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'meta.scope.limit.css', 'entity.other.attribute-name.class.css']); + // A scope root may itself contain a functional pseudo-class, whose + // closing parenthesis must not end the prelude region. + var nested = testGrammar.tokenizeLine('@scope (:has(.a)) to (.b) {').tokens; + assert.deepStrictEqual(nested.find(t => t.value === 'has').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'meta.scope.start.css', 'entity.other.attribute-name.pseudo-class.css']); + assert.deepStrictEqual(nested.find(t => t.value === 'b').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'meta.scope.limit.css', 'entity.other.attribute-name.class.css']); + }); + + it('tokenizes @starting-style', function () { + var tokens; + tokens = testGrammar.tokenizeLine('@starting-style { .z { opacity: 0; } }').tokens; + assert.deepStrictEqual(tokens[1], { scopes: ['source.css', 'meta.at-rule.starting-style.header.css', 'keyword.control.at-rule.starting-style.css'], value: 'starting-style' }); + }); + + it('tokenizes block at-rules whose prelude is omitted entirely', function () { + [ + ['@container{ .x { color: red; } }', 'container'], + ['@scope{ .x { color: red; } }', 'scope'], + ['@starting-style{ .x { color: red; } }', 'starting-style'] + ].forEach(function (pair) { + var tokens = testGrammar.tokenizeLine(pair[0]).tokens; + assert.deepStrictEqual(tokens[1], { scopes: ['source.css', 'meta.at-rule.' + pair[1] + '.header.css', 'keyword.control.at-rule.' + pair[1] + '.css'], value: pair[1] }, pair[0]); + assert.deepStrictEqual(tokens[2].scopes, ['source.css', 'meta.at-rule.' + pair[1] + '.body.css', 'punctuation.section.' + pair[1] + '.begin.bracket.curly.css'], pair[0]); + }); + }); + it('balances the brackets of a functional pseudo-class it does not recognise', function () { + // The `)` of `:host(.a)` is not the one that closes the scope + // root, or a valid selector is mis-scoped and a malformed one + // resumes as though it had closed. + var tokens = testGrammar.tokenizeLine('@scope (:host(.a)) to (.b) { }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'to').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'keyword.operator.logical.scope.css']); + assert.deepStrictEqual(tokens.find(x => x.value === 'b').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'meta.scope.limit.css', 'entity.other.attribute-name.class.css']); + }); + + it('leaks an unclosed scope root whether or not the pseudo-class is recognised', function () { + // `:has()` is recognised and `:host()` is not, and both have to + // behave the same when the root is left open. + ['@scope (:has(.a){', '@scope (:host(.a){'].forEach(function (prelude) { + var lines = testGrammar.tokenizeLines(prelude + '\n.after { color: red; }'); + assert.ok(lines[1][0].scopes.includes('meta.at-rule.scope.header.css'), prelude + ' -> ' + JSON.stringify(lines[1][0].scopes)); + }); + }); + + it('recognises an escaped scoping limit keyword', function () { + var tokens = testGrammar.tokenizeLine('@scope (.a) t\\6f (.b) { }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 't\\6f ').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'keyword.operator.logical.scope.css']); + assert.deepStrictEqual(tokens.find(x => x.value === 'b').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'meta.scope.limit.css', 'entity.other.attribute-name.class.css']); + }); + + it('ends a hexadecimal escape in a custom property name at any whitespace', function () { + // A form feed terminates the escape and belongs to the name, so + // the rest of the name is not lost. + var tokens = testGrammar.tokenizeLine('@property --\\31\ffoo { }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === '\ffoo').scopes, ['source.css', 'meta.at-rule.property.header.css', 'variable.css']); + }); + + it('reads the scoping limit written with simple escapes', function () { + // A backslash before a non-hexadecimal character is a valid CSS + // escape for that character, so `\t\o` spells `to`. + var tokens = testGrammar.tokenizeLine('@scope (.a) \\t\\o (.b) { }').tokens; + assert.ok(tokens.find(x => x.scopes.includes('keyword.operator.logical.scope.css')), 'no scoping limit keyword'); + assert.ok(tokens.find(x => x.scopes.includes('meta.scope.limit.css')), 'no scoping limit'); + }); + + it('reads the scoping limit at the six-digit escape boundary', function () { + // Six hexadecimal digits are the most an escape may have, so + // `\\000074` is `t` but `\\0000074` is not. + var six = testGrammar.tokenizeLine('@scope (.a) \\000074o (.b) { }').tokens; + assert.deepStrictEqual(six.find(x => x.value === '\\000074o').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'keyword.operator.logical.scope.css']); + assert.deepStrictEqual(six.find(x => x.value === 'b').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'meta.scope.limit.css', 'entity.other.attribute-name.class.css']); + + var seven = testGrammar.tokenizeLine('@scope (.a) \\0000074o (.b) { }').tokens; + assert.ok(!seven.some(x => x.scopes.includes('keyword.operator.logical.scope.css')), 'seven-digit escape read as `to`'); + assert.ok(!seven.some(x => x.scopes.includes('meta.scope.limit.css')), 'seven-digit escape opened a scoping limit'); + }); + + it('ends the scoping limit keyword escape at a form feed', function () { + var tokens = testGrammar.tokenizeLine('@scope (.a) t\\6f\f(.b) { }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 't\\6f\f').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'keyword.operator.logical.scope.css']); + assert.deepStrictEqual(tokens.find(x => x.value === 'b').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'meta.scope.limit.css', 'entity.other.attribute-name.class.css']); + }); + + it('balances parentheses of a nested unknown function in the scope root', function () { + var tokens = testGrammar.tokenizeLine('@scope (:host(foo(.a)) > .b) to (.c) { }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'b').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'meta.scope.start.css', 'entity.other.attribute-name.class.css']); + assert.deepStrictEqual(tokens.find(x => x.value === 'c').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'meta.scope.limit.css', 'entity.other.attribute-name.class.css']); + }); + + it('ignores a parenthesis inside a string when balancing the scope root', function () { + var tokens = testGrammar.tokenizeLine('@scope (:host(")" .a) > .b) to (.c) { }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'b').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'meta.scope.start.css', 'entity.other.attribute-name.class.css']); + assert.deepStrictEqual(tokens.find(x => x.value === 'c').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'meta.scope.limit.css', 'entity.other.attribute-name.class.css']); + }); + + it('balances parentheses of a functional pseudo-class in the scoping limit', function () { + var tokens = testGrammar.tokenizeLine('@scope (.a) to (:host(.b) > .c) { }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'c').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'meta.scope.limit.css', 'entity.other.attribute-name.class.css']); + }); + + it('reads the new at-rules and the scoping limit keyword in any case', function () { + var scope = testGrammar.tokenizeLine('@SCOPE (.a) TO (.b) { }').tokens; + assert.deepStrictEqual(scope.find(x => x.value === 'SCOPE').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'keyword.control.at-rule.scope.css']); + assert.deepStrictEqual(scope.find(x => x.value === 'TO').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'keyword.operator.logical.scope.css']); + assert.deepStrictEqual(scope.find(x => x.value === 'b').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'meta.scope.limit.css', 'entity.other.attribute-name.class.css']); + + var property = testGrammar.tokenizeLine('@PROPERTY --x { SYNTAX: "*"; }').tokens; + assert.deepStrictEqual(property.find(x => x.value === 'PROPERTY').scopes, ['source.css', 'meta.at-rule.property.header.css', 'keyword.control.at-rule.property.css']); + assert.deepStrictEqual(property.find(x => x.value === 'SYNTAX').scopes, ['source.css', 'meta.at-rule.property.body.css', 'meta.property-name.css', 'support.type.property-name.css']); + + var starting = testGrammar.tokenizeLine('@STARTING-STYLE { a { color: red; } }').tokens; + assert.deepStrictEqual(starting.find(x => x.value === 'STARTING-STYLE').scopes, ['source.css', 'meta.at-rule.starting-style.header.css', 'keyword.control.at-rule.starting-style.css']); + }); + + it('allows comments around the scoping limit keyword', function () { + var tokens = testGrammar.tokenizeLine('@scope /*head*/ (.a) to /*limit*/ (.b) { }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'head').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'comment.block.css']); + assert.deepStrictEqual(tokens.find(x => x.value === 'limit').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'comment.block.css']); + assert.deepStrictEqual(tokens.find(x => x.value === 'b').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'meta.scope.limit.css', 'entity.other.attribute-name.class.css']); + }); + + it('marks the punctuation of the new at-rules', function () { + var tokens = testGrammar.tokenizeLine('@scope (.a) to (.b) { }').tokens; + var ats = tokens.filter(x => x.scopes.includes('punctuation.definition.keyword.css')); + assert.strictEqual(ats.length, 1); + assert.deepStrictEqual(ats[0].scopes, ['source.css', 'meta.at-rule.scope.header.css', 'keyword.control.at-rule.scope.css', 'punctuation.definition.keyword.css']); + + var opens = tokens.filter(x => x.scopes.includes('punctuation.definition.parameters.begin.bracket.round.css')); + assert.strictEqual(opens.length, 2); + assert.ok(opens[0].scopes.includes('meta.scope.start.css'), 'scope root parenthesis unmarked'); + assert.ok(opens[1].scopes.includes('meta.scope.limit.css'), 'scoping limit parenthesis unmarked'); + + var property = testGrammar.tokenizeLine('@property --x { }').tokens; + assert.deepStrictEqual(property.find(x => x.value === '{').scopes, ['source.css', 'meta.at-rule.property.body.css', 'punctuation.section.property-list.begin.bracket.curly.css']); + }); + + it('does not read an over-long escape as the scoping limit', function () { + // A hexadecimal escape is at most six digits, so `\00000074` + // is not `t` and there is no scoping limit here. + var tokens = testGrammar.tokenizeLine('@scope (.a) \\00000074o (.b) { }').tokens; + assert.ok(!tokens.some(x => x.scopes.includes('keyword.operator.logical.scope.css')), 'over-long escape read as `to`'); + assert.ok(!tokens.some(x => x.scopes.includes('meta.scope.limit.css')), 'over-long escape opened a scoping limit'); + }); + + + it('tokenizes a declaration written directly in the @scope body', function () { + var tokens = testGrammar.tokenizeLine('@scope (.a) { color: red; }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'color').scopes, ['source.css', 'meta.at-rule.scope.body.css', 'meta.property-name.css', 'support.type.property-name.css']); + assert.deepStrictEqual(tokens.find(x => x.value === 'red').scopes, ['source.css', 'meta.at-rule.scope.body.css', 'meta.property-value.css', 'support.constant.color.w3c-standard-color-name.css']); + assert.deepStrictEqual(tokens.find(x => x.value === ';').scopes, ['source.css', 'meta.at-rule.scope.body.css', 'punctuation.terminator.rule.css']); + }); + + it('tokenizes a declaration in a nested @starting-style body', function () { + // A top-level `@starting-style` holds style rules. A declaration is + // written straight into the body only when the rule is nested in one. + var tokens = testGrammar.tokenizeLine('#target { @starting-style { opacity: 0; } }').tokens; + var head = ['source.css', 'meta.property-list.css', 'meta.at-rule.starting-style.body.css']; + assert.deepStrictEqual(tokens.find(x => x.value === 'opacity').scopes, head.concat(['meta.property-name.css', 'support.type.property-name.css'])); + assert.deepStrictEqual(tokens.find(x => x.value === '0').scopes, head.concat(['meta.property-value.css', 'constant.numeric.css'])); + assert.deepStrictEqual(tokens.find(x => x.value === ';').scopes, head.concat(['punctuation.terminator.rule.css'])); + }); + + it('tokenizes a scoping limit written without a scope root', function () { + // Both boundaries are optional, so `to (...)` may stand alone. + var tokens = testGrammar.tokenizeLine('@scope to (.limit) { }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'to').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'keyword.operator.logical.scope.css']); + assert.deepStrictEqual(tokens.find(x => x.value === 'limit').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'meta.scope.limit.css', 'entity.other.attribute-name.class.css']); + }); + + it('tokenizes a list of @property names', function () { + // `@property #` registers every name in the list. + var tokens = testGrammar.tokenizeLine('@property --width, --height, --depth { }').tokens; + var head = ['source.css', 'meta.at-rule.property.header.css']; + ['--width', '--height', '--depth'].forEach(function (name) { + assert.deepStrictEqual(tokens.find(x => x.value === name).scopes, head.concat(['variable.css']), name); + }); + assert.deepStrictEqual(tokens.filter(x => x.value === ',').length, 2); + tokens.filter(x => x.value === ',').forEach(function (comma) { + assert.deepStrictEqual(comma.scopes, head.concat(['punctuation.separator.list.comma.css'])); + }); + }); + + it('keeps a balanced block in an initial-value inside the value', function () { + // A registered initial value is a ``, which admits a + // balanced block, so the descriptor after it is still a descriptor. + var lines = testGrammar.tokenizeLines('@property --x {\n initial-value: {"foo":"bar"};\n inherits: false;\n}'); + assert.deepStrictEqual(lines[2].find(x => x.value === 'inherits').scopes, ['source.css', 'meta.at-rule.property.body.css', 'meta.property-name.css', 'support.type.property-name.css']); + }); + + it('leaks past a malformed semicolon form exactly as main does', function () { + // These at-rules take no semicolon form. A malformed one must swallow + // what follows rather than resume, which is what a browser does. + ['@scope (.a); .after { color: red; }', '@property --x; .after { color: red; }'].forEach(function (line) { + var tokens = testGrammar.tokenizeLine(line).tokens; + assert.ok(!tokens.some(x => x.scopes.includes('meta.selector.css')), line + ' recovered at the semicolon'); + }); + }); + + it('tokenizes a comment between @starting-style and its body', function () { + var tokens = testGrammar.tokenizeLine('@starting-style /* c */ { }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === '/*').scopes, ['source.css', 'meta.at-rule.starting-style.header.css', 'comment.block.css', 'punctuation.definition.comment.begin.css']); + assert.deepStrictEqual(tokens.find(x => x.value === ' c ').scopes, ['source.css', 'meta.at-rule.starting-style.header.css', 'comment.block.css']); + assert.deepStrictEqual(tokens.find(x => x.value === '*/').scopes, ['source.css', 'meta.at-rule.starting-style.header.css', 'comment.block.css', 'punctuation.definition.comment.end.css']); + }); + + it('tokenizes a comment between @property and the property name', function () { + var tokens = testGrammar.tokenizeLine('@property /* c */ --x { }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === ' c ').scopes, ['source.css', 'meta.at-rule.property.header.css', 'comment.block.css']); + assert.deepStrictEqual(tokens.find(x => x.value === '--x').scopes, ['source.css', 'meta.at-rule.property.header.css', 'variable.css']); + }); + + it('tokenizes a comment inside the @property body', function () { + var tokens = testGrammar.tokenizeLine('@property --x { /* c */ syntax: "*"; }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === ' c ').scopes, ['source.css', 'meta.at-rule.property.body.css', 'comment.block.css']); + assert.deepStrictEqual(tokens.find(x => x.value === 'syntax').scopes, ['source.css', 'meta.at-rule.property.body.css', 'meta.property-name.css', 'support.type.property-name.css']); + }); + + it('tokenizes an escape inside the @property body', function () { + var tokens = testGrammar.tokenizeLine('@property --x { synt\\61 x: "*"; }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === '\\61').scopes, ['source.css', 'meta.at-rule.property.body.css', 'constant.character.escape.codepoint.css']); + }); + + it('marks the at sign of @starting-style and @property', function () { + var starting = testGrammar.tokenizeLine('@starting-style { }').tokens; + assert.deepStrictEqual(starting[0], { scopes: ['source.css', 'meta.at-rule.starting-style.header.css', 'keyword.control.at-rule.starting-style.css', 'punctuation.definition.keyword.css'], value: '@' }); + var property = testGrammar.tokenizeLine('@property --x { }').tokens; + assert.deepStrictEqual(property[0], { scopes: ['source.css', 'meta.at-rule.property.header.css', 'keyword.control.at-rule.property.css', 'punctuation.definition.keyword.css'], value: '@' }); + }); + + it('tokenizes a selector inside a balanced group in the scope root', function () { + var tokens = testGrammar.tokenizeLine('@scope (:unknownfn(.a) .b) { }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'a').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'meta.scope.start.css', 'entity.other.attribute-name.class.css']); + assert.deepStrictEqual(tokens.find(x => x.value === 'b').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'meta.scope.start.css', 'entity.other.attribute-name.class.css']); + }); + + it('tokenizes a selector inside a balanced group in the scoping limit', function () { + var tokens = testGrammar.tokenizeLine('@scope (.a) to (:unknownfn(#b)) { }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'b').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'meta.scope.limit.css', 'entity.other.attribute-name.id.css']); + }); + }); }); From ba911785b45d66b7adbdf3fcadd1c8f2d13b7d79 Mon Sep 17 00:00:00 2001 From: Kristofer Baxter Date: Tue, 1 Sep 2026 10:44:45 -0700 Subject: [PATCH 3/5] feat: env(), anchor positioning, color-mix() and math functions Adds scopes for `env()`, where only the first argument is the environment variable name and indices, fallbacks, comments and continuation lines are handled; `anchor()` and `anchor-size()` with their keywords; `color-mix()`, `light-dark()`, `contrast-color()` and `device-cmyk()` as color functions, including custom color spaces and interpolation keywords; and the math and timeline functions the css-values-5 draft defines. An `env()` whose first argument cannot be a name, such as `env(calc(x) red)`, `env(-10px)` or `env(initial)`, is left alone rather than having a later identifier read as the name, on that line or on a following one. An environment variable name, an anchor name and a custom color space are all identifiers, so any of them may contain an escape, may begin with a digit after the two hyphens, and is not read out of the middle of a longer identifier. `anchor()` takes an `` and `anchor-size()` takes an ``. Those two sets are disjoint, so they are matched separately and neither function takes the other's keywords. `color-mix()` is the only color function that takes a ``, so the interpolation-keyword matcher is on it alone. The other color functions this change adds join the existing color rule, and `rgb()` and its neighbours tokenize exactly as before. Every function and keyword name this change adds is asserted, so that removing one from the grammar fails a test. --- grammars/css.cson | 228 ++++++++++++++++++++- spec/css-spec.mjs | 498 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 723 insertions(+), 3 deletions(-) diff --git a/grammars/css.cson b/grammars/css.cson index b9ab1e4..e16afa4 100644 --- a/grammars/css.cson +++ b/grammars/css.cson @@ -1224,7 +1224,7 @@ } # Colors { - 'begin': '(?i)(?`, so it is a rule of its own: the + # identifier matcher below would otherwise reach colour functions that + # take no interpolation keywords at all. + { + 'begin': '(?i)(?` is a ``, so `in --brand-space` + # has to be accepted here too. + { + 'match': '''(?xi) (? (?!(?:initial|inherit|unset|revert|revert-layer|default)(?![-\\w])) (?:--(?:[-a-zA-Z0-9_] | [^\\x00-\\x7F] |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\f]?|[^\\n])) | -?(?:[a-zA-Z_] | [^\\x00-\\x7F] |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\f]?|[^\\n])))(?:[-a-zA-Z0-9_] | [^\\x00-\\x7F] |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\f]?|[^\\n]))* ) + (?!\\() # A function is not a name + ) + ) + | (?=[,;{})]) # Malformed: give up on a name + # The name is the first component. If the first component is + # not a name then this function has no name, and the region has + # to close here rather than search on and scope a later + # identifier on a following line. + | (?= \\s*(?:/\\*(?:[^*]|\\*[^/])*\\*/\\s*)* + (?! (?!(?:initial|inherit|unset|revert|revert-layer|default)(?![-\\w])) (?:--(?:[-a-zA-Z0-9_] | [^\\x00-\\x7F] |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\f]?|[^\\n])) | -?(?:[a-zA-Z_] | [^\\x00-\\x7F] |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\f]?|[^\\n]))) + | /\\* # A comment is not the name either + ) + \\S + ) + # A function is not a name either, and it may be the first + # component on a line of its own, so the region has to close + # here rather than carry the search on to the next line. + | (?= \\s*(?:/\\*(?:[^*]|\\*[^/])*\\*/\\s*)* + (?:--(?:[-a-zA-Z0-9_] | [^\\x00-\\x7F] |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\f]?|[^\\n])) | -?(?:[a-zA-Z_] | [^\\x00-\\x7F] |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\f]?|[^\\n])))(?:[-a-zA-Z0-9_] | [^\\x00-\\x7F] |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\f]?|[^\\n]))* + \\( + ) + ''' + 'endCaptures': + '1': + 'name': 'support.constant.property-value.css' + '2': + 'name': 'variable.argument.css' + 'patterns': [ + { + # Anything that is not the name: comments, the indices of + # `env(viewport-segment-width 0 0)`, strings, nested + # functions and malformed arguments. + 'include': '#property-values' + } + ] + } + { + 'include': '#property-values' + } + ] + } + # Anchor positioning + { + 'begin': '(?i)(?`, so any part of it may be + # written as an escape, and a hexadecimal escape takes the single + # whitespace character that terminates it. + 'name': 'variable.argument.css' + 'match': '''(?x) + (?`, which is a disjoint set from the `` keywords `anchor-size()` takes. + 'match': '''(?xi) (?`, so any part of it may be + # written as an escape, and a hexadecimal escape takes the single + # whitespace character that terminates it. + 'name': 'variable.argument.css' + 'match': '''(?x) + (?`, not an ``. + 'match': '''(?xi) (? x.value === 'b').scopes, ['source.css', 'meta.at-rule.scope.header.css', 'meta.scope.limit.css', 'entity.other.attribute-name.id.css']); }); }); + + describe('value functions', function () { + it('bounds an unterminated env() to a single argument name', function () { + // The name is the first argument, so the region that looks for it + // closes as soon as the first argument turns out not to be a name. + // Nothing on the line below an unterminated env() is scoped as one. + var lines = testGrammar.tokenizeLines('a { padding: env(\n.after > p { color: red; }'); + assert.deepStrictEqual(lines[1].filter(t => t.scopes.includes('variable.argument.css')).map(t => t.value), []); + }); + + it('closes anchor() and anchor-size()', function () { + // The closing parenthesis of the anchor rule was the one part of + // it no test asserted, so its scope could be renamed freely. + var tokens = testGrammar.tokenizeLine('a { top: anchor(--x bottom); width: anchor-size(--x width); }').tokens; + [12, 23].forEach(function (index) { + assert.deepStrictEqual(tokens[index], { + scopes: [ + 'source.css', + 'meta.property-list.css', + 'meta.property-value.css', + 'meta.function.anchor.css', + 'punctuation.section.function.end.bracket.round.css' + ], + value: ')' + }, 'token ' + index); + }); + }); + + it('does not abandon the line when an env() argument cannot be named', function () { + // Before the argument region excluded the characters its own `end` + // matches, `begin` and `end` both matched at zero width and the + // tokenizer emitted the remainder of the line as a single token. + var prefix = ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.env.css']; + var tokens = testGrammar.tokenizeLine('a { padding: env(;) }').tokens; + assert.deepStrictEqual(tokens.slice(9, 11), [ + { scopes: prefix, value: ';' }, + { scopes: prefix.concat('punctuation.section.function.end.bracket.round.css'), value: ')' } + ]); + + // An unbalanced `{` keeps the block open, exactly as it does for any + // other function, but the line is still tokenized to its end. + tokens = testGrammar.tokenizeLine('a { padding: env({) }').tokens; + assert.deepStrictEqual(tokens[9], { scopes: prefix.concat('punctuation.section.group.begin.bracket.curly.css'), value: '{' }); + assert.deepStrictEqual(tokens[11], { scopes: prefix.concat('punctuation.section.group.end.bracket.curly.css'), value: '}' }); + }); + + it('does not absorb whitespace into env() argument scopes', function () { + var prefix = ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.env.css']; + assert.deepStrictEqual(testGrammar.tokenizeLine('a { padding: env( safe-area-inset-top ); }').tokens.slice(8, 12), [ + { scopes: prefix.concat('punctuation.section.function.begin.bracket.round.css'), value: '(' }, + { scopes: prefix, value: ' ' }, + { scopes: prefix.concat('support.constant.property-value.css'), value: 'safe-area-inset-top' }, + { scopes: prefix, value: ' ' } + ]); + + assert.deepStrictEqual(testGrammar.tokenizeLine('a { padding: env( --custom ); }').tokens.slice(8, 12), [ + { scopes: prefix.concat('punctuation.section.function.begin.bracket.round.css'), value: '(' }, + { scopes: prefix, value: ' ' }, + { scopes: prefix.concat('variable.argument.css'), value: '--custom' }, + { scopes: prefix, value: ' ' } + ]); + }); + + it('does not scope env() fallback values as environment variables', function () { + var tokens; + tokens = testGrammar.tokenizeLine('a { padding: env(--custom, red); }').tokens; + assert.deepStrictEqual(tokens.find(t => t.value === 'red').scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.env.css', 'support.constant.color.w3c-standard-color-name.css']); + assert.ok(!tokens.find(t => t.value === 'red' && t.scopes.includes('variable.argument.css'))); + }); + + it('keeps an escaped env() identifier in a single token', function () { + // `\61 bc` is one CSS identifier: the space terminates the hex escape. + var tokens = testGrammar.tokenizeLine('a { padding: env(\\61 bc, 1px); }').tokens; + assert.deepStrictEqual(tokens.find(t => t.scopes.includes('variable.argument.css')), { + scopes: ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.env.css', 'variable.argument.css'], + value: '\\61 bc' + }); + }); + + it('keeps functions that the css-values-5 draft still defines', function () { + // The draft defines the value-cycling function as `cycle()`; + // `toggle()` was its earlier name and no longer appears in it. + ['progress', 'cycle'].forEach(function (fn) { + var tokens = testGrammar.tokenizeLine('a { width: ' + fn + '(1px, 2px); }').tokens; + var t = tokens.find(x => x.value === fn); + assert.deepStrictEqual(t.scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.misc.css', 'support.function.misc.css'], fn); + }); + }); + + it('keeps nested functions scoped inside env() arguments', function () { + var tokens; + tokens = testGrammar.tokenizeLine('a { padding: env(--custom calc(1 + 1), 10px); }').tokens; + assert.deepStrictEqual(tokens.find(t => t.value === 'calc').scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.env.css', 'meta.function.calc.css', 'support.function.calc.css']); + assert.deepStrictEqual(tokens.find(t => t.value === '10').scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.env.css', 'constant.numeric.css']); + }); + + it('recognises the timeline functions in a value', function () { + // The `animation-timeline` property name itself is left to + // microsoft/vscode-css#32; this covers the functions in its value. + ['view', 'scroll'].forEach(function (fn) { + var tokens = testGrammar.tokenizeLine('a { animation-timeline: ' + fn + '(block); }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === fn).scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.misc.css', 'support.function.misc.css'], fn); + }); + }); + + it('scopes a custom color space in color-mix()', function () { + // accepts a naming an @color-profile. + var tokens = testGrammar.tokenizeLine('a { color: color-mix(in --brand-space, red, blue); }').tokens; + assert.deepStrictEqual(tokens.find(t => t.value === '--brand-space').scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.color.css', 'variable.parameter.misc.css']); + }); + + it('scopes only the first argument of env() as the variable name', function () { + // css-env-1 is `env( *, ? )`, + // so a second identifier is not a second environment variable name. + [ + ['a { padding: env(--x --y); }', '--x', '--y'], + ['a { padding: env(safe-area-inset-top other); }', 'safe-area-inset-top', 'other'] + ].forEach(function (pair) { + var tokens = testGrammar.tokenizeLine(pair[0]).tokens; + assert.ok(tokens.find(t => t.value === pair[1] && t.scopes.length === 5), pair[0]); + assert.ok(!tokens.find(t => t.value.includes(pair[2]) && t.scopes.length > 4), pair[0]); + }); + }); + + it('tokenizes additional math functions', function () { + ['mod', 'rem', 'round'].forEach(function (fn) { + var tokens = testGrammar.tokenizeLine('a { width: ' + fn + '(10px, 3px); }').tokens; + var t = tokens.find(x => x.value === fn); + assert.deepStrictEqual(t.scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.misc.css', 'support.function.misc.css'], fn); + }); + }); + + it('tokenizes anchor() and anchor-size()', function () { + var tokens; + tokens = testGrammar.tokenizeLine('a { top: anchor(--x bottom); }').tokens; + var fn = tokens.find(t => t.value === 'anchor'); + assert.deepStrictEqual(fn.scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.anchor.css', 'support.function.misc.css']); + var arg = tokens.find(t => t.value === '--x'); + assert.deepStrictEqual(arg.scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.anchor.css', 'variable.argument.css']); + + tokens = testGrammar.tokenizeLine('a { width: anchor-size(--x width); }').tokens; + var fn2 = tokens.find(t => t.value === 'anchor-size'); + assert.deepStrictEqual(fn2.scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.anchor.css', 'support.function.misc.css']); + }); + + it('tokenizes color-mix() as a color function', function () { + var tokens; + tokens = testGrammar.tokenizeLine('a { color: color-mix(in oklch, red, blue); }').tokens; + var fn = tokens.find(t => t.value === 'color-mix'); + assert.deepStrictEqual(fn.scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.color.css', 'support.function.misc.css']); + }); + + it('tokenizes color-mix() interpolation keywords', function () { + var tokens; + tokens = testGrammar.tokenizeLine('a { color: color-mix(in oklch longer hue, red 40%, blue); }').tokens; + // `in`, the colorspace and the hue-interpolation method are not property + // values, so they need their own scope rather than falling through unscoped. + assert.deepStrictEqual(tokens.find(t => t.value === 'in').scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.color.css', 'variable.parameter.misc.css']); + assert.deepStrictEqual(tokens.find(t => t.value === 'oklch').scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.color.css', 'variable.parameter.misc.css']); + assert.deepStrictEqual(tokens.find(t => t.value === 'longer').scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.color.css', 'variable.parameter.misc.css']); + assert.deepStrictEqual(tokens.find(t => t.value === 'red').scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.color.css', 'support.constant.color.w3c-standard-color-name.css']); + }); + + it('tokenizes env() arguments separated from the bracket by a comment', function () { + var tokens; + tokens = testGrammar.tokenizeLine('a { padding: env(/* c */ safe-area-inset-top, 10px); }').tokens; + assert.deepStrictEqual(tokens.find(t => t.value === 'safe-area-inset-top').scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.env.css', 'support.constant.property-value.css']); + assert.deepStrictEqual(tokens.find(t => t.value === '/*').scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.env.css', 'comment.block.css', 'punctuation.definition.comment.begin.css']); + }); + + it('tokenizes env() arguments written on a continuation line', function () { + var lines; + lines = testGrammar.tokenizeLines('a {\n\tpadding-top: env(\n\t\tsafe-area-inset-top,\n\t\t12px\n\t);\n}'); + assert.deepStrictEqual(lines[2].find(t => t.value === 'safe-area-inset-top').scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.env.css', 'support.constant.property-value.css']); + + lines = testGrammar.tokenizeLines('a {\n\tpadding-top: env(\n\t\t--custom,\n\t\t12px\n\t);\n}'); + assert.deepStrictEqual(lines[2].find(t => t.value === '--custom').scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.env.css', 'variable.argument.css']); + }); + + it('tokenizes env() indices as numeric values', function () { + var tokens; + tokens = testGrammar.tokenizeLine('a { width: env(viewport-segment-width 0 0, 10px); }').tokens; + assert.deepStrictEqual(tokens.find(t => t.value === 'viewport-segment-width').scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.env.css', 'support.constant.property-value.css']); + assert.deepStrictEqual(tokens.find(t => t.value === '0').scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.env.css', 'constant.numeric.css']); + }); + + it('tokenizes env() with a known environment variable', function () { + var tokens; + tokens = testGrammar.tokenizeLine('a { padding: env(safe-area-inset-top); }').tokens; + var fn = tokens.find(t => t.value === 'env'); + assert.deepStrictEqual(fn.scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.env.css', 'support.function.misc.css']); + var arg = tokens.find(t => t.value === 'safe-area-inset-top'); + assert.deepStrictEqual(arg.scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.env.css', 'support.constant.property-value.css']); + }); + + it('tokenizes light-dark() as a color function', function () { + var tokens; + tokens = testGrammar.tokenizeLine('a { color: light-dark(white, black); }').tokens; + var fn = tokens.find(t => t.value === 'light-dark'); + assert.deepStrictEqual(fn.scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.color.css', 'support.function.misc.css']); + }); + + it('tokenizes the logical anchor-size() keywords', function () { + ['block', 'inline', 'self-block', 'self-inline'].forEach(function (keyword) { + var tokens = testGrammar.tokenizeLine('a { width: anchor-size(--x ' + keyword + '); }').tokens; + var t = tokens.find(x => x.value === keyword); + assert.deepStrictEqual(t.scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.anchor.css', 'support.constant.property-value.css'], keyword); + }); + }); + it('keeps an escaped anchor name whole', function () { + // `--\61 nchor` is one dashed-ident spelling `--anchor`, and the + // space terminates the escape rather than the name. + var tokens = testGrammar.tokenizeLine('a { top: anchor(--\\61 nchor bottom); }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === '--\\61 nchor').scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.anchor.css', 'variable.argument.css']); + }); + + it('keeps an escaped custom colour space whole', function () { + var tokens = testGrammar.tokenizeLine('a { color: color-mix(in --my\\2d space, red, blue); }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === '--my\\2d space').scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.color.css', 'variable.parameter.misc.css']); + }); + + it('does not take a later identifier for an environment variable name', function () { + // The name is the first component. If the first component cannot + // be a name then the call has none, and `red` is just a value. + ['env(calc(foo) red)', 'env(var(--x) red)', 'env("bad" red)', 'env(10px red)'].forEach(function (call) { + var tokens = testGrammar.tokenizeLine('a { padding: ' + call + '; }').tokens; + var red = tokens.find(x => x.value === 'red'); + assert.ok(!red.scopes.includes('variable.argument.css'), call + ' -> ' + JSON.stringify(red.scopes)); + assert.ok(red.scopes.includes('support.constant.color.w3c-standard-color-name.css'), call + ' -> ' + JSON.stringify(red.scopes)); + }); + }); + + it('still scopes an environment variable name that is the first component', function () { + var tokens = testGrammar.tokenizeLine('a { padding: env(safe-area-inset-top, 0px); }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'safe-area-inset-top').scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.env.css', 'support.constant.property-value.css']); + var custom = testGrammar.tokenizeLine('a { padding: env(--custom-name, 0px); }').tokens; + assert.deepStrictEqual(custom.find(x => x.value === '--custom-name').scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.env.css', 'variable.argument.css']); + }); + + // Every name this change adds to a list is asserted here, so that + // removing one from the grammar fails a test. + function eachScopes(cases, tail) { + cases.forEach(function (pair) { + var tokens = testGrammar.tokenizeLine(pair[1]).tokens; + var token = tokens.find(x => x.value === pair[0]); + assert.ok(token, pair[0] + ' produced no token in: ' + pair[1]); + assert.deepStrictEqual(token.scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css'].concat(tail), pair.join(' in ')); + }); + } + + it('names every environment variable it adds', function () { + eachScopes([ + ['safe-area-inset-right', 'a { top: env(safe-area-inset-right); }'], + ['safe-area-max-inset-top', 'a { top: env(safe-area-max-inset-top); }'], + ['titlebar-area-x', 'a { top: env(titlebar-area-x); }'], + ['keyboard-inset-height', 'a { top: env(keyboard-inset-height); }'], + ['viewport-segment-width', 'a { top: env(viewport-segment-width); }'], + ['preferred-text-scale', 'a { top: env(preferred-text-scale); }'] + ], ['meta.function.env.css', 'support.constant.property-value.css']); + }); + + it('names every color function it adds', function () { + eachScopes([ + ['color-mix', 'a { color: color-mix(in oklch, red, blue); }'], + ['light-dark', 'a { color: light-dark(red, blue); }'], + ['contrast-color', 'a { color: contrast-color(red); }'], + ['device-cmyk', 'a { color: device-cmyk(0 0 0 1); }'] + ], ['meta.function.color.css', 'support.function.misc.css']); + }); + + it('names every value function it adds', function () { + eachScopes([ + ['mod', 'a { width: mod(x); }'], + ['calc-size', 'a { width: calc-size(x); }'], + ['calc-mix', 'a { width: calc-mix(x); }'], + ['calc-interpolate', 'a { width: calc-interpolate(x); }'], + ['random', 'a { width: random(x); }'], + ['cycle', 'a { width: cycle(x); }'], + ['palette-mix', 'a { width: palette-mix(x); }'], + ['paint', 'a { background: paint(x); }'], + ['sibling-index', 'a { width: sibling-index(x); }'], + ['sibling-count', 'a { width: sibling-count(x); }'], + ['random-item', 'a { width: random-item(--k, 1px, 2px); }'] + ], ['meta.function.misc.css', 'support.function.misc.css']); + }); + + it('names every anchor side it adds', function () { + eachScopes([ + ['self-start', 'a { top: anchor(--a self-start); }'], + ['self-end', 'a { top: anchor(--a self-end); }'], + ['self-block', 'a { width: anchor-size(--a self-block); }'] + ], ['meta.function.anchor.css', 'support.constant.property-value.css']); + }); + + it('reads an environment variable name that starts with a digit', function () { + // `env()` takes a ``, and `--1foo` is one: a digit may + // follow the two hyphens even though it may not start a bare ident. + var tokens = testGrammar.tokenizeLine('a { padding: env(--1foo, 0px); }').tokens; + var token = tokens.find(x => x.value === '--1foo'); + assert.deepStrictEqual(token.scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.env.css', 'variable.argument.css']); + }); + + it('leaves an identifier alone in the colour functions that take no interpolation method', function () { + // `color-mix()` is the only one of these that takes a + // ``, so an identifier in the others is + // not a colour space and is left to `#property-values`. + ['a { color: light-dark(foo, bar); }', 'a { color: contrast-color(foo); }', 'a { color: device-cmyk(foo 0 0 1); }'].forEach(function (line) { + var tokens = testGrammar.tokenizeLine(line).tokens; + var token = tokens.find(x => x.value.trim() === 'foo'); + assert.deepStrictEqual(token.scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.color.css'], line); + }); + }); + + it('reads a colour space in color-mix() only', function () { + var tokens = testGrammar.tokenizeLine('a { color: color-mix(in --brand-space, red, blue); }').tokens; + var token = tokens.find(x => x.value === '--brand-space'); + assert.deepStrictEqual(token.scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.color.css', 'variable.parameter.misc.css']); + }); + + it('keeps the anchor() and anchor-size() keyword sets apart', function () { + // `anchor()` takes an `` and `anchor-size()` takes an + // ``. The two sets are disjoint, so neither function + // takes the other's keywords. Only the keywords that are not also + // ordinary property values are checked: `top` is a property value + // in its own right, so `#property-values` scopes it in either + // function whatever this matcher does. + [ + ['a { top: anchor(--a width); }', 'width'], + ['a { top: anchor(--a height); }', 'height'], + ['a { top: anchor(--a self-block); }', 'self-block'], + ['a { top: anchor(--a self-inline); }', 'self-inline'], + ['a { width: anchor-size(--a self-start); }', 'self-start'], + ['a { width: anchor-size(--a self-end); }', 'self-end'] + ].forEach(function (c) { + var tokens = testGrammar.tokenizeLine(c[0]).tokens; + assert.ok(!tokens.some(x => x.value === c[1] && x.scopes.includes('support.constant.property-value.css')), c[0]); + }); + }); + + it('leaves the color functions that were already here alone', function () { + // The interpolation-keyword matcher is only on the functions this + // change adds, so an identifier in rgb() is scoped as it was. + var tokens = testGrammar.tokenizeLine('a { color: rgb(foo); }').tokens; + var token = tokens.find(x => x.value === 'foo'); + assert.deepStrictEqual(token.scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.color.css']); + }); + + it('scopes an interpolation keyword in the color functions it adds', function () { + var tokens = testGrammar.tokenizeLine('a { color: color-mix(in oklch, red, blue); }').tokens; + var token = tokens.find(x => x.value === 'oklch'); + assert.deepStrictEqual(token.scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.color.css', 'variable.parameter.misc.css']); + }); + + it('does not name an env() argument that cannot be a name', function () { + // The name is the first argument. A dimension is not a name, and + // neither is a CSS-wide keyword, so neither is scoped as one and + // nor is anything after them. + ['a { padding: env(-10px, red); }', 'a { padding: env(initial, red); }'].forEach(function (line) { + var tokens = testGrammar.tokenizeLine(line).tokens; + assert.deepStrictEqual(tokens.filter(t => t.scopes.includes('variable.argument.css')).map(t => t.value), [], line); + }); + }); + + it('does not name an env() identifier from a following line', function () { + // The first argument is on the next line and is not a name, so the + // identifier after it is not promoted to one. + var lines = testGrammar.tokenizeLines('a { padding: env(\n\t10px red\n); }'); + var named = lines.flat().filter(t => t.scopes.includes('variable.argument.css')); + assert.deepStrictEqual(named, []); + }); + + it('reads an anchor name that begins with a digit', function () { + // A `` is `--` and then any name characters, so a + // digit may follow the two hyphens. + var tokens = testGrammar.tokenizeLine('a { top: anchor(--1foo top); }').tokens; + var token = tokens.find(x => x.value === '--1foo'); + assert.deepStrictEqual(token.scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.anchor.css', 'variable.argument.css']); + }); + + it('does not read an anchor name out of a longer identifier', function () { + var tokens = testGrammar.tokenizeLine('a { top: anchor(foo--bar top); }').tokens; + assert.ok(!tokens.some(t => t.scopes.includes('variable.argument.css')), 'read a name out of `foo--bar`'); + }); + + it('ends an env() escape on a form feed', function () { + // A form feed is whitespace, so it terminates a hexadecimal + // escape rather than ending the name: `--\61\fb` is `--ab`, and + // the name does not stop short at the escape. + var tokens = testGrammar.tokenizeLine('a { padding: env(--\\61\fb); }').tokens; + var token = tokens.find(x => x.scopes.includes('variable.argument.css')); + assert.strictEqual(token.value, '--\\61\fb'); + }); + + it('tokenizes every known environment variable name', function () { + var expected = ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.env.css', 'support.constant.property-value.css']; + var names = [].concat( + ['top', 'right', 'bottom', 'left'].map(x => 'safe-area-inset-' + x), + ['top', 'right', 'bottom', 'left'].map(x => 'safe-area-max-inset-' + x), + ['x', 'y', 'width', 'height'].map(x => 'titlebar-area-' + x), + ['top', 'right', 'bottom', 'left', 'width', 'height'].map(x => 'keyboard-inset-' + x), + ['top', 'right', 'bottom', 'left', 'width', 'height'].map(x => 'viewport-segment-' + x), + ['preferred-text-scale'] + ); + assert.strictEqual(names.length, 25); + names.forEach(function (name) { + var tokens = testGrammar.tokenizeLine('a { padding: env(' + name + ', red); }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === name).scopes, expected, name); + }); + }); + + it('does not read a CSS-wide keyword as an environment variable name', function () { + ['initial', 'inherit', 'unset', 'revert', 'revert-layer', 'default'].forEach(function (word) { + var tokens = testGrammar.tokenizeLine('a { padding: env(' + word + ', red); }').tokens; + var token = tokens.find(x => x.value === word); + assert.ok(token, word); + assert.ok(!token.scopes.includes('variable.argument.css'), word + ' was read as an environment variable name'); + }); + // A name that merely starts with one of them is still a name. + var tokens = testGrammar.tokenizeLine('a { padding: env(initial-position, red); }').tokens; + assert.ok(tokens.find(x => x.value === 'initial-position').scopes.includes('variable.argument.css')); + }); + + it('gives up on an environment variable name when the first component is a function', function () { + // The name is the first component, so a function in that position + // means there is no name and no later identifier may take its place. + var lines = testGrammar.tokenizeLines('a { padding: env(\n /* before */ calc(x)\n /* after */ red\n); }'); + var found = lines[2].find(x => x.value === 'red'); + assert.ok(found, 'red not tokenized'); + assert.deepStrictEqual(lines[1].find(x => x.value === 'calc').scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.env.css', 'meta.function.calc.css', 'support.function.calc.css']); + assert.ok(!found.scopes.includes('variable.argument.css'), 'a later line was read as the environment variable name'); + assert.ok(found.scopes.includes('support.constant.color.w3c-standard-color-name.css')); + }); + + it('tokenizes every anchor and anchor-size keyword', function () { + // Each keyword is checked in the function that takes it: the + // `` set belongs to `anchor()` and the `` + // set to `anchor-size()`. + [ + ['anchor', ['top', 'right', 'bottom', 'left', 'center', 'start', 'end', 'self-start', 'self-end', 'inside', 'outside']], + ['anchor-size', ['width', 'height', 'block', 'inline', 'self-block', 'self-inline']] + ].forEach(function (pair) { + pair[1].forEach(function (keyword) { + var line = 'a { top: ' + pair[0] + '(--a ' + keyword + '); }'; + var tokens = testGrammar.tokenizeLine(line).tokens.filter(x => x.value === keyword); + var token = tokens[tokens.length - 1]; + assert.ok(token, pair[0] + ' ' + keyword); + assert.deepStrictEqual(token.scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.anchor.css', 'support.constant.property-value.css'], pair[0] + ' ' + keyword); + }); + }); + }); + + it('takes a length or a colour as an anchor fallback', function () { + var head = ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.anchor.css']; + var length = testGrammar.tokenizeLine('a { top: anchor(--a 10px); }').tokens; + assert.deepStrictEqual(length.find(x => x.value === '10').scopes, head.concat(['constant.numeric.css'])); + assert.deepStrictEqual(length.find(x => x.value === 'px').scopes, head.concat(['constant.numeric.css', 'keyword.other.unit.px.css'])); + var colour = testGrammar.tokenizeLine('a { top: anchor(--a red); }').tokens; + assert.deepStrictEqual(colour.find(x => x.value === 'red').scopes, head.concat(['support.constant.color.w3c-standard-color-name.css'])); + }); + + it('scopes the brackets and the anchor name of anchor-size()', function () { + // `anchor-size()` is matched separately from `anchor()`, so its own + // brackets and name are asserted rather than assumed. + var head = ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.anchor.css']; + var tokens = testGrammar.tokenizeLine('a { width: anchor-size(--a width); }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === '(').scopes, head.concat(['punctuation.section.function.begin.bracket.round.css'])); + assert.deepStrictEqual(tokens.find(x => x.value === ')').scopes, head.concat(['punctuation.section.function.end.bracket.round.css'])); + assert.deepStrictEqual(tokens.find(x => x.value === '--a').scopes, head.concat(['variable.argument.css'])); + }); + + it('takes a length or a keyword as an anchor-size fallback', function () { + var head = ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.anchor.css']; + var length = testGrammar.tokenizeLine('a { width: anchor-size(--a, 10px); }').tokens; + assert.deepStrictEqual(length.find(x => x.value === '10').scopes, head.concat(['constant.numeric.css'])); + assert.deepStrictEqual(length.find(x => x.value === 'px').scopes, head.concat(['constant.numeric.css', 'keyword.other.unit.px.css'])); + var keyword = testGrammar.tokenizeLine('a { width: anchor-size(--a, auto); }').tokens; + assert.deepStrictEqual(keyword.find(x => x.value === 'auto').scopes, head.concat(['support.constant.property-value.css'])); + }); + + it('takes a custom colour space whose name starts with a digit', function () { + var tokens = testGrammar.tokenizeLine('a { color: color-mix(in --1foo, red, blue); }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === '--1foo').scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.color.css', 'variable.parameter.misc.css']); + }); + + it('marks the brackets of the colour and anchor functions', function () { + [ + ['a { color: rgb(0 0 0); }', 'meta.function.color.css'], + ['a { color: color-mix(in --1foo, red, blue); }', 'meta.function.color.css'], + ['a { top: anchor(--a top); }', 'meta.function.anchor.css'] + ].forEach(function (c) { + var tokens = testGrammar.tokenizeLine(c[0]).tokens; + var head = ['source.css', 'meta.property-list.css', 'meta.property-value.css', c[1]]; + assert.deepStrictEqual(tokens.find(x => x.value === '(').scopes, head.concat(['punctuation.section.function.begin.bracket.round.css']), c[0]); + assert.deepStrictEqual(tokens.filter(x => x.value === ')')[0].scopes, head.concat(['punctuation.section.function.end.bracket.round.css']), c[0]); + }); + }); + + }); }); From bf98868b9717bddf49b3ed4632c5599069717fae Mon Sep 17 00:00:00 2001 From: Kristofer Baxter Date: Tue, 1 Sep 2026 10:44:46 -0700 Subject: [PATCH 4/5] feat: Selectors 4 and 5 pseudo-elements and pseudo-classes Adds scopes for `::part()`, `::slotted()`, functional `:host()`, `:state()`, the scroll marker position pseudo-classes and the other pseudo-classes and pseudo-elements Selectors 4 defines, each parsed with the argument grammar its arguments call for. It also adds the names Selectors 4 defers to Level 5 and Level 5 goes on to define: `:blank`, `:local-link`, `:current`, `:past` and `:future`. `::column` comes from css-multicol-2 rather than Selectors 5, and is added here as well. `:nth-child()` gains the `of ` clause, and accepts a universal or namespaced selector after `of`. It is split from `:nth-of-type()`, which takes no such clause, so `of` is no longer accepted there. `::slotted()`, `:host()` and `:host-context()` each take a single compound selector, and `:current()` takes a list of them, so a combinator in any of the four is marked invalid, as is a comma in the first three. That includes the descendant combinator, which is written as whitespace. Whitespace that terminates a hexadecimal escape belongs to the name before it, so `:host(.\61 .b)` is one compound selector and stays valid, while the second space in `:host(.\61 .b)` is a combinator, and so is the space in `:host(.\\61 .b)`, where the backslash is itself escaped and the digits are literal text. Past two such runs the whitespace is left alone rather than called a combinator, since missing one reads better than calling valid CSS invalid. `:is()`, `:not()`, `:has()` and `:where()` take complex selectors and are unaffected. Each argument is matched to the production the specification gives it. `::part()` takes `+`, so every name in it is an argument. `:state()` takes one ``, `::highlight()` one ``, `::scroll-button()` one direction, and a view-transition pseudo-element one ``, so in those a second argument is not scoped as one. An identifier may begin with an escape, and it has to begin the way css-syntax-3 says: a lone hyphen, a digit after one hyphen, and a leading digit are not identifiers. A `` excludes the CSS-wide keywords and `default`, so `::highlight(default)` is not a name, while `:state(default)` is, since that argument is an ``. `:local-link()` is here in its functional form too, taking one non-negative integer. A keyword list is written out rather than parsed, so a keyword spelled as an escape is not recognised: `::scroll-button(\75 p)` and `:nth-child(2n \6f f a)` leave the argument alone. Nothing is scoped wrongly, and `main` reads neither of them either. Enforcing a sole argument means anchoring the match to the position after the opening parenthesis. That anchor does not survive a line break, so each of those matches takes a line start as well, and a selector wrapped across lines keeps its argument scope. The class selector additions css-view-transitions-2 makes to `::view-transition-group()`, `::view-transition-image-pair()`, `::view-transition-old()` and `::view-transition-new()` are not implemented. A class in that position keeps tokenizing as an ordinary class selector, as it does on `main`. Every pseudo-class and pseudo-element this change adds is asserted, so that removing one from the grammar fails a test. The scope names and the brackets of each new functional selector are asserted too. --- grammars/css.cson | 512 +++++++++++++++++++++++++++++++++++++++- spec/css-spec.mjs | 580 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1084 insertions(+), 8 deletions(-) diff --git a/grammars/css.cson b/grammars/css.cson index e16afa4..9b5670f 100644 --- a/grammars/css.cson +++ b/grammars/css.cson @@ -1104,6 +1104,81 @@ '0': 'name': 'punctuation.definition.comment.end.css' 'name': 'comment.block.css' + 'compound-selector-innards': + 'patterns': [ + { + 'match': '>|\\+|~|\\|\\|' + 'name': 'invalid.illegal.combinator.css' + } + { + # The descendant combinator is written as whitespace, so it is only + # one where a selector ends and another begins. A hexadecimal escape + # takes at most one whitespace to terminate it, and that one belongs + # to the name before it, so `.\\61 .b` is a single compound selector. + # A backslash run of even length is escaped backslashes rather than an + # escape, so `.\\\\61 .b` is two selectors. Past two such runs the + # whitespace is left alone rather than called a combinator, since + # missing one reads better than calling valid CSS invalid. + 'match': '''(?x) + (?+~|]) + [\\x20\\t\\f]+ + (?=[.#\\[:*\\w-]) + ''' + 'name': 'invalid.illegal.combinator.css' + } + { + # Whitespace left over once an escape has taken its terminator is a + # combinator after all, which is why `.\\61 .b` is two of them. + 'match': '''(?x) + (?<= + [^\\\\]\\\\[0-9a-fA-F][\\x20\\t\\f] + | [^\\\\]\\\\[0-9a-fA-F]{2}[\\x20\\t\\f] + | [^\\\\]\\\\[0-9a-fA-F]{3}[\\x20\\t\\f] + | [^\\\\]\\\\[0-9a-fA-F]{4}[\\x20\\t\\f] + | [^\\\\]\\\\[0-9a-fA-F]{5}[\\x20\\t\\f] + | [^\\\\]\\\\[0-9a-fA-F]{6}[\\x20\\t\\f] + | [^\\\\]\\\\\\\\\\\\[0-9a-fA-F][\\x20\\t\\f] + | [^\\\\]\\\\\\\\\\\\[0-9a-fA-F]{2}[\\x20\\t\\f] + | [^\\\\]\\\\\\\\\\\\[0-9a-fA-F]{3}[\\x20\\t\\f] + | [^\\\\]\\\\\\\\\\\\[0-9a-fA-F]{4}[\\x20\\t\\f] + | [^\\\\]\\\\\\\\\\\\[0-9a-fA-F]{5}[\\x20\\t\\f] + | [^\\\\]\\\\\\\\\\\\[0-9a-fA-F]{6}[\\x20\\t\\f] + ) + [\\x20\\t\\f]+ + (?=[.#\\[:*\\w-]) + ''' + 'name': 'invalid.illegal.combinator.css' + } + { + 'include': '#string' + } + { + 'include': '#balanced-selector-parens' + } + { + 'include': '#selector-innards' + } + ] 'escapes': 'patterns': [ { @@ -1835,9 +1910,55 @@ } ] } + { + # :host() and :host-context() each take a single compound selector, + # so a combinator or a comma in one of them is not valid. + 'begin': '(?i)((:)(?:host-context|host))(\\()' + 'beginCaptures': + '1': + 'name': 'entity.other.attribute-name.pseudo-class.css' + '2': + 'name': 'punctuation.definition.entity.css' + '3': + 'name': 'punctuation.section.function.begin.bracket.round.css' + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.section.function.end.bracket.round.css' + 'patterns': [ + { + 'match': ',' + 'name': 'invalid.illegal.comma.css' + } + { + 'include': '#compound-selector-innards' + } + ] + } + { + # :current() takes a list of compound selectors, so a comma in it + # is valid where a combinator still is not. + 'begin': '(?i)((:)current)(\\()' + 'beginCaptures': + '1': + 'name': 'entity.other.attribute-name.pseudo-class.css' + '2': + 'name': 'punctuation.definition.entity.css' + '3': + 'name': 'punctuation.section.function.begin.bracket.round.css' + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.section.function.end.bracket.round.css' + 'patterns': [ + { + 'include': '#compound-selector-innards' + } + ] + } { # Child-indexed - 'begin': '(?i)((:)nth-(?:last-)?(?:child|of-type))(\\()' + 'begin': '(?i)((:)nth-(?:last-)?child)(\\()' 'beginCaptures': '1': 'name': 'entity.other.attribute-name.pseudo-class.css' @@ -1858,6 +1979,367 @@ 'match': '(?i)even|odd' 'name': 'support.constant.parity.css' } + { + # `of ` syntax, e.g. :nth-child(2 of .visible) + 'begin': '(?i)(?<=[\\s)]|\\*/)(of)(?=[\\s(:.#\\[*|]|/\\*|$)' + 'beginCaptures': + '1': + 'name': 'keyword.operator.logical.of.css' + 'end': '(?=\\))' + 'patterns': [ + { + 'include': '#selector-innards' + } + ] + } + ] + } + { + # Typed-child-indexed; :nth-of-type() takes no `of` clause + 'begin': '(?i)((:)nth-(?:last-)?of-type)(\\()' + 'beginCaptures': + '1': + 'name': 'entity.other.attribute-name.pseudo-class.css' + '2': + 'name': 'punctuation.definition.entity.css' + '3': + 'name': 'punctuation.section.function.begin.bracket.round.css' + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.section.function.end.bracket.round.css' + 'patterns': [ + { + 'match': '(?i)[+-]?(\\d+n?|n)(\\s*[+-]\\s*\\d+)?' + 'name': 'constant.numeric.css' + } + { + 'match': '(?i)even|odd' + 'name': 'support.constant.parity.css' + } + ] + } + { + # As a functional pseudo-class, `:local-link()` takes one + # non-negative integer. + 'begin': '(?i)((:)local-link)(\\()' + 'beginCaptures': + '1': + 'name': 'entity.other.attribute-name.pseudo-class.css' + '2': + 'name': 'punctuation.definition.entity.css' + '3': + 'name': 'punctuation.section.function.begin.bracket.round.css' + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.section.function.end.bracket.round.css' + 'patterns': [ + { + 'match': '(?i)(?:\\G|^)((?:\\s|/\\*(?:[^*]|\\*[^/])*\\*/)*)(\\d+)(?=\\s*(?:\\)|/\\*|$))' + 'captures': + '1': + 'patterns': [ + { + 'include': '#comment-block' + } + ] + '2': + 'name': 'constant.numeric.css' + } + { + 'include': '#comment-block' + } + ] + } + { + # :state() takes a custom identifier, not a selector list + 'begin': '(?i)((:)state)(\\()' + 'beginCaptures': + '1': + 'name': 'entity.other.attribute-name.pseudo-class.css' + '2': + 'name': 'punctuation.definition.entity.css' + '3': + 'name': 'punctuation.section.function.begin.bracket.round.css' + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.section.function.end.bracket.round.css' + 'patterns': [ + { + 'match': '''(?xi) (?:\\G|^) + ( (?:\\s|/\\*(?:[^*]|\\*[^/])*\\*/)* # Leading comments + ) + ( (?: -- # An identifier starts with two + | -? # hyphens, or one optional + (?:[a-zA-Z_] | [^\\x00-\\x7F] # hyphen and a letter, and + |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\n\\r\\f]?|[^\\n]) + ) # any part of it may be an + ) # escape. A hexadecimal + (?:[-a-zA-Z0-9_] | [^\\x00-\\x7F] # escape takes the + |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\n\\r\\f]?|[^\\n]) + )* # whitespace that ends it. + ) + (?= \\s*(?:\\)|/\\*|$) ) # The sole argument + ''' + 'captures': + '1': + 'patterns': [ + { + 'include': '#comment-block' + } + ] + '2': + 'name': 'variable.parameter.state-name.css' + 'patterns': [ + { + 'include': '#escapes' + } + ] + } + { + 'include': '#comment-block' + } + ] + } + ] + 'functional-pseudo-elements': + 'patterns': [ + { + # ::part() is the only one of these that takes more than one + # identifier: css-shadow-parts-1 defines it as `+`. + 'begin': '(?i)((::)part)(\\()' + 'beginCaptures': + '1': + 'name': 'entity.other.attribute-name.pseudo-element.css' + '2': + 'name': 'punctuation.definition.entity.css' + '3': + 'name': 'punctuation.section.function.begin.bracket.round.css' + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.section.function.end.bracket.round.css' + 'patterns': [ + { + 'include': '#comment-block' + } + { + 'match': '''(?xi) (?` + ) + (?: -- # An identifier starts with two + | -? # hyphens, or one optional + (?:[a-zA-Z_] | [^\\x00-\\x7F] # hyphen and a letter, and + |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\n\\r\\f]?|[^\\n]) + ) # any part of it may be an + ) # escape. A hexadecimal + (?:[-a-zA-Z0-9_] | [^\\x00-\\x7F] # escape takes the + |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\n\\r\\f]?|[^\\n]) + )* # whitespace that ends it. + ) + (?= \\s*(?:\\)|/\\*|$) ) # The sole argument + ''' + 'captures': + '1': + 'patterns': [ + { + 'include': '#comment-block' + } + ] + '2': + 'name': 'variable.parameter.pseudo-element.css' + 'patterns': [ + { + 'include': '#escapes' + } + ] + } + { + 'include': '#comment-block' + } + ] + } + { + # View-transition pseudo-elements take a custom identifier or wildcard + 'begin': '(?i)((::)(?:view-transition-group-children|view-transition-group|view-transition-image-pair|view-transition-new|view-transition-old))(\\()' + 'beginCaptures': + '1': + 'name': 'entity.other.attribute-name.pseudo-element.css' + '2': + 'name': 'punctuation.definition.entity.css' + '3': + 'name': 'punctuation.section.function.begin.bracket.round.css' + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.section.function.end.bracket.round.css' + 'patterns': [ + { + 'match': '(?i)(?:\\G|^)((?:\\s|/\\*(?:[^*]|\\*[^/])*\\*/)*)(\\*)(?=\\s*(?:\\)|/\\*|$))' + 'captures': + '1': + 'patterns': [ + { + 'include': '#comment-block' + } + ] + '2': + 'name': 'entity.name.tag.wildcard.css' + } + { + 'match': '''(?xi) (?:\\G|^) + ( (?:\\s|/\\*(?:[^*]|\\*[^/])*\\*/)* # Leading comments + ) + ( (?! (?:initial|inherit|unset|revert|revert-layer|default) + (?![-\\w]) # Not a `` + ) + (?: -- # An identifier starts with two + | -? # hyphens, or one optional + (?:[a-zA-Z_] | [^\\x00-\\x7F] # hyphen and a letter, and + |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\n\\r\\f]?|[^\\n]) + ) # any part of it may be an + ) # escape. A hexadecimal + (?:[-a-zA-Z0-9_] | [^\\x00-\\x7F] # escape takes the + |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\n\\r\\f]?|[^\\n]) + )* # whitespace that ends it. + ) + (?= \\s*(?:\\)|/\\*|$) ) # The sole argument + ''' + 'captures': + '1': + 'patterns': [ + { + 'include': '#comment-block' + } + ] + '2': + 'name': 'variable.parameter.pseudo-element.css' + 'patterns': [ + { + 'include': '#escapes' + } + ] + } + { + 'include': '#comment-block' + } + ] + } + { + # ::scroll-button() takes a physical or logical direction + 'begin': '(?i)((::)scroll-button)(\\()' + 'beginCaptures': + '1': + 'name': 'entity.other.attribute-name.pseudo-element.css' + '2': + 'name': 'punctuation.definition.entity.css' + '3': + 'name': 'punctuation.section.function.begin.bracket.round.css' + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.section.function.end.bracket.round.css' + 'patterns': [ + { + 'match': '(?i)(?:\\G|^)((?:\\s|/\\*(?:[^*]|\\*[^/])*\\*/)*)(\\*)(?=\\s*(?:\\)|/\\*|$))' + 'captures': + '1': + 'patterns': [ + { + 'include': '#comment-block' + } + ] + '2': + 'name': 'entity.name.tag.wildcard.css' + } + { + # css-overflow-5 gives `::scroll-button()` one direction, so only + # the sole argument is a direction. + 'match': '(?i)(?:\\G|^)((?:\\s|/\\*(?:[^*]|\\*[^/])*\\*/)*)(block-start|block-end|inline-start|inline-end|up|down|left|right|prev|next)(?=\\s*(?:\\)|/\\*|$))' + 'captures': + '1': + 'patterns': [ + { + 'include': '#comment-block' + } + ] + '2': + 'name': 'support.constant.property-value.css' + } + { + 'include': '#comment-block' + } + ] + } + { + # ::slotted() takes a compound selector + 'begin': '(?i)((::)slotted)(\\()' + 'beginCaptures': + '1': + 'name': 'entity.other.attribute-name.pseudo-element.css' + '2': + 'name': 'punctuation.definition.entity.css' + '3': + 'name': 'punctuation.section.function.begin.bracket.round.css' + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.section.function.end.bracket.round.css' + 'patterns': [ + { + # ::slotted() takes one compound selector, so a comma or a + # combinator in it is not valid. + 'match': ',' + 'name': 'invalid.illegal.comma.css' + } + { + 'include': '#compound-selector-innards' + } ] } ] @@ -2769,11 +3251,13 @@ 'name': 'invalid.illegal.colon.css' 'match': '''(?xi) (:)(:*) - (?: active|any-link|checked|default|disabled|empty|enabled|first - | (?:first|last|only)-(?:child|of-type)|focus|focus-visible|focus-within|fullscreen|host|hover - | in-range|indeterminate|invalid|left|link|optional|out-of-range - | read-only|read-write|required|right|root|scope|target|unresolved - | valid|visited + (?: active|any-link|autofill|blank|buffering|checked|current|default|defined|disabled|empty|enabled|first + | (?:first|last|only)-(?:child|of-type)|focus|focus-visible|focus-within|fullscreen|future|host|hover + | in-range|indeterminate|invalid|left|link|local-link|modal|muted|open|optional|out-of-range + | past|paused|picture-in-picture|placeholder-shown|playing|popover-open + | read-only|read-write|required|right|root|scope|seeking|stalled|target|target-after|target-before|target-current + | unchecked + | unresolved|user-invalid|user-valid|valid|visited|volume-locked )(?![\\w-]|\\s*[;}]) ''' 'name': 'entity.other.attribute-name.pseudo-class.css' @@ -2798,13 +3282,22 @@ | (::) # Double-colon only (?: backdrop + | checkmark + | column | content + | details-content + | file-selector-button | grammar-error | marker + | picker-icon | placeholder + | scroll-marker + | scroll-marker-group | selection | shadow | spelling-error + | target-text + | view-transition ) ) (?![\\w-]|\\s*[;}]) @@ -3191,14 +3684,17 @@ } ] } + { + 'include': '#functional-pseudo-classes' + } { 'include': '#pseudo-classes' } { - 'include': '#pseudo-elements' + 'include': '#functional-pseudo-elements' } { - 'include': '#functional-pseudo-classes' + 'include': '#pseudo-elements' } # Custom HTML elements { diff --git a/spec/css-spec.mjs b/spec/css-spec.mjs index 47620ac..d67c557 100644 --- a/spec/css-spec.mjs +++ b/spec/css-spec.mjs @@ -5393,4 +5393,584 @@ describe('CSS grammar', function () { }); }); + + describe('Selectors 4 and 5', function () { + it('accepts an `of` clause not separated from its selector by a space', function () { + // `of` is an identifier, so a class, id, attribute or comment may follow + // it directly. Requiring whitespace dropped the selector on the floor. + [['a:nth-child(2 of.x) {}', 'x', 'entity.other.attribute-name.class.css'], + ['a:nth-child(2 of#x) {}', 'x', 'entity.other.attribute-name.id.css'], + ['a:nth-child(2 of/*c*/.x) {}', 'x', 'entity.other.attribute-name.class.css']].forEach(function (probe) { + var tokens = testGrammar.tokenizeLine(probe[0]).tokens; + assert.deepStrictEqual(tokens.find(t => t.value === 'of').scopes, ['source.css', 'meta.selector.css', 'keyword.operator.logical.of.css'], probe[0]); + assert.deepStrictEqual(tokens.find(t => t.value === probe[1]).scopes, ['source.css', 'meta.selector.css', probe[2]], probe[0]); + }); + var attr = testGrammar.tokenizeLine('a:nth-child(2 of[hidden]) {}').tokens; + assert.deepStrictEqual(attr.find(t => t.value === 'hidden').scopes, ['source.css', 'meta.selector.css', 'meta.attribute-selector.css', 'entity.other.attribute-name.css']); + }); + + it('closes every new functional selector', function () { + // Each of these rules is new on this branch and none of them had + // its closing parenthesis asserted, so the scope could be renamed + // without a failure. + [ + ['a:state(loading) {}', 5], + ['a::part(button) {}', 5], + ['a::highlight(search) {}', 5], + ['a::view-transition-old(hero) {}', 5], + ['a::scroll-button(next) {}', 5], + ['a::slotted(.x) {}', 6] + ].forEach(function (pair) { + var tokens = testGrammar.tokenizeLine(pair[0]).tokens; + assert.deepStrictEqual(tokens[pair[1]], { + scopes: [ + 'source.css', + 'meta.selector.css', + 'punctuation.section.function.end.bracket.round.css' + ], + value: ')' + }, pair[0]); + }); + }); + + it('gives a single-argument selector only its sole argument', function () { + // `:state()` takes one ``, `::highlight()` one + // ``, `::scroll-button()` one direction and a + // view-transition pseudo-element one ``. A second + // argument is invalid, so it is not scoped as one. + [ + ['a:state(foo bar) {}', 'bar'], + ['a::highlight(foo bar) {}', 'bar'], + ['a::scroll-button(up down) {}', 'down'], + ['a::view-transition-old(foo bar) {}', 'bar'] + ].forEach(function (c) { + var tokens = testGrammar.tokenizeLine(c[0]).tokens; + assert.ok(!tokens.some(x => x.value === c[1] && x.scopes.some(sc => sc.startsWith('variable.parameter') || sc === 'support.constant.property-value.css')), c[0]); + }); + }); + + it('takes more than one identifier in ::part() only', function () { + // css-shadow-parts-1 defines `::part( # )`, so both names + // here are arguments. + var tokens = testGrammar.tokenizeLine('a::part(foo bar) {}').tokens; + ['foo', 'bar'].forEach(function (name) { + assert.deepStrictEqual(tokens.find(x => x.value === name).scopes, ['source.css', 'meta.selector.css', 'variable.parameter.pseudo-element.css'], name); + }); + }); + + it('requires a valid identifier start in a selector argument', function () { + // css-syntax-3: an ident sequence starts with an ident-start code + // point, two hyphens, or a hyphen and an ident-start code point. + // A lone hyphen, a digit after one hyphen, and a leading digit are + // none of those, and the matcher does not take the tail of one. + ['a:state(-) {}', 'a:state(-1foo) {}', 'a:state(1foo) {}'].forEach(function (line) { + var tokens = testGrammar.tokenizeLine(line).tokens; + assert.ok(!tokens.some(x => x.scopes.includes('variable.parameter.state-name.css')), line); + }); + var ok = testGrammar.tokenizeLine('a:state(--x) {}').tokens; + assert.deepStrictEqual(ok.find(x => x.value === '--x').scopes, ['source.css', 'meta.selector.css', 'variable.parameter.state-name.css']); + }); + + it('does not take a reserved word as a custom identifier', function () { + // css-values-4: the CSS-wide keywords are not valid + // ``s, and `default` is reserved as well. + ['default', 'initial', 'inherit', 'unset', 'revert', 'revert-layer'].forEach(function (word) { + ['a::highlight(' + word + ') {}', 'a::view-transition-old(' + word + ') {}'].forEach(function (line) { + var tokens = testGrammar.tokenizeLine(line).tokens; + assert.ok(!tokens.some(x => x.scopes.includes('variable.parameter.pseudo-element.css')), line); + }); + }); + // `:state()` takes an ``, which has no such exclusion. + var tokens = testGrammar.tokenizeLine('a:state(default) {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'default').scopes, ['source.css', 'meta.selector.css', 'variable.parameter.state-name.css']); + }); + + it('does not read a class selector as a view-transition name', function () { + // `` is `'*' | `. The class + // additions in css-view-transitions-2 are not implemented here, so + // a class is left to tokenize as an ordinary class selector. + var tokens = testGrammar.tokenizeLine('a::view-transition-old(.active) {}').tokens; + assert.ok(!tokens.some(x => x.scopes.includes('variable.parameter.pseudo-element.css'))); + }); + + it('reads a comment before a selector argument', function () { + [ + ['a:state(/*c*/ foo) {}', 'foo', 'variable.parameter.state-name.css'], + ['a::highlight(/*c*/ foo) {}', 'foo', 'variable.parameter.pseudo-element.css'], + ['a::scroll-button(/*c*/ up) {}', 'up', 'support.constant.property-value.css'], + ['a::view-transition-old(/*c*/ *) {}', '*', 'entity.name.tag.wildcard.css'], + ['a::scroll-button(/*c*/ *) {}', '*', 'entity.name.tag.wildcard.css'], + ['a:local-link(/*c*/ 2) {}', '2', 'constant.numeric.css'] + ].forEach(function (c) { + var tokens = testGrammar.tokenizeLine(c[0]).tokens; + assert.deepStrictEqual(tokens.find(x => x.value === c[1]).scopes, ['source.css', 'meta.selector.css', c[2]], c[0]); + assert.deepStrictEqual(tokens.find(x => x.value === 'c').scopes, ['source.css', 'meta.selector.css', 'comment.block.css'], c[0]); + }); + }); + + it('reads a comment after a selector argument', function () { + // The argument is still the sole argument, so it keeps its scope, + // and the comment is a comment rather than part of it. + [ + ['a:state(foo /*c*/) {}', 'foo', 'variable.parameter.state-name.css'], + ['a::highlight(foo /*c*/) {}', 'foo', 'variable.parameter.pseudo-element.css'], + ['a::part(foo /*c*/) {}', 'foo', 'variable.parameter.pseudo-element.css'], + ['a::scroll-button(up /*c*/) {}', 'up', 'support.constant.property-value.css'], + ['a::view-transition-old(foo /*c*/) {}', 'foo', 'variable.parameter.pseudo-element.css'], + ['a:local-link(2 /*c*/) {}', '2', 'constant.numeric.css'] + ].forEach(function (c) { + var tokens = testGrammar.tokenizeLine(c[0]).tokens; + assert.deepStrictEqual(tokens.find(x => x.value === c[1]).scopes, ['source.css', 'meta.selector.css', c[2]], c[0]); + assert.deepStrictEqual(tokens.find(x => x.value === 'c').scopes, ['source.css', 'meta.selector.css', 'comment.block.css'], c[0]); + }); + }); + + it('reads a selector argument written on its own line', function () { + // A `\G` anchor does not survive a line break, so the matchers + // take a line start as well: a wrapped selector is still valid. + [ + ['a:state(\n\tfoo\n) {}', 'foo', 'variable.parameter.state-name.css'], + ['a::highlight(\n\tfoo\n) {}', 'foo', 'variable.parameter.pseudo-element.css'], + ['a::part(\n\tfoo\n) {}', 'foo', 'variable.parameter.pseudo-element.css'], + ['a::scroll-button(\n\tup\n) {}', 'up', 'support.constant.property-value.css'], + ['a::scroll-button(\n\t*\n) {}', '*', 'entity.name.tag.wildcard.css'], + ['a::view-transition-old(\n\tfoo\n) {}', 'foo', 'variable.parameter.pseudo-element.css'], + ['a::view-transition-old(\n\t*\n) {}', '*', 'entity.name.tag.wildcard.css'], + ['a:local-link(\n\t2\n) {}', '2', 'constant.numeric.css'] + ].forEach(function (c) { + var lines = testGrammar.tokenizeLines(c[0]); + var token = lines[1].find(x => x.value === c[1]); + assert.deepStrictEqual(token && token.scopes, ['source.css', 'meta.selector.css', c[2]], c[0]); + }); + }); + + it('does not read a keyword written as an escape', function () { + // A keyword list is written out, so `up` spelled `\75 p` and `of` + // spelled `\6f f` are not recognised. Nothing is scoped wrongly: + // the argument is simply left alone, as it is on `main`. + [ + ['a::scroll-button(\\75 p) {}', 'support.constant.property-value.css'], + ['a:nth-child(2n \\6f f a) {}', 'keyword.operator.logical.of.css'] + ].forEach(function (c) { + var tokens = testGrammar.tokenizeLine(c[0]).tokens; + assert.ok(!tokens.some(x => x.scopes.includes(c[1])), c[0]); + }); + }); + + it('takes a non-negative integer in the functional :local-link()', function () { + // selectors-5: "As a functional pseudo-class, :local-link() can + // also accept a non-negative integer as its sole argument". + var tokens = testGrammar.tokenizeLine('a:local-link(2) {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === ':').scopes, ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-class.css', 'punctuation.definition.entity.css']); + assert.deepStrictEqual(tokens.find(x => x.value === 'local-link').scopes, ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-class.css']); + assert.deepStrictEqual(tokens.find(x => x.value === '(').scopes, ['source.css', 'meta.selector.css', 'punctuation.section.function.begin.bracket.round.css']); + assert.deepStrictEqual(tokens.find(x => x.value === ')').scopes, ['source.css', 'meta.selector.css', 'punctuation.section.function.end.bracket.round.css']); + assert.deepStrictEqual(tokens.find(x => x.value === '2').scopes, ['source.css', 'meta.selector.css', 'constant.numeric.css']); + ['a:local-link(-1) {}', 'a:local-link(2 3) {}'].forEach(function (line) { + var bad = testGrammar.tokenizeLine(line).tokens; + assert.ok(!bad.some(x => x.scopes.includes('constant.numeric.css')), line); + }); + }); + + it('does not treat a longer identifier as a selector it adds', function () { + // Each name list has to stop at a word boundary. Without one, + // `:local-linkish` and `::view-transition-oldish` pick up the + // scopes that belong to the shorter names they start with. + ['a:local-linkish {}', 'a::view-transition-oldish {}'].forEach(function (line) { + var tokens = testGrammar.tokenizeLine(line).tokens; + assert.ok(!tokens.some(x => x.scopes.some(sc => sc.startsWith('entity.other.attribute-name.pseudo'))), line); + }); + }); + + it('keeps pseudo-classes deferred to Selectors 5', function () { + ['blank', 'local-link'].forEach(function (pseudoClass) { + var tokens = testGrammar.tokenizeLine('a:' + pseudoClass + ' {}').tokens; + assert.deepStrictEqual(tokens[2], { scopes: ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-class.css'], value: pseudoClass }); + }); + }); + + it('releases the closing parenthesis of an of clause', function () { + // `:nth-child(An+B of S)` ends the selector subregion before the + // pseudo-class rule closes, and that release was unpinned. + var tokens = testGrammar.tokenizeLine('a:nth-child(2 of .x) {}').tokens; + assert.deepStrictEqual(tokens[6], { + scopes: ['source.css', 'meta.selector.css', 'keyword.operator.logical.of.css'], + value: 'of' + }); + assert.deepStrictEqual(tokens[10], { + scopes: [ + 'source.css', + 'meta.selector.css', + 'punctuation.section.function.end.bracket.round.css' + ], + value: ')' + }); + }); + + it('tokenizes ::part() and ::slotted()', function () { + var tokens; + tokens = testGrammar.tokenizeLine('a::part(btn) {}').tokens; + assert.deepStrictEqual(tokens[2], { scopes: ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-element.css'], value: 'part' }); + assert.deepStrictEqual(tokens[4], { scopes: ['source.css', 'meta.selector.css', 'variable.parameter.pseudo-element.css'], value: 'btn' }); + + tokens = testGrammar.tokenizeLine('a::slotted(.x) {}').tokens; + assert.deepStrictEqual(tokens[2], { scopes: ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-element.css'], value: 'slotted' }); + assert.deepStrictEqual(tokens[5], { scopes: ['source.css', 'meta.selector.css', 'entity.other.attribute-name.class.css'], value: 'x' }); + }); + + it('tokenizes :state() with a custom identifier', function () { + var tokens; + tokens = testGrammar.tokenizeLine('a:state(loading) {}').tokens; + assert.deepStrictEqual(tokens[2], { scopes: ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-class.css'], value: 'state' }); + assert.deepStrictEqual(tokens[4], { scopes: ['source.css', 'meta.selector.css', 'variable.parameter.state-name.css'], value: 'loading' }); + }); + + it('tokenizes additional pseudo-classes', function () { + ['popover-open', 'user-valid', 'user-invalid', 'placeholder-shown', 'autofill', 'modal', 'defined', 'open'].forEach(function (pc) { + var tokens = testGrammar.tokenizeLine('a:' + pc + ' {}').tokens; + assert.deepStrictEqual(tokens[2], { scopes: ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-class.css'], value: pc }); + }); + }); + + it('tokenizes additional pseudo-elements', function () { + ['details-content', 'file-selector-button', 'target-text', 'backdrop', 'marker'].forEach(function (pe) { + var tokens = testGrammar.tokenizeLine('a::' + pe + ' {}').tokens; + assert.deepStrictEqual(tokens[2], { scopes: ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-element.css'], value: pe }); + }); + }); + + it('tokenizes functional :host() rather than the bare pseudo-class', function () { + var tokens; + tokens = testGrammar.tokenizeLine('a:host(.dark) {}').tokens; + var host = tokens.find(t => t.value === 'host'); + assert.deepStrictEqual(host.scopes, ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-class.css']); + var open = tokens.find(t => t.value === '('); + assert.deepStrictEqual(open.scopes, ['source.css', 'meta.selector.css', 'punctuation.section.function.begin.bracket.round.css']); + var cls = tokens.find(t => t.value === 'dark'); + assert.deepStrictEqual(cls.scopes, ['source.css', 'meta.selector.css', 'entity.other.attribute-name.class.css']); + }); + + it('tokenizes scroll marker position pseudo-classes', function () { + ['target-before', 'target-current', 'target-after'].forEach(function (pseudoClass) { + var tokens = testGrammar.tokenizeLine('a:' + pseudoClass + ' {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === pseudoClass).scopes, ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-class.css'], pseudoClass); + }); + }); + + it('tokenizes the `of` clause of :nth-child()', function () { + var tokens; + tokens = testGrammar.tokenizeLine('a:nth-child(2 of .x) {}').tokens; + var of = tokens.find(t => t.value === 'of'); + assert.deepStrictEqual(of.scopes, ['source.css', 'meta.selector.css', 'keyword.operator.logical.of.css']); + var cls = tokens.find(t => t.value === 'x'); + assert.deepStrictEqual(cls.scopes, ['source.css', 'meta.selector.css', 'entity.other.attribute-name.class.css']); + }); + + it('uses the argument grammar for each functional pseudo-element', function () { + var tokens = testGrammar.tokenizeLine('a::part(left)::view-transition-old(*)::scroll-button(next)::scroll-button(*) {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'left'), { scopes: ['source.css', 'meta.selector.css', 'variable.parameter.pseudo-element.css'], value: 'left' }); + assert.deepStrictEqual(tokens.filter(x => x.value === '*').map(x => x.scopes), [ + ['source.css', 'meta.selector.css', 'entity.name.tag.wildcard.css'], + ['source.css', 'meta.selector.css', 'entity.name.tag.wildcard.css'] + ]); + assert.deepStrictEqual(tokens.find(x => x.value === 'next'), { scopes: ['source.css', 'meta.selector.css', 'support.constant.property-value.css'], value: 'next' }); + + tokens = testGrammar.tokenizeLine('a::part(*) {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === '*').scopes, ['source.css', 'meta.selector.css']); + }); + it('accepts a universal or namespaced selector after an of clause', function () { + var tokens = testGrammar.tokenizeLine('li:nth-child(2 of*) { }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'of').scopes, ['source.css', 'meta.selector.css', 'keyword.operator.logical.of.css']); + assert.deepStrictEqual(tokens.find(x => x.value === '*').scopes, ['source.css', 'meta.selector.css', 'entity.name.tag.wildcard.css']); + var ns = testGrammar.tokenizeLine('li:nth-last-child(odd of*|div) { }').tokens; + assert.deepStrictEqual(ns.find(x => x.value === 'of').scopes, ['source.css', 'meta.selector.css', 'keyword.operator.logical.of.css']); + assert.deepStrictEqual(ns.find(x => x.value === 'div').scopes, ['source.css', 'meta.selector.css', 'entity.name.tag.css']); + }); + + it('accepts a leading escape in a custom identifier argument', function () { + // The class grammar already handles `.\_foo`, and these take the + // same custom identifier. + [ + ['::part(\\_foo) { }', 'variable.parameter.pseudo-element.css'], + [':state(\\_foo) { }', 'variable.parameter.state-name.css'], + ['::highlight(\\_foo) { }', 'variable.parameter.pseudo-element.css'], + ['::view-transition-old(\\_foo) { }', 'variable.parameter.pseudo-element.css'] + ].forEach(function (pair) { + var tokens = testGrammar.tokenizeLine(pair[0]).tokens; + assert.deepStrictEqual(tokens.find(x => x.value === '\\_').scopes, ['source.css', 'meta.selector.css', pair[1], 'constant.character.escape.css'], pair[0]); + assert.deepStrictEqual(tokens.find(x => x.value === 'foo').scopes, ['source.css', 'meta.selector.css', pair[1]], pair[0]); + }); + }); + + it('marks a comma or a combinator in ::slotted() invalid', function () { + // ::slotted() takes one compound selector, not a selector list. + var list = testGrammar.tokenizeLine('::slotted(.a, .b) { }').tokens; + assert.deepStrictEqual(list.find(x => x.value === ',').scopes, ['source.css', 'meta.selector.css', 'invalid.illegal.comma.css']); + var child = testGrammar.tokenizeLine('::slotted(.a > .b) { }').tokens; + assert.deepStrictEqual(child.find(x => x.value === '>').scopes, ['source.css', 'meta.selector.css', 'invalid.illegal.combinator.css']); + var ok = testGrammar.tokenizeLine('::slotted(span.a) { }').tokens; + assert.deepStrictEqual(ok.find(x => x.value === 'span').scopes, ['source.css', 'meta.selector.css', 'entity.name.tag.css']); + }); + + // Every name this change adds to a list is asserted here, so that + // removing one from the grammar fails a test. + function eachSel(cases, want) { + cases.forEach(function (pair) { + var tokens = testGrammar.tokenizeLine(pair[1]).tokens; + var token = tokens.find(x => x.value === pair[0]); + assert.ok(token, pair[0] + ' produced no token in: ' + pair[1]); + assert.deepStrictEqual(token.scopes, want, pair[1]); + }); + } + + it('names every pseudo-class it adds', function () { + eachSel([ + ['picture-in-picture', 'a:picture-in-picture {}'], + ['unchecked', 'a:unchecked {}'], + ['volume-locked', 'a:volume-locked {}'], + ['autofill', 'a:autofill {}'], + ['blank', 'a:blank {}'], + ['buffering', 'a:buffering {}'], + ['current', 'a:current {}'], + ['defined', 'a:defined {}'], + ['future', 'a:future {}'], + ['local-link', 'a:local-link {}'], + ['modal', 'a:modal {}'], + ['muted', 'a:muted {}'], + ['past', 'a:past {}'], + ['paused', 'a:paused {}'], + ['placeholder-shown', 'a:placeholder-shown {}'], + ['playing', 'a:playing {}'], + ['popover-open', 'a:popover-open {}'], + ['seeking', 'a:seeking {}'], + ['stalled', 'a:stalled {}'], + ['target-after', 'a:target-after {}'], + ['target-before', 'a:target-before {}'], + ['target-current', 'a:target-current {}'], + ['user-invalid', 'a:user-invalid {}'], + ['user-valid', 'a:user-valid {}'], + ['visited', 'a:visited {}'] + ], ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-class.css']); + }); + + it('names every pseudo-element it adds', function () { + eachSel([ + ['scroll-marker-group', 'a::scroll-marker-group {}'], + ['view-transition', 'a::view-transition {}'], + ['checkmark', 'a::checkmark {}'], + ['details-content', 'a::details-content {}'], + ['file-selector-button', 'a::file-selector-button {}'], + ['picker-icon', 'a::picker-icon {}'], + ['scroll-marker', 'a::scroll-marker {}'], + ['spelling-error', 'a::spelling-error {}'], + ['target-text', 'a::target-text {}'], + ['column', 'a::column {}'] + ], ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-element.css']); + }); + + it('names the functional pseudo-class it adds', function () { + eachSel([ + ['host-context', 'a:host-context(.x) {}'] + ], ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-class.css']); + }); + + it('names every functional pseudo-element it adds', function () { + eachSel([ + ['part', 'a::part(n) {}'], + ['view-transition-group', 'a::view-transition-group(n) {}'], + ['view-transition-group-children', 'a::view-transition-group-children(n) {}'], + ['view-transition-image-pair', 'a::view-transition-image-pair(n) {}'], + ['view-transition-new', 'a::view-transition-new(n) {}'] + ], ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-element.css']); + }); + + it('recognises every scroll-button direction', function () { + eachSel([ + ['block-start', 'a::scroll-button(block-start) {}'], + ['block-end', 'a::scroll-button(block-end) {}'], + ['inline-start', 'a::scroll-button(inline-start) {}'], + ['inline-end', 'a::scroll-button(inline-end) {}'], + ['up', 'a::scroll-button(up) {}'], + ['down', 'a::scroll-button(down) {}'], + ['left', 'a::scroll-button(left) {}'], + ['right', 'a::scroll-button(right) {}'], + ['prev', 'a::scroll-button(prev) {}'], + ['next', 'a::scroll-button(next) {}'] + ], ['source.css', 'meta.selector.css', 'support.constant.property-value.css']); + }); + + it('marks a combinator inside a compound-selector pseudo-class', function () { + // :host() and :host-context() each take a single compound selector, + // and the descendant combinator is written as whitespace. + [ + [':host(.a > .b) {}', '>'], + [':host(.a + .b) {}', '+'], + [':host(.a ~ .b) {}', '~'], + [':host(.a || .b) {}', '||'], + [':host(.a .b) {}', ' '], + [':host(.a\t.b) {}', '\t'], + [':host(.a\f.b) {}', '\f'], + [':HOST(.a .b) {}', ' '], + [':Host-Context(.a .b) {}', ' '], + [':host-context(.a ~ .b) {}', '~'], + [':current(.a .b) {}', ' '], + [':current(.a, .b .c) {}', ' '], + [':host(.a/**/ .b) {}', ' '], + [':host(.\\61 .b) {}', ' '], + [':host(.\\\\61 .b) {}', ' '], + [':host(.\\\\61 .b) {}', ' '], + ['::slotted(.a .b) {}', ' '], + ['::slotted(.a > .b) {}', '>'], + ['::slotted(.a || .b) {}', '||'] + ].forEach(function (pair) { + var tokens = testGrammar.tokenizeLine(pair[0]).tokens; + var token = tokens.find(x => x.scopes.includes('invalid.illegal.combinator.css')); + assert.ok(token, 'no combinator marked in: ' + pair[0]); + assert.strictEqual(token.value, pair[1], pair[0]); + }); + }); + + it('marks a selector list where only one compound selector is allowed', function () { + [':host(.a, .b) {}', ':host-context(.a, .b) {}', '::slotted(.a, .b) {}'].forEach(function (line) { + var tokens = testGrammar.tokenizeLine(line).tokens; + var token = tokens.find(x => x.scopes.includes('invalid.illegal.comma.css')); + assert.ok(token, 'no comma marked in: ' + line); + assert.strictEqual(token.value, ',', line); + }); + }); + + it('leaves a valid compound selector alone', function () { + // A compound selector has no combinator in it. Whitespace that + // terminates a hexadecimal escape belongs to the name before it, so + // `.\\61 .b` is the single compound selector `.a.b`. + [ + ':host(.a.b) {}', + '::slotted( .a ) {}', + ':host(.a/**/.b) {}', + ':host(.\\61 .b) {}', + ':host(\\64 iv.x) {}', + '::slotted([data-x="a b"]) {}', + ':host([title="a > b"]) {}', + ':current(.a, .b) {}', + ':host(.\\\\\\61 .b) {}', + ':host(.\\\\\\\\\\61 .b) {}', + '::slotted(.\\61 .b) {}' + ].forEach(function (line) { + var tokens = testGrammar.tokenizeLine(line).tokens; + assert.ok(!tokens.some(t => t.scopes.some(sc => sc.startsWith('invalid'))), line); + }); + }); + + it('still allows a complex selector where one is valid', function () { + // These take complex selectors, or a list of them, so the compound + // selector restriction above must not reach them. + [ + ':is(.a .b) {}', + ':not(.a .b) {}', + ':has(.a .b) {}', + ':where(.a .b) {}', + ':matches(.a .b) {}', + ':current(.a, .b) {}', + ':is(.a, .b) {}' + ].forEach(function (line) { + var tokens = testGrammar.tokenizeLine(line).tokens; + assert.ok(!tokens.some(t => t.scopes.some(sc => sc.startsWith('invalid'))), line); + }); + }); + + it('keeps a compound-selector pseudo-class open across a nested function', function () { + // The closing bracket of a nested function does not end :host(). + var tokens = testGrammar.tokenizeLine(':host(:not(.a)) .b {}').tokens; + var token = tokens.find(x => x.value === 'b'); + assert.deepStrictEqual(token.scopes, [ + 'source.css', 'meta.selector.css', 'entity.other.attribute-name.class.css' + ]); + }); + + it('punctuates every functional selector it adds', function () { + // The name, its leading colons and both brackets are all scoped. + [ + [':host', 'pseudo-class', ':host(.a) {}'], + [':host-context', 'pseudo-class', ':host-context(.a) {}'], + [':state', 'pseudo-class', ':state(a) {}'], + [':current', 'pseudo-class', ':current(.a) {}'], + ['::part', 'pseudo-element', '::part(a) {}'], + ['::highlight', 'pseudo-element', '::highlight(a) {}'], + ['::slotted', 'pseudo-element', '::slotted(.a) {}'], + ['::scroll-button', 'pseudo-element', '::scroll-button(up) {}'], + ['::view-transition-group', 'pseudo-element', '::view-transition-group(a) {}'], + ['::view-transition-group-children', 'pseudo-element', '::view-transition-group-children(a) {}'], + ['::view-transition-image-pair', 'pseudo-element', '::view-transition-image-pair(a) {}'], + ['::view-transition-new', 'pseudo-element', '::view-transition-new(a) {}'], + ['::view-transition-old', 'pseudo-element', '::view-transition-old(a) {}'] + ].forEach(function (row) { + var colons = row[0].startsWith('::') ? '::' : ':'; + var name = row[0].slice(colons.length); + var tokens = testGrammar.tokenizeLine(row[2]).tokens; + var base = ['source.css', 'meta.selector.css']; + assert.deepStrictEqual(tokens.find(x => x.value === name).scopes, + base.concat(['entity.other.attribute-name.' + row[1] + '.css']), row[2]); + assert.deepStrictEqual(tokens.find(x => x.value === colons).scopes, + base.concat(['entity.other.attribute-name.' + row[1] + '.css', 'punctuation.definition.entity.css']), row[2]); + assert.deepStrictEqual(tokens.find(x => x.value === '(').scopes, + base.concat(['punctuation.section.function.begin.bracket.round.css']), row[2]); + assert.deepStrictEqual(tokens.find(x => x.value === ')').scopes, + base.concat(['punctuation.section.function.end.bracket.round.css']), row[2]); + }); + }); + + it('reads a comment inside every functional selector it adds', function () { + [ + ':state(/*c*/ a) {}', + '::part(/*c*/ a) {}', + '::highlight(/*c*/ a) {}', + '::view-transition-group(/*c*/ a) {}', + '::view-transition-old(/*c*/ a) {}', + '::scroll-button(/*c*/ up) {}' + ].forEach(function (line) { + var tokens = testGrammar.tokenizeLine(line).tokens; + assert.ok(tokens.some(t => t.value === 'c' && t.scopes.includes('comment.block.css')), + 'no comment in: ' + line); + }); + }); + + it('keeps ::slotted() open across a nested function and a string', function () { + [ + '::slotted(:not(.a)) .b {}', + '::slotted([title=")"]) .b {}' + ].forEach(function (line) { + var tokens = testGrammar.tokenizeLine(line).tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'b').scopes, [ + 'source.css', 'meta.selector.css', 'entity.other.attribute-name.class.css' + ], line); + }); + }); + + it('names the parity keywords in a child-indexed function', function () { + ['nth-child', 'nth-last-child', 'nth-of-type', 'nth-last-of-type'].forEach(function (fn) { + ['even', 'odd'].forEach(function (kw) { + var line = 'a:' + fn + '(' + kw + ') {}'; + var tokens = testGrammar.tokenizeLine(line).tokens; + assert.deepStrictEqual(tokens.find(x => x.value === kw).scopes, + ['source.css', 'meta.selector.css', 'support.constant.parity.css'], line); + }); + }); + }); + + it('takes an of-clause only in the child-indexed functions', function () { + // :nth-child() and :nth-last-child() take `of `, the typed + // variants do not. + [':nth-child(2 of .x)', ':nth-last-child(2 of .x)'].forEach(function (sel) { + var tokens = testGrammar.tokenizeLine('a' + sel + ' {}').tokens; + assert.ok(tokens.some(t => t.value === 'of' && t.scopes.includes('keyword.operator.logical.of.css')), sel); + assert.ok(tokens.some(t => t.value === 'x' && t.scopes.includes('entity.other.attribute-name.class.css')), sel); + }); + [':nth-of-type(2 of .x)', ':nth-last-of-type(2 of .x)'].forEach(function (sel) { + var tokens = testGrammar.tokenizeLine('a' + sel + ' {}').tokens; + assert.ok(!tokens.some(t => t.scopes.includes('keyword.operator.logical.of.css')), sel); + assert.ok(!tokens.some(t => t.value === 'x' && t.scopes.includes('entity.other.attribute-name.class.css')), sel); + }); + }); + + }); }); From fda961b61226fc68f36fc985299991fe66f97749 Mon Sep 17 00:00:00 2001 From: Kristofer Baxter Date: Tue, 1 Sep 2026 15:11:37 -0700 Subject: [PATCH 5/5] feat: Media Queries 5 interaction and user-preference features The media feature list stops at the features that were standard when it was written, so `@media (any-pointer: coarse)` and `@media (prefers-reduced-motion: reduce)` get no feature name scope and no value scope. This adds the interaction features, the user-preference features and the rest of what Media Queries 5 defines, and adds the viewport segment features as range features, since they take a count rather than a keyword. Each feature name is added together with the values Media Queries 5 defines for it, so that a name never arrives without its values. The value list is flat, as it already was, so a value is recognised in any feature rather than only in the one that defines it. `video-width`, `video-height` and `video-resolution` are deliberately absent. Media Queries 5 dropped all three, so the set here is the set the current draft still defines. Every feature name and every feature value added here is asserted, so that removing one from the grammar fails a test. --- grammars/css.cson | 50 +++++++++++++++++++ spec/css-spec.mjs | 120 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) diff --git a/grammars/css.cson b/grammars/css.cson index 9b5670f..6f219e9 100644 --- a/grammars/css.cson +++ b/grammars/css.cson @@ -2364,12 +2364,35 @@ | color-index | monochrome | resolution + | horizontal-viewport-segments + | vertical-viewport-segments ) | grid # Discrete features | scan | orientation | display-mode | hover + | any-hover # Interaction (Media Queries 4) + | pointer + | any-pointer + | update + | overflow-block + | overflow-inline + | prefers-color-scheme # User preference (Media Queries 5) + | prefers-contrast + | prefers-reduced-motion + | prefers-reduced-transparency + | prefers-reduced-data + | forced-colors + | inverted-colors + | dynamic-range + | video-dynamic-range + | scripting + | color-gamut + | video-color-gamut + | environment-blending + | nav-controls + | ua-color-scheme ) | # Deprecated features @@ -2430,6 +2453,33 @@ | minimal-ui | browser | hover + | none # Interaction / preference values + | coarse + | fine + | slow + | fast + | light # prefers-color-scheme + | dark + | no-preference # prefers-* neutral value + | reduce # prefers-reduced-* + | more # prefers-contrast + | less + | custom + | active # forced-colors + | enabled # scripting + | initial-only + | srgb # color-gamut + | p3 + | rec2020 + | standard # dynamic-range + | high + | scroll # overflow-block / overflow-inline + | paged + | inverted # inverted-colors + | back # nav-controls + | opaque # environment-blending + | additive + | subtractive ) (?=\\s|\\)|$) ''' diff --git a/spec/css-spec.mjs b/spec/css-spec.mjs index d67c557..02a56b3 100644 --- a/spec/css-spec.mjs +++ b/spec/css-spec.mjs @@ -5973,4 +5973,124 @@ describe('CSS grammar', function () { }); }); + + describe('media features', function () { + + function eachSel(cases, want) { + cases.forEach(function (pair) { + var tokens = testGrammar.tokenizeLine(pair[1]).tokens; + var token = tokens.find(x => x.value === pair[0]); + assert.ok(token, pair[0] + ' produced no token in: ' + pair[1]); + assert.deepStrictEqual(token.scopes, want, pair[1]); + }); + } + + it('recognises interaction media features', function () { + ['pointer', 'any-pointer', 'hover', 'any-hover', 'update', 'scripting'].forEach(function (mf) { + var tokens = testGrammar.tokenizeLine('@media (' + mf + ': none) {}').tokens; + var t = tokens.find(x => x.value === mf); + assert.deepStrictEqual(t.scopes, ['source.css', 'meta.at-rule.media.header.css', 'support.type.property-name.media.css'], mf); + }); + }); + + it('recognises user-preference media features', function () { + ['prefers-color-scheme', 'prefers-contrast', 'prefers-reduced-motion', 'forced-colors', 'dynamic-range'].forEach(function (mf) { + var tokens = testGrammar.tokenizeLine('@media (' + mf + ': none) {}').tokens; + var t = tokens.find(x => x.value === mf); + assert.deepStrictEqual(t.scopes, ['source.css', 'meta.at-rule.media.header.css', 'support.type.property-name.media.css'], mf); + }); + }); + + it('treats viewport segments as range features', function () { + [ + 'horizontal-viewport-segments', + 'min-horizontal-viewport-segments', + 'max-horizontal-viewport-segments', + 'vertical-viewport-segments', + 'min-vertical-viewport-segments', + 'max-vertical-viewport-segments' + ].forEach(function (mf) { + var tokens = testGrammar.tokenizeLine('@media (' + mf + ': 2) {}').tokens; + var t = tokens.find(x => x.value === mf); + assert.deepStrictEqual(t.scopes, ['source.css', 'meta.at-rule.media.header.css', 'support.type.property-name.media.css'], mf); + }); + + var tokens = testGrammar.tokenizeLine('@media (horizontal-viewport-segments > 1) {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === '>').scopes, ['source.css', 'meta.at-rule.media.header.css', 'keyword.operator.comparison.css']); + }); + + it('recognises the values of each discrete media feature it names', function () { + // A feature name without its values is only half of the feature, + // so every name this change adds is paired with its values. + [ + ['overflow-block', 'paged'], + ['overflow-block', 'scroll'], + ['overflow-inline', 'scroll'], + ['inverted-colors', 'inverted'], + ['nav-controls', 'back'], + ['video-color-gamut', 'p3'], + ['ua-color-scheme', 'dark'], + ['environment-blending', 'opaque'], + ['environment-blending', 'additive'], + ['environment-blending', 'subtractive'], + ['scripting', 'initial-only'], + ['update', 'slow'], + ['prefers-color-scheme', 'dark'], + ['prefers-reduced-motion', 'reduce'], + ['prefers-contrast', 'more'], + ['forced-colors', 'active'], + ['color-gamut', 'p3'], + ['dynamic-range', 'high'] + ].forEach(function (pair) { + var tokens = testGrammar.tokenizeLine('@media (' + pair[0] + ': ' + pair[1] + ') {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === pair[0]).scopes, ['source.css', 'meta.at-rule.media.header.css', 'support.type.property-name.media.css'], pair.join(': ')); + assert.deepStrictEqual(tokens.find(x => x.value === pair[1]).scopes, ['source.css', 'meta.at-rule.media.header.css', 'support.constant.property-value.css'], pair.join(': ')); + }); + }); + + it('names every media feature it adds', function () { + eachSel([ + ['horizontal-viewport-segments', '@media (horizontal-viewport-segments: none) {}'], + ['any-pointer', '@media (any-pointer: none) {}'], + ['update', '@media (update: none) {}'], + ['prefers-contrast', '@media (prefers-contrast: none) {}'], + ['video-color-gamut', '@media (video-color-gamut: srgb) {}'], + ['ua-color-scheme', '@media (ua-color-scheme: light) {}'], + ['prefers-reduced-motion', '@media (prefers-reduced-motion: none) {}'], + ['prefers-reduced-transparency', '@media (prefers-reduced-transparency: none) {}'], + ['prefers-reduced-data', '@media (prefers-reduced-data: none) {}'], + ['forced-colors', '@media (forced-colors: none) {}'], + ['inverted-colors', '@media (inverted-colors: none) {}'], + ['dynamic-range', '@media (dynamic-range: none) {}'], + ['video-dynamic-range', '@media (video-dynamic-range: none) {}'], + ['scripting', '@media (scripting: none) {}'], + ['color-gamut', '@media (color-gamut: none) {}'], + ['environment-blending', '@media (environment-blending: none) {}'] + ], ['source.css', 'meta.at-rule.media.header.css', 'support.type.property-name.media.css']); + }); + + it('recognises every media feature value it adds', function () { + eachSel([ + ['no-preference', '@media (prefers-reduced-motion: no-preference) {}'], + ['coarse', '@media (any-pointer: coarse) {}'], + ['fine', '@media (any-pointer: fine) {}'], + ['slow', '@media (update: slow) {}'], + ['fast', '@media (update: fast) {}'], + ['more', '@media (prefers-contrast: more) {}'], + ['less', '@media (prefers-contrast: less) {}'], + ['custom', '@media (prefers-contrast: custom) {}'], + ['initial-only', '@media (scripting: initial-only) {}'], + ['rec2020', '@media (color-gamut: rec2020) {}'], + ['standard', '@media (dynamic-range: standard) {}'], + ['high', '@media (dynamic-range: high) {}'], + ['paged', '@media (overflow-block: paged) {}'], + ['enabled', '@media (scripting: enabled) {}'], + ['light', '@media (prefers-color-scheme: light) {}'], + ['active', '@media (forced-colors: active) {}'], + ['none', '@media (forced-colors: none) {}'], + ['srgb', '@media (color-gamut: srgb) {}'] + ], ['source.css', 'meta.at-rule.media.header.css', 'support.constant.property-value.css']); + }); + + }); });