@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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +216 -0
  3. package/dist/capabilities.d.ts +12 -0
  4. package/dist/capabilities.d.ts.map +1 -0
  5. package/dist/capabilities.js +54 -0
  6. package/dist/capabilities.js.map +1 -0
  7. package/dist/handlers/completions/completions.js +33 -0
  8. package/dist/handlers/completions/completions.js.map +1 -0
  9. package/dist/handlers/completions/context.js +228 -0
  10. package/dist/handlers/completions/context.js.map +1 -0
  11. package/dist/handlers/completions/providers/directive.js +50 -0
  12. package/dist/handlers/completions/providers/directive.js.map +1 -0
  13. package/dist/handlers/completions/providers/entity.js +52 -0
  14. package/dist/handlers/completions/providers/entity.js.map +1 -0
  15. package/dist/handlers/completions/providers/link.js +113 -0
  16. package/dist/handlers/completions/providers/link.js.map +1 -0
  17. package/dist/handlers/completions/providers/metadata-key.js +43 -0
  18. package/dist/handlers/completions/providers/metadata-key.js.map +1 -0
  19. package/dist/handlers/completions/providers/metadata-value.js +71 -0
  20. package/dist/handlers/completions/providers/metadata-value.js.map +1 -0
  21. package/dist/handlers/completions/providers/providers.js +31 -0
  22. package/dist/handlers/completions/providers/providers.js.map +1 -0
  23. package/dist/handlers/completions/providers/schema-block.js +46 -0
  24. package/dist/handlers/completions/providers/schema-block.js.map +1 -0
  25. package/dist/handlers/completions/providers/section.js +37 -0
  26. package/dist/handlers/completions/providers/section.js.map +1 -0
  27. package/dist/handlers/completions/providers/tag.js +55 -0
  28. package/dist/handlers/completions/providers/tag.js.map +1 -0
  29. package/dist/handlers/completions/providers/timestamp.js +32 -0
  30. package/dist/handlers/completions/providers/timestamp.js.map +1 -0
  31. package/dist/handlers/completions/providers/type-expr.js +56 -0
  32. package/dist/handlers/completions/providers/type-expr.js.map +1 -0
  33. package/dist/handlers/definition.js +166 -0
  34. package/dist/handlers/definition.js.map +1 -0
  35. package/dist/handlers/diagnostics.js +77 -0
  36. package/dist/handlers/diagnostics.js.map +1 -0
  37. package/dist/handlers/hover.js +73 -0
  38. package/dist/handlers/hover.js.map +1 -0
  39. package/dist/handlers/references.js +233 -0
  40. package/dist/handlers/references.js.map +1 -0
  41. package/dist/handlers/semantic-tokens.js +36 -0
  42. package/dist/handlers/semantic-tokens.js.map +1 -0
  43. package/dist/mod.d.ts +2 -0
  44. package/dist/mod.js +3 -0
  45. package/dist/server.bundled.js +1764 -0
  46. package/dist/server.d.ts +18 -0
  47. package/dist/server.d.ts.map +1 -0
  48. package/dist/server.js +367 -0
  49. package/dist/server.js.map +1 -0
  50. 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 };