Skip to content

Commit 9d1a981

Browse files
committed
JS LSP
1 parent 86f2c25 commit 9d1a981

File tree

7 files changed

+389
-0
lines changed

7 files changed

+389
-0
lines changed

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

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"dependencies": {
3030
"@shopify/liquid-html-parser": "^2.7.0",
3131
"@shopify/theme-check-common": "3.11.1",
32+
"@typescript/vfs": "1.6.1",
3233
"@vscode/web-custom-data": "^0.4.6",
3334
"vscode-json-languageservice": "^5.3.10",
3435
"vscode-languageserver": "^8.0.2",
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 './JSLanguageService';
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,156 @@
1+
import { createSystem } from '@typescript/vfs';
2+
import { LiquidRawTag, NodeTypes } from '@shopify/liquid-html-parser';
3+
import { AbstractFileSystem, Mode, Modes, SourceCodeType, findCurrentNode } from '@shopify/theme-check-common';
4+
import ts, { LanguageService } from 'typescript';
5+
import {
6+
CompletionItem,
7+
CompletionList,
8+
CompletionParams,
9+
Diagnostic,
10+
DocumentDiagnosticParams,
11+
Hover,
12+
HoverParams,
13+
ClientCapabilities as LSPClientCapabilities,
14+
Position
15+
} from 'vscode-languageserver';
16+
import { TextDocument } from 'vscode-languageserver-textdocument';
17+
18+
import { DocumentManager } from '../documents';
19+
import { findJSConfig } from './utils';
20+
21+
export class JSLanguageService {
22+
private service: LanguageService | null = null;
23+
private snapshotManager: Map<string, ts.IScriptSnapshot> = new Map();
24+
private fileMap: Map<string, string> = new Map();
25+
26+
constructor(private documentManager: DocumentManager) {}
27+
28+
async setup(clientCapabilities: LSPClientCapabilities, fs: AbstractFileSystem, workspacePath: string) {
29+
console.log('test')
30+
let vfs: any | null = null;
31+
try {
32+
vfs = createSystem(this.fileMap);
33+
} catch (error) {
34+
console.error(error);
35+
}
36+
37+
if (!vfs) {
38+
throw new Error('Failed to create VFS');
39+
}
40+
// TODO: we need to get fileExists to be synchronous
41+
const configPath = findJSConfig(workspacePath, [], () => true);
42+
const projectConfig = JSON.parse(await fs.readFile(configPath));
43+
const { compilerOptions } = projectConfig;
44+
let compilerHost: ts.CompilerHost | null = null;
45+
46+
const host: ts.LanguageServiceHost = {
47+
getCompilationSettings: () => compilerOptions,
48+
getScriptFileNames: () => Array.from(this.fileMap.keys()),
49+
fileExists: vfs.fileExists,
50+
readFile: vfs.readFile,
51+
readDirectory: vfs.readDirectory,
52+
// TODO: we need a snapshot manager
53+
getScriptVersion: (fileName: string) => '1',
54+
getScriptSnapshot: (fileName: string) =>
55+
this.snapshotManager.get(fileName) || ts.ScriptSnapshot.fromString(''),
56+
getCurrentDirectory: () => workspacePath,
57+
getDefaultLibFileName: ts.getDefaultLibFilePath,
58+
getCompilerHost: () => compilerHost!
59+
};
60+
this.service = ts.createLanguageService(host);
61+
}
62+
63+
async completions(params: CompletionParams): Promise<null | CompletionList | CompletionItem[]> {
64+
const service = this.service;
65+
if (!service) return null;
66+
const jsDocument = this.getDocuments(params, service);
67+
if (!jsDocument) return null;
68+
// TODO: convert position to offset
69+
const completions = await service.getCompletionsAtPosition(jsDocument.uri, positionToOffset(jsDocument, params.position), {});
70+
if (!completions) return null;
71+
// @ts-expect-error
72+
return completions;
73+
}
74+
75+
async diagnostics(params: DocumentDiagnosticParams): Promise<Diagnostic[]> {
76+
const service = this.service;
77+
if (!service) return [];
78+
const jsDocument = this.getDocuments(params, service);
79+
if (!jsDocument) return [];
80+
// @ts-expect-error
81+
return service.getSemanticDiagnostics(jsDocument.uri);
82+
}
83+
84+
async hover(params: HoverParams): Promise<Hover | null> {
85+
const service = this.service;
86+
if (!service) return null;
87+
const jsDocument = this.getDocuments(params, service);
88+
if (!jsDocument) return null;
89+
const hover = await service.getQuickInfoAtPosition(jsDocument.uri, positionToOffset(jsDocument, params.position));
90+
if (!hover) return null;
91+
// @ts-expect-error
92+
return hover;
93+
}
94+
95+
private getDocuments(
96+
params: HoverParams | CompletionParams | DocumentDiagnosticParams,
97+
service: LanguageService,
98+
): TextDocument | null {
99+
const document = this.documentManager.get(params.textDocument.uri);
100+
if (!document) return null;
101+
102+
switch (document.type) {
103+
case SourceCodeType.JSON: {
104+
return null;
105+
}
106+
case SourceCodeType.LiquidHtml: {
107+
if (document.ast instanceof Error) return null;
108+
const textDocument = document.textDocument;
109+
let offset = 0;
110+
let isDiagnostics = false;
111+
if ('position' in params && params.position.line !== 0 && params.position.character !== 0) {
112+
offset = textDocument.offsetAt(params.position);
113+
} else {
114+
const stylesheetIndex = document.source.indexOf('{% javascript %}');
115+
offset = stylesheetIndex;
116+
isDiagnostics = true;
117+
}
118+
const [node, ancestors] = findCurrentNode(document.ast, offset);
119+
let stylesheetTag = [...ancestors].find(
120+
(node): node is LiquidRawTag =>
121+
node.type === NodeTypes.LiquidRawTag && node.name === 'javascript',
122+
);
123+
if (isDiagnostics && 'children' in node && node.children) {
124+
stylesheetTag = node.children.find(
125+
(node): node is LiquidRawTag =>
126+
node.type === NodeTypes.LiquidRawTag && node.name === 'stylesheet',
127+
);
128+
}
129+
130+
if (!stylesheetTag) return null;
131+
132+
const schemaLineNumber = textDocument.positionAt(stylesheetTag.blockStartPosition.end).line;
133+
// Hacking away "same line numbers" here by prefixing the file with newlines
134+
// This way params.position will be at the same line number in this fake jsonTextDocument
135+
// Which means that the completions will be at the same line number in the Liquid document
136+
const stylesheetString =
137+
Array(schemaLineNumber).fill('\n').join('') +
138+
stylesheetTag.source
139+
.slice(stylesheetTag.blockStartPosition.end, stylesheetTag.blockEndPosition.start)
140+
.replace(/\n$/, ''); // Remove trailing newline so parsing errors don't show up on `{% endstylesheet %}`
141+
const stylesheetTextDocument = TextDocument.create(
142+
textDocument.uri,
143+
'json',
144+
textDocument.version,
145+
stylesheetString,
146+
);
147+
this.fileMap.set(textDocument.uri, stylesheetString);
148+
return stylesheetTextDocument
149+
}
150+
}
151+
}
152+
}
153+
154+
function positionToOffset(textDocument: TextDocument, position: Position): number {
155+
return textDocument.offsetAt(position);
156+
}
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+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import ts from "typescript";
2+
3+
function isSubPath(
4+
rootUri: string,
5+
config: string,
6+
) {
7+
return config.startsWith(rootUri);
8+
}
9+
10+
export function findJSConfig(
11+
fileName: string,
12+
rootUris: string[],
13+
fileExists: (path: string) => boolean
14+
) {
15+
const searchDir = fileName.split('/').slice(0, -1).join('/');
16+
17+
const tsconfig = ts.findConfigFile(searchDir, fileExists, 'tsconfig.json') || '';
18+
const jsconfig = ts.findConfigFile(searchDir, fileExists, 'jsconfig.json') || '';
19+
// Prefer closest config file
20+
const config = tsconfig.length >= jsconfig.length ? tsconfig : jsconfig;
21+
22+
// Don't return config files that are outside of the current workspace
23+
return !!config &&
24+
rootUris.some((rootUri) => isSubPath(rootUri, config)) &&
25+
!fileName
26+
.substring(config.length - 'tsconfig.json'.length)
27+
.split('/')
28+
.includes('node_modules')
29+
? config
30+
: '';
31+
}

0 commit comments

Comments
 (0)