Skip to content

Commit 6a8b798

Browse files
authored
Support @scope (#434)
* Add @scope nodes * Add @scope parsing tests * Add parsing for @scope * Add @scope navigation tests * Add navigation for @scope * Add @scope hover tests, update @media hover tests * Add @scope hover support. Suppport nested @scope/@media hover contexts * Add @scope completion tests
1 parent d50a147 commit 6a8b798

File tree

9 files changed

+248
-20
lines changed

9 files changed

+248
-20
lines changed

src/parser/cssNodes.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export enum NodeType {
6464
MixinContentReference,
6565
MixinContentDeclaration,
6666
Media,
67+
Scope,
6768
Keyframe,
6869
FontFace,
6970
Import,
@@ -1166,6 +1167,58 @@ export class Media extends BodyDeclaration {
11661167
}
11671168
}
11681169

1170+
export class Scope extends BodyDeclaration {
1171+
constructor(offset: number, length: number) {
1172+
super(offset, length);
1173+
}
1174+
1175+
public get type(): NodeType {
1176+
return NodeType.Scope;
1177+
}
1178+
}
1179+
1180+
export class ScopeLimits extends Node {
1181+
public scopeStart?: Node;
1182+
public scopeEnd?: Node;
1183+
1184+
constructor(offset: number, length: number) {
1185+
super(offset, length);
1186+
}
1187+
1188+
public get type(): NodeType {
1189+
return NodeType.Scope;
1190+
}
1191+
1192+
public getScopeStart(): Node | undefined {
1193+
return this.scopeStart;
1194+
}
1195+
1196+
public setScopeStart(right: Node | null): right is Node {
1197+
return this.setNode('scopeStart', right);
1198+
}
1199+
1200+
public getScopeEnd(): Node | undefined {
1201+
return this.scopeEnd;
1202+
}
1203+
1204+
public setScopeEnd(right: Node | null): right is Node {
1205+
return this.setNode('scopeEnd', right);
1206+
}
1207+
1208+
public getName(): string {
1209+
let name = ''
1210+
1211+
if (this.scopeStart) {
1212+
name += this.scopeStart.getText()
1213+
}
1214+
if (this.scopeEnd) {
1215+
name += `${this.scopeStart ? ' ' : ''}${this.scopeEnd.getText()}`
1216+
}
1217+
1218+
return name
1219+
}
1220+
}
1221+
11691222
export class Supports extends BodyDeclaration {
11701223

11711224
constructor(offset: number, length: number) {

src/parser/cssParser.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@ export class Parser {
316316
public _parseStylesheetAtStatement(isNested: boolean = false): nodes.Node | null {
317317
return this._parseImport()
318318
|| this._parseMedia(isNested)
319+
|| this._parseScope()
319320
|| this._parsePage()
320321
|| this._parseFontFace()
321322
|| this._parseKeyframe()
@@ -364,6 +365,7 @@ export class Parser {
364365

365366
protected _parseRuleSetDeclarationAtStatement(): nodes.Node | null {
366367
return this._parseMedia(true)
368+
|| this._parseScope()
367369
|| this._parseSupports(true)
368370
|| this._parseLayer(true)
369371
|| this._parseContainer(true)
@@ -398,6 +400,7 @@ export class Parser {
398400
case nodes.NodeType.MixinDeclaration:
399401
case nodes.NodeType.FunctionDeclaration:
400402
case nodes.NodeType.MixinContentDeclaration:
403+
case nodes.NodeType.Scope:
401404
return false;
402405
case nodes.NodeType.ExtendsReference:
403406
case nodes.NodeType.MixinContentReference:
@@ -1246,6 +1249,68 @@ export class Parser {
12461249
return this._parseRatio() || this._parseTermExpression();
12471250
}
12481251

1252+
public _parseScope(): nodes.Node | null {
1253+
// @scope [<scope-limits>]? { <block-contents> }
1254+
if (!this.peekKeyword('@scope')) {
1255+
return null;
1256+
}
1257+
1258+
const node = this.create(nodes.Scope);
1259+
// @scope
1260+
this.consumeToken();
1261+
1262+
node.addChild(this._parseScopeLimits())
1263+
1264+
return this._parseBody(node, this._parseScopeDeclaration.bind(this));
1265+
}
1266+
1267+
public _parseScopeDeclaration(): nodes.Node | null {
1268+
// Treat as nested as regular declarations are implicity wrapped with :where(:scope)
1269+
// https://github.com/w3c/csswg-drafts/issues/10389
1270+
// pseudo-selectors implicitly target :scope
1271+
// https://drafts.csswg.org/css-cascade-6/#scoped-rules
1272+
const isNested = true
1273+
return this._tryParseRuleset(isNested)
1274+
|| this._tryToParseDeclaration()
1275+
|| this._parseStylesheetStatement(isNested);
1276+
}
1277+
1278+
public _parseScopeLimits(): nodes.Node | null {
1279+
// [(<scope-start>)]? [to (<scope-end>)]?
1280+
const node = this.create(nodes.ScopeLimits);
1281+
1282+
// [(<scope-start>)]?
1283+
if (this.accept(TokenType.ParenthesisL)) {
1284+
// scope-start selector can start with a combinator as it defaults to :scope
1285+
// Treat as nested
1286+
if (!node.setScopeStart(this._parseSelector(true))) {
1287+
return this.finish(node, ParseError.SelectorExpected, [], [TokenType.ParenthesisR])
1288+
}
1289+
1290+
if (!this.accept(TokenType.ParenthesisR)) {
1291+
return this.finish(node, ParseError.RightParenthesisExpected, [], [TokenType.CurlyL]);
1292+
}
1293+
}
1294+
1295+
// [to (<scope-end>)]?
1296+
if (this.acceptIdent('to')) {
1297+
if (!this.accept(TokenType.ParenthesisL)) {
1298+
return this.finish(node, ParseError.LeftParenthesisExpected, [], [TokenType.CurlyL]);
1299+
}
1300+
// 'to' selector can start with a combinator as it defaults to :scope
1301+
// Treat as nested
1302+
if (!node.setScopeEnd(this._parseSelector(true))) {
1303+
return this.finish(node, ParseError.SelectorExpected, [], [TokenType.ParenthesisR])
1304+
}
1305+
1306+
if (!this.accept(TokenType.ParenthesisR)) {
1307+
return this.finish(node, ParseError.RightParenthesisExpected, [], [TokenType.CurlyL]);
1308+
}
1309+
}
1310+
1311+
return this.finish(node)
1312+
}
1313+
12491314
public _parseMedium(): nodes.Node | null {
12501315
const node = this.create(nodes.Node);
12511316
if (node.addChild(this._parseIdent())) {

src/services/cssHover.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,23 +40,32 @@ export class CSSHover {
4040
* Build up the hover by appending inner node's information
4141
*/
4242
let hover: Hover | null = null;
43-
let flagOpts: { text: string; isMedia: boolean };
43+
let selectorContexts: string[] = [];
4444

4545
for (let i = 0; i < nodepath.length; i++) {
4646
const node = nodepath[i];
4747

48+
if (node instanceof nodes.Scope) {
49+
const scopeLimits = node.getChild(0)
50+
51+
if (scopeLimits instanceof nodes.ScopeLimits) {
52+
const scopeName = `${scopeLimits.getName()}`
53+
selectorContexts.push(`@scope${scopeName ? ` ${scopeName}` : ''}`);
54+
}
55+
}
56+
4857
if (node instanceof nodes.Media) {
49-
const regex = /@media[^\{]+/g;
50-
const matches = node.getText().match(regex);
51-
flagOpts = {
52-
isMedia: true,
53-
text: matches?.[0]!,
54-
};
58+
const mediaList = node.getChild(0);
59+
60+
if (mediaList instanceof nodes.Medialist) {
61+
const name = '@media ' + mediaList.getText();
62+
selectorContexts.push(name)
63+
}
5564
}
5665

5766
if (node instanceof nodes.Selector) {
5867
hover = {
59-
contents: this.selectorPrinting.selectorToMarkedString(<nodes.Selector>node, flagOpts!),
68+
contents: this.selectorPrinting.selectorToMarkedString(<nodes.Selector>node, selectorContexts),
6069
range: getRange(node),
6170
};
6271
break;

src/services/cssNavigation.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,21 @@ export class CSSNavigation {
291291
const name = '@media ' + mediaList.getText();
292292
collect(name, SymbolKind.Module, node, mediaList, node.getDeclarations());
293293
}
294+
} else if (node instanceof nodes.Scope) {
295+
let scopeName = ''
296+
297+
const scopeLimits = node.getChild(0)
298+
if (scopeLimits instanceof nodes.ScopeLimits) {
299+
scopeName = `${scopeLimits.getName()}`
300+
}
301+
302+
collect(
303+
`@scope${scopeName ? ` ${scopeName}` : ''}`,
304+
SymbolKind.Module,
305+
node,
306+
scopeLimits ?? undefined,
307+
node.getDeclarations()
308+
)
294309
}
295310
return true;
296311
});

src/services/selectorPrinting.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ class MarkedStringPrinter {
126126
// empty
127127
}
128128

129-
public print(element: Element, flagOpts?: { isMedia: boolean; text: string }): MarkedString[] {
129+
public print(element: Element, selectorContexts?: string[] ): MarkedString[] {
130130
this.result = [];
131131
if (element instanceof RootElement) {
132132
if (element.children) {
@@ -136,8 +136,8 @@ class MarkedStringPrinter {
136136
this.doPrint([element], 0);
137137
}
138138
let value;
139-
if (flagOpts) {
140-
value = `${flagOpts.text}\n … ` + this.result.join('\n');
139+
if (selectorContexts) {
140+
value = [...selectorContexts, ...this.result].join('\n')
141141
} else {
142142
value = this.result.join('\n');
143143
}
@@ -323,10 +323,10 @@ function unescape(content: string) {
323323
export class SelectorPrinting {
324324
constructor(private cssDataManager: CSSDataManager) { }
325325

326-
public selectorToMarkedString(node: nodes.Selector, flagOpts?: { isMedia: boolean; text: string }): MarkedString[] {
326+
public selectorToMarkedString(node: nodes.Selector, selectorContexts?: string[] ): MarkedString[] {
327327
const root = selectorToElement(node);
328328
if (root) {
329-
const markedStrings = new MarkedStringPrinter('"').print(root, flagOpts);
329+
const markedStrings = new MarkedStringPrinter('"').print(root, selectorContexts);
330330
markedStrings.push(this.selectorToSpecificityMarkedString(node));
331331
return markedStrings;
332332
} else {

src/test/css/completion.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -969,6 +969,22 @@ suite('CSS - Completion', () => {
969969

970970
});
971971

972+
test('@scope selector completion', async function () {
973+
await testCompletionFor(`@scope (|) {`, {
974+
items: [
975+
{ label: 'html', resultText: '@scope (html) {' },
976+
{ label: ':has', resultText: '@scope (:has) {' }
977+
]
978+
});
979+
980+
await testCompletionFor(`@scope to (|) {`, {
981+
items: [
982+
{ label: 'html', resultText: '@scope to (html) {' },
983+
{ label: ':has', resultText: '@scope to (:has) {' }
984+
]
985+
});
986+
})
987+
972988
});
973989

974990
function newRange(start: number, end: number) {

src/test/css/hover.test.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,32 +75,60 @@ suite('CSS Hover', () => {
7575
contents: [{ language: 'html', value: '<element class="foo">' }, '[Selector Specificity](https://developer.mozilla.org/docs/Web/CSS/Specificity): (0, 1, 0)'],
7676
});
7777
});
78-
});
7978

80-
suite('SCSS Hover', () => {
8179
test('nested', () => {
8280
assertHover(
8381
'div { d|iv {} }',
8482
{
8583
contents: [{ language: 'html', value: '<div>\n …\n <div>' }, '[Selector Specificity](https://developer.mozilla.org/docs/Web/CSS/Specificity): (0, 0, 1)'],
8684
},
87-
'scss',
85+
'css',
8886
);
8987
assertHover(
9088
'.foo{ .bar{ @media only screen{ .|bar{ } } } }',
9189
{
9290
contents: [
9391
{
9492
language: 'html',
95-
value: '@media only screen\n<element class="foo">\n …\n <element class="bar">\n …\n <element class="bar">',
93+
value: '@media only screen\n<element class="foo">\n …\n <element class="bar">\n …\n <element class="bar">',
9694
},
9795
'[Selector Specificity](https://developer.mozilla.org/docs/Web/CSS/Specificity): (0, 1, 0)',
9896
],
9997
},
100-
'scss',
98+
'css',
99+
);
100+
101+
assertHover(
102+
'@scope (.foo) to (.bar) { .|baz{ } }',
103+
{
104+
contents: [
105+
{
106+
language: 'html',
107+
value: '@scope .foo → .bar\n<element class="baz">',
108+
},
109+
'[Selector Specificity](https://developer.mozilla.org/docs/Web/CSS/Specificity): (0, 1, 0)',
110+
],
111+
},
112+
'css',
113+
);
114+
115+
assertHover(
116+
'@scope (.from) to (.to) { .foo { @media print { .bar { @media only screen{ .|bar{ } } } } } }',
117+
{
118+
contents: [
119+
{
120+
language: 'html',
121+
value: '@scope .from → .to\n@media print\n@media only screen\n<element class="foo">\n …\n <element class="bar">\n …\n <element class="bar">',
122+
},
123+
'[Selector Specificity](https://developer.mozilla.org/docs/Web/CSS/Specificity): (0, 1, 0)',
124+
],
125+
},
126+
'css',
101127
);
102128
});
129+
});
103130

131+
suite('SCSS Hover', () => {
104132
test('@at-root', () => {
105133
assertHover(
106134
'.test { @|at-root { }',

src/test/css/navigation.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ suite('CSS - Navigation', () => {
237237
assertScopesAndSymbols(ls, '@keyframes animation {}; .class {}', 'animation,.class,[],[]');
238238
assertScopesAndSymbols(ls, '@page :pseudo-class { margin:2in; }', '[]');
239239
assertScopesAndSymbols(ls, '@media print { body { font-size: 10pt } }', '[body,[]]');
240+
assertScopesAndSymbols(ls, '@scope (.foo) to (.bar) { body { font-size: 10pt } }', '[body,[]]')
240241
assertScopesAndSymbols(ls, '@-moz-keyframes identifier { 0% { top: 0; } 50% { top: 30px; left: 20px; }}', 'identifier,[[],[]]');
241242
assertScopesAndSymbols(ls, '@font-face { font-family: "Bitstream Vera Serif Bold"; }', '[]');
242243
});
@@ -276,6 +277,9 @@ suite('CSS - Navigation', () => {
276277

277278
// Media Query
278279
assertSymbolInfos(ls, '@media screen, print {}', [{ name: '@media screen, print', kind: SymbolKind.Module, location: Location.create('test://test/test.css', newRange(0, 23)) }]);
280+
281+
// Scope
282+
assertSymbolInfos(ls, '@scope (.foo) to (.bar) {}', [{ name: '@scope .foo → .bar', kind: SymbolKind.Module, location: Location.create('test://test/test.css', newRange(0, 26)) }]);
279283
});
280284

281285
test('basic document symbols', () => {
@@ -291,8 +295,9 @@ suite('CSS - Navigation', () => {
291295

292296
// Media Query
293297
assertDocumentSymbols(ls, '@media screen, print {}', [{ name: '@media screen, print', kind: SymbolKind.Module, range: newRange(0, 23), selectionRange: newRange(7, 20) }]);
294-
assertDocumentSymbols(ls, '@media screen, print {}', [{ name: '@media screen, print', kind: SymbolKind.Module, range: newRange(0, 23), selectionRange: newRange(7, 20) }]);
295-
298+
299+
// Scope
300+
assertDocumentSymbols(ls, '@scope (.foo) to (.bar) {}', [{ name: '@scope .foo → .bar', kind: SymbolKind.Module, range: newRange(0, 26), selectionRange: newRange(7, 23) }]);
296301
});
297302
});
298303

0 commit comments

Comments
 (0)