Skip to content

Commit 7afd226

Browse files
committed
JS LSP
1 parent 86f2c25 commit 7afd226

File tree

7 files changed

+320
-0
lines changed

7 files changed

+320
-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,81 @@
1+
import { assert, beforeEach, describe, expect, it } from 'vitest';
2+
import { DocumentManager } from '../documents';
3+
import { JSLanguageService } from './JSLanguageService';
4+
import { getRequestParams, isCompletionList } from './test/test-helpers';
5+
import fs from 'fs/promises';
6+
import { FileType } from 'vscode-css-languageservice';
7+
8+
describe('Module: JSLanguageService', () => {
9+
let jsLanguageService: JSLanguageService;
10+
let documentManager: DocumentManager;
11+
12+
beforeEach(async () => {
13+
documentManager = new DocumentManager(
14+
undefined,
15+
undefined,
16+
undefined,
17+
async () => 'theme', // theme schema
18+
async () => false, // invalid
19+
);
20+
jsLanguageService = new JSLanguageService(documentManager);
21+
22+
await jsLanguageService.setup({
23+
textDocument: {
24+
completion: {
25+
contextSupport: true,
26+
completionItem: {
27+
snippetSupport: true,
28+
commitCharactersSupport: true,
29+
documentationFormat: ['markdown'],
30+
deprecatedSupport: true,
31+
preselectSupport: true,
32+
},
33+
},
34+
},
35+
},
36+
{
37+
readFile: (uri) => fs.readFile(uri, 'utf8'),
38+
readDirectory: (uri) => fs.readdir(uri).then((files) => files.map((file) => [file, 1])),
39+
stat: (uri) => fs.stat(uri).then((stats) => ({ type: stats.isFile() ? FileType.File : FileType.Directory, size: stats.size })),
40+
},
41+
'test/workspace',
42+
);
43+
});
44+
45+
describe('completions', () => {
46+
it('should return JS completions in a liquid file {% javascript %}', async () => {
47+
const params = getRequestParams(
48+
documentManager,
49+
'sections/section.liquid',
50+
`
51+
{% javascript %}
52+
document.add█
53+
{% endjavascript %}
54+
<div>hello world</div>
55+
56+
`,
57+
);
58+
59+
const completions = await jsLanguageService.completions(params);
60+
assert(isCompletionList(completions));
61+
expect(completions.items).to.have.lengthOf(357);
62+
expect(completions.items[0].label).to.equal(':active');
63+
expect(completions.items[0].documentation).to.deep.equal({
64+
kind: 'markdown',
65+
value:
66+
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\\.
67+
68+
(Edge 12, Firefox 1, Safari 1, Chrome 1, IE 4, Opera 5)
69+
70+
[MDN Reference](https://developer.mozilla.org/docs/Web/CSS/:active)`),
71+
});
72+
});
73+
});
74+
});
75+
76+
function dedent(text: string): string {
77+
return text
78+
.split('\n')
79+
.map((line) => line.trimStart())
80+
.join('\n');
81+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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+
let compilerOptions: ts.CompilerOptions;
43+
let compilerHost: ts.CompilerHost | null = null;
44+
45+
if (configPath) {
46+
const projectConfig = JSON.parse(await fs.readFile(configPath));
47+
compilerOptions = projectConfig.compilerOptions;
48+
} else {
49+
compilerOptions = {
50+
target: ts.ScriptTarget.ESNext,
51+
module: ts.ModuleKind.ESNext,
52+
moduleResolution: ts.ModuleResolutionKind.Node,
53+
}
54+
}
55+
const host: ts.LanguageServiceHost = {
56+
getCompilationSettings: () => compilerOptions,
57+
getScriptFileNames: () => Array.from(this.fileMap.keys()),
58+
fileExists: vfs.fileExists,
59+
readFile: vfs.readFile,
60+
readDirectory: vfs.readDirectory,
61+
// TODO: we need a snapshot manager
62+
getScriptVersion: (fileName: string) => '1',
63+
getScriptSnapshot: (fileName: string) =>
64+
this.snapshotManager.get(fileName) || ts.ScriptSnapshot.fromString(''),
65+
getCurrentDirectory: () => workspacePath,
66+
getDefaultLibFileName: ts.getDefaultLibFilePath,
67+
getCompilerHost: () => compilerHost!
68+
};
69+
this.service = ts.createLanguageService(host);
70+
}
71+
72+
async completions(params: CompletionParams): Promise<null | CompletionList | CompletionItem[]> {
73+
const service = this.service;
74+
if (!service) return null;
75+
const jsDocument = this.getDocuments(params, service);
76+
if (!jsDocument) return null;
77+
// TODO: convert position to offset
78+
const completions = await service.getCompletionsAtPosition(jsDocument.uri, positionToOffset(jsDocument, params.position), {});
79+
if (!completions) return null;
80+
// @ts-expect-error
81+
return completions;
82+
}
83+
84+
async diagnostics(params: DocumentDiagnosticParams): Promise<Diagnostic[]> {
85+
const service = this.service;
86+
if (!service) return [];
87+
const jsDocument = this.getDocuments(params, service);
88+
if (!jsDocument) return [];
89+
// @ts-expect-error
90+
return service.getSemanticDiagnostics(jsDocument.uri);
91+
}
92+
93+
async hover(params: HoverParams): Promise<Hover | null> {
94+
const service = this.service;
95+
if (!service) return null;
96+
const jsDocument = this.getDocuments(params, service);
97+
if (!jsDocument) return null;
98+
const hover = await service.getQuickInfoAtPosition(jsDocument.uri, positionToOffset(jsDocument, params.position));
99+
if (!hover) return null;
100+
// @ts-expect-error
101+
return hover;
102+
}
103+
104+
private getDocuments(
105+
params: HoverParams | CompletionParams | DocumentDiagnosticParams,
106+
service: LanguageService,
107+
): TextDocument | null {
108+
const document = this.documentManager.get(params.textDocument.uri);
109+
if (!document) return null;
110+
111+
switch (document.type) {
112+
case SourceCodeType.JSON: {
113+
return null;
114+
}
115+
case SourceCodeType.LiquidHtml: {
116+
if (document.ast instanceof Error) return null;
117+
const textDocument = document.textDocument;
118+
let offset = 0;
119+
let isDiagnostics = false;
120+
if ('position' in params && params.position.line !== 0 && params.position.character !== 0) {
121+
offset = textDocument.offsetAt(params.position);
122+
} else {
123+
const stylesheetIndex = document.source.indexOf('{% javascript %}');
124+
offset = stylesheetIndex;
125+
isDiagnostics = true;
126+
}
127+
const [node, ancestors] = findCurrentNode(document.ast, offset);
128+
let stylesheetTag = [...ancestors].find(
129+
(node): node is LiquidRawTag =>
130+
node.type === NodeTypes.LiquidRawTag && node.name === 'javascript',
131+
);
132+
if (isDiagnostics && 'children' in node && node.children) {
133+
stylesheetTag = node.children.find(
134+
(node): node is LiquidRawTag =>
135+
node.type === NodeTypes.LiquidRawTag && node.name === 'stylesheet',
136+
);
137+
}
138+
139+
if (!stylesheetTag) return null;
140+
141+
const schemaLineNumber = textDocument.positionAt(stylesheetTag.blockStartPosition.end).line;
142+
// Hacking away "same line numbers" here by prefixing the file with newlines
143+
// This way params.position will be at the same line number in this fake jsonTextDocument
144+
// Which means that the completions will be at the same line number in the Liquid document
145+
const stylesheetString =
146+
Array(schemaLineNumber).fill('\n').join('') +
147+
stylesheetTag.source
148+
.slice(stylesheetTag.blockStartPosition.end, stylesheetTag.blockEndPosition.start)
149+
.replace(/\n$/, ''); // Remove trailing newline so parsing errors don't show up on `{% endstylesheet %}`
150+
const stylesheetTextDocument = TextDocument.create(
151+
textDocument.uri,
152+
'json',
153+
textDocument.version,
154+
stylesheetString,
155+
);
156+
this.fileMap.set(textDocument.uri, stylesheetString);
157+
const program = this.service.getProgram();
158+
console.log(program?.getSourceFiles());
159+
return stylesheetTextDocument
160+
}
161+
}
162+
}
163+
}
164+
165+
function positionToOffset(textDocument: TextDocument, position: Position): number {
166+
return textDocument.offsetAt(position);
167+
}
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/startServer.ts

