@xano/xanoscript-language-server 12.16.6 → 12.18.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 (40) hide show
  1. package/cache/documentCache.js +12 -5
  2. package/language-server-worker.js +6 -0
  3. package/lexer/mcp.js +109 -0
  4. package/lexer/policy.js +30 -0
  5. package/lexer/tokens.js +14 -0
  6. package/onCompletion/contentAssist.js +6 -0
  7. package/onCompletion/onCompletion.js +33 -3
  8. package/onCompletion/policyCompletion.js +339 -0
  9. package/onHover/PolicyMessageProvider.js +142 -0
  10. package/onHover/functions.md +39 -0
  11. package/onHover/onHover.js +2 -0
  12. package/package.json +3 -1
  13. package/parser/base_parser.js +2 -0
  14. package/parser/clauses/inputClause.js +13 -4
  15. package/parser/clauses/mcpMetadataClauses.js +252 -0
  16. package/parser/clauses/nakedStackFn.js +8 -0
  17. package/parser/clauses/register.js +16 -0
  18. package/parser/definitions/columnDefinition.js +1 -0
  19. package/parser/definitions/enumColumnDefinition.js +1 -0
  20. package/parser/definitions/objectColumnDefinition.js +1 -0
  21. package/parser/functions/mcp/elicitLint.js +100 -0
  22. package/parser/functions/mcp/mcpElicitFn.js +103 -0
  23. package/parser/functions/mcp/mcpProgressFn.js +34 -0
  24. package/parser/functions/mcp/register.js +11 -0
  25. package/parser/functions/mcpFn.js +19 -0
  26. package/parser/functions/register.js +4 -0
  27. package/parser/mcp_primitive_parser.js +343 -0
  28. package/parser/mcp_server_parser.js +53 -0
  29. package/parser/multidoc.js +45 -13
  30. package/parser/parser.js +58 -0
  31. package/parser/policy/catalogue.js +100 -0
  32. package/parser/policy/catalogueSnapshot.js +7075 -0
  33. package/parser/policy/policyDocs.js +76 -0
  34. package/parser/policy/policyLiteral.js +227 -0
  35. package/parser/policy/validatePolicyRule.js +323 -0
  36. package/parser/policy_parser.js +463 -0
  37. package/parser/register.js +21 -0
  38. package/parser/tool_parser.js +32 -0
  39. package/server.js +6 -0
  40. package/utils.js +6 -2
@@ -1,6 +1,7 @@
1
1
  import { lexDocument } from "../lexer/lexer.js";
2
- import { multidocParser } from "../parser/multidoc.js";
2
+ import { multidocParser, splitMultidoc } from "../parser/multidoc.js";
3
3
  import { xanoscriptParser } from "../parser/parser.js";
4
+ import { policyCatalogueVersion } from "../parser/policy/catalogue.js";
4
5
  import { getSchemeFromContent } from "../utils.js";
5
6
 
