@intentius/chant 0.0.22 → 0.0.24
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/package.json +1 -1
- package/src/cli/commands/init-lexicon/templates/codegen.ts +188 -0
- package/src/cli/commands/init-lexicon/templates/docs.ts +81 -0
- package/src/cli/commands/init-lexicon/templates/examples.ts +35 -0
- package/src/cli/commands/init-lexicon/templates/lint.ts +30 -0
- package/src/cli/commands/init-lexicon/templates/lsp.ts +39 -0
- package/src/cli/commands/init-lexicon/templates/plugin.ts +110 -0
- package/src/cli/commands/init-lexicon/templates/project.ts +182 -0
- package/src/cli/commands/init-lexicon/templates/spec.ts +57 -0
- package/src/cli/commands/init-lexicon/templates/tests.ts +70 -0
- package/src/cli/commands/init-lexicon.ts +12 -774
- package/src/cli/conflict-check.test.ts +43 -0
- package/src/cli/main.ts +1 -1
- package/src/cli/mcp/resource-handlers.ts +227 -0
- package/src/cli/mcp/server.ts +20 -409
- package/src/cli/mcp/state-tools.ts +138 -0
- package/src/cli/mcp/types.ts +45 -0
- package/src/codegen/docs-file-markers.ts +69 -0
- package/src/codegen/docs-rule-scanning.ts +159 -0
- package/src/codegen/docs-sections.ts +159 -0
- package/src/codegen/docs-sidebar.ts +56 -0
- package/src/codegen/docs-types.ts +79 -0
- package/src/codegen/docs.ts +9 -495
- package/src/composite.test.ts +75 -0
- package/src/composite.ts +37 -0
- package/src/discovery/collect.test.ts +34 -0
- package/src/discovery/collect.ts +12 -0
- package/src/lexicon-plugin-helpers.ts +130 -0
- package/src/toml-emit.ts +182 -0
- package/src/toml-parse.ts +370 -0
- package/src/toml-utils.ts +60 -0
- package/src/toml.ts +5 -602
package/src/discovery/collect.ts
CHANGED
|
@@ -34,6 +34,18 @@ export function collectEntities(
|
|
|
34
34
|
} else {
|
|
35
35
|
entities.set(name, value);
|
|
36
36
|
}
|
|
37
|
+
} else if (Array.isArray(value)) {
|
|
38
|
+
// Arrays of Declarables — each element gets an indexed name: exportName_0, exportName_1, ...
|
|
39
|
+
for (let i = 0; i < value.length; i++) {
|
|
40
|
+
const item = value[i];
|
|
41
|
+
if (isDeclarable(item)) {
|
|
42
|
+
const indexedName = `${name}_${i}`;
|
|
43
|
+
if (entities.has(indexedName) && entities.get(indexedName) !== item) {
|
|
44
|
+
throw new DiscoveryError(file, `Duplicate entity name "${indexedName}"`, "resolution");
|
|
45
|
+
}
|
|
46
|
+
entities.set(indexedName, item);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
37
49
|
} else if (isCompositeInstance(value)) {
|
|
38
50
|
const expanded = expandComposite(name, value);
|
|
39
51
|
for (const [expandedName, entity] of expanded) {
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for lexicon plugin implementations.
|
|
3
|
+
*
|
|
4
|
+
* Eliminates boilerplate across the 8 lexicon plugins by providing
|
|
5
|
+
* factory functions for common plugin methods: skills loading,
|
|
6
|
+
* MCP diff tool, and MCP catalog resource.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from "fs";
|
|
10
|
+
import { join, dirname } from "path";
|
|
11
|
+
import { fileURLToPath } from "url";
|
|
12
|
+
import type { SkillDefinition } from "./lexicon";
|
|
13
|
+
import type { McpToolContribution, McpResourceContribution } from "./mcp/types";
|
|
14
|
+
import type { Serializer } from "./serializer";
|
|
15
|
+
|
|
16
|
+
// ── Skills Loader ─────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Metadata for a skill file on disk. Spread into the resulting SkillDefinition
|
|
20
|
+
* after the file content is read.
|
|
21
|
+
*/
|
|
22
|
+
export type SkillFileSpec = Omit<SkillDefinition, "content"> & {
|
|
23
|
+
/** Filename relative to the skills directory (e.g. "chant-aws.md") */
|
|
24
|
+
file: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create a skills loader that reads .md files from a lexicon's skills directory.
|
|
29
|
+
*
|
|
30
|
+
* Usage in a plugin:
|
|
31
|
+
* ```ts
|
|
32
|
+
* import { createSkillsLoader } from "@intentius/chant/lexicon-plugin-helpers";
|
|
33
|
+
*
|
|
34
|
+
* const loadSkills = createSkillsLoader(import.meta.url, [
|
|
35
|
+
* { file: "chant-aws.md", name: "chant-aws", description: "..." },
|
|
36
|
+
* ]);
|
|
37
|
+
*
|
|
38
|
+
* // In plugin:
|
|
39
|
+
* skills() { return loadSkills(); }
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export function createSkillsLoader(
|
|
43
|
+
importMetaUrl: string,
|
|
44
|
+
specs: SkillFileSpec[],
|
|
45
|
+
): () => SkillDefinition[] {
|
|
46
|
+
return () => {
|
|
47
|
+
const skillsDir = join(dirname(fileURLToPath(importMetaUrl)), "skills");
|
|
48
|
+
return specs.map(({ file, ...meta }) => {
|
|
49
|
+
try {
|
|
50
|
+
const content = readFileSync(join(skillsDir, file), "utf-8");
|
|
51
|
+
return { ...meta, content };
|
|
52
|
+
} catch {
|
|
53
|
+
return { ...meta, content: "" };
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── MCP Diff Tool ─────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Create an MCP diff tool contribution for a lexicon.
|
|
63
|
+
*
|
|
64
|
+
* All lexicons (except Azure) expose an identical "diff" tool that compares
|
|
65
|
+
* current build output against previous output using the lexicon's serializer.
|
|
66
|
+
*/
|
|
67
|
+
export function createDiffTool(
|
|
68
|
+
serializer: Serializer,
|
|
69
|
+
description: string,
|
|
70
|
+
): McpToolContribution {
|
|
71
|
+
return {
|
|
72
|
+
name: "diff",
|
|
73
|
+
description,
|
|
74
|
+
inputSchema: {
|
|
75
|
+
type: "object" as const,
|
|
76
|
+
properties: {
|
|
77
|
+
path: {
|
|
78
|
+
type: "string",
|
|
79
|
+
description: "Path to the infrastructure project directory",
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
async handler(params: Record<string, unknown>): Promise<unknown> {
|
|
84
|
+
const { diffCommand } = await import("./cli/commands/diff");
|
|
85
|
+
const result = await diffCommand({
|
|
86
|
+
path: (params.path as string) ?? ".",
|
|
87
|
+
serializers: [serializer],
|
|
88
|
+
});
|
|
89
|
+
return result;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── MCP Catalog Resource ──────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Create an MCP resource that serves the lexicon's meta.json as a catalog.
|
|
98
|
+
*
|
|
99
|
+
* Most lexicons expose a "resource-catalog" resource with identical structure.
|
|
100
|
+
*
|
|
101
|
+
* @param importMetaUrl — The plugin's import.meta.url (used to locate generated JSON)
|
|
102
|
+
* @param name — Display name (e.g. "AWS Resource Catalog")
|
|
103
|
+
* @param description — Resource description
|
|
104
|
+
* @param lexiconJsonFile — Filename of the generated lexicon JSON (e.g. "lexicon-aws.json")
|
|
105
|
+
*/
|
|
106
|
+
export function createCatalogResource(
|
|
107
|
+
importMetaUrl: string,
|
|
108
|
+
name: string,
|
|
109
|
+
description: string,
|
|
110
|
+
lexiconJsonFile: string,
|
|
111
|
+
): McpResourceContribution {
|
|
112
|
+
return {
|
|
113
|
+
uri: "resource-catalog",
|
|
114
|
+
name,
|
|
115
|
+
description,
|
|
116
|
+
mimeType: "application/json",
|
|
117
|
+
async handler(): Promise<string> {
|
|
118
|
+
const dir = dirname(fileURLToPath(importMetaUrl));
|
|
119
|
+
const lexicon = JSON.parse(
|
|
120
|
+
readFileSync(join(dir, "generated", lexiconJsonFile), "utf-8"),
|
|
121
|
+
) as Record<string, { resourceType: string; kind: string }>;
|
|
122
|
+
const entries = Object.entries(lexicon).map(([className, entry]) => ({
|
|
123
|
+
className,
|
|
124
|
+
resourceType: entry.resourceType,
|
|
125
|
+
kind: entry.kind,
|
|
126
|
+
}));
|
|
127
|
+
return JSON.stringify(entries);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
package/src/toml-emit.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOML emitter.
|
|
3
|
+
*
|
|
4
|
+
* Converts JavaScript objects to TOML document strings.
|
|
5
|
+
* Handles scalars, tables, arrays, array of tables, and inline tables.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { escapeKey, sortKeys } from "./toml-utils";
|
|
9
|
+
|
|
10
|
+
export interface EmitTOMLOptions {
|
|
11
|
+
/** Comment to prepend at the top of the document. */
|
|
12
|
+
header?: string;
|
|
13
|
+
/** Key ordering hint: keys matching earlier entries appear first. */
|
|
14
|
+
keyOrder?: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Emit a JavaScript object as a TOML document string.
|
|
19
|
+
*
|
|
20
|
+
* - Top-level scalars, arrays of scalars → bare key-value pairs.
|
|
21
|
+
* - Nested objects → `[section]` tables.
|
|
22
|
+
* - Arrays of objects → `[[section]]` array of tables.
|
|
23
|
+
* - Deeply nested objects → `[parent.child]` dotted sections.
|
|
24
|
+
*/
|
|
25
|
+
export function emitTOML(value: unknown, options?: EmitTOMLOptions): string {
|
|
26
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
27
|
+
throw new Error("emitTOML expects a plain object at the top level");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const lines: string[] = [];
|
|
31
|
+
|
|
32
|
+
if (options?.header) {
|
|
33
|
+
for (const line of options.header.split("\n")) {
|
|
34
|
+
lines.push(`# ${line}`);
|
|
35
|
+
}
|
|
36
|
+
lines.push("");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const obj = value as Record<string, unknown>;
|
|
40
|
+
emitTable(obj, [], lines, options?.keyOrder);
|
|
41
|
+
|
|
42
|
+
// Trim trailing blank lines, ensure single trailing newline
|
|
43
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
44
|
+
lines.pop();
|
|
45
|
+
}
|
|
46
|
+
return lines.join("\n") + "\n";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Emit a TOML table (recursively handles nested tables and array of tables).
|
|
51
|
+
*/
|
|
52
|
+
function emitTable(
|
|
53
|
+
obj: Record<string, unknown>,
|
|
54
|
+
path: string[],
|
|
55
|
+
lines: string[],
|
|
56
|
+
keyOrder?: string[],
|
|
57
|
+
): void {
|
|
58
|
+
const keys = sortKeys(Object.keys(obj), keyOrder);
|
|
59
|
+
|
|
60
|
+
// First pass: emit all scalar / inline values
|
|
61
|
+
for (const key of keys) {
|
|
62
|
+
const val = obj[key];
|
|
63
|
+
if (val === undefined) continue;
|
|
64
|
+
if (isTableValue(val)) continue; // handled in second pass
|
|
65
|
+
if (isArrayOfTables(val)) continue; // handled in second pass
|
|
66
|
+
|
|
67
|
+
lines.push(`${escapeKey(key)} = ${emitValue(val)}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Second pass: emit nested tables
|
|
71
|
+
for (const key of keys) {
|
|
72
|
+
const val = obj[key];
|
|
73
|
+
if (val === undefined) continue;
|
|
74
|
+
|
|
75
|
+
if (isArrayOfTables(val)) {
|
|
76
|
+
const arr = val as Record<string, unknown>[];
|
|
77
|
+
for (const item of arr) {
|
|
78
|
+
lines.push("");
|
|
79
|
+
const sectionPath = [...path, key];
|
|
80
|
+
lines.push(`[[${sectionPath.map(escapeKey).join(".")}]]`);
|
|
81
|
+
emitTable(item, sectionPath, lines, keyOrder);
|
|
82
|
+
}
|
|
83
|
+
} else if (isTableValue(val)) {
|
|
84
|
+
lines.push("");
|
|
85
|
+
const sectionPath = [...path, key];
|
|
86
|
+
lines.push(`[${sectionPath.map(escapeKey).join(".")}]`);
|
|
87
|
+
emitTable(val as Record<string, unknown>, sectionPath, lines, keyOrder);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Check if a value should be emitted as a `[table]` section.
|
|
94
|
+
*/
|
|
95
|
+
function isTableValue(val: unknown): val is Record<string, unknown> {
|
|
96
|
+
return (
|
|
97
|
+
typeof val === "object" &&
|
|
98
|
+
val !== null &&
|
|
99
|
+
!Array.isArray(val) &&
|
|
100
|
+
!(val instanceof Date)
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Check if a value is an array of tables (`[[section]]`).
|
|
106
|
+
*/
|
|
107
|
+
function isArrayOfTables(val: unknown): boolean {
|
|
108
|
+
if (!Array.isArray(val) || val.length === 0) return false;
|
|
109
|
+
return val.every(
|
|
110
|
+
(item) => typeof item === "object" && item !== null && !Array.isArray(item) && !(item instanceof Date),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Emit a TOML value (scalar, array of scalars, inline table).
|
|
116
|
+
*/
|
|
117
|
+
function emitValue(val: unknown): string {
|
|
118
|
+
if (val === null || val === undefined) {
|
|
119
|
+
return '""'; // TOML has no null — emit empty string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (typeof val === "boolean") {
|
|
123
|
+
return val ? "true" : "false";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (typeof val === "number") {
|
|
127
|
+
if (Number.isInteger(val)) return String(val);
|
|
128
|
+
return String(val);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (typeof val === "string") {
|
|
132
|
+
return emitString(val);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (val instanceof Date) {
|
|
136
|
+
return val.toISOString();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (Array.isArray(val)) {
|
|
140
|
+
if (val.length === 0) return "[]";
|
|
141
|
+
// Array of scalars → inline array
|
|
142
|
+
const items = val.map((item) => emitValue(item));
|
|
143
|
+
const inline = `[${items.join(", ")}]`;
|
|
144
|
+
if (inline.length <= 80) return inline;
|
|
145
|
+
// Multi-line array for long content
|
|
146
|
+
const multiLines = val.map((item) => ` ${emitValue(item)},`);
|
|
147
|
+
return "[\n" + multiLines.join("\n") + "\n]";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Inline table for non-table contexts (shouldn't normally reach here
|
|
151
|
+
// because isTableValue is checked first, but handles edge cases)
|
|
152
|
+
if (typeof val === "object") {
|
|
153
|
+
const entries = Object.entries(val as Record<string, unknown>);
|
|
154
|
+
if (entries.length === 0) return "{}";
|
|
155
|
+
const pairs = entries.map(([k, v]) => `${escapeKey(k)} = ${emitValue(v)}`);
|
|
156
|
+
return `{ ${pairs.join(", ")} }`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return String(val);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Emit a TOML string with proper quoting.
|
|
164
|
+
*/
|
|
165
|
+
function emitString(val: string): string {
|
|
166
|
+
// Use basic strings with escaping for most values
|
|
167
|
+
if (val.includes("\n") || val.includes("\r")) {
|
|
168
|
+
// Multi-line basic string
|
|
169
|
+
const escaped = val
|
|
170
|
+
.replace(/\\/g, "\\\\")
|
|
171
|
+
.replace(/"""/g, '\\"""');
|
|
172
|
+
return `"""\n${escaped}"""`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Regular basic string
|
|
176
|
+
const escaped = val
|
|
177
|
+
.replace(/\\/g, "\\\\")
|
|
178
|
+
.replace(/"/g, '\\"')
|
|
179
|
+
.replace(/\t/g, "\\t")
|
|
180
|
+
.replace(/\r/g, "\\r");
|
|
181
|
+
return `"${escaped}"`;
|
|
182
|
+
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOML parser.
|
|
3
|
+
*
|
|
4
|
+
* Handles the TOML subset used by Chant lexicons: tables, arrays of tables,
|
|
5
|
+
* key-value pairs, inline tables, inline arrays, strings, numbers, booleans.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { unescapeString, stripInlineComment } from "./toml-utils";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse a TOML document string into a plain object.
|
|
12
|
+
*
|
|
13
|
+
* Uses a built-in parser that handles the TOML subset used by Flyway
|
|
14
|
+
* configuration files.
|
|
15
|
+
*/
|
|
16
|
+
export function parseTOML(content: string): Record<string, unknown> {
|
|
17
|
+
const result: Record<string, unknown> = {};
|
|
18
|
+
const lines = content.split("\n");
|
|
19
|
+
let currentPath: string[] = [];
|
|
20
|
+
let isArrayOfTables = false;
|
|
21
|
+
|
|
22
|
+
for (let i = 0; i < lines.length; i++) {
|
|
23
|
+
const line = lines[i].trim();
|
|
24
|
+
|
|
25
|
+
// Skip empty lines and comments
|
|
26
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
27
|
+
|
|
28
|
+
// Array of tables: [[section.path]]
|
|
29
|
+
const aotMatch = line.match(/^\[\[([^\]]+)\]\]\s*(?:#.*)?$/);
|
|
30
|
+
if (aotMatch) {
|
|
31
|
+
currentPath = parseDottedKey(aotMatch[1].trim());
|
|
32
|
+
isArrayOfTables = true;
|
|
33
|
+
// Ensure the array exists and add a new entry
|
|
34
|
+
const arr = ensureArrayAt(result, currentPath);
|
|
35
|
+
arr.push({});
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Table header: [section.path]
|
|
40
|
+
const tableMatch = line.match(/^\[([^\]]+)\]\s*(?:#.*)?$/);
|
|
41
|
+
if (tableMatch) {
|
|
42
|
+
currentPath = parseDottedKey(tableMatch[1].trim());
|
|
43
|
+
isArrayOfTables = false;
|
|
44
|
+
ensureTableAt(result, currentPath);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Key-value pair
|
|
49
|
+
const kvResult = parseKeyValue(line);
|
|
50
|
+
if (kvResult) {
|
|
51
|
+
const target = isArrayOfTables
|
|
52
|
+
? getLastArrayEntry(result, currentPath)
|
|
53
|
+
: getTableAt(result, currentPath);
|
|
54
|
+
if (target) {
|
|
55
|
+
setNestedValue(target, kvResult.key, kvResult.value);
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Key-value parsing ─────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
interface KeyValueResult {
|
|
67
|
+
key: string[];
|
|
68
|
+
value: unknown;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parse a key = value line.
|
|
73
|
+
*/
|
|
74
|
+
function parseKeyValue(line: string): KeyValueResult | null {
|
|
75
|
+
// Find the = sign (not inside quotes)
|
|
76
|
+
const eqIndex = findEquals(line);
|
|
77
|
+
if (eqIndex === -1) return null;
|
|
78
|
+
|
|
79
|
+
const rawKey = line.slice(0, eqIndex).trim();
|
|
80
|
+
const rawValue = line.slice(eqIndex + 1).trim();
|
|
81
|
+
|
|
82
|
+
const key = parseDottedKey(rawKey);
|
|
83
|
+
const value = parseTOMLValue(rawValue);
|
|
84
|
+
|
|
85
|
+
return { key, value };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Find the index of the first `=` not inside quotes.
|
|
90
|
+
*/
|
|
91
|
+
function findEquals(line: string): number {
|
|
92
|
+
let inSingleQuote = false;
|
|
93
|
+
let inDoubleQuote = false;
|
|
94
|
+
|
|
95
|
+
for (let i = 0; i < line.length; i++) {
|
|
96
|
+
const ch = line[i];
|
|
97
|
+
if (ch === "'" && !inDoubleQuote) {
|
|
98
|
+
inSingleQuote = !inSingleQuote;
|
|
99
|
+
} else if (ch === '"' && !inSingleQuote && (i === 0 || line[i - 1] !== "\\")) {
|
|
100
|
+
inDoubleQuote = !inDoubleQuote;
|
|
101
|
+
} else if (ch === "=" && !inSingleQuote && !inDoubleQuote) {
|
|
102
|
+
return i;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return -1;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Parse a dotted key like `flyway.placeholders.name` into path segments.
|
|
110
|
+
*/
|
|
111
|
+
function parseDottedKey(raw: string): string[] {
|
|
112
|
+
const parts: string[] = [];
|
|
113
|
+
let current = "";
|
|
114
|
+
let inQuote = false;
|
|
115
|
+
let quoteChar = "";
|
|
116
|
+
|
|
117
|
+
for (let i = 0; i < raw.length; i++) {
|
|
118
|
+
const ch = raw[i];
|
|
119
|
+
if (inQuote) {
|
|
120
|
+
if (ch === quoteChar) {
|
|
121
|
+
inQuote = false;
|
|
122
|
+
} else {
|
|
123
|
+
current += ch;
|
|
124
|
+
}
|
|
125
|
+
} else if (ch === '"' || ch === "'") {
|
|
126
|
+
inQuote = true;
|
|
127
|
+
quoteChar = ch;
|
|
128
|
+
} else if (ch === ".") {
|
|
129
|
+
parts.push(current.trim());
|
|
130
|
+
current = "";
|
|
131
|
+
} else {
|
|
132
|
+
current += ch;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (current.trim()) parts.push(current.trim());
|
|
136
|
+
return parts;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Value parsing ─────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Parse a TOML value string.
|
|
143
|
+
*/
|
|
144
|
+
function parseTOMLValue(raw: string): unknown {
|
|
145
|
+
// Strip inline comments (not inside strings)
|
|
146
|
+
const value = stripInlineComment(raw);
|
|
147
|
+
|
|
148
|
+
if (value === "true") return true;
|
|
149
|
+
if (value === "false") return false;
|
|
150
|
+
|
|
151
|
+
// String values
|
|
152
|
+
if (value.startsWith('"""')) {
|
|
153
|
+
const end = value.indexOf('"""', 3);
|
|
154
|
+
return end !== -1 ? value.slice(3, end) : value.slice(3);
|
|
155
|
+
}
|
|
156
|
+
if (value.startsWith("'''")) {
|
|
157
|
+
const end = value.indexOf("'''", 3);
|
|
158
|
+
return end !== -1 ? value.slice(3, end) : value.slice(3);
|
|
159
|
+
}
|
|
160
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
161
|
+
return unescapeString(value.slice(1, -1));
|
|
162
|
+
}
|
|
163
|
+
if (value.startsWith("'") && value.endsWith("'")) {
|
|
164
|
+
return value.slice(1, -1); // Literal string, no escaping
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Array
|
|
168
|
+
if (value.startsWith("[")) {
|
|
169
|
+
return parseTOMLArray(value);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Inline table
|
|
173
|
+
if (value.startsWith("{")) {
|
|
174
|
+
return parseTOMLInlineTable(value);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Number
|
|
178
|
+
const num = Number(value);
|
|
179
|
+
if (!isNaN(num) && value !== "") return num;
|
|
180
|
+
|
|
181
|
+
// Bare string / date (return as string)
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Parse a TOML inline array.
|
|
187
|
+
*/
|
|
188
|
+
function parseTOMLArray(raw: string): unknown[] {
|
|
189
|
+
// Remove outer brackets
|
|
190
|
+
const inner = raw.slice(1, raw.lastIndexOf("]")).trim();
|
|
191
|
+
if (inner === "") return [];
|
|
192
|
+
|
|
193
|
+
const items: unknown[] = [];
|
|
194
|
+
let current = "";
|
|
195
|
+
let depth = 0;
|
|
196
|
+
let inString = false;
|
|
197
|
+
let stringChar = "";
|
|
198
|
+
|
|
199
|
+
for (let i = 0; i < inner.length; i++) {
|
|
200
|
+
const ch = inner[i];
|
|
201
|
+
if (inString) {
|
|
202
|
+
current += ch;
|
|
203
|
+
if (ch === stringChar && inner[i - 1] !== "\\") {
|
|
204
|
+
inString = false;
|
|
205
|
+
}
|
|
206
|
+
} else if (ch === '"' || ch === "'") {
|
|
207
|
+
inString = true;
|
|
208
|
+
stringChar = ch;
|
|
209
|
+
current += ch;
|
|
210
|
+
} else if (ch === "[" || ch === "{") {
|
|
211
|
+
depth++;
|
|
212
|
+
current += ch;
|
|
213
|
+
} else if (ch === "]" || ch === "}") {
|
|
214
|
+
depth--;
|
|
215
|
+
current += ch;
|
|
216
|
+
} else if (ch === "," && depth === 0) {
|
|
217
|
+
const trimmed = current.trim();
|
|
218
|
+
if (trimmed !== "") items.push(parseTOMLValue(trimmed));
|
|
219
|
+
current = "";
|
|
220
|
+
} else {
|
|
221
|
+
current += ch;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const trimmed = current.trim();
|
|
226
|
+
if (trimmed !== "") items.push(parseTOMLValue(trimmed));
|
|
227
|
+
|
|
228
|
+
return items;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Parse a TOML inline table.
|
|
233
|
+
*/
|
|
234
|
+
function parseTOMLInlineTable(raw: string): Record<string, unknown> {
|
|
235
|
+
const inner = raw.slice(1, raw.lastIndexOf("}")).trim();
|
|
236
|
+
if (inner === "") return {};
|
|
237
|
+
|
|
238
|
+
const result: Record<string, unknown> = {};
|
|
239
|
+
let current = "";
|
|
240
|
+
let depth = 0;
|
|
241
|
+
let inString = false;
|
|
242
|
+
let stringChar = "";
|
|
243
|
+
|
|
244
|
+
for (let i = 0; i < inner.length; i++) {
|
|
245
|
+
const ch = inner[i];
|
|
246
|
+
if (inString) {
|
|
247
|
+
current += ch;
|
|
248
|
+
if (ch === stringChar && inner[i - 1] !== "\\") {
|
|
249
|
+
inString = false;
|
|
250
|
+
}
|
|
251
|
+
} else if (ch === '"' || ch === "'") {
|
|
252
|
+
inString = true;
|
|
253
|
+
stringChar = ch;
|
|
254
|
+
current += ch;
|
|
255
|
+
} else if (ch === "[" || ch === "{") {
|
|
256
|
+
depth++;
|
|
257
|
+
current += ch;
|
|
258
|
+
} else if (ch === "]" || ch === "}") {
|
|
259
|
+
depth--;
|
|
260
|
+
current += ch;
|
|
261
|
+
} else if (ch === "," && depth === 0) {
|
|
262
|
+
parseInlineTableEntry(current.trim(), result);
|
|
263
|
+
current = "";
|
|
264
|
+
} else {
|
|
265
|
+
current += ch;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (current.trim()) parseInlineTableEntry(current.trim(), result);
|
|
270
|
+
return result;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function parseInlineTableEntry(entry: string, target: Record<string, unknown>): void {
|
|
274
|
+
const kv = parseKeyValue(entry);
|
|
275
|
+
if (kv) {
|
|
276
|
+
setNestedValue(target, kv.key, kv.value);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── Object path helpers ───────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
function ensureTableAt(root: Record<string, unknown>, path: string[]): Record<string, unknown> {
|
|
283
|
+
let current = root;
|
|
284
|
+
for (const segment of path) {
|
|
285
|
+
if (!(segment in current)) {
|
|
286
|
+
current[segment] = {};
|
|
287
|
+
}
|
|
288
|
+
const next = current[segment];
|
|
289
|
+
if (Array.isArray(next)) {
|
|
290
|
+
// Navigate into last array entry
|
|
291
|
+
current = next[next.length - 1] as Record<string, unknown>;
|
|
292
|
+
} else if (typeof next === "object" && next !== null) {
|
|
293
|
+
current = next as Record<string, unknown>;
|
|
294
|
+
} else {
|
|
295
|
+
const obj: Record<string, unknown> = {};
|
|
296
|
+
current[segment] = obj;
|
|
297
|
+
current = obj;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return current;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function ensureArrayAt(root: Record<string, unknown>, path: string[]): unknown[] {
|
|
304
|
+
let current = root;
|
|
305
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
306
|
+
const segment = path[i];
|
|
307
|
+
if (!(segment in current)) {
|
|
308
|
+
current[segment] = {};
|
|
309
|
+
}
|
|
310
|
+
const next = current[segment];
|
|
311
|
+
if (Array.isArray(next)) {
|
|
312
|
+
current = next[next.length - 1] as Record<string, unknown>;
|
|
313
|
+
} else if (typeof next === "object" && next !== null) {
|
|
314
|
+
current = next as Record<string, unknown>;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const lastKey = path[path.length - 1];
|
|
319
|
+
if (!(lastKey in current) || !Array.isArray(current[lastKey])) {
|
|
320
|
+
current[lastKey] = [];
|
|
321
|
+
}
|
|
322
|
+
return current[lastKey] as unknown[];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function getTableAt(root: Record<string, unknown>, path: string[]): Record<string, unknown> {
|
|
326
|
+
let current = root;
|
|
327
|
+
for (const segment of path) {
|
|
328
|
+
const next = current[segment];
|
|
329
|
+
if (Array.isArray(next)) {
|
|
330
|
+
current = next[next.length - 1] as Record<string, unknown>;
|
|
331
|
+
} else if (typeof next === "object" && next !== null) {
|
|
332
|
+
current = next as Record<string, unknown>;
|
|
333
|
+
} else {
|
|
334
|
+
return current;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return current;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function getLastArrayEntry(root: Record<string, unknown>, path: string[]): Record<string, unknown> | null {
|
|
341
|
+
let current = root;
|
|
342
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
343
|
+
const next = current[path[i]];
|
|
344
|
+
if (Array.isArray(next)) {
|
|
345
|
+
current = next[next.length - 1] as Record<string, unknown>;
|
|
346
|
+
} else if (typeof next === "object" && next !== null) {
|
|
347
|
+
current = next as Record<string, unknown>;
|
|
348
|
+
} else {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const lastKey = path[path.length - 1];
|
|
354
|
+
const arr = current[lastKey];
|
|
355
|
+
if (Array.isArray(arr) && arr.length > 0) {
|
|
356
|
+
return arr[arr.length - 1] as Record<string, unknown>;
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function setNestedValue(target: Record<string, unknown>, key: string[], value: unknown): void {
|
|
362
|
+
let current = target;
|
|
363
|
+
for (let i = 0; i < key.length - 1; i++) {
|
|
364
|
+
if (!(key[i] in current)) {
|
|
365
|
+
current[key[i]] = {};
|
|
366
|
+
}
|
|
367
|
+
current = current[key[i]] as Record<string, unknown>;
|
|
368
|
+
}
|
|
369
|
+
current[key[key.length - 1]] = value;
|
|
370
|
+
}
|