@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,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine defines harvest: `define:NNamespace|CONSTANT` references (grep-verified
|
|
3
|
+
* pipe separator) resolve against top-level namespace blocks in
|
|
4
|
+
* `common/defines/**\/*.txt`. Layers lowest→highest priority: jomini (6 files
|
|
5
|
+
* exist only here), game (00_defines.txt + ai/audio/graphic/jomini subdirs),
|
|
6
|
+
* read-only dependency parents, then the mod. Last-wins per (namespace,
|
|
7
|
+
* constant).
|
|
8
|
+
*
|
|
9
|
+
* In-memory only — a few thousand entries; NOT persisted into the vanillaIndex
|
|
10
|
+
* cache. No `vscode` imports: unit-tested in plain Node.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from "fs";
|
|
13
|
+
import * as path from "path";
|
|
14
|
+
import { LineIndex, parseScript, type ValueNode } from "../parser";
|
|
15
|
+
import { walkDir } from "@px-lsp/protocol/fsWalk";
|
|
16
|
+
|
|
17
|
+
export type DefineLayer = "jomini" | "game" | "parent" | "mod";
|
|
18
|
+
|
|
19
|
+
const LAYER_RANK: Record<DefineLayer, number> = { jomini: 0, game: 1, parent: 2, mod: 3 };
|
|
20
|
+
|
|
21
|
+
export interface DefineEntry {
|
|
22
|
+
namespace: string;
|
|
23
|
+
name: string;
|
|
24
|
+
/** Rendered value: the scalar text, or a collapsed `{ … }` for block values. */
|
|
25
|
+
value: string;
|
|
26
|
+
file: string;
|
|
27
|
+
line: number; // 0-based
|
|
28
|
+
layer: DefineLayer;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DefineResolution {
|
|
32
|
+
winner: DefineEntry;
|
|
33
|
+
/** Lower-priority entries the winner shadows, highest-priority first. */
|
|
34
|
+
shadowed: DefineEntry[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Scalar text, or a whitespace-collapsed `{ … }` rendering for block values. */
|
|
38
|
+
function renderValue(text: string, value: ValueNode): string {
|
|
39
|
+
if (value.kind === "scalar") return value.quoted ? `"${value.text}"` : value.text;
|
|
40
|
+
const raw = text.slice(value.range.start, value.range.end);
|
|
41
|
+
const collapsed = raw
|
|
42
|
+
.replace(/#[^\n]*/g, " ")
|
|
43
|
+
.replace(/\s+/g, " ")
|
|
44
|
+
.trim();
|
|
45
|
+
return collapsed.length > 200 ? collapsed.slice(0, 199) + "…" : collapsed;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class DefinesIndex {
|
|
49
|
+
/** namespace -> constant -> entries in ascending priority (winner last). */
|
|
50
|
+
private ns = new Map<string, Map<string, DefineEntry[]>>();
|
|
51
|
+
|
|
52
|
+
/** Harvest one root's `common/defines` tree at the given layer. */
|
|
53
|
+
addLayer(root: string, layer: DefineLayer): void {
|
|
54
|
+
const files: string[] = [];
|
|
55
|
+
walkDir(path.join(root, "common", "defines"), ".txt", files);
|
|
56
|
+
for (const file of files.sort()) {
|
|
57
|
+
let text: string;
|
|
58
|
+
try {
|
|
59
|
+
text = fs.readFileSync(file, "utf8").replace(/^/, "");
|
|
60
|
+
} catch {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
this.harvestText(text, file, layer);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Parse one defines file: top-level blocks are namespaces, their scalars constants. */
|
|
68
|
+
harvestText(text: string, file: string, layer: DefineLayer): void {
|
|
69
|
+
const { root } = parseScript(text);
|
|
70
|
+
const lines = new LineIndex(text);
|
|
71
|
+
for (const stmt of root.statements) {
|
|
72
|
+
if (stmt.kind !== "assignment" || stmt.key.quoted) continue;
|
|
73
|
+
const block = stmt.value;
|
|
74
|
+
if (!block || block.kind !== "block") continue;
|
|
75
|
+
const namespace = stmt.key.text;
|
|
76
|
+
let consts = this.ns.get(namespace);
|
|
77
|
+
if (!consts) this.ns.set(namespace, (consts = new Map()));
|
|
78
|
+
for (const inner of block.statements) {
|
|
79
|
+
if (inner.kind !== "assignment" || inner.key.quoted || inner.value === null) continue;
|
|
80
|
+
const name = inner.key.text;
|
|
81
|
+
const entry: DefineEntry = {
|
|
82
|
+
namespace,
|
|
83
|
+
name,
|
|
84
|
+
value: renderValue(text, inner.value),
|
|
85
|
+
file,
|
|
86
|
+
line: lines.positionAt(inner.key.range.start).line,
|
|
87
|
+
layer,
|
|
88
|
+
};
|
|
89
|
+
let list = consts.get(name);
|
|
90
|
+
if (!list) consts.set(name, (list = []));
|
|
91
|
+
list.push(entry);
|
|
92
|
+
// Stable sort keeps same-layer insertion order; winner is the last entry.
|
|
93
|
+
list.sort((a, b) => LAYER_RANK[a.layer] - LAYER_RANK[b.layer]);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
namespaces(): string[] {
|
|
99
|
+
return [...this.ns.keys()].sort();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Winning constants of a namespace, name-sorted. */
|
|
103
|
+
constants(namespace: string): DefineEntry[] {
|
|
104
|
+
const consts = this.ns.get(namespace);
|
|
105
|
+
if (!consts) return [];
|
|
106
|
+
return [...consts.values()]
|
|
107
|
+
.map((list) => list[list.length - 1])
|
|
108
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
resolve(namespace: string, name: string): DefineResolution | null {
|
|
112
|
+
const list = this.ns.get(namespace)?.get(name);
|
|
113
|
+
if (!list || list.length === 0) return null;
|
|
114
|
+
return { winner: list[list.length - 1], shadowed: list.slice(0, -1).reverse() };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Winning-constant count across all namespaces (status log line). */
|
|
118
|
+
get count(): number {
|
|
119
|
+
let n = 0;
|
|
120
|
+
for (const consts of this.ns.values()) n += consts.size;
|
|
121
|
+
return n;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for the Jomini `script_docs` console command output:
|
|
3
|
+
* triggers.log, effects.log, event_targets.log, modifiers.log.
|
|
4
|
+
*
|
|
5
|
+
* The format is line-based and has shifted slightly between game patches, so parse
|
|
6
|
+
* defensively: entries are separated by dashed lines; the first content line is
|
|
7
|
+
* usually `name - description`; known metadata lines (`Supported Scopes:` etc.) are
|
|
8
|
+
* extracted; anything unrecognized is appended to `doc` or `traits` instead of failing.
|
|
9
|
+
*
|
|
10
|
+
* Newer Jomini titles dump the same information in different shapes; which one a
|
|
11
|
+
* game uses is declared by its profile (GameMeta.scriptDocs, absent = classic):
|
|
12
|
+
* - "markdown" logs: `## name` / `### name` headings, `**Supported Scopes**: …`.
|
|
13
|
+
* - "masked-block" modifiers: `name:` + indented Mask/Name/Description lines.
|
|
14
|
+
* - "tag-line" modifiers: one `Tag: name, Categories: …` line per modifier.
|
|
15
|
+
* All of them produce the same TokenData shape, so everything downstream (hover,
|
|
16
|
+
* completion, the docs cache) is format-agnostic.
|
|
17
|
+
*
|
|
18
|
+
* No `vscode` imports here: this module is unit-tested in plain Node.
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from "fs";
|
|
21
|
+
import * as path from "path";
|
|
22
|
+
import type { TokenData, TokenKind } from "@px-lsp/protocol/types";
|
|
23
|
+
import { LOG_FILES } from "@px-lsp/protocol/constants";
|
|
24
|
+
import { activeProfile } from "../games/active";
|
|
25
|
+
|
|
26
|
+
export { LOG_FILES };
|
|
27
|
+
|
|
28
|
+
const SEPARATOR = /^-{4,}\s*$/;
|
|
29
|
+
const NAME_DESC = /^([A-Za-z0-9_.:<>|[\]]+)\s+-\s*(.*)$/;
|
|
30
|
+
const BARE_NAME = /^([A-Za-z0-9_]+)\s*$/;
|
|
31
|
+
// A `usage:` section header; everything until the next metadata line is the example.
|
|
32
|
+
const USAGE_HEADER = /^usage:\s*$/i;
|
|
33
|
+
// An inline syntax example line: `add_hook = { … }`, `<scheme starter> = …`,
|
|
34
|
+
// or a comparison form like `monthly_income > 10`.
|
|
35
|
+
const SYNTAX_LINE = /^(?:<[^>]+>|[A-Za-z_][A-Za-z0-9_]*)\s*(?:[<>]=?|!=|=)/;
|
|
36
|
+
// modifiers.log style: "Tag: monthly_income, Categories: character".
|
|
37
|
+
// `$` admits templated tags ($CULTURE$_opinion); they are split off into
|
|
38
|
+
// DocsLoadResult.templates downstream, never into the concrete token list.
|
|
39
|
+
const TAG_LINE = /^Tag:\s*([A-Za-z0-9_.$]+)\s*(?:,\s*(.*))?$/;
|
|
40
|
+
const SCOPE_LINE = /^(Supported [Ss]copes|Input [Ss]copes|Output [Ss]copes):\s*(.*)$/;
|
|
41
|
+
const META_LINE =
|
|
42
|
+
/^(Supported [Tt]argets|Targets?|Traits|Categories|Use [Aa]reas|Requires [Dd]ata|Wild[ _]?[Cc]ard|Global [Ll]ink):\s*(.*)$/;
|
|
43
|
+
|
|
44
|
+
export function parseLog(content: string, kind: TokenKind): TokenData[] {
|
|
45
|
+
const tokens: TokenData[] = [];
|
|
46
|
+
const seen = new Set<string>();
|
|
47
|
+
const lines = content.split(/\r?\n/);
|
|
48
|
+
|
|
49
|
+
let current: TokenData | null = null;
|
|
50
|
+
// True once a `usage:` header was seen for the current entry: subsequent
|
|
51
|
+
// non-metadata lines are captured (with indentation) as the usage example.
|
|
52
|
+
let inUsage = false;
|
|
53
|
+
const flush = () => {
|
|
54
|
+
if (current && current.name && !seen.has(current.name)) {
|
|
55
|
+
current.doc = current.doc.trim();
|
|
56
|
+
if (current.traits) current.traits = current.traits.trim();
|
|
57
|
+
if (current.usage) current.usage = current.usage.replace(/^\n+|\s+$/g, "");
|
|
58
|
+
if (!current.usage) delete current.usage;
|
|
59
|
+
seen.add(current.name);
|
|
60
|
+
tokens.push(current);
|
|
61
|
+
}
|
|
62
|
+
current = null;
|
|
63
|
+
inUsage = false;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
for (const rawLine of lines) {
|
|
67
|
+
const line = rawLine.trimEnd();
|
|
68
|
+
if (SEPARATOR.test(line)) {
|
|
69
|
+
flush();
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const trimmed = line.trim();
|
|
73
|
+
if (trimmed === "") {
|
|
74
|
+
// Blank lines inside a `usage:` block are structural; keep them.
|
|
75
|
+
if (current && inUsage && current.usage) current.usage += "\n";
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// modifiers.log (1.19+) has no dashed separators: every "Tag:" line
|
|
80
|
+
// begins a new entry. Templated tags ($CULTURE$_opinion) parse like any
|
|
81
|
+
// other; callers partition them out (script references the expanded
|
|
82
|
+
// names, so they feed lazy expansion — modifierTemplates.ts).
|
|
83
|
+
if (trimmed.startsWith("Tag:")) {
|
|
84
|
+
flush();
|
|
85
|
+
const m = TAG_LINE.exec(trimmed);
|
|
86
|
+
if (m) {
|
|
87
|
+
current = { name: m[1], kind, doc: "", scopes: [] };
|
|
88
|
+
inUsage = false;
|
|
89
|
+
if (m[2]) applyMetaLine(current, m[2]);
|
|
90
|
+
}
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!current) {
|
|
95
|
+
let m = NAME_DESC.exec(trimmed);
|
|
96
|
+
if (m) {
|
|
97
|
+
current = { name: m[1], kind, doc: m[2], scopes: [] };
|
|
98
|
+
inUsage = false;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
m = BARE_NAME.exec(trimmed);
|
|
102
|
+
if (m) {
|
|
103
|
+
current = { name: m[1], kind, doc: "", scopes: [] };
|
|
104
|
+
inUsage = false;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
// Preamble text ("Printing a list of ..."); skip.
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// A metadata line ends any open usage capture and is recorded structurally.
|
|
112
|
+
if (applyMetaLine(current, trimmed)) {
|
|
113
|
+
inUsage = false;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
// `usage:` opens a multi-line syntax block; the header itself is dropped.
|
|
117
|
+
if (USAGE_HEADER.test(trimmed)) {
|
|
118
|
+
inUsage = true;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (inUsage) {
|
|
122
|
+
current.usage = current.usage ? current.usage + "\n" + line : line;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
// A lone inline syntax example (`add_hook = { … }`): the first one becomes
|
|
126
|
+
// the usage example; anything after it stays prose.
|
|
127
|
+
if (current.usage === undefined && SYNTAX_LINE.test(trimmed)) {
|
|
128
|
+
current.usage = trimmed;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
// Otherwise: continuation of the description prose.
|
|
132
|
+
current.doc = current.doc === "" ? trimmed : current.doc + "\n" + trimmed;
|
|
133
|
+
}
|
|
134
|
+
flush();
|
|
135
|
+
return tokens;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Returns true if the line was a recognized metadata line and has been recorded. */
|
|
139
|
+
function applyMetaLine(token: TokenData, line: string): boolean {
|
|
140
|
+
const scope = SCOPE_LINE.exec(line);
|
|
141
|
+
if (scope) {
|
|
142
|
+
const label = scope[1].toLowerCase();
|
|
143
|
+
const values = scope[2]
|
|
144
|
+
.split(/[,\s]+/)
|
|
145
|
+
.map((s) => s.trim())
|
|
146
|
+
.filter((s) => s !== "");
|
|
147
|
+
const prefix = label.startsWith("input") ? "input: " : label.startsWith("output") ? "output: " : "";
|
|
148
|
+
for (const v of values) token.scopes.push(prefix + v);
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
const meta = META_LINE.exec(line);
|
|
152
|
+
if (meta) {
|
|
153
|
+
const entry = `${meta[1]}: ${meta[2]}`;
|
|
154
|
+
token.traits = token.traits ? token.traits + "\n" + entry : entry;
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// --- Newer dump dialects ------------------------------------------------
|
|
161
|
+
|
|
162
|
+
// `## effect_name` / `### event_target_name`. Level 1 is the file title
|
|
163
|
+
// ("# Effect Documentation") and starts no entry.
|
|
164
|
+
const MD_HEADING = /^(#{1,6})\s+(.+?)$/;
|
|
165
|
+
// `**Supported Scopes**: state` — the bold wrapper is the only difference from
|
|
166
|
+
// the classic metadata lines, so it is unwrapped and fed to applyMetaLine.
|
|
167
|
+
const MD_BOLD_LABEL = /^\*\*([^*]+)\*\*\s*:/;
|
|
168
|
+
// `battle_casualties_mult:` opens a masked-block entry; its Mask/Name/Description
|
|
169
|
+
// lines are indented underneath it.
|
|
170
|
+
const MASKED_NAME = /^([A-Za-z0-9_.$]+):\s*$/;
|
|
171
|
+
const MASKED_FIELD = /^(Mask|Name|Description):\s*(.*)$/;
|
|
172
|
+
// `--- Static modifier types ---` and friends: section banners, not entries.
|
|
173
|
+
const SECTION_BANNER = /^-{3,}/;
|
|
174
|
+
|
|
175
|
+
const braceDelta = (s: string): number => (s.match(/\{/g) ?? []).length - (s.match(/\}/g) ?? []).length;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Markdown dump dialect (effects.log, triggers.log, event_targets.log of newer
|
|
179
|
+
* titles): entries open at a `##`/`###` heading and run until the next one.
|
|
180
|
+
* Metadata lines are the classic ones, optionally bold-wrapped; the remaining
|
|
181
|
+
* body is prose, except a leading `name = { … }` example which is lifted into
|
|
182
|
+
* `usage` (multi-line examples are followed until the braces balance).
|
|
183
|
+
*/
|
|
184
|
+
export function parseMarkdownLog(content: string, kind: TokenKind): TokenData[] {
|
|
185
|
+
const tokens: TokenData[] = [];
|
|
186
|
+
const seen = new Set<string>();
|
|
187
|
+
|
|
188
|
+
let current: TokenData | null = null;
|
|
189
|
+
// >0 while a multi-line usage example is still open.
|
|
190
|
+
let openBraces = 0;
|
|
191
|
+
const flush = () => {
|
|
192
|
+
if (current && current.name && !seen.has(current.name)) {
|
|
193
|
+
current.doc = current.doc.trim();
|
|
194
|
+
if (current.traits) current.traits = current.traits.trim();
|
|
195
|
+
if (current.usage) current.usage = current.usage.trim();
|
|
196
|
+
if (!current.usage) delete current.usage;
|
|
197
|
+
seen.add(current.name);
|
|
198
|
+
tokens.push(current);
|
|
199
|
+
}
|
|
200
|
+
current = null;
|
|
201
|
+
openBraces = 0;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
205
|
+
// Markdown hard line breaks are trailing double spaces; they are noise here.
|
|
206
|
+
const line = rawLine.trimEnd();
|
|
207
|
+
const heading = MD_HEADING.exec(line);
|
|
208
|
+
if (heading) {
|
|
209
|
+
flush();
|
|
210
|
+
if (heading[1].length > 1) current = { name: heading[2].trim(), kind, doc: "", scopes: [] };
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const trimmed = line.trim();
|
|
214
|
+
// A trailing `------` appendix (bare lists of code-saved scope names) is not
|
|
215
|
+
// part of any entry.
|
|
216
|
+
if (SEPARATOR.test(trimmed)) {
|
|
217
|
+
flush();
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (!current) continue;
|
|
221
|
+
const meta = trimmed.replace(MD_BOLD_LABEL, "$1:");
|
|
222
|
+
if (openBraces > 0) {
|
|
223
|
+
// Some dumped examples never close their braces (`switch = { … ` has no
|
|
224
|
+
// final `}`); a metadata line always ends the entry's body, so it wins.
|
|
225
|
+
if (applyMetaLine(current, meta)) {
|
|
226
|
+
openBraces = 0;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
current.usage += "\n" + line;
|
|
230
|
+
openBraces += braceDelta(line);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (trimmed === "") continue;
|
|
234
|
+
if (applyMetaLine(current, meta)) continue;
|
|
235
|
+
if (current.usage === undefined && SYNTAX_LINE.test(trimmed)) {
|
|
236
|
+
current.usage = trimmed;
|
|
237
|
+
openBraces = Math.max(0, braceDelta(trimmed));
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
current.doc = current.doc === "" ? trimmed : current.doc + "\n" + trimmed;
|
|
241
|
+
}
|
|
242
|
+
flush();
|
|
243
|
+
return tokens;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* "masked-block" modifiers.log: `name:` followed by indented `Mask:`, `Name:`
|
|
248
|
+
* and `Description:` lines (the display name and description become the doc,
|
|
249
|
+
* the mask is metadata). Descriptions may continue on later unindented lines.
|
|
250
|
+
*/
|
|
251
|
+
export function parseMaskedBlockModifiers(content: string, kind: TokenKind): TokenData[] {
|
|
252
|
+
const tokens: TokenData[] = [];
|
|
253
|
+
const seen = new Set<string>();
|
|
254
|
+
let current: TokenData | null = null;
|
|
255
|
+
const flush = () => {
|
|
256
|
+
if (current && !seen.has(current.name)) {
|
|
257
|
+
current.doc = current.doc.trim();
|
|
258
|
+
seen.add(current.name);
|
|
259
|
+
tokens.push(current);
|
|
260
|
+
}
|
|
261
|
+
current = null;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
265
|
+
const line = rawLine.trimEnd();
|
|
266
|
+
const trimmed = line.trim();
|
|
267
|
+
if (SECTION_BANNER.test(trimmed)) {
|
|
268
|
+
flush();
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const name = MASKED_NAME.exec(line);
|
|
272
|
+
if (name) {
|
|
273
|
+
flush();
|
|
274
|
+
current = { name: name[1], kind, doc: "", scopes: [] };
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (!current || trimmed === "") continue;
|
|
278
|
+
const field = MASKED_FIELD.exec(trimmed);
|
|
279
|
+
if (field && field[1] === "Mask") {
|
|
280
|
+
current.traits = current.traits ? current.traits + "\n" + trimmed : trimmed;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
// Name/Description text, plus any unindented continuation of it.
|
|
284
|
+
const text = field ? field[2] : trimmed;
|
|
285
|
+
if (text !== "") current.doc = current.doc === "" ? text : current.doc + "\n" + text;
|
|
286
|
+
}
|
|
287
|
+
flush();
|
|
288
|
+
return tokens;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* "tag-line" modifiers.log: one `Tag: name, Categories: Country, , All,` line
|
|
293
|
+
* per modifier (the category list is padded with empty entries). No description
|
|
294
|
+
* is dumped, so the categories are all the metadata there is.
|
|
295
|
+
*/
|
|
296
|
+
export function parseTagLineModifiers(content: string, kind: TokenKind): TokenData[] {
|
|
297
|
+
const tokens: TokenData[] = [];
|
|
298
|
+
const seen = new Set<string>();
|
|
299
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
300
|
+
const m = TAG_LINE.exec(rawLine.trim());
|
|
301
|
+
if (!m || seen.has(m[1])) continue;
|
|
302
|
+
seen.add(m[1]);
|
|
303
|
+
const token: TokenData = { name: m[1], kind, doc: "", scopes: [] };
|
|
304
|
+
const categories = (m[2] ?? "")
|
|
305
|
+
.replace(/^Categories:\s*/i, "")
|
|
306
|
+
.split(",")
|
|
307
|
+
.map((s) => s.trim())
|
|
308
|
+
.filter((s) => s !== "");
|
|
309
|
+
if (categories.length > 0) token.traits = `Categories: ${categories.join(", ")}`;
|
|
310
|
+
tokens.push(token);
|
|
311
|
+
}
|
|
312
|
+
return tokens;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export interface DocsLoadResult {
|
|
316
|
+
tokens: TokenData[];
|
|
317
|
+
/** Templated modifier tags ($CULTURE$_opinion), for lazy expansion. */
|
|
318
|
+
templates: TokenData[];
|
|
319
|
+
/** mtimeMs per log file found; the cache key. */
|
|
320
|
+
mtimes: Record<string, number>;
|
|
321
|
+
/** Log file names that were missing from logsPath. */
|
|
322
|
+
missing: string[];
|
|
323
|
+
fromCache: boolean;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Parse on_actions.log (console `script_docs` output): each entry documents an
|
|
328
|
+
* on_action's expected root scope. Returns an empty map when the log is absent.
|
|
329
|
+
*
|
|
330
|
+
* on_death:
|
|
331
|
+
* From Code: Yes
|
|
332
|
+
* Expected Scope: character
|
|
333
|
+
*/
|
|
334
|
+
export function parseOnActionsLog(logsDir: string): Map<string, string> {
|
|
335
|
+
const scopes = new Map<string, string>();
|
|
336
|
+
let content: string;
|
|
337
|
+
try {
|
|
338
|
+
content = fs.readFileSync(path.join(logsDir, "on_actions.log"), "utf8");
|
|
339
|
+
} catch {
|
|
340
|
+
return scopes;
|
|
341
|
+
}
|
|
342
|
+
const entry = /^([A-Za-z0-9_.-]+):\s*\r?\nFrom Code: (?:Yes|No)\s*\r?\nExpected Scope: (\w+)/gm;
|
|
343
|
+
let m: RegExpExecArray | null;
|
|
344
|
+
while ((m = entry.exec(content)) !== null) scopes.set(m[1], m[2].toLowerCase());
|
|
345
|
+
return scopes;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** The parser for one log, per the active profile's dump dialect. */
|
|
349
|
+
function parserFor(kind: TokenKind): (content: string, kind: TokenKind) => TokenData[] {
|
|
350
|
+
const dialect = activeProfile().scriptDocs;
|
|
351
|
+
if (kind === "modifier") {
|
|
352
|
+
if (dialect?.modifiers === "masked-block") return parseMaskedBlockModifiers;
|
|
353
|
+
if (dialect?.modifiers === "tag-line") return parseTagLineModifiers;
|
|
354
|
+
return parseLog;
|
|
355
|
+
}
|
|
356
|
+
return dialect?.format === "markdown" ? parseMarkdownLog : parseLog;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Parse the four script_docs logs found in `logsDir`. Missing files are reported, not fatal. */
|
|
360
|
+
export function loadTokenDataFromLogs(logsDir: string): DocsLoadResult {
|
|
361
|
+
const tokens: TokenData[] = [];
|
|
362
|
+
const templates: TokenData[] = [];
|
|
363
|
+
const mtimes: Record<string, number> = {};
|
|
364
|
+
const missing: string[] = [];
|
|
365
|
+
for (const { file, kind } of LOG_FILES) {
|
|
366
|
+
const full = path.join(logsDir, file);
|
|
367
|
+
let stat: fs.Stats;
|
|
368
|
+
try {
|
|
369
|
+
stat = fs.statSync(full);
|
|
370
|
+
} catch {
|
|
371
|
+
missing.push(file);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
mtimes[file] = stat.mtimeMs;
|
|
375
|
+
try {
|
|
376
|
+
for (const t of parserFor(kind)(fs.readFileSync(full, "utf8"), kind)) {
|
|
377
|
+
(t.name.includes("$") ? templates : tokens).push(t);
|
|
378
|
+
}
|
|
379
|
+
} catch {
|
|
380
|
+
missing.push(file);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return { tokens, templates, mtimes, missing, fromCache: false };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
interface DocsCacheFile {
|
|
387
|
+
cacheFormat: number;
|
|
388
|
+
mtimes: Record<string, number>;
|
|
389
|
+
tokens: TokenData[];
|
|
390
|
+
templates: TokenData[];
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Bump when the parsed TokenData shape changes (a stale mtime-keyed cache would
|
|
394
|
+
// otherwise serve old parses). 3: templated modifier tags ($CULTURE$_opinion).
|
|
395
|
+
// 4: added the `usage` field (syntax examples).
|
|
396
|
+
// 5: per-profile dump dialects (markdown / masked-block / tag-line), which the
|
|
397
|
+
// classic parser had mangled into near-empty caches for the newer titles.
|
|
398
|
+
const DOCS_CACHE_FORMAT = 5;
|
|
399
|
+
|
|
400
|
+
/** Load token data, using the JSON cache when log mtimes are unchanged. */
|
|
401
|
+
export function loadTokenData(logsDir: string, cacheFile: string, forceReparse = false): DocsLoadResult {
|
|
402
|
+
const fresh = () => {
|
|
403
|
+
const result = loadTokenDataFromLogs(logsDir);
|
|
404
|
+
try {
|
|
405
|
+
fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
|
|
406
|
+
const payload: DocsCacheFile = {
|
|
407
|
+
cacheFormat: DOCS_CACHE_FORMAT,
|
|
408
|
+
mtimes: result.mtimes,
|
|
409
|
+
tokens: result.tokens,
|
|
410
|
+
templates: result.templates,
|
|
411
|
+
};
|
|
412
|
+
fs.writeFileSync(cacheFile, JSON.stringify(payload));
|
|
413
|
+
} catch {
|
|
414
|
+
// Cache write failure is non-fatal.
|
|
415
|
+
}
|
|
416
|
+
return result;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
if (forceReparse) return fresh();
|
|
420
|
+
|
|
421
|
+
let cached: DocsCacheFile;
|
|
422
|
+
try {
|
|
423
|
+
cached = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
|
|
424
|
+
} catch {
|
|
425
|
+
return fresh();
|
|
426
|
+
}
|
|
427
|
+
if (cached.cacheFormat !== DOCS_CACHE_FORMAT || !cached.tokens || !cached.templates || !cached.mtimes)
|
|
428
|
+
return fresh();
|
|
429
|
+
|
|
430
|
+
// Cache is valid only if the exact same set of files exists with the same mtimes.
|
|
431
|
+
const currentMtimes: Record<string, number> = {};
|
|
432
|
+
const missing: string[] = [];
|
|
433
|
+
for (const { file } of LOG_FILES) {
|
|
434
|
+
try {
|
|
435
|
+
currentMtimes[file] = fs.statSync(path.join(logsDir, file)).mtimeMs;
|
|
436
|
+
} catch {
|
|
437
|
+
missing.push(file);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const cachedKeys = Object.keys(cached.mtimes).sort().join(",");
|
|
441
|
+
const currentKeys = Object.keys(currentMtimes).sort().join(",");
|
|
442
|
+
const same =
|
|
443
|
+
cachedKeys === currentKeys && Object.entries(currentMtimes).every(([f, t]) => cached.mtimes[f] === t);
|
|
444
|
+
if (!same) return fresh();
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
tokens: cached.tokens,
|
|
448
|
+
templates: cached.templates,
|
|
449
|
+
mtimes: cached.mtimes,
|
|
450
|
+
missing,
|
|
451
|
+
fromCache: true,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated docs for grammar/math keywords that no other layer serves: script_docs
|
|
3
|
+
* documents triggers/effects but not the glue vocabulary (limit, NOT, base…),
|
|
4
|
+
* which the 2026-07 mod-coverage audit measured as the single biggest source of
|
|
5
|
+
* empty hovers (~1400 uses in one mid-size mod). Served by hover as a fallback
|
|
6
|
+
* card, so a real token/definition with the same name always wins.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const KEYWORD_DOCS: Record<string, string> = {
|
|
10
|
+
// Control flow (effects)
|
|
11
|
+
if: "Conditional effect: runs its contents when its `limit = { … }` trigger holds.",
|
|
12
|
+
else_if: "Runs when the preceding if/else_if did not, and its own `limit` holds.",
|
|
13
|
+
else: "Runs when the preceding if/else_if did not.",
|
|
14
|
+
while: "Loop effect: repeats while `limit` holds, or `count` times.",
|
|
15
|
+
switch: "Branches on one trigger: `switch = { trigger = x case_a = { … } fallback = { … } }`.",
|
|
16
|
+
// Control flow (triggers)
|
|
17
|
+
trigger_if: "Conditional trigger: contents are only checked when its `limit` holds.",
|
|
18
|
+
trigger_else_if: "Checked when the preceding trigger_if did not apply and its own `limit` holds.",
|
|
19
|
+
trigger_else: "Checked when the preceding trigger_if did not apply.",
|
|
20
|
+
limit:
|
|
21
|
+
"Trigger block: restricts the enclosing if / iterator / random_list entry to cases where these triggers hold.",
|
|
22
|
+
alternative_limit: "Additional trigger set an iterator accepts when `limit` fails.",
|
|
23
|
+
// Logic connectives
|
|
24
|
+
AND: "All child triggers must be true (the implicit default).",
|
|
25
|
+
OR: "At least one child trigger must be true.",
|
|
26
|
+
NOT: "True when the child trigger is false. Alias of NOR: with several children, true when none hold.",
|
|
27
|
+
NOR: "True when none of the child triggers are true.",
|
|
28
|
+
NAND: "True when at least one child trigger is false.",
|
|
29
|
+
calc_true_if: "True when at least `amount = N` of the listed triggers hold.",
|
|
30
|
+
// Script-value math
|
|
31
|
+
base: "Starting value of a script-value block; later steps (add, multiply…) modify it.",
|
|
32
|
+
value: "Sets the current value directly — a number, another script value, or a scope chain.",
|
|
33
|
+
add: "Adds to the current value (number, script value, or `{ … }` block). Inside weights, raises the weight.",
|
|
34
|
+
subtract: "Subtracts from the current value.",
|
|
35
|
+
multiply: "Multiplies the current value.",
|
|
36
|
+
divide: "Divides the current value.",
|
|
37
|
+
modulo: "Remainder of dividing the current value.",
|
|
38
|
+
min: "Lower clamp of the surrounding script value.",
|
|
39
|
+
max: "Upper clamp of the surrounding script value.",
|
|
40
|
+
round: "Round to the nearest integer (yes/no).",
|
|
41
|
+
ceiling: "Round up to an integer (yes/no).",
|
|
42
|
+
floor: "Round down to an integer (yes/no).",
|
|
43
|
+
fixed_range: "A uniformly random value between `min` and `max` (re-rolls each evaluation).",
|
|
44
|
+
integer_range: "A uniformly random integer between `min` and `max`.",
|
|
45
|
+
save_temporary_value_as:
|
|
46
|
+
"Saves the current value of this script-value calculation under a name; read it back later in the same calculation as `scope:<name>`.",
|
|
47
|
+
factor: "Multiplies the surrounding weight/value when the enclosing modifier's trigger holds.",
|
|
48
|
+
weight: "Weight of this entry when the game picks one of several candidates.",
|
|
49
|
+
// Durations & chances
|
|
50
|
+
days: "Duration in days: a number, a `{ min max }` range, or a script value.",
|
|
51
|
+
weeks: "Duration in weeks: a number, a range, or a script value.",
|
|
52
|
+
months: "Duration in months: a number, a range, or a script value.",
|
|
53
|
+
years: "Duration in years: a number, a range, or a script value.",
|
|
54
|
+
chance: "Percent chance, 0–100.",
|
|
55
|
+
// Iterators / lists
|
|
56
|
+
count: "How many list members must match (a number or `all`), or how many times to repeat.",
|
|
57
|
+
percent: "Fraction of list members that must match (0–1).",
|
|
58
|
+
order_by: "Script value an ordered_ iterator sorts by (descending).",
|
|
59
|
+
position: "0-based index the ordered_ iterator picks after sorting.",
|
|
60
|
+
// Descriptions & misc
|
|
61
|
+
trigger: "Trigger block: conditions that must hold for the surrounding item to apply or fire.",
|
|
62
|
+
effect: "Effect block: commands run when the surrounding item fires.",
|
|
63
|
+
desc: "Description: a loc key, or a dynamic-description block (first_valid / triggered_desc / random_valid).",
|
|
64
|
+
namespace: "Declares this file's event-id namespace: events are then named `<namespace>.<n>`.",
|
|
65
|
+
alias: "Alternate names for this game concept — each works in loc [Concept|E] links.",
|
|
66
|
+
// ai_will_do / ai_chance modifier vocabulary (_scripted_modifiers.info)
|
|
67
|
+
who: "Character whose opinion is measured, in an opinion_modifier weight.",
|
|
68
|
+
opinion_target: "Character the opinion is about, in an opinion_modifier weight.",
|
|
69
|
+
multiplier: "Weight applied per opinion/value point in opinion_modifier / compare_modifier.",
|
|
70
|
+
step: "compare_modifier: granularity — the value is divided into steps of this size.",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Lowercase logic words double the uppercase forms. */
|
|
74
|
+
for (const k of ["AND", "OR", "NOT", "NOR", "NAND"]) {
|
|
75
|
+
KEYWORD_DOCS[k.toLowerCase()] = KEYWORD_DOCS[k];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const SCOPE_WORD_DOCS: Record<string, string> = {
|
|
79
|
+
root: "The scope this script context started with (for an event: usually the event's target character).",
|
|
80
|
+
this: "The current scope.",
|
|
81
|
+
prev: "The scope before the last scope change. Chain for more steps back (prevprev…).",
|
|
82
|
+
from: "The sending scope in event chains. Chain for more steps back (fromfrom…).",
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Doc for root/this/prev(prev…)/from(from…), any case; null when not a scope word. */
|
|
86
|
+
export function scopeWordDoc(word: string): { name: string; doc: string } | null {
|
|
87
|
+
const lower = word.toLowerCase();
|
|
88
|
+
if (SCOPE_WORD_DOCS[lower]) return { name: lower, doc: SCOPE_WORD_DOCS[lower] };
|
|
89
|
+
if (/^(?:prev)+$/.test(lower)) {
|
|
90
|
+
const n = lower.length / 4;
|
|
91
|
+
return { name: lower, doc: `The scope ${n} scope-changes back (chained \`prev\`).` };
|
|
92
|
+
}
|
|
93
|
+
if (/^(?:from)+$/.test(lower)) {
|
|
94
|
+
const n = lower.length / 4;
|
|
95
|
+
return { name: lower, doc: `The sender ${n} steps back in the event chain (chained \`from\`).` };
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|