@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.
Files changed (51) hide show
  1. package/README.md +1 -1
  2. package/data/ck3/skeletons.json +1 -0
  3. package/data/eu5/skeletons.json +1 -0
  4. package/data/vic3/skeletons.json +1 -0
  5. package/dist/browser-data/ck3/docs.json +1 -1
  6. package/dist/browser-data/ck3/tokens.json +1 -1
  7. package/dist/browser-data/vic3/tokens.json +1 -1
  8. package/dist/browser.js +50 -37
  9. package/dist/server.js +2000 -352
  10. package/dist/types/packages/server/src/features/blockSnippets.d.ts +20 -4
  11. package/dist/types/packages/server/src/features/completion.d.ts +11 -0
  12. package/dist/types/packages/server/src/features/definitionSkeletons.d.ts +24 -0
  13. package/dist/types/packages/server/src/games/ck3/schema.d.ts +1 -1
  14. package/dist/types/packages/server/src/games/eu5/index.d.ts +1 -1
  15. package/dist/types/packages/server/src/games/profile.d.ts +85 -1
  16. package/dist/types/packages/server/src/schema/skeletons.d.ts +102 -0
  17. package/dist/types/packages/server/src/schema/types.d.ts +30 -0
  18. package/package.json +3 -3
  19. package/src/coa/coa.ts +31 -0
  20. package/src/coa/coaDesigner.ts +131 -0
  21. package/src/coa/coaParse.ts +3 -0
  22. package/src/creators/definitionEdit.ts +198 -0
  23. package/src/creators/definitionForm.ts +479 -0
  24. package/src/creators/modifierFormats.ts +0 -0
  25. package/src/data/docsParser.ts +21 -4
  26. package/src/features/blockSnippets.ts +151 -20
  27. package/src/features/calendarDates.ts +8 -5
  28. package/src/features/completion.ts +38 -4
  29. package/src/features/definitionSkeletons.ts +109 -0
  30. package/src/features/inlayHints.ts +5 -2
  31. package/src/features/locText.ts +223 -0
  32. package/src/features/snippetList.ts +83 -0
  33. package/src/games/ck3/index.ts +16 -0
  34. package/src/games/ck3/meta.ts +81 -1
  35. package/src/games/ck3/schema.ts +83 -7
  36. package/src/games/ck3/structures.ts +37 -0
  37. package/src/games/eu5/index.ts +7 -1
  38. package/src/games/eu5/meta.ts +5 -1
  39. package/src/games/profile.ts +77 -1
  40. package/src/games/vic3/index.ts +4 -0
  41. package/src/games/vic3/meta.ts +5 -1
  42. package/src/gui/sourceModel.ts +51 -8
  43. package/src/gui/textResolve.ts +3 -3
  44. package/src/overview/dynastyTree.ts +493 -0
  45. package/src/overview/eventGraph.ts +21 -1
  46. package/src/overview/eventVocabulary.ts +33 -33
  47. package/src/overview/exampleWiki.ts +4 -1
  48. package/src/schema/loader.ts +2 -1
  49. package/src/schema/skeletons.ts +182 -0
  50. package/src/schema/types.ts +30 -0
  51. package/src/server.ts +147 -6
@@ -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 `usage:` block are structural; keep them.
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 means the example enumerates alternatives ("# or:") that
19
- * cannot be collapsed into one template;
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
- * Both forms are produced at once: `snippet` for clients that declared
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 text = usage.replace(/\r/g, "");
78
- // "# or:" style comments enumerate mutually exclusive forms: not one template.
79
- if (text.includes("#")) return null;
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 = /^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\{/.exec(text);
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 body = new BodyParser(text, open + 1).parseBody();
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
- return { snippet: render(name, body, true), plain: render(name, body, false) };
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
- items.push({ key: null, value: first });
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
- items.push({ key: first, value: nested });
267
+ push({ key: first, value: nested });
145
268
  continue;
146
269
  }
147
270
  const value = this.readLeaf();
148
271
  if (!value) return null;
149
- items.push({ key: first, value });
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
- function render(name: string, body: Body, snippet: boolean): string {
187
- return `${name} = {\n${renderBody(body, "\t", snippet, { n: 0 }).join("")}}`;
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(body: Body, indent: string, snippet: boolean, counter: { n: number }): string[] {
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, monthsOf, type CalendarSetting } from "@px-lsp/protocol/calendar";
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(cal, y, m, d)) continue;
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
- `*px.calendar: epoch ${cal.epoch} (${eras}), ${monthsOf(cal).length} months*`;
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 structured =
481
- structureItems.length > 0
482
- ? [...structureItems.filter((s) => matchesTypedWord(wordLow, s.label)), ...ranked]
483
- : ranked;
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 (settings.calendar) hints.push(...calendarHints(settings.calendar, document, range));
45
+ if (calendar) hints.push(...calendarHints(calendar, document, range));
43
46
  return hints;
44
47
  }
45
48