6
7
  /**
@@ -28,12 +29,15 @@ class DocumentCache {
28
29
  getOrParse(uri, version, text) {
29
30
  const cached = this.cache.get(uri);
30
31
  const textLength = text.length;
32
+ // A policy's diagnostics depend on the check catalogue as well as the text.
33
+ const catalogueVersion = policyCatalogueVersion();
31
34
 
32
35
  // Check version and text length to detect same URI with different content (e.g., in tests)
33
36
  if (
34
37
  cached &&
35
38
  cached.version === version &&
36
- cached.textLength === textLength
39
+ cached.textLength === textLength &&
40
+ cached.catalogueVersion === catalogueVersion
37
41
  ) {
38
42
  return {
39
43
  lexResult: cached.scheme === "multidoc" ? { tokens: [] } : lexDocument(text),
@@ -42,12 +46,14 @@ class DocumentCache {
42
46
  };
43
47
  }
44
48
 
45
- // Multidoc detection — split on \n---\n separator
46
- if (text.includes("\n---\n")) {
47
- const parserState = multidocParser(text);
49
+ // Multidoc detection — split with splitMultidoc (a `---` line outside a triple-quoted string)
50
+ const segments = splitMultidoc(text);
51
+ if (segments.length > 1) {
52
+ const parserState = multidocParser(text, segments);
48
53
  const cacheEntry = {
49
54
  version,
50
55
  textLength,
56
+ catalogueVersion,
51
57
  parserState,
52
58
  scheme: "multidoc",
53
59
  };
@@ -124,6 +130,7 @@ class DocumentCache {
124
130
  const cacheEntry = {
125
131
  version,
126
132
  textLength,
133
+ catalogueVersion,
127
134
  parserState,
128
135
  scheme,
129
136
  };
@@ -12,6 +12,12 @@ import { onHover } from "./onHover/onHover.js";
12
12
  import { onSemanticCheck } from "./onSemanticCheck/onSemanticCheck.js";
13
13
  import { TOKEN_TYPES } from "./onSemanticCheck/tokens.js";
14
14
  import pkg from "./package.json" with { type: "json" };
15
+ import { setPolicyCatalogue } from "./parser/policy/catalogue.js";
16
+ import policyCatalogueSnapshot from "./parser/policy/catalogueSnapshot.js";
17
+
18
+ // Policy rules are checked against the check catalogue this package was built with (see
19
+ // scripts/gen-policy-catalogue.mjs); what it finds is a warning, since the instance may be newer.
20
+ setPolicyCatalogue(policyCatalogueSnapshot, { source: "snapshot" });
15
21
 
16
22
  const messageReader = new BrowserMessageReader(self);
17
23
  const messageWriter = new BrowserMessageWriter(self);
package/lexer/mcp.js ADDED
@@ -0,0 +1,109 @@
1
+ import { Identifier } from "./identifier.js";
2
+ import { createTokenByName } from "./utils.js";
3
+
4
+ // MCP 2026-07-28 authoring keywords: the `prompt` and `resource` object kinds,
5
+ // the tool/prompt/resource metadata keys, and the `mcp.elicit` / `mcp.progress`
6
+ // statements. Grounded against cloud-client's schema DSL
7
+ // (`script/kind/schema/core/{prompt,resource,tool,mcp_server}.yaml` and
8
+ // `script/kind/schema/statement/mcp.{elicit,progress}.yaml`).
9
+ //
10
+ // Ordering note: a keyword with `longer_alt: Identifier` loses to an EARLIER
11
+ // shorter token, so `prompts` must precede `prompt`, and `resource` is lexed
12
+ // after `resources` (MicroserviceTokens, reused by the mcp_server parser).
13
+
14
+ // "prompts"
15
+ export const PromptsToken = createTokenByName("prompts", {
16
+ longer_alt: Identifier,
17
+ categories: [Identifier],
18
+ });
19
+
20
+ // "prompt"
21
+ export const PromptToken = createTokenByName("prompt", {
22
+ longer_alt: Identifier,
23
+ categories: [Identifier],
24
+ });
25
+
26
+ // "resource"
27
+ export const ResourceToken = createTokenByName("resource", {
28
+ longer_alt: Identifier,
29
+ categories: [Identifier],
30
+ });
31
+
32
+ // "title"
33
+ export const TitleToken = createTokenByName("title", {
34
+ longer_alt: Identifier,
35
+ categories: [Identifier],
36
+ });
37
+
38
+ // "annotations"
39
+ export const AnnotationsToken = createTokenByName("annotations", {
40
+ longer_alt: Identifier,
41
+ categories: [Identifier],
42
+ });
43
+
44
+ // "icons"
45
+ export const IconsToken = createTokenByName("icons", {
46
+ longer_alt: Identifier,
47
+ categories: [Identifier],
48
+ });
49
+
50
+ // "uri"
51
+ export const UriToken = createTokenByName("uri", {
52
+ longer_alt: Identifier,
53
+ categories: [Identifier],
54
+ });
55
+
56
+ // "mime_type"
57
+ export const MimeTypeToken = createTokenByName("mime_type", {
58
+ longer_alt: Identifier,
59
+ categories: [Identifier],
60
+ });
61
+
62
+ // "elicit" (mcp.elicit)
63
+ export const ElicitToken = createTokenByName("elicit", {
64
+ longer_alt: Identifier,
65
+ categories: [Identifier],
66
+ });
67
+
68
+ // "progress" (mcp.progress)
69
+ export const ProgressToken = createTokenByName("progress", {
70
+ longer_alt: Identifier,
71
+ categories: [Identifier],
72
+ });
73
+
74
+ export const McpObjectTokens = [
75
+ PromptsToken,
76
+ PromptToken,
77
+ ResourceToken,
78
+ TitleToken,
79
+ AnnotationsToken,
80
+ IconsToken,
81
+ UriToken,
82
+ MimeTypeToken,
83
+ ElicitToken,
84
+ ProgressToken,
85
+ ];
86
+
87
+ /**
88
+ * Maps a token name to a type
89
+ * @param {string} token the token name
90
+ */
91
+ export function mapTokenToType(token) {
92
+ switch (token) {
93
+ case PromptToken.name:
94
+ case ResourceToken.name:
95
+ case PromptsToken.name:
96
+ return "keyword";
97
+ case TitleToken.name:
98
+ case AnnotationsToken.name:
99
+ case IconsToken.name:
100
+ case UriToken.name:
101
+ case MimeTypeToken.name:
102
+ return "variable";
103
+ case ElicitToken.name:
104
+ case ProgressToken.name:
105
+ return "function";
106
+ default:
107
+ return null;
108
+ }
109
+ }
@@ -0,0 +1,30 @@
1
+ import { Identifier } from "./identifier.js";
2
+ import { createTokenByName } from "./utils.js";
3
+
4
+ // "policy"
5
+ export const PolicyToken = createTokenByName("policy", {
6
+ longer_alt: Identifier,
7
+ categories: [Identifier],
8
+ });
9
+
10
+ // "rule"
11
+ export const RuleToken = createTokenByName("rule", {
12
+ longer_alt: Identifier,
13
+ categories: [Identifier],
14
+ });
15
+
16
+ export const PolicyTokens = [PolicyToken, RuleToken];
17
+
18
+ /**
19
+ * Maps a token name to a type
20
+ * @param {string} token the token name
21
+ */
22
+ export function mapTokenToType(token) {
23
+ switch (token) {
24
+ case PolicyToken.name:
25
+ case RuleToken.name:
26
+ return "keyword";
27
+ default:
28
+ return null;
29
+ }
30
+ }
package/lexer/tokens.js CHANGED
@@ -84,6 +84,10 @@ import {
84
84
  mapTokenToType as mapLiteralTokenToType,
85
85
  } from "./literal.js";
