@rejot-dev/thalo-lsp 0.0.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/LICENSE +21 -0
- package/README.md +216 -0
- package/dist/capabilities.d.ts +12 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +54 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/handlers/completions/completions.js +33 -0
- package/dist/handlers/completions/completions.js.map +1 -0
- package/dist/handlers/completions/context.js +228 -0
- package/dist/handlers/completions/context.js.map +1 -0
- package/dist/handlers/completions/providers/directive.js +50 -0
- package/dist/handlers/completions/providers/directive.js.map +1 -0
- package/dist/handlers/completions/providers/entity.js +52 -0
- package/dist/handlers/completions/providers/entity.js.map +1 -0
- package/dist/handlers/completions/providers/link.js +113 -0
- package/dist/handlers/completions/providers/link.js.map +1 -0
- package/dist/handlers/completions/providers/metadata-key.js +43 -0
- package/dist/handlers/completions/providers/metadata-key.js.map +1 -0
- package/dist/handlers/completions/providers/metadata-value.js +71 -0
- package/dist/handlers/completions/providers/metadata-value.js.map +1 -0
- package/dist/handlers/completions/providers/providers.js +31 -0
- package/dist/handlers/completions/providers/providers.js.map +1 -0
- package/dist/handlers/completions/providers/schema-block.js +46 -0
- package/dist/handlers/completions/providers/schema-block.js.map +1 -0
- package/dist/handlers/completions/providers/section.js +37 -0
- package/dist/handlers/completions/providers/section.js.map +1 -0
- package/dist/handlers/completions/providers/tag.js +55 -0
- package/dist/handlers/completions/providers/tag.js.map +1 -0
- package/dist/handlers/completions/providers/timestamp.js +32 -0
- package/dist/handlers/completions/providers/timestamp.js.map +1 -0
- package/dist/handlers/completions/providers/type-expr.js +56 -0
- package/dist/handlers/completions/providers/type-expr.js.map +1 -0
- package/dist/handlers/definition.js +166 -0
- package/dist/handlers/definition.js.map +1 -0
- package/dist/handlers/diagnostics.js +77 -0
- package/dist/handlers/diagnostics.js.map +1 -0
- package/dist/handlers/hover.js +73 -0
- package/dist/handlers/hover.js.map +1 -0
- package/dist/handlers/references.js +233 -0
- package/dist/handlers/references.js.map +1 -0
- package/dist/handlers/semantic-tokens.js +36 -0
- package/dist/handlers/semantic-tokens.js.map +1 -0
- package/dist/mod.d.ts +2 -0
- package/dist/mod.js +3 -0
- package/dist/server.bundled.js +1764 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +367 -0
- package/dist/server.js.map +1 -0
- package/package.json +45 -0
|
@@ -0,0 +1,1764 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { DidChangeConfigurationNotification, FileChangeType, ProposedFeatures, createConnection as createConnection$1 } from "vscode-languageserver/node.js";
|
|
4
|
+
import { TextDocument } from "vscode-languageserver-textdocument";
|
|
5
|
+
import { createWorkspace, parseDocument } from "@rejot-dev/thalo/native";
|
|
6
|
+
import { ALL_DIRECTIVES, INSTANCE_DIRECTIVES, PRIMITIVE_TYPES, SCHEMA_BLOCK_HEADERS, SCHEMA_DIRECTIVES, TypeExpr, checkDocument, encodeSemanticTokens, extractSemanticTokens, findDefinition, findEntityDefinition, findEntityReferences, findFieldDefinition, findFieldReferences, findNodeAtPosition, findReferences, findSectionDefinition, findSectionReferences, findTagReferences, getHoverInfo, isInstanceDirective, isSchemaDirective, isSyntaxError, isSynthesisDirective, tokenModifiers, tokenTypes } from "@rejot-dev/thalo";
|
|
7
|
+
|
|
8
|
+
//#region src/capabilities.ts
|
|
9
|
+
/**
|
|
10
|
+
* File operation filters for thalo and markdown files
|
|
11
|
+
*/
|
|
12
|
+
const thaloFileFilters = [{
|
|
13
|
+
scheme: "file",
|
|
14
|
+
pattern: { glob: "**/*.thalo" }
|
|
15
|
+
}, {
|
|
16
|
+
scheme: "file",
|
|
17
|
+
pattern: { glob: "**/*.md" }
|
|
18
|
+
}];
|
|
19
|
+
/**
|
|
20
|
+
* Semantic tokens legend for LSP
|
|
21
|
+
* Maps the token types and modifiers from @rejot-dev/thalo
|
|
22
|
+
*/
|
|
23
|
+
const tokenLegend = {
|
|
24
|
+
tokenTypes: [...tokenTypes],
|
|
25
|
+
tokenModifiers: [...tokenModifiers]
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* LSP server capabilities for thalo
|
|
29
|
+
*
|
|
30
|
+
* Defines which LSP features are supported by this server.
|
|
31
|
+
*/
|
|
32
|
+
const serverCapabilities = {
|
|
33
|
+
textDocumentSync: {
|
|
34
|
+
openClose: true,
|
|
35
|
+
change: 2,
|
|
36
|
+
save: { includeText: true }
|
|
37
|
+
},
|
|
38
|
+
definitionProvider: true,
|
|
39
|
+
referencesProvider: true,
|
|
40
|
+
hoverProvider: true,
|
|
41
|
+
completionProvider: {
|
|
42
|
+
triggerCharacters: ["^", "#"],
|
|
43
|
+
resolveProvider: true
|
|
44
|
+
},
|
|
45
|
+
semanticTokensProvider: {
|
|
46
|
+
legend: tokenLegend,
|
|
47
|
+
full: true,
|
|
48
|
+
range: false
|
|
49
|
+
},
|
|
50
|
+
workspace: { fileOperations: {
|
|
51
|
+
didCreate: { filters: thaloFileFilters },
|
|
52
|
+
didDelete: { filters: thaloFileFilters },
|
|
53
|
+
didRename: { filters: thaloFileFilters }
|
|
54
|
+
} }
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region src/handlers/definition.ts
|
|
59
|
+
/**
|
|
60
|
+
* Convert a file path to a URI
|
|
61
|
+
*/
|
|
62
|
+
function pathToUri$1(path$1) {
|
|
63
|
+
if (!path$1.startsWith("file://")) return `file://${encodeURIComponent(path$1).replace(/%2F/g, "/")}`;
|
|
64
|
+
return path$1;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Get the file type from a URI
|
|
68
|
+
*/
|
|
69
|
+
function getFileType$4(uri) {
|
|
70
|
+
if (uri.endsWith(".thalo")) return "thalo";
|
|
71
|
+
if (uri.endsWith(".md")) return "markdown";
|
|
72
|
+
return "thalo";
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Convert LSP Position to thalo Position (both are 0-based).
|
|
76
|
+
*/
|
|
77
|
+
function lspToThaloPosition$2(pos) {
|
|
78
|
+
return {
|
|
79
|
+
line: pos.line,
|
|
80
|
+
column: pos.character
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Handle textDocument/definition request
|
|
85
|
+
*
|
|
86
|
+
* Finds the definition location for various syntax elements at the given position:
|
|
87
|
+
* - ^link-id → Entry definition
|
|
88
|
+
* - Entity name in instance/alter-entity → define-entity
|
|
89
|
+
* - Metadata key → Field definition in schema
|
|
90
|
+
* - Section header → Section definition in schema
|
|
91
|
+
*
|
|
92
|
+
* Supports both standalone .thalo files and thalo blocks embedded in markdown.
|
|
93
|
+
*
|
|
94
|
+
* @param workspace - The thalo workspace
|
|
95
|
+
* @param document - The text document
|
|
96
|
+
* @param position - The position in the document (line/character)
|
|
97
|
+
* @returns The definition location, or null if not found
|
|
98
|
+
*/
|
|
99
|
+
function handleDefinition(workspace, document, position) {
|
|
100
|
+
const fileType = getFileType$4(document.uri);
|
|
101
|
+
try {
|
|
102
|
+
const context = findNodeAtPosition(parseDocument(document.getText(), {
|
|
103
|
+
fileType,
|
|
104
|
+
filename: document.uri
|
|
105
|
+
}), lspToThaloPosition$2(position));
|
|
106
|
+
switch (context.kind) {
|
|
107
|
+
case "link": {
|
|
108
|
+
const result = findDefinition(workspace, context.linkId);
|
|
109
|
+
if (result) return {
|
|
110
|
+
uri: pathToUri$1(result.file),
|
|
111
|
+
range: {
|
|
112
|
+
start: {
|
|
113
|
+
line: result.location.startPosition.row,
|
|
114
|
+
character: result.location.startPosition.column
|
|
115
|
+
},
|
|
116
|
+
end: {
|
|
117
|
+
line: result.location.endPosition.row,
|
|
118
|
+
character: result.location.endPosition.column
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
case "entity":
|
|
125
|
+
case "schema_entity": {
|
|
126
|
+
const result = findEntityDefinition(workspace, context.kind === "entity" ? context.entityName : context.entityName);
|
|
127
|
+
if (result) return {
|
|
128
|
+
uri: pathToUri$1(result.file),
|
|
129
|
+
range: {
|
|
130
|
+
start: {
|
|
131
|
+
line: result.location.startPosition.row,
|
|
132
|
+
character: result.location.startPosition.column
|
|
133
|
+
},
|
|
134
|
+
end: {
|
|
135
|
+
line: result.location.endPosition.row,
|
|
136
|
+
character: result.location.endPosition.column
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
case "metadata_key": {
|
|
143
|
+
const result = findFieldDefinition(workspace, context.key, context.entityContext);
|
|
144
|
+
if (result) return {
|
|
145
|
+
uri: pathToUri$1(result.file),
|
|
146
|
+
range: {
|
|
147
|
+
start: {
|
|
148
|
+
line: result.location.startPosition.row,
|
|
149
|
+
character: result.location.startPosition.column
|
|
150
|
+
},
|
|
151
|
+
end: {
|
|
152
|
+
line: result.location.endPosition.row,
|
|
153
|
+
character: result.location.endPosition.column
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
case "section_header": {
|
|
160
|
+
const result = findSectionDefinition(workspace, context.sectionName, context.entityContext);
|
|
161
|
+
if (result) return {
|
|
162
|
+
uri: pathToUri$1(result.file),
|
|
163
|
+
range: {
|
|
164
|
+
start: {
|
|
165
|
+
line: result.location.startPosition.row,
|
|
166
|
+
character: result.location.startPosition.column
|
|
167
|
+
},
|
|
168
|
+
end: {
|
|
169
|
+
line: result.location.endPosition.row,
|
|
170
|
+
character: result.location.endPosition.column
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
case "field_name": {
|
|
177
|
+
const result = findFieldDefinition(workspace, context.fieldName, context.entityContext);
|
|
178
|
+
if (result) return {
|
|
179
|
+
uri: pathToUri$1(result.file),
|
|
180
|
+
range: {
|
|
181
|
+
start: {
|
|
182
|
+
line: result.location.startPosition.row,
|
|
183
|
+
character: result.location.startPosition.column
|
|
184
|
+
},
|
|
185
|
+
end: {
|
|
186
|
+
line: result.location.endPosition.row,
|
|
187
|
+
character: result.location.endPosition.column
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
case "section_name": {
|
|
194
|
+
const result = findSectionDefinition(workspace, context.sectionName, context.entityContext);
|
|
195
|
+
if (result) return {
|
|
196
|
+
uri: pathToUri$1(result.file),
|
|
197
|
+
range: {
|
|
198
|
+
start: {
|
|
199
|
+
line: result.location.startPosition.row,
|
|
200
|
+
character: result.location.startPosition.column
|
|
201
|
+
},
|
|
202
|
+
end: {
|
|
203
|
+
line: result.location.endPosition.row,
|
|
204
|
+
character: result.location.endPosition.column
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
default: return null;
|
|
211
|
+
}
|
|
212
|
+
} catch (error) {
|
|
213
|
+
console.error(`[thalo-lsp] Error in definition handler:`, error);
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
//#endregion
|
|
219
|
+
//#region src/handlers/references.ts
|
|
220
|
+
/**
|
|
221
|
+
* Convert a file path to a URI
|
|
222
|
+
*/
|
|
223
|
+
function pathToUri(path$1) {
|
|
224
|
+
if (!path$1.startsWith("file://")) return `file://${encodeURIComponent(path$1).replace(/%2F/g, "/")}`;
|
|
225
|
+
return path$1;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Get the file type from a URI
|
|
229
|
+
*/
|
|
230
|
+
function getFileType$3(uri) {
|
|
231
|
+
if (uri.endsWith(".thalo")) return "thalo";
|
|
232
|
+
if (uri.endsWith(".md")) return "markdown";
|
|
233
|
+
return "thalo";
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Convert LSP Position to thalo Position (both are 0-based).
|
|
237
|
+
*/
|
|
238
|
+
function lspToThaloPosition$1(pos) {
|
|
239
|
+
return {
|
|
240
|
+
line: pos.line,
|
|
241
|
+
column: pos.character
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Handle textDocument/references request
|
|
246
|
+
*
|
|
247
|
+
* Finds all references to various syntax elements at the given position:
|
|
248
|
+
* - ^link-id → All usages of that link
|
|
249
|
+
* - #tag → All entries with that tag
|
|
250
|
+
* - Entity name → All entries using that entity type
|
|
251
|
+
* - Metadata key → All entries using that field
|
|
252
|
+
* - Section header → All entries with that section
|
|
253
|
+
*
|
|
254
|
+
* Supports both standalone .thalo files and thalo blocks embedded in markdown.
|
|
255
|
+
*
|
|
256
|
+
* @param workspace - The thalo workspace
|
|
257
|
+
* @param document - The text document
|
|
258
|
+
* @param position - The position in the document (line/character)
|
|
259
|
+
* @param context - Reference context (includeDeclaration)
|
|
260
|
+
* @returns Array of reference locations, or null if not found
|
|
261
|
+
*/
|
|
262
|
+
function handleReferences(workspace, document, position, context) {
|
|
263
|
+
const fileType = getFileType$3(document.uri);
|
|
264
|
+
try {
|
|
265
|
+
const nodeContext = findNodeAtPosition(parseDocument(document.getText(), {
|
|
266
|
+
fileType,
|
|
267
|
+
filename: document.uri
|
|
268
|
+
}), lspToThaloPosition$1(position));
|
|
269
|
+
switch (nodeContext.kind) {
|
|
270
|
+
case "link": {
|
|
271
|
+
const result = findReferences(workspace, nodeContext.linkId, context.includeDeclaration);
|
|
272
|
+
if (result) return result.locations.map((loc) => ({
|
|
273
|
+
uri: pathToUri(loc.file),
|
|
274
|
+
range: {
|
|
275
|
+
start: {
|
|
276
|
+
line: loc.location.startPosition.row,
|
|
277
|
+
character: loc.location.startPosition.column
|
|
278
|
+
},
|
|
279
|
+
end: {
|
|
280
|
+
line: loc.location.endPosition.row,
|
|
281
|
+
character: loc.location.endPosition.column
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}));
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
case "tag": return findTagReferences(workspace, nodeContext.tagName).references.map((ref) => ({
|
|
288
|
+
uri: pathToUri(ref.file),
|
|
289
|
+
range: {
|
|
290
|
+
start: {
|
|
291
|
+
line: ref.location.startPosition.row,
|
|
292
|
+
character: ref.location.startPosition.column
|
|
293
|
+
},
|
|
294
|
+
end: {
|
|
295
|
+
line: ref.location.endPosition.row,
|
|
296
|
+
character: ref.location.endPosition.column
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}));
|
|
300
|
+
case "entity":
|
|
301
|
+
case "schema_entity": return findEntityReferences(workspace, nodeContext.kind === "entity" ? nodeContext.entityName : nodeContext.entityName, context.includeDeclaration).locations.map((loc) => ({
|
|
302
|
+
uri: pathToUri(loc.file),
|
|
303
|
+
range: {
|
|
304
|
+
start: {
|
|
305
|
+
line: loc.location.startPosition.row,
|
|
306
|
+
character: loc.location.startPosition.column
|
|
307
|
+
},
|
|
308
|
+
end: {
|
|
309
|
+
line: loc.location.endPosition.row,
|
|
310
|
+
character: loc.location.endPosition.column
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}));
|
|
314
|
+
case "metadata_key": {
|
|
315
|
+
const result = findFieldReferences(workspace, nodeContext.key, nodeContext.entityContext);
|
|
316
|
+
const locations = [];
|
|
317
|
+
if (context.includeDeclaration && result.definition) locations.push({
|
|
318
|
+
uri: pathToUri(result.definition.file),
|
|
319
|
+
range: {
|
|
320
|
+
start: {
|
|
321
|
+
line: result.definition.location.startPosition.row,
|
|
322
|
+
character: result.definition.location.startPosition.column
|
|
323
|
+
},
|
|
324
|
+
end: {
|
|
325
|
+
line: result.definition.location.endPosition.row,
|
|
326
|
+
character: result.definition.location.endPosition.column
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
for (const ref of result.references) locations.push({
|
|
331
|
+
uri: pathToUri(ref.file),
|
|
332
|
+
range: {
|
|
333
|
+
start: {
|
|
334
|
+
line: ref.location.startPosition.row,
|
|
335
|
+
character: ref.location.startPosition.column
|
|
336
|
+
},
|
|
337
|
+
end: {
|
|
338
|
+
line: ref.location.endPosition.row,
|
|
339
|
+
character: ref.location.endPosition.column
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
return locations;
|
|
344
|
+
}
|
|
345
|
+
case "section_header": {
|
|
346
|
+
const result = findSectionReferences(workspace, nodeContext.sectionName, nodeContext.entityContext);
|
|
347
|
+
const locations = [];
|
|
348
|
+
if (context.includeDeclaration && result.definition) locations.push({
|
|
349
|
+
uri: pathToUri(result.definition.file),
|
|
350
|
+
range: {
|
|
351
|
+
start: {
|
|
352
|
+
line: result.definition.location.startPosition.row,
|
|
353
|
+
character: result.definition.location.startPosition.column
|
|
354
|
+
},
|
|
355
|
+
end: {
|
|
356
|
+
line: result.definition.location.endPosition.row,
|
|
357
|
+
character: result.definition.location.endPosition.column
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
for (const ref of result.references) locations.push({
|
|
362
|
+
uri: pathToUri(ref.file),
|
|
363
|
+
range: {
|
|
364
|
+
start: {
|
|
365
|
+
line: ref.location.startPosition.row,
|
|
366
|
+
character: ref.location.startPosition.column
|
|
367
|
+
},
|
|
368
|
+
end: {
|
|
369
|
+
line: ref.location.endPosition.row,
|
|
370
|
+
character: ref.location.endPosition.column
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
return locations;
|
|
375
|
+
}
|
|
376
|
+
case "field_name": {
|
|
377
|
+
const result = findFieldReferences(workspace, nodeContext.fieldName, nodeContext.entityContext);
|
|
378
|
+
const locations = [];
|
|
379
|
+
if (context.includeDeclaration && result.definition) locations.push({
|
|
380
|
+
uri: pathToUri(result.definition.file),
|
|
381
|
+
range: {
|
|
382
|
+
start: {
|
|
383
|
+
line: result.definition.location.startPosition.row,
|
|
384
|
+
character: result.definition.location.startPosition.column
|
|
385
|
+
},
|
|
386
|
+
end: {
|
|
387
|
+
line: result.definition.location.endPosition.row,
|
|
388
|
+
character: result.definition.location.endPosition.column
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
for (const ref of result.references) locations.push({
|
|
393
|
+
uri: pathToUri(ref.file),
|
|
394
|
+
range: {
|
|
395
|
+
start: {
|
|
396
|
+
line: ref.location.startPosition.row,
|
|
397
|
+
character: ref.location.startPosition.column
|
|
398
|
+
},
|
|
399
|
+
end: {
|
|
400
|
+
line: ref.location.endPosition.row,
|
|
401
|
+
character: ref.location.endPosition.column
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
return locations;
|
|
406
|
+
}
|
|
407
|
+
case "section_name": {
|
|
408
|
+
const result = findSectionReferences(workspace, nodeContext.sectionName, nodeContext.entityContext);
|
|
409
|
+
const locations = [];
|
|
410
|
+
if (context.includeDeclaration && result.definition) locations.push({
|
|
411
|
+
uri: pathToUri(result.definition.file),
|
|
412
|
+
range: {
|
|
413
|
+
start: {
|
|
414
|
+
line: result.definition.location.startPosition.row,
|
|
415
|
+
character: result.definition.location.startPosition.column
|
|
416
|
+
},
|
|
417
|
+
end: {
|
|
418
|
+
line: result.definition.location.endPosition.row,
|
|
419
|
+
character: result.definition.location.endPosition.column
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
for (const ref of result.references) locations.push({
|
|
424
|
+
uri: pathToUri(ref.file),
|
|
425
|
+
range: {
|
|
426
|
+
start: {
|
|
427
|
+
line: ref.location.startPosition.row,
|
|
428
|
+
character: ref.location.startPosition.column
|
|
429
|
+
},
|
|
430
|
+
end: {
|
|
431
|
+
line: ref.location.endPosition.row,
|
|
432
|
+
character: ref.location.endPosition.column
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
return locations;
|
|
437
|
+
}
|
|
438
|
+
default: return null;
|
|
439
|
+
}
|
|
440
|
+
} catch (error) {
|
|
441
|
+
console.error(`[thalo-lsp] Error in references handler:`, error);
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
//#endregion
|
|
447
|
+
//#region src/handlers/semantic-tokens.ts
|
|
448
|
+
/**
|
|
449
|
+
* Get the file type from a URI
|
|
450
|
+
*/
|
|
451
|
+
function getFileType$2(uri) {
|
|
452
|
+
if (uri.endsWith(".thalo")) return "thalo";
|
|
453
|
+
if (uri.endsWith(".md")) return "markdown";
|
|
454
|
+
return "thalo";
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Handle textDocument/semanticTokens/full request
|
|
458
|
+
*
|
|
459
|
+
* Returns semantic tokens for syntax highlighting.
|
|
460
|
+
*
|
|
461
|
+
* @param document - The text document
|
|
462
|
+
* @returns Semantic tokens in LSP format
|
|
463
|
+
*/
|
|
464
|
+
function handleSemanticTokens(document) {
|
|
465
|
+
const fileType = getFileType$2(document.uri);
|
|
466
|
+
try {
|
|
467
|
+
return { data: encodeSemanticTokens(extractSemanticTokens(parseDocument(document.getText(), {
|
|
468
|
+
fileType,
|
|
469
|
+
filename: document.uri
|
|
470
|
+
}))) };
|
|
471
|
+
} catch (error) {
|
|
472
|
+
console.error(`[thalo-lsp] Error extracting semantic tokens:`, error);
|
|
473
|
+
return { data: [] };
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
//#endregion
|
|
478
|
+
//#region src/handlers/diagnostics.ts
|
|
479
|
+
/**
|
|
480
|
+
* Map thalo severity to LSP DiagnosticSeverity
|
|
481
|
+
*/
|
|
482
|
+
function mapSeverity(severity) {
|
|
483
|
+
switch (severity) {
|
|
484
|
+
case "error": return 1;
|
|
485
|
+
case "warning": return 2;
|
|
486
|
+
case "info": return 3;
|
|
487
|
+
default: return 3;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Convert a URI to a file path
|
|
492
|
+
*/
|
|
493
|
+
function uriToPath$1(uri) {
|
|
494
|
+
if (uri.startsWith("file://")) return decodeURIComponent(uri.slice(7));
|
|
495
|
+
return uri;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Convert a thalo Diagnostic to an LSP Diagnostic
|
|
499
|
+
*/
|
|
500
|
+
function convertDiagnostic(diagnostic) {
|
|
501
|
+
return {
|
|
502
|
+
range: {
|
|
503
|
+
start: {
|
|
504
|
+
line: diagnostic.location.startPosition.row,
|
|
505
|
+
character: diagnostic.location.startPosition.column
|
|
506
|
+
},
|
|
507
|
+
end: {
|
|
508
|
+
line: diagnostic.location.endPosition.row,
|
|
509
|
+
character: diagnostic.location.endPosition.column
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
severity: mapSeverity(diagnostic.severity),
|
|
513
|
+
code: diagnostic.code,
|
|
514
|
+
source: "thalo",
|
|
515
|
+
message: diagnostic.message
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Get diagnostics for a document
|
|
520
|
+
*
|
|
521
|
+
* @param workspace - The thalo workspace
|
|
522
|
+
* @param textDocument - The LSP text document
|
|
523
|
+
* @returns Array of LSP diagnostics
|
|
524
|
+
*/
|
|
525
|
+
function getDiagnostics(workspace, textDocument) {
|
|
526
|
+
const path$1 = uriToPath$1(textDocument.uri);
|
|
527
|
+
if (!workspace.getModel(path$1)) return [];
|
|
528
|
+
try {
|
|
529
|
+
return checkDocument(path$1, workspace).map(convertDiagnostic);
|
|
530
|
+
} catch (error) {
|
|
531
|
+
console.error(`[thalo-lsp] Error checking ${path$1}:`, error);
|
|
532
|
+
return [{
|
|
533
|
+
range: {
|
|
534
|
+
start: {
|
|
535
|
+
line: 0,
|
|
536
|
+
character: 0
|
|
537
|
+
},
|
|
538
|
+
end: {
|
|
539
|
+
line: 0,
|
|
540
|
+
character: 0
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
severity: 1,
|
|
544
|
+
source: "thalo",
|
|
545
|
+
message: `Parse error: ${error instanceof Error ? error.message : String(error)}`
|
|
546
|
+
}];
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
//#endregion
|
|
551
|
+
//#region src/handlers/hover.ts
|
|
552
|
+
/**
|
|
553
|
+
* Get the file type from a URI
|
|
554
|
+
*/
|
|
555
|
+
function getFileType$1(uri) {
|
|
556
|
+
if (uri.endsWith(".thalo")) return "thalo";
|
|
557
|
+
if (uri.endsWith(".md")) return "markdown";
|
|
558
|
+
return "thalo";
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Convert LSP Position to thalo Position (both are 0-based).
|
|
562
|
+
*/
|
|
563
|
+
function lspToThaloPosition(pos) {
|
|
564
|
+
return {
|
|
565
|
+
line: pos.line,
|
|
566
|
+
column: pos.character
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Handle textDocument/hover request
|
|
571
|
+
*
|
|
572
|
+
* Provides hover information for various syntax elements:
|
|
573
|
+
* - ^link-id: Shows target entry details
|
|
574
|
+
* - #tag: Shows tag usage statistics
|
|
575
|
+
* - Directives: Shows documentation
|
|
576
|
+
* - Entity names: Shows schema with fields and sections
|
|
577
|
+
* - Metadata keys: Shows field type and description
|
|
578
|
+
* - Type expressions: Shows type documentation
|
|
579
|
+
* - Section headers: Shows section description
|
|
580
|
+
* - Timestamps: Shows entry info or link reference hint
|
|
581
|
+
*
|
|
582
|
+
* Supports both standalone .thalo files and thalo blocks embedded in markdown.
|
|
583
|
+
*
|
|
584
|
+
* @param workspace - The thalo workspace
|
|
585
|
+
* @param document - The text document
|
|
586
|
+
* @param position - The hover position
|
|
587
|
+
* @returns Hover information, or null if nothing to show
|
|
588
|
+
*/
|
|
589
|
+
function handleHover(workspace, document, position) {
|
|
590
|
+
const fileType = getFileType$1(document.uri);
|
|
591
|
+
try {
|
|
592
|
+
const result = getHoverInfo(workspace, findNodeAtPosition(parseDocument(document.getText(), {
|
|
593
|
+
fileType,
|
|
594
|
+
filename: document.uri
|
|
595
|
+
}), lspToThaloPosition(position)));
|
|
596
|
+
if (!result) return null;
|
|
597
|
+
const hover = { contents: {
|
|
598
|
+
kind: "markdown",
|
|
599
|
+
value: result.content
|
|
600
|
+
} };
|
|
601
|
+
if (result.range) hover.range = {
|
|
602
|
+
start: {
|
|
603
|
+
line: result.range.startPosition.row,
|
|
604
|
+
character: result.range.startPosition.column
|
|
605
|
+
},
|
|
606
|
+
end: {
|
|
607
|
+
line: result.range.endPosition.row,
|
|
608
|
+
character: result.range.endPosition.column
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
return hover;
|
|
612
|
+
} catch (error) {
|
|
613
|
+
console.error(`[thalo-lsp] Error in hover handler:`, error);
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
//#endregion
|
|
619
|
+
//#region src/handlers/completions/context.ts
|
|
620
|
+
/** Timestamp pattern: YYYY-MM-DDTHH:MMZ or YYYY-MM-DDTHH:MM±HH:MM */
|
|
621
|
+
const TIMESTAMP_PREFIX_PATTERN = /^[12]\d{3}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d(Z|[+-][0-2]\d:[0-5]\d)\s+/;
|
|
622
|
+
/**
|
|
623
|
+
* Get the text before the cursor on the current line.
|
|
624
|
+
*/
|
|
625
|
+
function getTextBeforeCursor(document, position) {
|
|
626
|
+
return document.getText({
|
|
627
|
+
start: {
|
|
628
|
+
line: position.line,
|
|
629
|
+
character: 0
|
|
630
|
+
},
|
|
631
|
+
end: position
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Get the full text of a line.
|
|
636
|
+
*/
|
|
637
|
+
function getLineText(document, lineNumber) {
|
|
638
|
+
if (lineNumber >= document.lineCount) return "";
|
|
639
|
+
const start = {
|
|
640
|
+
line: lineNumber,
|
|
641
|
+
character: 0
|
|
642
|
+
};
|
|
643
|
+
const end = {
|
|
644
|
+
line: lineNumber + 1,
|
|
645
|
+
character: 0
|
|
646
|
+
};
|
|
647
|
+
return document.getText({
|
|
648
|
+
start,
|
|
649
|
+
end
|
|
650
|
+
}).replace(/\r?\n$/, "");
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Find the entry header line by scanning backwards from the cursor.
|
|
654
|
+
* Returns the line number and text of the header, or undefined if not found.
|
|
655
|
+
*/
|
|
656
|
+
function findEntryHeader(document, fromLine) {
|
|
657
|
+
for (let i = fromLine; i >= 0; i--) {
|
|
658
|
+
const lineText = getLineText(document, i);
|
|
659
|
+
if (TIMESTAMP_PREFIX_PATTERN.test(lineText)) return {
|
|
660
|
+
line: i,
|
|
661
|
+
text: lineText
|
|
662
|
+
};
|
|
663
|
+
if (lineText.trim() && !lineText.startsWith(" ") && !lineText.startsWith(" ")) break;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Parse entry info from a header line.
|
|
668
|
+
*/
|
|
669
|
+
function parseEntryInfo(headerText) {
|
|
670
|
+
const parts = headerText.split(/\s+/);
|
|
671
|
+
const info = {};
|
|
672
|
+
if (parts.length >= 2) {
|
|
673
|
+
const directive = parts[1];
|
|
674
|
+
info.directive = directive;
|
|
675
|
+
info.isSchemaEntry = isSchemaDirective(directive);
|
|
676
|
+
info.isSynthesisEntry = isSynthesisDirective(directive);
|
|
677
|
+
}
|
|
678
|
+
if (parts.length >= 3) if (info.isSynthesisEntry) info.entity = "synthesis";
|
|
679
|
+
else info.entity = parts[2];
|
|
680
|
+
return info;
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Extract existing metadata keys from lines between header and cursor.
|
|
684
|
+
*/
|
|
685
|
+
function extractExistingMetadataKeys(document, headerLine, currentLine) {
|
|
686
|
+
const keys = [];
|
|
687
|
+
for (let i = headerLine + 1; i < currentLine; i++) {
|
|
688
|
+
const match = getLineText(document, i).match(/^[\t ]+([a-z][a-zA-Z0-9\-_]*):/);
|
|
689
|
+
if (match) keys.push(match[1]);
|
|
690
|
+
}
|
|
691
|
+
return keys;
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Check if a line is in the content area (after a blank line following metadata).
|
|
695
|
+
*/
|
|
696
|
+
function isInContentArea(document, headerLine, currentLine) {
|
|
697
|
+
let foundBlankAfterMetadata = false;
|
|
698
|
+
let foundMetadata = false;
|
|
699
|
+
for (let i = headerLine + 1; i < currentLine; i++) {
|
|
700
|
+
const line = getLineText(document, i);
|
|
701
|
+
if (!line.trim()) {
|
|
702
|
+
if (foundMetadata) foundBlankAfterMetadata = true;
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
if (/^[\t ]+[a-z][a-zA-Z0-9\-_]*:/.test(line)) {
|
|
706
|
+
foundMetadata = true;
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
if (foundBlankAfterMetadata && /^[\t ]+/.test(line)) return true;
|
|
710
|
+
}
|
|
711
|
+
return foundBlankAfterMetadata;
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Check if we're inside a schema block definition (after # Metadata, # Sections, etc.)
|
|
715
|
+
*/
|
|
716
|
+
function isInSchemaBlock(document, headerLine, currentLine) {
|
|
717
|
+
for (let i = currentLine - 1; i > headerLine; i--) {
|
|
718
|
+
const trimmed = getLineText(document, i).trim();
|
|
719
|
+
if (trimmed === "# Metadata" || trimmed === "# Sections" || trimmed === "# Remove Metadata" || trimmed === "# Remove Sections") return true;
|
|
720
|
+
}
|
|
721
|
+
return false;
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Detect the completion context at a given position.
|
|
725
|
+
*/
|
|
726
|
+
function detectContext(document, position) {
|
|
727
|
+
const textBefore = getTextBeforeCursor(document, position);
|
|
728
|
+
const ctx = {
|
|
729
|
+
kind: "unknown",
|
|
730
|
+
textBefore,
|
|
731
|
+
lineText: getLineText(document, position.line),
|
|
732
|
+
lineNumber: position.line,
|
|
733
|
+
entry: {},
|
|
734
|
+
partial: ""
|
|
735
|
+
};
|
|
736
|
+
if (textBefore.endsWith("^") || /\^[\w\-:]*$/.test(textBefore)) {
|
|
737
|
+
ctx.kind = "link";
|
|
738
|
+
const match = textBefore.match(/\^([\w\-:]*)$/);
|
|
739
|
+
ctx.partial = match ? match[1] : "";
|
|
740
|
+
const header = findEntryHeader(document, position.line);
|
|
741
|
+
if (header) ctx.entry = parseEntryInfo(header.text);
|
|
742
|
+
return ctx;
|
|
743
|
+
}
|
|
744
|
+
if (!textBefore.trimStart().startsWith("#") && (textBefore.endsWith("#") || /#[\w\-./]*$/.test(textBefore))) {
|
|
745
|
+
ctx.kind = "tag";
|
|
746
|
+
const match = textBefore.match(/#([\w\-./]*)$/);
|
|
747
|
+
ctx.partial = match ? match[1] : "";
|
|
748
|
+
const header = findEntryHeader(document, position.line);
|
|
749
|
+
if (header) ctx.entry = parseEntryInfo(header.text);
|
|
750
|
+
return ctx;
|
|
751
|
+
}
|
|
752
|
+
if (textBefore === "" && position.character === 0) {
|
|
753
|
+
ctx.kind = "line_start";
|
|
754
|
+
return ctx;
|
|
755
|
+
}
|
|
756
|
+
const timestampMatch = textBefore.match(/^([12]\d{3}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d(Z|[+-][0-2]\d:[0-5]\d))\s*/);
|
|
757
|
+
if (timestampMatch) {
|
|
758
|
+
const afterTimestamp = textBefore.slice(timestampMatch[0].length);
|
|
759
|
+
if (afterTimestamp === "" || afterTimestamp.match(/^\s*$/)) {
|
|
760
|
+
ctx.kind = "after_timestamp";
|
|
761
|
+
ctx.partial = afterTimestamp.trim();
|
|
762
|
+
return ctx;
|
|
763
|
+
}
|
|
764
|
+
const parts = afterTimestamp.trim().split(/\s+/);
|
|
765
|
+
const directive = parts[0];
|
|
766
|
+
ctx.entry.directive = directive;
|
|
767
|
+
ctx.entry.isSchemaEntry = isSchemaDirective(directive);
|
|
768
|
+
ctx.entry.isSynthesisEntry = isSynthesisDirective(directive);
|
|
769
|
+
if (parts.length === 1 && !afterTimestamp.endsWith(" ")) {
|
|
770
|
+
ctx.kind = "after_timestamp";
|
|
771
|
+
ctx.partial = directive;
|
|
772
|
+
return ctx;
|
|
773
|
+
}
|
|
774
|
+
if (parts.length === 1 && afterTimestamp.endsWith(" ")) {
|
|
775
|
+
ctx.kind = "after_directive";
|
|
776
|
+
ctx.partial = "";
|
|
777
|
+
return ctx;
|
|
778
|
+
}
|
|
779
|
+
if (parts.length >= 2) ctx.entry.entity = parts[1];
|
|
780
|
+
if (parts.length === 2 && !afterTimestamp.endsWith(" ") && !afterTimestamp.includes("\"")) {
|
|
781
|
+
ctx.kind = "after_directive";
|
|
782
|
+
ctx.partial = parts[1];
|
|
783
|
+
return ctx;
|
|
784
|
+
}
|
|
785
|
+
const titleMatch = afterTimestamp.match(/"[^"]*"/);
|
|
786
|
+
if (titleMatch) {
|
|
787
|
+
ctx.kind = "header_suffix";
|
|
788
|
+
ctx.partial = afterTimestamp.slice(afterTimestamp.indexOf(titleMatch[0]) + titleMatch[0].length).trim();
|
|
789
|
+
return ctx;
|
|
790
|
+
}
|
|
791
|
+
if (parts.length >= 2) {
|
|
792
|
+
ctx.kind = "after_entity";
|
|
793
|
+
return ctx;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
const indentMatch = textBefore.match(/^([\t ]+)/);
|
|
797
|
+
if (indentMatch && indentMatch[1].length >= 2) {
|
|
798
|
+
const header = findEntryHeader(document, position.line);
|
|
799
|
+
if (header) {
|
|
800
|
+
ctx.entry = parseEntryInfo(header.text);
|
|
801
|
+
ctx.entry.existingMetadataKeys = extractExistingMetadataKeys(document, header.line, position.line);
|
|
802
|
+
}
|
|
803
|
+
const afterIndent = textBefore.slice(indentMatch[0].length);
|
|
804
|
+
if (ctx.entry.isSchemaEntry) {
|
|
805
|
+
if (afterIndent.startsWith("#") && !afterIndent.includes(":")) {
|
|
806
|
+
ctx.kind = "schema_block_header";
|
|
807
|
+
ctx.partial = afterIndent;
|
|
808
|
+
return ctx;
|
|
809
|
+
}
|
|
810
|
+
if (header && isInSchemaBlock(document, header.line, position.line)) {
|
|
811
|
+
const colonMatch = afterIndent.match(/^[a-z][a-zA-Z0-9\-_]*\??:\s*/);
|
|
812
|
+
if (colonMatch) {
|
|
813
|
+
ctx.kind = "field_type";
|
|
814
|
+
ctx.partial = afterIndent.slice(colonMatch[0].length);
|
|
815
|
+
return ctx;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
if (header && isInContentArea(document, header.line, position.line)) {
|
|
820
|
+
if (afterIndent.startsWith("#") && afterIndent.length <= 2) {
|
|
821
|
+
ctx.kind = "section_header";
|
|
822
|
+
ctx.partial = afterIndent.replace(/^#\s*/, "");
|
|
823
|
+
return ctx;
|
|
824
|
+
}
|
|
825
|
+
ctx.kind = "unknown";
|
|
826
|
+
return ctx;
|
|
827
|
+
}
|
|
828
|
+
const colonIndex = afterIndent.indexOf(":");
|
|
829
|
+
if (colonIndex !== -1) {
|
|
830
|
+
ctx.kind = "metadata_value";
|
|
831
|
+
ctx.metadataKey = afterIndent.slice(0, colonIndex).trim();
|
|
832
|
+
ctx.partial = afterIndent.slice(colonIndex + 1).trim();
|
|
833
|
+
return ctx;
|
|
834
|
+
}
|
|
835
|
+
ctx.kind = "metadata_key";
|
|
836
|
+
ctx.partial = afterIndent.trim();
|
|
837
|
+
return ctx;
|
|
838
|
+
}
|
|
839
|
+
return ctx;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
//#endregion
|
|
843
|
+
//#region src/handlers/completions/providers/timestamp.ts
|
|
844
|
+
/**
|
|
845
|
+
* Format a date as a thalo timestamp (YYYY-MM-DDTHH:MMZ) in UTC.
|
|
846
|
+
*/
|
|
847
|
+
function formatTimestamp$1(date = /* @__PURE__ */ new Date()) {
|
|
848
|
+
return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}T${String(date.getUTCHours()).padStart(2, "0")}:${String(date.getUTCMinutes()).padStart(2, "0")}Z`;
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Provider for timestamp completion at the start of a new entry.
|
|
852
|
+
*/
|
|
853
|
+
const timestampProvider = {
|
|
854
|
+
name: "timestamp",
|
|
855
|
+
contextKinds: ["line_start"],
|
|
856
|
+
getCompletions(_ctx, _workspace) {
|
|
857
|
+
const timestamp = formatTimestamp$1();
|
|
858
|
+
return [{
|
|
859
|
+
label: timestamp,
|
|
860
|
+
kind: 12,
|
|
861
|
+
detail: "Current timestamp",
|
|
862
|
+
documentation: {
|
|
863
|
+
kind: "markdown",
|
|
864
|
+
value: "Insert the current timestamp to start a new entry."
|
|
865
|
+
},
|
|
866
|
+
insertText: `${timestamp} `,
|
|
867
|
+
sortText: "0"
|
|
868
|
+
}];
|
|
869
|
+
}
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
//#endregion
|
|
873
|
+
//#region src/handlers/completions/providers/directive.ts
|
|
874
|
+
/**
|
|
875
|
+
* Get description for a directive.
|
|
876
|
+
*/
|
|
877
|
+
function getDirectiveDescription(directive) {
|
|
878
|
+
switch (directive) {
|
|
879
|
+
case "create": return "Create a new instance entry (lore, opinion, reference, journal)";
|
|
880
|
+
case "update": return "Update an existing entry (reference a previous entry with supersedes:)";
|
|
881
|
+
case "define-entity": return "Define a new entity schema with fields and sections";
|
|
882
|
+
case "alter-entity": return "Modify an existing entity schema (add/remove fields or sections)";
|
|
883
|
+
case "define-synthesis": return "Define a synthesis operation that queries entries and generates content via LLM";
|
|
884
|
+
case "actualize-synthesis": return "Trigger a synthesis to regenerate its output based on current data";
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Get detail text for a directive.
|
|
889
|
+
*/
|
|
890
|
+
function getDirectiveDetail(directive) {
|
|
891
|
+
if (INSTANCE_DIRECTIVES.includes(directive)) return "Instance directive";
|
|
892
|
+
if (SCHEMA_DIRECTIVES.includes(directive)) return "Schema directive";
|
|
893
|
+
return "Synthesis directive";
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Provider for directive completion after timestamp.
|
|
897
|
+
*/
|
|
898
|
+
const directiveProvider = {
|
|
899
|
+
name: "directive",
|
|
900
|
+
contextKinds: ["after_timestamp"],
|
|
901
|
+
getCompletions(ctx, _workspace) {
|
|
902
|
+
const partial = ctx.partial.toLowerCase();
|
|
903
|
+
return ALL_DIRECTIVES.filter((d) => !partial || d.toLowerCase().startsWith(partial)).map((directive, index) => ({
|
|
904
|
+
label: directive,
|
|
905
|
+
kind: 14,
|
|
906
|
+
detail: getDirectiveDetail(directive),
|
|
907
|
+
documentation: {
|
|
908
|
+
kind: "markdown",
|
|
909
|
+
value: getDirectiveDescription(directive)
|
|
910
|
+
},
|
|
911
|
+
insertText: `${directive} `,
|
|
912
|
+
sortText: String(index),
|
|
913
|
+
filterText: directive
|
|
914
|
+
}));
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
//#endregion
|
|
919
|
+
//#region src/handlers/completions/providers/entity.ts
|
|
920
|
+
/**
|
|
921
|
+
* Provider for entity type completion after directive.
|
|
922
|
+
*
|
|
923
|
+
* Entity types are defined via `define-entity` and stored in the SchemaRegistry.
|
|
924
|
+
*/
|
|
925
|
+
const entityProvider = {
|
|
926
|
+
name: "entity",
|
|
927
|
+
contextKinds: ["after_directive"],
|
|
928
|
+
getCompletions(ctx, workspace) {
|
|
929
|
+
const partial = ctx.partial.toLowerCase();
|
|
930
|
+
const directive = ctx.entry.directive;
|
|
931
|
+
const items = [];
|
|
932
|
+
if (directive && isInstanceDirective(directive)) for (const entityName of workspace.schemaRegistry.entityNames()) {
|
|
933
|
+
if (partial && !entityName.toLowerCase().startsWith(partial)) continue;
|
|
934
|
+
const schema = workspace.schemaRegistry.get(entityName);
|
|
935
|
+
items.push({
|
|
936
|
+
label: entityName,
|
|
937
|
+
kind: 7,
|
|
938
|
+
detail: "Entity type",
|
|
939
|
+
documentation: schema ? {
|
|
940
|
+
kind: "markdown",
|
|
941
|
+
value: `**${schema.description}**\n\nDefined at ${schema.definedAt}`
|
|
942
|
+
} : void 0,
|
|
943
|
+
insertText: `${entityName} `,
|
|
944
|
+
filterText: entityName
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
if (directive === "alter-entity") for (const entityName of workspace.schemaRegistry.entityNames()) {
|
|
948
|
+
if (partial && !entityName.toLowerCase().startsWith(partial)) continue;
|
|
949
|
+
const schema = workspace.schemaRegistry.get(entityName);
|
|
950
|
+
items.push({
|
|
951
|
+
label: entityName,
|
|
952
|
+
kind: 7,
|
|
953
|
+
detail: "Existing entity",
|
|
954
|
+
documentation: schema ? {
|
|
955
|
+
kind: "markdown",
|
|
956
|
+
value: `**${schema.description}**\n\nDefined at ${schema.definedAt}`
|
|
957
|
+
} : void 0,
|
|
958
|
+
insertText: `${entityName} `,
|
|
959
|
+
filterText: entityName
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
return items;
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
//#endregion
|
|
967
|
+
//#region src/handlers/completions/providers/metadata-key.ts
|
|
968
|
+
/**
|
|
969
|
+
* Provider for metadata key completion in instance entries.
|
|
970
|
+
*/
|
|
971
|
+
const metadataKeyProvider = {
|
|
972
|
+
name: "metadata-key",
|
|
973
|
+
contextKinds: ["metadata_key"],
|
|
974
|
+
getCompletions(ctx, workspace) {
|
|
975
|
+
const partial = ctx.partial.toLowerCase();
|
|
976
|
+
const entity = ctx.entry.entity;
|
|
977
|
+
const existingKeys = ctx.entry.existingMetadataKeys ?? [];
|
|
978
|
+
const items = [];
|
|
979
|
+
if (ctx.entry.isSchemaEntry) return items;
|
|
980
|
+
if (!entity) return items;
|
|
981
|
+
const schema = workspace.schemaRegistry.get(entity);
|
|
982
|
+
if (!schema) return items;
|
|
983
|
+
for (const [fieldName, field] of schema.fields) {
|
|
984
|
+
if (existingKeys.includes(fieldName)) continue;
|
|
985
|
+
if (partial && !fieldName.toLowerCase().startsWith(partial)) continue;
|
|
986
|
+
const typeStr = TypeExpr.toString(field.type);
|
|
987
|
+
const optionalSuffix = field.optional ? " (optional)" : " (required)";
|
|
988
|
+
items.push({
|
|
989
|
+
label: fieldName,
|
|
990
|
+
kind: 5,
|
|
991
|
+
detail: `${typeStr}${optionalSuffix}`,
|
|
992
|
+
documentation: field.description ? {
|
|
993
|
+
kind: "markdown",
|
|
994
|
+
value: field.description
|
|
995
|
+
} : void 0,
|
|
996
|
+
insertText: `${fieldName}: `,
|
|
997
|
+
filterText: fieldName,
|
|
998
|
+
sortText: field.optional ? `1${fieldName}` : `0${fieldName}`
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
return items;
|
|
1002
|
+
}
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
//#endregion
|
|
1006
|
+
//#region src/handlers/completions/providers/metadata-value.ts
|
|
1007
|
+
/**
|
|
1008
|
+
* Extract literal values from a type expression.
|
|
1009
|
+
*/
|
|
1010
|
+
function extractLiteralValues(type) {
|
|
1011
|
+
switch (type.kind) {
|
|
1012
|
+
case "literal": return [type.value];
|
|
1013
|
+
case "union": return type.members.flatMap((m) => extractLiteralValues(m));
|
|
1014
|
+
default: return [];
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Check if a type accepts links.
|
|
1019
|
+
*/
|
|
1020
|
+
function acceptsLinks(type) {
|
|
1021
|
+
switch (type.kind) {
|
|
1022
|
+
case "primitive": return type.name === "link";
|
|
1023
|
+
case "array": return acceptsLinks(type.elementType);
|
|
1024
|
+
case "union": return type.members.some((m) => acceptsLinks(m));
|
|
1025
|
+
default: return false;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Provider for metadata value completion.
|
|
1030
|
+
*/
|
|
1031
|
+
const metadataValueProvider = {
|
|
1032
|
+
name: "metadata-value",
|
|
1033
|
+
contextKinds: ["metadata_value"],
|
|
1034
|
+
getCompletions(ctx, workspace) {
|
|
1035
|
+
const partial = ctx.partial.toLowerCase();
|
|
1036
|
+
const entity = ctx.entry.entity;
|
|
1037
|
+
const metadataKey = ctx.metadataKey;
|
|
1038
|
+
const items = [];
|
|
1039
|
+
if (ctx.entry.isSchemaEntry) return items;
|
|
1040
|
+
if (!entity || !metadataKey) return items;
|
|
1041
|
+
const schema = workspace.schemaRegistry.get(entity);
|
|
1042
|
+
if (!schema) return items;
|
|
1043
|
+
const field = schema.fields.get(metadataKey);
|
|
1044
|
+
if (!field) return items;
|
|
1045
|
+
const literals = extractLiteralValues(field.type);
|
|
1046
|
+
for (const value of literals) {
|
|
1047
|
+
if (partial && !value.toLowerCase().startsWith(partial)) continue;
|
|
1048
|
+
items.push({
|
|
1049
|
+
label: value,
|
|
1050
|
+
kind: 12,
|
|
1051
|
+
detail: `Valid value for ${metadataKey}`,
|
|
1052
|
+
insertText: value,
|
|
1053
|
+
filterText: value
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
if (metadataKey === "subject" && acceptsLinks(field.type)) {
|
|
1057
|
+
if (!partial || "self".startsWith(partial) || "^self".startsWith(partial)) items.push({
|
|
1058
|
+
label: "^self",
|
|
1059
|
+
kind: 18,
|
|
1060
|
+
detail: "Reference to yourself",
|
|
1061
|
+
documentation: {
|
|
1062
|
+
kind: "markdown",
|
|
1063
|
+
value: "Use `^self` for personal lore entries about yourself."
|
|
1064
|
+
},
|
|
1065
|
+
insertText: "^self",
|
|
1066
|
+
filterText: "self",
|
|
1067
|
+
sortText: "0"
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
return items;
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
|
|
1074
|
+
//#endregion
|
|
1075
|
+
//#region src/handlers/completions/providers/link.ts
|
|
1076
|
+
/**
|
|
1077
|
+
* Format a timestamp to string
|
|
1078
|
+
*/
|
|
1079
|
+
function formatTimestamp(ts) {
|
|
1080
|
+
return `${`${ts.date.year}-${String(ts.date.month).padStart(2, "0")}-${String(ts.date.day).padStart(2, "0")}`}T${`${String(ts.time.hour).padStart(2, "0")}:${String(ts.time.minute).padStart(2, "0")}`}${isSyntaxError(ts.timezone) ? "" : ts.timezone.value}`;
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Get sort text for an entry (prefer recent entries).
|
|
1084
|
+
*/
|
|
1085
|
+
function getSortText(entry) {
|
|
1086
|
+
let ts;
|
|
1087
|
+
switch (entry.type) {
|
|
1088
|
+
case "instance_entry":
|
|
1089
|
+
ts = entry.header.timestamp;
|
|
1090
|
+
break;
|
|
1091
|
+
case "schema_entry":
|
|
1092
|
+
ts = entry.header.timestamp;
|
|
1093
|
+
break;
|
|
1094
|
+
case "synthesis_entry":
|
|
1095
|
+
ts = entry.header.timestamp;
|
|
1096
|
+
break;
|
|
1097
|
+
case "actualize_entry":
|
|
1098
|
+
ts = entry.header.timestamp;
|
|
1099
|
+
break;
|
|
1100
|
+
}
|
|
1101
|
+
return formatTimestamp(ts).split("").map((c) => String.fromCharCode(126 - c.charCodeAt(0))).join("");
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* Get title from an entry
|
|
1105
|
+
*/
|
|
1106
|
+
function getEntryTitle(entry) {
|
|
1107
|
+
switch (entry.type) {
|
|
1108
|
+
case "instance_entry": return entry.header.title?.value ?? "(no title)";
|
|
1109
|
+
case "synthesis_entry": return entry.header.title?.value ?? "(no title)";
|
|
1110
|
+
case "actualize_entry": return `actualize-synthesis ^${entry.header.target.id}`;
|
|
1111
|
+
case "schema_entry": return entry.header.title?.value ?? "(no title)";
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Get entity description from an entry
|
|
1116
|
+
*/
|
|
1117
|
+
function getEntryEntity(entry) {
|
|
1118
|
+
switch (entry.type) {
|
|
1119
|
+
case "instance_entry": return entry.header.entity;
|
|
1120
|
+
case "synthesis_entry": return "synthesis";
|
|
1121
|
+
case "actualize_entry": return "actualize";
|
|
1122
|
+
case "schema_entry": return entry.header.entityName.value;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* Format a link completion item.
|
|
1127
|
+
*/
|
|
1128
|
+
function formatLinkCompletion(linkId, definition) {
|
|
1129
|
+
const entry = definition.entry;
|
|
1130
|
+
const title = getEntryTitle(entry);
|
|
1131
|
+
const entity = getEntryEntity(entry);
|
|
1132
|
+
let ts;
|
|
1133
|
+
switch (entry.type) {
|
|
1134
|
+
case "instance_entry":
|
|
1135
|
+
ts = entry.header.timestamp;
|
|
1136
|
+
break;
|
|
1137
|
+
case "schema_entry":
|
|
1138
|
+
ts = entry.header.timestamp;
|
|
1139
|
+
break;
|
|
1140
|
+
case "synthesis_entry":
|
|
1141
|
+
ts = entry.header.timestamp;
|
|
1142
|
+
break;
|
|
1143
|
+
case "actualize_entry":
|
|
1144
|
+
ts = entry.header.timestamp;
|
|
1145
|
+
break;
|
|
1146
|
+
}
|
|
1147
|
+
const timestamp = formatTimestamp(ts);
|
|
1148
|
+
return {
|
|
1149
|
+
label: `^${linkId}`,
|
|
1150
|
+
kind: 18,
|
|
1151
|
+
detail: title,
|
|
1152
|
+
documentation: {
|
|
1153
|
+
kind: "markdown",
|
|
1154
|
+
value: `**${title}**\n\n${timestamp} • ${entity}\n\n*${definition.file}*`
|
|
1155
|
+
},
|
|
1156
|
+
insertText: linkId,
|
|
1157
|
+
sortText: getSortText(entry),
|
|
1158
|
+
filterText: linkId
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Provider for link completion (^link-id).
|
|
1163
|
+
*/
|
|
1164
|
+
const linkProvider = {
|
|
1165
|
+
name: "link",
|
|
1166
|
+
contextKinds: ["link", "header_suffix"],
|
|
1167
|
+
getCompletions(ctx, workspace) {
|
|
1168
|
+
if (ctx.kind === "header_suffix" && !ctx.partial.includes("^")) return [];
|
|
1169
|
+
const partial = ctx.kind === "link" ? ctx.partial.toLowerCase() : ctx.partial.replace(/.*\^/, "").toLowerCase();
|
|
1170
|
+
const items = [];
|
|
1171
|
+
const linkIndex = workspace.linkIndex;
|
|
1172
|
+
for (const [linkId, definition] of linkIndex.definitions) {
|
|
1173
|
+
if (partial && !linkId.toLowerCase().includes(partial)) {
|
|
1174
|
+
const entry = definition.entry;
|
|
1175
|
+
if (!getEntryTitle(entry).toLowerCase().includes(partial)) continue;
|
|
1176
|
+
}
|
|
1177
|
+
items.push(formatLinkCompletion(linkId, definition));
|
|
1178
|
+
}
|
|
1179
|
+
return items;
|
|
1180
|
+
}
|
|
1181
|
+
};
|
|
1182
|
+
|
|
1183
|
+
//#endregion
|
|
1184
|
+
//#region src/handlers/completions/providers/tag.ts
|
|
1185
|
+
/**
|
|
1186
|
+
* Get tags from an entry
|
|
1187
|
+
*/
|
|
1188
|
+
function getEntryTags(entry) {
|
|
1189
|
+
switch (entry.type) {
|
|
1190
|
+
case "instance_entry": return entry.header.tags.map((t) => t.name);
|
|
1191
|
+
case "schema_entry": return entry.header.tags.map((t) => t.name);
|
|
1192
|
+
case "synthesis_entry": return entry.header.tags.map((t) => t.name);
|
|
1193
|
+
case "actualize_entry": return [];
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Get all unique tags from the workspace with counts.
|
|
1198
|
+
*/
|
|
1199
|
+
function getTagsWithCounts(workspace) {
|
|
1200
|
+
const tagCounts = /* @__PURE__ */ new Map();
|
|
1201
|
+
for (const model of workspace.allModels()) for (const entry of model.ast.entries) for (const tag of getEntryTags(entry)) tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
|
|
1202
|
+
return tagCounts;
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Format a tag completion item.
|
|
1206
|
+
*/
|
|
1207
|
+
function formatTagCompletion(tag, count) {
|
|
1208
|
+
return {
|
|
1209
|
+
label: `#${tag}`,
|
|
1210
|
+
kind: 20,
|
|
1211
|
+
detail: `${count} ${count === 1 ? "entry" : "entries"}`,
|
|
1212
|
+
insertText: tag,
|
|
1213
|
+
filterText: tag,
|
|
1214
|
+
sortText: String(1e6 - count).padStart(7, "0")
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
/**
|
|
1218
|
+
* Provider for tag completion (#tag).
|
|
1219
|
+
*/
|
|
1220
|
+
const tagProvider = {
|
|
1221
|
+
name: "tag",
|
|
1222
|
+
contextKinds: ["tag", "header_suffix"],
|
|
1223
|
+
getCompletions(ctx, workspace) {
|
|
1224
|
+
if (ctx.kind === "header_suffix" && !ctx.partial.includes("#")) return [];
|
|
1225
|
+
const partial = ctx.kind === "tag" ? ctx.partial.toLowerCase() : ctx.partial.replace(/.*#/, "").toLowerCase();
|
|
1226
|
+
const items = [];
|
|
1227
|
+
const tagCounts = getTagsWithCounts(workspace);
|
|
1228
|
+
for (const [tag, count] of tagCounts) {
|
|
1229
|
+
if (partial && !tag.toLowerCase().includes(partial)) continue;
|
|
1230
|
+
items.push(formatTagCompletion(tag, count));
|
|
1231
|
+
}
|
|
1232
|
+
return items;
|
|
1233
|
+
}
|
|
1234
|
+
};
|
|
1235
|
+
|
|
1236
|
+
//#endregion
|
|
1237
|
+
//#region src/handlers/completions/providers/section.ts
|
|
1238
|
+
/**
|
|
1239
|
+
* Provider for section header completion in content area (# Section).
|
|
1240
|
+
*/
|
|
1241
|
+
const sectionProvider = {
|
|
1242
|
+
name: "section",
|
|
1243
|
+
contextKinds: ["section_header"],
|
|
1244
|
+
getCompletions(ctx, workspace) {
|
|
1245
|
+
const partial = ctx.partial.toLowerCase();
|
|
1246
|
+
const entity = ctx.entry.entity;
|
|
1247
|
+
const items = [];
|
|
1248
|
+
if (!entity) return items;
|
|
1249
|
+
const schema = workspace.schemaRegistry.get(entity);
|
|
1250
|
+
if (!schema) return items;
|
|
1251
|
+
for (const [sectionName, section] of schema.sections) {
|
|
1252
|
+
if (partial && !sectionName.toLowerCase().startsWith(partial)) continue;
|
|
1253
|
+
const optionalSuffix = section.optional ? " (optional)" : " (required)";
|
|
1254
|
+
items.push({
|
|
1255
|
+
label: sectionName,
|
|
1256
|
+
kind: 22,
|
|
1257
|
+
detail: `Section${optionalSuffix}`,
|
|
1258
|
+
documentation: section.description ? {
|
|
1259
|
+
kind: "markdown",
|
|
1260
|
+
value: section.description
|
|
1261
|
+
} : void 0,
|
|
1262
|
+
insertText: ` ${sectionName}`,
|
|
1263
|
+
filterText: sectionName,
|
|
1264
|
+
sortText: section.optional ? `1${sectionName}` : `0${sectionName}`
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
return items;
|
|
1268
|
+
}
|
|
1269
|
+
};
|
|
1270
|
+
|
|
1271
|
+
//#endregion
|
|
1272
|
+
//#region src/handlers/completions/providers/schema-block.ts
|
|
1273
|
+
/**
|
|
1274
|
+
* Get description for a schema block header.
|
|
1275
|
+
*/
|
|
1276
|
+
function getBlockDescription(header) {
|
|
1277
|
+
switch (header) {
|
|
1278
|
+
case "# Metadata": return "Define metadata fields for this entity";
|
|
1279
|
+
case "# Sections": return "Define content sections for this entity";
|
|
1280
|
+
case "# Remove Metadata": return "Remove metadata fields (alter-entity only)";
|
|
1281
|
+
case "# Remove Sections": return "Remove sections (alter-entity only)";
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
/**
|
|
1285
|
+
* Provider for schema block header completion (# Metadata, # Sections, etc.)
|
|
1286
|
+
*/
|
|
1287
|
+
const schemaBlockProvider = {
|
|
1288
|
+
name: "schema-block",
|
|
1289
|
+
contextKinds: ["schema_block_header"],
|
|
1290
|
+
getCompletions(ctx, _workspace) {
|
|
1291
|
+
const partial = ctx.partial.toLowerCase();
|
|
1292
|
+
const directive = ctx.entry.directive;
|
|
1293
|
+
const items = [];
|
|
1294
|
+
for (const header of SCHEMA_BLOCK_HEADERS) {
|
|
1295
|
+
if ((header === "# Remove Metadata" || header === "# Remove Sections") && directive !== "alter-entity") continue;
|
|
1296
|
+
if (partial && !header.toLowerCase().startsWith(partial)) continue;
|
|
1297
|
+
items.push({
|
|
1298
|
+
label: header,
|
|
1299
|
+
kind: 22,
|
|
1300
|
+
detail: "Schema block",
|
|
1301
|
+
documentation: {
|
|
1302
|
+
kind: "markdown",
|
|
1303
|
+
value: getBlockDescription(header)
|
|
1304
|
+
},
|
|
1305
|
+
insertText: header.slice(1),
|
|
1306
|
+
filterText: header
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
return items;
|
|
1310
|
+
}
|
|
1311
|
+
};
|
|
1312
|
+
|
|
1313
|
+
//#endregion
|
|
1314
|
+
//#region src/handlers/completions/providers/type-expr.ts
|
|
1315
|
+
/**
|
|
1316
|
+
* Get description for a primitive type.
|
|
1317
|
+
*/
|
|
1318
|
+
function getPrimitiveTypeDescription(type) {
|
|
1319
|
+
switch (type) {
|
|
1320
|
+
case "string": return "Any text value";
|
|
1321
|
+
case "datetime": return "Date value (YYYY-MM-DD)";
|
|
1322
|
+
case "daterange": return "Date range (YYYY ~ YYYY, YYYY-MM, YYYY Q1, etc.)";
|
|
1323
|
+
case "link": return "Reference to another entry (^link-id)";
|
|
1324
|
+
case "number": return "Numeric value (integer or float)";
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
/**
|
|
1328
|
+
* Provider for type expression completion in schema field definitions.
|
|
1329
|
+
*/
|
|
1330
|
+
const typeExprProvider = {
|
|
1331
|
+
name: "type-expr",
|
|
1332
|
+
contextKinds: ["field_type"],
|
|
1333
|
+
getCompletions(ctx, _workspace) {
|
|
1334
|
+
const partial = ctx.partial.toLowerCase();
|
|
1335
|
+
const items = [];
|
|
1336
|
+
for (const type of PRIMITIVE_TYPES) {
|
|
1337
|
+
if (partial && !type.toLowerCase().startsWith(partial)) continue;
|
|
1338
|
+
items.push({
|
|
1339
|
+
label: type,
|
|
1340
|
+
kind: 25,
|
|
1341
|
+
detail: "Primitive type",
|
|
1342
|
+
documentation: {
|
|
1343
|
+
kind: "markdown",
|
|
1344
|
+
value: getPrimitiveTypeDescription(type)
|
|
1345
|
+
},
|
|
1346
|
+
insertText: type,
|
|
1347
|
+
filterText: type
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
if (!partial || "\"".startsWith(partial)) items.push({
|
|
1351
|
+
label: "\"...\"",
|
|
1352
|
+
kind: 12,
|
|
1353
|
+
detail: "Literal type",
|
|
1354
|
+
documentation: {
|
|
1355
|
+
kind: "markdown",
|
|
1356
|
+
value: "Define a literal value type (e.g., \"article\" | \"video\")"
|
|
1357
|
+
},
|
|
1358
|
+
insertText: "\"",
|
|
1359
|
+
filterText: "\""
|
|
1360
|
+
});
|
|
1361
|
+
return items;
|
|
1362
|
+
}
|
|
1363
|
+
};
|
|
1364
|
+
|
|
1365
|
+
//#endregion
|
|
1366
|
+
//#region src/handlers/completions/providers/providers.ts
|
|
1367
|
+
/**
|
|
1368
|
+
* All completion providers, in order of priority.
|
|
1369
|
+
*/
|
|
1370
|
+
const allProviders = [
|
|
1371
|
+
timestampProvider,
|
|
1372
|
+
directiveProvider,
|
|
1373
|
+
entityProvider,
|
|
1374
|
+
metadataKeyProvider,
|
|
1375
|
+
metadataValueProvider,
|
|
1376
|
+
linkProvider,
|
|
1377
|
+
tagProvider,
|
|
1378
|
+
sectionProvider,
|
|
1379
|
+
schemaBlockProvider,
|
|
1380
|
+
typeExprProvider
|
|
1381
|
+
];
|
|
1382
|
+
|
|
1383
|
+
//#endregion
|
|
1384
|
+
//#region src/handlers/completions/completions.ts
|
|
1385
|
+
/**
|
|
1386
|
+
* Handle textDocument/completion request.
|
|
1387
|
+
*
|
|
1388
|
+
* @param workspace - The thalo workspace for schema/link lookups
|
|
1389
|
+
* @param document - The text document
|
|
1390
|
+
* @param params - Completion parameters
|
|
1391
|
+
* @returns Array of completion items
|
|
1392
|
+
*/
|
|
1393
|
+
function handleCompletion(workspace, document, params) {
|
|
1394
|
+
const ctx = detectContext(document, params.position);
|
|
1395
|
+
const items = [];
|
|
1396
|
+
for (const provider of allProviders) if (provider.contextKinds.includes(ctx.kind)) items.push(...provider.getCompletions(ctx, workspace));
|
|
1397
|
+
return items;
|
|
1398
|
+
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Handle completionItem/resolve request.
|
|
1401
|
+
*
|
|
1402
|
+
* Provides additional details for a completion item.
|
|
1403
|
+
*
|
|
1404
|
+
* @param item - The completion item to resolve
|
|
1405
|
+
* @returns The resolved completion item
|
|
1406
|
+
*/
|
|
1407
|
+
function handleCompletionResolve(item) {
|
|
1408
|
+
return item;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
//#endregion
|
|
1412
|
+
//#region src/server.ts
|
|
1413
|
+
/**
|
|
1414
|
+
* Create the initial server state
|
|
1415
|
+
*/
|
|
1416
|
+
function createServerState(connection) {
|
|
1417
|
+
return {
|
|
1418
|
+
workspace: createWorkspace(),
|
|
1419
|
+
documents: /* @__PURE__ */ new Map(),
|
|
1420
|
+
connection,
|
|
1421
|
+
workspaceFolders: []
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
/**
|
|
1425
|
+
* Get the file type from a URI
|
|
1426
|
+
*/
|
|
1427
|
+
function getFileType(uri) {
|
|
1428
|
+
if (uri.endsWith(".thalo")) return "thalo";
|
|
1429
|
+
if (uri.endsWith(".md")) return "markdown";
|
|
1430
|
+
return "thalo";
|
|
1431
|
+
}
|
|
1432
|
+
/**
|
|
1433
|
+
* Convert a URI to a file path
|
|
1434
|
+
*/
|
|
1435
|
+
function uriToPath(uri) {
|
|
1436
|
+
if (uri.startsWith("file://")) return decodeURIComponent(uri.slice(7));
|
|
1437
|
+
return uri;
|
|
1438
|
+
}
|
|
1439
|
+
/**
|
|
1440
|
+
* Collect all .thalo and .md files from a directory recursively
|
|
1441
|
+
*/
|
|
1442
|
+
function collectThaloFiles(dir) {
|
|
1443
|
+
const files = [];
|
|
1444
|
+
function walk(currentDir) {
|
|
1445
|
+
let entries;
|
|
1446
|
+
try {
|
|
1447
|
+
entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
1448
|
+
} catch {
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
for (const entry of entries) {
|
|
1452
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
1453
|
+
if (entry.isDirectory()) {
|
|
1454
|
+
if (!entry.name.startsWith(".") && entry.name !== "node_modules") walk(fullPath);
|
|
1455
|
+
} else if (entry.isFile()) {
|
|
1456
|
+
if (entry.name.endsWith(".thalo") || entry.name.endsWith(".md")) files.push(fullPath);
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
walk(dir);
|
|
1461
|
+
return files;
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* Load all thalo files from the workspace folders into the workspace.
|
|
1465
|
+
* This ensures cross-file features work correctly even for files not yet opened.
|
|
1466
|
+
*/
|
|
1467
|
+
function loadWorkspaceFiles(state) {
|
|
1468
|
+
for (const folder of state.workspaceFolders) {
|
|
1469
|
+
const files = collectThaloFiles(folder);
|
|
1470
|
+
for (const file of files) {
|
|
1471
|
+
if (state.workspace.getModel(file)) continue;
|
|
1472
|
+
try {
|
|
1473
|
+
const source = fs.readFileSync(file, "utf-8");
|
|
1474
|
+
const fileType = getFileType(file);
|
|
1475
|
+
state.workspace.addDocument(source, {
|
|
1476
|
+
filename: file,
|
|
1477
|
+
fileType
|
|
1478
|
+
});
|
|
1479
|
+
} catch (err) {
|
|
1480
|
+
console.error(`[thalo-lsp] Error loading ${file}: ${err instanceof Error ? err.message : err}`);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
console.error(`[thalo-lsp] Loaded ${files.length} files from ${folder}`);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
/**
|
|
1487
|
+
* Publish diagnostics for a single document
|
|
1488
|
+
*/
|
|
1489
|
+
function publishDiagnosticsForDocument(state, doc) {
|
|
1490
|
+
try {
|
|
1491
|
+
const diagnostics = getDiagnostics(state.workspace, doc);
|
|
1492
|
+
state.connection.sendDiagnostics({
|
|
1493
|
+
uri: doc.uri,
|
|
1494
|
+
diagnostics
|
|
1495
|
+
});
|
|
1496
|
+
} catch (error) {
|
|
1497
|
+
const path$1 = uriToPath(doc.uri);
|
|
1498
|
+
console.error(`[thalo-lsp] Error getting diagnostics for ${path$1}:`, error);
|
|
1499
|
+
state.connection.sendDiagnostics({
|
|
1500
|
+
uri: doc.uri,
|
|
1501
|
+
diagnostics: [{
|
|
1502
|
+
range: {
|
|
1503
|
+
start: {
|
|
1504
|
+
line: 0,
|
|
1505
|
+
character: 0
|
|
1506
|
+
},
|
|
1507
|
+
end: {
|
|
1508
|
+
line: 0,
|
|
1509
|
+
character: 0
|
|
1510
|
+
}
|
|
1511
|
+
},
|
|
1512
|
+
severity: 1,
|
|
1513
|
+
source: "thalo",
|
|
1514
|
+
message: `Diagnostic error: ${error instanceof Error ? error.message : String(error)}`
|
|
1515
|
+
}]
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Refresh diagnostics for specific files based on invalidation result.
|
|
1521
|
+
* Only refreshes open documents that are in the affected files list.
|
|
1522
|
+
*/
|
|
1523
|
+
function refreshDiagnosticsForFiles(state, affectedFiles, changedFile) {
|
|
1524
|
+
const affectedSet = new Set(affectedFiles);
|
|
1525
|
+
for (const doc of state.documents.values()) {
|
|
1526
|
+
const docPath = uriToPath(doc.uri);
|
|
1527
|
+
if (docPath === changedFile || affectedSet.has(docPath)) publishDiagnosticsForDocument(state, doc);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
/**
|
|
1531
|
+
* Update a document in the workspace and publish diagnostics
|
|
1532
|
+
*/
|
|
1533
|
+
function updateDocument(state, doc) {
|
|
1534
|
+
const filePath = uriToPath(doc.uri);
|
|
1535
|
+
try {
|
|
1536
|
+
const invalidation = state.workspace.updateDocument(filePath, doc.getText());
|
|
1537
|
+
if (invalidation.schemasChanged || invalidation.linksChanged) refreshDiagnosticsForFiles(state, invalidation.affectedFiles, filePath);
|
|
1538
|
+
else publishDiagnosticsForDocument(state, doc);
|
|
1539
|
+
} catch (error) {
|
|
1540
|
+
console.error(`[thalo-lsp] Parse error in ${filePath}:`, error);
|
|
1541
|
+
state.connection.sendDiagnostics({
|
|
1542
|
+
uri: doc.uri,
|
|
1543
|
+
diagnostics: [{
|
|
1544
|
+
range: {
|
|
1545
|
+
start: {
|
|
1546
|
+
line: 0,
|
|
1547
|
+
character: 0
|
|
1548
|
+
},
|
|
1549
|
+
end: {
|
|
1550
|
+
line: 0,
|
|
1551
|
+
character: 0
|
|
1552
|
+
}
|
|
1553
|
+
},
|
|
1554
|
+
severity: 1,
|
|
1555
|
+
source: "thalo",
|
|
1556
|
+
message: `Parse error: ${error instanceof Error ? error.message : String(error)}`
|
|
1557
|
+
}]
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* Create a new LSP connection
|
|
1563
|
+
*/
|
|
1564
|
+
function createConnection() {
|
|
1565
|
+
return createConnection$1(ProposedFeatures.all);
|
|
1566
|
+
}
|
|
1567
|
+
/**
|
|
1568
|
+
* Start the LSP server
|
|
1569
|
+
*
|
|
1570
|
+
* @param connection - The LSP connection to use (defaults to stdio)
|
|
1571
|
+
*/
|
|
1572
|
+
function startServer(connection = createConnection()) {
|
|
1573
|
+
const state = createServerState(connection);
|
|
1574
|
+
connection.onInitialize((params) => {
|
|
1575
|
+
console.error(`[thalo-lsp] Initializing with workspace: ${params.workspaceFolders?.[0]?.uri}`);
|
|
1576
|
+
if (params.workspaceFolders) state.workspaceFolders = params.workspaceFolders.map((folder) => uriToPath(folder.uri));
|
|
1577
|
+
else if (params.rootUri) state.workspaceFolders = [uriToPath(params.rootUri)];
|
|
1578
|
+
return {
|
|
1579
|
+
capabilities: serverCapabilities,
|
|
1580
|
+
serverInfo: {
|
|
1581
|
+
name: "thalo-lsp",
|
|
1582
|
+
version: "0.0.0"
|
|
1583
|
+
}
|
|
1584
|
+
};
|
|
1585
|
+
});
|
|
1586
|
+
connection.onInitialized(() => {
|
|
1587
|
+
console.error("[thalo-lsp] Server initialized");
|
|
1588
|
+
connection.client.register(DidChangeConfigurationNotification.type, void 0);
|
|
1589
|
+
loadWorkspaceFiles(state);
|
|
1590
|
+
});
|
|
1591
|
+
connection.onDidOpenTextDocument((params) => {
|
|
1592
|
+
const doc = TextDocument.create(params.textDocument.uri, params.textDocument.languageId, params.textDocument.version, params.textDocument.text);
|
|
1593
|
+
state.documents.set(params.textDocument.uri, doc);
|
|
1594
|
+
updateDocument(state, doc);
|
|
1595
|
+
console.error(`[thalo-lsp] Opened: ${params.textDocument.uri}`);
|
|
1596
|
+
});
|
|
1597
|
+
connection.onDidChangeTextDocument((params) => {
|
|
1598
|
+
const doc = state.documents.get(params.textDocument.uri);
|
|
1599
|
+
if (doc) {
|
|
1600
|
+
const updated = TextDocument.update(doc, params.contentChanges, params.textDocument.version);
|
|
1601
|
+
state.documents.set(params.textDocument.uri, updated);
|
|
1602
|
+
updateDocument(state, updated);
|
|
1603
|
+
}
|
|
1604
|
+
});
|
|
1605
|
+
connection.onDidCloseTextDocument((params) => {
|
|
1606
|
+
state.documents.delete(params.textDocument.uri);
|
|
1607
|
+
const filePath = uriToPath(params.textDocument.uri);
|
|
1608
|
+
try {
|
|
1609
|
+
if (fs.existsSync(filePath)) {
|
|
1610
|
+
const source = fs.readFileSync(filePath, "utf-8");
|
|
1611
|
+
const fileType = getFileType(params.textDocument.uri);
|
|
1612
|
+
state.workspace.addDocument(source, {
|
|
1613
|
+
filename: filePath,
|
|
1614
|
+
fileType
|
|
1615
|
+
});
|
|
1616
|
+
} else state.workspace.removeDocument(filePath);
|
|
1617
|
+
} catch {
|
|
1618
|
+
state.workspace.removeDocument(filePath);
|
|
1619
|
+
}
|
|
1620
|
+
connection.sendDiagnostics({
|
|
1621
|
+
uri: params.textDocument.uri,
|
|
1622
|
+
diagnostics: []
|
|
1623
|
+
});
|
|
1624
|
+
console.error(`[thalo-lsp] Closed: ${params.textDocument.uri}`);
|
|
1625
|
+
});
|
|
1626
|
+
connection.onDidChangeWatchedFiles((params) => {
|
|
1627
|
+
const allAffectedFiles = [];
|
|
1628
|
+
for (const change of params.changes) {
|
|
1629
|
+
const filePath = uriToPath(change.uri);
|
|
1630
|
+
if (!filePath.endsWith(".thalo") && !filePath.endsWith(".md")) continue;
|
|
1631
|
+
if (change.type === FileChangeType.Deleted) {
|
|
1632
|
+
const affected = state.workspace.getAffectedFiles(filePath);
|
|
1633
|
+
allAffectedFiles.push(...affected);
|
|
1634
|
+
state.workspace.removeDocument(filePath);
|
|
1635
|
+
state.documents.delete(change.uri);
|
|
1636
|
+
connection.sendDiagnostics({
|
|
1637
|
+
uri: change.uri,
|
|
1638
|
+
diagnostics: []
|
|
1639
|
+
});
|
|
1640
|
+
console.error(`[thalo-lsp] Removed deleted file: ${filePath}`);
|
|
1641
|
+
continue;
|
|
1642
|
+
}
|
|
1643
|
+
if (state.documents.has(change.uri)) continue;
|
|
1644
|
+
switch (change.type) {
|
|
1645
|
+
case FileChangeType.Created:
|
|
1646
|
+
case FileChangeType.Changed:
|
|
1647
|
+
try {
|
|
1648
|
+
if (fs.existsSync(filePath)) {
|
|
1649
|
+
const source = fs.readFileSync(filePath, "utf-8");
|
|
1650
|
+
const invalidation = state.workspace.updateDocument(filePath, source);
|
|
1651
|
+
allAffectedFiles.push(...invalidation.affectedFiles);
|
|
1652
|
+
console.error(`[thalo-lsp] Loaded external file: ${filePath}`);
|
|
1653
|
+
}
|
|
1654
|
+
} catch (err) {
|
|
1655
|
+
console.error(`[thalo-lsp] Error loading ${filePath}: ${err instanceof Error ? err.message : err}`);
|
|
1656
|
+
}
|
|
1657
|
+
break;
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
if (allAffectedFiles.length > 0) {
|
|
1661
|
+
const affectedSet = new Set(allAffectedFiles);
|
|
1662
|
+
for (const doc of state.documents.values()) if (affectedSet.has(uriToPath(doc.uri))) publishDiagnosticsForDocument(state, doc);
|
|
1663
|
+
}
|
|
1664
|
+
});
|
|
1665
|
+
connection.workspace.onDidCreateFiles((params) => {
|
|
1666
|
+
const allAffectedFiles = [];
|
|
1667
|
+
for (const file of params.files) {
|
|
1668
|
+
const filePath = uriToPath(file.uri);
|
|
1669
|
+
if (!filePath.endsWith(".thalo") && !filePath.endsWith(".md")) continue;
|
|
1670
|
+
try {
|
|
1671
|
+
if (fs.existsSync(filePath)) {
|
|
1672
|
+
const source = fs.readFileSync(filePath, "utf-8");
|
|
1673
|
+
const invalidation = state.workspace.updateDocument(filePath, source);
|
|
1674
|
+
allAffectedFiles.push(...invalidation.affectedFiles);
|
|
1675
|
+
console.error(`[thalo-lsp] Loaded created file: ${filePath}`);
|
|
1676
|
+
}
|
|
1677
|
+
} catch (err) {
|
|
1678
|
+
console.error(`[thalo-lsp] Error loading created file ${filePath}: ${err instanceof Error ? err.message : err}`);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
if (allAffectedFiles.length > 0) {
|
|
1682
|
+
const affectedSet = new Set(allAffectedFiles);
|
|
1683
|
+
for (const doc of state.documents.values()) if (affectedSet.has(uriToPath(doc.uri))) publishDiagnosticsForDocument(state, doc);
|
|
1684
|
+
}
|
|
1685
|
+
});
|
|
1686
|
+
connection.workspace.onDidDeleteFiles((params) => {
|
|
1687
|
+
const allAffectedFiles = [];
|
|
1688
|
+
for (const file of params.files) {
|
|
1689
|
+
const filePath = uriToPath(file.uri);
|
|
1690
|
+
if (!filePath.endsWith(".thalo") && !filePath.endsWith(".md")) continue;
|
|
1691
|
+
const affected = state.workspace.getAffectedFiles(filePath);
|
|
1692
|
+
allAffectedFiles.push(...affected);
|
|
1693
|
+
state.workspace.removeDocument(filePath);
|
|
1694
|
+
console.error(`[thalo-lsp] Removed file: ${filePath}`);
|
|
1695
|
+
}
|
|
1696
|
+
if (allAffectedFiles.length > 0) {
|
|
1697
|
+
const affectedSet = new Set(allAffectedFiles);
|
|
1698
|
+
for (const doc of state.documents.values()) if (affectedSet.has(uriToPath(doc.uri))) publishDiagnosticsForDocument(state, doc);
|
|
1699
|
+
}
|
|
1700
|
+
});
|
|
1701
|
+
connection.workspace.onDidRenameFiles((params) => {
|
|
1702
|
+
const allAffectedFiles = [];
|
|
1703
|
+
for (const file of params.files) {
|
|
1704
|
+
const oldPath = uriToPath(file.oldUri);
|
|
1705
|
+
const newPath = uriToPath(file.newUri);
|
|
1706
|
+
const oldIsThalo = oldPath.endsWith(".thalo") || oldPath.endsWith(".md");
|
|
1707
|
+
const newIsThalo = newPath.endsWith(".thalo") || newPath.endsWith(".md");
|
|
1708
|
+
if (oldIsThalo) {
|
|
1709
|
+
const affected = state.workspace.getAffectedFiles(oldPath);
|
|
1710
|
+
allAffectedFiles.push(...affected);
|
|
1711
|
+
state.workspace.removeDocument(oldPath);
|
|
1712
|
+
console.error(`[thalo-lsp] Removed renamed file: ${oldPath}`);
|
|
1713
|
+
}
|
|
1714
|
+
if (newIsThalo) try {
|
|
1715
|
+
if (fs.existsSync(newPath)) {
|
|
1716
|
+
const source = fs.readFileSync(newPath, "utf-8");
|
|
1717
|
+
const invalidation = state.workspace.updateDocument(newPath, source);
|
|
1718
|
+
allAffectedFiles.push(...invalidation.affectedFiles);
|
|
1719
|
+
console.error(`[thalo-lsp] Loaded renamed file: ${newPath}`);
|
|
1720
|
+
}
|
|
1721
|
+
} catch (err) {
|
|
1722
|
+
console.error(`[thalo-lsp] Error loading renamed file ${newPath}: ${err instanceof Error ? err.message : err}`);
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
if (allAffectedFiles.length > 0) {
|
|
1726
|
+
const affectedSet = new Set(allAffectedFiles);
|
|
1727
|
+
for (const doc of state.documents.values()) if (affectedSet.has(uriToPath(doc.uri))) publishDiagnosticsForDocument(state, doc);
|
|
1728
|
+
}
|
|
1729
|
+
});
|
|
1730
|
+
connection.onDefinition((params) => {
|
|
1731
|
+
const doc = state.documents.get(params.textDocument.uri);
|
|
1732
|
+
if (!doc) return null;
|
|
1733
|
+
return handleDefinition(state.workspace, doc, params.position);
|
|
1734
|
+
});
|
|
1735
|
+
connection.onReferences((params) => {
|
|
1736
|
+
const doc = state.documents.get(params.textDocument.uri);
|
|
1737
|
+
if (!doc) return null;
|
|
1738
|
+
return handleReferences(state.workspace, doc, params.position, params.context);
|
|
1739
|
+
});
|
|
1740
|
+
connection.onHover((params) => {
|
|
1741
|
+
const doc = state.documents.get(params.textDocument.uri);
|
|
1742
|
+
if (!doc) return null;
|
|
1743
|
+
return handleHover(state.workspace, doc, params.position);
|
|
1744
|
+
});
|
|
1745
|
+
connection.onCompletion((params) => {
|
|
1746
|
+
const doc = state.documents.get(params.textDocument.uri);
|
|
1747
|
+
if (!doc) return [];
|
|
1748
|
+
return handleCompletion(state.workspace, doc, params);
|
|
1749
|
+
});
|
|
1750
|
+
connection.onCompletionResolve((item) => {
|
|
1751
|
+
return handleCompletionResolve(item);
|
|
1752
|
+
});
|
|
1753
|
+
connection.onRequest("textDocument/semanticTokens/full", (params) => {
|
|
1754
|
+
const doc = state.documents.get(params.textDocument.uri);
|
|
1755
|
+
if (!doc) return { data: [] };
|
|
1756
|
+
return handleSemanticTokens(doc);
|
|
1757
|
+
});
|
|
1758
|
+
connection.listen();
|
|
1759
|
+
console.error("[thalo-lsp] Server started");
|
|
1760
|
+
}
|
|
1761
|
+
if (import.meta.url === `file://${process.argv[1]}`) startServer();
|
|
1762
|
+
|
|
1763
|
+
//#endregion
|
|
1764
|
+
export { createConnection, startServer, tokenLegend };
|