+3
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { VERSION } from '../version';
4747
import { CachedFileSystem } from './CachedFileSystem';
4848
import { Configuration } from './Configuration';
4949
import { safe } from './safe';
50+
import { JSLanguageService } from '../js/JSLanguageService';
5051

5152
const defaultLogger = () => {};
5253

@@ -151,6 +152,7 @@ export function startServer(
151152
// These are augmented here so that the caching is maintained over different runs.
152153
const themeDocset = new AugmentedThemeDocset(remoteThemeDocset);
153154
const cssLanguageService = new CSSLanguageService(documentManager);
155+
const jsLanguageService = new JSLanguageService(documentManager);
154156
const runChecks = debounce(
155157
makeRunChecks(documentManager, diagnosticsManager, {
156158
fs,
@@ -300,6 +302,7 @@ export function startServer(
300302
connection.onInitialize((params) => {
301303
clientCapabilities.setup(params.capabilities, params.initializationOptions);
302304
cssLanguageService.setup(params.capabilities);
305+
jsLanguageService.setup(params.capabilities, fs, params.initializationOptions.workspaceFolders?.[0]?.uri);
303306
jsonLanguageService.setup(params.capabilities);
304307
configuration.setup();
305308

yarn.lock

+7
Original file line numberDiff line numberDiff line change
@@ -1115,6 +1115,13 @@
11151115
"@typescript-eslint/types" "5.62.0"
11161116
eslint-visitor-keys "^3.3.0"
11171117

1118+
"@typescript/[email protected]":
1119+
version "1.6.1"
1120+
resolved "https://registry.yarnpkg.com/@typescript/vfs/-/vfs-1.6.1.tgz#fe7087d5a43715754f7ea9bf6e0b905176c9eebd"
1121+
integrity sha512-JwoxboBh7Oz1v38tPbkrZ62ZXNHAk9bJ7c9x0eI5zBfBnBYGhURdbnh7Z4smN/MV48Y5OCcZb58n972UtbazsA==
1122+
dependencies:
1123+
debug "^4.1.1"
1124+
11181125
11191126
version "2.1.1"
11201127
resolved "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz#907137a86246c5328929d796d741c4e95d1ee19d"

0 commit comments

Comments
 (0)