@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
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Definition skeletons: the canonical shape of one definition of a kind,
3
+ * derived from measured vanilla usage by scripts/build-skeletons.ts and shipped
4
+ * as packages/server/data/<game>/skeletons.json.
5
+ *
6
+ * Nothing here is written by hand. A key is in a skeleton because at least
7
+ * SKELETON_MAJORITY of the game's own definitions of that kind carry it; its
8
+ * order is the median position it holds in them; its pre-filled value is the
9
+ * value the corpus uses most, and only where the key's whole measured
10
+ * vocabulary is small enough to be a real choice. A key whose vocabulary is
11
+ * wider but entirely NUMERIC still gets the number the corpus writes most,
12
+ * since a key name is valid script nowhere in a numeric slot. Anything wider
13
+ * than that (loc keys, names, paths) gets its own name as placeholder text,
14
+ * which says "fill this in" without asserting a value.
15
+ *
16
+ * Both insert forms are rendered at once, exactly like features/blockSnippets.ts:
17
+ * `snippet` for clients that declared snippetSupport, `plain` (free of `${`) for
18
+ * the rest.
19
+ *
20
+ * No `vscode` imports: plain data plus pure renderers.
21
+ */
22
+
23
+ /**
24
+ * Share of a kind's definitions a key must appear in to enter the skeleton.
25
+ * Half: a skeleton is what a definition of this kind USUALLY looks like, not
26
+ * the union of everything the folder has ever used. Applied identically to the
27
+ * keys of a nested block (share of that block's own occurrences).
28
+ */
29
+ export const SKELETON_MAJORITY = 0.5;
30
+
31
+ /** One key of a skeleton body. */
32
+ export interface SkeletonKey {
33
+ key: string;
34
+ /**
35
+ * The key's whole measured value vocabulary, most-used first. Set only where
36
+ * the corpus uses few enough distinct values for the list to be a choice
37
+ * rather than a guess; absent = the value is a placeholder.
38
+ */
39
+ choices?: string[];
40
+ /**
41
+ * The single most-used measured value, set only where the vocabulary is too
42
+ * wide to offer as a choice AND every value the corpus writes for the key is
43
+ * a number. A key name is valid script nowhere in a numeric slot, so a
44
+ * measured number is the honest placeholder; it stays a tabstop, so it still
45
+ * reads as "fill this in".
46
+ */
47
+ placeholder?: string;
48
+ /**
49
+ * Nested one level: the block this key opens, carrying the keys measured in
50
+ * at least SKELETON_MAJORITY of its occurrences. An empty array = the block
51
+ * has no stable shape, so it is inserted empty.
52
+ */
53
+ block?: SkeletonKey[];
54
+ }
55
+
56
+ /** A named child block of a definition, offered on its own. */
57
+ export interface SkeletonBlock {
58
+ /** Occurrences of the block measured across the kind's definitions. */
59
+ sampled: number;
60
+ keys: SkeletonKey[];
61
+ }
62
+
63
+ export interface KindSkeleton {
64
+ /** Vanilla definitions of this kind the skeleton was measured over. */
65
+ sampled: number;
66
+ keys: SkeletonKey[];
67
+ /**
68
+ * The file-level key the kind's definition names were measured to derive
69
+ * from: at least SKELETON_MAJORITY of the folder's files declare it as a
70
+ * top-level scalar, and at least that share of the kind's names are
71
+ * `<its value>.<number>` (`namespace = intrigue` → `intrigue.0001`). Absent =
72
+ * names are plain identifiers and files need no header line.
73
+ */
74
+ nameFromHeader?: string;
75
+ /** Child-block skeletons, keyed by the block's key. */
76
+ blocks?: Record<string, SkeletonBlock>;
77
+ }
78
+
79
+ export interface SkeletonData {
80
+ meta: { generated: string; sources: string[]; majority: number };
81
+ /** Definition kind, as the schema table spells it. */
82
+ kinds: Record<string, KindSkeleton>;
83
+ }
84
+
85
+ /** Both insert forms of one skeleton. */
86
+ export interface RenderedSkeleton {
87
+ /** `${n:…}` tabstop form; only for clients declaring snippetSupport. */
88
+ snippet: string;
89
+ /** Plain-text skeleton, free of `${`. */
90
+ plain: string;
91
+ }
92
+
93
+ interface Counter {
94
+ n: number;
95
+ }
96
+
97
+ function escapeSnippet(text: string): string {
98
+ return text.replace(/[\\$}]/g, "\\$&");
99
+ }
100
+
101
+ /** The right-hand side of a leaf key, in one of the two forms. */
102
+ function leafValue(spec: SkeletonKey, snippet: boolean, c: Counter): string {
103
+ const filled = spec.choices?.[0] ?? spec.placeholder ?? spec.key;
104
+ if (!snippet) return filled;
105
+ if (spec.choices && spec.choices.length > 1) return `\${${++c.n}|${spec.choices.join(",")}|}`;
106
+ return `\${${++c.n}:${escapeSnippet(filled)}}`;
107
+ }
108
+
109
+ function renderKeys(keys: SkeletonKey[], indent: string, snippet: boolean, c: Counter): string {
110
+ let out = "";
111
+ for (const spec of keys) {
112
+ if (spec.block) {
113
+ out += `${indent}${spec.key} = {\n`;
114
+ out +=
115
+ spec.block.length > 0
116
+ ? renderKeys(spec.block, indent + "\t", snippet, c)
117
+ : `${indent}\t${snippet ? `$${++c.n}` : ""}\n`;
118
+ out += `${indent}}\n`;
119
+ continue;
120
+ }
121
+ out += `${indent}${spec.key} = ${leafValue(spec, snippet, c)}\n`;
122
+ }
123
+ return out;
124
+ }
125
+
126
+ export interface SkeletonRenderOptions {
127
+ /**
128
+ * Value of the header key the target document already declares
129
+ * (`namespace = my_events`), so a generated name matches its own file.
130
+ */
131
+ headerValue?: string;
132
+ /** Write the header line above the definition (the document declares none). */
133
+ withHeader?: boolean;
134
+ }
135
+
136
+ /** The whole definition, header line included when the document lacks it. */
137
+ export function renderDefinitionSkeleton(
138
+ kind: string,
139
+ skel: KindSkeleton,
140
+ opts: SkeletonRenderOptions = {}
141
+ ): RenderedSkeleton {
142
+ const one = (snippet: boolean): string => {
143
+ const c: Counter = { n: 0 };
144
+ // A name built from a header key the insert writes itself mirrors tabstop 1
145
+ // into both places, so editing the namespace renames the definition too.
146
+ const mirrored = skel.nameFromHeader !== undefined && opts.headerValue === undefined;
147
+ if (mirrored) c.n = 1;
148
+ const prefix = mirrored
149
+ ? snippet
150
+ ? `\${1:my_${escapeSnippet(skel.nameFromHeader!)}}`
151
+ : `my_${skel.nameFromHeader}`
152
+ : opts.headerValue;
153
+
154
+ // The header line is written only when the document has none: a second
155
+ // `namespace =` in one file is exactly the silent failure to avoid.
156
+ const out = opts.withHeader && skel.nameFromHeader ? `${skel.nameFromHeader} = ${prefix}\n\n` : "";
157
+
158
+ const name = skel.nameFromHeader
159
+ ? snippet
160
+ ? `${prefix}.\${${++c.n}:1}`
161
+ : `${prefix}.1`
162
+ : snippet
163
+ ? `\${${++c.n}:my_${escapeSnippet(kind)}}`
164
+ : `my_${kind}`;
165
+ // A kind with no measured body is still worth inserting: the wrapper, the
166
+ // name shape and the header are the parts the game fails silently on.
167
+ const body =
168
+ skel.keys.length > 0 ? renderKeys(skel.keys, "\t", snippet, c) : `\t${snippet ? `$${++c.n}` : ""}\n`;
169
+ return `${out}${name} = {\n${body}}`;
170
+ };
171
+ return { snippet: one(true), plain: one(false) };
172
+ }
173
+
174
+ /** One named child block (`option = { name = … }`), inserted on its own. */
175
+ export function renderBlockSkeleton(name: string, block: SkeletonBlock): RenderedSkeleton {
176
+ const one = (snippet: boolean): string => {
177
+ const body =
178
+ block.keys.length > 0 ? renderKeys(block.keys, "\t", snippet, { n: 0 }) : `\t${snippet ? "$1" : ""}\n`;
179
+ return `${name} = {\n${body}}`;
180
+ };
181
+ return { snippet: one(true), plain: one(false) };
182
+ }
@@ -48,6 +48,13 @@ export interface KeySpec {
48
48
  * vocabulary, the completion regression rank-eval caught in 2026-07.
49
49
  */
50
50
  curated?: boolean;
51
+ /**
52
+ * Definition kinds this key's value names, where a global `RefField` would
53
+ * be wrong because the key means different things in different folders
54
+ * (`opposites` is a trait list here and a scripted_relation list there).
55
+ * Per-kind and therefore unambiguous; read by the creators' form request.
56
+ */
57
+ refKinds?: string[];
51
58
  }
