@px-lsp/server 0.3.0 → 0.3.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/README.md +1 -1
- package/data/ck3/skeletons.json +1 -0
- package/data/eu5/skeletons.json +1 -0
- package/data/vic3/skeletons.json +1 -0
- package/dist/browser-data/ck3/docs.json +1 -1
- package/dist/browser-data/ck3/tokens.json +1 -1
- package/dist/browser-data/vic3/tokens.json +1 -1
- package/dist/browser.js +50 -37
- package/dist/server.js +2000 -352
- package/dist/types/packages/server/src/features/blockSnippets.d.ts +20 -4
- package/dist/types/packages/server/src/features/completion.d.ts +11 -0
- package/dist/types/packages/server/src/features/definitionSkeletons.d.ts +24 -0
- package/dist/types/packages/server/src/games/ck3/schema.d.ts +1 -1
- package/dist/types/packages/server/src/games/eu5/index.d.ts +1 -1
- package/dist/types/packages/server/src/games/profile.d.ts +85 -1
- package/dist/types/packages/server/src/schema/skeletons.d.ts +102 -0
- package/dist/types/packages/server/src/schema/types.d.ts +30 -0
- package/package.json +3 -3
- package/src/coa/coa.ts +31 -0
- package/src/coa/coaDesigner.ts +131 -0
- package/src/coa/coaParse.ts +3 -0
- package/src/creators/definitionEdit.ts +198 -0
- package/src/creators/definitionForm.ts +479 -0
- package/src/creators/modifierFormats.ts +0 -0
- package/src/data/docsParser.ts +21 -4
- package/src/features/blockSnippets.ts +151 -20
- package/src/features/calendarDates.ts +8 -5
- package/src/features/completion.ts +38 -4
- package/src/features/definitionSkeletons.ts +109 -0
- package/src/features/inlayHints.ts +5 -2
- package/src/features/locText.ts +223 -0
- package/src/features/snippetList.ts +83 -0
- package/src/games/ck3/index.ts +16 -0
- package/src/games/ck3/meta.ts +81 -1
- package/src/games/ck3/schema.ts +83 -7
- package/src/games/ck3/structures.ts +37 -0
- package/src/games/eu5/index.ts +7 -1
- package/src/games/eu5/meta.ts +5 -1
- package/src/games/profile.ts +77 -1
- package/src/games/vic3/index.ts +4 -0
- package/src/games/vic3/meta.ts +5 -1
- package/src/gui/sourceModel.ts +51 -8
- package/src/gui/textResolve.ts +3 -3
- package/src/overview/dynastyTree.ts +493 -0
- package/src/overview/eventGraph.ts +21 -1
- package/src/overview/eventVocabulary.ts +33 -33
- package/src/overview/exampleWiki.ts +4 -1
- package/src/schema/loader.ts +2 -1
- package/src/schema/skeletons.ts +182 -0
- package/src/schema/types.ts +30 -0
- package/src/server.ts +147 -6
package/src/data/docsParser.ts
CHANGED
|
@@ -41,6 +41,8 @@ const SCOPE_LINE = /^(Supported [Ss]copes|Input [Ss]copes|Output [Ss]copes):\s*(
|
|
|
41
41
|
const META_LINE =
|
|
42
42
|
/^(Supported [Tt]argets|Targets?|Traits|Categories|Use [Aa]reas|Requires [Dd]ata|Wild[ _]?[Cc]ard|Global [Ll]ink):\s*(.*)$/;
|
|
43
43
|
|
|
44
|
+
const braceDelta = (s: string): number => (s.match(/\{/g) ?? []).length - (s.match(/\}/g) ?? []).length;
|
|
45
|
+
|
|
44
46
|
export function parseLog(content: string, kind: TokenKind): TokenData[] {
|
|
45
47
|
const tokens: TokenData[] = [];
|
|
46
48
|
const seen = new Set<string>();
|
|
@@ -50,6 +52,9 @@ export function parseLog(content: string, kind: TokenKind): TokenData[] {
|
|
|
50
52
|
// True once a `usage:` header was seen for the current entry: subsequent
|
|
51
53
|
// non-metadata lines are captured (with indentation) as the usage example.
|
|
52
54
|
let inUsage = false;
|
|
55
|
+
// >0 while an inline (header-less) example is still open. Measured on the
|
|
56
|
+
// 1.19 dumps: 378 effects.log examples run over several lines.
|
|
57
|
+
let openBraces = 0;
|
|
53
58
|
const flush = () => {
|
|
54
59
|
if (current && current.name && !seen.has(current.name)) {
|
|
55
60
|
current.doc = current.doc.trim();
|
|
@@ -61,6 +66,7 @@ export function parseLog(content: string, kind: TokenKind): TokenData[] {
|
|
|
61
66
|
}
|
|
62
67
|
current = null;
|
|
63
68
|
inUsage = false;
|
|
69
|
+
openBraces = 0;
|
|
64
70
|
};
|
|
65
71
|
|
|
66
72
|
for (const rawLine of lines) {
|
|
@@ -71,8 +77,8 @@ export function parseLog(content: string, kind: TokenKind): TokenData[] {
|
|
|
71
77
|
}
|
|
72
78
|
const trimmed = line.trim();
|
|
73
79
|
if (trimmed === "") {
|
|
74
|
-
// Blank lines inside a
|
|
75
|
-
if (current && inUsage && current.usage) current.usage += "\n";
|
|
80
|
+
// Blank lines inside a captured example are structural; keep them.
|
|
81
|
+
if (current && (inUsage || openBraces > 0) && current.usage) current.usage += "\n";
|
|
76
82
|
continue;
|
|
77
83
|
}
|
|
78
84
|
|
|
@@ -108,6 +114,18 @@ export function parseLog(content: string, kind: TokenKind): TokenData[] {
|
|
|
108
114
|
continue;
|
|
109
115
|
}
|
|
110
116
|
|
|
117
|
+
// An inline example that opened a block keeps capturing until the braces
|
|
118
|
+
// balance. A metadata line always ends the entry's body, so it wins over an
|
|
119
|
+
// example that never closes (the extractor's balance guard drops those).
|
|
120
|
+
if (openBraces > 0) {
|
|
121
|
+
if (applyMetaLine(current, trimmed)) {
|
|
122
|
+
openBraces = 0;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
current.usage += "\n" + line;
|
|
126
|
+
openBraces += braceDelta(line);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
111
129
|
// A metadata line ends any open usage capture and is recorded structurally.
|
|
112
130
|
if (applyMetaLine(current, trimmed)) {
|
|
113
131
|
inUsage = false;
|
|
@@ -126,6 +144,7 @@ export function parseLog(content: string, kind: TokenKind): TokenData[] {
|
|
|
126
144
|
// the usage example; anything after it stays prose.
|
|
127
145
|
if (current.usage === undefined && SYNTAX_LINE.test(trimmed)) {
|
|
128
146
|
current.usage = trimmed;
|
|
147
|
+
openBraces = Math.max(0, braceDelta(trimmed));
|
|
129
148
|
continue;
|
|
130
149
|
}
|
|
131
150
|
// Otherwise: continuation of the description prose.
|
|
@@ -172,8 +191,6 @@ const MASKED_FIELD = /^(Mask|Name|Description):\s*(.*)$/;
|
|
|
172
191
|
// `--- Static modifier types ---` and friends: section banners, not entries.
|
|
173
192
|
const SECTION_BANNER = /^-{3,}/;
|
|
174
193
|
|
|
175
|
-
const braceDelta = (s: string): number => (s.match(/\{/g) ?? []).length - (s.match(/\}/g) ?? []).length;
|
|
176
|
-
|
|
177
194
|
/**
|
|
178
195
|
* Markdown dump dialect (effects.log, triggers.log, event_targets.log of newer
|
|
179
196
|
* titles): entries open at a `##`/`###` heading and run until the next one.
|
|
@@ -10,18 +10,25 @@
|
|
|
10
10
|
*
|
|
11
11
|
* - the example's leading identifier must be the token's own name (dumps
|
|
12
12
|
* routinely paste a sibling's example: `add_title_law_effects` shows
|
|
13
|
-
* `add_title_law = …`)
|
|
13
|
+
* `add_title_law = …`). A `<scope> = { name = { … } }` wrapper around a
|
|
14
|
+
* single such block is peeled off first; any other placeholder key rejects;
|
|
14
15
|
* - the example must open a block and its braces must balance (truncated
|
|
15
16
|
* dumps that never close are dropped);
|
|
16
17
|
* - every key must be a plain lowercase identifier, or a `a/b/c` alternation
|
|
17
18
|
* of them (this drops pseudo-key weight lists, `X1 = { … } X2 = { … } …`);
|
|
18
|
-
* - a `#` comment
|
|
19
|
-
*
|
|
19
|
+
* - a `#` comment is read ONLY when it says the field is optional
|
|
20
|
+
* (`#Optional`, `# optional way to …`); every other comment means the
|
|
21
|
+
* example enumerates alternatives ("# or:") or explains something we cannot
|
|
22
|
+
* turn into script, and rejects the example;
|
|
20
23
|
* - `(optional)` and `...` truncate the body at that point;
|
|
21
24
|
* - `<name>` placeholders become tabstops, concrete example values become
|
|
22
25
|
* pre-filled tabstops.
|
|
23
26
|
*
|
|
24
|
-
*
|
|
27
|
+
* An example that marks fields optional yields TWO templates rather than none:
|
|
28
|
+
* `snippet`/`plain` carry the required fields only, `full` carries every field
|
|
29
|
+
* the example shows.
|
|
30
|
+
*
|
|
31
|
+
* Both text forms are produced at once: `snippet` for clients that declared
|
|
25
32
|
* snippetSupport, `plain` (no `${`, insertable as literal text) for the rest.
|
|
26
33
|
*/
|
|
27
34
|
import type { TokenData } from "@px-lsp/protocol/types";
|
|
@@ -31,6 +38,12 @@ export interface BlockTemplate {
|
|
|
31
38
|
snippet: string;
|
|
32
39
|
/** Plain-text skeleton, free of `${`. */
|
|
33
40
|
plain: string;
|
|
41
|
+
/**
|
|
42
|
+
* The same block with the fields the example marked `# optional` put back.
|
|
43
|
+
* Present only when the example marked at least one, so `full` never repeats
|
|
44
|
+
* what `snippet`/`plain` already say. Tabstops are numbered per form.
|
|
45
|
+
*/
|
|
46
|
+
full?: { snippet: string; plain: string };
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
/** Keyed on the token object, so a reload or a game switch cannot serve a stale
|
|
@@ -54,6 +67,14 @@ const KEY = /^[a-z_][a-z0-9_]*$/;
|
|
|
54
67
|
/** Everything from here on is optional or elided; the body stops. */
|
|
55
68
|
const TRUNCATE = ["(optional)", "..."];
|
|
56
69
|
|
|
70
|
+
/** A comment the dumps use to mark a field as not required: `#Optional`,
|
|
71
|
+
* `# optional way to get a reference`, `# Optional; if set, …`. Anything else
|
|
72
|
+
* after a `#` is prose or an alternation and rejects the example. */
|
|
73
|
+
const OPTIONAL_COMMENT = /^#[ \t]*optional/i;
|
|
74
|
+
|
|
75
|
+
/** A `<placeholder> = {` scope wrapper around the example. */
|
|
76
|
+
const WRAPPER_HEAD = /^\s*<[^>]+>\s*=\s*\{/;
|
|
77
|
+
|
|
57
78
|
interface Leaf {
|
|
58
79
|
/** Raw text as written in the example. */
|
|
59
80
|
text: string;
|
|
@@ -63,7 +84,10 @@ interface Leaf {
|
|
|
63
84
|
alts: string[] | null;
|
|
64
85
|
}
|
|
65
86
|
|
|
66
|
-
type Item = { key: Leaf; value: Leaf | Body } | { key: null; value: Leaf }
|
|
87
|
+
type Item = ({ key: Leaf; value: Leaf | Body } | { key: null; value: Leaf }) & {
|
|
88
|
+
/** The example's own `# optional` comment ended this item's line. */
|
|
89
|
+
optional: boolean;
|
|
90
|
+
};
|
|
67
91
|
|
|
68
92
|
interface Body {
|
|
69
93
|
items: Item[];
|
|
@@ -74,11 +98,23 @@ interface Body {
|
|
|
74
98
|
/** Pure extractor (exported for the accept/reject table in the tests). */
|
|
75
99
|
export function extractBlockTemplate(name: string, usage: string | undefined): BlockTemplate | null {
|
|
76
100
|
if (!usage) return null;
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
101
|
+
const stripped = stripOptionalComments(usage.replace(/\r/g, ""));
|
|
102
|
+
if (!stripped) return null;
|
|
103
|
+
let { text } = stripped;
|
|
104
|
+
const { optionalLines } = stripped;
|
|
105
|
+
|
|
106
|
+
// Some `usage:` blocks wrap the effect in the scope it has to run in
|
|
107
|
+
// (`<founding character> = { create_cadet_branch = { … } }`; 5 effects in the
|
|
108
|
+
// 1.19 dumps). The template is the inner block: the modder types it inside the
|
|
109
|
+
// scope they already mean. Blanking rather than slicing keeps line numbers,
|
|
110
|
+
// which the `# optional` marks are keyed on. The guards below then do the
|
|
111
|
+
// rest of the work: the wrapper is unwrapped only when its ONE item is a
|
|
112
|
+
// block named after the token.
|
|
113
|
+
const wrapper = WRAPPER_HEAD.test(text) ? unwrap(text) : text;
|
|
114
|
+
if (wrapper === null) return null;
|
|
115
|
+
text = wrapper;
|
|
80
116
|
|
|
81
|
-
const head =
|
|
117
|
+
const head = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\{/.exec(text);
|
|
82
118
|
if (!head || head[1] !== name) return null;
|
|
83
119
|
|
|
84
120
|
const open = text.indexOf("{");
|
|
@@ -86,10 +122,75 @@ export function extractBlockTemplate(name: string, usage: string | undefined): B
|
|
|
86
122
|
// Unbalanced, or something follows the block: not a template we can trust.
|
|
87
123
|
if (close < 0 || text.slice(close + 1).trim() !== "") return null;
|
|
88
124
|
|
|
89
|
-
const
|
|
125
|
+
const parser = new BodyParser(text, open + 1, optionalLines);
|
|
126
|
+
const body = parser.parseBody();
|
|
90
127
|
if (body === null) return null;
|
|
128
|
+
// Every accepted "optional" comment must have landed on an item. One that did
|
|
129
|
+
// not (a lone `# optional effects…` line, or a comment on the line that only
|
|
130
|
+
// OPENS a nested block) says something about the example we cannot express.
|
|
131
|
+
for (const line of optionalLines) if (!parser.markedLines.has(line)) return null;
|
|
132
|
+
|
|
133
|
+
const minimal = { snippet: render(name, body, true, false), plain: render(name, body, false, false) };
|
|
134
|
+
if (!hasOptional(body)) return minimal;
|
|
135
|
+
return {
|
|
136
|
+
...minimal,
|
|
137
|
+
full: { snippet: render(name, body, true, true), plain: render(name, body, false, true) },
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Drops the `# optional` comments and reports which lines carried one; returns
|
|
143
|
+
* null when any OTHER `#` comment appears, which still rejects the example.
|
|
144
|
+
*
|
|
145
|
+
* A comment runs to the end of its line, and a following line whose first
|
|
146
|
+
* non-space character is `#` wraps the same comment (the dumps wrap long
|
|
147
|
+
* `# Optional, …` notes over three lines) and is dropped with it. Dropped
|
|
148
|
+
* lines are kept as empty lines so line numbers still address the example.
|
|
149
|
+
*/
|
|
150
|
+
function stripOptionalComments(text: string): { text: string; optionalLines: Set<number> } | null {
|
|
151
|
+
const lines = text.split("\n");
|
|
152
|
+
const out: string[] = [];
|
|
153
|
+
const optionalLines = new Set<number>();
|
|
154
|
+
let wrapping = false;
|
|
155
|
+
for (let i = 0; i < lines.length; i++) {
|
|
156
|
+
const hash = lines[i].indexOf("#");
|
|
157
|
+
if (hash < 0) {
|
|
158
|
+
out.push(lines[i]);
|
|
159
|
+
wrapping = false;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const before = lines[i].slice(0, hash);
|
|
163
|
+
// A continuation of the optional comment above.
|
|
164
|
+
if (wrapping && before.trim() === "") {
|
|
165
|
+
out.push("");
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (!OPTIONAL_COMMENT.test(lines[i].slice(hash))) return null;
|
|
169
|
+
// A comment on its own line marks no item.
|
|
170
|
+
if (before.trim() === "") return null;
|
|
171
|
+
out.push(before.trimEnd());
|
|
172
|
+
optionalLines.add(i);
|
|
173
|
+
wrapping = true;
|
|
174
|
+
}
|
|
175
|
+
return { text: out.join("\n"), optionalLines };
|
|
176
|
+
}
|
|
91
177
|
|
|
92
|
-
|
|
178
|
+
/** True when the block, at any depth, has a field the example marked optional. */
|
|
179
|
+
function hasOptional(body: Body): boolean {
|
|
180
|
+
return body.items.some((item) => item.optional || ("items" in item.value && hasOptional(item.value)));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Blanks everything outside the wrapper's braces (newlines kept, so line
|
|
185
|
+
* numbers still address the example). Null when the wrapper never closes or
|
|
186
|
+
* something follows it.
|
|
187
|
+
*/
|
|
188
|
+
function unwrap(text: string): string | null {
|
|
189
|
+
const open = text.indexOf("{");
|
|
190
|
+
const close = matchingBrace(text, open);
|
|
191
|
+
if (close < 0 || text.slice(close + 1).trim() !== "") return null;
|
|
192
|
+
const blank = (s: string) => s.replace(/[^\n]/g, " ");
|
|
193
|
+
return blank(text.slice(0, open + 1)) + text.slice(open + 1, close) + blank(text.slice(close));
|
|
93
194
|
}
|
|
94
195
|
|
|
95
196
|
/** Index of the `}` closing the `{` at `open`, or -1 when it never closes. */
|
|
@@ -103,14 +204,35 @@ function matchingBrace(text: string, open: number): number {
|
|
|
103
204
|
}
|
|
104
205
|
|
|
105
206
|
class BodyParser {
|
|
207
|
+
/** Lines of `optionalLines` an item actually ended on. */
|
|
208
|
+
readonly markedLines = new Set<number>();
|
|
209
|
+
/** Line number per character offset, so an item can be tied to its comment. */
|
|
210
|
+
private readonly lineAt: number[];
|
|
211
|
+
|
|
106
212
|
constructor(
|
|
107
213
|
private readonly text: string,
|
|
108
|
-
private i: number
|
|
109
|
-
|
|
214
|
+
private i: number,
|
|
215
|
+
private readonly optionalLines: Set<number> = new Set()
|
|
216
|
+
) {
|
|
217
|
+
this.lineAt = new Array<number>(text.length);
|
|
218
|
+
let line = 0;
|
|
219
|
+
for (let n = 0; n < text.length; n++) {
|
|
220
|
+
this.lineAt[n] = line;
|
|
221
|
+
if (text[n] === "\n") line++;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
110
224
|
|
|
111
225
|
/** Items of the block whose `{` was just consumed; null rejects the example. */
|
|
112
226
|
parseBody(): Body | null {
|
|
113
227
|
const items: Item[] = [];
|
|
228
|
+
const push = (item: Omit<Item, "optional">, end = this.i): void => {
|
|
229
|
+
// A comment (already stripped) on the line the item ENDS on marked it
|
|
230
|
+
// optional, so measure the end before any trailing whitespace is skipped.
|
|
231
|
+
const line = this.lineAt[end - 1] ?? 0;
|
|
232
|
+
const optional = this.optionalLines.has(line);
|
|
233
|
+
if (optional) this.markedLines.add(line);
|
|
234
|
+
items.push({ ...item, optional } as Item);
|
|
235
|
+
};
|
|
114
236
|
for (;;) {
|
|
115
237
|
this.skipSpace();
|
|
116
238
|
if (this.i >= this.text.length) return null;
|
|
@@ -126,11 +248,12 @@ class BodyParser {
|
|
|
126
248
|
}
|
|
127
249
|
const first = this.readLeaf();
|
|
128
250
|
if (!first) return null;
|
|
251
|
+
const firstEnd = this.i;
|
|
129
252
|
this.skipSpace();
|
|
130
253
|
if (this.text[this.i] !== "=") {
|
|
131
254
|
// A bare word only makes sense as a `<effects>`-style placeholder.
|
|
132
255
|
if (!first.placeholder) return null;
|
|
133
|
-
|
|
256
|
+
push({ key: null, value: first }, firstEnd);
|
|
134
257
|
continue;
|
|
135
258
|
}
|
|
136
259
|
if (first.placeholder) return null; // a key we cannot name is not a key
|
|
@@ -141,12 +264,12 @@ class BodyParser {
|
|
|
141
264
|
this.i++;
|
|
142
265
|
const nested = this.parseBody();
|
|
143
266
|
if (nested === null) return null;
|
|
144
|
-
|
|
267
|
+
push({ key: first, value: nested });
|
|
145
268
|
continue;
|
|
146
269
|
}
|
|
147
270
|
const value = this.readLeaf();
|
|
148
271
|
if (!value) return null;
|
|
149
|
-
|
|
272
|
+
push({ key: first, value });
|
|
150
273
|
}
|
|
151
274
|
}
|
|
152
275
|
|
|
@@ -183,13 +306,21 @@ class BodyParser {
|
|
|
183
306
|
|
|
184
307
|
// ---- rendering -------------------------------------------------------------
|
|
185
308
|
|
|
186
|
-
|
|
187
|
-
|
|
309
|
+
/** `all` = the "all fields" form; false drops what the example called optional. */
|
|
310
|
+
function render(name: string, body: Body, snippet: boolean, all: boolean): string {
|
|
311
|
+
return `${name} = {\n${renderBody(body, "\t", snippet, { n: 0 }, all).join("")}}`;
|
|
188
312
|
}
|
|
189
313
|
|
|
190
|
-
function renderBody(
|
|
314
|
+
function renderBody(
|
|
315
|
+
body: Body,
|
|
316
|
+
indent: string,
|
|
317
|
+
snippet: boolean,
|
|
318
|
+
counter: { n: number },
|
|
319
|
+
all: boolean
|
|
320
|
+
): string[] {
|
|
191
321
|
const lines: string[] = [];
|
|
192
322
|
for (const item of body.items) {
|
|
323
|
+
if (item.optional && !all) continue;
|
|
193
324
|
if (item.key === null) {
|
|
194
325
|
lines.push(`${indent}${leaf(item.value, snippet, counter)}\n`);
|
|
195
326
|
continue;
|
|
@@ -198,7 +329,7 @@ function renderBody(body: Body, indent: string, snippet: boolean, counter: { n:
|
|
|
198
329
|
const key = item.key.alts ? leaf(item.key, snippet, counter) : item.key.text;
|
|
199
330
|
if ("items" in item.value) {
|
|
200
331
|
lines.push(`${indent}${key} = {\n`);
|
|
201
|
-
lines.push(...renderBody(item.value, indent + "\t", snippet, counter));
|
|
332
|
+
lines.push(...renderBody(item.value, indent + "\t", snippet, counter, all));
|
|
202
333
|
lines.push(`${indent}}\n`);
|
|
203
334
|
continue;
|
|
204
335
|
}
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
type Range,
|
|
13
13
|
} from "vscode-languageserver/node";
|
|
14
14
|
import type { TextDocument } from "vscode-languageserver-textdocument";
|
|
15
|
-
import { displayDate, isValidScriptDate,
|
|
15
|
+
import { displayDate, isValidScriptDate, type CalendarSetting } from "@px-lsp/protocol/calendar";
|
|
16
16
|
import { getLineText, isScriptLanguage } from "../documents";
|
|
17
17
|
|
|
18
18
|
interface DateToken {
|
|
@@ -47,7 +47,7 @@ export function dateTokensOnLine(cal: CalendarSetting, lineText: string): DateTo
|
|
|
47
47
|
const quotesBefore = (code.slice(0, match.index).match(/"/g) ?? []).length;
|
|
48
48
|
if (quotesBefore % 2 === 1) continue;
|
|
49
49
|
const [y, m, d] = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
50
|
-
if (!isValidScriptDate(
|
|
50
|
+
if (!isValidScriptDate(y, m, d)) continue;
|
|
51
51
|
tokens.push({ start: match.index, end: match.index + match[0].length, y, m, d });
|
|
52
52
|
}
|
|
53
53
|
return tokens;
|
|
@@ -72,11 +72,14 @@ export function calendarHints(cal: CalendarSetting, document: TextDocument, rang
|
|
|
72
72
|
return hints;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/** Hover on a date token: the display form plus the calendar rule in force
|
|
75
|
+
/** Hover on a date token: the display form plus the calendar rule in force,
|
|
76
|
+
* labelled with where the rule came from (`.px-toolkit/calendar.json` or the
|
|
77
|
+
* `px.calendar` setting). */
|
|
76
78
|
export function provideDateHover(
|
|
77
79
|
cal: CalendarSetting | undefined,
|
|
78
80
|
document: TextDocument,
|
|
79
|
-
position: Position
|
|
81
|
+
position: Position,
|
|
82
|
+
source = "px.calendar"
|
|
80
83
|
): Hover | null {
|
|
81
84
|
if (!cal || !isScriptLanguage(document.languageId)) return null;
|
|
82
85
|
const lineText = getLineText(document, position.line);
|
|
@@ -90,7 +93,7 @@ export function provideDateHover(
|
|
|
90
93
|
const eras = cal.before ? `${cal.before} / ${cal.after}` : cal.after;
|
|
91
94
|
const value =
|
|
92
95
|
`\`${script}\` → **${display}**\n\n` +
|
|
93
|
-
|
|
96
|
+
`*${source}: epoch ${cal.epoch} (${eras})${cal.months ? ", the mod's month names" : ""}*`;
|
|
94
97
|
return {
|
|
95
98
|
contents: { kind: MarkupKind.Markdown, value },
|
|
96
99
|
range: {
|
|
@@ -72,6 +72,7 @@ import type { ParadoxSettings } from "@px-lsp/protocol/protocol";
|
|
|
72
72
|
import { assetDirContext, provideAssetDirCompletion, provideBareNameCompletion } from "./assetPaths";
|
|
73
73
|
import { snippetSupport } from "../clientMode";
|
|
74
74
|
import { blockTemplateFor } from "./blockSnippets";
|
|
75
|
+
import { skeletonsAt } from "./definitionSkeletons";
|
|
75
76
|
|
|
76
77
|
/** Cap on items per response; the client re-queries per keystroke (isIncomplete). */
|
|
77
78
|
export const MAX_ITEMS = 1000;
|
|
@@ -477,13 +478,46 @@ export class CompletionFeature {
|
|
|
477
478
|
}
|
|
478
479
|
ranked.push(out);
|
|
479
480
|
}
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
481
|
+
const lead = [...structureItems, ...this.skeletonItems(result, offset, entry, lineSuffix)].filter((s) =>
|
|
482
|
+
matchesTypedWord(wordLow, s.label)
|
|
483
|
+
);
|
|
484
|
+
const structured = lead.length > 0 ? [...lead, ...ranked] : ranked;
|
|
484
485
|
return finalize(structured, typedWord, limit, /*alreadyFiltered*/ true);
|
|
485
486
|
}
|
|
486
487
|
|
|
488
|
+
/**
|
|
489
|
+
* Definition and child-block skeletons (schema/skeletons.ts) as items of their
|
|
490
|
+
* own kind, ranked right after the block's structure keys.
|
|
491
|
+
*
|
|
492
|
+
* Gated on a BLANK line tail: a multi-line insert must not land inside an
|
|
493
|
+
* existing statement, and half a definition pasted over `= { … }` is worse
|
|
494
|
+
* than no offer. That gate also keeps these items out of every rank-eval
|
|
495
|
+
* sample, which always leaves the `= …` in place when it strips a key, so the
|
|
496
|
+
* ranking of existing items is unchanged by construction.
|
|
497
|
+
*/
|
|
498
|
+
private skeletonItems(
|
|
499
|
+
result: ParseResult,
|
|
500
|
+
offset: number,
|
|
501
|
+
entry: SchemaEntry | null,
|
|
502
|
+
lineSuffix: string
|
|
503
|
+
): CompletionItem[] {
|
|
504
|
+
if (!entry?.kind || lineSuffix.trim() !== "") return [];
|
|
505
|
+
return skeletonsAt(result, offset, entry.kind, activeProfile().skeletons).map((offer) => {
|
|
506
|
+
const item: CompletionItem = {
|
|
507
|
+
label: offer.label,
|
|
508
|
+
kind: CompletionItemKind.Snippet,
|
|
509
|
+
detail: offer.detail,
|
|
510
|
+
// Structure tier, coldest rank: after every structure key of the block
|
|
511
|
+
// and ahead of the token list. At a file's top level there are no
|
|
512
|
+
// structure keys, so the skeleton leads a list that otherwise has
|
|
513
|
+
// nothing to offer outside a definition body.
|
|
514
|
+
sortText: TIER_STRUCTURE + rankBucket(99) + SRC_MOD + offer.label,
|
|
515
|
+
};
|
|
516
|
+
setInsert(item, offer.text.snippet, offer.text.plain);
|
|
517
|
+
return item;
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
487
521
|
/**
|
|
488
522
|
* Completing a scripted effect/trigger/modifier inserts a ready-to-fill
|
|
489
523
|
* block: one `PARAM = <tabstop>` line per $PARAM$ the definition's body
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which definition skeletons (schema/skeletons.ts) apply at a cursor, and how
|
|
3
|
+
* they read. One resolver, three consumers: key-position completion, the
|
|
4
|
+
* paradox/snippets request, and the unit tests.
|
|
5
|
+
*
|
|
6
|
+
* Placement is the whole trick. A definition skeleton belongs OUTSIDE every
|
|
7
|
+
* definition body (the file's top level, where completion has nothing useful to
|
|
8
|
+
* say today); a child-block skeleton belongs directly INSIDE a definition body,
|
|
9
|
+
* where that block key is valid. Anywhere else the answer is nothing.
|
|
10
|
+
*/
|
|
11
|
+
import { blockStackFromParse } from "../context";
|
|
12
|
+
import type { ParseResult } from "../parser";
|
|
13
|
+
import {
|
|
14
|
+
renderBlockSkeleton,
|
|
15
|
+
renderDefinitionSkeleton,
|
|
16
|
+
type KindSkeleton,
|
|
17
|
+
type RenderedSkeleton,
|
|
18
|
+
} from "../schema/skeletons";
|
|
19
|
+
|
|
20
|
+
export interface SkeletonOffer {
|
|
21
|
+
/** Stable id: `<kind>` for the definition, `<kind>.<block>` for a child block. */
|
|
22
|
+
id: string;
|
|
23
|
+
/** What the item reads as: "new event", "option block". */
|
|
24
|
+
label: string;
|
|
25
|
+
/** How many of the game's own definitions the shape was measured over. */
|
|
26
|
+
detail: string;
|
|
27
|
+
/** A whole definition, or one of its child blocks. */
|
|
28
|
+
form: "definition" | "block";
|
|
29
|
+
text: RenderedSkeleton;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Depth of the enclosing definition bodies at `offset`; 0 = the file's top level. */
|
|
33
|
+
function definitionDepth(parse: ParseResult, offset: number): number {
|
|
34
|
+
return blockStackFromParse(parse, offset).filter((s) => s !== "<anon>").length;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `3214` → `3,214`, so a detail line reads as a measurement and not as an id. */
|
|
38
|
+
function count(n: number): string {
|
|
39
|
+
return n.toLocaleString("en-US");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The value the document already declares for the kind's header key
|
|
44
|
+
* (`namespace = intrigue`), or undefined when it declares none.
|
|
45
|
+
*/
|
|
46
|
+
function headerValue(parse: ParseResult, key: string): string | undefined {
|
|
47
|
+
for (const stmt of parse.root.statements) {
|
|
48
|
+
if (stmt.kind !== "assignment" || stmt.key.quoted || stmt.key.text !== key) continue;
|
|
49
|
+
if (stmt.value?.kind === "scalar") return stmt.value.text;
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Skeletons offered at `offset` in a document the schema classifies as `kind`.
|
|
56
|
+
* Empty when the game has no measured skeleton for the kind, or when the cursor
|
|
57
|
+
* is deeper than a definition body (an effect block wants effects, not a form).
|
|
58
|
+
*/
|
|
59
|
+
export function skeletonsAt(
|
|
60
|
+
parse: ParseResult,
|
|
61
|
+
offset: number,
|
|
62
|
+
kind: string,
|
|
63
|
+
skeletons: Record<string, KindSkeleton> | undefined
|
|
64
|
+
): SkeletonOffer[] {
|
|
65
|
+
const skel = skeletons?.[kind];
|
|
66
|
+
if (!skel) return [];
|
|
67
|
+
const depth = definitionDepth(parse, offset);
|
|
68
|
+
if (depth === 0) return [definitionOffer(parse, kind, skel)];
|
|
69
|
+
if (depth !== 1) return [];
|
|
70
|
+
return blockOffers(kind, skel);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Every skeleton the document's kind has, position-independent: what a picker
|
|
75
|
+
* offers, where the modder chooses the insert point rather than the cursor.
|
|
76
|
+
*/
|
|
77
|
+
export function skeletonsFor(
|
|
78
|
+
parse: ParseResult,
|
|
79
|
+
kind: string,
|
|
80
|
+
skeletons: Record<string, KindSkeleton> | undefined
|
|
81
|
+
): SkeletonOffer[] {
|
|
82
|
+
const skel = skeletons?.[kind];
|
|
83
|
+
if (!skel) return [];
|
|
84
|
+
return [definitionOffer(parse, kind, skel), ...blockOffers(kind, skel)];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function definitionOffer(parse: ParseResult, kind: string, skel: KindSkeleton): SkeletonOffer {
|
|
88
|
+
const existing = skel.nameFromHeader ? headerValue(parse, skel.nameFromHeader) : undefined;
|
|
89
|
+
return {
|
|
90
|
+
id: kind,
|
|
91
|
+
label: `new ${kind.replace(/_/g, " ")}`,
|
|
92
|
+
detail: `skeleton measured over ${count(skel.sampled)} vanilla definitions`,
|
|
93
|
+
form: "definition",
|
|
94
|
+
text: renderDefinitionSkeleton(kind, skel, {
|
|
95
|
+
headerValue: existing,
|
|
96
|
+
withHeader: skel.nameFromHeader !== undefined && existing === undefined,
|
|
97
|
+
}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function blockOffers(kind: string, skel: KindSkeleton): SkeletonOffer[] {
|
|
102
|
+
return Object.entries(skel.blocks ?? {}).map(([name, block]) => ({
|
|
103
|
+
id: `${kind}.${name}`,
|
|
104
|
+
label: `${name} block`,
|
|
105
|
+
detail: `skeleton measured over ${count(block.sampled)} vanilla ${name} blocks`,
|
|
106
|
+
form: "block" as const,
|
|
107
|
+
text: renderBlockSkeleton(name, block),
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
@@ -21,6 +21,7 @@ import type { SchemaEntry } from "../schema/types";
|
|
|
21
21
|
import type { Scope } from "../scopes/model";
|
|
22
22
|
import { walkStatements } from "../parser";
|
|
23
23
|
import { calendarHints } from "./calendarDates";
|
|
24
|
+
import type { CalendarSetting } from "@px-lsp/protocol/calendar";
|
|
24
25
|
|
|
25
26
|
const HINT_MAX_LEN = 60;
|
|
26
27
|
|
|
@@ -34,12 +35,14 @@ export function provideInlayHints(
|
|
|
34
35
|
document: TextDocument,
|
|
35
36
|
range: Range,
|
|
36
37
|
rootScopes: Set<Scope> | null,
|
|
37
|
-
entry: SchemaEntry | null
|
|
38
|
+
entry: SchemaEntry | null,
|
|
39
|
+
/** The mod's own `.px-toolkit/calendar.json` when it has one; defaults to the setting. */
|
|
40
|
+
calendar: CalendarSetting | undefined = settings.calendar
|
|
38
41
|
): InlayHint[] {
|
|
39
42
|
if (document.languageId === "paradox-loc") return translationOverlayHints(data, settings, document, range);
|
|
40
43
|
const hints = locPreviewHints(data, document, range);
|
|
41
44
|
if (settings.scopeInlayHints) hints.push(...scopeHints(data, document, range, rootScopes, entry));
|
|
42
|
-
if (
|
|
45
|
+
if (calendar) hints.push(...calendarHints(calendar, document, range));
|
|
43
46
|
return hints;
|
|
44
47
|
}
|
|
45
48
|
|