Skip to content

Commit 86f2c25

Browse files
Add CSS LSP for stylesheet tags within liquid (#851)
* Add css based completions * Similar approach to json * Run dedupe * Try implementing diagnostics * Fixups --------- Co-authored-by: Charles-P. Clermont <[email protected]>
1 parent 40f5d93 commit 86f2c25

File tree

13 files changed

+425
-78
lines changed

13 files changed

+425
-78
lines changed

.changeset/gentle-beds-lie.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@shopify/theme-language-server-common': minor
3+
'theme-check-vscode': minor
4+
---
5+
6+
Add support for the CSS Language Server when we are within `{% stylesheet %}` tags. This add support for hover-information, auto-complete and diagnostics.

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@ package-lock.json
2121
packages/theme-check-docs-updater/data
2222
packages/lang-jsonc/src/parser.*
2323
.vscode-test-web
24+
.shopify

packages/theme-language-server-common/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"@vscode/web-custom-data": "^0.4.6",
3333
"vscode-json-languageservice": "^5.3.10",
3434
"vscode-languageserver": "^8.0.2",
35+
"vscode-css-languageservice": "6.3.2",
3536
"vscode-languageserver-textdocument": "^1.0.8",
3637
"vscode-uri": "^3.0.7"
3738
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { assert, beforeEach, describe, expect, it } from 'vitest';
2+
import { DocumentManager } from '../documents';
3+
import { CSSLanguageService } from './CSSLanguageService';
4+
import { getRequestParams, isCompletionList } from './test/test-helpers';
5+
6+
describe('Module: CSSLanguageService', () => {
7+
let cssLanguageService: CSSLanguageService;
8+
let documentManager: DocumentManager;
9+
10+
beforeEach(async () => {
11+
documentManager = new DocumentManager(
12+
undefined,
13+
undefined,
14+
undefined,
15+
async () => 'theme', // theme schema
16+
async () => false, // invalid
17+
);
18+
cssLanguageService = new CSSLanguageService(documentManager);
19+
20+
await cssLanguageService.setup({
21+
textDocument: {
22+
completion: {
23+
contextSupport: true,
24+
completionItem: {
25+
snippetSupport: true,
26+
commitCharactersSupport: true,
27+
documentationFormat: ['markdown'],
28+
deprecatedSupport: true,
29+
preselectSupport: true,
30+
},
31+
},
32+
},
33+
});
34+
});
35+
36+
describe('completions', () => {
37+
it('should return CSS completions in a liquid file {% stylesheet %}', async () => {
38+
const params = getRequestParams(
39+
documentManager,
40+
'sections/section.liquid',
41+
`
42+
{% stylesheet %}
43+
.a:hov█ {
44+
color: red;
45+
}
46+
{% endstylesheet %}
47+
<div>hello world</div>
48+
49+
`,
50+
);
51+
52+
const completions = await cssLanguageService.completions(params);
53+
assert(isCompletionList(completions));
54+
expect(completions.items).to.have.lengthOf(357);
55+
expect(completions.items[0].label).to.equal(':active');
56+
expect(completions.items[0].documentation).to.deep.equal({
57+
kind: 'markdown',
58+
value:
59+
dedent(`Applies while an element is being activated by the user\\. For example, between the times the user presses the mouse button and releases it\\.
60+
61+
(Edge 12, Firefox 1, Safari 1, Chrome 1, IE 4, Opera 5)
62+
63+
[MDN Reference](https://developer.mozilla.org/docs/Web/CSS/:active)`),
64+
});
65+
});
66+
});
67+
68+
describe('hover', () => {
69+
it('should return hover information for the given property in a {% stylesheet %}', async () => {
70+
const params = getRequestParams(
71+
documentManager,
72+
'sections/section.liquid',
73+
`
74+
{% stylesheet %}
75+
.wrapper {
76+
display: flex█;
77+
}
78+
{% endstylesheet %}
79+
<div>hello world</div>
80+
`,
81+
);
82+
const hover = await cssLanguageService.hover(params);
83+
assert(hover !== null);
84+
expect(hover.contents).to.eql({
85+
kind: 'plaintext',
86+
// Dedent this
87+
value:
88+
dedent(`In combination with 'float' and 'position', determines the type of box or boxes that are generated for an element.
89+
(Edge 12, Firefox 1, Safari 1, Chrome 1, IE 4, Opera 7)
90+
91+
Syntax: [ <display-outside> || <display-inside> ] | <display-listitem> | <display-internal> | <display-box> | <display-legacy>
92+
93+
MDN Reference: https://developer.mozilla.org/docs/Web/CSS/display`),
94+
});
95+
});
96+
97+
it('should return hover information for the given tag in a {% stylesheet %}', async () => {
98+
const params = getRequestParams(
99+
documentManager,
100+
'sections/section.liquid',
101+
`
102+
{% stylesheet %}
103+
.wrapper█ {
104+
display: flex;
105+
}
106+
{% endstylesheet %}
107+
<div>hello world</div>
108+
`,
109+
);
110+
const hover = await cssLanguageService.hover(params);
111+
assert(hover !== null);
112+
expect(hover.contents).to.eql([
113+
'<element class="wrapper">',
114+
'[Selector Specificity](https://developer.mozilla.org/docs/Web/CSS/Specificity): (0, 1, 0)',
115+
]);
116+
});
117+
});
118+
119+
describe('diagnostics', () => {
120+
it('should return ddiagnostics information for the given property in a {% stylesheet %}', async () => {
121+
const params = getRequestParams(
122+
documentManager,
123+
'sections/section.liquid',
124+
`
125+
{% stylesheet %}
126+
a:h {
127+
}
128+
{% endstylesheet %}
129+
<div>hello world</div>
130+
`,
131+
);
132+
const diagnostics = await cssLanguageService.diagnostics(params);
133+
assert(diagnostics !== null);
134+
expect(diagnostics).to.eql([
135+
{
136+
code: 'emptyRules',
137+
message: 'Do not use empty rulesets',
138+
range: {
139+
end: {
140+
character: 15,
141+
line: 2,
142+
},
143+
start: {
144+
character: 12,
145+
line: 2,
146+
},
147+
},
148+
severity: 2,
149+
source: 'json',
150+
},
151+
]);
152+
});
153+
});
154+
});
155+
156+
function dedent(text: string): string {
157+
return text
158+
.split('\n')
159+
.map((line) => line.trimStart())
160+
.join('\n');
161+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { LiquidRawTag, NodeTypes } from '@shopify/liquid-html-parser';
2+
import { Mode, Modes, SourceCodeType, findCurrentNode } from '@shopify/theme-check-common';
3+
import { LanguageService, Stylesheet, getCSSLanguageService } from 'vscode-css-languageservice';
4+
import {
5+
CompletionItem,
6+
CompletionList,
7+
CompletionParams,
8+
Diagnostic,
9+
DocumentDiagnosticParams,
10+
Hover,
11+
HoverParams,
12+
ClientCapabilities as LSPClientCapabilities,
13+
} from 'vscode-languageserver';
14+
import { TextDocument } from 'vscode-languageserver-textdocument';
15+
import { DocumentManager } from '../documents';
16+
17+
export class CSSLanguageService {
18+
private service: LanguageService | null = null;
19+
20+
constructor(private documentManager: DocumentManager) {}
21+
22+
async setup(clientCapabilities: LSPClientCapabilities) {
23+
this.service = getCSSLanguageService({
24+
clientCapabilities,
25+
});
26+
}
27+
28+
async completions(params: CompletionParams): Promise<null | CompletionList | CompletionItem[]> {
29+
const service = this.service;
30+
if (!service) return null;
31+
const documents = this.getDocuments(params, service);
32+
if (!documents) return null;
33+
const [stylesheetTextDocument, stylesheetDocument] = documents;
34+
return service.doComplete(stylesheetTextDocument, params.position, stylesheetDocument);
35+
}
36+
37+
async diagnostics(params: DocumentDiagnosticParams): Promise<Diagnostic[]> {
38+
const service = this.service;
39+
if (!service) return [];
40+
const documents = this.getDocuments(params, service);
41+
if (!documents) return [];
42+
const [stylesheetTextDocument, stylesheetDocument] = documents;
43+
return service.doValidation(stylesheetTextDocument, stylesheetDocument);
44+
}
45+
46+
async hover(params: HoverParams): Promise<Hover | null> {
47+
const service = this.service;
48+
if (!service) return null;
49+
const documents = this.getDocuments(params, service);
50+
if (!documents) return null;
51+
const [stylesheetTextDocument, stylesheetDocument] = documents;
52+
return service.doHover(stylesheetTextDocument, params.position, stylesheetDocument);
53+
}
54+
55+
private getDocuments(
56+
params: HoverParams | CompletionParams | DocumentDiagnosticParams,
57+
service: LanguageService,
58+
): [TextDocument, Stylesheet] | null {
59+
const document = this.documentManager.get(params.textDocument.uri);
60+
if (!document) return null;
61+
62+
switch (document.type) {
63+
case SourceCodeType.JSON: {
64+
return null;
65+
}
66+
case SourceCodeType.LiquidHtml: {
67+
if (document.ast instanceof Error) return null;
68+
const textDocument = document.textDocument;
69+
let offset = 0;
70+
let isDiagnostics = false;
71+
if ('position' in params && params.position.line !== 0 && params.position.character !== 0) {
72+
offset = textDocument.offsetAt(params.position);
73+
} else {
74+
const stylesheetIndex = document.source.indexOf('{% stylesheet %}');
75+
offset = stylesheetIndex;
76+
isDiagnostics = true;
77+
}
78+
const [node, ancestors] = findCurrentNode(document.ast, offset);
79+
let stylesheetTag = [...ancestors].find(
80+
(node): node is LiquidRawTag =>
81+
node.type === NodeTypes.LiquidRawTag && node.name === 'stylesheet',
82+
);
83+
if (isDiagnostics && 'children' in node && node.children) {
84+
stylesheetTag = node.children.find(
85+
(node): node is LiquidRawTag =>
86+
node.type === NodeTypes.LiquidRawTag && node.name === 'stylesheet',
87+
);
88+
}
89+
90+
if (!stylesheetTag) return null;
91+
92+
const schemaLineNumber = textDocument.positionAt(stylesheetTag.blockStartPosition.end).line;
93+
// Hacking away "same line numbers" here by prefixing the file with newlines
94+
// This way params.position will be at the same line number in this fake jsonTextDocument
95+
// Which means that the completions will be at the same line number in the Liquid document
96+
const stylesheetString =
97+
Array(schemaLineNumber).fill('\n').join('') +
98+
stylesheetTag.source
99+
.slice(stylesheetTag.blockStartPosition.end, stylesheetTag.blockEndPosition.start)
100+
.replace(/\n$/, ''); // Remove trailing newline so parsing errors don't show up on `{% endstylesheet %}`
101+
const stylesheetTextDocument = TextDocument.create(
102+
textDocument.uri,
103+
'json',
104+
textDocument.version,
105+
stylesheetString,
106+
);
107+
const stylesheetDocument = service.parseStylesheet(stylesheetTextDocument);
108+
return [stylesheetTextDocument, stylesheetDocument];
109+
}
110+
}
111+
}
112+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {
2+
CompletionItem,
3+
CompletionList,
4+
CompletionParams,
5+
HoverParams,
6+
} from 'vscode-languageserver-protocol';
7+
import { DocumentManager } from '../../documents';
8+
9+
export function getRequestParams(
10+
documentManager: DocumentManager,
11+
relativePath: string,
12+
source: string,
13+
): HoverParams & CompletionParams {
14+
const uri = `file:///root/${relativePath}`;
15+
const sourceWithoutCursor = source.replace('█', '');
16+
documentManager.open(uri, sourceWithoutCursor, 1);
17+
const doc = documentManager.get(uri)!.textDocument;
18+
const position = doc.positionAt(source.indexOf('█'));
19+
20+
return {
21+
textDocument: { uri: uri },
22+
position: position,
23+
};
24+
}
25+
26+
export function isCompletionList(
27+
completions: null | CompletionList | CompletionItem[],
28+
): completions is CompletionList {
29+
return completions !== null && !Array.isArray(completions);
30+
}

packages/theme-language-server-common/src/diagnostics/offenseToDiagnostic.ts

+18
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,21 @@ function diagnosticSeverity(offense: Offense): DiagnosticSeverity {
6060
}
6161
}
6262
}
63+
64+
export function offenseSeverity(diagnostic: Diagnostic): Severity {
65+
switch (diagnostic.severity) {
66+
case DiagnosticSeverity.Hint:
67+
case DiagnosticSeverity.Information: {
68+
return Severity.INFO;
69+
}
70+
case DiagnosticSeverity.Warning: {
71+
return Severity.WARNING;
72+
}
73+
case DiagnosticSeverity.Error: {
74+
return Severity.ERROR;
75+
}
76+
default: {
77+
return Severity.INFO;
78+
}
79+
}
80+
}

0 commit comments

Comments
 (0)