52
59
 
53
60
  /** The document shape of a definition kind: top-level keys plus named sub-blocks. */
@@ -102,6 +109,29 @@ export interface SchemaEntry {
102
109
  * of this kind actually define.
103
110
  */
104
111
  requiredLoc?: string[];
112
+ /**
113
+ * Every loc key the game generates for a definition of this kind, `$` = the
114
+ * name, as the folder's `_*.info` doc states them. A SUPERSET of
115
+ * `requiredLoc`, which may only carry the patterns ≥95% of vanilla actually
116
+ * defines because a diagnostic hangs off it; a creator writes the whole set
117
+ * and asks the modder for each. Absent = `requiredLoc` is the whole set.
118
+ */
119
+ locPatterns?: string[];
120
+ /**
121
+ * Where the game looks for a definition's default icon, relative to the
122
+ * game/mod root, forward slashes, no trailing slash. Only set where the
123
+ * folder's `_*.info` doc states the path; absent = this kind has no
124
+ * name-derived icon and a creator offers no icon picker for it.
125
+ */
126
+ iconFolder?: string;
127
+ /**
128
+ * Key inside a definition's body whose value sorts this kind's definitions
129
+ * into families, when one folder holds several (`type = ethos` in
130
+ * common/culture/pillars). A creator that draws one picker per family reads
131
+ * it through paradox/definitionForm; nothing else does. Absent = the kind is
132
+ * one flat list.
133
+ */
134
+ groupKey?: string;
105
135
  /** Scope(s) a definition of this type pushes as root in its script blocks (Phase 3). */
106
136
  rootScopes?: string[];
107
137
  /** Include definitions of this kind in completion lists (default true).
package/src/server.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  configChangedNotification,
33
33
  indexChangedNotification,
34
34
  indexStatsRequest,
35
+ locTextRequest,
35
36
  lookupLocRequest,
36
37
  modFileChangedNotification,
37
38
  progressNotification,
@@ -41,6 +42,7 @@ import {
41
42
  type ParadoxSettings,
42
43
  type StatusPayload,
43
44
  type LocEntryInfo,
45
+ type LocTextParams,
44
46
  type LookupLocParams,
45
47
  type ModFileChangeParams,
46
48
  type ModScopedParams,
@@ -53,13 +55,21 @@ import {
53
55
  exampleWikiVariableKinds,
54
56
  type ExampleWikiEntryParams,
55
57
  type ExampleWikiKind,
58
+ dynastyTreeRequest,
56
59
  eventGraphRequest,
57
60
  eventValueOptionsRequest,
58
61
  eventVocabularyRequest,
62
+ definitionFormRequest,
63
+ definitionEditRequest,
64
+ modifierFormatsRequest,
65
+ type ModifierFormatsParams,
66
+ type DefinitionFormParams,
67
+ type DefinitionEditParams,
59
68
  guiTreeRequest,
60
69
  locCoverageRequest,
61
70
  modOverviewRequest,
62
71
  overridesRequest,
72
+ type DynastyTreeParams,
63
73
  type EventBannerParams,
64
74
  type EventDetailParams,
65
75
  type EventGraphParams,
@@ -89,6 +99,9 @@ import {
89
99
  type DependenciesParams,
90
100
  scopeAtRequest,
91
101
  type ScopeAtParams,
102
+ snippetsRequest,
103
+ type SnippetsParams,
104
+ type SnippetsResult,
92
105
  type ScopeAtResult,
93
106
  } from "@px-lsp/protocol/protocol";
94
107
  import { buildGuiTree } from "./features/guiTree";
@@ -149,6 +162,7 @@ import { ModOriginResolver } from "./index/modOrigin";
149
162
  import { loadSchema, type SchemaData } from "./schema/loader";
150
163
  import { VARIABLE_KINDS } from "./games/jomini/variables";
151
164
  import { activeProfile, setActiveProfile } from "./games/active";
165
+ import { resolveConfigDir } from "@px-lsp/protocol/configDir";
152
166
  import { resolveProfile } from "./games/registry";
153
167
  import type { SchemaEntry } from "./schema/types";
154
168
  import { URI } from "vscode-uri";
@@ -157,7 +171,8 @@ import { CompletionFeature } from "./features/completion";
157
171
  import { provideHover } from "./features/hover";
158
172
  import { setHoverDetail } from "./features/hoverRender";
159
173
  import { provideDateHover } from "./features/calendarDates";
160
- import { sanitizeCalendar } from "@px-lsp/protocol/calendar";
174
+ import { sanitizeCalendar, type CalendarSetting } from "@px-lsp/protocol/calendar";
175
+ import { isCalendarFile, readCalendarFile } from "@px-lsp/protocol/calendarFile";
161
176
  import { provideTextureHover } from "./features/textureHover";
162
177
  import { provideDefinition, provideLocDefinition } from "./features/definition";
163
178
  import { SEMANTIC_LEGEND, provideSemanticTokens } from "./features/semanticTokens";
@@ -180,14 +195,20 @@ import { provideReferences } from "./features/references";
180
195
  import { prepareRename, provideRename } from "./features/rename";
181
196
  import { provideWorkspaceSymbols } from "./features/workspaceSymbols";
182
197
  import { evictParse, getLocParse, getParse } from "./parseCache";
198
+ import { buildSnippetList } from "./features/snippetList";
183
199
  import { resolveClientCapabilities, setClientCapabilities } from "./clientMode";
184
200
  import { isIgnoredByConfig, isSuppressedInline, scanInlineSuppressions } from "@px-lsp/protocol/suppression";
185
201
  import { computeModOverview } from "./overview/modOverview";
186
202
  import { computeLocCoverage } from "./overview/locCoverage";
187
203
  import { computeOverrides } from "./overview/overrides";
188
204
  import { computeEventGraph } from "./overview/eventGraph";
205
+ import { computeDynastyTree } from "./overview/dynastyTree";
189
206
  import { computeEventVocabulary, computeValueOptions } from "./overview/eventVocabulary";
190
207
  import { computeEventBanner } from "./overview/eventBanner";
208
+ import { computeDefinitionForm } from "./creators/definitionForm";
209
+ import { computeDefinitionEdits } from "./creators/definitionEdit";
210
+ import { computeModifierFormats } from "./creators/modifierFormats";
211
+ import { computeLocText, type LocTextDeps } from "./features/locText";
191
212
  import { computeDependencies } from "./overview/dependencies";
192
213
  import { wordRangeAt } from "./wordAt";
193
214
 
@@ -579,6 +600,30 @@ function workspaceRootOf(fsPath: string): string | null {
579
600
  return workspaceModRoots().find((r) => lower.startsWith(r.toLowerCase())) ?? null;
580
601
  }
581
602
 
603
+ /**
604
+ * The display calendar a file's dates follow: the owning mod's
605
+ * `<mod>/.px-toolkit/calendar.json` (read once per root, dropped when the file
606
+ * changes), else the window-scoped `calendar` setting. `source` names which one
607
+ * for the hover, so a wrong date can be traced to its declaration.
608
+ */
609
+ const calendarByRoot = new Map<string, { calendar: CalendarSetting; source: string } | null>();
610
+ function calendarFor(fsPath: string): { calendar: CalendarSetting | undefined; source: string } {
611
+ const root = workspaceRootOf(fsPath);
612
+ if (root) {
613
+ const key = root.toLowerCase();
614
+ let cached = calendarByRoot.get(key);
615
+ if (cached === undefined) {
616
+ const read = readCalendarFile(root, activeProfile());
617
+ cached = read?.calendar
618
+ ? { calendar: read.calendar, source: path.relative(root, read.file).replace(/\\/g, "/") }
619
+ : null;
620
+ calendarByRoot.set(key, cached);
621
+ }
622
+ if (cached) return cached;
623
+ }
624
+ return { calendar: settings.calendar, source: "px.calendar" };
625
+ }
626
+
582
627
  /** Schema entry for the folder a file lives in (structure/ambient/root-scope seed). */
583
628
  function schemaEntryForFile(fsPath: string): SchemaEntry | null {
584
629
  const lower = fsPath.toLowerCase();
@@ -1077,7 +1122,7 @@ function rebuildModNamespaces(): void {
1077
1122
 
1078
1123
  /** Ordered parent-mod roots from <mod>/<configDir>/playset.json, if present. */
1079
1124
  function readPlayset(modPath: string): string[] {
1080
- const file = path.join(modPath, activeProfile().configDirName, "playset.json");
1125
+ const file = path.join(resolveConfigDir(modPath, activeProfile()), "playset.json");
1081
1126
  try {
1082
1127
  if (!fs.existsSync(file)) return [];
1083
1128
  const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
@@ -1104,6 +1149,7 @@ async function buildIndex(): Promise<void> {
1104
1149
  const tBuild = Date.now();
1105
1150
  const generation = ++scanGeneration;
1106
1151
  clearPathCaches();
1152
+ calendarByRoot.clear();
1107
1153
  schema = loadSchema([...(settings.modPath ? [settings.modPath] : []), ...workspaceModRoots()], log);
1108
1154
  data.completableKinds = new Set([
1109
1155
  ...schema.entries.filter((e) => e.completable !== false).map((e) => e.kind),
@@ -1431,7 +1477,11 @@ connection.onInitialized(() => {
1431
1477
  // ourselves when the client supports dynamic registration.
1432
1478
  if (!clientOwnFileWatcher && clientWatchedFilesDynamic) {
1433
1479
  void connection.client.register(DidChangeWatchedFilesNotification.type, {
1434
- watchers: [{ globPattern: "**/*.{txt,yml,gui,mod}" }, { globPattern: "**/metadata.json" }],
1480
+ watchers: [
1481
+ { globPattern: "**/*.{txt,yml,gui,mod}" },
1482
+ { globPattern: "**/metadata.json" },
1483
+ { globPattern: "**/calendar.json" },
1484
+ ],
1435
1485
  });
1436
1486
  }
1437
1487
  // Bundled frequency tables for completion ranking (§C3); fail-soft to empty.
@@ -1546,6 +1596,7 @@ function applyModFileChange(fsPath: string): void {
1546
1596
  const lower = fsPath.toLowerCase();
1547
1597
  if (lower.endsWith(".mod") || lower.endsWith("metadata.json")) refreshModOrigin();
1548
1598
  if (lower.endsWith(".gui")) invalidateGuiDefsCache();
1599
+ if (isCalendarFile(fsPath, activeProfile())) calendarByRoot.clear();
1549
1600
  // Full re-harvest when a mod defines file or a gui file WITH textformatting
1550
1601
  // changed. Not on every .gui: the harvest reads only those out of gui/, and
1551
1602
  // autosave fires this after every GUI editor gesture.
@@ -1586,6 +1637,13 @@ connection.onRequest(eventGraphRequest, (params: EventGraphParams) =>
1586
1637
  computeEventGraph(data, params ?? {}, focusFilter(params?.modRoot))
1587
1638
  );
1588
1639
 
1640
+ // The dynasty picker list, or one dynasty's houses and members. The whole
1641
+ // character corpus is read once per index revision (see overview/dynastyTree.ts);
1642
+ // a profile without a `dynasty` schema kind answers supported: false.
1643
+ connection.onRequest(dynastyTreeRequest, (params: DynastyTreeParams | null) =>
1644
+ computeDynastyTree(data, params ?? {}, focusFilter(params?.modRoot))
1645
+ );
1646
+
1589
1647
  // What an event editor may offer: the profile's structure table, the schema's
1590
1648
  // reference fields resolved through the index, and the script_docs tokens.
1591
1649
  // Never a hand-written name list.
@@ -1605,6 +1663,26 @@ connection.onRequest(eventBannerRequest, (params: EventBannerParams) =>
1605
1663
  computeEventBanner(data, params?.theme ?? "")
1606
1664
  );
1607
1665
 
1666
+ // What a visual creator may offer for one definition kind: the schema entry,
1667
+ // the harvested structure keys, the index (options and the mod's own
1668
+ // definitions) and the modifier tokens. Never a hand-written field list.
1669
+ connection.onRequest(definitionFormRequest, (params: DefinitionFormParams | null) =>
1670
+ computeDefinitionForm(data, schema, params ?? { kind: "" }, focusFilter(params?.modRoot))
1671
+ );
1672
+
1673
+ // The script sibling of guiSourceEdit: the creators' writes as offsets into the
1674
+ // text the host handed over, applied there as one WorkspaceEdit.
1675
+ connection.onRequest(definitionEditRequest, (params: DefinitionEditParams | null) =>
1676
+ computeDefinitionEdits(params)
1677
+ );
1678
+
1679
+ // How the game itself prints a modifier: the profile's format folder, the loc
1680
+ // index and the game's own texticon blocks. A profile that names no formats
1681
+ // source answers null instead of a made-up label.
1682
+ connection.onRequest(modifierFormatsRequest, (params: ModifierFormatsParams | null) =>
1683
+ computeModifierFormats(data, activeProfile().modifierFormats, settings.gamePath, params?.lines)
1684
+ );
1685
+
1608
1686
  connection.onRequest(guiTreeRequest, (params: GuiTreeParams) => buildGuiTree(params.text ?? ""));
1609
1687
 
1610
1688
  /** Loc keys resolve through the index (configured language, english files as fallback). */
@@ -1768,6 +1846,26 @@ connection.onRequest(scopeAtRequest, (params: ScopeAtParams): ScopeAtResult | nu
1768
1846
  );
1769
1847
  });
1770
1848
 
1849
+ connection.onRequest(snippetsRequest, (params: SnippetsParams): SnippetsResult => {
1850
+ // Open script documents only; anything else is an empty list, not an error:
1851
+ // a host that offers an "Insert Snippet" command asks from wherever the user
1852
+ // happens to be.
1853
+ const doc = params?.uri ? documents.get(params.uri) : undefined;
1854
+ if (!doc || !isScriptLanguage(doc.languageId) || !params.position) return { snippets: [] };
1855
+ const entry = schemaEntryForFile(URI.parse(doc.uri).fsPath);
1856
+ const { result } = getParse(doc);
1857
+ return {
1858
+ snippets: buildSnippetList(
1859
+ result,
1860
+ doc.offsetAt(params.position),
1861
+ entry?.kind ?? null,
1862
+ activeProfile().skeletons,
1863
+ data.tokens,
1864
+ freqs.tokens
1865
+ ),
1866
+ };
1867
+ });
1868
+
1771
1869
  connection.onRequest(lookupLocRequest, (params: LookupLocParams): LocEntryInfo[] => {
1772
1870
  return data.index
1773
1871
  .lookup(params.key)
@@ -1775,6 +1873,39 @@ connection.onRequest(lookupLocRequest, (params: LookupLocParams): LocEntryInfo[]
1775
1873
  .map((d) => ({ file: d.file, line: d.line, source: d.source, value: d.value }));
1776
1874
  });
1777
1875
 
1876
+ /**
1877
+ * What paradox/locText resolves a value with: the loc index for the words, the
1878
+ * definition index for the kind a `Get<Something>('name')` chain names, and the
1879
+ * active profile's schema for the loc key that kind's names take. A kind the
1880
+ * schema states nothing for falls back to the bare name inside locText.ts, so
1881
+ * every game answers from its own table with no per-game branch here.
1882
+ */
1883
+ function locTextDeps(): LocTextDeps {
1884
+ return {
1885
+ loc: locValue,
1886
+ kindsOf: (name) => [
1887
+ ...new Set(
1888
+ data.index
1889
+ .lookup(name)
1890
+ .filter((d) => d.kind !== "loc_key")
1891
+ .map((d) => d.kind)
1892
+ ),
1893
+ ],
1894
+ patternsOf: (kind) => {
1895
+ const entry = schema.entries.find((e) => e.kind === kind);
1896
+ // The conservative `requiredLoc` first (the pattern nearly every
1897
+ // definition of the kind defines), then the full `locPatterns` set.
1898
+ return [entry?.requiredLoc?.[0], entry?.locPatterns?.[0]].filter((p): p is string => !!p);
1899
+ },
1900
+ };
1901
+ }
1902
+
1903
+ // A loc value as the PLAYER reads it: markup stripped and the game's own
1904
+ // datafunctions resolved through the loc, definition and schema tables.
1905
+ connection.onRequest(locTextRequest, (params: LocTextParams | null) =>
1906
+ computeLocText(params?.keys ?? [], locTextDeps())
1907
+ );
1908
+
1778
1909
  // ---- language features ----------------------------------------------------------
1779
1910
 
1780
1911
  connection.onCompletion((params) =>
@@ -1845,7 +1976,8 @@ connection.onHover((params) =>
1845
1976
  if (!isScriptLanguage(doc.languageId)) return null;
1846
1977
  const fsPath = URI.parse(doc.uri).fsPath;
1847
1978
  const entry = schemaEntryForFile(fsPath);
1848
- const dateHover = provideDateHover(settings.calendar, doc, params.position);
1979
+ const { calendar, source } = calendarFor(fsPath);
1980
+ const dateHover = provideDateHover(calendar, doc, params.position, source);
1849
1981
  if (dateHover) return dateHover;
1850
1982
  const texture = provideTextureHover(settings, doc, params.position, entry?.kind);
1851
1983
  if (texture) return texture;
@@ -1932,11 +2064,20 @@ connection.languages.inlayHint.on((params) =>
1932
2064
  indexRead(`inlayHint ${perfName(params.textDocument.uri)}`, () => {
1933
2065
  const doc = documents.get(params.textDocument.uri);
1934
2066
  if (!doc) return [];
1935
- const entry = schemaEntryForFile(URI.parse(doc.uri).fsPath);
2067
+ const fsPath = URI.parse(doc.uri).fsPath;
2068
+ const entry = schemaEntryForFile(fsPath);
1936
2069
  const rootScopes = entry?.rootScopes?.length
1937
2070
  ? new Set(entry.rootScopes.map((s) => s.toLowerCase()))
1938
2071
  : null;
1939
- return provideInlayHints(data, settings, doc, params.range, rootScopes, entry);
2072
+ return provideInlayHints(
2073
+ data,
2074
+ settings,
2075
+ doc,
2076
+ params.range,
2077
+ rootScopes,
2078
+ entry,
2079
+ calendarFor(fsPath).calendar
2080
+ );
1940
2081
  })
1941
2082
  );
1942
2083