Skip to content

Commit d58b696

Browse files
committed
finally. a proper language server for idea.
1 parent 225d180 commit d58b696

32 files changed

Lines changed: 2401 additions & 816 deletions

language/client/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

language/client/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"description": "Implementation of idea language server in node.",
44
"author": "Chris <chris@incept.asia>",
55
"license": "Apache-2.0",
6-
"version": "0.4.6",
6+
"version": "0.9.2",
77
"publisher": "stackpress",
88
"repository": {
99
"type": "git",

language/client/src/extension.ts

Lines changed: 52 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,66 @@
1-
/* --------------------------------------------------------------------------------------------
2-
* Copyright (c) Microsoft Corporation. All rights reserved.
3-
* Licensed under the MIT License. See License.txt in the project root for license information.
4-
* ------------------------------------------------------------------------------------------ */
5-
6-
import * as path from 'path';
7-
import { workspace, ExtensionContext } from 'vscode';
8-
9-
import {
10-
LanguageClient,
11-
LanguageClientOptions,
1+
/* --------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
* ------------------------------------------------------------------------------------------ */
5+
6+
import * as path from 'path';
7+
import { workspace, ExtensionContext } from 'vscode';
8+
import {
9+
LanguageClient,
10+
LanguageClientOptions,
1211
ServerOptions,
1312
TransportKind
1413
} from 'vscode-languageclient/node';
15-
16-
let client: LanguageClient;
17-
18-
export function activate(context: ExtensionContext) {
19-
// The server is implemented in node
20-
const serverModule = context.asAbsolutePath(
21-
path.join('server', 'out', 'server.js')
22-
);
23-
24-
// If the extension is launched in debug mode then the debug server options are used
25-
// Otherwise the run options are used
26-
const serverOptions: ServerOptions = {
27-
run: { module: serverModule, transport: TransportKind.ipc },
28-
debug: {
14+
15+
let client: LanguageClient;
16+
17+
/**
18+
* The client stays intentionally small so all schema semantics live on the
19+
* server side, where they can also be tested in isolation.
20+
*/
21+
export function activate(context: ExtensionContext) {
22+
const serverModule = context.asAbsolutePath(
23+
path.join('server', 'out', 'server.js')
24+
);
25+
26+
const serverOptions: ServerOptions = {
27+
run: { module: serverModule, transport: TransportKind.ipc },
28+
debug: {
2929
module: serverModule,
3030
transport: TransportKind.ipc,
3131
}
3232
};
3333

34-
// Options to control the language client
35-
const clientOptions: LanguageClientOptions = {
36-
// Register the server for plain text documents
37-
documentSelector: [{ scheme: 'file', language: 'idea' }],
38-
synchronize: {
39-
// Notify the server about file changes to '.clientrc files contained in the workspace
40-
fileEvents: workspace.createFileSystemWatcher('**/.clientrc')
41-
}
42-
};
43-
44-
// Create the language client and start the client.
45-
client = new LanguageClient(
46-
'ideaLanguageServer',
47-
'Idea Language Server',
34+
const clientOptions: LanguageClientOptions = {
35+
// Untitled support matters for scratch buffers and new unsaved schema files.
36+
documentSelector: [
37+
{ scheme: 'file', language: 'idea' },
38+
{ scheme: 'untitled', language: 'idea' }
39+
],
40+
synchronize: {
41+
// Watching all Idea files lets the server refresh imported schemas when
42+
// related files change outside the active editor buffer.
43+
fileEvents: workspace.createFileSystemWatcher('**/*.idea')
44+
}
45+
};
46+
47+
client = new LanguageClient(
48+
'ideaLanguageServer',
49+
'Idea Language Server',
4850
serverOptions,
4951
clientOptions
5052
);
5153

52-
// Start the client. This will also launch the server
53-
client.start();
54-
}
55-
56-
export function deactivate(): Thenable<void> | undefined {
57-
if (!client) {
58-
return undefined;
54+
client.start();
55+
}
56+
57+
/**
58+
* VS Code calls deactivate during shutdown or reload so the server process
59+
* can exit cleanly instead of hanging around as an orphan.
60+
*/
61+
export function deactivate(): Thenable<void> | undefined {
62+
if (!client) {
63+
return undefined;
5964
}
6065
return client.stop();
61-
}
66+
}

language/client/src/test/completion.test.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,41 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
* ------------------------------------------------------------------------------------------ */
55

6-
// import * as vscode from 'vscode';
7-
// import * as assert from 'assert';
8-
import { getDocUri, activate } from './helper';
6+
import * as vscode from 'vscode';
7+
import * as assert from 'assert';
8+
import {
9+
activate,
10+
getCompletionLabels,
11+
getDocUri,
12+
setTestContent
13+
} from './helper';
914

1015
suite('Should do completion', () => {
11-
const docUri = getDocUri('schema.idea');
16+
const docUri = getDocUri('completion.idea');
1217

13-
test('Completes JS/TS in idea file', async () => {
18+
test('Completes top-level keywords in idea file', async () => {
1419
await activate(docUri);
20+
// An empty scratch document is the clearest way to prove the top-level
21+
// keyword suggestions are not leaking in from fixture content.
22+
await setTestContent('');
23+
const labels = await getCompletionLabels(docUri, new vscode.Position(0, 0));
24+
assert.ok(labels.includes('model'));
25+
assert.ok(labels.includes('use'));
1526
});
16-
});
27+
28+
test('Completes built in types for columns', async () => {
29+
// Partial type names verify type-ahead behavior, not just exact matches.
30+
await setTestContent('model User {\n role Str\n}\n');
31+
const labels = await getCompletionLabels(docUri, new vscode.Position(1, 10));
32+
assert.ok(labels.includes('String'));
33+
});
34+
35+
test('Completes attributes after a column type', async () => {
36+
// A trailing `@` is the edit shape most likely to regress if the context
37+
// classifier starts preferring type completions too aggressively.
38+
await setTestContent('model User {\n role String @\n}\n');
39+
const labels = await getCompletionLabels(docUri, new vscode.Position(1, 15));
40+
assert.ok(labels.includes('@label'));
41+
assert.ok(labels.includes('@field.input'));
42+
});
43+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import * as assert from 'assert';
2+
import { activate, getDefinitions, getDocUri, positionOf } from './helper';
3+
4+
suite('Definitions', () => {
5+
test('Resolves imported type definitions', async () => {
6+
const uri = getDocUri('schema.idea');
7+
await activate(uri);
8+
const position = positionOf('Company?');
9+
const definitions = await getDefinitions(uri, position);
10+
assert.ok(definitions.length > 0);
11+
// Definition providers may return either Location or LocationLink,
12+
// depending on how VS Code chooses to surface the result.
13+
const targetUri = 'uri' in definitions[0]
14+
? definitions[0].uri
15+
: definitions[0].targetUri;
16+
assert.match(targetUri.fsPath, /another\.idea$/);
17+
});
18+
19+
test('Resolves package-style imports through packages directory', async () => {
20+
const uri = getDocUri('pkg-import.idea');
21+
await activate(uri);
22+
// Package-style imports are a Stackpress-specific layout expectation,
23+
// so the test locks that lookup behavior in place.
24+
const position = positionOf('Shared');
25+
const definitions = await getDefinitions(uri, position);
26+
assert.ok(definitions.length > 0);
27+
const targetUri = 'uri' in definitions[0]
28+
? definitions[0].uri
29+
: definitions[0].targetUri;
30+
assert.match(targetUri.fsPath, /packages\/foo\/shared\.idea$/);
31+
});
32+
33+
test('Resolves plugin package targets through packages directory', async () => {
34+
const uri = getDocUri('plugin-package.idea');
35+
await activate(uri);
36+
// Plugin strings should navigate like package references, not like inert
37+
// string literals, because that is how authors verify plugin targets.
38+
const position = positionOf('demo-plugin');
39+
const definitions = await getDefinitions(uri, position);
40+
assert.ok(definitions.length > 0);
41+
const targetUri = 'uri' in definitions[0]
42+
? definitions[0].uri
43+
: definitions[0].targetUri;
44+
assert.match(targetUri.fsPath, /packages\/demo-plugin\/package\.json$/);
45+
});
46+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import * as assert from 'assert';
2+
import { activate, getDocUri, waitForDiagnostics } from './helper';
3+
4+
suite('Diagnostics', () => {
5+
test('Reports parser errors', async () => {
6+
const uri = getDocUri('bad.idea');
7+
await activate(uri);
8+
// The test only asserts the user-visible contract: a broken document
9+
// should surface at least one meaningful parser error.
10+
const diagnostics = await waitForDiagnostics(uri);
11+
assert.ok(diagnostics.length > 0);
12+
assert.match(diagnostics[0].message, /Unexpected|Expecting|Invalid/i);
13+
});
14+
});

language/client/src/test/helper.ts

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,43 +5,128 @@
55

66
import * as vscode from 'vscode';
77
import * as path from 'path';
8+
import * as assert from 'assert';
89

910
export let doc: vscode.TextDocument;
1011
export let editor: vscode.TextEditor;
1112
export let documentEol: string;
1213
export let platformEol: string;
1314

1415
/**
15-
* Activates the vscode.lsp-sample extension
16+
* Tests activate the extension through a real editor open so the language
17+
* server lifecycle matches what users do in VS Code.
1618
*/
1719
export async function activate(docUri: vscode.Uri) {
18-
// The extensionId is `publisher.name` from package.json
1920
const ext = vscode.extensions.getExtension('stackpress.idea-schema')!;
2021
await ext.activate();
2122
try {
2223
doc = await vscode.workspace.openTextDocument(docUri);
2324
editor = await vscode.window.showTextDocument(doc);
24-
await sleep(2000); // Wait for server activation
25+
// A short delay keeps the tests black-box oriented by waiting for the
26+
// server the same way a user would, instead of poking internal events.
27+
await sleep(2000);
2528
} catch (e) {
2629
console.error(e);
2730
}
2831
}
2932

33+
/**
34+
* Polling-based waits are good enough here because the integration suite is
35+
* validating editor-visible behavior rather than micro-benchmarking latency.
36+
*/
3037
async function sleep(ms: number) {
3138
return new Promise(resolve => setTimeout(resolve, ms));
3239
}
3340

41+
/**
42+
* Fixtures live outside the compiled `out` directory so path resolution is
43+
* anchored relative to the test source layout.
44+
*/
3445
export const getDocPath = (p: string) => {
3546
return path.resolve(__dirname, '../../testFixture', p);
3647
};
48+
3749
export const getDocUri = (p: string) => {
3850
return vscode.Uri.file(getDocPath(p));
3951
};
4052

53+
/**
54+
* Replacing the full document keeps completion/definition tests compact and
55+
* avoids creating a new fixture file for every single editing scenario.
56+
*/
4157
export async function setTestContent(content: string): Promise<boolean> {
4258
const all = new vscode.Range(
4359
doc.positionAt(0),
4460
doc.positionAt(doc.getText().length)
4561
);
46-
return editor.edit(eb => eb.replace(all, content));
47-
}
62+
const applied = await editor.edit(eb => eb.replace(all, content));
63+
// The follow-up pause lets the LSP server digest the edit before the test
64+
// asks for completions or diagnostics.
65+
await sleep(300);
66+
return applied;
67+
}
68+
69+
/**
70+
* Diagnostics are asynchronous, so tests wait until the expected minimum
71+
* appears instead of assuming the server responded immediately.
72+
*/
73+
export async function waitForDiagnostics(uri: vscode.Uri, minimum = 1) {
74+
for (let attempt = 0; attempt < 20; attempt++) {
75+
const diagnostics = vscode.languages.getDiagnostics(uri);
76+
if (diagnostics.length >= minimum) {
77+
return diagnostics;
78+
}
79+
await sleep(200);
80+
}
81+
return vscode.languages.getDiagnostics(uri);
82+
}
83+
84+
/**
85+
* Some completion tests temporarily introduce incomplete text, so this helper
86+
* waits until the document has settled back into a clean state first.
87+
*/
88+
export async function waitForNoDiagnostics(uri: vscode.Uri) {
89+
for (let attempt = 0; attempt < 20; attempt++) {
90+
const diagnostics = vscode.languages.getDiagnostics(uri);
91+
if (diagnostics.length === 0) {
92+
return diagnostics;
93+
}
94+
await sleep(200);
95+
}
96+
return vscode.languages.getDiagnostics(uri);
97+
}
98+
99+
/**
100+
* Tests only care about the visible labels because the user-facing list is
101+
* what confirms the context classifier is doing the right thing.
102+
*/
103+
export async function getCompletionLabels(uri: vscode.Uri, position: vscode.Position) {
104+
const completionList = await vscode.commands.executeCommand<vscode.CompletionList>(
105+
'vscode.executeCompletionItemProvider',
106+
uri,
107+
position
108+
);
109+
return (completionList?.items || []).map(item => item.label.toString());
110+
}
111+
112+
/**
113+
* Definitions can return either direct locations or location links, so the
114+
* helper leaves that distinction to the individual assertion.
115+
*/
116+
export async function getDefinitions(uri: vscode.Uri, position: vscode.Position) {
117+
return await vscode.commands.executeCommand<(vscode.Location | vscode.LocationLink)[]>(
118+
'vscode.executeDefinitionProvider',
119+
uri,
120+
position
121+
) || [];
122+
}
123+
124+
/**
125+
* Searching by text keeps the tests readable because the fixture itself
126+
* shows the navigation target without hard-coded line numbers.
127+
*/
128+
export function positionOf(search: string) {
129+
const index = doc.getText().indexOf(search);
130+
assert.ok(index >= 0, `Expected fixture to contain "${search}"`);
131+
return doc.positionAt(index);
132+
}

0 commit comments

Comments
 (0)