@px-lsp/server 0.1.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.
Files changed (163) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +334 -0
  3. package/THIRD-PARTY-NOTICES.md +83 -0
  4. package/data/ck3/dataTypes.json +2195 -0
  5. package/data/ck3/data_types/data_types_common.txt +2040 -0
  6. package/data/ck3/data_types/data_types_gui.txt +5264 -0
  7. package/data/ck3/data_types/data_types_internalclausewitzgui.txt +14843 -0
  8. package/data/ck3/data_types/data_types_script.txt +4251 -0
  9. package/data/ck3/data_types/data_types_uncategorized.txt +109984 -0
  10. package/data/ck3/freqs.json +1 -0
  11. package/data/ck3/guiSchema.json +6344 -0
  12. package/data/ck3/script_docs/effects.log +16059 -0
  13. package/data/ck3/script_docs/event_targets.log +2098 -0
  14. package/data/ck3/script_docs/modifiers.log +2228 -0
  15. package/data/ck3/script_docs/on_actions.log +5275 -0
  16. package/data/ck3/script_docs/triggers.log +11991 -0
  17. package/data/ck3/structures.json +9743 -0
  18. package/data/ck3/wikidocs/ATTRIBUTION.md +18 -0
  19. package/data/ck3/wikidocs/Data_types.md +2568 -0
  20. package/data/ck3/wikidocs/Effects_list.md +1176 -0
  21. package/data/ck3/wikidocs/Scopes_list.md +341 -0
  22. package/data/ck3/wikidocs/Triggers_list.md +1097 -0
  23. package/data/eu5/data_types/data_types_common.txt +2087 -0
  24. package/data/eu5/data_types/data_types_gui.txt +6732 -0
  25. package/data/eu5/data_types/data_types_internalclausewitzgui.txt +19276 -0
  26. package/data/eu5/data_types/data_types_script.txt +5688 -0
  27. package/data/eu5/data_types/data_types_uncategorized.txt +135569 -0
  28. package/data/vic3/data_types/data_types_common.txt +2021 -0
  29. package/data/vic3/data_types/data_types_gui.txt +5592 -0
  30. package/data/vic3/data_types/data_types_internalclausewitzgui.txt +17304 -0
  31. package/data/vic3/data_types/data_types_script.txt +2817 -0
  32. package/data/vic3/data_types/data_types_uncategorized.txt +84354 -0
  33. package/data/vic3/freqs.json +1 -0
  34. package/data/vic3/guiSchema.json +5578 -0
  35. package/data/vic3/script_docs/effects.log +38135 -0
  36. package/data/vic3/script_docs/event_targets.log +2028 -0
  37. package/data/vic3/script_docs/modifiers.log +18954 -0
  38. package/data/vic3/script_docs/on_actions.log +1561 -0
  39. package/data/vic3/script_docs/triggers.log +15738 -0
  40. package/data/vic3/structures.json +10189 -0
  41. package/dist/server.js +63668 -0
  42. package/media/px-lsp.svg +12 -0
  43. package/package.json +50 -0
  44. package/src/clientMode.ts +60 -0
  45. package/src/coa/coa.ts +184 -0
  46. package/src/coa/coaParse.ts +267 -0
  47. package/src/context.ts +78 -0
  48. package/src/contextKeywords.ts +224 -0
  49. package/src/data/dataBindingMacros.ts +82 -0
  50. package/src/data/dataFnDocs.ts +152 -0
  51. package/src/data/dataFnUsage.ts +431 -0
  52. package/src/data/dataTypes.ts +279 -0
  53. package/src/data/defines.ts +123 -0
  54. package/src/data/docsParser.ts +453 -0
  55. package/src/data/keywordDocs.ts +98 -0
  56. package/src/data/modifierTemplates.ts +143 -0
  57. package/src/data/textFormatting.ts +165 -0
  58. package/src/data/wikiDocs.ts +187 -0
  59. package/src/dds/decoder.ts +1007 -0
  60. package/src/dds/encode.ts +235 -0
  61. package/src/dds/index.ts +58 -0
  62. package/src/dds/png.ts +96 -0
  63. package/src/dds/tga.ts +62 -0
  64. package/src/documents.ts +35 -0
  65. package/src/features/assetPaths.ts +169 -0
  66. package/src/features/codeActions.ts +148 -0
  67. package/src/features/colors.ts +244 -0
  68. package/src/features/completion.ts +961 -0
  69. package/src/features/datafunction.ts +729 -0
  70. package/src/features/definition.ts +84 -0
  71. package/src/features/diagnostics.ts +244 -0
  72. package/src/features/folding.ts +106 -0
  73. package/src/features/formatting.ts +60 -0
  74. package/src/features/guiLanguage.ts +366 -0
  75. package/src/features/guiNavigation.ts +140 -0
  76. package/src/features/guiTree.ts +97 -0
  77. package/src/features/hover.ts +817 -0
  78. package/src/features/hoverRender.ts +222 -0
  79. package/src/features/inlayHints.ts +147 -0
  80. package/src/features/locFormatting.ts +127 -0
  81. package/src/features/references.ts +70 -0
  82. package/src/features/rename.ts +135 -0
  83. package/src/features/scopeAt.ts +65 -0
  84. package/src/features/semanticTokens.ts +188 -0
  85. package/src/features/signatureHelp.ts +72 -0
  86. package/src/features/symbols.ts +241 -0
  87. package/src/features/textureHover.ts +143 -0
  88. package/src/features/workspaceSymbols.ts +69 -0
  89. package/src/games/active.ts +19 -0
  90. package/src/games/ck3/ambientScopes.ts +273 -0
  91. package/src/games/ck3/index.ts +38 -0
  92. package/src/games/ck3/meta.ts +28 -0
  93. package/src/games/ck3/modifierPlaceholders.ts +61 -0
  94. package/src/games/ck3/saveSchema.ts +134 -0
  95. package/src/games/ck3/scaffolds.ts +197 -0
  96. package/src/games/ck3/schema.ts +422 -0
  97. package/src/games/ck3/structures.ts +887 -0
  98. package/src/games/eu5/index.ts +75 -0
  99. package/src/games/eu5/meta.ts +44 -0
  100. package/src/games/eu5/scaffolds.ts +49 -0
  101. package/src/games/eu5/schema.generated.ts +1043 -0
  102. package/src/games/jomini/variables.ts +134 -0
  103. package/src/games/profile.ts +205 -0
  104. package/src/games/registry.ts +27 -0
  105. package/src/games/vic3/index.ts +52 -0
  106. package/src/games/vic3/meta.ts +55 -0
  107. package/src/games/vic3/saveSchema.ts +77 -0
  108. package/src/games/vic3/scaffolds.ts +135 -0
  109. package/src/games/vic3/schema.ts +650 -0
  110. package/src/games/vic3/structures.ts +33 -0
  111. package/src/gui/anchorSpec.ts +66 -0
  112. package/src/gui/declMarkers.ts +30 -0
  113. package/src/gui/fillGeometry.ts +101 -0
  114. package/src/gui/guiDefs.ts +386 -0
  115. package/src/gui/guiDependencies.ts +352 -0
  116. package/src/gui/guiLinks.ts +64 -0
  117. package/src/gui/layoutEngine.ts +1998 -0
  118. package/src/gui/layoutService.ts +221 -0
  119. package/src/gui/measuredMetrics.ts +21 -0
  120. package/src/gui/previewService.ts +89 -0
  121. package/src/gui/saveSchema.ts +220 -0
  122. package/src/gui/saveValues.ts +399 -0
  123. package/src/gui/saveZip.ts +60 -0
  124. package/src/gui/sourceEdit.ts +535 -0
  125. package/src/gui/sourceEditService.ts +439 -0
  126. package/src/gui/sourceModel.ts +603 -0
  127. package/src/gui/textResolve.ts +145 -0
  128. package/src/gui/textureInfo.ts +106 -0
  129. package/src/gui/vocabulary.ts +149 -0
  130. package/src/gui/widgetEdit.ts +52 -0
  131. package/src/gui/widgetInfo.ts +245 -0
  132. package/src/index/docComments.ts +103 -0
  133. package/src/index/extract.ts +252 -0
  134. package/src/index/indexer.ts +369 -0
  135. package/src/index/intern.ts +101 -0
  136. package/src/index/lazyRefs.ts +145 -0
  137. package/src/index/modOrigin.ts +69 -0
  138. package/src/index/references.ts +534 -0
  139. package/src/overview/dependencies.ts +240 -0
  140. package/src/overview/eventBanner.ts +95 -0
  141. package/src/overview/eventDetail.ts +482 -0
  142. package/src/overview/eventGraph.ts +617 -0
  143. package/src/overview/eventVocabulary.ts +214 -0
  144. package/src/overview/locCoverage.ts +138 -0
  145. package/src/overview/modOverview.ts +29 -0
  146. package/src/overview/overrides.ts +89 -0
  147. package/src/parseCache.ts +81 -0
  148. package/src/parser/cst.ts +257 -0
  149. package/src/parser/encoding.ts +106 -0
  150. package/src/parser/index.ts +7 -0
  151. package/src/parser/lexer.ts +245 -0
  152. package/src/parser/locParser.ts +276 -0
  153. package/src/parser/parser.ts +360 -0
  154. package/src/schema/freqs.ts +70 -0
  155. package/src/schema/loader.ts +113 -0
  156. package/src/schema/types.ts +142 -0
  157. package/src/scopes/inference.ts +478 -0
  158. package/src/scopes/model.ts +148 -0
  159. package/src/scopes/varTypes.ts +290 -0
  160. package/src/server.ts +1894 -0
  161. package/src/serverData.ts +98 -0
  162. package/src/structure.ts +56 -0
  163. package/src/wordAt.ts +49 -0
