@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.
- package/cache/documentCache.js +12 -5
- package/language-server-worker.js +6 -0
- package/lexer/mcp.js +109 -0
- package/lexer/policy.js +30 -0
- package/lexer/tokens.js +14 -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 +39 -0
- package/onHover/onHover.js +2 -0
- package/package.json +3 -1
- package/parser/base_parser.js +2 -0
- package/parser/clauses/inputClause.js +13 -4
- package/parser/clauses/mcpMetadataClauses.js +252 -0
- package/parser/clauses/nakedStackFn.js +8 -0
- package/parser/clauses/register.js +16 -0
- package/parser/definitions/columnDefinition.js +1 -0
- package/parser/definitions/enumColumnDefinition.js +1 -0
- package/parser/definitions/objectColumnDefinition.js +1 -0
- package/parser/functions/mcp/elicitLint.js +100 -0
- package/parser/functions/mcp/mcpElicitFn.js +103 -0
- package/parser/functions/mcp/mcpProgressFn.js +34 -0
- package/parser/functions/mcp/register.js +11 -0
- package/parser/functions/mcpFn.js +19 -0
- package/parser/functions/register.js +4 -0
- package/parser/mcp_primitive_parser.js +343 -0
- package/parser/mcp_server_parser.js +53 -0
- package/parser/multidoc.js +45 -13
- package/parser/parser.js +58 -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 +21 -0
- package/parser/tool_parser.js +32 -0
- package/server.js +6 -0
- package/utils.js +6 -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
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
|
+
}
|
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
|
@@ -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 =
|
|
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
|
+
}
|