@xano/xanoscript-language-server 12.17.0 → 12.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cache/documentCache.js +12 -5
- package/language-server-worker.js +6 -0
- package/lexer/mcp.js +26 -1
- package/lexer/policy.js +30 -0
- package/lexer/tokens.js +6 -0
- package/onCompletion/contentAssist.js +6 -0
- package/onCompletion/onCompletion.js +33 -3
- package/onCompletion/policyCompletion.js +339 -0
- package/onHover/PolicyMessageProvider.js +142 -0
- package/onHover/functions.md +43 -0
- package/onHover/onHover.js +2 -0
- package/package.json +3 -1
- package/parser/functions/mcp/mcpOauthFn.js +140 -0
- package/parser/functions/mcp/register.js +10 -0
- package/parser/functions/mcpFn.js +2 -1
- package/parser/mcp_server_parser.js +48 -1
- package/parser/multidoc.js +45 -13
- package/parser/parser.js +52 -0
- package/parser/policy/catalogue.js +100 -0
- package/parser/policy/catalogueSnapshot.js +7075 -0
- package/parser/policy/policyDocs.js +76 -0
- package/parser/policy/policyLiteral.js +227 -0
- package/parser/policy/validatePolicyRule.js +323 -0
- package/parser/policy_parser.js +463 -0
- package/parser/register.js +12 -0
- package/server.js +6 -0
- package/utils.js +4 -2
package/cache/documentCache.js
CHANGED
|
@@ -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
|
|
46
|
-
|
|
47
|
-
|
|
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
CHANGED
|
@@ -3,7 +3,8 @@ import { createTokenByName } from "./utils.js";
|
|
|
3
3
|
|
|
4
4
|
// MCP 2026-07-28 authoring keywords: the `prompt` and `resource` object kinds,
|
|
5
5
|
// the tool/prompt/resource metadata keys, and the `mcp.elicit` / `mcp.progress`
|
|
6
|
-
// statements
|
|
6
|
+
// statements, plus the per-server `oauth` block and the `mcp.oauth.complete` /
|
|
7
|
+
// `mcp.oauth.request` / `mcp.oauth.revoke` statements. Grounded against cloud-client's schema DSL
|
|
7
8
|
// (`script/kind/schema/core/{prompt,resource,tool,mcp_server}.yaml` and
|
|
8
9
|
// `script/kind/schema/statement/mcp.{elicit,progress}.yaml`).
|
|
9
10
|
//
|
|
@@ -71,6 +72,24 @@ export const ProgressToken = createTokenByName("progress", {
|
|
|
71
72
|
categories: [Identifier],
|
|
72
73
|
});
|
|
73
74
|
|
|
75
|
+
// "oauth" (the mcp_server `oauth` block and the `mcp.oauth.*` statements)
|
|
76
|
+
export const OauthToken = createTokenByName("oauth", {
|
|
77
|
+
longer_alt: Identifier,
|
|
78
|
+
categories: [Identifier],
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// "complete" (mcp.oauth.complete)
|
|
82
|
+
export const CompleteToken = createTokenByName("complete", {
|
|
83
|
+
longer_alt: Identifier,
|
|
84
|
+
categories: [Identifier],
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// "revoke" (mcp.oauth.revoke)
|
|
88
|
+
export const RevokeToken = createTokenByName("revoke", {
|
|
89
|
+
longer_alt: Identifier,
|
|
90
|
+
categories: [Identifier],
|
|
91
|
+
});
|
|
92
|
+
|
|
74
93
|
export const McpObjectTokens = [
|
|
75
94
|
PromptsToken,
|
|
76
95
|
PromptToken,
|
|
@@ -82,6 +101,9 @@ export const McpObjectTokens = [
|
|
|
82
101
|
MimeTypeToken,
|
|
83
102
|
ElicitToken,
|
|
84
103
|
ProgressToken,
|
|
104
|
+
OauthToken,
|
|
105
|
+
CompleteToken,
|
|
106
|
+
RevokeToken,
|
|
85
107
|
];
|
|
86
108
|
|
|
87
109
|
/**
|
|
@@ -99,9 +121,12 @@ export function mapTokenToType(token) {
|
|
|
99
121
|
case IconsToken.name:
|
|
100
122
|
case UriToken.name:
|
|
101
123
|
case MimeTypeToken.name:
|
|
124
|
+
case OauthToken.name:
|
|
102
125
|
return "variable";
|
|
103
126
|
case ElicitToken.name:
|
|
104
127
|
case ProgressToken.name:
|
|
128
|
+
case CompleteToken.name:
|
|
129
|
+
case RevokeToken.name:
|
|
105
130
|
return "function";
|
|
106
131
|
default:
|
|
107
132
|
return null;
|
package/lexer/policy.js
ADDED
|
@@ -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
|
@@ -116,6 +116,10 @@ import {
|
|
|
116
116
|
mapTokenToType as mapObjectTokenToType,
|
|
117
117
|
ObjectTokens,
|
|
118
118
|
} from "./object.js";
|
|
119
|
+
import {
|
|
120
|
+
mapTokenToType as mapPolicyTokenToType,
|
|
121
|
+
PolicyTokens,
|
|
122
|
+
} from "./policy.js";
|
|
119
123
|
import { mapTokenToType as mapQueryTokenToType, QueryTokens } from "./query.js";
|
|
120
124
|
import {
|
|
121
125
|
mapTokenToType as mapRealtimeTokenToType,
|
|
@@ -478,6 +482,7 @@ export const allTokens = uniq([
|
|
|
478
482
|
...AddonTokens,
|
|
479
483
|
...ActionTokens,
|
|
480
484
|
...BranchTokens,
|
|
485
|
+
...PolicyTokens,
|
|
481
486
|
...WorkspaceTokens,
|
|
482
487
|
ViewToken,
|
|
483
488
|
JsonInToken,
|
|
@@ -518,6 +523,7 @@ const tokenMappers = [
|
|
|
518
523
|
mapMiddlewareTokenToType,
|
|
519
524
|
mapMultilineTokenToType,
|
|
520
525
|
mapObjectTokenToType,
|
|
526
|
+
mapPolicyTokenToType,
|
|
521
527
|
mapQueryTokenToType,
|
|
522
528
|
mapRealtimeTokenToType,
|
|
523
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 =
|
|
63
|
+
const suggestions = shiftTextEdits(
|
|
64
|
+
getContentAssistSuggestions(prefix, scheme),
|
|
65
|
+
lineOffset
|
|
66
|
+
);
|
|
61
67
|
|
|
62
|
-
// Add workspace object name completions
|
|
63
|
-
|
|
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
|
+
}
|