@@ -0,0 +1,961 @@
1
+ /**
2
+ * Completion v3 (post-v1.1 "scrambled suggestions" overhaul; v2 was Workstream C).
3
+ *
4
+ * What changed vs v2, and why — all grounded in the fuzzy-diag measurements
5
+ * (scripts/fuzzy-diag.ts) which replay VS Code's real suggest scoring
6
+ * (test/vscodeFuzzy.ts) over the provider's output:
7
+ *
8
+ * 1. KEY POSITION OFFERS VERBS ONLY. v2 offered every completable definition
9
+ * kind as a key: typing `tra` in an effect block surfaced ten vanilla
10
+ * script VALUES (tradition_base_cost…) above add_trait, `na` in an option
11
+ * block surfaced event IDs (natural_disaster.0110). Script values, events,
12
+ * traits, loc keys, on_actions… are nouns: they are only valid on the right
13
+ * of `=` (or behind a prefix), so that is where they now complete — key
14
+ * position keeps engine triggers/effects/scope-changers, scripted
15
+ * effects/triggers, and the block's structure keys.
16
+ * 2. SERVER-SIDE WORD FILTER + CAP + isIncomplete. v2 shipped the whole list
17
+ * (11k–38k items, 1.9–6 MB JSON per request); above 2000 items VS Code
18
+ * also downgrades its scorer. v3 filters with the same match predicate
19
+ * VS Code uses (strong-first subsequence), ranks by sortText, and caps at
20
+ * MAX_ITEMS with isIncomplete so the client re-queries per keystroke.
21
+ * Filtering here is NOT hiding in the AD-5 sense: the client would drop
22
+ * non-matching items anyway; the cap only defers cold items until a
23
+ * keystroke narrows the set.
24
+ * 3. LAZY DOCUMENTATION. Token/definition docs resolve on selection
25
+ * (completionItem/resolve) instead of shipping with every item.
26
+ * 4. VALUE POSITION ALWAYS ANSWERS. `key = |` completes, in order: schema ref
27
+ * fields (has_trait = <traits>), structure-key values (bool → yes/no,
28
+ * enums), loc-valued properties, else a generic value set (script values +
29
+ * event targets + yes/no) — never the key soup. `trigger_event = { id = | }`
30
+ * and list-form refs (`on_actions = { | }`) complete their target kinds.
31
+ * 5. TYPED-KEY PREFIXES. `culture:|`, `faith:|`, `title:|` … complete from the
32
+ * definition index via schema.prefixRefs (previously only scope:/var:).
33
+ * 6. DEDUP. Same-name tokens merge into one item; definitions shadowed by an
34
+ * engine token of the same name are skipped (the index `entries()` fix
35
+ * handles cross-kind name collisions like vanilla `brave`).
36
+ *
37
+ * sortText scheme (unchanged from v2 §C2): composed "<T><F><S><label>" —
38
+ * slot tier T ("0" structure, "1" scope-valid, "2" neutral, "4" other-scope),
39
+ * two-digit frequency bucket F (log2×6 scale; dense rank for structure keys),
40
+ * source tiebreak S ("0" mod, "1" other), label as final alphabetical tiebreak.
41
+ */
42
+ import {
43
+ CompletionItemKind,
44
+ InsertTextFormat,
45
+ MarkupKind,
46
+ type CompletionItem,
47
+ } from "vscode-languageserver/node";
48
+ import type { TextDocument } from "vscode-languageserver-textdocument";
49
+ import type { Definition, DefSource, TokenData } from "@px-lsp/protocol/types";
50
+ import type { SchemaEntry, KeySpec, RefField } from "../schema/types";
51
+ import type { FreqContext, FreqData } from "../schema/freqs";
52
+ import { VAR_PREFIX_KINDS, dynamicRefKinds } from "../games/jomini/variables";
53
+ import { activeProfile } from "../games/active";
54
+ import { emptyFreqData } from "../schema/freqs";
55
+ import { isLocProperty } from "@px-lsp/protocol/locProperties";
56
+ import type { ServerData } from "../serverData";
57
+ import type { SchemaData } from "../schema/loader";
58
+ import {
59
+ expandModifierTemplates,
60
+ matchTemplatedModifier,
61
+ templatedModifierDoc,
62
+ } from "../data/modifierTemplates";
63
+ import { detectContextFromParse, blockStackFromParse, type BlockContext } from "../context";
64
+ import { structureContextAt } from "../structure";
65
+ import type { ParseResult } from "../parser";
66
+ import { getParse, getSavedScopes } from "../parseCache";
67
+ import { inferScopeAt } from "../scopes/inference";
68
+ import { inferenceContextFor, variableTypes } from "../scopes/varTypes";
69
+ import type { Scope } from "../scopes/model";
70
+ import type { ParadoxSettings } from "@px-lsp/protocol/protocol";
71
+ import { assetDirContext, provideAssetDirCompletion, provideBareNameCompletion } from "./assetPaths";
72
+
73
+ /** Cap on items per response; the client re-queries per keystroke (isIncomplete). */
74
+ export const MAX_ITEMS = 1000;
75
+
76
+ export interface CompletionResult {
77
+ isIncomplete: boolean;
78
+ items: CompletionItem[];
79
+ }
80
+
81
+ const TOKEN_ITEM_KINDS: Record<TokenData["kind"], CompletionItemKind> = {
82
+ trigger: CompletionItemKind.Function,
83
+ effect: CompletionItemKind.Method,
84
+ event_target: CompletionItemKind.Variable,
85
+ modifier: CompletionItemKind.Property,
86
+ };
87
+
88
+ // Distinct kinds so user/vanilla definitions are visually different from engine tokens.
89
+ // Schema-driven kinds are an open set; unlisted ones fall back to Reference.
90
+ const DEF_ITEM_KINDS: Record<string, CompletionItemKind> = {
91
+ scripted_effect: CompletionItemKind.Struct,
92
+ scripted_trigger: CompletionItemKind.Interface,
93
+ event: CompletionItemKind.Event,
94
+ on_action: CompletionItemKind.Event,
95
+ script_value: CompletionItemKind.Value,
96
+ scripted_modifier: CompletionItemKind.Unit,
97
+ loc_key: CompletionItemKind.Text,
98
+ trait: CompletionItemKind.EnumMember,
99
+ decision: CompletionItemKind.Operator,
100
+ saved_scope: CompletionItemKind.Variable,
101
+ variable: CompletionItemKind.Variable,
102
+ local_variable: CompletionItemKind.Variable,
103
+ global_variable: CompletionItemKind.Variable,
104
+ variable_list: CompletionItemKind.Variable,
105
+ local_variable_list: CompletionItemKind.Variable,
106
+ global_variable_list: CompletionItemKind.Variable,
107
+ };
108
+
109
+ const TIER_STRUCTURE = "0";
110
+ const TIER_VALID = "1";
111
+ const TIER_NEUTRAL = "2";
112
+ const TIER_OTHER = "4";
113
+
114
+ const SRC_MOD = "0";
115
+ const SRC_OTHER = "1";
116
+
117
+ /** Definition kinds whose completion expands to a parameter snippet. */
118
+ const SNIPPET_KINDS = new Set(["scripted_effect", "scripted_trigger", "scripted_modifier"]);
119
+
120
+ /** Two-digit frequency bucket: "00" hottest … "99" coldest. count ≤ 0 → coldest. */
121
+ function freqBucket(count: number | undefined): string {
122
+ if (!count || count <= 0) return "99";
123
+ const b = 99 - Math.min(99, Math.round(Math.log2(count + 1) * 6));
124
+ return String(Math.max(0, b)).padStart(2, "0");
125
+ }
126
+
127
+ /** Dense freq-rank bucket for a structure key: 0-based rank among block keys, 2 digits. */
128
+ function rankBucket(rank: number): string {
129
+ return String(Math.min(99, rank)).padStart(2, "0");
130
+ }
131
+
132
+ const STRUCTURE_VALUE_HINT: Record<string, string> = {
133
+ loc: "loc key",
134
+ bool: "yes/no",
135
+ block: "{ … }",
136
+ };
137
+
138
+ function structureItem(spec: KeySpec, kind: string, rank: number): CompletionItem {
139
+ const item: CompletionItem = { label: spec.key, kind: CompletionItemKind.Keyword };
140
+ const hint = spec.values
141
+ ? spec.values.startsWith("enum:")
142
+ ? spec.values.slice(5).replace(/\|/g, " / ")
143
+ : STRUCTURE_VALUE_HINT[spec.values]
144
+ : undefined;
145
+ item.detail = `${kind.replace(/_/g, " ")} key${hint ? ` · ${hint}` : ""}`;
146
+ if (spec.doc) item.documentation = spec.doc;
147
+ // Structure tier: F is the dense freq-rank within the block; S fixed to SRC_MOD.
148
+ item.sortText = TIER_STRUCTURE + rankBucket(rank) + SRC_MOD + spec.key;
149
+ return item;
150
+ }
151
+
152
+ function tokenItem(t: TokenData): CompletionItem {
153
+ const item: CompletionItem = { label: t.name, kind: TOKEN_ITEM_KINDS[t.kind] };
154
+ item.detail = t.kind + (t.scopes.length > 0 ? ` (${t.scopes.join(", ")})` : "");
155
+ item.data = { t: "tok", k: t.kind, n: t.name };
156
+ return item;
157
+ }
158
+
159
+ function defItem(d: Definition, origin: string = d.source): CompletionItem {
160
+ const item: CompletionItem = {
161
+ label: d.name,
162
+ kind: DEF_ITEM_KINDS[d.kind] ?? CompletionItemKind.Reference,
163
+ };
164
+ item.detail = `${d.kind.replace(/_/g, " ")} (${origin})`;
165
+ item.data = { t: "def", k: d.kind, n: d.name };
166
+ return item;
167
+ }
168
+
169
+ /**
170
+ * Completion documentation for a definition with PdxDoc (§E3): prose first, then
171
+ * `@param NAME — desc` lines. Returns undefined when the def carries no doc.
172
+ */
173
+ export function defDocMarkdown(d: Definition): string | undefined {
174
+ const parts: string[] = [];
175
+ if (d.kind === "loc_key" && d.value) parts.push(d.value);
176
+ if (d.doc) parts.push(d.doc);
177
+ const params = (d.tags ?? []).filter((t) => t.tag === "param");
178
+ if (params.length > 0) {
179
+ parts.push(
180
+ params
181
+ .map((t) => {
182
+ const m = /^(\S+)\s*(.*)$/.exec(t.text);
183
+ if (!m) return `@param`;
184
+ return m[2].trim() ? `@param ${m[1]} — ${m[2].trim()}` : `@param ${m[1]}`;
185
+ })
186
+ .join(" \n")
187
+ );
188
+ }
189
+ const deprecated = (d.tags ?? []).find((t) => t.tag === "deprecated");
190
+ if (deprecated) parts.push(deprecated.text ? `⚠ Deprecated — ${deprecated.text}` : `⚠ Deprecated`);
191
+ return parts.length > 0 ? parts.join("\n\n") : undefined;
192
+ }
193
+
194
+ /**
195
+ * VS Code's suggest match predicate (fuzzyScore with firstMatchCanBeWeak:false):
196
+ * the word must be a case-insensitive subsequence of the label AND the first
197
+ * word character must match at a "strong" position — label start, right after a
198
+ * separator (_ . - : / space …), or an uppercase boundary. Items failing this
199
+ * are dropped client-side anyway, so pre-filtering with the same rule is safe.
200
+ */
201
+ export function matchesTypedWord(wordLow: string, label: string): boolean {
202
+ if (wordLow.length === 0) return true;
203
+ const labelLow = label.toLowerCase();
204
+ if (wordLow.length > labelLow.length) return false;
205
+ const first = wordLow.charCodeAt(0);
206
+ const lastStart = labelLow.length - wordLow.length;
207
+ for (let i = 0; i <= lastStart; i++) {
208
+ if (labelLow.charCodeAt(i) !== first) continue;
209
+ if (!isStrongPosition(label, labelLow, i)) continue;
210
+ if (isSubsequence(wordLow, 1, labelLow, i + 1)) return true;
211
+ }
212
+ return false;
213
+ }
214
+
215
+ const SEPARATORS = new Set([
216
+ "_",
217
+ ".",
218
+ "-",
219
+ ":",
220
+ " ",
221
+ "/",
222
+ "\\",
223
+ "'",
224
+ '"',
225
+ "$",
226
+ "(",
227
+ ")",
228
+ "[",
229
+ "]",
230
+ "{",
231
+ "}",
232
+ "<",
233
+ ">",
234
+ ]);
235
+
236
+ function isStrongPosition(label: string, labelLow: string, i: number): boolean {
237
+ if (i === 0) return true;
238
+ if (SEPARATORS.has(labelLow[i - 1])) return true;
239
+ // Uppercase boundary (rare in Paradox script, common in GUI names).
240
+ return label[i] !== labelLow[i] && label[i - 1] === labelLow[i - 1];
241
+ }
242
+
243
+ function isSubsequence(wordLow: string, wordPos: number, labelLow: string, labelPos: number): boolean {
244
+ while (wordPos < wordLow.length && labelPos < labelLow.length) {
245
+ if (wordLow[wordPos] === labelLow[labelPos]) wordPos++;
246
+ labelPos++;
247
+ }
248
+ return wordPos === wordLow.length;
249
+ }
250
+
251
+ const VALUE_POSITION = /([A-Za-z_][A-Za-z0-9_.-]*)\s*\??=\s*"?([A-Za-z0-9_.-]*)$/;
252
+ /** Any `prefix:name` being typed; dispatching on the prefix happens in provide(). */
253
+ const PREFIX_POSITION = /([A-Za-z_][A-Za-z0-9_]*):([A-Za-z0-9_.-]*)$/;
254
+ /** `define:NS|CONST` (pipe separator): group 1 namespace, group 2 present when a
255
+ * `|` was typed, group 3 the constant. Handled ahead of PREFIX_POSITION because
256
+ * the pipe form is not a plain `prefix:name`. */
257
+ const DEFINE_POSITION = /(?:^|[^A-Za-z0-9_])define:([A-Za-z0-9_]*)(\|([A-Za-z0-9_]*))?$/;
258
+ /** The word being typed at the cursor (mirrors the language's wordPattern). */
259
+ const WORD_AT_END = /[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
260
+
261
+ /** Prefixes that reference freeform names (no index kind): offer nothing.
262
+ * `define` is handled separately (its pipe form completes namespaces/constants). */
263
+ const FREEFORM_PREFIXES = new Set(["flag", "event_target", "list"]);
264
+
265
+ /** Cached per-context base items with the metadata needed to rank per request. */
266
+ interface BaseItems {
267
+ items: CompletionItem[];
268
+ /** Parallel: the source token (null for definitions). */
269
+ tokens: (TokenData | null)[];
270
+ /** Parallel: the definition source (null for tokens). */
271
+ sources: (DefSource | null)[];
272
+ }
273
+
274
+ export class CompletionFeature {
275
+ private cache = new Map<BlockContext, BaseItems>();
276
+ private cacheRevision = -1;
277
+ /** Bundled per-context frequency tables (§C3); empty until setFreqs / fail-soft. */
278
+ private freqs: FreqData = emptyFreqData();
279
+ /** Content roots for filesystem-backed asset-path completion; null until pushed. */
280
+ private settings: ParadoxSettings | null = null;
281
+
282
+ constructor(
283
+ private readonly data: ServerData,
284
+ private readonly getSchema: () => SchemaData,
285
+ freqs?: FreqData
286
+ ) {
287
+ if (freqs) this.freqs = freqs;
288
+ data.onDidChange(() => this.cache.clear());
289
+ }
290
+
291
+ /** Install the bundled frequency tables (loaded once at startup, like tokens). */
292
+ setFreqs(freqs: FreqData): void {
293
+ this.freqs = freqs;
294
+ this.cache.clear();
295
+ }
296
+
297
+ /** Push the resolved settings (paths) used for asset-path completion. */
298
+ setSettings(settings: ParadoxSettings): void {
299
+ this.settings = settings;
300
+ }
301
+
302
+ /**
303
+ * Merged frequency count for `name` (§C2): MAX of the bundled per-context count
304
+ * and the live workspace usage count. O(1) — two map hits, no scan. `fctx` is
305
+ * the frequency context (effect_block/trigger_block/…) or null to use the global
306
+ * token table.
307
+ */
308
+ private mergedCount(name: string, fctx: FreqContext | null): number {
309
+ const bundled = fctx
310
+ ? (this.freqs.contexts[fctx][name] ?? this.freqs.tokens[name] ?? 0)
311
+ : (this.freqs.tokens[name] ?? 0);
312
+ const live = this.data.refIndex.usageCount(name);
313
+ return bundled > live ? bundled : live;
314
+ }
315
+
316
+ provide(
317
+ document: TextDocument,
318
+ offset: number,
319
+ rootScopes: Set<Scope> | null,
320
+ entry: SchemaEntry | null = null,
321
+ limit: number = MAX_ITEMS
322
+ ): CompletionResult {
323
+ const { result, lineIndex } = getParse(document);
324
+ const pos = lineIndex.positionAt(offset);
325
+ const linePrefix = document.getText({
326
+ start: { line: pos.line, character: 0 },
327
+ end: { line: pos.line, character: pos.character },
328
+ });
329
+
330
+ // `define:NS|CONST` (pipe form) → namespaces, then that namespace's constants.
331
+ const defineMatch = DEFINE_POSITION.exec(linePrefix);
332
+ if (defineMatch) {
333
+ if (defineMatch[2] === undefined) return finalize(this.defineNamespaceItems(), defineMatch[1], limit);
334
+ return finalize(this.defineConstantItems(defineMatch[1]), defineMatch[3], limit);
335
+ }
336
+
337
+ // Quoted/unquoted asset path (`icon = "gfx/interface/ico`) → directory drill-down.
338
+ if (this.settings) {
339
+ const assetPath = assetDirContext(linePrefix);
340
+ if (assetPath !== null) return provideAssetDirCompletion(this.settings, assetPath);
341
+ }
342
+ // A stray "/" outside a path context must not fall through to the key soup.
343
+ if (linePrefix.endsWith("/")) return { isIncomplete: false, items: [] };
344
+
345
+ // `prefix:name` → saved scopes, variables, or index kinds via schema.prefixRefs.
346
+ const prefixMatch = PREFIX_POSITION.exec(linePrefix);
347
+ if (prefixMatch) {
348
+ const handled = this.prefixItems(document, prefixMatch[1], rootScopes, entry);
349
+ if (handled !== null) return finalize(handled, prefixMatch[2], limit);
350
+ }
351
+
352
+ // Value position: `key = |` → targeted completion, never the key soup.
353
+ const valueMatch = VALUE_POSITION.exec(linePrefix);
354
+ if (valueMatch) {
355
+ const items = this.valueItems(valueMatch[1], result, offset, entry);
356
+ return finalize(items, valueMatch[2], limit);
357
+ }
358
+
359
+ const typedWord = WORD_AT_END.exec(linePrefix)?.[0] ?? "";
360
+
361
+ // Inside a list-form ref block (`on_actions = { | }`) → the target kinds.
362
+ const listRef = this.listRefItems(result, offset, entry);
363
+ if (listRef !== null) return finalize(listRef, typedWord, limit);
364
+
365
+ // Structure keys of the current block (§B2), ranked above everything else.
366
+ const structureItems = entry?.kind ? this.structureItems(result, offset, entry.kind) : [];
367
+
368
+ let { context } = detectContextFromParse(result, offset);
369
+ // A script_value definition body IS a value block (its name is the only
370
+ // enclosing keyword, which classifies as unknown).
371
+ if (
372
+ context === "unknown" &&
373
+ entry?.kind === "script_value" &&
374
+ blockStackFromParse(result, offset).some((s) => s !== "<anon>")
375
+ ) {
376
+ context = "value";
377
+ }
378
+ // Script-value math blocks (ai_chance, ai_will_do, weight…): fixed math keys
379
+ // lead; the base list keeps only iterators and scope targets.
380
+ if (context === "value") {
381
+ const have = new Set(structureItems.map((s) => s.label));
382
+ for (const m of valueMathItems()) if (!have.has(m.label)) structureItems.push(m);
383
+ }
384
+ const base = this.itemsFor(context);
385
+ // Frequency context for the token/def list: trigger/effect blocks use their own
386
+ // table; anything else falls back to the global token table (null).
387
+ const fctx: FreqContext | null =
388
+ context === "trigger" ? "trigger_block" : context === "effect" ? "effect_block" : null;
389
+
390
+ // Scope-aware ranking: annotate, never hide (AD-5). Tier T from scope validity,
391
+ // F from merged frequency, S from source — composed "<T><F><S><label>".
392
+ const ictx = inferenceContextFor(this.data, entry);
393
+ const inference = inferScopeAt(
394
+ result,
395
+ offset,
396
+ this.data.scopeModel,
397
+ rootScopes,
398
+ getSavedScopes(document, this.data.scopeModel, rootScopes, entry?.ambientScopes, ictx),
399
+ ictx
400
+ );
401
+ const current = inference.scopes && inference.scopes.size > 0 ? inference.scopes : null;
402
+
403
+ // Completing a name that already has `= …` after the cursor must not
404
+ // insert a second block, so snippets only apply on a bare line tail.
405
+ const lineSuffix = document.getText({
406
+ start: pos,
407
+ end: { line: pos.line + 1, character: 0 },
408
+ });
409
+ const allowSnippet = !lineSuffix.includes("=");
410
+
411
+ // Filter with the client's own match predicate BEFORE ranking: with a typed
412
+ // word most of the 10-20k base items drop here, keeping the per-keystroke
413
+ // work (object spreads + sort) on the small matched set.
414
+ const wordLow = typedWord.toLowerCase();
415
+ const ranked: CompletionItem[] = [];
416
+ for (let i = 0; i < base.items.length; i++) {
417
+ const item = base.items[i];
418
+ if (wordLow.length > 0 && !matchesTypedWord(wordLow, item.label)) continue;
419
+ const token = base.tokens[i];
420
+ const src = base.sources[i];
421
+ const f = freqBucket(this.mergedCount(item.label, fctx));
422
+ const s = src === "mod" ? SRC_MOD : SRC_OTHER;
423
+
424
+ if (!token) {
425
+ // A definition (scripted effect/trigger/modifier): context-valid when
426
+ // scope context exists, neutral otherwise — and completed as a snippet
427
+ // that materializes its $PARAM$ block (or a yes|no choice).
428
+ const defItem = { ...item, sortText: (current ? TIER_VALID : TIER_NEUTRAL) + f + s + item.label };
429
+ if (allowSnippet) this.applyParamSnippet(defItem);
430
+ ranked.push(defItem);
431
+ continue;
432
+ }
433
+ // No scope context: everything is neutral-tier, still frequency-ranked.
434
+ if (!current) {
435
+ ranked.push({ ...item, sortText: TIER_NEUTRAL + f + s + item.label });
436
+ continue;
437
+ }
438
+ const scopeAware = token.kind === "trigger" || token.kind === "effect";
439
+ const supported =
440
+ token.kind === "trigger" || token.kind === "effect"
441
+ ? this.data.scopeModel.inputScopesOf(token.kind, token.name)
442
+ : token.kind === "event_target"
443
+ ? (this.data.scopeModel.links.get(token.name)?.inputs ?? null)
444
+ : null;
445
+ if (supported === null) {
446
+ // A scope-agnostic trigger/effect (no declared input scopes) is valid in
447
+ // any scope — tier VALID so hot universals (save_scope_as, custom_tooltip,
448
+ // if…) aren't stranded behind scoped effects (§C2 intent). Other kinds with
449
+ // unknown scope (modifiers) stay neutral.
450
+ ranked.push({ ...item, sortText: (scopeAware ? TIER_VALID : TIER_NEUTRAL) + f + s + item.label });
451
+ } else if (intersects(supported, current)) {
452
+ ranked.push({ ...item, sortText: TIER_VALID + f + s + item.label });
453
+ } else {
454
+ ranked.push({
455
+ ...item,
456
+ sortText: TIER_OTHER + f + s + item.label,
457
+ detail: `${item.detail ?? ""} — other scope`,
458
+ });
459
+ }
460
+ }
461
+ const structured =
462
+ structureItems.length > 0
463
+ ? [...structureItems.filter((s) => matchesTypedWord(wordLow, s.label)), ...ranked]
464
+ : ranked;
465
+ return finalize(structured, typedWord, limit, /*alreadyFiltered*/ true);
466
+ }
467
+
468
+ /**
469
+ * Completing a scripted effect/trigger/modifier inserts a ready-to-fill
470
+ * block: one `PARAM = <tabstop>` line per $PARAM$ the definition's body
471
+ * declares; paramless effects/triggers insert `name = yes|no` as a choice.
472
+ */
473
+ private applyParamSnippet(item: CompletionItem): void {
474
+ const data = item.data as { t?: string; k?: string; n?: string } | undefined;
475
+ if (!data || data.t !== "def" || !data.k || !data.n || !SNIPPET_KINDS.has(data.k)) return;
476
+ const def = this.data.index.lookup(data.n).find((d) => d.kind === data.k);
477
+ if (!def) return;
478
+ if (def.params && def.params.length > 0) {
479
+ const body = def.params.map((p, i) => `\t${p} = \${${i + 1}:${p}}`).join("\n");
480
+ item.insertText = `${data.n} = {\n${body}\n}`;
481
+ item.insertTextFormat = InsertTextFormat.Snippet;
482
+ item.detail = `${item.detail} · params: ${def.params.join(", ")}`;
483
+ } else if (data.k !== "scripted_modifier") {
484
+ // Bare scripted modifiers are referenced by name, not assigned yes/no.
485
+ item.insertText = `${data.n} = \${1|yes,no|}`;
486
+ item.insertTextFormat = InsertTextFormat.Snippet;
487
+ }
488
+ }
489
+
490
+ /**
491
+ * completionItem/resolve: attach documentation on selection. Token docs come
492
+ * from script_docs/wiki data; definition docs from the index (PdxDoc §E3).
493
+ */
494
+ resolve(item: CompletionItem): CompletionItem {
495
+ const data = item.data as { t?: string; k?: string; n?: string } | undefined;
496
+ if (!data || !data.n) return item;
497
+ if (data.t === "tok") {
498
+ const token = this.data.tokenMap.get(data.n)?.find((t) => t.kind === data.k);
499
+ if (token?.doc) item.documentation = token.doc;
500
+ return item;
501
+ }
502
+ if (data.t === "tmpl") {
503
+ const m = matchTemplatedModifier(data.n, this.data.modifierTemplates, (n) => this.data.index.lookup(n));
504
+ if (m) item.documentation = { kind: MarkupKind.Markdown, value: templatedModifierDoc(m) };
505
+ return item;
506
+ }
507
+ if (data.t === "def") {
508
+ const def = this.data.index.lookup(data.n).find((d) => d.kind === data.k);
509
+ if (def) {
510
+ const doc = defDocMarkdown(def);
511
+ if (doc) item.documentation = { kind: MarkupKind.Markdown, value: doc };
512
+ }
513
+ }
514
+ return item;
515
+ }
516
+
517
+ /**
518
+ * Structure keys of the current block (§B2), as Keyword items ranked first.
519
+ * F is a dense freq-rank over the block's keys: sort by KeySpec.freq desc (keys
520
+ * without freq fall to the tail, alphabetically) and assign 0-based ranks.
521
+ */
522
+ private structureItems(result: ParseResult, offset: number, kind: string): CompletionItem[] {
523
+ const ctx = structureContextAt(result, offset, kind, this.getSchema().structures);
524
+ if (!ctx) return [];
525
+ // Curated keys keep their deliberate list order AHEAD of harvested ones:
526
+ // harvested .info freqs count usage at any depth of the folder, so a raw
527
+ // freq sort buries the real top-level vocabulary under sub-block keys
528
+ // (rank-eval regression, 2026-07). Harvested extras stay freq-ranked.
529
+ const all = [...ctx.keys.values()];
530
+ const curated = all.filter((k) => k.curated);
531
+ const harvested = all
532
+ .filter((k) => !k.curated)
533
+ .sort((a, b) => {
534
+ const fa = a.freq ?? 0;
535
+ const fb = b.freq ?? 0;
536
+ if (fa !== fb) return fb - fa;
537
+ return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
538
+ });
539
+ return [...curated, ...harvested].map((spec, i) => structureItem(spec, ctx.kind, i));
540
+ }
541
+
542
+ /**
543
+ * Key-position base items per block context: engine tokens of the context's
544
+ * kinds plus scripted effect/trigger definitions — verbs only (v3 change #1).
545
+ * Same-name tokens merge; defs shadowed by a token name are skipped. sortText
546
+ * is composed per request (scope validity + frequency).
547
+ */
548
+ private itemsFor(context: BlockContext): BaseItems {
549
+ if (this.cacheRevision !== this.data.index.revision) {
550
+ this.cache.clear();
551
+ this.cacheRevision = this.data.index.revision;
552
+ }
553
+ const cached = this.cache.get(context);
554
+ if (cached) return cached;
555
+
556
+ const items: CompletionItem[] = [];
557
+ const tokens: (TokenData | null)[] = [];
558
+ const sources: (DefSource | null)[] = [];
559
+ const byLabel = new Map<string, number>();
560
+ for (const t of this.data.tokens) {
561
+ if (context === "trigger" && (t.kind === "effect" || t.kind === "modifier")) continue;
562
+ if (context === "effect" && (t.kind === "trigger" || t.kind === "modifier")) continue;
563
+ // Value blocks: only iterators (every_realm_county = { add = … }) and
564
+ // scope targets are valid keys besides the fixed math keys.
565
+ if (context === "value" && !(t.kind === "event_target" || ITERATOR_NAME.test(t.name))) continue;
566
+ const existing = byLabel.get(t.name);
567
+ if (existing !== undefined) {
568
+ // Same name, several token kinds (`death` is a trigger AND an event
569
+ // target): one item, merged detail, first token wins for data/scopes.
570
+ const prev = items[existing];
571
+ if (!String(prev.detail).includes(t.kind)) prev.detail = `${prev.detail} · ${t.kind}`;
572
+ continue;
573
+ }
574
+ byLabel.set(t.name, items.length);
575
+ items.push(tokenItem(t));
576
+ tokens.push(t);
577
+ sources.push(null);
578
+ }
579
+ // Scripted lists generate iterators script_docs does not dump: offer
580
+ // every_/random_/ordered_<list> as effects and any_<list> as a trigger.
581
+ if (context !== "value") {
582
+ const prefixes =
583
+ context === "trigger"
584
+ ? ["any"]
585
+ : context === "effect"
586
+ ? ["every", "random", "ordered"]
587
+ : ["any", "every", "random", "ordered"];
588
+ for (const d of this.data.index.entries((def) => def.kind === "scripted_list")) {
589
+ for (const prefix of prefixes) {
590
+ const label = `${prefix}_${d.name}`;
591
+ if (byLabel.has(label)) continue; // engine iterator of the same name wins
592
+ const targets = this.data.scopeModel.outputOf(label);
593
+ byLabel.set(label, items.length);
594
+ items.push({
595
+ label,
596
+ kind: prefix === "any" ? CompletionItemKind.Function : CompletionItemKind.Method,
597
+ detail: `scripted list iterator${targets ? ` (${[...targets].join(", ")})` : ""} · ${this.data.originLabel(d)}`,
598
+ data: { t: "def", k: "scripted_list", n: d.name },
599
+ });
600
+ tokens.push(null);
601
+ sources.push(d.source);
602
+ }
603
+ }
604
+ }
605
+ // Templated modifiers ($CULTURE$_opinion → french_opinion) expand against
606
+ // the definition index, but only where modifier tokens are offered at all
607
+ // (unknown context, per the kind filters above). Rebuilt with this cache
608
+ // per index revision — never materialized into tokenMap.
609
+ if (context === "unknown") {
610
+ for (const e of expandModifierTemplates(
611
+ this.data.modifierTemplates,
612
+ this.data.index,
613
+ this.data.completableKinds
614
+ )) {
615
+ if (byLabel.has(e.name)) continue; // a concrete dumped modifier wins
616
+ byLabel.set(e.name, items.length);
617
+ items.push({
618
+ label: e.name,
619
+ kind: CompletionItemKind.Property,
620
+ detail: `modifier · from ${e.template.name}`,
621
+ data: { t: "tmpl", n: e.name },
622
+ });
623
+ tokens.push(null);
624
+ sources.push(e.def?.source ?? "vanilla");
625
+ }
626
+ }
627
+ for (const d of this.data.index.entries((def) => this.defAllowed(def, context))) {
628
+ if (byLabel.has(d.name)) continue; // engine token shadows a same-name def
629
+ byLabel.set(d.name, items.length);
630
+ items.push(defItem(d, this.data.originLabel(d)));
631
+ tokens.push(null);
632
+ sources.push(d.source);
633
+ }
634
+ const entry: BaseItems = { items, tokens, sources };
635
+ this.cache.set(context, entry);
636
+ return entry;
637
+ }
638
+
639
+ /** Verbs only in key position: scripted effect/trigger (+ modifier in unknown). */
640
+ private defAllowed(def: Definition, context: BlockContext): boolean {
641
+ // Huge/noisy kinds opt out via the schema (history characters, coats of arms...).
642
+ if (this.data.completableKinds.size > 0 && !this.data.completableKinds.has(def.kind)) return false;
643
+ switch (context) {
644
+ case "trigger":
645
+ return def.kind === "scripted_trigger";
646
+ case "effect":
647
+ return def.kind === "scripted_effect";
648
+ case "value":
649
+ return false; // math keys + iterators only; script values complete as VALUES
650
+ default:
651
+ return (
652
+ def.kind === "scripted_trigger" ||
653
+ def.kind === "scripted_effect" ||
654
+ def.kind === "scripted_modifier"
655
+ );
656
+ }
657
+ }
658
+
659
+ /**
660
+ * `key = |` value completion (v3 change #4). Sources in priority order:
661
+ * schema ref fields, structure-key value specs (bool/enum/loc), loc-valued
662
+ * properties, generic value set. Never returns the key list.
663
+ */
664
+ private valueItems(
665
+ key: string,
666
+ result: ParseResult,
667
+ offset: number,
668
+ entry: SchemaEntry | null
669
+ ): CompletionItem[] {
670
+ // Bare-filename `.dds` field (trait icon, death-reason icon, building type_icon):
671
+ // list *.dds from the engine-fixed base dirs across roots, mod-first.
672
+ if (this.settings) {
673
+ const bare = provideBareNameCompletion(this.settings, entry?.kind, key);
674
+ if (bare) return bare;
675
+ }
676
+
677
+ const schema = this.getSchema();
678
+ let field = schema.refFields.get(key);
679
+ // Keys too generic for a global ref field (`id`, `reference`, `variable`)
680
+ // resolve via their enclosing block: trigger_event = { id = <event> },
681
+ // every_in_list = { variable = <variable list> }, …
682
+ if (!field) {
683
+ const named = blockStackFromParse(result, offset).filter((s) => s !== "<anon>");
684
+ const block = named[named.length - 1]?.toLowerCase();
685
+ const kinds = block ? activeProfile().blockRefFields[block]?.[key] : undefined;
686
+ if (kinds) field = { key, kinds };
687
+ }
688
+ // Pattern families: has_character_flag = <flag>, has_variable = <variable>,
689
+ // is_in_list = <list> … (open-ended key sets, see dynamicRefKinds).
690
+ if (!field) {
691
+ const kinds = dynamicRefKinds(key);
692
+ if (kinds) field = { key, kinds };
693
+ }
694
+ if (field) {
695
+ const items = this.refFieldItems(field);
696
+ if (items.length > 0) return items;
697
+ }
698
+
699
+ // Structure-key value spec: bool → yes/no, enum → its members.
700
+ if (entry?.kind) {
701
+ const ctx = structureContextAt(result, offset, entry.kind, schema.structures);
702
+ const spec = ctx?.keys.get(key);
703
+ if (spec?.values === "bool") return boolItems();
704
+ if (spec?.values?.startsWith("enum:")) {
705
+ return spec.values
706
+ .slice(5)
707
+ .split("|")
708
+ .map((v, i) => ({
709
+ label: v,
710
+ kind: CompletionItemKind.EnumMember,
711
+ detail: `${spec.key} value`,
712
+ sortText: TIER_VALID + rankBucket(i) + SRC_MOD + v,
713
+ }));
714
+ }
715
+ if (spec?.values === "loc") return this.modLocItems();
716
+ }
717
+
718
+ if (isLocProperty(key)) return this.modLocItems();
719
+
720
+ // Generic fallback: things that are valid on the right of `=` when we know
721
+ // nothing about the key — script values, event targets/links, yes/no.
722
+ const items: CompletionItem[] = boolItems();
723
+ const seen = new Set<string>(["yes", "no"]);
724
+ for (const t of this.data.tokens) {
725
+ if (t.kind !== "event_target" || seen.has(t.name)) continue;
726
+ seen.add(t.name);
727
+ const f = freqBucket(this.mergedCount(t.name, null));
728
+ items.push({ ...tokenItem(t), sortText: TIER_NEUTRAL + f + SRC_OTHER + t.name });
729
+ }
730
+ for (const d of this.data.index.entries((def) => def.kind === "script_value")) {
731
+ if (seen.has(d.name)) continue;
732
+ const f = freqBucket(this.mergedCount(d.name, null));
733
+ const s = d.source === "mod" ? SRC_MOD : SRC_OTHER;
734
+ items.push({ ...defItem(d, this.data.originLabel(d)), sortText: TIER_NEUTRAL + f + s + d.name });
735
+ }
736
+ return items;
737
+ }
738
+
739
+ /** Items for a ref field's target kinds, frequency-then-source ranked. */
740
+ private refFieldItems(field: RefField): CompletionItem[] {
741
+ const kinds = new Set(field.kinds);
742
+ const items: CompletionItem[] = [];
743
+ for (const d of this.data.index.entries((def) => kinds.has(def.kind))) {
744
+ const f = freqBucket(this.mergedCount(d.name, null));
745
+ const s = d.source === "mod" ? SRC_MOD : SRC_OTHER;
746
+ items.push({ ...defItem(d, this.data.originLabel(d)), sortText: TIER_VALID + f + s + d.name });
747
+ }
748
+ return items;
749
+ }
750
+
751
+ /** Mod localization keys (vanilla loc is excluded: hundreds of thousands). */
752
+ private modLocItems(): CompletionItem[] {
753
+ const items: CompletionItem[] = [];
754
+ for (const d of this.data.index.entries((def) => def.kind === "loc_key" && def.source === "mod")) {
755
+ items.push({ ...defItem(d, this.data.originLabel(d)), sortText: TIER_VALID + "50" + SRC_MOD + d.name });
756
+ }
757
+ return items;
758
+ }
759
+
760
+ /**
761
+ * Inside a list-form ref block (`on_actions = { | }`, `events = { | }`) the
762
+ * bare words are names of the field's target kinds. Returns null when the
763
+ * cursor is not in such a block. `first_valid` doubles as an event-desc
764
+ * wrapper, so it only counts inside on_action files.
765
+ */
766
+ private listRefItems(
767
+ result: ParseResult,
768
+ offset: number,
769
+ entry: SchemaEntry | null
770
+ ): CompletionItem[] | null {
771
+ const named = blockStackFromParse(result, offset).filter((s) => s !== "<anon>");
772
+ const innermost = named[named.length - 1]?.toLowerCase();
773
+ if (!innermost) return null;
774
+ const field = this.getSchema().refFields.get(innermost);
775
+ if (!field || (field.form !== "list" && field.form !== "both")) return null;
776
+ if (innermost === "first_valid" && entry?.kind !== "on_action") return null;
777
+ return this.refFieldItems(field);
778
+ }
779
+
780
+ /** `define:` → engine/game/mod define namespaces (alphabetical). */
781
+ private defineNamespaceItems(): CompletionItem[] {
782
+ return this.data.defines.namespaces().map((ns) => ({
783
+ label: ns,
784
+ kind: CompletionItemKind.Module,
785
+ detail: "define namespace",
786
+ sortText: ns.toLowerCase(),
787
+ }));
788
+ }
789
+
790
+ /** `define:NS|` → that namespace's constants, detail = the resolved value. */
791
+ private defineConstantItems(namespace: string): CompletionItem[] {
792
+ return this.data.defines.constants(namespace).map((c) => ({
793
+ label: c.name,
794
+ kind: CompletionItemKind.Constant,
795
+ detail: `= ${c.value}`,
796
+ sortText: c.name.toLowerCase(),
797
+ }));
798
+ }
799
+
800
+ /**
801
+ * `prefix:name` completion. scope:/var: → saved scopes / variables (ambient +
802
+ * file-local first); schema.prefixRefs prefixes (culture:, faith:, title:…) →
803
+ * index kinds; freeform prefixes (flag:) → empty. Returns null for an
804
+ * unrecognized prefix (falls through to value/key handling).
805
+ */
806
+ private prefixItems(
807
+ document: TextDocument,
808
+ prefix: string,
809
+ rootScopes: Set<Scope> | null,
810
+ entry: SchemaEntry | null
811
+ ): CompletionItem[] | null {
812
+ const p = prefix.toLowerCase();
813
+ if (FREEFORM_PREFIXES.has(p)) return [];
814
+ if (p === "scope" || p === "var" || p === "local_var" || p === "global_var") {
815
+ // Each var prefix reads its own storage class (set_variable / set_local_
816
+ // variable / set_global_variable are separate namespaces).
817
+ const wantKinds = new Set(p === "scope" ? ["saved_scope"] : VAR_PREFIX_KINDS[p]);
818
+ const items = new Map<string, CompletionItem>();
819
+ if (p === "scope") {
820
+ // Fixed boost (§C2): ambient (engine-provided) scopes first, then file-local
821
+ // saves, then the mod-wide index. Composed sortText keeps them in that order:
822
+ // structure tier "0", frequency slot reused as a rank slot (0 ambient, 1 file).
823
+ for (const a of entry?.ambientScopes ?? []) {
824
+ const item: CompletionItem = {
825
+ label: a.name,
826
+ kind: CompletionItemKind.Variable,
827
+ detail: `ambient scope → ${a.type} (engine)`,
828
+ sortText: TIER_STRUCTURE + "00" + SRC_MOD + a.name,
829
+ };
830
+ item.documentation = a.doc;
831
+ items.set(a.name, item);
832
+ }
833
+ // File-local saves next, annotated with their inferred scope type.
834
+ const ictx = inferenceContextFor(this.data, entry);
835
+ const saved = getSavedScopes(document, this.data.scopeModel, rootScopes, entry?.ambientScopes, ictx);
836
+ for (const [name, scopes] of saved) {
837
+ if (items.has(name)) continue;
838
+ items.set(name, {
839
+ label: name,
840
+ kind: CompletionItemKind.Variable,
841
+ detail: `saved scope${scopes ? ` → ${[...scopes].join("|")}` : ""} (this file)`,
842
+ sortText: TIER_STRUCTURE + "01" + SRC_MOD + name,
843
+ });
844
+ }
845
+ }
846
+ const varInfo = p === "scope" ? null : variableTypes(this.data, this.data.rootScopesForFile);
847
+ for (const d of this.data.index.entries((def) => wantKinds.has(def.kind))) {
848
+ if (items.has(d.name)) continue;
849
+ const isList = d.kind.endsWith("_list");
850
+ const typed = varInfo
851
+ ? (isList ? varInfo.listItemTypes : varInfo.types).get(`${p}:${d.name}`)
852
+ : undefined;
853
+ const typeNote = typed
854
+ ? ` → ${isList ? "list of " : ""}${[...typed].join("|")}`
855
+ : isList
856
+ ? " (list)"
857
+ : "";
858
+ items.set(d.name, {
859
+ label: d.name,
860
+ kind: CompletionItemKind.Variable,
861
+ detail: `${d.kind.replace(/_/g, " ")}${typeNote}${d.container ? ` (in ${d.container})` : ""}`,
862
+ sortText: TIER_VALID + "99" + SRC_MOD + d.name,
863
+ });
864
+ }
865
+ return [...items.values()];
866
+ }
867
+ const kinds = this.getSchema().prefixRefs[p];
868
+ if (kinds && kinds.length > 0) {
869
+ const wanted = new Set(kinds);
870
+ const items: CompletionItem[] = [];
871
+ for (const d of this.data.index.entries((def) => wanted.has(def.kind))) {
872
+ const f = freqBucket(this.mergedCount(d.name, null));
873
+ const s = d.source === "mod" ? SRC_MOD : SRC_OTHER;
874
+ items.push({ ...defItem(d, this.data.originLabel(d)), sortText: TIER_VALID + f + s + d.name });
875
+ }
876
+ return items;
877
+ }
878
+ return null;
879
+ }
880
+ }
881
+
882
+ /**
883
+ * Rank, filter and cap a provider list the way the client will consume it:
884
+ * drop items the client's matcher would drop anyway, order by sortText (the
885
+ * client's empty-prefix order / tiebreak), cap at `limit`. isIncomplete when
886
+ * capped OR a word is typed — the client then re-queries per keystroke, so
887
+ * items beyond the cap surface as the word narrows the set.
888
+ */
889
+ export function finalize(
890
+ items: CompletionItem[],
891
+ typedWord: string,
892
+ limit: number,
893
+ alreadyFiltered = false
894
+ ): CompletionResult {
895
+ const wordLow = typedWord.toLowerCase();
896
+ const matched =
897
+ wordLow.length === 0 || alreadyFiltered
898
+ ? items
899
+ : items.filter((i) => matchesTypedWord(wordLow, i.filterText ?? i.label));
900
+ matched.sort((a, b) => {
901
+ const ka = a.sortText ?? a.label;
902
+ const kb = b.sortText ?? b.label;
903
+ if (ka < kb) return -1;
904
+ if (ka > kb) return 1;
905
+ return a.label < b.label ? -1 : a.label > b.label ? 1 : 0;
906
+ });
907
+ if (matched.length <= limit) {
908
+ return { isIncomplete: wordLow.length > 0, items: matched };
909
+ }
910
+ return { isIncomplete: true, items: matched.slice(0, limit) };
911
+ }
912
+
913
+ function boolItems(): CompletionItem[] {
914
+ return [
915
+ { label: "yes", kind: CompletionItemKind.Constant, sortText: TIER_VALID + "05" + SRC_MOD + "yes" },
916
+ { label: "no", kind: CompletionItemKind.Constant, sortText: TIER_VALID + "05" + SRC_MOD + "no" },
917
+ ];
918
+ }
919
+
920
+ const ITERATOR_NAME = /^(any|every|random|ordered)_/;
921
+
922
+ /** The fixed script-value math vocabulary, ordered by typical frequency. */
923
+ const VALUE_MATH_KEYS: Array<[key: string, doc: string]> = [
924
+ ["value", "Set or override the running value."],
925
+ ["add", "Add to the running value (number, script value, or a { } block)."],
926
+ ["factor", "Multiply the FINAL value."],
927
+ ["modifier", "Conditional block: triggers plus add/factor applied when they hold."],
928
+ ["if", "Conditional math: limit = { … } plus add/factor/value."],
929
+ ["min", "Clamp: lower bound."],
930
+ ["max", "Clamp: upper bound."],
931
+ ["multiply", "Multiply the running value."],
932
+ ["else_if", "Chained conditional math."],
933
+ ["else", "Fallback branch of an if."],
934
+ ["base", "Starting value."],
935
+ ["divide", "Divide the running value."],
936
+ ["subtract", "Subtract from the running value."],
937
+ ["compare_modifier", "Scaled modifier from comparing a value (target, multiplier, step)."],
938
+ ["opinion_modifier", "Scaled modifier from an opinion (who, opinion_target, multiplier)."],
939
+ ["save_temporary_value_as", "Save the running value under a name; read it back as scope:<name>."],
940
+ ["fixed_range", "Uniformly random value between min and max."],
941
+ ["integer_range", "Uniformly random integer between min and max."],
942
+ ["desc", "Custom description shown in the value breakdown tooltip."],
943
+ ["round", "Round to the nearest integer (yes/no)."],
944
+ ["floor", "Round down (yes/no)."],
945
+ ["ceiling", "Round up (yes/no)."],
946
+ ];
947
+
948
+ function valueMathItems(): CompletionItem[] {
949
+ return VALUE_MATH_KEYS.map(([key, doc], i) => ({
950
+ label: key,
951
+ kind: CompletionItemKind.Keyword,
952
+ detail: "script value math",
953
+ documentation: doc,
954
+ sortText: TIER_STRUCTURE + rankBucket(i) + SRC_MOD + key,
955
+ }));
956
+ }
957
+
958
+ function intersects(a: Set<string>, b: Set<string>): boolean {
959
+ for (const x of a) if (b.has(x)) return true;
960
+ return false;
961
+ }