@eventcatalog/language-server 0.6.0 → 0.6.1

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 (87) hide show
  1. package/dist/browser.d.ts +22 -0
  2. package/dist/browser.d.ts.map +1 -0
  3. package/dist/browser.js +20 -0
  4. package/dist/browser.js.map +1 -0
  5. package/dist/compiler.js +1 -1
  6. package/dist/compiler.js.map +1 -1
  7. package/dist/completion-data.d.ts +15 -0
  8. package/dist/completion-data.d.ts.map +1 -0
  9. package/dist/completion-data.js +453 -0
  10. package/dist/completion-data.js.map +1 -0
  11. package/dist/completion-utils.d.ts +29 -0
  12. package/dist/completion-utils.d.ts.map +1 -0
  13. package/dist/completion-utils.js +128 -0
  14. package/dist/completion-utils.js.map +1 -0
  15. package/dist/ec-completion-provider.d.ts +22 -1
  16. package/dist/ec-completion-provider.d.ts.map +1 -1
  17. package/dist/ec-completion-provider.js +461 -150
  18. package/dist/ec-completion-provider.js.map +1 -1
  19. package/dist/ec-definition-provider.d.ts +14 -0
  20. package/dist/ec-definition-provider.d.ts.map +1 -0
  21. package/dist/ec-definition-provider.js +123 -0
  22. package/dist/ec-definition-provider.js.map +1 -0
  23. package/dist/ec-formatter-provider.d.ts +10 -0
  24. package/dist/ec-formatter-provider.d.ts.map +1 -0
  25. package/dist/ec-formatter-provider.js +28 -0
  26. package/dist/ec-formatter-provider.js.map +1 -0
  27. package/dist/ec-hover-provider.d.ts +10 -0
  28. package/dist/ec-hover-provider.d.ts.map +1 -0
  29. package/dist/ec-hover-provider.js +41 -0
  30. package/dist/ec-hover-provider.js.map +1 -0
  31. package/dist/ec-module.d.ts +0 -6
  32. package/dist/ec-module.d.ts.map +1 -1
  33. package/dist/ec-module.js +0 -10
  34. package/dist/ec-module.js.map +1 -1
  35. package/dist/ec-resource-index.d.ts +64 -0
  36. package/dist/ec-resource-index.d.ts.map +1 -0
  37. package/dist/ec-resource-index.js +281 -0
  38. package/dist/ec-resource-index.js.map +1 -0
  39. package/dist/ec-validator.d.ts.map +1 -1
  40. package/dist/ec-validator.js +22 -87
  41. package/dist/ec-validator.js.map +1 -1
  42. package/dist/generated/ast.d.ts +2 -2
  43. package/dist/generated/ast.d.ts.map +1 -1
  44. package/dist/generated/ast.js +1 -1
  45. package/dist/generated/ast.js.map +1 -1
  46. package/dist/generated/grammar.d.ts.map +1 -1
  47. package/dist/generated/grammar.js +103 -53
  48. package/dist/generated/grammar.js.map +1 -1
  49. package/dist/graph.d.ts.map +1 -1
  50. package/dist/graph.js +83 -35
  51. package/dist/graph.js.map +1 -1
  52. package/dist/index.d.ts +6 -2
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +4 -2
  55. package/dist/index.js.map +1 -1
  56. package/dist/main.js +16 -1
  57. package/dist/main.js.map +1 -1
  58. package/dist/resolvers/asyncapi.d.ts +5 -0
  59. package/dist/resolvers/asyncapi.d.ts.map +1 -1
  60. package/dist/resolvers/asyncapi.js +50 -14
  61. package/dist/resolvers/asyncapi.js.map +1 -1
  62. package/dist/resolvers/catalog.d.ts +34 -0
  63. package/dist/resolvers/catalog.d.ts.map +1 -0
  64. package/dist/resolvers/catalog.js +291 -0
  65. package/dist/resolvers/catalog.js.map +1 -0
  66. package/dist/resolvers/index.d.ts +4 -2
  67. package/dist/resolvers/index.d.ts.map +1 -1
  68. package/dist/resolvers/index.js +4 -2
  69. package/dist/resolvers/index.js.map +1 -1
  70. package/dist/resolvers/openapi.d.ts.map +1 -1
  71. package/dist/resolvers/openapi.js +30 -0
  72. package/dist/resolvers/openapi.js.map +1 -1
  73. package/dist/resolvers/resolve.d.ts +5 -2
  74. package/dist/resolvers/resolve.d.ts.map +1 -1
  75. package/dist/resolvers/resolve.js +161 -5
  76. package/dist/resolvers/resolve.js.map +1 -1
  77. package/dist/resolvers/types.d.ts +16 -1
  78. package/dist/resolvers/types.d.ts.map +1 -1
  79. package/dist/resolvers/types.js +11 -1
  80. package/dist/resolvers/types.js.map +1 -1
  81. package/package.json +7 -2
  82. package/specification/03-event.md +1 -0
  83. package/specification/04-command.md +4 -1
  84. package/specification/05-query.md +4 -1
  85. package/specification/06-channel.md +24 -4
  86. package/specification/16-annotations.md +52 -0
  87. package/syntaxes/ec.tmLanguage.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"ec-completion-provider.d.ts","sourceRoot":"","sources":["../src/ec-completion-provider.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAC;AACxD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACnD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAC5C,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAErC,OAAO,KAAK,EACV,kBAAkB,EAClB,iBAAiB,EACjB,yBAAyB,EAE1B,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAwI/C,qBAAa,oBAAqB,SAAQ,yBAAyB;IACjE,SAAkB,iBAAiB,EAAE,yBAAyB,CAE5D;gBAEU,QAAQ,EAAE,eAAe;cAIlB,aAAa,CAC9B,OAAO,EAAE,iBAAiB,EAC1B,IAAI,EAAE,WAAW,EACjB,QAAQ,EAAE,kBAAkB,GAC3B,YAAY,CAAC,IAAI,CAAC;cASF,oBAAoB,CACrC,OAAO,EAAE,iBAAiB,EAC1B,OAAO,EAAE,UAAU,CAAC,OAAO,EAC3B,QAAQ,EAAE,kBAAkB,GAC3B,YAAY,CAAC,IAAI,CAAC;IAiCrB,OAAO,CAAC,uBAAuB;IAgC/B,OAAO,CAAC,uBAAuB;CAchC"}
