@eventcatalog/language-server 0.5.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.
- package/dist/browser.d.ts +22 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +20 -0
- package/dist/browser.js.map +1 -0
- package/dist/compiler.js +1 -1
- package/dist/compiler.js.map +1 -1
- package/dist/completion-data.d.ts +15 -0
- package/dist/completion-data.d.ts.map +1 -0
- package/dist/completion-data.js +453 -0
- package/dist/completion-data.js.map +1 -0
- package/dist/completion-utils.d.ts +29 -0
- package/dist/completion-utils.d.ts.map +1 -0
- package/dist/completion-utils.js +128 -0
- package/dist/completion-utils.js.map +1 -0
- package/dist/ec-completion-provider.d.ts +22 -1
- package/dist/ec-completion-provider.d.ts.map +1 -1
- package/dist/ec-completion-provider.js +461 -150
- package/dist/ec-completion-provider.js.map +1 -1
- package/dist/ec-definition-provider.d.ts +14 -0
- package/dist/ec-definition-provider.d.ts.map +1 -0
- package/dist/ec-definition-provider.js +123 -0
- package/dist/ec-definition-provider.js.map +1 -0
- package/dist/ec-formatter-provider.d.ts +10 -0
- package/dist/ec-formatter-provider.d.ts.map +1 -0
- package/dist/ec-formatter-provider.js +28 -0
- package/dist/ec-formatter-provider.js.map +1 -0
- package/dist/ec-hover-provider.d.ts +10 -0
- package/dist/ec-hover-provider.d.ts.map +1 -0
- package/dist/ec-hover-provider.js +41 -0
- package/dist/ec-hover-provider.js.map +1 -0
- package/dist/ec-module.d.ts +0 -6
- package/dist/ec-module.d.ts.map +1 -1
- package/dist/ec-module.js +0 -10
- package/dist/ec-module.js.map +1 -1
- package/dist/ec-resource-index.d.ts +64 -0
- package/dist/ec-resource-index.d.ts.map +1 -0
- package/dist/ec-resource-index.js +281 -0
- package/dist/ec-resource-index.js.map +1 -0
- package/dist/ec-validator.d.ts.map +1 -1
- package/dist/ec-validator.js +22 -87
- package/dist/ec-validator.js.map +1 -1
- package/dist/generated/ast.d.ts +2 -2
- package/dist/generated/ast.d.ts.map +1 -1
- package/dist/generated/ast.js +1 -1
- package/dist/generated/ast.js.map +1 -1
- package/dist/generated/grammar.d.ts.map +1 -1
- package/dist/generated/grammar.js +103 -53
- package/dist/generated/grammar.js.map +1 -1
- package/dist/graph.d.ts.map +1 -1
- package/dist/graph.js +83 -35
- package/dist/graph.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/main.js +16 -1
- package/dist/main.js.map +1 -1
- package/dist/resolvers/asyncapi.d.ts +10 -16
- package/dist/resolvers/asyncapi.d.ts.map +1 -1
- package/dist/resolvers/asyncapi.js +60 -235
- package/dist/resolvers/asyncapi.js.map +1 -1
- package/dist/resolvers/catalog.d.ts +34 -0
- package/dist/resolvers/catalog.d.ts.map +1 -0
- package/dist/resolvers/catalog.js +291 -0
- package/dist/resolvers/catalog.js.map +1 -0
- package/dist/resolvers/index.d.ts +8 -1
- package/dist/resolvers/index.d.ts.map +1 -1
- package/dist/resolvers/index.js +7 -1
- package/dist/resolvers/index.js.map +1 -1
- package/dist/resolvers/openapi.d.ts +44 -0
- package/dist/resolvers/openapi.d.ts.map +1 -0
- package/dist/resolvers/openapi.js +212 -0
- package/dist/resolvers/openapi.js.map +1 -0
- package/dist/resolvers/resolve.d.ts +28 -0
- package/dist/resolvers/resolve.d.ts.map +1 -0
- package/dist/resolvers/resolve.js +485 -0
- package/dist/resolvers/resolve.js.map +1 -0
- package/dist/resolvers/types.d.ts +17 -1
- package/dist/resolvers/types.d.ts.map +1 -1
- package/dist/resolvers/types.js +11 -1
- package/dist/resolvers/types.js.map +1 -1
- package/package.json +7 -2
- package/specification/03-event.md +1 -0
- package/specification/04-command.md +4 -1
- package/specification/05-query.md +4 -1
- package/specification/06-channel.md +24 -4
- package/specification/16-annotations.md +52 -0
- 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;
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
//
|
|
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
|
|
41
|
+
// Skip '@' itself — we handle annotation names in completionFor
|
|
142
42
|
if (kw === "@") {
|
|
143
43
|
return;
|
|
144
44
|
}
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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:
|
|
72
|
+
label: kw,
|
|
160
73
|
kind: CompletionItemKind.Snippet,
|
|
161
|
-
detail:
|
|
162
|
-
insertText:
|
|
74
|
+
detail: resourceSuggestion.detail,
|
|
75
|
+
insertText: resourceSuggestion.insertText,
|
|
163
76
|
insertTextFormat: InsertTextFormat.Snippet,
|
|
164
|
-
sortText: "
|
|
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
|
|
497
|
+
for (const annotation of ANNOTATION_SUGGESTIONS) {
|
|
188
498
|
acceptor(context, {
|
|
189
|
-
label: annotation.
|
|
190
|
-
kind: CompletionItemKind.
|
|
191
|
-
detail:
|
|
192
|
-
|
|
193
|
-
|
|
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
|
}
|