diff --git a/grammars/css.cson b/grammars/css.cson index 039f54b..6f219e9 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) (?|\\+|~|\\|\\|' + 'name': 'invalid.illegal.combinator.css' + } + { + # The descendant combinator is written as whitespace, so it is only + # one where a selector ends and another begins. A hexadecimal escape + # takes at most one whitespace to terminate it, and that one belongs + # to the name before it, so `.\\61 .b` is a single compound selector. + # A backslash run of even length is escaped backslashes rather than an + # escape, so `.\\\\61 .b` is two selectors. Past two such runs the + # whitespace is left alone rather than called a combinator, since + # missing one reads better than calling valid CSS invalid. + 'match': '''(?x) + (?+~|]) + [\\x20\\t\\f]+ + (?=[.#\\[:*\\w-]) + ''' + 'name': 'invalid.illegal.combinator.css' + } + { + # Whitespace left over once an escape has taken its terminator is a + # combinator after all, which is why `.\\61 .b` is two of them. + 'match': '''(?x) + (?<= + [^\\\\]\\\\[0-9a-fA-F][\\x20\\t\\f] + | [^\\\\]\\\\[0-9a-fA-F]{2}[\\x20\\t\\f] + | [^\\\\]\\\\[0-9a-fA-F]{3}[\\x20\\t\\f] + | [^\\\\]\\\\[0-9a-fA-F]{4}[\\x20\\t\\f] + | [^\\\\]\\\\[0-9a-fA-F]{5}[\\x20\\t\\f] + | [^\\\\]\\\\[0-9a-fA-F]{6}[\\x20\\t\\f] + | [^\\\\]\\\\\\\\\\\\[0-9a-fA-F][\\x20\\t\\f] + | [^\\\\]\\\\\\\\\\\\[0-9a-fA-F]{2}[\\x20\\t\\f] + | [^\\\\]\\\\\\\\\\\\[0-9a-fA-F]{3}[\\x20\\t\\f] + | [^\\\\]\\\\\\\\\\\\[0-9a-fA-F]{4}[\\x20\\t\\f] + | [^\\\\]\\\\\\\\\\\\[0-9a-fA-F]{5}[\\x20\\t\\f] + | [^\\\\]\\\\\\\\\\\\[0-9a-fA-F]{6}[\\x20\\t\\f] + ) + [\\x20\\t\\f]+ + (?=[.#\\[:*\\w-]) + ''' + 'name': 'invalid.illegal.combinator.css' + } + { + 'include': '#string' + } + { + 'include': '#balanced-selector-parens' + } + { + 'include': '#selector-innards' + } + ] 'escapes': 'patterns': [ { @@ -957,7 +1299,29 @@ } # Colors { - 'begin': '(?i)(?`, so it is a rule of its own: the + # identifier matcher below would otherwise reach colour functions that + # take no interpolation keywords at all. + { + 'begin': '(?i)(?` is a ``, so `in --brand-space` + # has to be accepted here too. + { + 'match': '''(?xi) (? (?!(?:initial|inherit|unset|revert|revert-layer|default)(?![-\\w])) (?:--(?:[-a-zA-Z0-9_] | [^\\x00-\\x7F] |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\f]?|[^\\n])) | -?(?:[a-zA-Z_] | [^\\x00-\\x7F] |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\f]?|[^\\n])))(?:[-a-zA-Z0-9_] | [^\\x00-\\x7F] |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\f]?|[^\\n]))* ) + (?!\\() # A function is not a name + ) + ) + | (?=[,;{})]) # Malformed: give up on a name + # The name is the first component. If the first component is + # not a name then this function has no name, and the region has + # to close here rather than search on and scope a later + # identifier on a following line. + | (?= \\s*(?:/\\*(?:[^*]|\\*[^/])*\\*/\\s*)* + (?! (?!(?:initial|inherit|unset|revert|revert-layer|default)(?![-\\w])) (?:--(?:[-a-zA-Z0-9_] | [^\\x00-\\x7F] |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\f]?|[^\\n])) | -?(?:[a-zA-Z_] | [^\\x00-\\x7F] |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\f]?|[^\\n]))) + | /\\* # A comment is not the name either + ) + \\S + ) + # A function is not a name either, and it may be the first + # component on a line of its own, so the region has to close + # here rather than carry the search on to the next line. + | (?= \\s*(?:/\\*(?:[^*]|\\*[^/])*\\*/\\s*)* + (?:--(?:[-a-zA-Z0-9_] | [^\\x00-\\x7F] |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\f]?|[^\\n])) | -?(?:[a-zA-Z_] | [^\\x00-\\x7F] |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\f]?|[^\\n])))(?:[-a-zA-Z0-9_] | [^\\x00-\\x7F] |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\f]?|[^\\n]))* + \\( + ) + ''' + 'endCaptures': + '1': + 'name': 'support.constant.property-value.css' + '2': + 'name': 'variable.argument.css' + 'patterns': [ + { + # Anything that is not the name: comments, the indices of + # `env(viewport-segment-width 0 0)`, strings, nested + # functions and malformed arguments. + 'include': '#property-values' + } + ] + } + { + 'include': '#property-values' + } + ] + } + # Anchor positioning + { + 'begin': '(?i)(?`, so any part of it may be + # written as an escape, and a hexadecimal escape takes the single + # whitespace character that terminates it. + 'name': 'variable.argument.css' + 'match': '''(?x) + (?`, which is a disjoint set from the `` keywords `anchor-size()` takes. + 'match': '''(?xi) (?`, so any part of it may be + # written as an escape, and a hexadecimal escape takes the single + # whitespace character that terminates it. + 'name': 'variable.argument.css' + 'match': '''(?x) + (?`, not an ``. + 'match': '''(?xi) (?` syntax, e.g. :nth-child(2 of .visible) + 'begin': '(?i)(?<=[\\s)]|\\*/)(of)(?=[\\s(:.#\\[*|]|/\\*|$)' + 'beginCaptures': + '1': + 'name': 'keyword.operator.logical.of.css' + 'end': '(?=\\))' + 'patterns': [ + { + 'include': '#selector-innards' + } + ] + } + ] + } + { + # Typed-child-indexed; :nth-of-type() takes no `of` clause + 'begin': '(?i)((:)nth-(?:last-)?of-type)(\\()' + 'beginCaptures': + '1': + 'name': 'entity.other.attribute-name.pseudo-class.css' + '2': + 'name': 'punctuation.definition.entity.css' + '3': + 'name': 'punctuation.section.function.begin.bracket.round.css' + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.section.function.end.bracket.round.css' + 'patterns': [ + { + 'match': '(?i)[+-]?(\\d+n?|n)(\\s*[+-]\\s*\\d+)?' + 'name': 'constant.numeric.css' + } + { + 'match': '(?i)even|odd' + 'name': 'support.constant.parity.css' + } + ] + } + { + # As a functional pseudo-class, `:local-link()` takes one + # non-negative integer. + 'begin': '(?i)((:)local-link)(\\()' + 'beginCaptures': + '1': + 'name': 'entity.other.attribute-name.pseudo-class.css' + '2': + 'name': 'punctuation.definition.entity.css' + '3': + 'name': 'punctuation.section.function.begin.bracket.round.css' + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.section.function.end.bracket.round.css' + 'patterns': [ + { + 'match': '(?i)(?:\\G|^)((?:\\s|/\\*(?:[^*]|\\*[^/])*\\*/)*)(\\d+)(?=\\s*(?:\\)|/\\*|$))' + 'captures': + '1': + 'patterns': [ + { + 'include': '#comment-block' + } + ] + '2': + 'name': 'constant.numeric.css' + } + { + 'include': '#comment-block' + } + ] + } + { + # :state() takes a custom identifier, not a selector list + 'begin': '(?i)((:)state)(\\()' + 'beginCaptures': + '1': + 'name': 'entity.other.attribute-name.pseudo-class.css' + '2': + 'name': 'punctuation.definition.entity.css' + '3': + 'name': 'punctuation.section.function.begin.bracket.round.css' + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.section.function.end.bracket.round.css' + 'patterns': [ + { + 'match': '''(?xi) (?:\\G|^) + ( (?:\\s|/\\*(?:[^*]|\\*[^/])*\\*/)* # Leading comments + ) + ( (?: -- # An identifier starts with two + | -? # hyphens, or one optional + (?:[a-zA-Z_] | [^\\x00-\\x7F] # hyphen and a letter, and + |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\n\\r\\f]?|[^\\n]) + ) # any part of it may be an + ) # escape. A hexadecimal + (?:[-a-zA-Z0-9_] | [^\\x00-\\x7F] # escape takes the + |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\n\\r\\f]?|[^\\n]) + )* # whitespace that ends it. + ) + (?= \\s*(?:\\)|/\\*|$) ) # The sole argument + ''' + 'captures': + '1': + 'patterns': [ + { + 'include': '#comment-block' + } + ] + '2': + 'name': 'variable.parameter.state-name.css' + 'patterns': [ + { + 'include': '#escapes' + } + ] + } + { + 'include': '#comment-block' + } + ] + } + ] + 'functional-pseudo-elements': + 'patterns': [ + { + # ::part() is the only one of these that takes more than one + # identifier: css-shadow-parts-1 defines it as `+`. + 'begin': '(?i)((::)part)(\\()' + 'beginCaptures': + '1': + 'name': 'entity.other.attribute-name.pseudo-element.css' + '2': + 'name': 'punctuation.definition.entity.css' + '3': + 'name': 'punctuation.section.function.begin.bracket.round.css' + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.section.function.end.bracket.round.css' + 'patterns': [ + { + 'include': '#comment-block' + } + { + 'match': '''(?xi) (?` + ) + (?: -- # An identifier starts with two + | -? # hyphens, or one optional + (?:[a-zA-Z_] | [^\\x00-\\x7F] # hyphen and a letter, and + |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\n\\r\\f]?|[^\\n]) + ) # any part of it may be an + ) # escape. A hexadecimal + (?:[-a-zA-Z0-9_] | [^\\x00-\\x7F] # escape takes the + |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\n\\r\\f]?|[^\\n]) + )* # whitespace that ends it. + ) + (?= \\s*(?:\\)|/\\*|$) ) # The sole argument + ''' + 'captures': + '1': + 'patterns': [ + { + 'include': '#comment-block' + } + ] + '2': + 'name': 'variable.parameter.pseudo-element.css' + 'patterns': [ + { + 'include': '#escapes' + } + ] + } + { + 'include': '#comment-block' + } + ] + } + { + # View-transition pseudo-elements take a custom identifier or wildcard + 'begin': '(?i)((::)(?:view-transition-group-children|view-transition-group|view-transition-image-pair|view-transition-new|view-transition-old))(\\()' + 'beginCaptures': + '1': + 'name': 'entity.other.attribute-name.pseudo-element.css' + '2': + 'name': 'punctuation.definition.entity.css' + '3': + 'name': 'punctuation.section.function.begin.bracket.round.css' + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.section.function.end.bracket.round.css' + 'patterns': [ + { + 'match': '(?i)(?:\\G|^)((?:\\s|/\\*(?:[^*]|\\*[^/])*\\*/)*)(\\*)(?=\\s*(?:\\)|/\\*|$))' + 'captures': + '1': + 'patterns': [ + { + 'include': '#comment-block' + } + ] + '2': + 'name': 'entity.name.tag.wildcard.css' + } + { + 'match': '''(?xi) (?:\\G|^) + ( (?:\\s|/\\*(?:[^*]|\\*[^/])*\\*/)* # Leading comments + ) + ( (?! (?:initial|inherit|unset|revert|revert-layer|default) + (?![-\\w]) # Not a `` + ) + (?: -- # An identifier starts with two + | -? # hyphens, or one optional + (?:[a-zA-Z_] | [^\\x00-\\x7F] # hyphen and a letter, and + |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\n\\r\\f]?|[^\\n]) + ) # any part of it may be an + ) # escape. A hexadecimal + (?:[-a-zA-Z0-9_] | [^\\x00-\\x7F] # escape takes the + |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\n\\r\\f]?|[^\\n]) + )* # whitespace that ends it. + ) + (?= \\s*(?:\\)|/\\*|$) ) # The sole argument + ''' + 'captures': + '1': + 'patterns': [ + { + 'include': '#comment-block' + } + ] + '2': + 'name': 'variable.parameter.pseudo-element.css' + 'patterns': [ + { + 'include': '#escapes' + } + ] + } + { + 'include': '#comment-block' + } + ] + } + { + # ::scroll-button() takes a physical or logical direction + 'begin': '(?i)((::)scroll-button)(\\()' + 'beginCaptures': + '1': + 'name': 'entity.other.attribute-name.pseudo-element.css' + '2': + 'name': 'punctuation.definition.entity.css' + '3': + 'name': 'punctuation.section.function.begin.bracket.round.css' + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.section.function.end.bracket.round.css' + 'patterns': [ + { + 'match': '(?i)(?:\\G|^)((?:\\s|/\\*(?:[^*]|\\*[^/])*\\*/)*)(\\*)(?=\\s*(?:\\)|/\\*|$))' + 'captures': + '1': + 'patterns': [ + { + 'include': '#comment-block' + } + ] + '2': + 'name': 'entity.name.tag.wildcard.css' + } + { + # css-overflow-5 gives `::scroll-button()` one direction, so only + # the sole argument is a direction. + 'match': '(?i)(?:\\G|^)((?:\\s|/\\*(?:[^*]|\\*[^/])*\\*/)*)(block-start|block-end|inline-start|inline-end|up|down|left|right|prev|next)(?=\\s*(?:\\)|/\\*|$))' + 'captures': + '1': + 'patterns': [ + { + 'include': '#comment-block' + } + ] + '2': + 'name': 'support.constant.property-value.css' + } + { + 'include': '#comment-block' + } + ] + } + { + # ::slotted() takes a compound selector + 'begin': '(?i)((::)slotted)(\\()' + 'beginCaptures': + '1': + 'name': 'entity.other.attribute-name.pseudo-element.css' + '2': + 'name': 'punctuation.definition.entity.css' + '3': + 'name': 'punctuation.section.function.begin.bracket.round.css' + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.section.function.end.bracket.round.css' + 'patterns': [ + { + # ::slotted() takes one compound selector, so a comma or a + # combinator in it is not valid. + 'match': ',' + 'name': 'invalid.illegal.comma.css' + } + { + 'include': '#compound-selector-innards' + } + ] + } + ] + 'media-features': + 'captures': + '1': + 'name': 'support.type.property-name.media.css' + '2': + 'name': 'support.type.property-name.media.css' + '3': + 'name': 'support.type.vendored.property-name.media.css' + 'match': '''(?xi) + (?<=^|\\s|\\(|\\*/) # Preceded by whitespace, bracket or comment + (?: + # Standardised features + ( + (?:min-|max-)? # Range features + (?: height + | width + | aspect-ratio + | color + | color-index + | monochrome + | resolution + | horizontal-viewport-segments + | vertical-viewport-segments + ) + | grid # Discrete features + | scan + | orientation + | display-mode + | hover + | any-hover # Interaction (Media Queries 4) + | pointer + | any-pointer + | update + | overflow-block + | overflow-inline + | prefers-color-scheme # User preference (Media Queries 5) + | prefers-contrast + | prefers-reduced-motion + | prefers-reduced-transparency + | prefers-reduced-data + | forced-colors + | inverted-colors + | dynamic-range + | video-dynamic-range + | scripting + | color-gamut + | video-color-gamut + | environment-blending + | nav-controls + | ua-color-scheme + ) + | + # Deprecated features + ( + (?:min-|max-)? # Deprecated in Media Queries 4 + device- + (?: height + | width + | aspect-ratio + ) + ) + | + # Vendor extensions + ( + (?: + # Spec-compliant syntax + [-_] + (?: webkit # Webkit/Blink + | apple|khtml # Webkit aliases + | epub # ePub3 + | moz # Gecko + | ms # Microsoft + | o # Presto (pre-Opera 15) + | xv|ah|rim|atsc| # Less common vendors + hp|tc|wap|ro + ) + | + # Non-standard prefixes + (?: mso # Microsoft Office + | prince # YesLogic + ) + ) + - + [\\w-]+ # Feature name + (?= # Terminates correctly + \\s* # Possible whitespace + (?: # Possible injected comment + /\\* + (?:[^*]|\\*[^/])* + \\*/ + )? + \\s* + [:)] # Ends with a colon or closed bracket + ) + ) + ) + (?=\\s|$|[><:=]|\\)|/\\*) # Terminates cleanly + ''' + 'media-feature-keywords': + 'match': '''(?xi) + (?<=^|\\s|:|\\*/) + (?: portrait # Orientation + | landscape + | progressive # Scan types + | interlace + | fullscreen # Display modes + | standalone + | minimal-ui + | browser + | hover + | none # Interaction / preference values + | coarse + | fine + | slow + | fast + | light # prefers-color-scheme + | dark + | no-preference # prefers-* neutral value + | reduce # prefers-reduced-* + | more # prefers-contrast + | less + | custom + | active # forced-colors + | enabled # scripting + | initial-only + | srgb # color-gamut + | p3 + | rec2020 + | standard # dynamic-range + | high + | scroll # overflow-block / overflow-inline + | paged + | inverted # inverted-colors + | back # nav-controls + | opaque # environment-blending + | additive + | subtractive + ) + (?=\\s|\\)|$) + ''' + 'name': 'support.constant.property-value.css' + 'container-query': + 'begin': '\\G' + 'end': '(?=\\s*[{;])' + 'patterns': [ + { + 'include': '#comment-block' + } + { + # Optional container name preceding the query, e.g. @container card (…) + # A `` is a ``, so it may begin with an + # escape. The name is immediately followed by the container condition, + # so anything not in that position is part of the condition instead. + 'captures': + '1': + 'name': 'variable.parameter.container-name.css' + 'patterns': [ + { + 'include': '#escapes' + } + ] + 'match': '''(?xi) + (?<=^|\\s|\\*/|,) + (?!(?:none|and|or|not)(?![\\w-])) # The four names the spec reserves + ( + (?:[-a-zA-Z_] | [^\\x00-\\x7F] # First letter + |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\n\\f]?|[^\\n]) + ) + (?:[-a-zA-Z0-9_] | [^\\x00-\\x7F] # Remainder of identifier + |\\\\(?:[0-9a-fA-F]{1,6}[ \\t\\n\\f]?|[^\\n]) + )* + ) + (?![\\w(-]) # Not a function call + (?= # A query, the body or the end + \\s*(?:/\\*(?:[^*]|\\*[^/])*\\*/\\s*)* + (?:\\(|(?i:style|scroll-state)\\s*\\(|\\{|$) + ) + ''' + } + { + 'include': '#commas' + } + { + 'match': '(?i)(?<=\\s|^|\\*/|\\))and(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.and.container.css' + } + { + 'match': '(?i)(?<=\\s|^|\\*/|\\))or(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.or.container.css' + } + { + 'match': '(?i)(?<=\\s|^|\\*/|\\))not(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.not.container.css' + } + { + # style() queries, e.g. @container style(--theme: dark) + 'begin': '(?i)(?`, in which a `{` is legal. + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.section.function.end.bracket.round.css' + 'name': 'meta.function.style.css' + 'patterns': [ + { + 'include': '#style-query-innards' + } + ] + } + { + # Scroll-state queries, e.g. @container scroll-state(stuck: top) + 'begin': '(?i)(?` here too, not + # just inside a parenthesised query. + 'include': '#container-general-enclosed' + } + ] + 'style-query-innards': + # A `` is a recursive grammar: `not`, `and`, `or`, groups in + # parentheses, a declaration, or a declaration-value range. + 'patterns': [ + { + 'match': '(?i)(?<=\\s|^|\\*/|\\(|\\))and(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.and.container.css' + } + { + 'match': '(?i)(?<=\\s|^|\\*/|\\(|\\))or(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.or.container.css' + } + { + 'match': '(?i)(?<=\\s|^|\\*/|\\(|\\))not(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.not.container.css' + } + { + 'include': '#style-in-parens' + } + { + 'include': '#container-general-enclosed' + } + { + # A style feature may be a range, e.g. style(--level >= 2), which is + # not a declaration and so must not reach #rule-list-innards. + 'begin': '''(?x) \\G + (?= + # A range holds a comparison before any `:`, which is what + # separates it from a declaration. Anchoring at \\G keeps this + # lookahead from being attempted at every character. + (?: [^:;{}()<>=] | \\([^()]*\\) )* + (?:>=|<=|=|<|>) + ) + ''' + 'end': '(?=[)}])' + 'patterns': [ + { + 'match': '--[^\\s;{}()<>=]+' + 'name': 'variable.css' + } + { + 'match': '>=|<=|=|<|>' + 'name': 'keyword.operator.comparison.css' + } + { + 'include': '#property-values' + } + ] + } + { + # Local copy of the declaration-value region from #rule-list-innards, + # which also ends at `;`. A style() query holds a + # ``, where a `;` nested in brackets or a + # balanced block belongs to the value, so this copy ends only at + # `}` or `)` and keeps the value whole. + 'begin': '(:)\\s*' + 'beginCaptures': + '1': + 'name': 'punctuation.separator.key-value.css' + 'end': '\\s*(?=[})])' + 'contentName': 'meta.property-value.css' + 'patterns': [ + { + 'include': '#property-values' + } + ] + } + { + 'include': '#rule-list-innards' + } + ] + 'style-in-parens': + 'begin': '\\(' + 'beginCaptures': + '0': + 'name': 'punctuation.definition.parameters.begin.bracket.round.css' + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.definition.parameters.end.bracket.round.css' + 'patterns': [ + { + 'include': '#style-query-innards' + } + ] + 'container-query-in-parens': + 'begin': '\\(' + 'beginCaptures': + '0': + 'name': 'punctuation.definition.parameters.begin.bracket.round.css' + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.definition.parameters.end.bracket.round.css' + 'patterns': [ + { + # A query in parentheses may itself be a parenthesised query, so the + # groups have to nest rather than close at the first `)`. + 'include': '#container-query-in-parens' + } + { + 'include': '#size-container-features' + } + { + 'match': '(?i)(?<=\\s|^|\\*/|\\(|\\))and(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.and.container.css' + } + { + 'match': '(?i)(?<=\\s|^|\\*/|\\(|\\))or(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.or.container.css' + } + { + 'match': '(?i)(?<=\\s|^|\\*/|\\(|\\))not(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.not.container.css' + } + { + 'match': ':' + 'name': 'punctuation.separator.key-value.css' + } + { + 'match': '>=|<=|=|<|>' + 'name': 'keyword.operator.comparison.css' + } + { + 'captures': + '1': + 'name': 'constant.numeric.css' + '2': + 'name': 'keyword.operator.arithmetic.css' + '3': + 'name': 'constant.numeric.css' + 'match': '(\\d+)\\s*(/)\\s*(\\d+)' + 'name': 'meta.ratio.css' + } + { + # A size container feature takes a length or a ratio, and only + # `orientation` takes a keyword, so the media feature values do + # not belong here. + 'match': '(?i)(?<=^|\\s|:|\\*/)(?:portrait|landscape)(?=\\s|\\)|$)' + 'name': 'support.constant.property-value.css' + } + { + 'include': '#numeric-values' + } + { + 'include': '#comment-block' + } + { + 'include': '#functions' + } + { + 'include': '#container-general-enclosed' + } + { + 'include': '#string' + } + ] + 'container-general-enclosed': + # `` lets an unknown function appear in a query. Its + # contents only have to be balanced, so they are parsed for nesting and + # for strings, which may hold an unmatched bracket. + # The function name is a CSS identifier, so it may contain escapes and + # non-ASCII characters. Matching only the ASCII tail would start a + # function part way through a name such as `f\\6f o(…)`. + 'begin': '''(?x) + (?<:=]|\\)|/\\*) # Terminates cleanly + ''' + 'scroll-state-condition': + 'patterns': [ + { + 'include': '#comment-block' + } + { + 'begin': '\\(' + 'beginCaptures': + '0': + 'name': 'punctuation.definition.parameters.begin.bracket.round.css' + 'end': '\\)' + 'endCaptures': + '0': + 'name': 'punctuation.definition.parameters.end.bracket.round.css' 'patterns': [ { - 'name': 'variable.argument.css' - 'match': '''(?x) - -- - (?:[-a-zA-Z_] | [^\\x00-\\x7F]) # First letter - (?:[-a-zA-Z0-9_] | [^\\x00-\\x7F] # Remainder of identifier - |\\\\(?:[0-9a-fA-F]{1,6}|.) - )* - ''' - } - { - 'include': '#property-values' + 'include': '#scroll-state-condition' } ] } - ] - 'function-nesting': - 'begin': '\\(' - 'beginCaptures': - '0': - 'name': 'punctuation.definition.function.begin.bracket.round.css' - 'end': '\\)' - 'endCaptures': - '0': - 'name': 'punctuation.definition.function.end.bracket.round.css' - 'patterns': [ { - 'include': '#arithmetic-operators' + 'match': '(?i)(?<=\\s|^|\\*/|\\(|\\))and(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.and.container.css' } { - 'include': '#property-values' - }, + 'match': '(?i)(?<=\\s|^|\\*/|\\(|\\))or(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.or.container.css' + } { - 'include': '#function-nesting' + 'match': '(?i)(?<=\\s|^|\\*/|\\(|\\))not(?=\\s|\\(|/\\*|$)' + 'name': 'keyword.operator.logical.not.container.css' } - ] - 'functional-pseudo-classes': - 'patterns': [ { - # Text direction - 'begin': '(?i)((:)dir)(\\()' + 'include': '#container-general-enclosed' + } + { + 'include': '#string' + } + { + 'begin': '(?i)(?<:=]|\\)|/\\*) # Terminates cleanly - ''' - 'media-feature-keywords': - 'match': '''(?xi) - (?<=^|\\s|:|\\*/) - (?: portrait # Orientation - | landscape - | progressive # Scan types - | interlace - | fullscreen # Display modes - | standalone - | minimal-ui - | browser - | hover - ) - (?=\\s|\\)|$) - ''' - 'name': 'support.constant.property-value.css' + ''' + 'name': 'support.type.property-name.container.css' + } + ] 'media-query': 'begin': '\\G' 'end': '(?=\\s*[{;])' @@ -1829,11 +3301,13 @@ 'name': 'invalid.illegal.colon.css' 'match': '''(?xi) (:)(:*) - (?: active|any-link|checked|default|disabled|empty|enabled|first - | (?:first|last|only)-(?:child|of-type)|focus|focus-visible|focus-within|fullscreen|host|hover - | in-range|indeterminate|invalid|left|link|optional|out-of-range - | read-only|read-write|required|right|root|scope|target|unresolved - | valid|visited + (?: active|any-link|autofill|blank|buffering|checked|current|default|defined|disabled|empty|enabled|first + | (?:first|last|only)-(?:child|of-type)|focus|focus-visible|focus-within|fullscreen|future|host|hover + | in-range|indeterminate|invalid|left|link|local-link|modal|muted|open|optional|out-of-range + | past|paused|picture-in-picture|placeholder-shown|playing|popover-open + | read-only|read-write|required|right|root|scope|seeking|stalled|target|target-after|target-before|target-current + | unchecked + | unresolved|user-invalid|user-valid|valid|visited|volume-locked )(?![\\w-]|\\s*[;}]) ''' 'name': 'entity.other.attribute-name.pseudo-class.css' @@ -1858,13 +3332,22 @@ | (::) # Double-colon only (?: backdrop + | checkmark + | column | content + | details-content + | file-selector-button | grammar-error | marker + | picker-icon | placeholder + | scroll-marker + | scroll-marker-group | selection | shadow | spelling-error + | target-text + | view-transition ) ) (?![\\w-]|\\s*[;}]) @@ -2251,14 +3734,17 @@ } ] } + { + 'include': '#functional-pseudo-classes' + } { 'include': '#pseudo-classes' } { - 'include': '#pseudo-elements' + 'include': '#functional-pseudo-elements' } { - 'include': '#functional-pseudo-classes' + 'include': '#pseudo-elements' } # Custom HTML elements { diff --git a/spec/css-spec.mjs b/spec/css-spec.mjs index c769608..02a56b3 100644 --- a/spec/css-spec.mjs +++ b/spec/css-spec.mjs @@ -3864,4 +3864,2233 @@ 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('container queries', function () { + it('does not treat a longer identifier as a size feature', function () { + // The size feature names have to stop at a word boundary, or + // `widthish` picks up `support.type.property-name.container.css` + // from the `width` alternative. + var tokens = testGrammar.tokenizeLine('@container (widthish > 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]); + }); + }); + + }); + + describe('Selectors 4 and 5', function () { + it('accepts an `of` clause not separated from its selector by a space', function () { + // `of` is an identifier, so a class, id, attribute or comment may follow + // it directly. Requiring whitespace dropped the selector on the floor. + [['a:nth-child(2 of.x) {}', 'x', 'entity.other.attribute-name.class.css'], + ['a:nth-child(2 of#x) {}', 'x', 'entity.other.attribute-name.id.css'], + ['a:nth-child(2 of/*c*/.x) {}', 'x', 'entity.other.attribute-name.class.css']].forEach(function (probe) { + var tokens = testGrammar.tokenizeLine(probe[0]).tokens; + assert.deepStrictEqual(tokens.find(t => t.value === 'of').scopes, ['source.css', 'meta.selector.css', 'keyword.operator.logical.of.css'], probe[0]); + assert.deepStrictEqual(tokens.find(t => t.value === probe[1]).scopes, ['source.css', 'meta.selector.css', probe[2]], probe[0]); + }); + var attr = testGrammar.tokenizeLine('a:nth-child(2 of[hidden]) {}').tokens; + assert.deepStrictEqual(attr.find(t => t.value === 'hidden').scopes, ['source.css', 'meta.selector.css', 'meta.attribute-selector.css', 'entity.other.attribute-name.css']); + }); + + it('closes every new functional selector', function () { + // Each of these rules is new on this branch and none of them had + // its closing parenthesis asserted, so the scope could be renamed + // without a failure. + [ + ['a:state(loading) {}', 5], + ['a::part(button) {}', 5], + ['a::highlight(search) {}', 5], + ['a::view-transition-old(hero) {}', 5], + ['a::scroll-button(next) {}', 5], + ['a::slotted(.x) {}', 6] + ].forEach(function (pair) { + var tokens = testGrammar.tokenizeLine(pair[0]).tokens; + assert.deepStrictEqual(tokens[pair[1]], { + scopes: [ + 'source.css', + 'meta.selector.css', + 'punctuation.section.function.end.bracket.round.css' + ], + value: ')' + }, pair[0]); + }); + }); + + it('gives a single-argument selector only its sole argument', function () { + // `:state()` takes one ``, `::highlight()` one + // ``, `::scroll-button()` one direction and a + // view-transition pseudo-element one ``. A second + // argument is invalid, so it is not scoped as one. + [ + ['a:state(foo bar) {}', 'bar'], + ['a::highlight(foo bar) {}', 'bar'], + ['a::scroll-button(up down) {}', 'down'], + ['a::view-transition-old(foo bar) {}', 'bar'] + ].forEach(function (c) { + var tokens = testGrammar.tokenizeLine(c[0]).tokens; + assert.ok(!tokens.some(x => x.value === c[1] && x.scopes.some(sc => sc.startsWith('variable.parameter') || sc === 'support.constant.property-value.css')), c[0]); + }); + }); + + it('takes more than one identifier in ::part() only', function () { + // css-shadow-parts-1 defines `::part( # )`, so both names + // here are arguments. + var tokens = testGrammar.tokenizeLine('a::part(foo bar) {}').tokens; + ['foo', 'bar'].forEach(function (name) { + assert.deepStrictEqual(tokens.find(x => x.value === name).scopes, ['source.css', 'meta.selector.css', 'variable.parameter.pseudo-element.css'], name); + }); + }); + + it('requires a valid identifier start in a selector argument', function () { + // css-syntax-3: an ident sequence starts with an ident-start code + // point, two hyphens, or a hyphen and an ident-start code point. + // A lone hyphen, a digit after one hyphen, and a leading digit are + // none of those, and the matcher does not take the tail of one. + ['a:state(-) {}', 'a:state(-1foo) {}', 'a:state(1foo) {}'].forEach(function (line) { + var tokens = testGrammar.tokenizeLine(line).tokens; + assert.ok(!tokens.some(x => x.scopes.includes('variable.parameter.state-name.css')), line); + }); + var ok = testGrammar.tokenizeLine('a:state(--x) {}').tokens; + assert.deepStrictEqual(ok.find(x => x.value === '--x').scopes, ['source.css', 'meta.selector.css', 'variable.parameter.state-name.css']); + }); + + it('does not take a reserved word as a custom identifier', function () { + // css-values-4: the CSS-wide keywords are not valid + // ``s, and `default` is reserved as well. + ['default', 'initial', 'inherit', 'unset', 'revert', 'revert-layer'].forEach(function (word) { + ['a::highlight(' + word + ') {}', 'a::view-transition-old(' + word + ') {}'].forEach(function (line) { + var tokens = testGrammar.tokenizeLine(line).tokens; + assert.ok(!tokens.some(x => x.scopes.includes('variable.parameter.pseudo-element.css')), line); + }); + }); + // `:state()` takes an ``, which has no such exclusion. + var tokens = testGrammar.tokenizeLine('a:state(default) {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'default').scopes, ['source.css', 'meta.selector.css', 'variable.parameter.state-name.css']); + }); + + it('does not read a class selector as a view-transition name', function () { + // `` is `'*' | `. The class + // additions in css-view-transitions-2 are not implemented here, so + // a class is left to tokenize as an ordinary class selector. + var tokens = testGrammar.tokenizeLine('a::view-transition-old(.active) {}').tokens; + assert.ok(!tokens.some(x => x.scopes.includes('variable.parameter.pseudo-element.css'))); + }); + + it('reads a comment before a selector argument', function () { + [ + ['a:state(/*c*/ foo) {}', 'foo', 'variable.parameter.state-name.css'], + ['a::highlight(/*c*/ foo) {}', 'foo', 'variable.parameter.pseudo-element.css'], + ['a::scroll-button(/*c*/ up) {}', 'up', 'support.constant.property-value.css'], + ['a::view-transition-old(/*c*/ *) {}', '*', 'entity.name.tag.wildcard.css'], + ['a::scroll-button(/*c*/ *) {}', '*', 'entity.name.tag.wildcard.css'], + ['a:local-link(/*c*/ 2) {}', '2', 'constant.numeric.css'] + ].forEach(function (c) { + var tokens = testGrammar.tokenizeLine(c[0]).tokens; + assert.deepStrictEqual(tokens.find(x => x.value === c[1]).scopes, ['source.css', 'meta.selector.css', c[2]], c[0]); + assert.deepStrictEqual(tokens.find(x => x.value === 'c').scopes, ['source.css', 'meta.selector.css', 'comment.block.css'], c[0]); + }); + }); + + it('reads a comment after a selector argument', function () { + // The argument is still the sole argument, so it keeps its scope, + // and the comment is a comment rather than part of it. + [ + ['a:state(foo /*c*/) {}', 'foo', 'variable.parameter.state-name.css'], + ['a::highlight(foo /*c*/) {}', 'foo', 'variable.parameter.pseudo-element.css'], + ['a::part(foo /*c*/) {}', 'foo', 'variable.parameter.pseudo-element.css'], + ['a::scroll-button(up /*c*/) {}', 'up', 'support.constant.property-value.css'], + ['a::view-transition-old(foo /*c*/) {}', 'foo', 'variable.parameter.pseudo-element.css'], + ['a:local-link(2 /*c*/) {}', '2', 'constant.numeric.css'] + ].forEach(function (c) { + var tokens = testGrammar.tokenizeLine(c[0]).tokens; + assert.deepStrictEqual(tokens.find(x => x.value === c[1]).scopes, ['source.css', 'meta.selector.css', c[2]], c[0]); + assert.deepStrictEqual(tokens.find(x => x.value === 'c').scopes, ['source.css', 'meta.selector.css', 'comment.block.css'], c[0]); + }); + }); + + it('reads a selector argument written on its own line', function () { + // A `\G` anchor does not survive a line break, so the matchers + // take a line start as well: a wrapped selector is still valid. + [ + ['a:state(\n\tfoo\n) {}', 'foo', 'variable.parameter.state-name.css'], + ['a::highlight(\n\tfoo\n) {}', 'foo', 'variable.parameter.pseudo-element.css'], + ['a::part(\n\tfoo\n) {}', 'foo', 'variable.parameter.pseudo-element.css'], + ['a::scroll-button(\n\tup\n) {}', 'up', 'support.constant.property-value.css'], + ['a::scroll-button(\n\t*\n) {}', '*', 'entity.name.tag.wildcard.css'], + ['a::view-transition-old(\n\tfoo\n) {}', 'foo', 'variable.parameter.pseudo-element.css'], + ['a::view-transition-old(\n\t*\n) {}', '*', 'entity.name.tag.wildcard.css'], + ['a:local-link(\n\t2\n) {}', '2', 'constant.numeric.css'] + ].forEach(function (c) { + var lines = testGrammar.tokenizeLines(c[0]); + var token = lines[1].find(x => x.value === c[1]); + assert.deepStrictEqual(token && token.scopes, ['source.css', 'meta.selector.css', c[2]], c[0]); + }); + }); + + it('does not read a keyword written as an escape', function () { + // A keyword list is written out, so `up` spelled `\75 p` and `of` + // spelled `\6f f` are not recognised. Nothing is scoped wrongly: + // the argument is simply left alone, as it is on `main`. + [ + ['a::scroll-button(\\75 p) {}', 'support.constant.property-value.css'], + ['a:nth-child(2n \\6f f a) {}', 'keyword.operator.logical.of.css'] + ].forEach(function (c) { + var tokens = testGrammar.tokenizeLine(c[0]).tokens; + assert.ok(!tokens.some(x => x.scopes.includes(c[1])), c[0]); + }); + }); + + it('takes a non-negative integer in the functional :local-link()', function () { + // selectors-5: "As a functional pseudo-class, :local-link() can + // also accept a non-negative integer as its sole argument". + var tokens = testGrammar.tokenizeLine('a:local-link(2) {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === ':').scopes, ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-class.css', 'punctuation.definition.entity.css']); + assert.deepStrictEqual(tokens.find(x => x.value === 'local-link').scopes, ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-class.css']); + assert.deepStrictEqual(tokens.find(x => x.value === '(').scopes, ['source.css', 'meta.selector.css', 'punctuation.section.function.begin.bracket.round.css']); + assert.deepStrictEqual(tokens.find(x => x.value === ')').scopes, ['source.css', 'meta.selector.css', 'punctuation.section.function.end.bracket.round.css']); + assert.deepStrictEqual(tokens.find(x => x.value === '2').scopes, ['source.css', 'meta.selector.css', 'constant.numeric.css']); + ['a:local-link(-1) {}', 'a:local-link(2 3) {}'].forEach(function (line) { + var bad = testGrammar.tokenizeLine(line).tokens; + assert.ok(!bad.some(x => x.scopes.includes('constant.numeric.css')), line); + }); + }); + + it('does not treat a longer identifier as a selector it adds', function () { + // Each name list has to stop at a word boundary. Without one, + // `:local-linkish` and `::view-transition-oldish` pick up the + // scopes that belong to the shorter names they start with. + ['a:local-linkish {}', 'a::view-transition-oldish {}'].forEach(function (line) { + var tokens = testGrammar.tokenizeLine(line).tokens; + assert.ok(!tokens.some(x => x.scopes.some(sc => sc.startsWith('entity.other.attribute-name.pseudo'))), line); + }); + }); + + it('keeps pseudo-classes deferred to Selectors 5', function () { + ['blank', 'local-link'].forEach(function (pseudoClass) { + var tokens = testGrammar.tokenizeLine('a:' + pseudoClass + ' {}').tokens; + assert.deepStrictEqual(tokens[2], { scopes: ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-class.css'], value: pseudoClass }); + }); + }); + + it('releases the closing parenthesis of an of clause', function () { + // `:nth-child(An+B of S)` ends the selector subregion before the + // pseudo-class rule closes, and that release was unpinned. + var tokens = testGrammar.tokenizeLine('a:nth-child(2 of .x) {}').tokens; + assert.deepStrictEqual(tokens[6], { + scopes: ['source.css', 'meta.selector.css', 'keyword.operator.logical.of.css'], + value: 'of' + }); + assert.deepStrictEqual(tokens[10], { + scopes: [ + 'source.css', + 'meta.selector.css', + 'punctuation.section.function.end.bracket.round.css' + ], + value: ')' + }); + }); + + it('tokenizes ::part() and ::slotted()', function () { + var tokens; + tokens = testGrammar.tokenizeLine('a::part(btn) {}').tokens; + assert.deepStrictEqual(tokens[2], { scopes: ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-element.css'], value: 'part' }); + assert.deepStrictEqual(tokens[4], { scopes: ['source.css', 'meta.selector.css', 'variable.parameter.pseudo-element.css'], value: 'btn' }); + + tokens = testGrammar.tokenizeLine('a::slotted(.x) {}').tokens; + assert.deepStrictEqual(tokens[2], { scopes: ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-element.css'], value: 'slotted' }); + assert.deepStrictEqual(tokens[5], { scopes: ['source.css', 'meta.selector.css', 'entity.other.attribute-name.class.css'], value: 'x' }); + }); + + it('tokenizes :state() with a custom identifier', function () { + var tokens; + tokens = testGrammar.tokenizeLine('a:state(loading) {}').tokens; + assert.deepStrictEqual(tokens[2], { scopes: ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-class.css'], value: 'state' }); + assert.deepStrictEqual(tokens[4], { scopes: ['source.css', 'meta.selector.css', 'variable.parameter.state-name.css'], value: 'loading' }); + }); + + it('tokenizes additional pseudo-classes', function () { + ['popover-open', 'user-valid', 'user-invalid', 'placeholder-shown', 'autofill', 'modal', 'defined', 'open'].forEach(function (pc) { + var tokens = testGrammar.tokenizeLine('a:' + pc + ' {}').tokens; + assert.deepStrictEqual(tokens[2], { scopes: ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-class.css'], value: pc }); + }); + }); + + it('tokenizes additional pseudo-elements', function () { + ['details-content', 'file-selector-button', 'target-text', 'backdrop', 'marker'].forEach(function (pe) { + var tokens = testGrammar.tokenizeLine('a::' + pe + ' {}').tokens; + assert.deepStrictEqual(tokens[2], { scopes: ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-element.css'], value: pe }); + }); + }); + + it('tokenizes functional :host() rather than the bare pseudo-class', function () { + var tokens; + tokens = testGrammar.tokenizeLine('a:host(.dark) {}').tokens; + var host = tokens.find(t => t.value === 'host'); + assert.deepStrictEqual(host.scopes, ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-class.css']); + var open = tokens.find(t => t.value === '('); + assert.deepStrictEqual(open.scopes, ['source.css', 'meta.selector.css', 'punctuation.section.function.begin.bracket.round.css']); + var cls = tokens.find(t => t.value === 'dark'); + assert.deepStrictEqual(cls.scopes, ['source.css', 'meta.selector.css', 'entity.other.attribute-name.class.css']); + }); + + it('tokenizes scroll marker position pseudo-classes', function () { + ['target-before', 'target-current', 'target-after'].forEach(function (pseudoClass) { + var tokens = testGrammar.tokenizeLine('a:' + pseudoClass + ' {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === pseudoClass).scopes, ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-class.css'], pseudoClass); + }); + }); + + it('tokenizes the `of` clause of :nth-child()', function () { + var tokens; + tokens = testGrammar.tokenizeLine('a:nth-child(2 of .x) {}').tokens; + var of = tokens.find(t => t.value === 'of'); + assert.deepStrictEqual(of.scopes, ['source.css', 'meta.selector.css', 'keyword.operator.logical.of.css']); + var cls = tokens.find(t => t.value === 'x'); + assert.deepStrictEqual(cls.scopes, ['source.css', 'meta.selector.css', 'entity.other.attribute-name.class.css']); + }); + + it('uses the argument grammar for each functional pseudo-element', function () { + var tokens = testGrammar.tokenizeLine('a::part(left)::view-transition-old(*)::scroll-button(next)::scroll-button(*) {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'left'), { scopes: ['source.css', 'meta.selector.css', 'variable.parameter.pseudo-element.css'], value: 'left' }); + assert.deepStrictEqual(tokens.filter(x => x.value === '*').map(x => x.scopes), [ + ['source.css', 'meta.selector.css', 'entity.name.tag.wildcard.css'], + ['source.css', 'meta.selector.css', 'entity.name.tag.wildcard.css'] + ]); + assert.deepStrictEqual(tokens.find(x => x.value === 'next'), { scopes: ['source.css', 'meta.selector.css', 'support.constant.property-value.css'], value: 'next' }); + + tokens = testGrammar.tokenizeLine('a::part(*) {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === '*').scopes, ['source.css', 'meta.selector.css']); + }); + it('accepts a universal or namespaced selector after an of clause', function () { + var tokens = testGrammar.tokenizeLine('li:nth-child(2 of*) { }').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'of').scopes, ['source.css', 'meta.selector.css', 'keyword.operator.logical.of.css']); + assert.deepStrictEqual(tokens.find(x => x.value === '*').scopes, ['source.css', 'meta.selector.css', 'entity.name.tag.wildcard.css']); + var ns = testGrammar.tokenizeLine('li:nth-last-child(odd of*|div) { }').tokens; + assert.deepStrictEqual(ns.find(x => x.value === 'of').scopes, ['source.css', 'meta.selector.css', 'keyword.operator.logical.of.css']); + assert.deepStrictEqual(ns.find(x => x.value === 'div').scopes, ['source.css', 'meta.selector.css', 'entity.name.tag.css']); + }); + + it('accepts a leading escape in a custom identifier argument', function () { + // The class grammar already handles `.\_foo`, and these take the + // same custom identifier. + [ + ['::part(\\_foo) { }', 'variable.parameter.pseudo-element.css'], + [':state(\\_foo) { }', 'variable.parameter.state-name.css'], + ['::highlight(\\_foo) { }', 'variable.parameter.pseudo-element.css'], + ['::view-transition-old(\\_foo) { }', 'variable.parameter.pseudo-element.css'] + ].forEach(function (pair) { + var tokens = testGrammar.tokenizeLine(pair[0]).tokens; + assert.deepStrictEqual(tokens.find(x => x.value === '\\_').scopes, ['source.css', 'meta.selector.css', pair[1], 'constant.character.escape.css'], pair[0]); + assert.deepStrictEqual(tokens.find(x => x.value === 'foo').scopes, ['source.css', 'meta.selector.css', pair[1]], pair[0]); + }); + }); + + it('marks a comma or a combinator in ::slotted() invalid', function () { + // ::slotted() takes one compound selector, not a selector list. + var list = testGrammar.tokenizeLine('::slotted(.a, .b) { }').tokens; + assert.deepStrictEqual(list.find(x => x.value === ',').scopes, ['source.css', 'meta.selector.css', 'invalid.illegal.comma.css']); + var child = testGrammar.tokenizeLine('::slotted(.a > .b) { }').tokens; + assert.deepStrictEqual(child.find(x => x.value === '>').scopes, ['source.css', 'meta.selector.css', 'invalid.illegal.combinator.css']); + var ok = testGrammar.tokenizeLine('::slotted(span.a) { }').tokens; + assert.deepStrictEqual(ok.find(x => x.value === 'span').scopes, ['source.css', 'meta.selector.css', 'entity.name.tag.css']); + }); + + // Every name this change adds to a list is asserted here, so that + // removing one from the grammar fails a test. + function eachSel(cases, want) { + cases.forEach(function (pair) { + var tokens = testGrammar.tokenizeLine(pair[1]).tokens; + var token = tokens.find(x => x.value === pair[0]); + assert.ok(token, pair[0] + ' produced no token in: ' + pair[1]); + assert.deepStrictEqual(token.scopes, want, pair[1]); + }); + } + + it('names every pseudo-class it adds', function () { + eachSel([ + ['picture-in-picture', 'a:picture-in-picture {}'], + ['unchecked', 'a:unchecked {}'], + ['volume-locked', 'a:volume-locked {}'], + ['autofill', 'a:autofill {}'], + ['blank', 'a:blank {}'], + ['buffering', 'a:buffering {}'], + ['current', 'a:current {}'], + ['defined', 'a:defined {}'], + ['future', 'a:future {}'], + ['local-link', 'a:local-link {}'], + ['modal', 'a:modal {}'], + ['muted', 'a:muted {}'], + ['past', 'a:past {}'], + ['paused', 'a:paused {}'], + ['placeholder-shown', 'a:placeholder-shown {}'], + ['playing', 'a:playing {}'], + ['popover-open', 'a:popover-open {}'], + ['seeking', 'a:seeking {}'], + ['stalled', 'a:stalled {}'], + ['target-after', 'a:target-after {}'], + ['target-before', 'a:target-before {}'], + ['target-current', 'a:target-current {}'], + ['user-invalid', 'a:user-invalid {}'], + ['user-valid', 'a:user-valid {}'], + ['visited', 'a:visited {}'] + ], ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-class.css']); + }); + + it('names every pseudo-element it adds', function () { + eachSel([ + ['scroll-marker-group', 'a::scroll-marker-group {}'], + ['view-transition', 'a::view-transition {}'], + ['checkmark', 'a::checkmark {}'], + ['details-content', 'a::details-content {}'], + ['file-selector-button', 'a::file-selector-button {}'], + ['picker-icon', 'a::picker-icon {}'], + ['scroll-marker', 'a::scroll-marker {}'], + ['spelling-error', 'a::spelling-error {}'], + ['target-text', 'a::target-text {}'], + ['column', 'a::column {}'] + ], ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-element.css']); + }); + + it('names the functional pseudo-class it adds', function () { + eachSel([ + ['host-context', 'a:host-context(.x) {}'] + ], ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-class.css']); + }); + + it('names every functional pseudo-element it adds', function () { + eachSel([ + ['part', 'a::part(n) {}'], + ['view-transition-group', 'a::view-transition-group(n) {}'], + ['view-transition-group-children', 'a::view-transition-group-children(n) {}'], + ['view-transition-image-pair', 'a::view-transition-image-pair(n) {}'], + ['view-transition-new', 'a::view-transition-new(n) {}'] + ], ['source.css', 'meta.selector.css', 'entity.other.attribute-name.pseudo-element.css']); + }); + + it('recognises every scroll-button direction', function () { + eachSel([ + ['block-start', 'a::scroll-button(block-start) {}'], + ['block-end', 'a::scroll-button(block-end) {}'], + ['inline-start', 'a::scroll-button(inline-start) {}'], + ['inline-end', 'a::scroll-button(inline-end) {}'], + ['up', 'a::scroll-button(up) {}'], + ['down', 'a::scroll-button(down) {}'], + ['left', 'a::scroll-button(left) {}'], + ['right', 'a::scroll-button(right) {}'], + ['prev', 'a::scroll-button(prev) {}'], + ['next', 'a::scroll-button(next) {}'] + ], ['source.css', 'meta.selector.css', 'support.constant.property-value.css']); + }); + + it('marks a combinator inside a compound-selector pseudo-class', function () { + // :host() and :host-context() each take a single compound selector, + // and the descendant combinator is written as whitespace. + [ + [':host(.a > .b) {}', '>'], + [':host(.a + .b) {}', '+'], + [':host(.a ~ .b) {}', '~'], + [':host(.a || .b) {}', '||'], + [':host(.a .b) {}', ' '], + [':host(.a\t.b) {}', '\t'], + [':host(.a\f.b) {}', '\f'], + [':HOST(.a .b) {}', ' '], + [':Host-Context(.a .b) {}', ' '], + [':host-context(.a ~ .b) {}', '~'], + [':current(.a .b) {}', ' '], + [':current(.a, .b .c) {}', ' '], + [':host(.a/**/ .b) {}', ' '], + [':host(.\\61 .b) {}', ' '], + [':host(.\\\\61 .b) {}', ' '], + [':host(.\\\\61 .b) {}', ' '], + ['::slotted(.a .b) {}', ' '], + ['::slotted(.a > .b) {}', '>'], + ['::slotted(.a || .b) {}', '||'] + ].forEach(function (pair) { + var tokens = testGrammar.tokenizeLine(pair[0]).tokens; + var token = tokens.find(x => x.scopes.includes('invalid.illegal.combinator.css')); + assert.ok(token, 'no combinator marked in: ' + pair[0]); + assert.strictEqual(token.value, pair[1], pair[0]); + }); + }); + + it('marks a selector list where only one compound selector is allowed', function () { + [':host(.a, .b) {}', ':host-context(.a, .b) {}', '::slotted(.a, .b) {}'].forEach(function (line) { + var tokens = testGrammar.tokenizeLine(line).tokens; + var token = tokens.find(x => x.scopes.includes('invalid.illegal.comma.css')); + assert.ok(token, 'no comma marked in: ' + line); + assert.strictEqual(token.value, ',', line); + }); + }); + + it('leaves a valid compound selector alone', function () { + // A compound selector has no combinator in it. Whitespace that + // terminates a hexadecimal escape belongs to the name before it, so + // `.\\61 .b` is the single compound selector `.a.b`. + [ + ':host(.a.b) {}', + '::slotted( .a ) {}', + ':host(.a/**/.b) {}', + ':host(.\\61 .b) {}', + ':host(\\64 iv.x) {}', + '::slotted([data-x="a b"]) {}', + ':host([title="a > b"]) {}', + ':current(.a, .b) {}', + ':host(.\\\\\\61 .b) {}', + ':host(.\\\\\\\\\\61 .b) {}', + '::slotted(.\\61 .b) {}' + ].forEach(function (line) { + var tokens = testGrammar.tokenizeLine(line).tokens; + assert.ok(!tokens.some(t => t.scopes.some(sc => sc.startsWith('invalid'))), line); + }); + }); + + it('still allows a complex selector where one is valid', function () { + // These take complex selectors, or a list of them, so the compound + // selector restriction above must not reach them. + [ + ':is(.a .b) {}', + ':not(.a .b) {}', + ':has(.a .b) {}', + ':where(.a .b) {}', + ':matches(.a .b) {}', + ':current(.a, .b) {}', + ':is(.a, .b) {}' + ].forEach(function (line) { + var tokens = testGrammar.tokenizeLine(line).tokens; + assert.ok(!tokens.some(t => t.scopes.some(sc => sc.startsWith('invalid'))), line); + }); + }); + + it('keeps a compound-selector pseudo-class open across a nested function', function () { + // The closing bracket of a nested function does not end :host(). + var tokens = testGrammar.tokenizeLine(':host(:not(.a)) .b {}').tokens; + var token = tokens.find(x => x.value === 'b'); + assert.deepStrictEqual(token.scopes, [ + 'source.css', 'meta.selector.css', 'entity.other.attribute-name.class.css' + ]); + }); + + it('punctuates every functional selector it adds', function () { + // The name, its leading colons and both brackets are all scoped. + [ + [':host', 'pseudo-class', ':host(.a) {}'], + [':host-context', 'pseudo-class', ':host-context(.a) {}'], + [':state', 'pseudo-class', ':state(a) {}'], + [':current', 'pseudo-class', ':current(.a) {}'], + ['::part', 'pseudo-element', '::part(a) {}'], + ['::highlight', 'pseudo-element', '::highlight(a) {}'], + ['::slotted', 'pseudo-element', '::slotted(.a) {}'], + ['::scroll-button', 'pseudo-element', '::scroll-button(up) {}'], + ['::view-transition-group', 'pseudo-element', '::view-transition-group(a) {}'], + ['::view-transition-group-children', 'pseudo-element', '::view-transition-group-children(a) {}'], + ['::view-transition-image-pair', 'pseudo-element', '::view-transition-image-pair(a) {}'], + ['::view-transition-new', 'pseudo-element', '::view-transition-new(a) {}'], + ['::view-transition-old', 'pseudo-element', '::view-transition-old(a) {}'] + ].forEach(function (row) { + var colons = row[0].startsWith('::') ? '::' : ':'; + var name = row[0].slice(colons.length); + var tokens = testGrammar.tokenizeLine(row[2]).tokens; + var base = ['source.css', 'meta.selector.css']; + assert.deepStrictEqual(tokens.find(x => x.value === name).scopes, + base.concat(['entity.other.attribute-name.' + row[1] + '.css']), row[2]); + assert.deepStrictEqual(tokens.find(x => x.value === colons).scopes, + base.concat(['entity.other.attribute-name.' + row[1] + '.css', 'punctuation.definition.entity.css']), row[2]); + assert.deepStrictEqual(tokens.find(x => x.value === '(').scopes, + base.concat(['punctuation.section.function.begin.bracket.round.css']), row[2]); + assert.deepStrictEqual(tokens.find(x => x.value === ')').scopes, + base.concat(['punctuation.section.function.end.bracket.round.css']), row[2]); + }); + }); + + it('reads a comment inside every functional selector it adds', function () { + [ + ':state(/*c*/ a) {}', + '::part(/*c*/ a) {}', + '::highlight(/*c*/ a) {}', + '::view-transition-group(/*c*/ a) {}', + '::view-transition-old(/*c*/ a) {}', + '::scroll-button(/*c*/ up) {}' + ].forEach(function (line) { + var tokens = testGrammar.tokenizeLine(line).tokens; + assert.ok(tokens.some(t => t.value === 'c' && t.scopes.includes('comment.block.css')), + 'no comment in: ' + line); + }); + }); + + it('keeps ::slotted() open across a nested function and a string', function () { + [ + '::slotted(:not(.a)) .b {}', + '::slotted([title=")"]) .b {}' + ].forEach(function (line) { + var tokens = testGrammar.tokenizeLine(line).tokens; + assert.deepStrictEqual(tokens.find(x => x.value === 'b').scopes, [ + 'source.css', 'meta.selector.css', 'entity.other.attribute-name.class.css' + ], line); + }); + }); + + it('names the parity keywords in a child-indexed function', function () { + ['nth-child', 'nth-last-child', 'nth-of-type', 'nth-last-of-type'].forEach(function (fn) { + ['even', 'odd'].forEach(function (kw) { + var line = 'a:' + fn + '(' + kw + ') {}'; + var tokens = testGrammar.tokenizeLine(line).tokens; + assert.deepStrictEqual(tokens.find(x => x.value === kw).scopes, + ['source.css', 'meta.selector.css', 'support.constant.parity.css'], line); + }); + }); + }); + + it('takes an of-clause only in the child-indexed functions', function () { + // :nth-child() and :nth-last-child() take `of `, the typed + // variants do not. + [':nth-child(2 of .x)', ':nth-last-child(2 of .x)'].forEach(function (sel) { + var tokens = testGrammar.tokenizeLine('a' + sel + ' {}').tokens; + assert.ok(tokens.some(t => t.value === 'of' && t.scopes.includes('keyword.operator.logical.of.css')), sel); + assert.ok(tokens.some(t => t.value === 'x' && t.scopes.includes('entity.other.attribute-name.class.css')), sel); + }); + [':nth-of-type(2 of .x)', ':nth-last-of-type(2 of .x)'].forEach(function (sel) { + var tokens = testGrammar.tokenizeLine('a' + sel + ' {}').tokens; + assert.ok(!tokens.some(t => t.scopes.includes('keyword.operator.logical.of.css')), sel); + assert.ok(!tokens.some(t => t.value === 'x' && t.scopes.includes('entity.other.attribute-name.class.css')), sel); + }); + }); + + }); + + describe('media features', function () { + + function eachSel(cases, want) { + cases.forEach(function (pair) { + var tokens = testGrammar.tokenizeLine(pair[1]).tokens; + var token = tokens.find(x => x.value === pair[0]); + assert.ok(token, pair[0] + ' produced no token in: ' + pair[1]); + assert.deepStrictEqual(token.scopes, want, pair[1]); + }); + } + + it('recognises interaction media features', function () { + ['pointer', 'any-pointer', 'hover', 'any-hover', 'update', 'scripting'].forEach(function (mf) { + var tokens = testGrammar.tokenizeLine('@media (' + mf + ': none) {}').tokens; + var t = tokens.find(x => x.value === mf); + assert.deepStrictEqual(t.scopes, ['source.css', 'meta.at-rule.media.header.css', 'support.type.property-name.media.css'], mf); + }); + }); + + it('recognises user-preference media features', function () { + ['prefers-color-scheme', 'prefers-contrast', 'prefers-reduced-motion', 'forced-colors', 'dynamic-range'].forEach(function (mf) { + var tokens = testGrammar.tokenizeLine('@media (' + mf + ': none) {}').tokens; + var t = tokens.find(x => x.value === mf); + assert.deepStrictEqual(t.scopes, ['source.css', 'meta.at-rule.media.header.css', 'support.type.property-name.media.css'], mf); + }); + }); + + it('treats viewport segments as range features', function () { + [ + 'horizontal-viewport-segments', + 'min-horizontal-viewport-segments', + 'max-horizontal-viewport-segments', + 'vertical-viewport-segments', + 'min-vertical-viewport-segments', + 'max-vertical-viewport-segments' + ].forEach(function (mf) { + var tokens = testGrammar.tokenizeLine('@media (' + mf + ': 2) {}').tokens; + var t = tokens.find(x => x.value === mf); + assert.deepStrictEqual(t.scopes, ['source.css', 'meta.at-rule.media.header.css', 'support.type.property-name.media.css'], mf); + }); + + var tokens = testGrammar.tokenizeLine('@media (horizontal-viewport-segments > 1) {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === '>').scopes, ['source.css', 'meta.at-rule.media.header.css', 'keyword.operator.comparison.css']); + }); + + it('recognises the values of each discrete media feature it names', function () { + // A feature name without its values is only half of the feature, + // so every name this change adds is paired with its values. + [ + ['overflow-block', 'paged'], + ['overflow-block', 'scroll'], + ['overflow-inline', 'scroll'], + ['inverted-colors', 'inverted'], + ['nav-controls', 'back'], + ['video-color-gamut', 'p3'], + ['ua-color-scheme', 'dark'], + ['environment-blending', 'opaque'], + ['environment-blending', 'additive'], + ['environment-blending', 'subtractive'], + ['scripting', 'initial-only'], + ['update', 'slow'], + ['prefers-color-scheme', 'dark'], + ['prefers-reduced-motion', 'reduce'], + ['prefers-contrast', 'more'], + ['forced-colors', 'active'], + ['color-gamut', 'p3'], + ['dynamic-range', 'high'] + ].forEach(function (pair) { + var tokens = testGrammar.tokenizeLine('@media (' + pair[0] + ': ' + pair[1] + ') {}').tokens; + assert.deepStrictEqual(tokens.find(x => x.value === pair[0]).scopes, ['source.css', 'meta.at-rule.media.header.css', 'support.type.property-name.media.css'], pair.join(': ')); + assert.deepStrictEqual(tokens.find(x => x.value === pair[1]).scopes, ['source.css', 'meta.at-rule.media.header.css', 'support.constant.property-value.css'], pair.join(': ')); + }); + }); + + it('names every media feature it adds', function () { + eachSel([ + ['horizontal-viewport-segments', '@media (horizontal-viewport-segments: none) {}'], + ['any-pointer', '@media (any-pointer: none) {}'], + ['update', '@media (update: none) {}'], + ['prefers-contrast', '@media (prefers-contrast: none) {}'], + ['video-color-gamut', '@media (video-color-gamut: srgb) {}'], + ['ua-color-scheme', '@media (ua-color-scheme: light) {}'], + ['prefers-reduced-motion', '@media (prefers-reduced-motion: none) {}'], + ['prefers-reduced-transparency', '@media (prefers-reduced-transparency: none) {}'], + ['prefers-reduced-data', '@media (prefers-reduced-data: none) {}'], + ['forced-colors', '@media (forced-colors: none) {}'], + ['inverted-colors', '@media (inverted-colors: none) {}'], + ['dynamic-range', '@media (dynamic-range: none) {}'], + ['video-dynamic-range', '@media (video-dynamic-range: none) {}'], + ['scripting', '@media (scripting: none) {}'], + ['color-gamut', '@media (color-gamut: none) {}'], + ['environment-blending', '@media (environment-blending: none) {}'] + ], ['source.css', 'meta.at-rule.media.header.css', 'support.type.property-name.media.css']); + }); + + it('recognises every media feature value it adds', function () { + eachSel([ + ['no-preference', '@media (prefers-reduced-motion: no-preference) {}'], + ['coarse', '@media (any-pointer: coarse) {}'], + ['fine', '@media (any-pointer: fine) {}'], + ['slow', '@media (update: slow) {}'], + ['fast', '@media (update: fast) {}'], + ['more', '@media (prefers-contrast: more) {}'], + ['less', '@media (prefers-contrast: less) {}'], + ['custom', '@media (prefers-contrast: custom) {}'], + ['initial-only', '@media (scripting: initial-only) {}'], + ['rec2020', '@media (color-gamut: rec2020) {}'], + ['standard', '@media (dynamic-range: standard) {}'], + ['high', '@media (dynamic-range: high) {}'], + ['paged', '@media (overflow-block: paged) {}'], + ['enabled', '@media (scripting: enabled) {}'], + ['light', '@media (prefers-color-scheme: light) {}'], + ['active', '@media (forced-colors: active) {}'], + ['none', '@media (forced-colors: none) {}'], + ['srgb', '@media (color-gamut: srgb) {}'] + ], ['source.css', 'meta.at-rule.media.header.css', 'support.constant.property-value.css']); + }); + + }); });