Skip to content

Commit df32a39

Browse files
committed
JS LSP
1 parent 86f2c25 commit df32a39

File tree

8 files changed

+375
-0
lines changed

8 files changed

+375
-0
lines changed

packages/theme-check-common/src/AbstractFileSystem.ts

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface AbstractFileSystem {
99
stat(uri: string): Promise<FileStat>;
1010
readFile(uri: string): Promise<string>;
1111
readDirectory(uri: string): Promise<FileTuple[]>;
12+
exists(uri: string): boolean;
1213
}
1314

1415
export enum FileType {
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,140 @@
1+
import { LiquidRawTag, NodeTypes } from '@shopify/liquid-html-parser';
2+
import { AbstractFileSystem, Mode, Modes, SourceCodeType, findCurrentNode } from '@shopify/theme-check-common';
3+
import ts, { LanguageService } from 'typescript';
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+
import { findJSConfig } from './utils';
17+
18+
export class JSLanguageService {
19+
private service: LanguageService | null = null;
20+
private snapshotManager: Map<string, ts.IScriptSnapshot> = new Map();
21+
22+
constructor(private documentManager: DocumentManager) {}
23+
24+
async setup(clientCapabilities: LSPClientCapabilities, fs: AbstractFileSystem, workspacePath: string) {
25+
// TODO: we need to get fileExists to be synchronous
26+
const configPath = findJSConfig(workspacePath, [], fs.exists);
27+
const projectConfig = JSON.parse(await fs.readFile(configPath));
28+
const { compilerOptions } = projectConfig;
29+
let compilerHost: ts.CompilerHost | null = null;
30+
const host: ts.LanguageServiceHost = {
31+
getCompilationSettings: () => compilerOptions,
32+
getScriptFileNames: () => [],
33+
// TODO: we need to get fileExists, readFile and readDirectory to be synchronous
34+
fileExists: fs.exists,
35+
readFile: (uri) => fs.readFile(uri),
36+
// TODO: we need a snapshot manager
37+
getScriptVersion: (fileName: string) => '1',
38+
getScriptSnapshot: (fileName: string) =>
39+
this.snapshotManager.get(fileName) || ts.ScriptSnapshot.fromString(''),
40+
getCurrentDirectory: () => workspacePath,
41+
getDefaultLibFileName: ts.getDefaultLibFilePath,
42+
useSourceOfProjectReferenceRedirect() {
43+
return false;
44+
},
45+
setCompilerHost: (host: ts.CompilerHost) => (compilerHost = host),
46+
getCompilerHost: () => compilerHost!
47+
};
48+
this.service = ts.createLanguageService(host);
49+
}
50+
51+
async completions(params: CompletionParams): Promise<null | CompletionList | CompletionItem[]> {
52+
const service = this.service;
53+
if (!service) return null;
54+
const jsDocument = this.getDocuments(params, service);
55+
if (!jsDocument) return null;
56+
// TODO: convert position to offset
57+
const completions = await service.getCompletionsAtPosition(jsDocument.uri, positionToOffset(jsDocument, params.position), {});
58+
if (!completions) return null;
59+
return completions;
60+
}
61+
62+
async diagnostics(params: DocumentDiagnosticParams): Promise<Diagnostic[]> {
63+
const service = this.service;
64+
if (!service) return [];
65+
const jsDocument = this.getDocuments(params, service);
66+
if (!jsDocument) return [];
67+
return service.getSemanticDiagnostics(jsDocument.uri);
68+
}
69+
70+
async hover(params: HoverParams): Promise<Hover | null> {
71+
const service = this.service;
72+
if (!service) return null;
73+
const jsDocument = this.getDocuments(params, service);
74+
if (!jsDocument) return null;
75+
const hover = await service.getQuickInfoAtPosition(jsDocument.uri, positionToOffset(jsDocument, params.position));
76+
if (!hover) return null;
77+
return hover;
78+
}
79+
80+
private getDocuments(
81+
params: HoverParams | CompletionParams | DocumentDiagnosticParams,
82+
service: LanguageService,
83+
): TextDocument | null {
84+
const document = this.documentManager.get(params.textDocument.uri);
85+
if (!document) return null;
86+
87+
switch (document.type) {
88+
case SourceCodeType.JSON: {
89+
return null;
90+
}
91+
case SourceCodeType.LiquidHtml: {
92+
if (document.ast instanceof Error) return null;
93+
const textDocument = document.textDocument;
94+
let offset = 0;
95+
let isDiagnostics = false;
96+
if ('position' in params && params.position.line !== 0 && params.position.character !== 0) {
97+
offset = textDocument.offsetAt(params.position);
98+
} else {
99+
const stylesheetIndex = document.source.indexOf('{% javascript %}');
100+
offset = stylesheetIndex;
101+
isDiagnostics = true;
102+
}
103+
const [node, ancestors] = findCurrentNode(document.ast, offset);
104+
let stylesheetTag = [...ancestors].find(
105+
(node): node is LiquidRawTag =>
106+
node.type === NodeTypes.LiquidRawTag && node.name === 'javascript',
107+
);
108+
if (isDiagnostics && 'children' in node && node.children) {
109+
stylesheetTag = node.children.find(
110+
(node): node is LiquidRawTag =>
111+
node.type === NodeTypes.LiquidRawTag && node.name === 'stylesheet',
112+
);
113+
}
114+
115+
if (!stylesheetTag) return null;
116+
117+
const schemaLineNumber = textDocument.positionAt(stylesheetTag.blockStartPosition.end).line;
118+
// Hacking away "same line numbers" here by prefixing the file with newlines
119+
// This way params.position will be at the same line number in this fake jsonTextDocument
120+
// Which means that the completions will be at the same line number in the Liquid document
121+
const stylesheetString =
122+
Array(schemaLineNumber).fill('\n').join('') +
123+
stylesheetTag.source
124+
.slice(stylesheetTag.blockStartPosition.end, stylesheetTag.blockEndPosition.start)
125+
.replace(/\n$/, ''); // Remove trailing newline so parsing errors don't show up on `{% endstylesheet %}`
126+
const stylesheetTextDocument = TextDocument.create(
127+
textDocument.uri,
128+
'json',
129+
textDocument.version,
130+
stylesheetString,
131+
);
132+
return stylesheetTextDocument
133+
}
134+
}
135+
}
136+
}
137+
138+
function positionToOffset(textDocument: TextDocument, position: Position): number {
139+
return textDocument.offsetAt(position);
140+
}
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+
}

packages/theme-language-server-common/src/server/CachedFileSystem.ts

+2
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ export class CachedFileSystem implements AbstractFileSystem {
44
readFile: Cached<AbstractFileSystem['readFile']>;
55
readDirectory: Cached<AbstractFileSystem['readDirectory']>;
66
stat: Cached<AbstractFileSystem['stat']>;
7+
exists: AbstractFileSystem['exists'];
78

89
constructor(fs: AbstractFileSystem) {
910
this.readFile = cachedByUri(fs.readFile.bind(fs));
1011
this.readDirectory = cachedByUri(fs.readDirectory.bind(fs));
1112
this.stat = cachedByUri(fs.stat.bind(fs));
13+
this.exists = fs.exists.bind(fs);
1214
}
1315
}
1416

0 commit comments

Comments
 (0)