1
+ {"version":3,"file":"ec-completion-provider.d.ts","sourceRoot":"","sources":["../src/ec-completion-provider.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAC;AACxD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACnD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAC5C,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAErC,OAAO,KAAK,EACV,kBAAkB,EAClB,iBAAiB,EACjB,yBAAyB,EAE1B,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AA4C/C,qBAAa,oBAAqB,SAAQ,yBAAyB;IACjE,SAAkB,iBAAiB,EAAE,yBAAyB,CAE5D;IAEF,OAAO,CAAC,eAAe,CAAkB;IAEzC,uFAAuF;IACvF,OAAO,CAAC,QAAQ,CAAyB;gBAE7B,QAAQ,EAAE,eAAe;cAKZ,aAAa,CACpC,OAAO,EAAE,iBAAiB,EAC1B,IAAI,EAAE,WAAW,EACjB,QAAQ,EAAE,kBAAkB,GAC3B,OAAO,CAAC,IAAI,CAAC;cAgBG,oBAAoB,CACrC,OAAO,EAAE,iBAAiB,EAC1B,OAAO,EAAE,UAAU,CAAC,OAAO,EAC3B,QAAQ,EAAE,kBAAkB,GAC3B,YAAY,CAAC,IAAI,CAAC;YA2EP,oBAAoB;IAoGlC,OAAO,CAAC,mBAAmB;IAW3B,OAAO,CAAC,kBAAkB;IAO1B,OAAO,CAAC,cAAc;IAItB;;;OAGG;IACH,OAAO,CAAC,aAAa;IAmCrB,OAAO,CAAC,qBAAqB;IAa7B,OAAO,CAAC,YAAY;IAcpB,OAAO,CAAC,mBAAmB;IAqB3B,OAAO,CAAC,mBAAmB;YA6Bb,mBAAmB;YAyGnB,0BAA0B;IAuExC,OAAO,CAAC,oBAAoB;IA4B5B,OAAO,CAAC,oBAAoB;IA8B5B,OAAO,CAAC,gBAAgB;IAuBxB,OAAO,CAAC,uBAAuB;IA8B/B,OAAO,CAAC,uBAAuB;CAehC"}
@@ -1,172 +1,483 @@
1
1
  import { DefaultCompletionProvider } from "langium/lsp";
2
2
  import { CompletionItemKind, InsertTextFormat } from "vscode-languageserver";
3
- const RESOURCE_KEYWORDS = new Map([
4
- ["domain", "Top-level bounded context"],
5
- ["service", "Microservice or application"],
6
- ["event", "Domain event"],
7
- ["command", "Command message"],
8
- ["query", "Query message"],
9
- ["channel", "Communication channel"],
10
- ["container", "Data container (database, cache, etc.)"],
11
- ["data-product", "Analytical data product"],
12
- ["flow", "Process flow definition"],
13
- ["diagram", "Architecture diagram"],
14
- ["user", "User definition"],
15
- ["team", "Team definition"],
16
- ["visualizer", "Visualizer view"],
17
- ["actor", "Human actor (for flows)"],
18
- ["external-system", "External system (for flows)"],
19
- ]);
20
- const PROPERTY_KEYWORDS = new Map([
21
- ["version", "Semantic version (e.g. 1.0.0)"],
22
- ["name", "Display name"],
23
- ["summary", "Short description"],
24
- ["owner", "Owner reference (e.g. team-name)"],
25
- ["schema", "Schema file path"],
26
- ["draft", "Mark resource as draft"],
27
- ["deprecated", "Mark resource as deprecated"],
28
- ["address", "Channel address or topic"],
29
- ["protocol", "Channel protocol (e.g. kafka, http)"],
30
- ["technology", "Technology or implementation"],
31
- ["residency", "Data residency region"],
32
- ["retention", "Data retention policy"],
33
- ["authoritative", "Authoritative data source"],
34
- ["classification", "Data classification level"],
35
- ["access-mode", "Data access pattern (read, write, etc.)"],
36
- ["container-type", "Type of container (database, cache, etc.)"],
37
- ["legend", "Show/hide legend in visualizer"],
38
- ["search", "Show/hide search in visualizer"],
39
- ["toolbar", "Show/hide toolbar in visualizer"],
40
- ["focus-mode", "Enable/disable focus mode in visualizer"],
41
- ["animated", "Simulate message flow animation in visualizer"],
42
- ]);
43
- const RELATIONSHIP_KEYWORDS = new Map([
44
- ["sends", "Service sends a message"],
45
- ["receives", "Service receives a message"],
46
- ["writes-to", "Service writes to a container"],
47
- ["reads-from", "Service reads from a container"],
48
- ["to", "Target channel"],
49
- ["from", "Source channel"],
50
- ]);
51
- const BLOCK_KEYWORDS = new Map([
52
- ["subdomain", "Nested subdomain"],
53
- ["parameter", "Channel parameter"],
54
- ["->", "Flow arrow"],
55
- ["when", "Flow when-block trigger"],
56
- ["and", "Convergence (multiple triggers)"],
57
- ["input", "Data product input"],
58
- ["output", "Data product output"],
59
- ["route", "Route to another channel"],
60
- ["contract", "Output contract definition"],
61
- ]);
62
- const KNOWN_ANNOTATIONS = [
63
- { name: "badge", description: "Add a visual badge to the resource" },
64
- { name: "note", description: "Add a developer note or reminder" },
65
- { name: "repository", description: "Link to a source code repository" },
66
- { name: "specifications", description: "Add specification links" },
67
- { name: "externalId", description: "Set an external identifier" },
68
- { name: "tag", description: "Add a tag to the resource" },
69
- ];
70
- /** Snippet templates for resource types that benefit from scaffolding */
71
- const RESOURCE_SNIPPETS = {
72
- service: {
73
- label: "service (block)",
74
- snippet: 'service ${1:ServiceName} {\n version ${2:0.0.1}\n summary "${3:Service that manages and processes $1 operations}"\n $0\n}',
75
- },
76
- event: {
77
- label: "event (block)",
78
- snippet: 'event ${1:EventName} {\n version ${2:0.0.1}\n summary "${3:Triggered when a significant change occurs in the domain}"\n $0\n}',
79
- },
80
- command: {
81
- label: "command (block)",
82
- snippet: 'command ${1:CommandName} {\n version ${2:0.0.1}\n summary "${3:Requests an action to be performed in the system}"\n $0\n}',
83
- },
84
- query: {
85
- label: "query (block)",
86
- snippet: 'query ${1:QueryName} {\n version ${2:0.0.1}\n summary "${3:Retrieves data from the system without side effects}"\n $0\n}',
87
- },
88
- domain: {
89
- label: "domain (block)",
90
- snippet: 'domain ${1:DomainName} {\n version ${2:0.0.1}\n summary "${3:Bounded context responsible for $1}"\n $0\n}',
91
- },
92
- container: {
93
- label: "container (block)",
94
- snippet: 'container ${1:ContainerName} {\n version ${2:0.0.1}\n summary "${3:Data store that persists and manages $1 data}"\n $0\n}',
95
- },
96
- visualizer: {
97
- label: "visualizer (block)",
98
- snippet: 'visualizer ${1:main} {\n name "${2:View Name}"\n $0\n}',
99
- },
100
- actor: {
101
- label: "actor (block)",
102
- snippet: 'actor ${1:ActorName} {\n name "${2:Display Name}"\n summary "${3:User or persona that interacts with the system}"\n}',
103
- },
104
- "external-system": {
105
- label: "external-system (block)",
106
- snippet: 'external-system ${1:SystemName} {\n name "${2:Display Name}"\n summary "${3:Third-party system that integrates with the platform}"\n}',
107
- },
108
- };
109
- function getKeywordInfo(keyword) {
110
- const resourceDesc = RESOURCE_KEYWORDS.get(keyword);
111
- if (resourceDesc)
112
- return { category: "Resource", description: resourceDesc };
113
- const propDesc = PROPERTY_KEYWORDS.get(keyword);
114
- if (propDesc)
115
- return { category: "Property", description: propDesc };
116
- const relDesc = RELATIONSHIP_KEYWORDS.get(keyword);
117
- if (relDesc)
118
- return { category: "Relationship", description: relDesc };
119
- const blockDesc = BLOCK_KEYWORDS.get(keyword);
120
- if (blockDesc)
121
- return { category: "Block", description: blockDesc };
122
- return undefined;
123
- }
3
+ import { isSpecFile } from "./resolvers/resolve.js";
4
+ import { isCatalogPath } from "./resolvers/types.js";
5
+ import { parseCatalogResources, parseCatalogChannels, parseCatalogServices, } from "./resolvers/catalog.js";
6
+ import { collectRegexMatches, extractResourceVersions, parseSpecAuto, findEnclosingResource, collectChannelNames, collectMessageNames, } from "./completion-utils.js";
7
+ import { RESOURCE_KEYWORDS, ANNOTATION_SUGGESTIONS, CONTEXT_SUGGESTIONS, MESSAGE_TYPE_PLURAL, } from "./completion-data.js";
8
+ import { readFileSync, readdirSync, statSync } from "node:fs";
9
+ import { resolve, dirname } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ /** Build a lookup from keyword label → Suggestion for resource snippets */
12
+ const RESOURCE_SUGGESTION_MAP = new Map(RESOURCE_KEYWORDS.map((r) => [r.label, r]));
13
+ // ─── Completion provider ─────────────────────────────────
124
14
  export class EcCompletionProvider extends DefaultCompletionProvider {
125
15
  completionOptions = {
126
- triggerCharacters: ["@"],
16
+ triggerCharacters: ["@", '"', "{"],
127
17
  };
18
+ langiumServices;
19
+ /** Snapshot for the current completion request. Reset on each `completionFor` call. */
20
+ snapshot = {};
128
21
  constructor(services) {
129
22
  super(services);
23
+ this.langiumServices = services;
130
24
  }
131
- completionFor(context, next, acceptor) {
132
- // When completing after '@', offer known annotation names
25
+ async completionFor(context, next, acceptor) {
26
+ // Reset per-request cache
27
+ this.snapshot = {};
28
+ // When completing after '@', offer annotation snippets
133
29
  if (this.isAnnotationNameContext(context, next)) {
134
30
  this.completeAnnotationNames(context, acceptor);
135
31
  return;
136
32
  }
33
+ // Try dynamic cross-file completions before grammar-driven ones
34
+ if (await this.tryDynamicCompletion(context, acceptor)) {
35
+ return;
36
+ }
137
37
  return super.completionFor(context, next, acceptor);
138
38
  }
139
39
  completionForKeyword(context, keyword, acceptor) {
140
40
  const kw = keyword.value;
141
- // Skip '@' itself as a completion — we handle annotation names in completionFor
41
+ // Skip '@' itself — we handle annotation names in completionFor
142
42
  if (kw === "@") {
143
43
  return;
144
44
  }
145
- const info = getKeywordInfo(kw);
146
- const item = {
147
- label: kw,
148
- kind: CompletionItemKind.Keyword,
149
- ...(info
150
- ? { detail: info.category, documentation: info.description }
151
- : {}),
152
- sortText: info?.category === "Resource" ? "0" + kw : undefined,
153
- };
154
- acceptor(context, item);
155
- // Also offer a snippet variant for resource keywords
156
- const snippet = RESOURCE_SNIPPETS[kw];
157
- if (snippet) {
45
+ // Determine what resource block the cursor is inside (if any)
46
+ const text = context.textDocument.getText();
47
+ const textBefore = text.substring(0, context.offset);
48
+ const enclosing = findEnclosingResource(textBefore);
49
+ // Inside a non-visualizer resource block: only show context-appropriate items
50
+ if (enclosing && enclosing !== "visualizer") {
51
+ const suggestions = CONTEXT_SUGGESTIONS[enclosing];
52
+ if (suggestions) {
53
+ const match = suggestions.find((s) => s.label === kw);
54
+ if (match) {
55
+ acceptor(context, {
56
+ label: kw,
57
+ kind: CompletionItemKind.Snippet,
58
+ detail: match.detail,
59
+ insertText: match.insertText,
60
+ insertTextFormat: InsertTextFormat.Snippet,
61
+ sortText: "0" + kw,
62
+ });
63
+ }
64
+ }
65
+ // Don't show resource keywords or unrelated keywords inside resource blocks
66
+ return;
67
+ }
68
+ // At top level or inside a visualizer: offer resource keyword snippets
69
+ const resourceSuggestion = RESOURCE_SUGGESTION_MAP.get(kw);
70
+ if (resourceSuggestion) {
158
71
  acceptor(context, {
159
- label: snippet.label,
72
+ label: kw,
160
73
  kind: CompletionItemKind.Snippet,
161
- detail: "Resource (snippet)",
162
- insertText: snippet.snippet,
74
+ detail: resourceSuggestion.detail,
75
+ insertText: resourceSuggestion.insertText,
163
76
  insertTextFormat: InsertTextFormat.Snippet,
164
- sortText: "1" + kw,
77
+ sortText: "0" + kw,
165
78
  });
79
+ return;
80
+ }
81
+ // Inside a visualizer: also check visualizer-specific context suggestions
82
+ if (enclosing === "visualizer") {
83
+ const suggestions = CONTEXT_SUGGESTIONS[enclosing];
84
+ if (suggestions) {
85
+ const match = suggestions.find((s) => s.label === kw);
86
+ if (match) {
87
+ acceptor(context, {
88
+ label: kw,
89
+ kind: CompletionItemKind.Snippet,
90
+ detail: match.detail,
91
+ insertText: match.insertText,
92
+ insertTextFormat: InsertTextFormat.Snippet,
93
+ sortText: "0" + kw,
94
+ });
95
+ return;
96
+ }
97
+ }
98
+ }
99
+ // Fallback: plain keyword
100
+ acceptor(context, {
101
+ label: kw,
102
+ kind: CompletionItemKind.Keyword,
103
+ });
104
+ }
105
+ // ─── Dynamic cross-file completions ──────────────────
106
+ async tryDynamicCompletion(context, acceptor) {
107
+ const text = context.textDocument.getText();
108
+ const offset = context.offset;
109
+ // Get the current line text before cursor
110
+ const textBefore = text.substring(0, offset);
111
+ const lastNewline = textBefore.lastIndexOf("\n");
112
+ const lineText = textBefore.substring(lastNewline + 1);
113
+ // 1. Import file paths: from "..."
114
+ if (lineText.includes("import")) {
115
+ const importFromMatch = lineText.match(/import\s+(?:(?:events|commands|queries|channels|services)\s+)?\{[^}]*\}\s*from\s*"([^"]*)$/);
116
+ if (importFromMatch) {
117
+ this.completeImportPaths(context, acceptor);
118
+ return true;
119
+ }
120
+ // 2. Resource names in import braces: import { ... }
121
+ const importBracesMatch = lineText.match(/import\s+(?:(events|commands|queries|channels|services|containers)\s+)?\{([^}]*)$/);
122
+ if (importBracesMatch) {
123
+ const fullLineContent = this.getFullLineContent(text, offset);
124
+ await this.completeImportNames(context, acceptor, importBracesMatch[1], importBracesMatch[2] || "", fullLineContent);
125
+ return true;
126
+ }
127
+ }
128
+ if (lineText.includes("sends") || lineText.includes("receives")) {
129
+ // 3. Channel names: sends event X to ...
130
+ const channelRefMatch = lineText.match(/\b(?:sends|receives)\s+(?:event|command|query)\s+[a-zA-Z_][a-zA-Z0-9_.\-]*(?:@[\d]+\.[\d]+\.[\d]+[a-zA-Z0-9_.\-]*)?\s+(?:to|from)\s+(?:.*,\s*)?([a-zA-Z_][a-zA-Z0-9_.\-]*)?$/);
131
+ if (channelRefMatch) {
132
+ this.completeChannelNames(context, acceptor);
133
+ return true;
134
+ }
135
+ // 4. Message names: sends event ...
136
+ const sendsReceivesMatch = lineText.match(/\b(?:sends|receives)\s+(event|command|query)\s+([a-zA-Z_][a-zA-Z0-9_.\-]*)?$/);
137
+ if (sendsReceivesMatch && !sendsReceivesMatch[0].endsWith("@")) {
138
+ this.completeMessageNames(context, acceptor, sendsReceivesMatch[1]);
139
+ return true;
140
+ }
141
+ // 5. Version after @: sends event Name@
142
+ const atMatch = lineText.match(/\b(?:sends|receives)\s+(event|command|query)\s+([a-zA-Z_][a-zA-Z0-9_.\-]*)@$/);
143
+ if (atMatch) {
144
+ this.completeVersions(context, acceptor, atMatch[1], atMatch[2]);
145
+ return true;
146
+ }
147
+ }
148
+ // 6. Context-aware completions for the enclosing resource block
149
+ // Offer all suggestions for the current context — this ensures items like
150
+ // sends, receives, writes-to, reads-from, and scaffold snippets are always
151
+ // available, even when Langium's grammar analysis doesn't offer them.
152
+ const enclosing = findEnclosingResource(textBefore);
153
+ if (enclosing) {
154
+ const suggestions = CONTEXT_SUGGESTIONS[enclosing];
155
+ if (suggestions) {
156
+ for (const s of suggestions) {
157
+ acceptor(context, {
158
+ label: s.label,
159
+ kind: CompletionItemKind.Snippet,
160
+ detail: s.detail,
161
+ insertText: s.insertText,
162
+ insertTextFormat: InsertTextFormat.Snippet,
163
+ sortText: "1" + s.label,
164
+ });
165
+ }
166
+ }
167
+ }
168
+ return false;
169
+ }
170
+ // ─── Lazy per-request helpers ─────────────────────────
171
+ getAllWorkspaceText() {
172
+ if (this.snapshot.allText !== undefined)
173
+ return this.snapshot.allText;
174
+ const docs = this.langiumServices.shared.workspace.LangiumDocuments.all;
175
+ this.snapshot.allText = docs
176
+ .filter((d) => d.uri.path.endsWith(".ec"))
177
+ .map((d) => d.textDocument.getText())
178
+ .toArray()
179
+ .join("\n");
180
+ return this.snapshot.allText;
181
+ }
182
+ getFullLineContent(text, offset) {
183
+ const start = text.lastIndexOf("\n", offset - 1) + 1;
184
+ let end = text.indexOf("\n", offset);
185
+ if (end === -1)
186
+ end = text.length;
187
+ return text.substring(start, end);
188
+ }
189
+ getDocumentDir(uri) {
190
+ return dirname(fileURLToPath(uri));
191
+ }
192
+ /**
193
+ * Read the workspace directory listing once per request.
194
+ * Returns files (spec + .ec), directories, and the base dir.
195
+ */
196
+ getDirListing(currentUri) {
197
+ if (this.snapshot.dirListing)
198
+ return this.snapshot.dirListing;
199
+ try {
200
+ const currentPath = fileURLToPath(currentUri);
201
+ const dir = dirname(currentPath);
202
+ const currentFilename = currentPath.substring(currentPath.lastIndexOf("/") + 1);
203
+ const files = [];
204
+ const dirs = [];
205
+ for (const entry of readdirSync(dir)) {
206
+ if (entry === currentFilename)
207
+ continue;
208
+ if (entry.startsWith(".") || entry === "node_modules")
209
+ continue;
210
+ try {
211
+ if (statSync(resolve(dir, entry)).isDirectory()) {
212
+ dirs.push(entry);
213
+ }
214
+ else if (entry.endsWith(".ec") || isSpecFile(entry)) {
215
+ files.push(entry);
216
+ }
217
+ }
218
+ catch {
219
+ // skip unreadable entries
220
+ }
221
+ }
222
+ this.snapshot.dirListing = { dir, files, dirs };
223
+ return this.snapshot.dirListing;
224
+ }
225
+ catch {
226
+ this.snapshot.dirListing = { dir: "", files: [], dirs: [] };
227
+ return this.snapshot.dirListing;
166
228
  }
167
229
  }
230
+ getWorkspaceFilenames(currentUri) {
231
+ const listing = this.getDirListing(currentUri);
232
+ if (listing.files.length > 0)
233
+ return listing.files;
234
+ const docs = this.langiumServices.shared.workspace.LangiumDocuments.all;
235
+ return docs
236
+ .filter((d) => d.uri.toString() !== currentUri)
237
+ .map((d) => {
238
+ const p = d.uri.path;
239
+ return p.substring(p.lastIndexOf("/") + 1);
240
+ })
241
+ .toArray();
242
+ }
243
+ readSpecFile(specPath, currentDocUri) {
244
+ try {
245
+ const dir = this.getDocumentDir(currentDocUri);
246
+ const normalizedPath = specPath.replace(/^\.\//, "");
247
+ const fullPath = resolve(dir, normalizedPath);
248
+ return readFileSync(fullPath, "utf-8");
249
+ }
250
+ catch {
251
+ return undefined;
252
+ }
253
+ }
254
+ parseWorkspaceSpecs(currentDocUri) {
255
+ if (this.snapshot.parsedSpecs)
256
+ return this.snapshot.parsedSpecs;
257
+ const parsed = new Map();
258
+ const listing = this.getDirListing(currentDocUri);
259
+ for (const filename of listing.files) {
260
+ if (!isSpecFile(filename))
261
+ continue;
262
+ try {
263
+ const content = readFileSync(resolve(listing.dir, filename), "utf-8");
264
+ parsed.set(filename, parseSpecAuto(content));
265
+ }
266
+ catch {
267
+ // skip unreadable or invalid files
268
+ }
269
+ }
270
+ this.snapshot.parsedSpecs = parsed;
271
+ return parsed;
272
+ }
273
+ // 1. Complete import file paths
274
+ completeImportPaths(context, acceptor) {
275
+ const filenames = this.getWorkspaceFilenames(context.textDocument.uri);
276
+ for (const filename of filenames) {
277
+ acceptor(context, {
278
+ label: `./${filename}`,
279
+ kind: CompletionItemKind.File,
280
+ detail: `Import from ${filename}`,
281
+ insertText: `./${filename}`,
282
+ sortText: `0${filename}`,
283
+ });
284
+ }
285
+ // Also suggest directories as potential catalog imports
286
+ const listing = this.getDirListing(context.textDocument.uri);
287
+ for (const dirName of listing.dirs) {
288
+ acceptor(context, {
289
+ label: `./${dirName}`,
290
+ kind: CompletionItemKind.Folder,
291
+ detail: `Import from catalog directory ${dirName}`,
292
+ insertText: `./${dirName}`,
293
+ sortText: `1${dirName}`,
294
+ });
295
+ }
296
+ }
297
+ // 2. Complete resource names inside import { ... }
298
+ async completeImportNames(context, acceptor, resourceKind, alreadyTyped, fullLineContent) {
299
+ const alreadyImported = new Set(alreadyTyped
300
+ .split(",")
301
+ .map((s) => s.trim())
302
+ .filter(Boolean));
303
+ const fromSpecMatch = fullLineContent.match(/from\s*"([^"]+\.(?:ya?ml|json))"/);
304
+ if (resourceKind && fromSpecMatch) {
305
+ const specContent = this.readSpecFile(fromSpecMatch[1], context.textDocument.uri);
306
+ if (specContent) {
307
+ try {
308
+ const parsed = parseSpecAuto(specContent);
309
+ const catalog = resourceKind === "channels" ? parsed.channels : parsed.messages;
310
+ for (const [name, resource] of catalog) {
311
+ if (!alreadyImported.has(name)) {
312
+ acceptor(context, {
313
+ label: name,
314
+ kind: CompletionItemKind.Field,
315
+ detail: resource.summary || `Import from ${fromSpecMatch[1]}`,
316
+ insertText: name,
317
+ sortText: `0${name}`,
318
+ });
319
+ }
320
+ }
321
+ return;
322
+ }
323
+ catch {
324
+ // Fall through to generic suggestions
325
+ }
326
+ }
327
+ }
328
+ // Catalog directory imports: import events { ... } from "./my-catalog"
329
+ const fromCatalogMatch = fullLineContent.match(/from\s*"([^"]+)"/);
330
+ if (resourceKind &&
331
+ fromCatalogMatch &&
332
+ isCatalogPath(fromCatalogMatch[1])) {
333
+ await this.completeCatalogImportNames(context, acceptor, resourceKind, fromCatalogMatch[1], alreadyImported);
334
+ return;
335
+ }
336
+ const allText = this.getAllWorkspaceText();
337
+ const ecResourceTypes = [
338
+ "service",
339
+ "event",
340
+ "command",
341
+ "query",
342
+ "domain",
343
+ "channel",
344
+ "flow",
345
+ "container",
346
+ ];
347
+ const resources = new Set();
348
+ for (const type of ecResourceTypes) {
349
+ for (const name of collectRegexMatches(new RegExp(`\\b${type}\\s+([a-zA-Z_][a-zA-Z0-9_.\\-]*)\\s*\\{`, "g"), allText)) {
350
+ if (!alreadyImported.has(name)) {
351
+ resources.add(name);
352
+ }
353
+ }
354
+ }
355
+ for (const name of resources) {
356
+ acceptor(context, {
357
+ label: name,
358
+ kind: CompletionItemKind.Class,
359
+ detail: "Resource to import",
360
+ insertText: name,
361
+ sortText: `0${name}`,
362
+ });
363
+ }
364
+ }
365
+ // 2b. Complete resource names from a catalog directory import
366
+ async completeCatalogImportNames(context, acceptor, resourceKind, catalogPath, alreadyImported) {
367
+ try {
368
+ const dir = this.getDocumentDir(context.textDocument.uri);
369
+ const catalogDir = resolve(dir, catalogPath);
370
+ if (resourceKind === "services") {
371
+ const { services } = await parseCatalogServices(catalogDir);
372
+ for (const [name, svc] of services) {
373
+ if (!alreadyImported.has(name)) {
374
+ acceptor(context, {
375
+ label: name,
376
+ kind: CompletionItemKind.Class,
377
+ detail: svc.summary || `Import from catalog ${catalogPath}`,
378
+ insertText: name,
379
+ sortText: `0${name}`,
380
+ });
381
+ }
382
+ }
383
+ return;
384
+ }
385
+ if (resourceKind === "channels") {
386
+ const { channels } = await parseCatalogChannels(catalogDir);
387
+ for (const [name, ch] of channels) {
388
+ if (!alreadyImported.has(name)) {
389
+ acceptor(context, {
390
+ label: name,
391
+ kind: CompletionItemKind.Field,
392
+ detail: ch.summary || `Import from catalog ${catalogPath}`,
393
+ insertText: name,
394
+ sortText: `0${name}`,
395
+ });
396
+ }
397
+ }
398
+ return;
399
+ }
400
+ const { messages } = await parseCatalogResources(catalogDir, resourceKind);
401
+ for (const [name, resource] of messages) {
402
+ if (!alreadyImported.has(name)) {
403
+ acceptor(context, {
404
+ label: name,
405
+ kind: CompletionItemKind.Field,
406
+ detail: resource.summary || `Import from catalog ${catalogPath}`,
407
+ insertText: name,
408
+ sortText: `0${name}`,
409
+ });
410
+ }
411
+ }
412
+ }
413
+ catch {
414
+ // Silently ignore errors in completions
415
+ }
416
+ }
417
+ // 3. Complete channel names
418
+ completeChannelNames(context, acceptor) {
419
+ const allText = this.getAllWorkspaceText();
420
+ const channels = collectChannelNames(allText);
421
+ const parsedSpecs = this.parseWorkspaceSpecs(context.textDocument.uri);
422
+ const channelSummaries = new Map();
423
+ for (const [, parsed] of parsedSpecs) {
424
+ for (const [name, resource] of parsed.channels) {
425
+ channels.add(name);
426
+ if (resource.summary)
427
+ channelSummaries.set(name, resource.summary);
428
+ }
429
+ }
430
+ for (const name of channels) {
431
+ acceptor(context, {
432
+ label: name,
433
+ kind: CompletionItemKind.Field,
434
+ detail: channelSummaries.get(name) || `channel ${name}`,
435
+ insertText: name,
436
+ sortText: `0${name}`,
437
+ });
438
+ }
439
+ }
440
+ // 4. Complete message names (event/command/query)
441
+ completeMessageNames(context, acceptor, msgType) {
442
+ const pluralType = MESSAGE_TYPE_PLURAL[msgType] || "events";
443
+ const allText = this.getAllWorkspaceText();
444
+ const names = collectMessageNames(allText, msgType, pluralType);
445
+ const parsedSpecs = this.parseWorkspaceSpecs(context.textDocument.uri);
446
+ const msgSummaries = new Map();
447
+ for (const [, parsed] of parsedSpecs) {
448
+ for (const [name, resource] of parsed.messages) {
449
+ names.add(name);
450
+ if (resource.summary)
451
+ msgSummaries.set(name, resource.summary);
452
+ }
453
+ }
454
+ for (const name of names) {
455
+ acceptor(context, {
456
+ label: name,
457
+ kind: CompletionItemKind.Field,
458
+ detail: msgSummaries.get(name) || `${msgType} ${name}`,
459
+ insertText: name,
460
+ sortText: `0${name}`,
461
+ });
462
+ }
463
+ }
464
+ // 5. Complete versions after @
465
+ completeVersions(context, acceptor, msgType, resourceName) {
466
+ const allText = this.getAllWorkspaceText();
467
+ const key = `${msgType}:${resourceName}`;
468
+ const versions = extractResourceVersions(allText, msgType).get(key) || [];
469
+ for (const ver of versions) {
470
+ acceptor(context, {
471
+ label: ver,
472
+ kind: CompletionItemKind.Value,
473
+ detail: `Version ${ver} of ${resourceName}`,
474
+ insertText: ver,
475
+ sortText: `0${ver}`,
476
+ });
477
+ }
478
+ }
479
+ // ─── Annotation completions ────────────────────────────
168
480
  isAnnotationNameContext(context, next) {
169
- // Check if the next expected feature is the AnnotationName rule call
170
481
  const feature = next.feature;
171
482
  if ("rule" in feature && "$type" in feature) {
172
483
  const ruleCall = feature;
@@ -175,7 +486,6 @@ export class EcCompletionProvider extends DefaultCompletionProvider {
175
486
  return true;
176
487
  }
177
488
  }
178
- // Also detect if we just typed '@' by checking the text before cursor
179
489
  const text = context.textDocument.getText();
180
490
  const beforeCursor = text.substring(Math.max(0, context.offset - 1), context.offset);
181
491
  if (beforeCursor === "@") {
@@ -184,13 +494,14 @@ export class EcCompletionProvider extends DefaultCompletionProvider {
184
494
  return false;
185
495
  }
186
496
  completeAnnotationNames(context, acceptor) {
187
- for (const annotation of KNOWN_ANNOTATIONS) {
497
+ for (const annotation of ANNOTATION_SUGGESTIONS) {
188
498
  acceptor(context, {
189
- label: annotation.name,
190
- kind: CompletionItemKind.Property,
191
- detail: "Annotation",
192
- documentation: annotation.description,
193
- sortText: "0" + annotation.name,
499
+ label: annotation.label,
500
+ kind: CompletionItemKind.Snippet,
501
+ detail: annotation.detail,
502
+ insertText: annotation.insertText,
503
+ insertTextFormat: InsertTextFormat.Snippet,
504
+ sortText: "0" + annotation.label,
194
505
  });
195
506
  }
196
507
  }