@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,817 @@
1
+ /**
2
+ * Hover: engine-token docs (script_docs/wiki), indexed-definition summaries,
3
+ * block-schema structure keys (§B2) and saved-scope cards for `scope:`/`var:`
4
+ * references (§B3).
5
+ *
6
+ * The data-gathering here builds `CardInput` records; visual assembly (badges,
7
+ * scope pills, fenced examples, the single shared scope footer) lives in
8
+ * `hoverRender.ts` so the D2 layout is unit-tested without LSP types.
9
+ */
10
+ import { MarkupKind, type Hover, type Position } from "vscode-languageserver/node";
11
+ import type { TextDocument } from "vscode-languageserver-textdocument";
12
+ import * as path from "path";
13
+ import { URI } from "vscode-uri";
14
+ import type { SchemaEntry } from "../schema/types";
15
+ import type { TokenData } from "@px-lsp/protocol/types";
16
+ import { clientCommands } from "@px-lsp/protocol/protocol";
17
+ import { canRunCommand } from "../clientMode";
18
+ import type { ServerData } from "../serverData";
19
+ import type { SchemaData } from "../schema/loader";
20
+ import { scopePrefixBefore, wordRangeAt } from "../wordAt";
21
+ import { getLineText } from "../documents";
22
+ import { getParse, getSavedScopes } from "../parseCache";
23
+ import { structureContextAt } from "../structure";
24
+ import { inferScopeAt } from "../scopes/inference";
25
+ import { inferenceContextFor, variableTypes } from "../scopes/varTypes";
26
+ import { nodeAtOffset, walkStatements } from "../parser";
27
+ import type { RefField } from "../schema/types";
28
+ import { VAR_PREFIX_KINDS } from "../games/jomini/variables";
29
+ import { activeProfile } from "../games/active";
30
+ import { KEYWORD_DOCS, scopeWordDoc } from "../data/keywordDocs";
31
+ import { matchTemplatedModifier, templatedModifierDoc } from "../data/modifierTemplates";
32
+ import type { Scope } from "../scopes/model";
33
+ import {
34
+ renderCard,
35
+ renderDocBody,
36
+ renderHover,
37
+ scopeHereLine,
38
+ scopePill,
39
+ scopeType,
40
+ type CardInput,
41
+ } from "./hoverRender";
42
+ import type { Definition } from "@px-lsp/protocol/types";
43
+ import type { DefineEntry } from "../data/defines";
44
+
45
+ export function provideHover(
46
+ data: ServerData,
47
+ document: TextDocument,
48
+ position: Position,
49
+ rootScopes: Set<Scope> | null,
50
+ entry: SchemaEntry | null = null,
51
+ getSchema?: () => SchemaData,
52
+ /** Definitions extracted from the OPEN document itself, consulted when the
53
+ * index has nothing for the word (same-file inline declarations, #5). */
54
+ docDefs?: (word: string) => Definition[]
55
+ ): Hover | null {
56
+ const lineText = getLineText(document, position.line);
57
+
58
+ // `define:NS|CONST` — reassemble the full token (wordPattern splits on `:`/`|`).
59
+ const defineHit = defineRefAt(lineText, position.character);
60
+ if (defineHit) {
61
+ const card = definesCard(data, defineHit.namespace, defineHit.name);
62
+ if (card) {
63
+ return {
64
+ contents: { kind: MarkupKind.Markdown, value: renderHover([card], null) },
65
+ range: {
66
+ start: { line: position.line, character: defineHit.start },
67
+ end: { line: position.line, character: defineHit.end },
68
+ },
69
+ };
70
+ }
71
+ }
72
+
73
+ let range = wordRangeAt(lineText, position.character);
74
+ if (!range) return null;
75
+ // Dot chains (`root.location.county`) resolve per segment under the cursor;
76
+ // event ids (`namespace.5.a`, any all-digit segment) stay whole.
77
+ if (range.word.includes(".") && !range.word.split(".").some((p) => /^\d+$/.test(p))) {
78
+ let start = range.start;
79
+ for (const part of range.word.split(".")) {
80
+ const end = start + part.length;
81
+ if (position.character <= end) {
82
+ range = { word: part, start, end };
83
+ break;
84
+ }
85
+ start = end + 1;
86
+ }
87
+ }
88
+ const word = range.word;
89
+
90
+ const cards: string[] = [];
91
+
92
+ // Current scope at the cursor, computed once and shared by the pills and the
93
+ // single footer line (§D2/§D3). null when we can't infer.
94
+ const current = currentScopes(data, document, position, rootScopes, entry);
95
+
96
+ // `scope:x` / `var:x` reference under the cursor → saved-scope card (§B3).
97
+ const prefix = scopePrefixBefore(lineText, range);
98
+ if (prefix === "scope") {
99
+ const card = savedScopeCard(data, document, word, rootScopes, entry);
100
+ if (card) cards.push(card);
101
+ } else if (prefix === "var" || prefix === "local_var" || prefix === "global_var") {
102
+ // Typed variable card: value type from the mod-wide set-site analysis, plus
103
+ // set-site links (namespace-correct: var/local_var/global_var are distinct).
104
+ const varInfo = variableTypes(data, data.rootScopesForFile);
105
+ const typed = varInfo.types.get(`${prefix}:${word}`);
106
+ const itemTyped = varInfo.listItemTypes.get(`${prefix}:${word}`);
107
+ const headTail = typed
108
+ ? `→ ${[...typed].map(scopeType).join(" | ")}`
109
+ : itemTyped
110
+ ? `→ list of ${[...itemTyped].map(scopeType).join(" | ")}`
111
+ : itemTyped === null
112
+ ? `→ list`
113
+ : `· ${prefix.replace(/_/g, " ")}`;
114
+ const setDefs = data.index
115
+ .lookup(word)
116
+ .filter((d) => VAR_PREFIX_KINDS[prefix].includes(d.kind))
117
+ .slice(0, 3);
118
+ const doc =
119
+ setDefs.length > 0
120
+ ? setDefs
121
+ .map((d) => `set in [${path.basename(d.file)}:${d.line + 1}](${URI.file(d.file)}#L${d.line + 1})`)
122
+ .join(" \n")
123
+ : undefined;
124
+ cards.push(renderCard({ kind: "saved_scope", badgeLabel: "variable", name: word, headTail, doc }));
125
+ }
126
+
127
+ // When the word is the VALUE of a schema ref field (`theme = faith`,
128
+ // `add_trait = brave`), the schema names the kinds it can reference — show
129
+ // only those meanings instead of every same-named symbol (the `faith` event
130
+ // target is noise on `theme = faith`). Falls through when nothing matches.
131
+ const expected = getSchema ? refKindsAt(document, position, getSchema().refFields) : null;
132
+ let defs = data.index.lookup(word);
133
+ if (defs.length === 0 && docDefs) defs = docDefs(word);
134
+ const expectedDefs = expected ? defs.filter((d) => expected.includes(d.kind)) : [];
135
+ // Anchor for the "N references" command link: the hovered site itself, so
136
+ // the client can drive the references view from it.
137
+ const at = { uri: document.uri, line: position.line, character: range.start };
138
+
139
+ // On the KEY of an assignment, the key's own structural meaning outranks
140
+ // same-named value identities: a total conversion that saves a scope named
141
+ // `type` in 33 places must not bury what `type =` means in an event.
142
+ const keyPos = !prefix && atKeyPosition(document, position);
143
+ const structureCard =
144
+ keyPos && entry?.kind && getSchema ? structureKeyCard(document, position, word, entry, getSchema) : null;
145
+
146
+ if (expectedDefs.length > 0) {
147
+ cards.push(...definitionCards(data, expectedDefs, at));
148
+ } else {
149
+ if (structureCard) cards.push(structureCard);
150
+ for (const token of data.tokenMap.get(word) ?? []) {
151
+ cards.push(tokenCard(token, current));
152
+ }
153
+ let shownDefs = defs;
154
+ if (keyPos && (cards.length > 0 || defs.some((d) => !VALUE_IDENTITY_KINDS.has(d.kind)))) {
155
+ shownDefs = defs.filter((d) => !VALUE_IDENTITY_KINDS.has(d.kind));
156
+ }
157
+ cards.push(...definitionCards(data, shownDefs, at));
158
+ }
159
+
160
+ // Fallback cards, only when nothing else matched, so a real token/def with
161
+ // the same name keeps precedence. Most-specific first: block structure key,
162
+ // enum value of a structure key, macro parameter of the called scripted
163
+ // effect/trigger, event namespace, scope keyword, grammar keyword.
164
+ if (!prefix && cards.length === 0) {
165
+ const card =
166
+ (entry?.kind && getSchema ? structureKeyCard(document, position, word, entry, getSchema) : null) ??
167
+ (entry?.kind && getSchema ? enumValueCard(document, position, word, entry, getSchema) : null) ??
168
+ macroParamCard(data, document, position, word) ??
169
+ relationTriggerCard(data, word) ??
170
+ templatedModifierCard(data, word) ??
171
+ namespaceCard(data, word) ??
172
+ scopeWordCard(word) ??
173
+ keywordCard(word) ??
174
+ effectArgumentCard(data, document, position, word);
175
+ if (card) cards.push(card);
176
+ }
177
+
178
+ if (cards.length === 0) return null;
179
+
180
+ // Scope context appears once, last (§D2). Suppressed for a `scope:` hover
181
+ // (its own card already carries the scope) to match the prior behavior.
182
+ let footer: string | null = null;
183
+ if (prefix !== "scope") {
184
+ const scopes = current && current.size > 0 ? [...current].join(" | ") : "unknown";
185
+ const inference = scopeInference(data, document, position, rootScopes, entry);
186
+ const chain = inference.chain.length > 1 ? inference.chain.join(" · ") : null;
187
+ footer = scopeHereLine(scopes, chain);
188
+ }
189
+
190
+ return {
191
+ contents: { kind: MarkupKind.Markdown, value: renderHover(cards, footer) },
192
+ range: {
193
+ start: { line: position.line, character: range.start },
194
+ end: { line: position.line, character: range.end },
195
+ },
196
+ };
197
+ }
198
+
199
+ /**
200
+ * An engine-token card (§D2 mock 1). Teaches USAGE, not usage counts: what the
201
+ * token does, the datatype its VALUE expects, a syntax example, and the scopes
202
+ * it runs in / the scope it returns. No vanilla-frequency line — engine tokens
203
+ * never carry one and the user does not want it.
204
+ */
205
+ function tokenCard(token: TokenData, current: ReadonlySet<string> | null): string {
206
+ const card: CardInput = { kind: token.kind, name: token.name };
207
+ const { input, output, plain } = partitionScopes(token.scopes);
208
+
209
+ // Event targets return a scope: surface it as the `→ type` head tail — it is
210
+ // this token's real "datatype".
211
+ if (output.length > 0) card.headTail = `→ ${output.map(scopeType).join(" | ")}`;
212
+
213
+ // Description, then the expected value datatype (the user's core ask).
214
+ const docParts: string[] = [];
215
+ if (token.doc) docParts.push(token.doc);
216
+ const shape = valueShape(token);
217
+ if (shape) docParts.push(`Value: ${shape}`);
218
+ if (docParts.length > 0) card.doc = docParts.join("\n\n");
219
+
220
+ // Syntax example, fenced (from a script_docs `usage:` block or the wiki).
221
+ if (token.usage) card.example = token.usage;
222
+
223
+ // Remaining metadata (targets, categories, requires-data, wiki note); the
224
+ // `Traits:` line is already folded into the value datatype above.
225
+ const meta = otherTraitBits(token.traits);
226
+ if (meta.length > 0) card.traits = meta.join(" · ");
227
+
228
+ // Input scope(s) you call it from — matched against the current cursor scope.
229
+ const scopeVals = plain.length > 0 ? plain : input;
230
+ if (scopeVals.length > 0) {
231
+ card.footer = [`Supported scopes: ${scopeVals.map((s) => scopePill(s, current)).join(" ")}`];
232
+ }
233
+ return renderCard(card);
234
+ }
235
+
236
+ /** Split raw scope strings into the input / output / plain buckets. */
237
+ function partitionScopes(scopes: string[]): { input: string[]; output: string[]; plain: string[] } {
238
+ const input: string[] = [];
239
+ const output: string[] = [];
240
+ const plain: string[] = [];
241
+ for (const s of scopes) {
242
+ if (s.startsWith("input: ")) input.push(s.slice("input: ".length));
243
+ else if (s.startsWith("output: ")) output.push(s.slice("output: ".length));
244
+ else plain.push(s);
245
+ }
246
+ return { input, output, plain };
247
+ }
248
+
249
+ /**
250
+ * Plain-language datatype for a token's VALUE, deduced from its `Traits:` line
251
+ * and kind: boolean, comparison, scope target, database key, block. null when
252
+ * the docs give no basis to say (e.g. a plain event target — the `→ type` head
253
+ * already carries that).
254
+ */
255
+ function valueShape(token: TokenData): string | null {
256
+ const tMatch = /Traits:\s*([^\n]*)/i.exec(token.traits ?? "");
257
+ const t = (tMatch ? tMatch[1] : "").trim();
258
+ if (token.kind === "trigger") {
259
+ if (/\byes\/no\b/i.test(t)) return "`yes`/`no` (boolean)";
260
+ if (/valid date/i.test(t)) return "a date, or a comparison operator + date";
261
+ if (/[<>]=?|!=/.test(t)) return "a number or script value (comparison: `<` `<=` `=` `!=` `>` `>=`)";
262
+ const scopeM = /\b([A-Za-z_][A-Za-z0-9_]*)\s+(scope|target)\b/i.exec(t);
263
+ if (scopeM) return `a ${scopeM[1]} ${scopeM[2].toLowerCase()}`;
264
+ if (/\bkey\b/i.test(t)) return "a database key";
265
+ if (t) return `one of: ${t}`;
266
+ return null;
267
+ }
268
+ if (token.kind === "effect") {
269
+ if (token.usage && token.usage.includes("{")) return "a block — see the example below";
270
+ return null;
271
+ }
272
+ if (token.kind === "modifier") return "a number (the modifier's magnitude)";
273
+ return null;
274
+ }
275
+
276
+ /** Metadata trait lines minus the `Traits:`/legacy `Example:` lines, flattened. */
277
+ function otherTraitBits(traits: string | undefined): string[] {
278
+ if (!traits) return [];
279
+ return traits
280
+ .split("\n")
281
+ .map((l) => l.trim())
282
+ .filter((l) => l !== "" && !/^Traits:/i.test(l) && !/^Example:/i.test(l));
283
+ }
284
+
285
+ /** Definition kinds that name a VALUE-side identity (something you reference
286
+ * with `scope:`/`var:` or as a list), never what an assignment KEY means. */
287
+ const VALUE_IDENTITY_KINDS = new Set(["saved_scope", "list", ...Object.values(VAR_PREFIX_KINDS).flat()]);
288
+
289
+ /** True when the cursor sits on the KEY of an unquoted assignment. */
290
+ function atKeyPosition(document: TextDocument, position: Position): boolean {
291
+ const { result, lineIndex } = getParse(document);
292
+ const offset = lineIndex.offsetAt(position);
293
+ const hit = nodeAtOffset(result.root, offset);
294
+ const last = hit?.path[hit.path.length - 1];
295
+ return (
296
+ !!last &&
297
+ last.kind === "assignment" &&
298
+ !last.key.quoted &&
299
+ offset >= last.key.range.start &&
300
+ offset <= last.key.range.end
301
+ );
302
+ }
303
+
304
+ /**
305
+ * Cards for a set of same-named definitions: one card per KIND, with N same-kind
306
+ * sites collapsed into a single card ("33 sites") instead of 33 identical cards.
307
+ * The name-wide reference count renders once, on the first card, because it is
308
+ * a property of the name, not of any one definition.
309
+ */
310
+ function definitionCards(
311
+ data: ServerData,
312
+ defs: Array<ReturnType<ServerData["index"]["lookup"]>[number]>,
313
+ at?: { uri: string; line: number; character: number }
314
+ ): string[] {
315
+ const order: string[] = [];
316
+ const byKind = new Map<string, typeof defs>();
317
+ for (const def of defs) {
318
+ let group = byKind.get(def.kind);
319
+ if (!group) {
320
+ byKind.set(def.kind, (group = []));
321
+ order.push(def.kind);
322
+ }
323
+ group.push(def);
324
+ }
325
+ const cards: string[] = [];
326
+ let withRefs = true;
327
+ for (const kind of order) {
328
+ const group = byKind.get(kind)!;
329
+ cards.push(
330
+ group.length === 1
331
+ ? definitionCard(data, group[0], at, withRefs)
332
+ : definitionGroupCard(data, group, at, withRefs)
333
+ );
334
+ withRefs = false;
335
+ }
336
+ return cards;
337
+ }
338
+
339
+ /** The "N references" command link (or plain count) for a name, once per hover. */
340
+ function referencesFooter(
341
+ data: ServerData,
342
+ name: string,
343
+ at?: { uri: string; line: number; character: number }
344
+ ): string | null {
345
+ const refs = data.refIndex.lookup(name).length;
346
+ if (refs === 0) return null;
347
+ const label = `${refs.toLocaleString("en-US")} reference${refs === 1 ? "" : "s"}`;
348
+ return at && canRunCommand(clientCommands.showReferences)
349
+ ? `[${label}](command:${clientCommands.showReferences}?${encodeURIComponent(
350
+ JSON.stringify([at.uri, at.line, at.character])
351
+ )} "Show all references")`
352
+ : label;
353
+ }
354
+
355
+ /** One card for N same-named, same-kind definitions: origin + site count up
356
+ * front, the first few sites as links, the rest as a count. */
357
+ function definitionGroupCard(
358
+ data: ServerData,
359
+ group: Array<ReturnType<ServerData["index"]["lookup"]>[number]>,
360
+ at?: { uri: string; line: number; character: number },
361
+ withRefs = true
362
+ ): string {
363
+ const def = group[0];
364
+ const origins = [...new Set(group.map((d) => data.originLabel(d)))];
365
+ const card: CardInput = {
366
+ kind: def.kind,
367
+ badgeLabel: def.kind.replace(/_/g, " "),
368
+ name: def.name,
369
+ headTail:
370
+ origins.length === 1
371
+ ? `· ${origins[0]} (${group.length} sites)`
372
+ : `· ${group.length} sites in ${origins.join(", ")}`,
373
+ };
374
+ const footer = group.slice(0, 3).map(provenance);
375
+ if (group.length > 3) footer.push(`+${group.length - 3} more`);
376
+ if (withRefs) {
377
+ const refs = referencesFooter(data, def.name, at);
378
+ if (refs) footer.push(refs);
379
+ }
380
+ card.footer = footer;
381
+ return renderCard(card);
382
+ }
383
+
384
+ /** An indexed-definition card: badge, name, `· source`, provenance link (§D2 mock 2). */
385
+ function definitionCard(
386
+ data: ServerData,
387
+ def: ReturnType<ServerData["index"]["lookup"]>[number],
388
+ at?: { uri: string; line: number; character: number },
389
+ withRefs = true
390
+ ): string {
391
+ const card: CardInput = { kind: def.kind, badgeLabel: def.kind.replace(/_/g, " "), name: def.name };
392
+ // Origin: the owning mod's descriptor name where known ("· My Mod"), the raw
393
+ // source tag ("· vanilla") otherwise.
394
+ const origin = data.originLabel(def);
395
+ card.headTail = `· ${origin}`;
396
+ if (def.kind === "loc_key" && def.value !== undefined) card.doc = `"${def.value}"`;
397
+ // Ad-hoc lists carry a statically resolved item type (varTypes.ts).
398
+ if (def.kind === "list") {
399
+ const item = variableTypes(data, data.rootScopesForFile).adhocListItemTypes.get(def.name);
400
+ if (item && item.size > 0) {
401
+ card.headTail = `of ${[...item].map(scopeType).join(" | ")} · ${origin}`;
402
+ }
403
+ }
404
+
405
+ // Doc-comment prose + structured tags (§E3). Prose first, then tags; `@example`
406
+ // fills the fenced slot; `@deprecated` strikes the name. Empty when absent.
407
+ const body = extractDoc(def);
408
+ if (body.doc) card.doc = body.doc;
409
+ if (body.example) card.example = body.example;
410
+ if (body.deprecated) card.name = `~~${def.name}~~`;
411
+
412
+ const footer: string[] = [provenance(def)];
413
+ // Full count including key-position call sites (usageCount excludes those).
414
+ // A command link opens the references view; the client's hover middleware
415
+ // trusts exactly this command (extension.ts). Plain text without an anchor.
416
+ // Only the hover's first definition card carries it: the count belongs to
417
+ // the NAME, so repeating it per meaning taught nothing.
418
+ if (withRefs) {
419
+ const refs = referencesFooter(data, def.name, at);
420
+ if (refs) footer.push(refs);
421
+ }
422
+ card.footer = footer;
423
+ return renderCard(card);
424
+ }
425
+
426
+ /** `file.txt:line` provenance, as a markdown link when a file URI is feasible. */
427
+ function provenance(def: { file: string; line: number }): string {
428
+ const label = `${path.basename(def.file)}:${def.line + 1}`;
429
+ // Plain text when no absolute path is available (fail-soft, e.g. synthetic defs).
430
+ if (!def.file || !path.isAbsolute(def.file)) return label;
431
+ const target = URI.file(def.file)
432
+ .with({ fragment: String(def.line + 1) })
433
+ .toString();
434
+ return `[${label}](${target})`;
435
+ }
436
+
437
+ /**
438
+ * Doc-comment / example extraction (§D2 mock 2, §E3). Reads the PdxDoc fields
439
+ * captured at index time (`Definition.doc`/`.tags`) and renders prose + tags.
440
+ * Fail-soft: an undocumented definition yields an empty body.
441
+ */
442
+ function extractDoc(def: Definition): ReturnType<typeof renderDocBody> {
443
+ return renderDocBody(def);
444
+ }
445
+
446
+ /** A saved-scope card: badge, name, `→ type`, save-site link, ambient doc (§D2 mock 3). */
447
+ function savedScopeCard(
448
+ data: ServerData,
449
+ document: TextDocument,
450
+ name: string,
451
+ rootScopes: Set<Scope> | null,
452
+ entry: SchemaEntry | null
453
+ ): string {
454
+ const ambient = entry?.ambientScopes?.find((a) => a.name === name);
455
+ const ictx = inferenceContextFor(data, entry);
456
+ const saved = getSavedScopes(document, data.scopeModel, rootScopes, entry?.ambientScopes, ictx);
457
+ const inferred = saved.get(name);
458
+ // Cross-file fallback: types merged over every indexed save site of the mod.
459
+ const global = inferred === undefined || inferred === null ? ictx.savedScopeTypes?.get(name) : undefined;
460
+ const typed = inferred && inferred.size > 0 ? inferred : global && global.size > 0 ? global : null;
461
+ const type = ambient?.type ?? (typed ? [...typed].join(" | ") : "unknown");
462
+
463
+ const card: CardInput = {
464
+ kind: "saved_scope",
465
+ name: `scope:${name}`,
466
+ headTail: `→ ${scopeType(type)}`,
467
+ };
468
+
469
+ const doc: string[] = [];
470
+ if (ambient) doc.push(`${ambient.doc}${entry ? ` *(${entry.kind.replace(/_/g, " ")})*` : ""}`);
471
+
472
+ const site = firstSaveSite(document, name);
473
+ if (site !== null) doc.push(`Saved in this file: ${path.basename(URIToPath(document.uri))}:${site + 1}`);
474
+ else if (ambient) doc.push(`Engine-provided (not saved in this file).`);
475
+ else if (!inferred) {
476
+ // Not saved here: link the mod's save sites (like the variable card does).
477
+ const sites = data.index
478
+ .lookup(name)
479
+ .filter((d) => d.kind === "saved_scope")
480
+ .slice(0, 3);
481
+ if (sites.length > 0) {
482
+ doc.push(
483
+ sites
484
+ .map((d) => `saved in [${path.basename(d.file)}:${d.line + 1}](${URI.file(d.file)}#L${d.line + 1})`)
485
+ .join(" \n")
486
+ );
487
+ } else {
488
+ doc.push(`Saved elsewhere in the mod.`);
489
+ }
490
+ }
491
+
492
+ if (doc.length > 0) card.doc = doc.join(" \n");
493
+ return renderCard(card);
494
+ }
495
+
496
+ /** Line (0-based) of the first `save_scope_as`/`save_temporary_scope_as`/
497
+ * `save_temporary_value_as = name` in the file. */
498
+ const SAVE_SITE_KEYS = new Set(["save_scope_as", "save_temporary_scope_as", "save_temporary_value_as"]);
499
+ function firstSaveSite(document: TextDocument, name: string): number | null {
500
+ const { result, lineIndex } = getParse(document);
501
+ let line: number | null = null;
502
+ walkStatements(result.root, (stmt) => {
503
+ if (line !== null) return;
504
+ if (stmt.kind !== "assignment" || stmt.key.quoted) return;
505
+ if (!SAVE_SITE_KEYS.has(stmt.key.text)) return;
506
+ if (stmt.value?.kind === "scalar" && !stmt.value.quoted && stmt.value.text === name) {
507
+ line = lineIndex.positionAt(stmt.value.range.start).line;
508
+ }
509
+ });
510
+ return line;
511
+ }
512
+
513
+ /** A structure-key card: KeySpec doc plus provenance (§B2). */
514
+ function structureKeyCard(
515
+ document: TextDocument,
516
+ position: Position,
517
+ word: string,
518
+ entry: SchemaEntry,
519
+ getSchema: () => SchemaData
520
+ ): string | null {
521
+ const { result, lineIndex } = getParse(document);
522
+ const offset = lineIndex.offsetAt(position);
523
+ const ctx = structureContextAt(result, offset, entry.kind, getSchema().structures);
524
+ if (!ctx) return null;
525
+ const spec = ctx.keys.get(word);
526
+ if (!spec) return null;
527
+ const source = getSchema().structures.source(entry.kind) ?? entry.kind;
528
+ const where = ctx.block ? `in \`${ctx.block}\`` : "";
529
+ const card: CardInput = {
530
+ kind: "structure_key",
531
+ badgeLabel: `${entry.kind.replace(/_/g, " ")} key`,
532
+ name: word,
533
+ };
534
+ if (where) card.headTail = where;
535
+ card.doc = spec.doc ? `${spec.doc} *(${source})*` : `*(${source})*`;
536
+ return renderCard(card);
537
+ }
538
+
539
+ /** `type = character_event` — the value is a member of the key's structure enum. */
540
+ function enumValueCard(
541
+ document: TextDocument,
542
+ position: Position,
543
+ word: string,
544
+ entry: SchemaEntry,
545
+ getSchema: () => SchemaData
546
+ ): string | null {
547
+ const { result, lineIndex } = getParse(document);
548
+ const offset = lineIndex.offsetAt(position);
549
+ const hit = nodeAtOffset(result.root, offset);
550
+ const last = hit?.path[hit.path.length - 1];
551
+ if (!last || last.kind !== "assignment" || last.key.quoted) return null;
552
+ if (last.value?.kind !== "scalar" || last.value.quoted) return null;
553
+ if (offset < last.value.range.start || offset > last.value.range.end) return null;
554
+ const ctx = structureContextAt(result, offset, entry.kind, getSchema().structures);
555
+ const spec = ctx?.keys.get(last.key.text);
556
+ if (!spec?.values?.startsWith("enum:")) return null;
557
+ const members = spec.values.slice(5).split("|");
558
+ if (!members.includes(word)) return null;
559
+ return renderCard({
560
+ kind: "structure_key",
561
+ badgeLabel: `${last.key.text} value`,
562
+ name: word,
563
+ doc: `One of: ${members.map((m) => (m === word ? `**${m}**` : m)).join(" · ")}`,
564
+ });
565
+ }
566
+
567
+ /** `my_effect = { AMOUNT = 3 }` — the key is a $PARAM$ of the called scripted effect/trigger. */
568
+ function macroParamCard(
569
+ data: ServerData,
570
+ document: TextDocument,
571
+ position: Position,
572
+ word: string
573
+ ): string | null {
574
+ const { result, lineIndex } = getParse(document);
575
+ const offset = lineIndex.offsetAt(position);
576
+ const hit = nodeAtOffset(result.root, offset);
577
+ const last = hit?.path[hit.path.length - 1];
578
+ if (!last || last.kind !== "assignment" || last.key.quoted) return null;
579
+ if (offset < last.key.range.start || offset > last.key.range.end) return null;
580
+ const enclosing = hit!.path[hit!.path.length - 2];
581
+ if (!enclosing || enclosing.kind !== "assignment" || enclosing.key.quoted) return null;
582
+ const callee = enclosing.key.text;
583
+ for (const def of data.index.lookup(callee)) {
584
+ if (def.kind !== "scripted_effect" && def.kind !== "scripted_trigger") continue;
585
+ if (!def.params?.includes(word)) continue;
586
+ return renderCard({
587
+ kind: "macro_param",
588
+ badgeLabel: "parameter",
589
+ name: word,
590
+ headTail: `of ${def.kind.replace(/_/g, " ")} \`${callee}\``,
591
+ doc: `Replaces \`$${word}$\` in the ${def.kind.replace(/_/g, " ")}'s body.`,
592
+ });
593
+ }
594
+ return null;
595
+ }
596
+
597
+ /**
598
+ * `has_relation_dao_guide` — triggers/effects the engine generates per scripted
599
+ * relation (`has_relation_X`, `set_relation_X`, `remove_relation_X`).
600
+ */
601
+ function relationTriggerCard(data: ServerData, word: string): string | null {
602
+ const m = /^(has|set|remove)_relation_([A-Za-z0-9_]+)$/.exec(word);
603
+ if (!m) return null;
604
+ const def = data.index.lookup(m[2]).find((d) => d.kind === "scripted_relation");
605
+ if (!def) return null;
606
+ const verb =
607
+ m[1] === "has"
608
+ ? "Trigger: the scoped character has"
609
+ : m[1] === "set"
610
+ ? "Effect: gives the scoped character"
611
+ : "Effect: removes the scoped character's";
612
+ return renderCard({
613
+ kind: m[1] === "has" ? "trigger" : "effect",
614
+ name: word,
615
+ headTail: `· generated from \`${m[2]}\``,
616
+ doc: `${verb} the scripted relation \`${m[2]}\` (${path.basename(def.file)}:${def.line + 1}) with the target character.`,
617
+ });
618
+ }
619
+
620
+ /**
621
+ * `french_opinion` — modifiers the engine generates per definition, matched
622
+ * lazily against the templated modifiers.log tags ($CULTURE$_opinion) and the
623
+ * definition index (never materialized: AGOT-scale mods define thousands of
624
+ * cultures).
625
+ */
626
+ function templatedModifierCard(data: ServerData, word: string): string | null {
627
+ const m = matchTemplatedModifier(word, data.modifierTemplates, (n) => data.index.lookup(n));
628
+ if (!m) return null;
629
+ const card: CardInput = {
630
+ kind: "modifier",
631
+ name: word,
632
+ headTail: `· generated from \`${m.template.name}\``,
633
+ doc: templatedModifierDoc(m),
634
+ };
635
+ if (m.template.traits) card.traits = m.template.traits.split("\n").join(" · ");
636
+ return renderCard(card);
637
+ }
638
+
639
+ /**
640
+ * `start_scheme = { target_character = … }` — the key is a block argument of an
641
+ * engine effect/trigger call; surface the call's own doc, which describes its
642
+ * arguments. Last-resort fallback: anything more specific wins.
643
+ */
644
+ function effectArgumentCard(
645
+ data: ServerData,
646
+ document: TextDocument,
647
+ position: Position,
648
+ word: string
649
+ ): string | null {
650
+ const { result, lineIndex } = getParse(document);
651
+ const offset = lineIndex.offsetAt(position);
652
+ const hit = nodeAtOffset(result.root, offset);
653
+ const last = hit?.path[hit.path.length - 1];
654
+ if (!last || last.kind !== "assignment" || last.key.quoted) return null;
655
+ if (offset < last.key.range.start || offset > last.key.range.end) return null;
656
+ const enclosing = hit!.path[hit!.path.length - 2];
657
+ if (!enclosing || enclosing.kind !== "assignment" || enclosing.key.quoted) return null;
658
+ const token = data.tokenMap.get(enclosing.key.text)?.[0];
659
+ if (!token) return null;
660
+ const card: CardInput = {
661
+ kind: "structure_key",
662
+ badgeLabel: "argument",
663
+ name: word,
664
+ headTail: `of ${token.kind.replace(/_/g, " ")} \`${token.name}\``,
665
+ };
666
+ if (token.doc) card.doc = token.doc;
667
+ return renderCard(card);
668
+ }
669
+
670
+ /** `cultivation_ruin` in `trigger_event = cultivation_ruin.5` etc. — a declared event namespace. */
671
+ function namespaceCard(data: ServerData, word: string): string | null {
672
+ if (!data.modNamespaces.has(word)) return null;
673
+ return renderCard({
674
+ kind: "namespace",
675
+ badgeLabel: "event namespace",
676
+ name: word,
677
+ doc: `Events in this namespace are named \`${word}.<n>\`.`,
678
+ });
679
+ }
680
+
681
+ /** The `define:NS|CONST` reference spanning the cursor, or null. */
682
+ function defineRefAt(
683
+ lineText: string,
684
+ character: number
685
+ ): { namespace: string; name: string; start: number; end: number } | null {
686
+ const re = /define:([A-Za-z0-9_]+)\|([A-Za-z0-9_]+)/g;
687
+ let m: RegExpExecArray | null;
688
+ while ((m = re.exec(lineText)) !== null) {
689
+ const start = m.index;
690
+ const end = m.index + m[0].length;
691
+ if (character >= start && character <= end) return { namespace: m[1], name: m[2], start, end };
692
+ }
693
+ return null;
694
+ }
695
+
696
+ /** A define card: name, resolved value, source file + layer, "overrides <layer>". */
697
+ function definesCard(data: ServerData, namespace: string, name: string): string | null {
698
+ const res = data.defines.resolve(namespace, name);
699
+ if (!res) return null;
700
+ const footer = [defineSourceLink(res.winner)];
701
+ if (res.shadowed.length > 0) footer.push(`overrides ${res.shadowed.map((s) => s.layer).join(", ")}`);
702
+ return renderCard({
703
+ kind: "define",
704
+ badgeLabel: "define",
705
+ name: `${namespace}|${name}`,
706
+ headTail: `= ${res.winner.value}`,
707
+ footer,
708
+ });
709
+ }
710
+
711
+ /** `<layer> · [common/defines/…:line](uri)` provenance for a define entry. */
712
+ function defineSourceLink(e: DefineEntry): string {
713
+ const norm = e.file.replace(/\\/g, "/");
714
+ const i = norm.toLowerCase().lastIndexOf("/common/defines/");
715
+ const rel = i >= 0 ? `${norm.slice(i + 1)}:${e.line + 1}` : `${path.basename(e.file)}:${e.line + 1}`;
716
+ const target = URI.file(e.file)
717
+ .with({ fragment: String(e.line + 1) })
718
+ .toString();
719
+ return `${e.layer} · [${rel}](${target})`;
720
+ }
721
+
722
+ /** root/ROOT/this/prev(prev…)/from(from…) — scope navigation keywords. */
723
+ function scopeWordCard(word: string): string | null {
724
+ const hit = scopeWordDoc(word);
725
+ if (!hit) return null;
726
+ return renderCard({ kind: "scope_word", badgeLabel: "scope", name: hit.name, doc: hit.doc });
727
+ }
728
+
729
+ /** Grammar/math glue vocabulary (limit, NOT, base, days…): curated docs. */
730
+ function keywordCard(word: string): string | null {
731
+ const doc = KEYWORD_DOCS[word];
732
+ if (!doc) return null;
733
+ return renderCard({ kind: "keyword", name: word, doc });
734
+ }
735
+
736
+ /**
737
+ * The definition kinds a ref field expects at this position, when the cursor
738
+ * sits on the VALUE of such a field (`theme = X` scalar form, `events = { X }`
739
+ * list form). null anywhere else — key position, non-ref keys, quoted values.
740
+ */
741
+ function refKindsAt(
742
+ document: TextDocument,
743
+ position: Position,
744
+ refFields: Map<string, RefField>
745
+ ): string[] | null {
746
+ const { result, lineIndex } = getParse(document);
747
+ const offset = lineIndex.offsetAt(position);
748
+ const hit = nodeAtOffset(result.root, offset);
749
+ if (!hit) return null;
750
+ const last = hit.path[hit.path.length - 1];
751
+
752
+ // `key = word` — scalar value of an assignment.
753
+ if (
754
+ last.kind === "assignment" &&
755
+ !last.key.quoted &&
756
+ last.value?.kind === "scalar" &&
757
+ !last.value.quoted &&
758
+ offset >= last.value.range.start &&
759
+ offset <= last.value.range.end
760
+ ) {
761
+ const field = refFields.get(last.key.text);
762
+ if (field) return field.form !== "list" ? field.kinds : null;
763
+ // Block-scoped ref keys (`trigger_event = { id = X }`,
764
+ // `override_background = { reference = X }`).
765
+ const enclosing = hit.path[hit.path.length - 2];
766
+ if (enclosing?.kind === "assignment" && !enclosing.key.quoted) {
767
+ return activeProfile().blockRefFields[enclosing.key.text.toLowerCase()]?.[last.key.text] ?? null;
768
+ }
769
+ return null;
770
+ }
771
+
772
+ // `key = { word ... }` — bare list element; the owning assignment is one up.
773
+ if (last.kind === "value" && last.value.kind === "scalar" && !last.value.quoted) {
774
+ const parent = hit.path[hit.path.length - 2];
775
+ if (parent?.kind === "assignment" && !parent.key.quoted) {
776
+ const field = refFields.get(parent.key.text);
777
+ return field && field.form !== "scalar" ? field.kinds : null;
778
+ }
779
+ }
780
+ return null;
781
+ }
782
+
783
+ /** The inferred scope set at the cursor, or null when inference is unavailable. */
784
+ function currentScopes(
785
+ data: ServerData,
786
+ document: TextDocument,
787
+ position: Position,
788
+ rootScopes: Set<Scope> | null,
789
+ entry: SchemaEntry | null
790
+ ): Set<string> | null {
791
+ const inference = scopeInference(data, document, position, rootScopes, entry);
792
+ return inference.scopes && inference.scopes.size > 0 ? inference.scopes : null;
793
+ }
794
+
795
+ function scopeInference(
796
+ data: ServerData,
797
+ document: TextDocument,
798
+ position: Position,
799
+ rootScopes: Set<Scope> | null,
800
+ entry: SchemaEntry | null
801
+ ): ReturnType<typeof inferScopeAt> {
802
+ const { result, lineIndex } = getParse(document);
803
+ const offset = lineIndex.offsetAt(position);
804
+ const ictx = inferenceContextFor(data, entry);
805
+ return inferScopeAt(
806
+ result,
807
+ offset,
808
+ data.scopeModel,
809
+ rootScopes,
810
+ getSavedScopes(document, data.scopeModel, rootScopes, entry?.ambientScopes, ictx),
811
+ ictx
812
+ );
813
+ }
814
+
815
+ function URIToPath(uri: string): string {
816
+ return uri.replace(/^file:\/\/\/?/, "").replace(/\//g, path.sep);
817
+ }