diff --git a/src/parser/cssNodes.ts b/src/parser/cssNodes.ts index a0193800..d7ebd7df 100644 --- a/src/parser/cssNodes.ts +++ b/src/parser/cssNodes.ts @@ -64,6 +64,7 @@ export enum NodeType { MixinContentReference, MixinContentDeclaration, Media, + Scope, Keyframe, FontFace, Import, @@ -1166,6 +1167,58 @@ export class Media extends BodyDeclaration { } } +export class Scope extends BodyDeclaration { + constructor(offset: number, length: number) { + super(offset, length); + } + + public get type(): NodeType { + return NodeType.Scope; + } +} + +export class ScopeLimits extends Node { + public scopeStart?: Node; + public scopeEnd?: Node; + + constructor(offset: number, length: number) { + super(offset, length); + } + + public get type(): NodeType { + return NodeType.Scope; + } + + public getScopeStart(): Node | undefined { + return this.scopeStart; + } + + public setScopeStart(right: Node | null): right is Node { + return this.setNode('scopeStart', right); + } + + public getScopeEnd(): Node | undefined { + return this.scopeEnd; + } + + public setScopeEnd(right: Node | null): right is Node { + return this.setNode('scopeEnd', right); + } + + public getName(): string { + let name = '' + + if (this.scopeStart) { + name += this.scopeStart.getText() + } + if (this.scopeEnd) { + name += `${this.scopeStart ? ' ' : ''}→ ${this.scopeEnd.getText()}` + } + + return name + } +} + export class Supports extends BodyDeclaration { constructor(offset: number, length: number) { diff --git a/src/parser/cssParser.ts b/src/parser/cssParser.ts index a9ef2c23..588f24e4 100644 --- a/src/parser/cssParser.ts +++ b/src/parser/cssParser.ts @@ -316,6 +316,7 @@ export class Parser { public _parseStylesheetAtStatement(isNested: boolean = false): nodes.Node | null { return this._parseImport() || this._parseMedia(isNested) + || this._parseScope() || this._parsePage() || this._parseFontFace() || this._parseKeyframe() @@ -364,6 +365,7 @@ export class Parser { protected _parseRuleSetDeclarationAtStatement(): nodes.Node | null { return this._parseMedia(true) + || this._parseScope() || this._parseSupports(true) || this._parseLayer(true) || this._parseContainer(true) @@ -398,6 +400,7 @@ export class Parser { case nodes.NodeType.MixinDeclaration: case nodes.NodeType.FunctionDeclaration: case nodes.NodeType.MixinContentDeclaration: + case nodes.NodeType.Scope: return false; case nodes.NodeType.ExtendsReference: case nodes.NodeType.MixinContentReference: @@ -1246,6 +1249,68 @@ export class Parser { return this._parseRatio() || this._parseTermExpression(); } + public _parseScope(): nodes.Node | null { + // @scope []? { } + if (!this.peekKeyword('@scope')) { + return null; + } + + const node = this.create(nodes.Scope); + // @scope + this.consumeToken(); + + node.addChild(this._parseScopeLimits()) + + return this._parseBody(node, this._parseScopeDeclaration.bind(this)); + } + + public _parseScopeDeclaration(): nodes.Node | null { + // Treat as nested as regular declarations are implicity wrapped with :where(:scope) + // https://github.com/w3c/csswg-drafts/issues/10389 + // pseudo-selectors implicitly target :scope + // https://drafts.csswg.org/css-cascade-6/#scoped-rules + const isNested = true + return this._tryParseRuleset(isNested) + || this._tryToParseDeclaration() + || this._parseStylesheetStatement(isNested); + } + + public _parseScopeLimits(): nodes.Node | null { + // [()]? [to ()]? + const node = this.create(nodes.ScopeLimits); + + // [()]? + if (this.accept(TokenType.ParenthesisL)) { + // scope-start selector can start with a combinator as it defaults to :scope + // Treat as nested + if (!node.setScopeStart(this._parseSelector(true))) { + return this.finish(node, ParseError.SelectorExpected, [], [TokenType.ParenthesisR]) + } + + if (!this.accept(TokenType.ParenthesisR)) { + return this.finish(node, ParseError.RightParenthesisExpected, [], [TokenType.CurlyL]); + } + } + + // [to ()]? + if (this.acceptIdent('to')) { + if (!this.accept(TokenType.ParenthesisL)) { + return this.finish(node, ParseError.LeftParenthesisExpected, [], [TokenType.CurlyL]); + } + // 'to' selector can start with a combinator as it defaults to :scope + // Treat as nested + if (!node.setScopeEnd(this._parseSelector(true))) { + return this.finish(node, ParseError.SelectorExpected, [], [TokenType.ParenthesisR]) + } + + if (!this.accept(TokenType.ParenthesisR)) { + return this.finish(node, ParseError.RightParenthesisExpected, [], [TokenType.CurlyL]); + } + } + + return this.finish(node) + } + public _parseMedium(): nodes.Node | null { const node = this.create(nodes.Node); if (node.addChild(this._parseIdent())) { diff --git a/src/services/cssHover.ts b/src/services/cssHover.ts index c0945a6d..dbcbd097 100644 --- a/src/services/cssHover.ts +++ b/src/services/cssHover.ts @@ -40,23 +40,32 @@ export class CSSHover { * Build up the hover by appending inner node's information */ let hover: Hover | null = null; - let flagOpts: { text: string; isMedia: boolean }; + let selectorContexts: string[] = []; for (let i = 0; i < nodepath.length; i++) { const node = nodepath[i]; + if (node instanceof nodes.Scope) { + const scopeLimits = node.getChild(0) + + if (scopeLimits instanceof nodes.ScopeLimits) { + const scopeName = `${scopeLimits.getName()}` + selectorContexts.push(`@scope${scopeName ? ` ${scopeName}` : ''}`); + } + } + if (node instanceof nodes.Media) { - const regex = /@media[^\{]+/g; - const matches = node.getText().match(regex); - flagOpts = { - isMedia: true, - text: matches?.[0]!, - }; + const mediaList = node.getChild(0); + + if (mediaList instanceof nodes.Medialist) { + const name = '@media ' + mediaList.getText(); + selectorContexts.push(name) + } } if (node instanceof nodes.Selector) { hover = { - contents: this.selectorPrinting.selectorToMarkedString(node, flagOpts!), + contents: this.selectorPrinting.selectorToMarkedString(node, selectorContexts), range: getRange(node), }; break; diff --git a/src/services/cssNavigation.ts b/src/services/cssNavigation.ts index 21cc8fec..4192dfd2 100644 --- a/src/services/cssNavigation.ts +++ b/src/services/cssNavigation.ts @@ -291,6 +291,21 @@ export class CSSNavigation { const name = '@media ' + mediaList.getText(); collect(name, SymbolKind.Module, node, mediaList, node.getDeclarations()); } + } else if (node instanceof nodes.Scope) { + let scopeName = '' + + const scopeLimits = node.getChild(0) + if (scopeLimits instanceof nodes.ScopeLimits) { + scopeName = `${scopeLimits.getName()}` + } + + collect( + `@scope${scopeName ? ` ${scopeName}` : ''}`, + SymbolKind.Module, + node, + scopeLimits ?? undefined, + node.getDeclarations() + ) } return true; }); diff --git a/src/services/selectorPrinting.ts b/src/services/selectorPrinting.ts index 9e30c093..c4ca8dee 100644 --- a/src/services/selectorPrinting.ts +++ b/src/services/selectorPrinting.ts @@ -126,7 +126,7 @@ class MarkedStringPrinter { // empty } - public print(element: Element, flagOpts?: { isMedia: boolean; text: string }): MarkedString[] { + public print(element: Element, selectorContexts?: string[] ): MarkedString[] { this.result = []; if (element instanceof RootElement) { if (element.children) { @@ -136,8 +136,8 @@ class MarkedStringPrinter { this.doPrint([element], 0); } let value; - if (flagOpts) { - value = `${flagOpts.text}\n … ` + this.result.join('\n'); + if (selectorContexts) { + value = [...selectorContexts, ...this.result].join('\n') } else { value = this.result.join('\n'); } @@ -323,10 +323,10 @@ function unescape(content: string) { export class SelectorPrinting { constructor(private cssDataManager: CSSDataManager) { } - public selectorToMarkedString(node: nodes.Selector, flagOpts?: { isMedia: boolean; text: string }): MarkedString[] { + public selectorToMarkedString(node: nodes.Selector, selectorContexts?: string[] ): MarkedString[] { const root = selectorToElement(node); if (root) { - const markedStrings = new MarkedStringPrinter('"').print(root, flagOpts); + const markedStrings = new MarkedStringPrinter('"').print(root, selectorContexts); markedStrings.push(this.selectorToSpecificityMarkedString(node)); return markedStrings; } else { diff --git a/src/test/css/completion.test.ts b/src/test/css/completion.test.ts index f5e7b187..411ef35d 100644 --- a/src/test/css/completion.test.ts +++ b/src/test/css/completion.test.ts @@ -969,6 +969,22 @@ suite('CSS - Completion', () => { }); + test('@scope selector completion', async function () { + await testCompletionFor(`@scope (|) {`, { + items: [ + { label: 'html', resultText: '@scope (html) {' }, + { label: ':has', resultText: '@scope (:has) {' } + ] + }); + + await testCompletionFor(`@scope to (|) {`, { + items: [ + { label: 'html', resultText: '@scope to (html) {' }, + { label: ':has', resultText: '@scope to (:has) {' } + ] + }); + }) + }); function newRange(start: number, end: number) { diff --git a/src/test/css/hover.test.ts b/src/test/css/hover.test.ts index 21a452be..729401a3 100644 --- a/src/test/css/hover.test.ts +++ b/src/test/css/hover.test.ts @@ -75,16 +75,14 @@ suite('CSS Hover', () => { contents: [{ language: 'html', value: '' }, '[Selector Specificity](https://developer.mozilla.org/docs/Web/CSS/Specificity): (0, 1, 0)'], }); }); -}); -suite('SCSS Hover', () => { test('nested', () => { assertHover( 'div { d|iv {} }', { contents: [{ language: 'html', value: '
\n …\n
' }, '[Selector Specificity](https://developer.mozilla.org/docs/Web/CSS/Specificity): (0, 0, 1)'], }, - 'scss', + 'css', ); assertHover( '.foo{ .bar{ @media only screen{ .|bar{ } } } }', @@ -92,15 +90,45 @@ suite('SCSS Hover', () => { contents: [ { language: 'html', - value: '@media only screen\n … \n …\n \n …\n ', + value: '@media only screen\n\n …\n \n …\n ', }, '[Selector Specificity](https://developer.mozilla.org/docs/Web/CSS/Specificity): (0, 1, 0)', ], }, - 'scss', + 'css', + ); + + assertHover( + '@scope (.foo) to (.bar) { .|baz{ } }', + { + contents: [ + { + language: 'html', + value: '@scope .foo → .bar\n', + }, + '[Selector Specificity](https://developer.mozilla.org/docs/Web/CSS/Specificity): (0, 1, 0)', + ], + }, + 'css', + ); + + assertHover( + '@scope (.from) to (.to) { .foo { @media print { .bar { @media only screen{ .|bar{ } } } } } }', + { + contents: [ + { + language: 'html', + value: '@scope .from → .to\n@media print\n@media only screen\n\n …\n \n …\n ', + }, + '[Selector Specificity](https://developer.mozilla.org/docs/Web/CSS/Specificity): (0, 1, 0)', + ], + }, + 'css', ); }); +}); +suite('SCSS Hover', () => { test('@at-root', () => { assertHover( '.test { @|at-root { }', diff --git a/src/test/css/navigation.test.ts b/src/test/css/navigation.test.ts index 1c1a051f..1be08419 100644 --- a/src/test/css/navigation.test.ts +++ b/src/test/css/navigation.test.ts @@ -237,6 +237,7 @@ suite('CSS - Navigation', () => { assertScopesAndSymbols(ls, '@keyframes animation {}; .class {}', 'animation,.class,[],[]'); assertScopesAndSymbols(ls, '@page :pseudo-class { margin:2in; }', '[]'); assertScopesAndSymbols(ls, '@media print { body { font-size: 10pt } }', '[body,[]]'); + assertScopesAndSymbols(ls, '@scope (.foo) to (.bar) { body { font-size: 10pt } }', '[body,[]]') assertScopesAndSymbols(ls, '@-moz-keyframes identifier { 0% { top: 0; } 50% { top: 30px; left: 20px; }}', 'identifier,[[],[]]'); assertScopesAndSymbols(ls, '@font-face { font-family: "Bitstream Vera Serif Bold"; }', '[]'); }); @@ -276,6 +277,9 @@ suite('CSS - Navigation', () => { // Media Query assertSymbolInfos(ls, '@media screen, print {}', [{ name: '@media screen, print', kind: SymbolKind.Module, location: Location.create('test://test/test.css', newRange(0, 23)) }]); + + // Scope + assertSymbolInfos(ls, '@scope (.foo) to (.bar) {}', [{ name: '@scope .foo → .bar', kind: SymbolKind.Module, location: Location.create('test://test/test.css', newRange(0, 26)) }]); }); test('basic document symbols', () => { @@ -291,8 +295,9 @@ suite('CSS - Navigation', () => { // Media Query assertDocumentSymbols(ls, '@media screen, print {}', [{ name: '@media screen, print', kind: SymbolKind.Module, range: newRange(0, 23), selectionRange: newRange(7, 20) }]); - assertDocumentSymbols(ls, '@media screen, print {}', [{ name: '@media screen, print', kind: SymbolKind.Module, range: newRange(0, 23), selectionRange: newRange(7, 20) }]); - + + // Scope + assertDocumentSymbols(ls, '@scope (.foo) to (.bar) {}', [{ name: '@scope .foo → .bar', kind: SymbolKind.Module, range: newRange(0, 26), selectionRange: newRange(7, 23) }]); }); }); diff --git a/src/test/css/parser.test.ts b/src/test/css/parser.test.ts index b3b1fec6..53a241a9 100644 --- a/src/test/css/parser.test.ts +++ b/src/test/css/parser.test.ts @@ -55,6 +55,10 @@ suite('CSS - Parser', () => { assertNode('@media asdsa { } ', parser, parser._parseStylesheet.bind(parser)); assertNode('@media screen, projection { }', parser, parser._parseStylesheet.bind(parser)); assertNode('@media screen and (max-width: 400px) { @-ms-viewport { width: 320px; }}', parser, parser._parseStylesheet.bind(parser)); + assertNode('@scope {}', parser, parser._parseStylesheet.bind(parser)) + assertNode('@scope (.foo) {}', parser, parser._parseStylesheet.bind(parser)) + assertNode('@scope to (.bar) {}', parser, parser._parseStylesheet.bind(parser)) + assertNode('@scope (.foo) to (.bar) {}', parser, parser._parseStylesheet.bind(parser)) assertNode('@-ms-viewport { width: 320px; height: 768px; }', parser, parser._parseStylesheet.bind(parser)); assertNode('#boo, far {} \n.far boo {}', parser, parser._parseStylesheet.bind(parser)); assertNode('@-moz-keyframes darkWordHighlight { from { background-color: inherit; } to { background-color: rgba(83, 83, 83, 0.7); } }', parser, parser._parseStylesheet.bind(parser)); @@ -267,6 +271,39 @@ suite('CSS - Parser', () => { assertNode('not all and (monochrome)', parser, parser._parseMediaQueryList.bind(parser)); }); + + test('@scope', function () { + const parser = new Parser(); + assertNode('@scope { }', parser, parser._parseScope.bind(parser)) + assertNode('@scope (.foo) { }', parser, parser._parseScope.bind(parser)) + assertNode('@scope to (.bar) { }', parser, parser._parseScope.bind(parser)) + assertNode('@scope (.foo) to (.bar) { }', parser, parser._parseScope.bind(parser)) + assertNode('@scope (#foo) to (:has(> link)) {}', parser, parser._parseScope.bind(parser)) + + assertError('@scope ( { }', parser, parser._parseScope.bind(parser), ParseError.SelectorExpected) + assertError('@scope () { }', parser, parser._parseScope.bind(parser), ParseError.SelectorExpected) + assertError('@scope () to (.bar) { }', parser, parser._parseScope.bind(parser), ParseError.SelectorExpected) + assertError('@scope to () { }', parser, parser._parseScope.bind(parser), ParseError.SelectorExpected) + assertError('@scope (.foo) to () { }', parser, parser._parseScope.bind(parser), ParseError.SelectorExpected) + + assertError('@scope to (.bar { }', parser, parser._parseScope.bind(parser), ParseError.RightParenthesisExpected) + assertError('@scope (.foo to (.bar) { }', parser, parser._parseScope.bind(parser), ParseError.RightParenthesisExpected) + assertError('@scope (.foo) to (.bar { }', parser, parser._parseScope.bind(parser), ParseError.RightParenthesisExpected) + + assertError('@scope (.foo) to { }', parser, parser._parseScope.bind(parser), ParseError.LeftParenthesisExpected) + + assertError('@scope ', parser, parser._parseScope.bind(parser), ParseError.LeftCurlyExpected) + assertError('@scope .foo { }', parser, parser._parseScope.bind(parser), ParseError.LeftCurlyExpected) + assertError('@scope (.foo)', parser, parser._parseScope.bind(parser), ParseError.LeftCurlyExpected) + assertError('@scope to (.bar)', parser, parser._parseScope.bind(parser), ParseError.LeftCurlyExpected) + assertError('@scope (.foo) to (.bar)', parser, parser._parseScope.bind(parser), ParseError.LeftCurlyExpected) + + assertError('@scope {', parser, parser._parseScope.bind(parser), ParseError.RightCurlyExpected) + assertError('@scope (.foo) {', parser, parser._parseScope.bind(parser), ParseError.RightCurlyExpected) + assertError('@scope to (.bar) {', parser, parser._parseScope.bind(parser), ParseError.RightCurlyExpected) + assertError('@scope (.foo) to (.bar) {', parser, parser._parseScope.bind(parser), ParseError.RightCurlyExpected) + }) + test('medium', function () { const parser = new Parser(); assertNode('somename', parser, parser._parseMedium.bind(parser));