diff --git a/grammars/css.cson b/grammars/css.cson index 039f54b..3c27063 100644 --- a/grammars/css.cson +++ b/grammars/css.cson @@ -318,9 +318,9 @@ 'name': 'support.function.document-rule.css' '2': 'name': 'punctuation.section.function.begin.bracket.round.css' - 'end': '\\)' + 'end': '(\\))|(?=\\{(?:[^)/"\\x27\\\\]|/(?!\\*)|/\\*(?:[^*]|\\*(?!/))*\\*/|/\\*(?:[^*]|\\*(?!/))*$|"(?:[^"\\\\]|\\\\.)*"|"(?:[^"\\\\]|\\\\.)*(?:\\\\)?$|\\x27(?:[^\\x27\\\\]|\\\\.)*\\x27|\\x27(?:[^\\x27\\\\]|\\\\.)*(?:\\\\)?$|\\\\.|\\\\$)*$)' 'endCaptures': - '0': + '1': 'name': 'punctuation.section.function.end.bracket.round.css' 'name': 'meta.function.document-rule.css' 'patterns': [ @@ -644,6 +644,218 @@ } ] }, + { + # @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' + } + { + 'match': '(?i)(?<=\\s|^|\\)|\\*/)to(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.scope.css' + } + { + 'begin': '\\(' + 'beginCaptures': + '0': + 'name': 'punctuation.definition.parameters.begin.bracket.round.css' + # Bail out at a `{` that nothing closes later on the line, so + # an unclosed limit cannot swallow the rest of the stylesheet. + # `;` is deliberately not a bail-out: `` permits a + # top-level one, and a prelude may span lines, so a `;` here is + # not evidence of a mistake. + 'end': '(\\))|(?=\\{(?:[^)/"\\x27\\\\]|/(?!\\*)|/\\*(?:[^*]|\\*(?!/))*\\*/|/\\*(?:[^*]|\\*(?!/))*$|"(?:[^"\\\\]|\\\\.)*"|"(?:[^"\\\\]|\\\\.)*(?:\\\\)?$|\\x27(?:[^\\x27\\\\]|\\\\.)*\\x27|\\x27(?:[^\\x27\\\\]|\\\\.)*(?:\\\\)?$|\\\\.|\\\\$)*$)' + 'endCaptures': + '1': + 'name': 'punctuation.definition.parameters.end.bracket.round.css' + 'name': 'meta.scope.limit.css' + 'patterns': [ + { + 'include': '#selector-innards' + } + ] + } + ] + } + { + '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' + } + { + 'captures': + '0': + 'patterns': [ + { + 'include': '#escapes' + } + ] + 'match': '''(?x) (?` admits a balanced curly + # block that legally spans lines, as in `--x: --foo({ ... });`. The legal + # `--foo({` and the malformed `--foo(a{` are indistinguishable within a + # single line, so recovering here would cost legal CSS. + 'end': '(\\))' 'endCaptures': - '0': + '1': 'name': 'punctuation.section.function.end.bracket.round.css' 'name': 'meta.function.custom.css' 'patterns': [ @@ -1180,6 +1472,116 @@ } ] } + # Environment variables + { + 'begin': '(?i)(? (?:[-a-zA-Z_] | [^\\x00-\\x7F] # First letter + |\\\\(?:[0-9a-fA-F]{1,6}[ \\t]?|[^\\n]) + ) + (?:[-a-zA-Z0-9_] | [^\\x00-\\x7F] # Remainder + |\\\\(?:[0-9a-fA-F]{1,6}[ \\t]?|[^\\n]) + )* + ) + (?!\\() # A function is not a name + ) + ) + | (?=[,;{})]) # Malformed: give up on a name + ''' + 'endCaptures': + '1': + 'name': 'support.constant.property-value.css' + '2': + 'name': 'variable.argument.css' + 'patterns': [ + { + 'include': '#comment-block' + } + { + 'include': '#numeric-values' # Indices, e.g. env(viewport-segment-width 0 0) + } + { + 'include': '#property-values' # Anything that is not a name: strings, + } # nested functions, malformed arguments + ] + } + { + 'include': '#property-values' + } + ] + } + # Anchor positioning + { + 'begin': '(?i)(? + | width|height|block|inline + | self-block|self-inline # + ) + (?![\\w-]) + ''' + 'name': 'support.constant.property-value.css' + } + { + 'include': '#property-values' + } + ] + } # Variable expansion { 'begin': '(?i)(?` syntax, e.g. :nth-child(2 of .visible) + 'begin': '(?i)(?<=[\\s)])(of)(?=[\\s(]|$)' + 'beginCaptures': + '1': + 'name': 'keyword.operator.logical.of.css' + 'end': '(?=\\)|\\{(?:[^)/"\\x27\\\\]|/(?!\\*)|/\\*(?:[^*]|\\*(?!/))*\\*/|/\\*(?:[^*]|\\*(?!/))*$|"(?:[^"\\\\]|\\\\.)*"|"(?:[^"\\\\]|\\\\.)*(?:\\\\)?$|\\x27(?:[^\\x27\\\\]|\\\\.)*\\x27|\\x27(?:[^\\x27\\\\]|\\\\.)*(?:\\\\)?$|\\\\.|\\\\$)*$)' + 'patterns': [ + { + 'include': '#selector-innards' + } + ] + } + ] + } + { + # :state() takes a custom identifier, not a selector list + 'begin': '(?i)((:)state)(\\()' + 'beginCaptures': + '1': + 'name': 'entity.other.attribute-name.pseudo-class.css' + '2': + 'name': 'punctuation.definition.entity.css' + '3': + 'name': 'punctuation.section.function.begin.bracket.round.css' + 'end': '(\\))|(?=\\{(?:[^)/"\\x27\\\\]|/(?!\\*)|/\\*(?:[^*]|\\*(?!/))*\\*/|/\\*(?:[^*]|\\*(?!/))*$|"(?:[^"\\\\]|\\\\.)*"|"(?:[^"\\\\]|\\\\.)*(?:\\\\)?$|\\x27(?:[^\\x27\\\\]|\\\\.)*\\x27|\\x27(?:[^\\x27\\\\]|\\\\.)*(?:\\\\)?$|\\\\.|\\\\$)*$)' + 'endCaptures': + '1': + 'name': 'punctuation.section.function.end.bracket.round.css' + 'patterns': [ + { + 'captures': + '0': + 'patterns': [ + { + 'include': '#escapes' + } + ] + 'match': '''(?x) + (?:[-a-zA-Z_] | [^\\x00-\\x7F]) # First letter + (?:[-a-zA-Z0-9_] | [^\\x00-\\x7F] # Remainder of identifier + |\\\\(?:[0-9a-fA-F]{1,6}|.) + )* + ''' + 'name': 'variable.parameter.state-name.css' + } + { + 'include': '#comment-block' + } + ] + } + ] + 'functional-pseudo-elements': + 'patterns': [ + { + # ::part() and ::highlight() take custom identifiers + 'begin': '(?i)((::)(?:part|highlight))(\\()' + 'beginCaptures': + '1': + 'name': 'entity.other.attribute-name.pseudo-element.css' + '2': + 'name': 'punctuation.definition.entity.css' + '3': + 'name': 'punctuation.section.function.begin.bracket.round.css' + 'end': '(\\))|(?=\\{(?:[^)/"\\x27\\\\]|/(?!\\*)|/\\*(?:[^*]|\\*(?!/))*\\*/|/\\*(?:[^*]|\\*(?!/))*$|"(?:[^"\\\\]|\\\\.)*"|"(?:[^"\\\\]|\\\\.)*(?:\\\\)?$|\\x27(?:[^\\x27\\\\]|\\\\.)*\\x27|\\x27(?:[^\\x27\\\\]|\\\\.)*(?:\\\\)?$|\\\\.|\\\\$)*$)' + 'endCaptures': + '1': + 'name': 'punctuation.section.function.end.bracket.round.css' + 'patterns': [ + { + 'include': '#comment-block' + } + { + 'captures': + '0': + 'patterns': [ + { + 'include': '#escapes' + } + ] + 'match': '''(?x) + (?:[-a-zA-Z_] | [^\\x00-\\x7F]) # First letter + (?:[-a-zA-Z0-9_] | [^\\x00-\\x7F] # Remainder of identifier + |\\\\(?:[0-9a-fA-F]{1,6}|.) + )* + ''' + 'name': 'variable.parameter.pseudo-element.css' + } + ] + } + { + # View-transition pseudo-elements take a custom identifier or wildcard + 'begin': '(?i)((::)(?:view-transition-group|view-transition-image-pair|view-transition-new|view-transition-old))(\\()' + 'beginCaptures': + '1': + 'name': 'entity.other.attribute-name.pseudo-element.css' + '2': + 'name': 'punctuation.definition.entity.css' + '3': + 'name': 'punctuation.section.function.begin.bracket.round.css' + 'end': '(\\))|(?=\\{(?:[^)/"\\x27\\\\]|/(?!\\*)|/\\*(?:[^*]|\\*(?!/))*\\*/|/\\*(?:[^*]|\\*(?!/))*$|"(?:[^"\\\\]|\\\\.)*"|"(?:[^"\\\\]|\\\\.)*(?:\\\\)?$|\\x27(?:[^\\x27\\\\]|\\\\.)*\\x27|\\x27(?:[^\\x27\\\\]|\\\\.)*(?:\\\\)?$|\\\\.|\\\\$)*$)' + 'endCaptures': + '1': + 'name': 'punctuation.section.function.end.bracket.round.css' + 'patterns': [ + { + 'include': '#comment-block' + } + { + 'match': '\\*' + 'name': 'entity.name.tag.wildcard.css' } { - 'include': '#commas' + 'captures': + '0': + 'patterns': [ + { + 'include': '#escapes' + } + ] + 'match': '''(?x) + (?:[-a-zA-Z_] | [^\\x00-\\x7F]) # First letter + (?:[-a-zA-Z0-9_] | [^\\x00-\\x7F] # Remainder of identifier + |\\\\(?:[0-9a-fA-F]{1,6}|.) + )* + ''' + 'name': 'variable.parameter.pseudo-element.css' } ] } { - # Logical / Selector-based - 'begin': '(?i)((:)(?:not|has|matches|where|is))(\\()' + # ::scroll-button() takes a physical or logical direction + 'begin': '(?i)((::)scroll-button)(\\()' 'beginCaptures': '1': - 'name': 'entity.other.attribute-name.pseudo-class.css' + 'name': 'entity.other.attribute-name.pseudo-element.css' '2': 'name': 'punctuation.definition.entity.css' '3': 'name': 'punctuation.section.function.begin.bracket.round.css' - 'end': '\\)' + 'end': '(\\))|(?=\\{(?:[^)/"\\x27\\\\]|/(?!\\*)|/\\*(?:[^*]|\\*(?!/))*\\*/|/\\*(?:[^*]|\\*(?!/))*$|"(?:[^"\\\\]|\\\\.)*"|"(?:[^"\\\\]|\\\\.)*(?:\\\\)?$|\\x27(?:[^\\x27\\\\]|\\\\.)*\\x27|\\x27(?:[^\\x27\\\\]|\\\\.)*(?:\\\\)?$|\\\\.|\\\\$)*$)' 'endCaptures': - '0': + '1': 'name': 'punctuation.section.function.end.bracket.round.css' 'patterns': [ { - 'include': '#selector-innards' + 'include': '#comment-block' + } + { + 'match': '\\*' + 'name': 'entity.name.tag.wildcard.css' + } + { + 'match': '(?i)(?` permits a top-level one, + # and a prelude may span lines, so a `;` here is not evidence of a + # mistake. + 'end': '(\\))|(?=\\{(?:[^)/"\\x27\\\\]|/(?!\\*)|/\\*(?:[^*]|\\*(?!/))*\\*/|/\\*(?:[^*]|\\*(?!/))*$|"(?:[^"\\\\]|\\\\.)*"|"(?:[^"\\\\]|\\\\.)*(?:\\\\)?$|\\x27(?:[^\\x27\\\\]|\\\\.)*\\x27|\\x27(?:[^\\x27\\\\]|\\\\.)*(?:\\\\)?$|\\\\.|\\\\$)*$)' + 'endCaptures': + '1': + 'name': 'punctuation.section.function.end.bracket.round.css' + 'name': 'meta.function.style.css' + 'patterns': [ + { + # Local copy of the declaration-value region from #rule-list-innards, + # which ends only at `;`, `}` or `)`. A `{` that truncates the query + # therefore stays inside the value there, hiding it from the + # enclosing bail-out lookahead, which can then only fire at end of + # line -- and vscode-textmate pops one rule per line boundary, so + # the following rule is left inside the unclosed query. This copy + # releases at that `{` instead. The same lexical bail-out applies, + # so a balanced `{...}` block in a custom property value, which + # #property-values handles, is still left intact. + 'begin': '(:)\\s*' + 'beginCaptures': + '1': + 'name': 'punctuation.separator.key-value.css' + 'end': '\\s*(?=[})])|\\s*(?=\\{(?:[^)/"\\x27\\\\]|/(?!\\*)|/\\*(?:[^*]|\\*(?!/))*\\*/|/\\*(?:[^*]|\\*(?!/))*$|"(?:[^"\\\\]|\\\\.)*"|"(?:[^"\\\\]|\\\\.)*(?:\\\\)?$|\\x27(?:[^\\x27\\\\]|\\\\.)*\\x27|\\x27(?:[^\\x27\\\\]|\\\\.)*(?:\\\\)?$|\\\\.|\\\\$)*$)' + 'contentName': 'meta.property-value.css' + 'patterns': [ + { + 'include': '#comment-block' + } + { + 'include': '#property-values' + } + ] + } + { + 'include': '#rule-list-innards' + } + ] + } + { + # Scroll-state queries, e.g. @container scroll-state(stuck: top) + 'begin': '(?i)(?` permits a top-level one, + # and a prelude may span lines, so a `;` here is not evidence of a + # mistake. + 'end': '(\\))|(?=\\{(?:[^)/"\\x27\\\\]|/(?!\\*)|/\\*(?:[^*]|\\*(?!/))*\\*/|/\\*(?:[^*]|\\*(?!/))*$|"(?:[^"\\\\]|\\\\.)*"|"(?:[^"\\\\]|\\\\.)*(?:\\\\)?$|\\x27(?:[^\\x27\\\\]|\\\\.)*\\x27|\\x27(?:[^\\x27\\\\]|\\\\.)*(?:\\\\)?$|\\\\.|\\\\$)*$)' + 'endCaptures': + '1': + 'name': 'punctuation.section.function.end.bracket.round.css' + 'name': 'meta.function.scroll-state.css' + 'patterns': [ + { + 'include': '#scroll-state-condition' + } + ] + } + { + 'begin': '\\(' + 'beginCaptures': + '0': + 'name': 'punctuation.definition.parameters.begin.bracket.round.css' + # Bail out at a `{` that nothing closes later on the line, so an + # unclosed query cannot swallow the rest of the stylesheet. `;` is + # deliberately not a bail-out: `` permits a top-level one, + # and a prelude may span lines, so a `;` here is not evidence of a + # mistake. + 'end': '(\\))|(?=\\{(?:[^)/"\\x27\\\\]|/(?!\\*)|/\\*(?:[^*]|\\*(?!/))*\\*/|/\\*(?:[^*]|\\*(?!/))*$|"(?:[^"\\\\]|\\\\.)*"|"(?:[^"\\\\]|\\\\.)*(?:\\\\)?$|\\x27(?:[^\\x27\\\\]|\\\\.)*\\x27|\\x27(?:[^\\x27\\\\]|\\\\.)*(?:\\\\)?$|\\\\.|\\\\$)*$)' + 'endCaptures': + '1': + 'name': 'punctuation.definition.parameters.end.bracket.round.css' + 'patterns': [ + { + 'include': '#size-container-features' + } + { + 'match': '(?i)(?<=\\s|^|\\*/|\\()(and|or|not)(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.$1.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' + } + { + 'include': '#media-feature-keywords' + } + { + 'include': '#numeric-values' + } + { + 'include': '#comment-block' + } + { + 'include': '#functions' + } + ] + } + { + # Optional container name preceding the query, e.g. @container card (…) + 'captures': + '0': + 'patterns': [ + { + 'include': '#escapes' + } + ] + 'match': '''(?xi) + (?<=^|\\s|\\*/) + (?!(?:none|and|or|not|style)(?![\\w-])) # Not a reserved keyword or style() query + (?:[-a-zA-Z_] | [^\\x00-\\x7F]) # First letter + (?:[-a-zA-Z0-9_] | [^\\x00-\\x7F] # Remainder of identifier + |\\\\(?:[0-9a-fA-F]{1,6}|.) + )* + (?![\\w(-]) # Not a function call + ''' + 'name': 'variable.parameter.container-name.css' + } + ] + 'size-container-features': + 'captures': + '1': + 'name': 'support.type.property-name.container.css' + 'match': '''(?xi) + (?<=^|\\s|\\(|\\*/) # Preceded by whitespace, bracket or comment + ( + (?:min-|max-)? # Range features + (?: width + | height + | inline-size + | block-size + | aspect-ratio + ) + | orientation # Discrete features + ) + (?=\\s|$|[><:=]|\\)|/\\*) # Terminates cleanly + ''' + 'scroll-state-condition': + 'patterns': [ + { + 'include': '#comment-block' + } + { + 'begin': '\\(' + 'beginCaptures': + '0': + 'name': 'punctuation.definition.parameters.begin.bracket.round.css' + 'end': '(\\))|(?=\\{(?:[^)/"\\x27\\\\]|/(?!\\*)|/\\*(?:[^*]|\\*(?!/))*\\*/|/\\*(?:[^*]|\\*(?!/))*$|"(?:[^"\\\\]|\\\\.)*"|"(?:[^"\\\\]|\\\\.)*(?:\\\\)?$|\\x27(?:[^\\x27\\\\]|\\\\.)*\\x27|\\x27(?:[^\\x27\\\\]|\\\\.)*(?:\\\\)?$|\\\\.|\\\\$)*$)' + 'endCaptures': + '1': + 'name': 'punctuation.definition.parameters.end.bracket.round.css' + 'patterns': [ + { + 'include': '#scroll-state-condition' + } + ] + } + { + 'match': '(?i)(?<=\\s|^|\\*/|\\()(and|or|not)(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.$1.container.css' + } + { + 'begin': '(?i)(?\n [}~" + ('ÁÂÃÄÅÆÇÈÊËÍÎ'.repeat(100)) + "\n"); assert.ok(Date.now() - start < 5000); }); + + it("tokenizes long function-like color arguments in linear time", function () { + var start; + start = Date.now(); + testGrammar.tokenizeLine('a { color: rgb(' + 'a'.repeat(20000) + '() }'); + assert.ok(Date.now() - start < 5000); + }); + + it("tokenizes long function-like container names in linear time", function () { + var start; + start = Date.now(); + testGrammar.tokenizeLine('@container ' + 'a'.repeat(20000) + '() {'); + assert.ok(Date.now() - start < 5000); + }); }); // Skipped because `firstLineRegex.scanner` does not exist in `vscode-textmate` @@ -3864,4 +3878,1053 @@ describe('CSS grammar', function () { assert.deepStrictEqual(tokens[22], { scopes: ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.misc.css', 'punctuation.section.function.end.bracket.round.css'], value: ')' }); }); }); + + describe('CSS at-rule coverage', function () { + 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 @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 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 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 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('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('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('supports comments between scroll-state conditions', function () { + var tokens = testGrammar.tokenizeLine('@container scroll-state(stuck: top/* c */and (snapped: x)) {').tokens; + 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('does not match scroll-state feature name prefixes', function () { + ['stuckand', 'snappedor'].forEach(function (identifier) { + var tokens = testGrammar.tokenizeLine('@container scroll-state(' + identifier + ') {').tokens; + assert.ok(!tokens.find(x => x.scopes.includes('support.type.property-name.container.css')), identifier); + }); + }); + + it('does not tokenize reserved container names', function () { + var tokens = testGrammar.tokenizeLine('@container none (width > 10px) {').tokens; + assert.ok(!tokens.find(x => x.value === 'none' && x.scopes.includes('variable.parameter.container-name.css'))); + }); + + 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('recovers from semicolon-terminated block at-rules', function () { + [ + '@container (width > 1px);', + '@container;', + '@scope (.x);', + '@scope;', + '@starting-style ;', + '@starting-style;', + '@property --theme;', + '@property;' + ].forEach(function (atRule) { + var lines = testGrammar.tokenizeLines(atRule + '\n.after { color: red; }'); + assert.deepStrictEqual(lines[1].find(x => x.value === 'after').scopes, ['source.css', 'meta.selector.css', 'entity.other.attribute-name.class.css'], atRule); + }); + }); + + 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' }); + assert.deepStrictEqual(tokens[4], { scopes: ['source.css', 'meta.at-rule.scope.header.css', 'meta.scope.limit.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.limit.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']); + }); + + 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('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('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('recovers from an unclosed parenthesis in a @container or @scope prelude', function () { + // The parenthesised regions used to end only on `)`, so an unclosed + // prelude leaked its header scope to the end of the document. The + // bail-out fires on a `{` that nothing closes later on the line. A + // `;` is deliberately *not* a bail-out: `` permits a + // top-level one, so treating it as an error breaks legal syntax. + [ + ['@container (width > 1px{', ['source.css', 'meta.at-rule.container.body.css']], + ['@container style(--theme: dark{', ['source.css', 'meta.at-rule.container.body.css']], + ['@container scroll-state(stuck: top{', ['source.css', 'meta.at-rule.container.body.css']], + ['@scope (:is(.a){', ['source.css', 'meta.at-rule.scope.body.css']], + ['@container (width > 1px', ['source.css']], + ['@container scroll-state(stuck: top', ['source.css']], + ['@scope (:is(.a)', ['source.css']] + ].forEach(function (probe) { + var source = probe[0] + '\n.after { color: red; }'; + assert.deepStrictEqual(testGrammar.scopeStackAtEnd(source), probe[1], probe[0]); + }); + }); + + it('keeps a balanced block in a style() query value out of the bail-out', function () { + // A custom property value may legally contain a `{...}` block. An + // earlier revision of the bail-out released on `{`, which ended the + // query here and painted the rest of the file as an at-rule body. + ['@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 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('recovers a style() query that is cut off by an unclosed brace', function () { + // `{` ends the query only when it ends the line, so the brace that + // follows a truncated query opens the container body rather than + // being absorbed into the value. + var lines = testGrammar.tokenizeLines('@container style(--theme: dark{\n.after { color: red; }\n}'); + assert.deepStrictEqual(lines[0].find(t => t.value === '{').scopes, ['source.css', 'meta.at-rule.container.body.css', 'punctuation.section.container.begin.bracket.curly.css']); + assert.deepStrictEqual(lines[1].find(t => t.value === 'after').scopes, ['source.css', 'meta.at-rule.container.body.css', 'meta.selector.css', 'entity.other.attribute-name.class.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. Recovery must therefore never fire on + // `;` at all, and must fire on `{` only when the `{` ends the line, + // or it breaks legal forward-compatible syntax. + [ + ['@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 brace that the query itself closes, in every guarded region', function () { + // One case per region carrying the `[^)]*$` guard, so that dropping + // the guard from any one of them fails a test. A `{` closed later + // on the same line belongs to the query, not to the rule that + // follows it. + [ + ['@scope (.a) to (.b { c })', 'scope'], + ['@scope (.a { b })', 'scope'], + ['@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('recovers every scroll-state() condition that is cut off by a brace', function () { + // `stuck`, `snapped` and `scrollable` each have their own condition + // region, so each needs its own bail-out. + ['stuck: top', 'snapped: x', 'scrollable: x'].forEach(function (condition) { + var lines = testGrammar.tokenizeLines('@container scroll-state(' + condition + '{\n.after { color: red; }\n}'); + assert.deepStrictEqual(lines[0].find(t => t.value === '{').scopes, ['source.css', 'meta.at-rule.container.body.css', 'punctuation.section.container.begin.bracket.curly.css'], condition); + assert.deepStrictEqual(lines[1].find(t => t.value === 'after').scopes, ['source.css', 'meta.at-rule.container.body.css', 'meta.selector.css', 'entity.other.attribute-name.class.css'], condition); + }); + }); + + it('does not bail out of a prelude that legally spans lines', function () { + // The bail-out is line-local, so a `;` inside a prelude whose `)` + // only arrives on a later line is the case it can most easily get + // wrong. `` permits a top-level `;` and + // `` permits one inside any balanced block, so + // none of these may end the query early. + [ + '@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('recovers when the unclosed parenthesis is a nested one', function () { + // The guard on an outer prelude region cannot fire while an inner + // parenthesised rule is still active, so every region reachable + // inside a prelude needs it too. Covering only outer parentheses + // hid this: each of these leaked the rest of the stylesheet into + // the at-rule header while the outer-paren cases all passed. + [ + '@container scroll-state((stuck: top{', + '@container (width > calc(10px{', + '@container (width > calc(10px + 2em{', + '@scope (:is(.a{', + '@scope (.a) to (:where(.b{', + '@scope (:nth-child(2 of .special{', + '@container (width > 10px) and (height < calc(2em{' + ].forEach(function (prelude) { + var lines = testGrammar.tokenizeLines(prelude + '\n.after { color: red; }'); + var token = lines[1].find(t => t.value === 'after'); + assert.ok(token, prelude + ' -> .after missing'); + token.scopes.forEach(function (scope) { + assert.ok(!['header', 'meta.function', 'scope.limit'].some(l => scope.includes(l)), + prelude + ' -> leaked ' + scope); + }); + assert.deepStrictEqual( + lines[1].find(t => t.value === '{').scopes.slice(-2), + ['meta.property-list.css', 'punctuation.section.property-list.begin.bracket.curly.css'], + prelude + ' -> brace'); + }); + }); + + it('recovers for every guarded region reachable inside a prelude', function () { + // Mutating each guard site individually showed which sites the + // suite actually pins; these cover the rest. Of the 36 sites, 35 + // are caught by a failing test, and the one survivor, + // `@document`'s argument functions, recovers no better with the + // guard than without it, exactly as on main. + // Value functions are reached through `@container style()`, + // selectors through `@scope`. + [ + '@container style(--x: layer(a{', + '@container style(--x: rgb(255{', + '@container style(--x: oklch(50%{', + '@container style(--x: linear-gradient(red{', + '@container style(--x: -webkit-gradient(linear{', + '@container style(--x: polygon(0 0{', + '@container style(--x: cubic-bezier(0,0{', + '@container style(--x: steps(2{', + '@container style(--x: anchor(top{', + '@container style(--x: anchor-size(width{', + '@container style(--x: clamp(1px{', + '@container style(--x: translate(1px{', + '@scope (:dir(ltr{', + '@scope (:state(foo{', + '@scope (::part(box{', + '@scope (::highlight(h{', + '@scope (::view-transition-group(g{', + '@scope (::scroll-button(up{', + '@scope (::slotted(a{', + '@media (width >= 600px{', + '@media screen and (min-width: 40em{', + '@supports (width > 1px{', + '@supports (a{', + '@container style(--x: url({' + ].forEach(function (prelude) { + var lines = testGrammar.tokenizeLines(prelude + '\n.after { color: red; }'); + var token = lines[1].find(t => t.value === 'after'); + assert.ok(token, prelude + ' -> .after missing'); + token.scopes.forEach(function (scope) { + assert.ok(!['header', 'meta.function', 'scope.limit'].some(l => scope.includes(l)), + prelude + ' -> leaked ' + scope); + }); + }); + }); + + it('bounds an unterminated language range to its own line', function () { + // `:lang()` carries its own local string rules; without the + // end-of-line fallback the shared `#string` rule already uses, an + // unterminated range stayed open and scoped every following line + // as string content to the end of the file. (`:dir()` takes only + // `ltr`/`rtl` and has no string region, so it is unaffected.) + ['"', "'"].forEach(function (quote) { + var lines = testGrammar.tokenizeLines('@scope (:lang(' + quote + 'en\n.after { color: red; }'); + lines[1].forEach(function (token) { + token.scopes.forEach(function (scope) { + assert.ok(!scope.includes('string.quoted'), + quote + ' -> ' + JSON.stringify(token.value) + ' leaked ' + scope); + }); + }); + assert.deepStrictEqual( + lines[1].find(t => t.value === 'color').scopes.slice(-1), + ['support.type.property-name.css'], + quote + ' -> declaration not recognised on the following line'); + }); + }); + + it('keeps a var() fallback that legally contains a curly block intact', function () { + // `` admits a balanced block, so `var()` is + // deliberately left without the `{` bail-out: protecting this legal + // value is worth losing recovery on `var(--y{`, which `origin/main` + // does not recover either. + var lines = testGrammar.tokenizeLines('a {\n --t: var(--fb, {\n color: red;\n });\n}'); + assert.deepStrictEqual(lines[1].find(t => t.value === '{').scopes, [ + 'source.css', + 'meta.property-list.css', + 'meta.property-value.css', + 'meta.function.variable.css', + 'punctuation.section.group.begin.bracket.curly.css' + ]); + assert.deepStrictEqual(lines[3].find(t => t.value === ')').scopes, [ + 'source.css', + 'meta.property-list.css', + 'meta.property-value.css', + 'meta.function.variable.css', + 'punctuation.section.function.end.bracket.round.css' + ]); + }); + + it('keeps a custom function argument that legally spans a curly block intact', function () { + // Same trade-off as `var()`. A custom function call is a declaration + // value, so a balanced block inside it may legally span lines. + // `origin/main` scopes this correctly and the guard had regressed it. + var lines = testGrammar.tokenizeLines('.foo {\n --x: --foo({\n color: red;\n });\n}'); + assert.deepStrictEqual(lines[1].find(t => t.value === '{').scopes, [ + 'source.css', + 'meta.property-list.css', + 'meta.property-value.css', + 'meta.function.custom.css', + 'punctuation.section.group.begin.bracket.curly.css' + ]); + assert.deepStrictEqual(lines[3].find(t => t.value === ')').scopes, [ + 'source.css', + 'meta.property-list.css', + 'meta.property-value.css', + 'meta.function.custom.css', + 'punctuation.section.function.end.bracket.round.css' + ]); + }); + + it('recovers when a closing parenthesis is shielded by a comment, string or escape', function () { + // The bail-out asks whether the rest of the line closes the + // parenthesis. A `)` inside a comment, string or escape does not, + // so it must not suppress recovery. `origin/main` recovers the + // first three of these because its generic header ends at `{`. + [ + '@container (width > 1px{ /* ) */', + '@media (min-width: 40em{ /* note (x) */', + '@container (width > 1px{ \\)', + '@container (width > 1px{ "x)"', + "@container (width > 1px{ 'x)'", + '@supports (a{ /* ) */', + // The scroll-state features and the `of` clause reach the brace + // through a lookahead-only `end`, so they need the same bail-out. + '@container scroll-state(stuck: top{ /* ) */', + '@container scroll-state(snapped: x{ "x)"', + '@container scroll-state(scrollable: top{ \\)', + '@scope (:nth-child(2 of .x{ /* ) */' + ].forEach(function (prelude) { + var lines = testGrammar.tokenizeLines(prelude + '\n.after { color: red; }'); + var token = lines[1].find(t => t.value === 'after'); + assert.ok(token, prelude + ' -> .after missing'); + token.scopes.forEach(function (scope) { + assert.ok(!['header', 'meta.function', 'scope.limit'].some(l => scope.includes(l)), + prelude + ' -> leaked ' + scope); + }); + }); + }); + + it('recovers when the prelude line ends in a line-continuation backslash', function () { + // A trailing backslash is not a complete escape, so it matched no + // alternative in the bail-out. The newline-escape rule then stayed + // active and held the prelude open across the line boundary. + [ + '@container (width > 1px{ \\', + '@scope (:nth-child(2 of .x{ \\', + // The same incomplete escape inside an unterminated string. + // The string alternatives accepted only complete escapes, so + // an odd number of trailing backslashes matched nothing. + '@scope (.a{ "x\\', + '@scope (.a{ \'x\\', + '@container (width > 1px{ "x\\\\\\', + '@container (width > 1px{ "x\\' + ].forEach(function (prelude) { + var lines = testGrammar.tokenizeLines(prelude + '\n.after { color: red; }'); + lines[1].forEach(function (token) { + token.scopes.forEach(function (scope) { + assert.ok(!['header', 'meta.function', 'scope.limit'].some(l => scope.includes(l)), + prelude + ' -> ' + JSON.stringify(token.value) + ' leaked ' + scope); + }); + }); + }); + }); + + it('recovers from an unclosed brace nested one level deeper than the function call', function () { + // These two sites survived an earlier mutation campaign only + // because nothing exercised a bare parenthesised group inside a + // function. They are load-bearing, not redundant. + [ + '@container (width > calc((10px{', + 'a { background: -webkit-gradient(linear, left top, from(red{' + ].forEach(function (prelude) { + var lines = testGrammar.tokenizeLines(prelude + '\n.after { color: red; }'); + lines[1].forEach(function (token) { + token.scopes.forEach(function (scope) { + assert.ok(!scope.includes('meta.function'), + prelude + ' -> ' + JSON.stringify(token.value) + ' leaked ' + scope); + }); + }); + }); + }); + + it('keeps a brace inside a legal general-enclosed string out of the body', function () { + // `` accepts ``, so a quoted string + // containing `{` is legal. The bail-out only inspects text from + // the candidate brace onwards, so without a string rule active it + // read the closing quote as an opening one, took the real `)` for + // shielded, and opened the body at the brace inside the string. + [ + ['@media (future: "a{b") {', 'meta.at-rule.media.header.css'], + ['@supports (future "a{b") {', 'meta.at-rule.supports.header.css'] + ].forEach(function (pair) { + var line = pair[0], header = pair[1]; + var tokens = testGrammar.tokenizeLine(line).tokens; + var index = line.indexOf('{'); + var offset = 0, token = null; + tokens.forEach(function (t) { + if (token === null && offset + t.value.length > index) { + token = t; + } + offset += t.value.length; + }); + assert.ok(token.scopes.includes(header), + line + ' -> brace inside string left ' + header); + assert.ok(token.scopes.some(function (s) { return s.includes('string.quoted'); }), + line + ' -> brace inside string is not scoped as a string'); + }); + }); + + it('releases a line-continuation string in an at-rule condition', function () { + // A legal `` string may end a line with an escaped + // newline. The shared `#string` rule handles that by entering a + // multi-line `constant.character.escape.newline.css` region whose + // `end` is `^(? no `color` token on the trailing rule'); + assert.ok(colour.scopes.includes('support.type.property-name.css'), + src + ' -> continuation escaped the condition: ' + colour.scopes.join(' ')); + assert.ok(!last.some(function (t) { + return t.scopes.some(function (sc) { + return sc.includes('escape.newline') || sc.includes('feature-query'); + }); + }), src + ' -> escape/feature-query region still open on the trailing rule'); + }); + }); + + it('does not bail out when the line really does close the parenthesis', function () { + // The mirror of the case above: a genuine `)` after the brace means + // the prelude is still open, and recovery must not fire. + var lines = testGrammar.tokenizeLines('@container (width > 1px{ /* x */ )\n.after { color: red; }'); + var brace = lines[0].find(t => t.value.includes('{')); + assert.ok(brace, 'brace token missing'); + assert.ok(brace.scopes.some(s => s.includes('meta.at-rule.container.header')), + 'brace left the container header: ' + brace.scopes.join(' ')); + }); + + it('bounds an unterminated layer() in an @import prelude', function () { + // `@import` legitimately runs to its `;`, as on `origin/main`, but + // the `layer()` region must not carry its own function scopes past + // the brace. + var lines = testGrammar.tokenizeLines('@import layer(base{\n.after { color: red; }'); + lines[1].forEach(function (token) { + token.scopes.forEach(function (scope) { + assert.ok(!scope.includes('meta.function'), + JSON.stringify(token.value) + ' leaked ' + scope); + }); + }); + }); + + it('does not let an unclosed prelude swallow the rest of the stylesheet', function () { + var rules = Array.from({ length: 48 }, function (_, i) { return '.r' + i + ' { color: red; }'; }); + // Every parenthesised region, against every way the prelude can be + // left unterminated. Covering only one terminator hid leaks in the + // others, and covering only a distant rule hid a leak that consumed + // the first rule whole. + [ + ['@container (width > 1px', ['{', '', ';']], + ['@container scroll-state(stuck: top', ['{', '', ';']], + ['@scope (:is(.a)', ['{', '', ';']], + ['@container style(--theme: dark', ['{', ';']] + ].forEach(function (probe) { + var open = probe[0]; + probe[1].forEach(function (terminator) { + var prelude = open + terminator; + var lines = testGrammar.tokenizeLines([prelude].concat(rules).join('\n')); + var leaked = ['header', 'meta.function', 'property-value']; + // A bare or `;`-terminated prelude legitimately continues + // onto the next line -- a prelude may span lines until `{`, + // and `;` is legal inside one -- so only a line-final `{` + // guarantees the very next rule is already outside it. + var probes = terminator === '{' ? [[1, 'r0'], [48, 'r47']] : [[48, 'r47']]; + probes.forEach(function (at) { + var token = lines[at[0]].find(t => t.value === at[1]); + assert.ok(token, prelude + ' -> ' + at[1] + ' missing'); + token.scopes.forEach(function (scope) { + assert.ok(!leaked.some(l => scope.includes(l)), prelude + ' -> ' + at[1] + ' leaked ' + scope); + }); + // The rule's own brace must open a property list, not be + // misread as the prelude's own body brace. A `{` + // terminator legitimately opens a container body, so the + // property list is nested rather than top level. + var brace = lines[at[0]].find(t => t.value === '{'); + assert.deepStrictEqual(brace.scopes.slice(-2), ['meta.property-list.css', 'punctuation.section.property-list.begin.bracket.curly.css'], prelude + ' -> ' + at[1] + ' brace'); + brace.scopes.forEach(function (scope) { + assert.ok(!leaked.some(l => scope.includes(l)), prelude + ' -> ' + at[1] + ' brace leaked ' + scope); + }); + }); + }); + }); + }); + + it('still opens the body when a prelude parenthesis is left unclosed before `{`', function () { + [ + ['@container (width > 1px{', 'container'], + ['@container scroll-state(stuck: top{', 'container'], + ['@scope (.a {', 'scope'] + ].forEach(function (probe) { + var lines = testGrammar.tokenizeLines(probe[0] + '\n color: red;\n}'); + assert.deepStrictEqual(lines[0].find(t => t.value === '{').scopes, ['source.css', 'meta.at-rule.' + probe[1] + '.body.css', 'punctuation.section.' + probe[1] + '.begin.bracket.curly.css'], probe[0]); + }); + }); + + 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.limit.css', 'punctuation.definition.parameters.end.bracket.round.css']); + }); + }); + + describe('CSS function coverage', function () { + 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 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('does not scope the slash separator inside color functions', function () { + var tokens; + tokens = testGrammar.tokenizeLine('a { color: rgb(0 0 0 / 50%); }').tokens; + var slash = tokens.find(t => t.value.trim() === '/'); + assert.deepStrictEqual(slash.scopes, ['source.css', 'meta.property-list.css', 'meta.property-value.css', 'meta.function.color.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 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() 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('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('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('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('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('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('recovers from env() arguments that begin with a block delimiter', function () { + // A `;` inside a function block and a matched `{}` pair are both + // allowed in a ``, so these are valid custom + // property declarations, not just mid-edit garbage. Either way the + // grammar must not leak past the closing bracket. + [ + 'a { padding: env(;) }', + 'a { padding: env(}) }', + 'a { padding: env(;); }', + 'a { padding: env(});}', + 'a{padding:env(;)}', + 'a { --x: env(;); }', + 'a { --gap: env(;) }', + 'a { --x: env({}); }', + 'a { --x: env({ color: red; }); }' + ].forEach(function (declaration) { + var source = declaration + '\n.after > p { color: red; }'; + var lines = testGrammar.tokenizeLines(source); + assert.deepStrictEqual(lines[1].find(t => t.value === 'after').scopes, ['source.css', 'meta.selector.css', 'entity.other.attribute-name.class.css'], declaration); + assert.ok(!lines[1].find(t => t.scopes.includes('meta.function.env.css')), declaration); + assert.deepStrictEqual(testGrammar.scopeStackAtEnd(source), ['source.css'], declaration); + }); + }); + + 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('bounds an unterminated env() to a single argument name', function () { + // The argument region closes, so only the first identifier after the + // bracket is treated as an environment variable name. The function + // region itself releases at a `{` that nothing closes later on the + // line, so a following rule is not swallowed. + 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), ['after']); + assert.ok(!lines[1].find(t => t.value === 'red').scopes.includes('meta.function.env.css')); + }); + + 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 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('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('does not recognize functions dropped from the css-values-5 draft', function () { + ['media-progress', 'container-progress'].forEach(function (fn) { + var tokens = testGrammar.tokenizeLine('a { width: ' + fn + '(width, 0px, 100px); }').tokens; + assert.ok(!tokens.find(x => x.scopes.includes('support.function.misc.css')), fn); + }); + }); + + it('keeps functions that the css-values-5 draft still defines', function () { + ['progress', 'toggle'].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); + }); + }); + }); + + describe('CSS property coverage', function () { + it('recognises additional property names as supported', function () { + [ + 'anchor-name', 'position-anchor', 'position-area', 'field-sizing', + 'view-transition-name', 'contain-intrinsic-size', 'scroll-timeline', + 'margin-trim', 'content-visibility', 'text-wrap-style', 'interpolate-size' + ].forEach(function (prop) { + var tokens = testGrammar.tokenizeLine('a { ' + prop + ': inherit; }').tokens; + var t = tokens.find(x => x.value === prop); + assert.deepStrictEqual(t.scopes, ['source.css', 'meta.property-list.css', 'meta.property-name.css', 'support.type.property-name.css'], prop); + }); + }); + + 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('recognises property names promoted out of the deprecated list', function () { + ['ruby-overhang', 'text-wrap', 'white-space-collapse', 'scroll-snap-stop'].forEach(function (prop) { + var tokens = testGrammar.tokenizeLine('a { ' + prop + ': inherit; }').tokens; + var t = tokens.find(x => x.value === prop); + assert.deepStrictEqual(t.scopes, ['source.css', 'meta.property-list.css', 'meta.property-name.css', 'support.type.property-name.css'], prop); + }); + }); + + it('does not recognize a bare shape property', function () { + var tokens = testGrammar.tokenizeLine('a { shape: none; }').tokens; + assert.ok(!tokens.find(x => x.value === 'shape' && x.scopes.includes('support.type.property-name.css'))); + }); + + it('does not recognize the renamed inset-area property', function () { + var tokens = testGrammar.tokenizeLine('a { inset-area: top; }').tokens; + assert.ok(!tokens.find(x => x.value === 'inset-area' && x.scopes.includes('support.type.property-name.css'))); + }); + }); + + describe('CSS selector coverage', function () { + it('tokenizes additional pseudo-classes', function () { + ['popover-open', 'user-valid', 'user-invalid', 'placeholder-shown', 'autofill', 'modal', 'defined', 'open'].forEach(function (pc) { + var tokens = testGrammar.tokenizeLine('a:' + pc + ' {}').tokens; + assert.deepStrictEqual(tokens[2], { scopes: ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-class.css'], value: pc }); + }); + }); + + it('tokenizes additional pseudo-elements', function () { + ['details-content', 'file-selector-button', 'target-text', 'backdrop', 'marker'].forEach(function (pe) { + var tokens = testGrammar.tokenizeLine('a::' + pe + ' {}').tokens; + assert.deepStrictEqual(tokens[2], { scopes: ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-element.css'], value: pe }); + }); + }); + + it('tokenizes ::part() and ::slotted()', function () { + var tokens; + tokens = testGrammar.tokenizeLine('a::part(btn) {}').tokens; + assert.deepStrictEqual(tokens[2], { scopes: ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-element.css'], value: 'part' }); + assert.deepStrictEqual(tokens[4], { scopes: ['source.css', 'meta.selector.css', 'variable.parameter.pseudo-element.css'], value: 'btn' }); + + tokens = testGrammar.tokenizeLine('a::slotted(.x) {}').tokens; + assert.deepStrictEqual(tokens[2], { scopes: ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-element.css'], value: 'slotted' }); + assert.deepStrictEqual(tokens[5], { scopes: ['source.css', 'meta.selector.css', 'entity.other.attribute-name.class.css'], value: 'x' }); + }); + + it('uses the argument grammar for each functional pseudo-element', function () { + var tokens = testGrammar.tokenizeLine('a::part(left)::view-transition-old(*)::scroll-button(next)::scroll-button(*) {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'left'), { scopes: ['source.css', 'meta.selector.css', 'variable.parameter.pseudo-element.css'], value: 'left' }); + assert.deepStrictEqual(tokens.filter(x => x.value === '*').map(x => x.scopes), [ + ['source.css', 'meta.selector.css', 'entity.name.tag.wildcard.css'], + ['source.css', 'meta.selector.css', 'entity.name.tag.wildcard.css'] + ]); + assert.deepStrictEqual(tokens.find(x => x.value === 'next'), { scopes: ['source.css', 'meta.selector.css', 'support.constant.property-value.css'], value: 'next' }); + + tokens = testGrammar.tokenizeLine('a::part(*) {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === '*').scopes, ['source.css', 'meta.selector.css']); + }); + + it('does not recognize functional-only pseudo-elements without arguments', function () { + ['scroll-button', 'view-transition-group', 'view-transition-image-pair', 'view-transition-new', 'view-transition-old'].forEach(function (pseudoElement) { + var tokens = testGrammar.tokenizeLine('a::' + pseudoElement + ' {}').tokens; + assert.ok(!tokens.find(x => x.value === pseudoElement && x.scopes.includes('entity.other.attribute-name.pseudo-element.css')), pseudoElement); + }); + }); + + it('only treats :current() as a functional time pseudo-class', function () { + ['past', 'future'].forEach(function (pseudoClass) { + var tokens = testGrammar.tokenizeLine('a:' + pseudoClass + '(.x) {}').tokens; + assert.ok(!tokens.find(x => x.value === '(' && x.scopes.includes('punctuation.section.function.begin.bracket.round.css')), pseudoClass); + }); + }); + + it('tokenizes scroll marker position pseudo-classes', function () { + ['target-before', 'target-current', 'target-after'].forEach(function (pseudoClass) { + var tokens = testGrammar.tokenizeLine('a:' + pseudoClass + ' {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === pseudoClass).scopes, ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-class.css'], pseudoClass); + }); + }); + + it('tokenizes :state() with a custom identifier', function () { + var tokens; + tokens = testGrammar.tokenizeLine('a:state(loading) {}').tokens; + assert.deepStrictEqual(tokens[2], { scopes: ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-class.css'], value: 'state' }); + assert.deepStrictEqual(tokens[4], { scopes: ['source.css', 'meta.selector.css', 'variable.parameter.state-name.css'], value: 'loading' }); + }); + + it('tokenizes functional :host() rather than the bare pseudo-class', function () { + var tokens; + tokens = testGrammar.tokenizeLine('a:host(.dark) {}').tokens; + var host = tokens.find(t => t.value === 'host'); + assert.deepStrictEqual(host.scopes, ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-class.css']); + var open = tokens.find(t => t.value === '('); + assert.deepStrictEqual(open.scopes, ['source.css', 'meta.selector.css', 'punctuation.section.function.begin.bracket.round.css']); + var cls = tokens.find(t => t.value === 'dark'); + assert.deepStrictEqual(cls.scopes, ['source.css', 'meta.selector.css', 'entity.other.attribute-name.class.css']); + }); + + it('tokenizes the `of` clause of :nth-child()', function () { + var tokens; + tokens = testGrammar.tokenizeLine('a:nth-child(2 of .x) {}').tokens; + var of = tokens.find(t => t.value === 'of'); + assert.deepStrictEqual(of.scopes, ['source.css', 'meta.selector.css', 'keyword.operator.logical.of.css']); + var cls = tokens.find(t => t.value === 'x'); + assert.deepStrictEqual(cls.scopes, ['source.css', 'meta.selector.css', 'entity.other.attribute-name.class.css']); + }); + + it('does not recognize pseudo-classes removed from Selectors 4', function () { + var tokens = testGrammar.tokenizeLine('a:target-within {}').tokens; + assert.ok(!tokens.find(x => x.value === 'target-within' && x.scopes.includes('entity.other.attribute-name.pseudo-class.css'))); + }); + + it('keeps pseudo-classes deferred to Selectors 5', function () { + ['blank', 'local-link'].forEach(function (pseudoClass) { + var tokens = testGrammar.tokenizeLine('a:' + pseudoClass + ' {}').tokens; + assert.deepStrictEqual(tokens[2], { scopes: ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-class.css'], value: pseudoClass }); + }); + }); + }); + + describe('CSS media feature coverage', function () { + it('recognises user-preference media features', function () { + ['prefers-color-scheme', 'prefers-contrast', 'prefers-reduced-motion', 'forced-colors', 'dynamic-range'].forEach(function (mf) { + var tokens = testGrammar.tokenizeLine('@media (' + mf + ': none) {}').tokens; + var t = tokens.find(x => x.value === mf); + assert.deepStrictEqual(t.scopes, ['source.css', 'meta.at-rule.media.header.css', 'support.type.property-name.media.css'], mf); + }); + }); + + it('recognises interaction media features', function () { + ['pointer', 'any-pointer', 'hover', 'any-hover', 'update', 'scripting'].forEach(function (mf) { + var tokens = testGrammar.tokenizeLine('@media (' + mf + ': none) {}').tokens; + var t = tokens.find(x => x.value === mf); + assert.deepStrictEqual(t.scopes, ['source.css', 'meta.at-rule.media.header.css', 'support.type.property-name.media.css'], mf); + }); + }); + + it('recognises the values of each discrete media feature it matches', function () { + [ + ['overflow-block', 'paged'], + ['overflow-block', 'scroll'], + ['overflow-inline', 'scroll'], + ['inverted-colors', 'inverted'], + ['nav-controls', 'back'], + ['environment-blending', 'opaque'], + ['environment-blending', 'additive'], + ['environment-blending', 'subtractive'], + ['display-mode', 'picture-in-picture'], + ['scripting', 'initial-only'], + ['update', 'slow'] + ].forEach(function (pair) { + var tokens = testGrammar.tokenizeLine('@media (' + pair[0] + ': ' + pair[1] + ') {}').tokens; + var t = tokens.find(x => x.value === pair[1]); + assert.deepStrictEqual(t.scopes, ['source.css', 'meta.at-rule.media.header.css', 'support.constant.property-value.css'], pair.join(': ')); + }); + }); + + it('treats viewport segments as range features', function () { + [ + 'horizontal-viewport-segments', + 'min-horizontal-viewport-segments', + 'max-horizontal-viewport-segments', + 'vertical-viewport-segments', + 'min-vertical-viewport-segments', + 'max-vertical-viewport-segments' + ].forEach(function (mf) { + var tokens = testGrammar.tokenizeLine('@media (' + mf + ': 2) {}').tokens; + var t = tokens.find(x => x.value === mf); + assert.deepStrictEqual(t.scopes, ['source.css', 'meta.at-rule.media.header.css', 'support.type.property-name.media.css'], mf); + }); + + var tokens = testGrammar.tokenizeLine('@media (horizontal-viewport-segments > 1) {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === '>').scopes, ['source.css', 'meta.at-rule.media.header.css', 'keyword.operator.comparison.css']); + }); + + it('does not recognize removed media features', function () { + ['display-capabilities', 'shape'].forEach(function (feature) { + var tokens = testGrammar.tokenizeLine('@media (' + feature + ': round) {').tokens; + assert.ok(!tokens.find(x => x.value === feature && x.scopes.includes('support.type.property-name.media.css')), feature); + }); + }); + }); }); diff --git a/testing-util/test.mjs b/testing-util/test.mjs index 26abebf..c22a849 100644 --- a/testing-util/test.mjs +++ b/testing-util/test.mjs @@ -65,5 +65,21 @@ export default { } return tokenizedLines; + }, + // The scope names left on the tokenizer's rule stack once the whole text + // has been tokenized. A well-behaved grammar closes everything it opens, so + // this is `['source.css']` unless a rule leaked past the end of the input. + scopeStackAtEnd: function scopeStackAtEnd(text) { + const lines = text.split(/\r\n|\r|\n/g); + + let ruleStack = vsctm.INITIAL; + for (let i = 0; i < lines.length; i++) { + ruleStack = grammar.tokenizeLine(lines[i], ruleStack).ruleStack; + } + + // The scopes an editor would report for the next character, rather than + // one entry per rule frame: an unnamed frame inherits its ancestor's + // name, so walking frames reports duplicates that no token ever carries. + return ruleStack.contentNameScopesList.getScopeNames(); } }