@nudojs/lsp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # @nudojs/lsp
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Conceptual design and basic implementation.
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies
12
+ - @nudojs/core@0.1.0
13
+ - @nudojs/service@0.1.0
@@ -0,0 +1,2 @@
1
+
2
+ export { }
package/dist/server.js ADDED
@@ -0,0 +1,254 @@
1
+ // src/server.ts
2
+ import {
3
+ createConnection,
4
+ TextDocuments,
5
+ ProposedFeatures,
6
+ TextDocumentSyncKind,
7
+ DiagnosticSeverity,
8
+ DiagnosticTag,
9
+ CompletionItemKind,
10
+ MarkupKind,
11
+ CodeLensRefreshRequest,
12
+ InlayHintKind
13
+ } from "vscode-languageserver/node";
14
+ import { TextDocument } from "vscode-languageserver-textdocument";
15
+ import { typeValueToString } from "@nudojs/core";
16
+ import {
17
+ analyzeFile,
18
+ getTypeAtPosition,
19
+ getCompletionsAtPosition,
20
+ getCasesForFile
21
+ } from "@nudojs/service";
22
+ var connection = createConnection(ProposedFeatures.all);
23
+ var documents = new TextDocuments(TextDocument);
24
+ var activeCases = /* @__PURE__ */ new Map();
25
+ function getActiveCasesForUri(uri) {
26
+ const existing = activeCases.get(uri);
27
+ if (existing) return existing;
28
+ const map = /* @__PURE__ */ new Map();
29
+ activeCases.set(uri, map);
30
+ return map;
31
+ }
32
+ var severityMap = {
33
+ error: DiagnosticSeverity.Error,
34
+ warning: DiagnosticSeverity.Warning,
35
+ info: DiagnosticSeverity.Information
36
+ };
37
+ connection.onInitialize((_params) => ({
38
+ capabilities: {
39
+ textDocumentSync: TextDocumentSyncKind.Full,
40
+ hoverProvider: true,
41
+ completionProvider: {
42
+ triggerCharacters: ["."],
43
+ resolveProvider: false
44
+ },
45
+ codeLensProvider: {
46
+ resolveProvider: false
47
+ },
48
+ inlayHintProvider: true
49
+ }
50
+ }));
51
+ var debounceTimers = /* @__PURE__ */ new Map();
52
+ documents.onDidChangeContent((change) => {
53
+ const uri = change.document.uri;
54
+ nudoFileCache.delete(uri);
55
+ const existing = debounceTimers.get(uri);
56
+ if (existing) clearTimeout(existing);
57
+ debounceTimers.set(
58
+ uri,
59
+ setTimeout(() => {
60
+ debounceTimers.delete(uri);
61
+ validateDocument(change.document);
62
+ }, 300)
63
+ );
64
+ });
65
+ documents.onDidClose((event) => {
66
+ const timer = debounceTimers.get(event.document.uri);
67
+ if (timer) clearTimeout(timer);
68
+ debounceTimers.delete(event.document.uri);
69
+ nudoFileCache.delete(event.document.uri);
70
+ connection.sendDiagnostics({ uri: event.document.uri, diagnostics: [] });
71
+ });
72
+ function validateDocument(document) {
73
+ const uri = document.uri;
74
+ if (!isNudoFile(uri)) {
75
+ connection.sendDiagnostics({ uri, diagnostics: [] });
76
+ return;
77
+ }
78
+ const filePath = uriToFilePath(uri);
79
+ const source = document.getText();
80
+ const cases = getActiveCasesForUri(uri);
81
+ try {
82
+ const result = analyzeFile(filePath, source, cases);
83
+ const diagnostics = result.diagnostics.map((d) => {
84
+ const diag = {
85
+ severity: severityMap[d.severity],
86
+ range: {
87
+ start: { line: d.range.start.line - 1, character: d.range.start.column },
88
+ end: { line: d.range.end.line - 1, character: d.range.end.column }
89
+ },
90
+ message: d.message,
91
+ source: "nudo"
92
+ };
93
+ if (d.tags?.includes("unnecessary")) {
94
+ diag.tags = [DiagnosticTag.Unnecessary];
95
+ }
96
+ return diag;
97
+ });
98
+ connection.sendDiagnostics({ uri, diagnostics });
99
+ } catch (err) {
100
+ connection.sendDiagnostics({
101
+ uri,
102
+ diagnostics: [
103
+ {
104
+ severity: DiagnosticSeverity.Error,
105
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
106
+ message: `Analysis error: ${err.message}`,
107
+ source: "nudo"
108
+ }
109
+ ]
110
+ });
111
+ }
112
+ }
113
+ connection.onHover((params) => {
114
+ const document = documents.get(params.textDocument.uri);
115
+ if (!document) return null;
116
+ if (!isNudoFile(params.textDocument.uri)) return null;
117
+ const filePath = uriToFilePath(params.textDocument.uri);
118
+ const source = document.getText();
119
+ const line = params.position.line + 1;
120
+ const column = params.position.character;
121
+ const cases = getActiveCasesForUri(params.textDocument.uri);
122
+ try {
123
+ const tv = getTypeAtPosition(filePath, source, line, column, cases);
124
+ if (!tv) return null;
125
+ return {
126
+ contents: {
127
+ kind: MarkupKind.Markdown,
128
+ value: `\`\`\`nudo
129
+ ${typeValueToString(tv)}
130
+ \`\`\``
131
+ }
132
+ };
133
+ } catch {
134
+ return null;
135
+ }
136
+ });
137
+ connection.onCompletion((params) => {
138
+ const document = documents.get(params.textDocument.uri);
139
+ if (!document) return [];
140
+ if (!isNudoFile(params.textDocument.uri)) return [];
141
+ const filePath = uriToFilePath(params.textDocument.uri);
142
+ const source = document.getText();
143
+ const line = params.position.line + 1;
144
+ const column = params.position.character;
145
+ try {
146
+ const items = getCompletionsAtPosition(filePath, source, line, column);
147
+ return items.map((item) => ({
148
+ label: item.label,
149
+ kind: item.kind === "method" ? CompletionItemKind.Method : item.kind === "property" ? CompletionItemKind.Property : CompletionItemKind.Variable,
150
+ detail: item.detail
151
+ }));
152
+ } catch {
153
+ return [];
154
+ }
155
+ });
156
+ connection.onCodeLens((params) => {
157
+ const document = documents.get(params.textDocument.uri);
158
+ if (!document) return [];
159
+ if (!isNudoFile(params.textDocument.uri)) return [];
160
+ const filePath = uriToFilePath(params.textDocument.uri);
161
+ const source = document.getText();
162
+ const cases = getActiveCasesForUri(params.textDocument.uri);
163
+ try {
164
+ const fnCases = getCasesForFile(filePath, source);
165
+ const lenses = [];
166
+ for (const fn of fnCases) {
167
+ if (fn.cases.length === 0) continue;
168
+ const activeIdx = cases.get(fn.functionName) ?? 0;
169
+ for (const c of fn.cases) {
170
+ const isActive = c.index === activeIdx;
171
+ const title = isActive ? `\u25CF case "${c.name}"` : `\u25CB case "${c.name}"`;
172
+ lenses.push({
173
+ range: {
174
+ start: { line: fn.loc.start.line - 1, character: 0 },
175
+ end: { line: fn.loc.start.line - 1, character: 0 }
176
+ },
177
+ command: {
178
+ title,
179
+ command: "nudo.selectCase",
180
+ arguments: [params.textDocument.uri, fn.functionName, c.index, c.name]
181
+ }
182
+ });
183
+ }
184
+ }
185
+ return lenses;
186
+ } catch {
187
+ return [];
188
+ }
189
+ });
190
+ connection.languages.inlayHint.on((params) => {
191
+ const document = documents.get(params.textDocument.uri);
192
+ if (!document) return [];
193
+ if (!isNudoFile(params.textDocument.uri)) return [];
194
+ const filePath = uriToFilePath(params.textDocument.uri);
195
+ const source = document.getText();
196
+ const cases = getActiveCasesForUri(params.textDocument.uri);
197
+ const lines = source.split("\n");
198
+ try {
199
+ const result = analyzeFile(filePath, source, cases);
200
+ const hints = [];
201
+ for (const hint of result.caseHints) {
202
+ const lineIdx = hint.line - 1;
203
+ if (lineIdx < 0 || lineIdx >= lines.length) continue;
204
+ const lineLen = lines[lineIdx].length;
205
+ hints.push({
206
+ position: { line: lineIdx, character: lineLen },
207
+ label: ` ${hint.label}`,
208
+ kind: InlayHintKind.Type,
209
+ paddingLeft: true
210
+ });
211
+ }
212
+ return hints;
213
+ } catch {
214
+ return [];
215
+ }
216
+ });
217
+ connection.onRequest("nudo/selectCase", (params) => {
218
+ const cases = getActiveCasesForUri(params.uri);
219
+ cases.set(params.functionName, params.caseIndex);
220
+ const document = documents.get(params.uri);
221
+ if (document) {
222
+ validateDocument(document);
223
+ }
224
+ connection.sendRequest(CodeLensRefreshRequest.type).catch(() => {
225
+ });
226
+ return { success: true };
227
+ });
228
+ connection.onRequest("nudo/getActiveCases", (params) => {
229
+ const cases = getActiveCasesForUri(params.uri);
230
+ const result = {};
231
+ for (const [fn, idx] of cases) {
232
+ result[fn] = idx;
233
+ }
234
+ return result;
235
+ });
236
+ var nudoFileCache = /* @__PURE__ */ new Map();
237
+ function isNudoFile(uri) {
238
+ if (!uri.endsWith(".js") && !uri.endsWith(".ts") && !uri.endsWith(".mjs")) return false;
239
+ const cached = nudoFileCache.get(uri);
240
+ if (cached !== void 0) return cached;
241
+ const doc = documents.get(uri);
242
+ if (!doc) return false;
243
+ const result = hasNudoDirectives(doc.getText());
244
+ nudoFileCache.set(uri, result);
245
+ return result;
246
+ }
247
+ function hasNudoDirectives(source) {
248
+ return /@nudo:(case|mock|pure|skip|sample|returns)\b/.test(source);
249
+ }
250
+ function uriToFilePath(uri) {
251
+ return uri.startsWith("file://") ? decodeURIComponent(uri.slice(7)) : uri;
252
+ }
253
+ documents.listen(connection);
254
+ connection.listen();
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@nudojs/lsp",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "src/server.ts",
6
+ "dependencies": {
7
+ "vscode-languageserver": "^9.0.0",
8
+ "vscode-languageserver-textdocument": "^1.0.0",
9
+ "@nudojs/service": "0.1.0",
10
+ "@nudojs/core": "0.1.0"
11
+ },
12
+ "scripts": {
13
+ "build": "tsup src/server.ts --format esm --dts"
14
+ }
15
+ }
package/src/server.ts ADDED
@@ -0,0 +1,302 @@
1
+ import {
2
+ createConnection,
3
+ TextDocuments,
4
+ ProposedFeatures,
5
+ TextDocumentSyncKind,
6
+ type InitializeParams,
7
+ type InitializeResult,
8
+ type Diagnostic as LspDiagnostic,
9
+ DiagnosticSeverity,
10
+ DiagnosticTag,
11
+ type CompletionItem as LspCompletionItem,
12
+ CompletionItemKind,
13
+ MarkupKind,
14
+ type CodeLens,
15
+ CodeLensRefreshRequest,
16
+ type InlayHint,
17
+ InlayHintKind,
18
+ } from "vscode-languageserver/node";
19
+ import { TextDocument } from "vscode-languageserver-textdocument";
20
+ import { typeValueToString } from "@nudojs/core";
21
+ import {
22
+ analyzeFile,
23
+ getTypeAtPosition,
24
+ getCompletionsAtPosition,
25
+ getCasesForFile,
26
+ type DiagnosticSeverity as JsDiagSeverity,
27
+ } from "@nudojs/service";
28
+
29
+ const connection = createConnection(ProposedFeatures.all);
30
+ const documents = new TextDocuments(TextDocument);
31
+
32
+ const activeCases = new Map<string, Map<string, number>>();
33
+
34
+ function getActiveCasesForUri(uri: string): Map<string, number> {
35
+ const existing = activeCases.get(uri);
36
+ if (existing) return existing;
37
+ const map = new Map<string, number>();
38
+ activeCases.set(uri, map);
39
+ return map;
40
+ }
41
+
42
+ const severityMap: Record<JsDiagSeverity, DiagnosticSeverity> = {
43
+ error: DiagnosticSeverity.Error,
44
+ warning: DiagnosticSeverity.Warning,
45
+ info: DiagnosticSeverity.Information,
46
+ };
47
+
48
+ connection.onInitialize((_params: InitializeParams): InitializeResult => ({
49
+ capabilities: {
50
+ textDocumentSync: TextDocumentSyncKind.Full,
51
+ hoverProvider: true,
52
+ completionProvider: {
53
+ triggerCharacters: ["."],
54
+ resolveProvider: false,
55
+ },
56
+ codeLensProvider: {
57
+ resolveProvider: false,
58
+ },
59
+ inlayHintProvider: true,
60
+ },
61
+ }));
62
+
63
+ let debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
64
+
65
+ documents.onDidChangeContent((change) => {
66
+ const uri = change.document.uri;
67
+ nudoFileCache.delete(uri);
68
+ const existing = debounceTimers.get(uri);
69
+ if (existing) clearTimeout(existing);
70
+
71
+ debounceTimers.set(
72
+ uri,
73
+ setTimeout(() => {
74
+ debounceTimers.delete(uri);
75
+ validateDocument(change.document);
76
+ }, 300),
77
+ );
78
+ });
79
+
80
+ documents.onDidClose((event) => {
81
+ const timer = debounceTimers.get(event.document.uri);
82
+ if (timer) clearTimeout(timer);
83
+ debounceTimers.delete(event.document.uri);
84
+ nudoFileCache.delete(event.document.uri);
85
+ connection.sendDiagnostics({ uri: event.document.uri, diagnostics: [] });
86
+ });
87
+
88
+ function validateDocument(document: TextDocument): void {
89
+ const uri = document.uri;
90
+ if (!isNudoFile(uri)) {
91
+ connection.sendDiagnostics({ uri, diagnostics: [] });
92
+ return;
93
+ }
94
+
95
+ const filePath = uriToFilePath(uri);
96
+ const source = document.getText();
97
+ const cases = getActiveCasesForUri(uri);
98
+
99
+ try {
100
+ const result = analyzeFile(filePath, source, cases);
101
+ const diagnostics: LspDiagnostic[] = result.diagnostics.map((d) => {
102
+ const diag: LspDiagnostic = {
103
+ severity: severityMap[d.severity],
104
+ range: {
105
+ start: { line: d.range.start.line - 1, character: d.range.start.column },
106
+ end: { line: d.range.end.line - 1, character: d.range.end.column },
107
+ },
108
+ message: d.message,
109
+ source: "nudo",
110
+ };
111
+ if (d.tags?.includes("unnecessary")) {
112
+ diag.tags = [DiagnosticTag.Unnecessary];
113
+ }
114
+ return diag;
115
+ });
116
+ connection.sendDiagnostics({ uri, diagnostics });
117
+ } catch (err) {
118
+ connection.sendDiagnostics({
119
+ uri,
120
+ diagnostics: [
121
+ {
122
+ severity: DiagnosticSeverity.Error,
123
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
124
+ message: `Analysis error: ${(err as Error).message}`,
125
+ source: "nudo",
126
+ },
127
+ ],
128
+ });
129
+ }
130
+ }
131
+
132
+ connection.onHover((params) => {
133
+ const document = documents.get(params.textDocument.uri);
134
+ if (!document) return null;
135
+ if (!isNudoFile(params.textDocument.uri)) return null;
136
+
137
+ const filePath = uriToFilePath(params.textDocument.uri);
138
+ const source = document.getText();
139
+ const line = params.position.line + 1;
140
+ const column = params.position.character;
141
+ const cases = getActiveCasesForUri(params.textDocument.uri);
142
+
143
+ try {
144
+ const tv = getTypeAtPosition(filePath, source, line, column, cases);
145
+ if (!tv) return null;
146
+
147
+ return {
148
+ contents: {
149
+ kind: MarkupKind.Markdown,
150
+ value: `\`\`\`nudo\n${typeValueToString(tv)}\n\`\`\``,
151
+ },
152
+ };
153
+ } catch {
154
+ return null;
155
+ }
156
+ });
157
+
158
+ connection.onCompletion((params) => {
159
+ const document = documents.get(params.textDocument.uri);
160
+ if (!document) return [];
161
+ if (!isNudoFile(params.textDocument.uri)) return [];
162
+
163
+ const filePath = uriToFilePath(params.textDocument.uri);
164
+ const source = document.getText();
165
+ const line = params.position.line + 1;
166
+ const column = params.position.character;
167
+
168
+ try {
169
+ const items = getCompletionsAtPosition(filePath, source, line, column);
170
+ return items.map((item): LspCompletionItem => ({
171
+ label: item.label,
172
+ kind: item.kind === "method"
173
+ ? CompletionItemKind.Method
174
+ : item.kind === "property"
175
+ ? CompletionItemKind.Property
176
+ : CompletionItemKind.Variable,
177
+ detail: item.detail,
178
+ }));
179
+ } catch {
180
+ return [];
181
+ }
182
+ });
183
+
184
+ connection.onCodeLens((params) => {
185
+ const document = documents.get(params.textDocument.uri);
186
+ if (!document) return [];
187
+ if (!isNudoFile(params.textDocument.uri)) return [];
188
+
189
+ const filePath = uriToFilePath(params.textDocument.uri);
190
+ const source = document.getText();
191
+ const cases = getActiveCasesForUri(params.textDocument.uri);
192
+
193
+ try {
194
+ const fnCases = getCasesForFile(filePath, source);
195
+ const lenses: CodeLens[] = [];
196
+
197
+ for (const fn of fnCases) {
198
+ if (fn.cases.length === 0) continue;
199
+ const activeIdx = cases.get(fn.functionName) ?? 0;
200
+
201
+ for (const c of fn.cases) {
202
+ const isActive = c.index === activeIdx;
203
+ const title = isActive ? `● case "${c.name}"` : `○ case "${c.name}"`;
204
+ lenses.push({
205
+ range: {
206
+ start: { line: fn.loc.start.line - 1, character: 0 },
207
+ end: { line: fn.loc.start.line - 1, character: 0 },
208
+ },
209
+ command: {
210
+ title,
211
+ command: "nudo.selectCase",
212
+ arguments: [params.textDocument.uri, fn.functionName, c.index, c.name],
213
+ },
214
+ });
215
+ }
216
+ }
217
+
218
+ return lenses;
219
+ } catch {
220
+ return [];
221
+ }
222
+ });
223
+
224
+ connection.languages.inlayHint.on((params) => {
225
+ const document = documents.get(params.textDocument.uri);
226
+ if (!document) return [];
227
+ if (!isNudoFile(params.textDocument.uri)) return [];
228
+
229
+ const filePath = uriToFilePath(params.textDocument.uri);
230
+ const source = document.getText();
231
+ const cases = getActiveCasesForUri(params.textDocument.uri);
232
+ const lines = source.split("\n");
233
+
234
+ try {
235
+ const result = analyzeFile(filePath, source, cases);
236
+ const hints: InlayHint[] = [];
237
+
238
+ for (const hint of result.caseHints) {
239
+ const lineIdx = hint.line - 1;
240
+ if (lineIdx < 0 || lineIdx >= lines.length) continue;
241
+ const lineLen = lines[lineIdx].length;
242
+
243
+ hints.push({
244
+ position: { line: lineIdx, character: lineLen },
245
+ label: ` ${hint.label}`,
246
+ kind: InlayHintKind.Type,
247
+ paddingLeft: true,
248
+ });
249
+ }
250
+
251
+ return hints;
252
+ } catch {
253
+ return [];
254
+ }
255
+ });
256
+
257
+ connection.onRequest("nudo/selectCase", (params: { uri: string; functionName: string; caseIndex: number }) => {
258
+ const cases = getActiveCasesForUri(params.uri);
259
+ cases.set(params.functionName, params.caseIndex);
260
+
261
+ const document = documents.get(params.uri);
262
+ if (document) {
263
+ validateDocument(document);
264
+ }
265
+
266
+ connection.sendRequest(CodeLensRefreshRequest.type).catch(() => {});
267
+
268
+ return { success: true };
269
+ });
270
+
271
+ connection.onRequest("nudo/getActiveCases", (params: { uri: string }) => {
272
+ const cases = getActiveCasesForUri(params.uri);
273
+ const result: Record<string, number> = {};
274
+ for (const [fn, idx] of cases) {
275
+ result[fn] = idx;
276
+ }
277
+ return result;
278
+ });
279
+
280
+ const nudoFileCache = new Map<string, boolean>();
281
+
282
+ function isNudoFile(uri: string): boolean {
283
+ if (!uri.endsWith(".js") && !uri.endsWith(".ts") && !uri.endsWith(".mjs")) return false;
284
+ const cached = nudoFileCache.get(uri);
285
+ if (cached !== undefined) return cached;
286
+ const doc = documents.get(uri);
287
+ if (!doc) return false;
288
+ const result = hasNudoDirectives(doc.getText());
289
+ nudoFileCache.set(uri, result);
290
+ return result;
291
+ }
292
+
293
+ function hasNudoDirectives(source: string): boolean {
294
+ return /@nudo:(case|mock|pure|skip|sample|returns)\b/.test(source);
295
+ }
296
+
297
+ function uriToFilePath(uri: string): string {
298
+ return uri.startsWith("file://") ? decodeURIComponent(uri.slice(7)) : uri;
299
+ }
300
+
301
+ documents.listen(connection);
302
+ connection.listen();
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"],
8
+ "references": [
9
+ { "path": "../core" },
10
+ { "path": "../service" }
11
+ ]
12
+ }