diff --git a/grammars/css.cson b/grammars/css.cson index 039f54b..e16afa4 100644 --- a/grammars/css.cson +++ b/grammars/css.cson @@ -644,6 +644,255 @@ } ] }, + { + # @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' + } + ] + } + ] + } + { + # @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) (?`, 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) (?` 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]); + }); + }); + + }); + + describe('@property, @scope and @starting-style', function () { + it('consumes the whitespace that terminates a hex escape in a custom property name', function () { + // `\\31 foo` is the single identifier `--1foo`: the space ends the hex + // escape rather than ending the name. + var tokens = testGrammar.tokenizeLine('@property --\\31 foo { syntax: "*"; }').tokens; + var tail = tokens.find(t => 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']); + }); + }); + + 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]); + }); + }); + + }); });