@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.
- package/LICENSE +674 -0
- package/README.md +334 -0
- package/THIRD-PARTY-NOTICES.md +83 -0
- package/data/ck3/dataTypes.json +2195 -0
- package/data/ck3/data_types/data_types_common.txt +2040 -0
- package/data/ck3/data_types/data_types_gui.txt +5264 -0
- package/data/ck3/data_types/data_types_internalclausewitzgui.txt +14843 -0
- package/data/ck3/data_types/data_types_script.txt +4251 -0
- package/data/ck3/data_types/data_types_uncategorized.txt +109984 -0
- package/data/ck3/freqs.json +1 -0
- package/data/ck3/guiSchema.json +6344 -0
- package/data/ck3/script_docs/effects.log +16059 -0
- package/data/ck3/script_docs/event_targets.log +2098 -0
- package/data/ck3/script_docs/modifiers.log +2228 -0
- package/data/ck3/script_docs/on_actions.log +5275 -0
- package/data/ck3/script_docs/triggers.log +11991 -0
- package/data/ck3/structures.json +9743 -0
- package/data/ck3/wikidocs/ATTRIBUTION.md +18 -0
- package/data/ck3/wikidocs/Data_types.md +2568 -0
- package/data/ck3/wikidocs/Effects_list.md +1176 -0
- package/data/ck3/wikidocs/Scopes_list.md +341 -0
- package/data/ck3/wikidocs/Triggers_list.md +1097 -0
- package/data/eu5/data_types/data_types_common.txt +2087 -0
- package/data/eu5/data_types/data_types_gui.txt +6732 -0
- package/data/eu5/data_types/data_types_internalclausewitzgui.txt +19276 -0
- package/data/eu5/data_types/data_types_script.txt +5688 -0
- package/data/eu5/data_types/data_types_uncategorized.txt +135569 -0
- package/data/vic3/data_types/data_types_common.txt +2021 -0
- package/data/vic3/data_types/data_types_gui.txt +5592 -0
- package/data/vic3/data_types/data_types_internalclausewitzgui.txt +17304 -0
- package/data/vic3/data_types/data_types_script.txt +2817 -0
- package/data/vic3/data_types/data_types_uncategorized.txt +84354 -0
- package/data/vic3/freqs.json +1 -0
- package/data/vic3/guiSchema.json +5578 -0
- package/data/vic3/script_docs/effects.log +38135 -0
- package/data/vic3/script_docs/event_targets.log +2028 -0
- package/data/vic3/script_docs/modifiers.log +18954 -0
- package/data/vic3/script_docs/on_actions.log +1561 -0
- package/data/vic3/script_docs/triggers.log +15738 -0
- package/data/vic3/structures.json +10189 -0
- package/dist/server.js +63668 -0
- package/media/px-lsp.svg +12 -0
- package/package.json +50 -0
- package/src/clientMode.ts +60 -0
- package/src/coa/coa.ts +184 -0
- package/src/coa/coaParse.ts +267 -0
- package/src/context.ts +78 -0
- package/src/contextKeywords.ts +224 -0
- package/src/data/dataBindingMacros.ts +82 -0
- package/src/data/dataFnDocs.ts +152 -0
- package/src/data/dataFnUsage.ts +431 -0
- package/src/data/dataTypes.ts +279 -0
- package/src/data/defines.ts +123 -0
- package/src/data/docsParser.ts +453 -0
- package/src/data/keywordDocs.ts +98 -0
- package/src/data/modifierTemplates.ts +143 -0
- package/src/data/textFormatting.ts +165 -0
- package/src/data/wikiDocs.ts +187 -0
- package/src/dds/decoder.ts +1007 -0
- package/src/dds/encode.ts +235 -0
- package/src/dds/index.ts +58 -0
- package/src/dds/png.ts +96 -0
- package/src/dds/tga.ts +62 -0
- package/src/documents.ts +35 -0
- package/src/features/assetPaths.ts +169 -0
- package/src/features/codeActions.ts +148 -0
- package/src/features/colors.ts +244 -0
- package/src/features/completion.ts +961 -0
- package/src/features/datafunction.ts +729 -0
- package/src/features/definition.ts +84 -0
- package/src/features/diagnostics.ts +244 -0
- package/src/features/folding.ts +106 -0
- package/src/features/formatting.ts +60 -0
- package/src/features/guiLanguage.ts +366 -0
- package/src/features/guiNavigation.ts +140 -0
- package/src/features/guiTree.ts +97 -0
- package/src/features/hover.ts +817 -0
- package/src/features/hoverRender.ts +222 -0
- package/src/features/inlayHints.ts +147 -0
- package/src/features/locFormatting.ts +127 -0
- package/src/features/references.ts +70 -0
- package/src/features/rename.ts +135 -0
- package/src/features/scopeAt.ts +65 -0
- package/src/features/semanticTokens.ts +188 -0
- package/src/features/signatureHelp.ts +72 -0
- package/src/features/symbols.ts +241 -0
- package/src/features/textureHover.ts +143 -0
- package/src/features/workspaceSymbols.ts +69 -0
- package/src/games/active.ts +19 -0
- package/src/games/ck3/ambientScopes.ts +273 -0
- package/src/games/ck3/index.ts +38 -0
- package/src/games/ck3/meta.ts +28 -0
- package/src/games/ck3/modifierPlaceholders.ts +61 -0
- package/src/games/ck3/saveSchema.ts +134 -0
- package/src/games/ck3/scaffolds.ts +197 -0
- package/src/games/ck3/schema.ts +422 -0
- package/src/games/ck3/structures.ts +887 -0
- package/src/games/eu5/index.ts +75 -0
- package/src/games/eu5/meta.ts +44 -0
- package/src/games/eu5/scaffolds.ts +49 -0
- package/src/games/eu5/schema.generated.ts +1043 -0
- package/src/games/jomini/variables.ts +134 -0
- package/src/games/profile.ts +205 -0
- package/src/games/registry.ts +27 -0
- package/src/games/vic3/index.ts +52 -0
- package/src/games/vic3/meta.ts +55 -0
- package/src/games/vic3/saveSchema.ts +77 -0
- package/src/games/vic3/scaffolds.ts +135 -0
- package/src/games/vic3/schema.ts +650 -0
- package/src/games/vic3/structures.ts +33 -0
- package/src/gui/anchorSpec.ts +66 -0
- package/src/gui/declMarkers.ts +30 -0
- package/src/gui/fillGeometry.ts +101 -0
- package/src/gui/guiDefs.ts +386 -0
- package/src/gui/guiDependencies.ts +352 -0
- package/src/gui/guiLinks.ts +64 -0
- package/src/gui/layoutEngine.ts +1998 -0
- package/src/gui/layoutService.ts +221 -0
- package/src/gui/measuredMetrics.ts +21 -0
- package/src/gui/previewService.ts +89 -0
- package/src/gui/saveSchema.ts +220 -0
- package/src/gui/saveValues.ts +399 -0
- package/src/gui/saveZip.ts +60 -0
- package/src/gui/sourceEdit.ts +535 -0
- package/src/gui/sourceEditService.ts +439 -0
- package/src/gui/sourceModel.ts +603 -0
- package/src/gui/textResolve.ts +145 -0
- package/src/gui/textureInfo.ts +106 -0
- package/src/gui/vocabulary.ts +149 -0
- package/src/gui/widgetEdit.ts +52 -0
- package/src/gui/widgetInfo.ts +245 -0
- package/src/index/docComments.ts +103 -0
- package/src/index/extract.ts +252 -0
- package/src/index/indexer.ts +369 -0
- package/src/index/intern.ts +101 -0
- package/src/index/lazyRefs.ts +145 -0
- package/src/index/modOrigin.ts +69 -0
- package/src/index/references.ts +534 -0
- package/src/overview/dependencies.ts +240 -0
- package/src/overview/eventBanner.ts +95 -0
- package/src/overview/eventDetail.ts +482 -0
- package/src/overview/eventGraph.ts +617 -0
- package/src/overview/eventVocabulary.ts +214 -0
- package/src/overview/locCoverage.ts +138 -0
- package/src/overview/modOverview.ts +29 -0
- package/src/overview/overrides.ts +89 -0
- package/src/parseCache.ts +81 -0
- package/src/parser/cst.ts +257 -0
- package/src/parser/encoding.ts +106 -0
- package/src/parser/index.ts +7 -0
- package/src/parser/lexer.ts +245 -0
- package/src/parser/locParser.ts +276 -0
- package/src/parser/parser.ts +360 -0
- package/src/schema/freqs.ts +70 -0
- package/src/schema/loader.ts +113 -0
- package/src/schema/types.ts +142 -0
- package/src/scopes/inference.ts +478 -0
- package/src/scopes/model.ts +148 -0
- package/src/scopes/varTypes.ts +290 -0
- package/src/server.ts +1894 -0
- package/src/serverData.ts +98 -0
- package/src/structure.ts +56 -0
- 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
|
+
}
|