86
86
  import { mapTokenToType as mapMathTokenToType, MathTokens } from "./math.js";
87
+ import {
88
+ mapTokenToType as mapMcpObjectTokenToType,
89
+ McpObjectTokens,
90
+ } from "./mcp.js";
87
91
  import {
88
92
  mapTokenToType as mapMcpServerTokenToType,
89
93
  McpServerTokens,
@@ -112,6 +116,10 @@ import {
112
116
  mapTokenToType as mapObjectTokenToType,
113
117
  ObjectTokens,
114
118
  } from "./object.js";
119
+ import {
120
+ mapTokenToType as mapPolicyTokenToType,
121
+ PolicyTokens,
122
+ } from "./policy.js";
115
123
  import { mapTokenToType as mapQueryTokenToType, QueryTokens } from "./query.js";
116
124
  import {
117
125
  mapTokenToType as mapRealtimeTokenToType,
@@ -468,9 +476,13 @@ export const allTokens = uniq([
468
476
  ChannelServerToken,
469
477
  ...AgentTokens, // Must come before ToolTokens because of ToolsToken
470
478
  ...ToolTokens,
479
+ // MCP authoring keywords (prompt/resource kinds, tool metadata, mcp.*);
480
+ // after MicroserviceTokens so `resources` wins over `resource`.
481
+ ...McpObjectTokens,
471
482
  ...AddonTokens,
472
483
  ...ActionTokens,
473
484
  ...BranchTokens,
485
+ ...PolicyTokens,
474
486
  ...WorkspaceTokens,
475
487
  ViewToken,
476
488
  JsonInToken,
@@ -503,6 +515,7 @@ const tokenMappers = [
503
515
  mapFunctionTokenToType,
504
516
  mapLiteralTokenToType,
505
517
  mapMathTokenToType,
518
+ mapMcpObjectTokenToType,
506
519
  mapMcpServerTokenToType,
507
520
  mapMcpServerTriggerTokenToType,
508
521
  mapMessageTokenToType,
@@ -510,6 +523,7 @@ const tokenMappers = [
510
523
  mapMiddlewareTokenToType,
511
524
  mapMultilineTokenToType,
512
525
  mapObjectTokenToType,
526
+ mapPolicyTokenToType,
513
527
  mapQueryTokenToType,
514
528
  mapRealtimeTokenToType,
515
529
  mapRealtimeServerTriggerTokenToType,
@@ -5,6 +5,7 @@ import { FilterMessageProvider } from "../onHover/FilterMessageProvider.js";
5
5
  import { encodeTokenType } from "../onSemanticCheck/tokens.js";
6
6
  import { filterNames } from "../parser/generic/filterNames.js";
7
7
  import { parser } from "../parser/parser.js";
8
+ import { getPolicyCompletions } from "./policyCompletion.js";
8
9
 
9
10
  // FIXME: this should be imported from the onHover/FilterMessageProvider.js
10
11
  const filterDocsMarkdown = `
@@ -133,6 +134,11 @@ const filterSuggestions = Object.freeze(
133
134
  const MAX_CONTENT_ASSIST_TOKENS = 40;
134
135
 
135
136
  export function getContentAssistSuggestions(text, scheme) {
137
+ // A policy is read with a tolerant scanner and the check catalogue, not content assist.
138
+ if (scheme === "policy") {
139
+ return getPolicyCompletions(text);
140
+ }
141
+
136
142
  try {
137
143
  const lexResult = lexDocument(text);
138
144
 
@@ -43,6 +43,7 @@ export function onCompletion(params, documents) {
43
43
 
44
44
  let scheme;
45
45
  let prefix;
46
+ let lineOffset = 0;
46
47
 
47
48
  if (parser.isMultidoc) {
48
49
  const match = findSegmentAtOffset(parser, offset);
@@ -52,15 +53,21 @@ export function onCompletion(params, documents) {
52
53
  // parses backwards from the cursor to find context, so it needs the segment
53
54
  // text up to the local offset
54
55
  prefix = match.segment.text.slice(0, match.localOffset);
56
+ // A textEdit (policy completion) is in segment coordinates; a segment starts a line.
57
+ lineOffset = document.positionAt(match.segment.globalOffset).line;
55
58
  } else {
56
59
  scheme = getSchemeFromContent(text);
57
60
  prefix = text.slice(0, offset);
58
61
  }
59
62
 
60
- const suggestions = getContentAssistSuggestions(prefix, scheme);
63
+ const suggestions = shiftTextEdits(
64
+ getContentAssistSuggestions(prefix, scheme),
65
+ lineOffset
66
+ );
61
67
 
62
- // Add workspace object name completions
63
- const workspaceCompletions = getWorkspaceCompletions(prefix);
68
+ // Add workspace object name completions; a policy names no workspace object (its prose would
69
+ // match the reference patterns).
70
+ const workspaceCompletions = scheme === "policy" ? null : getWorkspaceCompletions(prefix);
64
71
  if (workspaceCompletions) {
65
72
  const existing = suggestions || [];
66
73
  return [...existing, ...workspaceCompletions];
@@ -69,6 +76,29 @@ export function onCompletion(params, documents) {
69
76
  return suggestions;
70
77
  }
71
78
 
79
+ /**
80
+ * Move each item's textEdit down by `lines`.
81
+ * @param {Array|null} items
82
+ * @param {number} lines
83
+ */
84
+ function shiftTextEdits(items, lines) {
85
+ if (!items || !lines) return items;
86
+ return items.map((item) => {
87
+ if (!item.textEdit) return item;
88
+ const { start, end } = item.textEdit.range;
89
+ return {
90
+ ...item,
91
+ textEdit: {
92
+ ...item.textEdit,
93
+ range: {
94
+ start: { line: start.line + lines, character: start.character },
95
+ end: { line: end.line + lines, character: end.character },
96
+ },
97
+ },
98
+ };
99
+ });
100
+ }
101
+
72
102
  /**
73
103
  * Get completions for cross-file references based on cursor context.
74
104
  * @param {string} prefix - Text before cursor
@@ -0,0 +1,339 @@
1
+ import { getPolicyCatalogue } from "../parser/policy/catalogue.js";
2
+ import {
3
+ checkMarkdown,
4
+ paramMarkdown,
5
+ paramValues,
6
+ } from "../parser/policy/policyDocs.js";
7
+ import { own, policyStringValue } from "../parser/policy/policyLiteral.js";
8
+
9
+ /**
10
+ * Completion inside a `policy` document, all of it read from the check catalogue: the policy's and
11
+ * a rule's fields and their closed values (the catalogue's `document`), `rule`, check ids, a
12
+ * check's params, an object param's fields and a param's closed values.
13
+ *
14
+ * The text before the cursor is usually unfinished (an open string, a half-typed key), which the
15
+ * grammar cannot parse, so this reads it with a small tolerant scanner: strings, words,
16
+ * brackets, `=`, `:`, `,` and line breaks, tracking which block each `{` or `[` opened.
17
+ *
18
+ * Items use LSP CompletionItemKind numbers and carry a `textEdit` whose range is in the
19
+ * coordinates of `prefix`, so a check id with dots replaces what was typed as a whole.
20
+ */
21
+
22
+ /** LSP CompletionItemKind. */
23
+ const KIND = {
24
+ Field: 5,
25
+ Property: 10,
26
+ Value: 12,
27
+ Keyword: 14,
28
+ EnumMember: 20,
29
+ };
30
+
31
+ /** A document field's closed values, as completion offers them (`bool` is true and false). */
32
+ function fieldValues(spec) {
33
+ if (spec?.type === "bool") return ["true", "false"];
34
+ return spec?.values ?? null;
35
+ }
36
+
37
+ /** Completion documentation for a document field. */
38
+ function fieldDoc(name, spec) {
39
+ if (!spec?.description) return undefined;
40
+ const values = fieldValues(spec);
41
+ const lines = [`**${name}** (${spec.type ?? "string"})`, "", spec.description];
42
+ if (values) lines.push("", `One of: ${values.map((v) => "`" + v + "`").join(", ")}.`);
43
+ if (spec.default !== undefined) lines.push("", `Default: \`${spec.default}\``);
44
+ return { kind: "markdown", value: lines.join("\n") };
45
+ }
46
+
47
+ /**
48
+ * Split the text before the cursor into the few token kinds completion needs.
49
+ * @param {string} text
50
+ */
51
+ function scan(text) {
52
+ const tokens = [];
53
+ let i = 0;
54
+ while (i < text.length) {
55
+ const c = text[i];
56
+ if (c === "\n") {
57
+ tokens.push({ t: "nl", start: i, end: i + 1 });
58
+ i++;
59
+ } else if (c === " " || c === "\t" || c === "\r") {
60
+ i++;
61
+ } else if (c === "/" && text[i + 1] === "/") {
62
+ while (i < text.length && text[i] !== "\n") i++;
63
+ } else if (text.startsWith('"""', i) || text.startsWith("'''", i)) {
64
+ const close = text.indexOf(text.slice(i, i + 3), i + 3);
65
+ const end = close === -1 ? text.length : close + 3;
66
+ tokens.push({
67
+ t: "str",
68
+ start: i,
69
+ end,
70
+ quote: 3,
71
+ open: close === -1,
72
+ value: text.slice(i + 3, close === -1 ? text.length : close),
73
+ });
74
+ i = end;
75
+ } else if (c === '"' || c === "'") {
76
+ let j = i + 1;
77
+ while (j < text.length && text[j] !== c && text[j] !== "\n") {
78
+ j += text[j] === "\\" ? 2 : 1;
79
+ }
80
+ const open = j >= text.length;
81
+ const end = open ? text.length : j + 1;
82
+ tokens.push({
83
+ t: "str",
84
+ start: i,
85
+ end,
86
+ quote: 1,
87
+ open,
88
+ value: text.slice(i + 1, open ? text.length : j),
89
+ });
90
+ i = end;
91
+ } else if ("{}[]=:,".includes(c)) {
92
+ tokens.push({ t: c, start: i, end: i + 1 });
93
+ i++;
94
+ } else if (/[\w$.-]/.test(c)) {
95
+ let j = i;
96
+ while (j < text.length && /[\w$.-]/.test(text[j])) j++;
97
+ tokens.push({ t: "word", start: i, end: j, value: text.slice(i, j) });
98
+ i = j;
99
+ } else {
100
+ tokens.push({ t: "other", start: i, end: i + 1 });
101
+ i++;
102
+ }
103
+ }
104
+ return tokens;
105
+ }
106
+
107
+ /** The first word on the line holding token `k` (`rule` in `rule {`, `policy` in `policy KEY {`). */
108
+ function lineHead(tokens, k) {
109
+ if (tokens[k - 1]?.t === "word" && tokens[k - 1].value === "rule" && tokens[k - 2]?.value !== "policy") return "rule";
110
+ let j = k;
111
+ while (j > 0 && tokens[j - 1].t !== "nl") j--;
112
+ return tokens[j]?.t === "word" ? tokens[j].value : null;
113
+ }
114
+
115
+ /** The key a value at token `k` belongs to: the word before the `=` or `:` just before it. */
116
+ function keyBefore(tokens, k) {
117
+ const sep = tokens[k - 1];
118
+ if (sep && (sep.t === "=" || sep.t === ":") && tokens[k - 2]?.t === "word") {
119
+ return tokens[k - 2].value;
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * Where the cursor is: the stack of open blocks, and what is being typed.
126
+ * @param {string} prefix
127
+ */
128
+ function contextOf(prefix) {
129
+ const tokens = scan(prefix);
130
+ const frames = [];
131
+ const top = () => frames[frames.length - 1];
132
+
133
+ tokens.forEach((token, k) => {
134
+ if (token.t === "{" || token.t === "[") {
135
+ frames.push({
136
+ open: token.t,
137
+ owner: keyBefore(tokens, k) ?? (token.t === "{" ? lineHead(tokens, k) : null),
138
+ keys: new Set(),
139
+ check: null,
140
+ });
141
+ } else if (token.t === "}" || token.t === "]") {
142
+ frames.pop();
143
+ } else if (token.t === "word" && (tokens[k + 1]?.t === "=" || tokens[k + 1]?.t === ":")) {
144
+ top()?.keys.add(token.value);
145
+ } else if (token.t === "str" && !token.open && keyBefore(tokens, k) === "check") {
146
+ if (top()) top().check = policyStringValue(prefix.slice(token.start, token.end)).trim();
147
+ }
148
+ });
149
+
150
+ const last = tokens[tokens.length - 1];
151
+ const touching = last && last.end === prefix.length;
152
+
153
+ // Typing inside a string: a value.
154
+ if (touching && last.t === "str" && last.open) {
155
+ const k = tokens.length - 1;
156
+ const prev = tokens[k - 1];
157
+ const inList = prev && (prev.t === "[" || prev.t === ",") && top()?.open === "[";
158
+ return {
159
+ frames,
160
+ kind: "value",
161
+ key: inList ? top().owner : keyBefore(tokens, k),
162
+ inList,
163
+ quoted: true,
164
+ start: last.start + last.quote,
165
+ };
166
+ }
167
+
168
+ // A bare word being typed: a key, or an unquoted value after `=` / `:`.
169
+ const typing = touching && last.t === "word" ? last : null;
170
+ const k = typing ? tokens.length - 1 : tokens.length;
171
+ const prev = tokens[k - 1];
172
+ if (!prev || prev.t === "nl" || prev.t === "{" || prev.t === ",") {
173
+ if (top()?.open === "[") return null;
174
+ return {
175
+ frames,
176
+ kind: "key",
177
+ start: typing ? typing.start : prefix.length,
178
+ };
179
+ }
180
+ if (prev.t === "=" || prev.t === ":") {
181
+ return {
182
+ frames,
183
+ kind: "value",
184
+ key: keyBefore(tokens, k),
185
+ inList: false,
186
+ quoted: false,
187
+ start: typing ? typing.start : prefix.length,
188
+ };
189
+ }
190
+ if (prev.t === "[" && top()?.open === "[") {
191
+ return {
192
+ frames,
193
+ kind: "value",
194
+ key: top().owner,
195
+ inList: true,
196
+ quoted: false,
197
+ start: typing ? typing.start : prefix.length,
198
+ };
199
+ }
200
+ return null;
201
+ }
202
+
203
+ /** An LSP position within `text`. */
204
+ function positionAt(text, offset) {
205
+ const before = text.slice(0, offset);
206
+ const line = before.split("\n").length - 1;
207
+ return { line, character: offset - (before.lastIndexOf("\n") + 1) };
208
+ }
209
+
210
+ /**
211
+ * @param {string} prefix the document text before the cursor
212
+ * @returns {Object[]} LSP completion items; empty when there is nothing to offer here
213
+ */
214
+ export function getPolicyCompletions(prefix) {
215
+ const context = contextOf(prefix);
216
+ if (!context) return [];
217
+ const { frames } = context;
218
+ const catalogue = getPolicyCatalogue();
219
+ const range = {
220
+ start: positionAt(prefix, context.start),
221
+ end: positionAt(prefix, prefix.length),
222
+ };
223
+ const item = (label, kind, extra = {}) => ({
224
+ label,
225
+ kind,
226
+ ...extra,
227
+ textEdit: { range, newText: extra.newText ?? label },
228
+ });
229
+ const value = (label, kind, extra = {}) =>
230
+ item(label, kind, {
231
+ ...extra,
232
+ newText: context.quoted ? label : `"${label}"`,
233
+ filterText: context.quoted ? label : `"${label}"`,
234
+ });
235
+
236
+ const [policy, rule, params, nested] = frames;
237
+ if (!policy || policy.owner !== "policy") return [];
238
+ const ruleOpen = rule && rule.open === "{" && rule.owner === "rule";
239
+ const check =
240
+ ruleOpen && rule.check ? (catalogue.checks.get(rule.check) ?? null) : null;
241
+ const policyFields = catalogue.document?.fields ?? {};
242
+ const ruleFields = catalogue.document?.rule ?? {};
243
+ const fieldItem = (name, spec) =>
244
+ item(name, KIND.Property, {
245
+ detail: spec?.type,
246
+ documentation: fieldDoc(name, spec),
247
+ });
248
+
249
+ if (context.kind === "key") {
250
+ if (frames.length === 1) {
251
+ return [
252
+ ...Object.entries(policyFields)
253
+ .filter(([name]) => !policy.keys.has(name))
254
+ .map(([name, spec]) => fieldItem(name, spec)),
255
+ item("rule", KIND.Keyword, { detail: "rule { check = \"...\" }" }),
256
+ ];
257
+ }
258
+ if (frames.length === 2 && ruleOpen) {
259
+ return Object.entries(ruleFields)
260
+ .filter(([name]) => !rule.keys.has(name))
261
+ .map(([name, spec]) => fieldItem(name, spec));
262
+ }
263
+ if (frames.length === 3 && ruleOpen && params.owner === "params" && check) {
264
+ const applicable = new Set((check.scope_fields ?? []).map((field) => field.name));
265
+ return Object.entries(check.params ?? {})
266
+ .filter(([name]) => !params.keys.has(name))
267
+ .filter(([name]) => !catalogue.scopeFields.has(name) || applicable.has(name))
268
+ .map(([name, spec]) =>
269
+ item(name, KIND.Property, {
270
+ detail: `${spec.type ?? "string"}${spec.required ? ", required" : ""}`,
271
+ documentation: { kind: "markdown", value: paramMarkdown(check.id, name, spec) },
272
+ sortText: `${spec.required ? 0 : 1}${catalogue.scopeFields.has(name) ? 1 : 0}${name}`,
273
+ })
274
+ );
275
+ }
276
+ if (frames.length === 4 && ruleOpen && params.owner === "params" && check && nested.open === "{") {
277
+ const spec = own(check.params, nested.owner);
278
+ return Object.entries(spec?.properties ?? {})
279
+ .filter(([name]) => !name.startsWith("<") && !nested.keys.has(name))
280
+ .map(([name, property]) =>
281
+ item(name, KIND.Field, {
282
+ detail: property.type ?? "string",
283
+ documentation: {
284
+ kind: "markdown",
285
+ value: paramMarkdown(check.id, `${nested.owner}.${name}`, property),
286
+ },
287
+ })
288
+ );
289
+ }
290
+ return [];
291
+ }
292
+
293
+ // A value.
294
+ const closed = frames.length === 1 ? fieldValues(own(policyFields, context.key)) : null;
295
+ if (closed) {
296
+ const bool = policyFields[context.key].type === "bool";
297
+ if (bool && context.quoted) return [];
298
+ return closed.map((name) =>
299
+ bool
300
+ ? item(name, KIND.EnumMember, { newText: name, filterText: name })
301
+ : value(name, KIND.EnumMember)
302
+ );
303
+ }
304
+ if (frames.length === 2 && ruleOpen && context.key === "check") {
305
+ return catalogue.ids.map((id) => {
306
+ const entry = catalogue.checks.get(id);
307
+ return value(id, KIND.Value, {
308
+ detail: entry.label,
309
+ documentation: { kind: "markdown", value: checkMarkdown(entry, catalogue) },
310
+ });
311
+ });
312
+ }
313
+ if (!check || params?.owner !== "params") return [];
314
+
315
+ // Inside params: the param (or its list items, or an object param's field) with closed values.
316
+ let spec = null;
317
+ let path = null;
318
+ if (frames.length === 3 && !context.inList) {
319
+ spec = own(check.params, context.key);
320
+ path = context.key;
321
+ } else if (frames.length === 4 && context.inList) {
322
+ spec = own(check.params, context.key);
323
+ path = context.key;
324
+ } else if (frames.length === 4 && nested.open === "{" && !context.inList) {
325
+ spec = own(own(check.params, nested.owner)?.properties, context.key);
326
+ path = `${nested.owner}.${context.key}`;
327
+ }
328
+ const bool = spec?.type === "bool";
329
+ if (bool && context.quoted) return [];
330
+ const values = bool ? ["true", "false"] : paramValues(spec);
331
+ if (!values) return [];
332
+ return values
333
+ .filter((option) => option !== "")
334
+ .map((option) =>
335
+ (bool ? item : value)(String(option), KIND.EnumMember, {
336
+ documentation: { kind: "markdown", value: paramMarkdown(check.id, path, spec) },
337
+ })
338
+ );
339
+ }