@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,245 @@
1
+ /**
2
+ * paradox/guiWidgetInfo backend: the properties of ONE widget, with the
3
+ * template/type chain each value came from. This is the designer inspector's
4
+ * read side.
5
+ *
6
+ * Two rules keep it honest:
7
+ * - It addresses the widget the way the WRITER does (`findWidgetAtLine` over
8
+ * the source model), so what the inspector lists is what a `setProperties`
9
+ * op would rewrite, on the same line, in the same file.
10
+ * - It resolves the widget the way the ENGINE does (`effectiveDefs` +
11
+ * `expandWidgetWithOrigins`), so it cannot list a value the canvas did not
12
+ * lay the widget out with. Last-in-wins per key, exactly as the engine reads
13
+ * an expanded body.
14
+ *
15
+ * Values are rendered from the parser's own tokens rather than sliced out of
16
+ * the document: a property inherited from a type lives in another file, whose
17
+ * text the def store does not keep.
18
+ *
19
+ * Three answers ride on the same expansion, so none of them can disagree with
20
+ * the property list: what a row OVERRIDES (the values the same key had before
21
+ * it), the TEXTURES the widget draws (with their frame-sheet grid), and, when
22
+ * the caller asks for it, the PLACEMENT trace explaining the widget's rect.
23
+ * The placement trace is the only one that costs a layout run, so it is the
24
+ * only one behind a flag.
25
+ *
26
+ * No `vscode` imports: unit-tested in plain Node.
27
+ */
28
+ import type {
29
+ GuiPlacement,
30
+ GuiTextureInfo,
31
+ GuiWidgetInfo,
32
+ GuiWidgetOverride,
33
+ GuiWidgetProperty,
34
+ } from "@px-lsp/protocol/protocol";
35
+ import type { BlockNode, Statement, ValueNode } from "../parser";
36
+ import {
37
+ computeGuiLayout,
38
+ effectiveDefs,
39
+ PROPERTY_BLOCKS,
40
+ resolveFill,
41
+ type Fill,
42
+ type PlacementExplain,
43
+ } from "./layoutEngine";
44
+ import { expandWidgetWithOrigins, typeBaseChain, type GuiDefs } from "./guiDefs";
45
+ import { profileMeasurer } from "./layoutService";
46
+ import { findWidgetAtLine, parseGuiSource, type GuiSourceFile } from "./sourceModel";
47
+ import { describeTexture, type TextureRoots } from "./textureInfo";
48
+
49
+ /** Marker words whose assignment is a named slot, not a property. */
50
+ const SLOT_KEYS = new Set(["block", "blockoverride"]);
51
+
52
+ /** Marker words that turn the FOLLOWING assignment into a declaration. */
53
+ const DECL_MARKERS = new Set(["template", "local_template", "types", "type", "block", "blockoverride"]);
54
+
55
+ export interface WidgetInfoOptions {
56
+ /** Run the layout with an explanation trace and fill `placement`. */
57
+ placement?: boolean;
58
+ /** Roots a texture path resolves against; without them a texture row carries
59
+ * the path alone (no sheet size, so no grid). */
60
+ roots?: TextureRoots;
61
+ /** Viewport the placement trace lays out against; the service's own default. */
62
+ viewport?: { w: number; h: number };
63
+ }
64
+
65
+ export function computeGuiWidgetInfo(
66
+ text: string,
67
+ line: number,
68
+ store?: GuiDefs,
69
+ options?: WidgetInfoOptions
70
+ ): GuiWidgetInfo | null {
71
+ const file = parseGuiSource(text);
72
+ const target = findWidgetAtLine(file, line);
73
+ if (!target || !target.block) return null;
74
+ // A template/type DECLARATION is not an instance: expanding its own name
75
+ // would splice the definition into itself. The canvas never selects one.
76
+ if (target.marker) return null;
77
+
78
+ const defs = effectiveDefs(text, store);
79
+ const expanded = expandWidgetWithOrigins(target.key, target.block, defs);
80
+
81
+ // Last-in-wins per key, and the winner keeps the position it was written at:
82
+ // inherited rows stay where the type put them, an override moves down to the
83
+ // instance body, which is where its bytes are.
84
+ const byKey = new Map<string, GuiWidgetProperty>();
85
+ // The same winners as raw nodes, for the readers that need structure rather
86
+ // than a rendering (textures, frame sheets).
87
+ const rawByKey = new Map<string, ValueNode>();
88
+ let marker: string | null = null;
89
+ for (const { stmt, origin } of expanded.statements) {
90
+ if (stmt.kind === "value") {
91
+ marker =
92
+ stmt.value.kind === "scalar" && DECL_MARKERS.has(stmt.value.text.toLowerCase())
93
+ ? stmt.value.text.toLowerCase()
94
+ : null;
95
+ continue;
96
+ }
97
+ const wasDecl = marker !== null;
98
+ marker = null;
99
+ if (wasDecl || !stmt.value) continue;
100
+ const keyLower = stmt.key.text.toLowerCase();
101
+ if (SLOT_KEYS.has(keyLower)) continue;
102
+ // The inverse rule the writer and the engine share: a block child is a
103
+ // widget unless its key is a known attribute block.
104
+ if (stmt.value.kind === "block" && !PROPERTY_BLOCKS.has(keyLower)) continue;
105
+ const shadowed = byKey.get(keyLower);
106
+ // The shadowed value is the "overrides X from template Y" note: it is the
107
+ // engine's own discard, recorded where the discard happens rather than
108
+ // reconstructed from a second walk.
109
+ const overrides: GuiWidgetOverride[] | undefined = shadowed
110
+ ? [...(shadowed.overrides ?? []), { value: shadowed.value, origin: shadowed.origin }]
111
+ : undefined;
112
+ byKey.delete(keyLower);
113
+ byKey.set(keyLower, {
114
+ key: stmt.key.text,
115
+ value: renderValue(stmt.value),
116
+ origin,
117
+ ...(overrides ? { overrides } : {}),
118
+ });
119
+ rawByKey.set(keyLower, stmt.value);
120
+ }
121
+
122
+ const info: GuiWidgetInfo = {
123
+ key: target.key,
124
+ name: nameOf(expanded.statements),
125
+ typeChain: typeBaseChain(target.key, [defs]),
126
+ properties: [...byKey.values()],
127
+ textures: texturesOf(rawByKey, constantsIn(file), defs, options?.roots),
128
+ };
129
+
130
+ if (options?.placement) {
131
+ // The trace is what the flag gates: an ordinary layout never records it,
132
+ // so the default path stays at today's cost.
133
+ const explain: PlacementExplain = { line: target.line };
134
+ computeGuiLayout(text, { defs: store, viewport: options.viewport, explain, measurer: profileMeasurer() });
135
+ // Placement is structurally the wire type, like LayoutNode is.
136
+ if (explain.result) info.placement = explain.result as unknown as GuiPlacement;
137
+ }
138
+ return info;
139
+ }
140
+
141
+ /** The widget's effective `name`, last-in-wins like every other scalar. */
142
+ function nameOf(statements: readonly { stmt: Statement }[]): string | undefined {
143
+ let found: string | undefined;
144
+ for (const { stmt } of statements) {
145
+ if (stmt.kind !== "assignment" || stmt.value?.kind !== "scalar") continue;
146
+ if (stmt.key.text.toLowerCase() === "name") found = stmt.value.text;
147
+ }
148
+ return found;
149
+ }
150
+
151
+ /** Top-level `@name = 42` constants, which a `framesize` or `frame` may use. */
152
+ function constantsIn(file: GuiSourceFile): Map<string, number> {
153
+ const consts = new Map<string, number>();
154
+ for (const entry of file.root.entries) {
155
+ if (!entry.key.startsWith("@") || entry.valueKind !== "scalar" || entry.value === null) continue;
156
+ const v = parseFloat(entry.value);
157
+ if (Number.isFinite(v)) consts.set(entry.key, v);
158
+ }
159
+ return consts;
160
+ }
161
+
162
+ /**
163
+ * The textures the widget draws: its own fill first, then its background. Read
164
+ * off the same last-in-wins winners the property list came from, and the
165
+ * background through the engine's own `resolveFill`, so a row here names a
166
+ * texture the canvas drew.
167
+ */
168
+ function texturesOf(
169
+ raw: Map<string, ValueNode>,
170
+ consts: Map<string, number>,
171
+ defs: GuiDefs,
172
+ roots?: TextureRoots
173
+ ): GuiTextureInfo[] {
174
+ const out: GuiTextureInfo[] = [];
175
+ const own = raw.get("texture");
176
+ if (own?.kind === "scalar") {
177
+ const framesize = numberPair(raw.get("framesize"), consts);
178
+ out.push(
179
+ describeTexture(
180
+ {
181
+ texture: own.text,
182
+ framesize,
183
+ frame: framesize ? numberOf(raw.get("frame"), consts) : undefined,
184
+ },
185
+ "fill",
186
+ roots
187
+ )
188
+ );
189
+ }
190
+ const background = raw.get("background");
191
+ const block: BlockNode | null =
192
+ background?.kind === "block" ? background : background?.kind === "tagged-block" ? background.block : null;
193
+ if (block) {
194
+ const fill: Fill = resolveFill(block, consts, defs);
195
+ if (fill.texture !== undefined) {
196
+ out.push(
197
+ describeTexture(
198
+ { texture: fill.texture, framesize: fill.framesize, frame: fill.frame },
199
+ "background",
200
+ roots
201
+ )
202
+ );
203
+ }
204
+ }
205
+ return out;
206
+ }
207
+
208
+ function numberOf(value: ValueNode | undefined, consts: Map<string, number>): number | undefined {
209
+ if (value?.kind !== "scalar") return undefined;
210
+ if (value.text.startsWith("@")) return consts.get(value.text);
211
+ const v = parseFloat(value.text);
212
+ return Number.isFinite(v) ? v : undefined;
213
+ }
214
+
215
+ function numberPair(value: ValueNode | undefined, consts: Map<string, number>): [number, number] | undefined {
216
+ if (value?.kind !== "block") return undefined;
217
+ const nums: number[] = [];
218
+ for (const s of value.statements) {
219
+ if (s.kind !== "value" || s.value.kind !== "scalar") continue;
220
+ const n = numberOf(s.value, consts);
221
+ nums.push(n ?? 0);
222
+ }
223
+ return nums.length >= 2 ? [nums[0], nums[1]] : undefined;
224
+ }
225
+
226
+ /**
227
+ * A value as authored, from the tokens alone. Quoted scalars keep their quotes
228
+ * (`.gui` distinguishes them and the row should read like the source); a block
229
+ * renders with single spaces, so the rendering is independent of the interior
230
+ * whitespace of a file this reader may not have.
231
+ */
232
+ function renderValue(value: ValueNode): string {
233
+ if (value.kind === "scalar") return value.quoted ? `"${value.text}"` : value.text;
234
+ if (value.kind === "tagged-block") return `${value.tag.text} ${renderValue(value.block)}`;
235
+ const parts = value.statements.map(renderStatement);
236
+ return parts.length === 0 ? "{}" : `{ ${parts.join(" ")} }`;
237
+ }
238
+
239
+ function renderStatement(stmt: Statement): string {
240
+ if (stmt.kind === "value") return renderValue(stmt.value);
241
+ const key = stmt.key.quoted ? `"${stmt.key.text}"` : stmt.key.text;
242
+ if (!stmt.value) return stmt.op === null ? key : `${key} ${stmt.op}`;
243
+ const value = renderValue(stmt.value);
244
+ return stmt.op === null ? `${key} ${value}` : `${key} ${stmt.op} ${value}`;
245
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * PdxDoc doc comments (§E). A contiguous `#` comment block immediately above a
3
+ * definition (no blank line between) documents it. Plain lines are prose;
4
+ * `@tag` lines are structured.
5
+ *
6
+ * Extraction works off the raw file lines above a definition's line — no CST
7
+ * re-parse, encoding-safe (BOM/CRLF handled by the caller splitting on \n and
8
+ * this module trimming \r). Cost is near zero at index time.
9
+ *
10
+ * No `vscode` imports here: unit-tested in plain Node.
11
+ */
12
+
13
+ /** Recognized structured tags (§E1). Unknown `@tags` render as prose. */
14
+ export const KNOWN_DOC_TAGS = new Set(["scope", "param", "saves", "returns", "example", "deprecated"]);
15
+
16
+ /** One structured tag line: `@scope`, `@param NAME desc`, `@example`, … */
17
+ export interface DocTag {
18
+ /** Tag name without the leading `@` (e.g. "param", "scope"). */
19
+ tag: string;
20
+ /** Everything after the tag word (e.g. "DYNASTY_HOUSE the house…"). Empty for bare tags. */
21
+ text: string;
22
+ }
23
+
24
+ export interface DocBlock {
25
+ /** Prose lines joined; capped at PROSE_CAP. Empty string when no prose. */
26
+ doc: string;
27
+ tags: DocTag[];
28
+ }
29
+
30
+ /** Prose is capped so the index stays lean (~338 K defs on AGOT). */
31
+ export const PROSE_CAP = 1000;
32
+
33
+ /**
34
+ * True for a separator line — a comment that is only `#`/punctuation/repeats
35
+ * with no prose (`####`, `#---`, `# ===`, `# ***`). These are section dividers,
36
+ * not documentation, and are skipped (§E2). A line with any letter/digit after
37
+ * the `#` run is real prose, not a separator.
38
+ */
39
+ function isSeparator(afterHash: string): boolean {
40
+ const t = afterHash.trim();
41
+ if (t === "") return false; // a blank comment line is a spacer, handled separately
42
+ return !/[A-Za-z0-9]/.test(t);
43
+ }
44
+
45
+ /**
46
+ * Parse a comment block (top-to-bottom, in file order) into prose + tags.
47
+ * Lines must already be stripped of their trailing \r and be `#`-comment lines
48
+ * (leading whitespace allowed). Separator lines are dropped.
49
+ */
50
+ export function parseDocBlock(commentLines: string[]): DocBlock | null {
51
+ const proseParts: string[] = [];
52
+ const tags: DocTag[] = [];
53
+
54
+ for (const raw of commentLines) {
55
+ const line = raw.replace(/\r$/, "");
56
+ const trimmed = line.trim();
57
+ if (!trimmed.startsWith("#")) continue;
58
+ const afterHash = trimmed.replace(/^#+[ \t]?/, "");
59
+ if (isSeparator(trimmed.slice(1))) continue; // slice off first `#` for the check
60
+ if (afterHash.trim() === "") continue; // blank comment line inside the block: skip
61
+
62
+ const tagMatch = /^@([A-Za-z_][A-Za-z0-9_]*)\b[ \t]*(.*)$/.exec(afterHash);
63
+ if (tagMatch && KNOWN_DOC_TAGS.has(tagMatch[1].toLowerCase())) {
64
+ tags.push({ tag: tagMatch[1].toLowerCase(), text: tagMatch[2].trim() });
65
+ } else {
66
+ proseParts.push(afterHash.trim());
67
+ }
68
+ }
69
+
70
+ if (proseParts.length === 0 && tags.length === 0) return null;
71
+
72
+ let doc = proseParts.join(" ");
73
+ if (doc.length > PROSE_CAP) doc = doc.slice(0, PROSE_CAP);
74
+ return { doc, tags };
75
+ }
76
+
77
+ /**
78
+ * Given all file lines (split on `\n`, so entries may retain trailing `\r`) and
79
+ * the 0-based line of a definition, return its attached doc block or null.
80
+ *
81
+ * Walks upward collecting contiguous `#` comment lines. Stops at the first
82
+ * blank/non-comment line — a blank line detaches the block (§E2). Separator
83
+ * lines interrupt attachment too: a divider above the def is not documentation.
84
+ */
85
+ export function docForDefinition(lines: string[], defLine: number): DocBlock | null {
86
+ const collected: string[] = [];
87
+ for (let i = defLine - 1; i >= 0; i--) {
88
+ const line = lines[i] ?? "";
89
+ const trimmed = line.replace(/\r$/, "").trim();
90
+ if (trimmed === "") break; // blank line detaches
91
+ if (!trimmed.startsWith("#")) break; // code line detaches
92
+ // A pure separator directly adjacent detaches the block (a divider is not doc);
93
+ // but a separator further up simply bounds an already-collected block.
94
+ if (isSeparator(trimmed.slice(1))) {
95
+ if (collected.length === 0) return null;
96
+ break;
97
+ }
98
+ collected.push(line);
99
+ }
100
+ if (collected.length === 0) return null;
101
+ collected.reverse();
102
+ return parseDocBlock(collected);
103
+ }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Schema-driven definition extraction: given a file's content and its schema
3
+ * entry, produce the definitions it contains. This is what the per-entry
4
+ * schema fixture tests exercise.
5
+ *
6
+ * No `vscode` imports here: unit-tested in plain Node.
7
+ */
8
+ import type { Definition, DefSource } from "@px-lsp/protocol/types";
9
+ import type { SchemaEntry } from "../schema/types";
10
+ import { LineIndex, parseLoc, parseScript, walkStatements, type Statement } from "../parser";
11
+ import { docForDefinition } from "./docComments";
12
+ import { shareDefinitionStrings } from "./intern";
13
+ import { activeProfile } from "../games/active";
14
+
15
+ /** Max loc value length kept in memory; the edit flow re-reads the yml from disk. */
16
+ export const LOC_VALUE_LIMIT = 200;
17
+
18
+ const DEF_NAME = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
19
+ export const EVENT_ID = /^[A-Za-z0-9_-]+\.\d+$/;
20
+ const TITLE_KEY = /^[ekdcb]_[A-Za-z0-9_-]+$/;
21
+
22
+ export function extractDefinitions(
23
+ content: string,
24
+ entry: SchemaEntry,
25
+ file: string,
26
+ source: DefSource
27
+ ): Definition[] {
28
+ const extraction = entry.extraction ?? "top-level-key";
29
+ if (extraction === "loc-key") return extractLocDefinitions(content, file, source);
30
+
31
+ const defs: Definition[] = [];
32
+ const { root } = parseScript(content);
33
+ const lines = new LineIndex(content);
34
+ // Raw lines (split on \n; entries may keep a trailing \r) for encoding-safe
35
+ // leading-comment capture (§E). Computed once per file, near-zero cost.
36
+ const rawLines = content.split("\n");
37
+ const push = (name: string, offset: number, container?: string) => {
38
+ const line = lines.positionAt(offset).line;
39
+ const def: Definition = { name, kind: entry.kind, file, line, source };
40
+ if (container !== undefined) def.container = container;
41
+ const block = docForDefinition(rawLines, line);
42
+ if (block) {
43
+ if (block.doc) def.doc = block.doc;
44
+ if (block.tags.length > 0) def.tags = block.tags;
45
+ }
46
+ defs.push(def);
47
+ };
48
+
49
+ // Scripted effects/triggers/modifiers can declare $PARAM$ parameters in their body.
50
+ const harvestParams =
51
+ entry.kind === "scripted_effect" ||
52
+ entry.kind === "scripted_trigger" ||
53
+ entry.kind === "scripted_modifier";
54
+ const PARAM = /\$([A-Za-z0-9_]+)(?:\|[^$\n]*)?\$/g;
55
+
56
+ // Names declared INSIDE a definition body but referenced like definitions
57
+ // elsewhere: trait `group = X` (has_trait accepts the group) and game-concept
58
+ // `alias = { a b }` (loc [Concept] links). Deduped per file.
59
+ const seenInnerNames = new Set<string>();
60
+
61
+ // Database entry modes (`REPLACE:key = { ... }`), for games whose profile
62
+ // declares them: index under the bare name, keep the mode on the Definition.
63
+ const entryModes = activeProfile().entryModes;
64
+ const modePrefix = entryModes?.length ? new RegExp(`^(${entryModes.join("|")}):`) : null;
65
+
66
+ switch (extraction) {
67
+ case "top-level-key":
68
+ for (const stmt of root.statements) {
69
+ if (stmt.kind !== "assignment" || stmt.key.quoted) continue;
70
+ if (stmt.op !== "=" && stmt.op !== "?=") continue;
71
+ let name = stmt.key.text;
72
+ let entryMode: string | undefined;
73
+ const mode = modePrefix?.exec(name);
74
+ if (mode) {
75
+ entryMode = mode[1];
76
+ name = name.slice(mode[0].length);
77
+ }
78
+ if (!DEF_NAME.test(name) || name === "namespace") continue;
79
+ push(name, stmt.key.range.start);
80
+ if (entryMode) defs[defs.length - 1].entryMode = entryMode;
81
+ if (harvestParams) {
82
+ const body = content.slice(stmt.range.start, stmt.range.end);
83
+ const params: string[] = [];
84
+ PARAM.lastIndex = 0;
85
+ let m: RegExpExecArray | null;
86
+ while ((m = PARAM.exec(body)) !== null) {
87
+ if (!params.includes(m[1])) params.push(m[1]);
88
+ }
89
+ if (params.length > 0) defs[defs.length - 1].params = params;
90
+ }
91
+ // Scripted lists generate every_/any_/random_/ordered_<name> iterators;
92
+ // the `base` link decides the iterated scope (scopes/model.ts consumes it).
93
+ if (entry.kind === "scripted_list" && stmt.value?.kind === "block") {
94
+ for (const s of stmt.value.statements) {
95
+ if (s.kind !== "assignment" || s.key.quoted || s.key.text !== "base") continue;
96
+ if (s.value?.kind !== "scalar" || s.value.quoted) continue;
97
+ defs[defs.length - 1].value = s.value.text;
98
+ break;
99
+ }
100
+ }
101
+ if (entry.kind === "trait" && stmt.value?.kind === "block") {
102
+ for (const s of stmt.value.statements) {
103
+ if (s.kind !== "assignment" || s.key.quoted || s.key.text !== "group") continue;
104
+ if (s.value?.kind !== "scalar" || s.value.quoted || !DEF_NAME.test(s.value.text)) continue;
105
+ if (seenInnerNames.has(s.value.text)) continue;
106
+ seenInnerNames.add(s.value.text);
107
+ defs.push({
108
+ name: s.value.text,
109
+ kind: "trait_group",
110
+ file,
111
+ line: lines.positionAt(s.value.range.start).line,
112
+ source,
113
+ container: name,
114
+ });
115
+ }
116
+ }
117
+ if (entry.kind === "game_concept" && stmt.value?.kind === "block") {
118
+ for (const s of stmt.value.statements) {
119
+ if (
120
+ s.kind !== "assignment" ||
121
+ s.key.quoted ||
122
+ s.key.text !== "alias" ||
123
+ s.value?.kind !== "block"
124
+ )
125
+ continue;
126
+ for (const el of s.value.statements) {
127
+ if (el.kind !== "value" || el.value.kind !== "scalar" || el.value.quoted) continue;
128
+ if (!DEF_NAME.test(el.value.text) || seenInnerNames.has(el.value.text)) continue;
129
+ seenInnerNames.add(el.value.text);
130
+ defs.push({
131
+ name: el.value.text,
132
+ kind: "game_concept",
133
+ file,
134
+ line: lines.positionAt(el.value.range.start).line,
135
+ source,
136
+ container: name,
137
+ });
138
+ }
139
+ }
140
+ }
141
+ }
142
+ break;
143
+
144
+ case "event-id": {
145
+ // Event files may also declare `scripted_trigger NAME = { ... }` /
146
+ // `scripted_effect NAME = { ... }` inline (file-local, but referenced
147
+ // like their common/ counterparts). The tolerant parser reads the
148
+ // keyword as a bare marker scalar followed by the NAME assignment.
149
+ // Scanned at EVERY depth: one tolerant-parse hiccup earlier in a
150
+ // thousand-line vanilla file nests everything after it inside a block,
151
+ // and the declarations must survive that (#5). Event ids stay
152
+ // top-level only — nested ids are option/trigger content, not events.
153
+ const scanList = (statements: Statement[], depth: number) => {
154
+ let inlineKind: "scripted_trigger" | "scripted_effect" | null = null;
155
+ for (const stmt of statements) {
156
+ if (stmt.kind === "value") {
157
+ const text = stmt.value.kind === "scalar" && !stmt.value.quoted ? stmt.value.text : "";
158
+ inlineKind = text === "scripted_trigger" || text === "scripted_effect" ? text : null;
159
+ if (stmt.value.kind === "block") scanList(stmt.value.statements, depth + 1);
160
+ else if (stmt.value.kind === "tagged-block") scanList(stmt.value.block.statements, depth + 1);
161
+ continue;
162
+ }
163
+ const marker = inlineKind;
164
+ inlineKind = null;
165
+ if (stmt.key.quoted) continue;
166
+ if (stmt.op === "=" || stmt.op === "?=") {
167
+ if (marker && DEF_NAME.test(stmt.key.text)) {
168
+ const line = lines.positionAt(stmt.key.range.start).line;
169
+ const def: Definition = { name: stmt.key.text, kind: marker, file, line, source };
170
+ const block = docForDefinition(rawLines, line);
171
+ if (block) {
172
+ if (block.doc) def.doc = block.doc;
173
+ if (block.tags.length > 0) def.tags = block.tags;
174
+ }
175
+ const body = content.slice(stmt.range.start, stmt.range.end);
176
+ const params: string[] = [];
177
+ PARAM.lastIndex = 0;
178
+ let m: RegExpExecArray | null;
179
+ while ((m = PARAM.exec(body)) !== null) {
180
+ if (!params.includes(m[1])) params.push(m[1]);
181
+ }
182
+ if (params.length > 0) def.params = params;
183
+ defs.push(def);
184
+ } else if (depth === 0 && EVENT_ID.test(stmt.key.text)) {
185
+ push(stmt.key.text, stmt.key.range.start);
186
+ }
187
+ }
188
+ if (stmt.value?.kind === "block") scanList(stmt.value.statements, depth + 1);
189
+ else if (stmt.value?.kind === "tagged-block") scanList(stmt.value.block.statements, depth + 1);
190
+ }
191
+ };
192
+ scanList(root.statements, 0);
193
+ break;
194
+ }
195
+
196
+ case "nested-title":
197
+ // Landed titles nest: e_empire { k_kingdom { d_duchy { c_county { b_barony } } } }
198
+ walkStatements(root, (stmt: Statement, ancestors) => {
199
+ if (stmt.kind !== "assignment" || stmt.key.quoted) return;
200
+ if (!TITLE_KEY.test(stmt.key.text)) return;
201
+ if (stmt.value?.kind !== "block") return;
202
+ // Container: the nearest ancestor assignment that is itself a title.
203
+ let container: string | undefined;
204
+ for (let i = ancestors.length - 1; i >= 0; i--) {
205
+ const a = ancestors[i];
206
+ if (a.kind === "assignment" && TITLE_KEY.test(a.key.text)) {
207
+ container = a.key.text;
208
+ break;
209
+ }
210
+ }
211
+ push(stmt.key.text, stmt.key.range.start, container);
212
+ });
213
+ break;
214
+
215
+ case "gui-type": {
216
+ // GUI: `type NAME = base { ... }`, `template NAME { ... }` and
217
+ // `local_template NAME { ... }`. The tolerant parser reads these as a bare
218
+ // marker scalar statement followed by an assignment whose key is the NAME.
219
+ // `types Group { ... }` wraps the `type` pattern one level down.
220
+ const scan = (statements: Statement[]) => {
221
+ for (let i = 0; i < statements.length - 1; i++) {
222
+ const marker = statements[i];
223
+ if (marker.kind !== "value" || marker.value.kind !== "scalar" || marker.value.quoted) continue;
224
+ const kw = marker.value.text.toLowerCase();
225
+ if (kw !== "type" && kw !== "template" && kw !== "local_template" && kw !== "types") continue;
226
+ const named = statements[i + 1];
227
+ if (named.kind !== "assignment" || named.key.quoted) continue;
228
+ if (kw === "types") {
229
+ if (named.value?.kind === "block") scan(named.value.statements);
230
+ } else if (DEF_NAME.test(named.key.text)) {
231
+ push(named.key.text, named.key.range.start);
232
+ }
233
+ }
234
+ };
235
+ scan(root.statements);
236
+ break;
237
+ }
238
+ }
239
+ // Every string above is a slice of `content` and would pin the whole file for
240
+ // as long as the index holds the definition (§C2).
241
+ return shareDefinitionStrings(defs);
242
+ }
243
+
244
+ export function extractLocDefinitions(content: string, file: string, source: DefSource): Definition[] {
245
+ const defs: Definition[] = [];
246
+ for (const entry of parseLoc(content).entries) {
247
+ let value = entry.value;
248
+ if (value.length > LOC_VALUE_LIMIT) value = value.slice(0, LOC_VALUE_LIMIT);
249
+ defs.push({ name: entry.key, kind: "loc_key", file, line: entry.line, source, value });
250
+ }
251
+ return shareDefinitionStrings(defs);
252
+ }