mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(oc-path): support deep config edits (#86060)
This commit is contained in:
@@ -197,9 +197,12 @@ the per-kind AST shape.
|
||||
Markdown insertions append sections, frontmatter keys, or section items and
|
||||
render a canonical markdown shape for the changed file.
|
||||
- JSONC leaf writes coerce the string value to the existing leaf type
|
||||
(`string`, finite `number`, `true`/`false`, or `null`). JSONC object and array
|
||||
insertions parse `<value>` as JSON and use the `jsonc-parser` edit path for
|
||||
ordinary leaf writes, preserving comments and nearby formatting.
|
||||
(`string`, finite `number`, `true`/`false`, or `null`). Use `--value-json`
|
||||
when a JSONC/JSON/JSONL leaf replacement should parse `<value>` as JSON and
|
||||
may change shape, such as replacing a string SecretRef shorthand with an
|
||||
object. JSONC object and array insertions parse `<value>` as JSON and use the
|
||||
`jsonc-parser` edit path for ordinary leaf writes, preserving comments and
|
||||
nearby formatting.
|
||||
- JSONL leaf writes coerce like JSONC inside a line. Whole-line replacement and
|
||||
append parse `<value>` as JSON. Rendered JSONL preserves the file's dominant
|
||||
LF/CRLF line-ending convention.
|
||||
@@ -245,6 +248,12 @@ More grammar examples:
|
||||
# Quote keys containing / or .
|
||||
openclaw path resolve 'oc://config.jsonc/agents.defaults.models/"anthropic/claude-opus-4-7"/alias'
|
||||
|
||||
# Deep JSON/JSONC paths can use slash segments; they normalize to dotted subsegments
|
||||
openclaw path set 'oc://openclaw.json/agents/list/0/tools/exec/security' 'allowlist' --dry-run
|
||||
|
||||
# Replace a JSONC leaf with a parsed object
|
||||
openclaw path set 'oc://openclaw.json/gateway/auth/token' '{"source":"file","provider":"secrets","id":"/test"}' --value-json --dry-run
|
||||
|
||||
# Predicate search over JSONC children
|
||||
openclaw path find 'oc://config.jsonc/plugins/[enabled=true]/id'
|
||||
|
||||
|
||||
@@ -255,6 +255,43 @@ describe("openclaw path CLI", () => {
|
||||
expect(readFileSync(filePath, "utf-8")).toBe(before);
|
||||
});
|
||||
|
||||
it("CLI-S08 sets slash-deep JSONC paths and parsed JSON values", async () => {
|
||||
const filePath = join(workspaceDir, "openclaw.json");
|
||||
writeFileSync(
|
||||
filePath,
|
||||
'{ "agents": { "list": [{ "tools": { "exec": { "security": "deny" } } }] }, "gateway": { "auth": { "token": "${TOKEN}" } } }\n',
|
||||
"utf-8",
|
||||
);
|
||||
const rt = createTestRuntime();
|
||||
|
||||
await pathSetCommand(
|
||||
"oc://openclaw.json/gateway/auth/token",
|
||||
'{"source":"file","provider":"secrets","id":"/test"}',
|
||||
{ cwd: workspaceDir, json: true, valueJson: true },
|
||||
rt,
|
||||
);
|
||||
|
||||
expect(rt.exitCode).toBe(0);
|
||||
expect(JSON.parse(readFileSync(filePath, "utf8")).gateway.auth.token).toEqual({
|
||||
source: "file",
|
||||
provider: "secrets",
|
||||
id: "/test",
|
||||
});
|
||||
|
||||
const rt2 = createTestRuntime();
|
||||
await pathSetCommand(
|
||||
"oc://openclaw.json/agents/list/0/tools/exec/security",
|
||||
"allowlist",
|
||||
{ cwd: workspaceDir, json: true },
|
||||
rt2,
|
||||
);
|
||||
|
||||
expect(rt2.exitCode).toBe(0);
|
||||
expect(JSON.parse(readFileSync(filePath, "utf8")).agents.list[0].tools.exec.security).toBe(
|
||||
"allowlist",
|
||||
);
|
||||
});
|
||||
|
||||
it("CLI-S03 sentinel-bearing value is refused at emit", async () => {
|
||||
const filePath = join(workspaceDir, "gateway.jsonc");
|
||||
writeFileSync(filePath, '{ "token": "x" }', "utf-8");
|
||||
|
||||
@@ -41,6 +41,7 @@ export type OutputRuntimeEnv = {
|
||||
export interface PathCommandOptions {
|
||||
readonly json?: boolean;
|
||||
readonly human?: boolean;
|
||||
readonly valueJson?: boolean;
|
||||
readonly cwd?: string;
|
||||
readonly file?: string;
|
||||
readonly dryRun?: boolean;
|
||||
@@ -334,7 +335,9 @@ export async function pathSetCommand(
|
||||
const oldBytes = await fs.readFile(fsPath, "utf-8");
|
||||
const ast = await loadAst(fsPath, ocPath.file);
|
||||
|
||||
const result = catchSentinel("set", runtime, mode, () => setOcPath(ast, ocPath, value));
|
||||
const result = catchSentinel("set", runtime, mode, () =>
|
||||
setOcPath(ast, ocPath, value, { valueJson: options.valueJson === true }),
|
||||
);
|
||||
if (result === null) {
|
||||
return;
|
||||
}
|
||||
@@ -553,6 +556,7 @@ export function registerPathCli(program: Command): void {
|
||||
.description("Write a leaf value at an oc:// path")
|
||||
.argument("<oc-path>", "oc:// path to write")
|
||||
.argument("<value>", "string value to write")
|
||||
.option("--value-json", "Parse <value> as JSON for JSON/JSONC/JSONL leaf replacement")
|
||||
.option("--dry-run", "Print bytes without writing")
|
||||
.option("--diff", "With --dry-run, print a unified diff instead of full bytes"),
|
||||
).action(async (pathStr: string, value: string, opts: PathCommandOptions) => {
|
||||
|
||||
@@ -168,12 +168,17 @@ export function parseOcPath(input: string): OcPath {
|
||||
fail(`Empty oc:// path: ${printable(input)}`, input, "OC_PATH_EMPTY");
|
||||
}
|
||||
|
||||
const segments = splitRespectingBrackets(pathPart, "/", input);
|
||||
for (const seg of segments) {
|
||||
const rawSegments = splitRespectingBrackets(pathPart, "/", input);
|
||||
for (const seg of rawSegments) {
|
||||
if (seg.length === 0) {
|
||||
fail(`Empty segment in oc:// path: ${printable(input)}`, input, "OC_PATH_EMPTY_SEGMENT");
|
||||
}
|
||||
}
|
||||
const fileSeg = rawSegments[0];
|
||||
const file = isQuotedSeg(fileSeg) ? unquoteSeg(fileSeg) : fileSeg;
|
||||
validateFileSlot(file, input);
|
||||
|
||||
const segments = normalizeDeepJsonPathSegments(rawSegments, file, input);
|
||||
if (segments.length > 4) {
|
||||
fail(`Too many segments in oc:// path (max 4): ${printable(input)}`, input, "OC_PATH_TOO_DEEP");
|
||||
}
|
||||
@@ -193,13 +198,6 @@ export function parseOcPath(input: string): OcPath {
|
||||
}
|
||||
}
|
||||
|
||||
// Unquote the file slot — splitRespectingBrackets keeps a quoted file
|
||||
// segment intact so its `/` isn't a slot separator; strip the quotes
|
||||
// so consumers see the literal filename.
|
||||
const fileSeg = segments[0];
|
||||
const file = isQuotedSeg(fileSeg) ? unquoteSeg(fileSeg) : fileSeg;
|
||||
validateFileSlot(file, input);
|
||||
|
||||
const session = extractSession(queryPart, input);
|
||||
return {
|
||||
file,
|
||||
@@ -210,6 +208,33 @@ export function parseOcPath(input: string): OcPath {
|
||||
};
|
||||
}
|
||||
|
||||
function isJsonPathFile(file: string): boolean {
|
||||
const lower = file.toLowerCase();
|
||||
return lower.endsWith(".json") || lower.endsWith(".jsonc");
|
||||
}
|
||||
|
||||
function normalizeDeepJsonPathSegments(
|
||||
segments: readonly string[],
|
||||
file: string,
|
||||
input: string,
|
||||
): readonly string[] {
|
||||
if (segments.length <= 4 || !isJsonPathFile(file)) {
|
||||
return segments;
|
||||
}
|
||||
const pathSegments = segments.slice(1);
|
||||
if (pathSegments.length > MAX_TRAVERSAL_DEPTH) {
|
||||
fail(
|
||||
`JSON oc:// path exceeds ${MAX_TRAVERSAL_DEPTH} nested segments: ${printable(input)}`,
|
||||
input,
|
||||
"OC_PATH_TOO_DEEP",
|
||||
);
|
||||
}
|
||||
const section = pathSegments.slice(0, -2).join(".");
|
||||
const item = pathSegments[pathSegments.length - 2];
|
||||
const field = pathSegments[pathSegments.length - 1];
|
||||
return [segments[0], section, item, field];
|
||||
}
|
||||
|
||||
/** Format an `OcPath` struct into its canonical string form. */
|
||||
export function formatOcPath(path: OcPath): string {
|
||||
if (!path.file || path.file.length === 0) {
|
||||
|
||||
@@ -113,6 +113,64 @@ describe("findOcPaths — JSONC kind", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOcPaths — slash-deep JSONC paths", () => {
|
||||
const jsonc = parseJsonc(
|
||||
JSON.stringify({
|
||||
mcp: {
|
||||
servers: {
|
||||
github: { env: { GITHUB_TOKEN: "gh-token" } },
|
||||
gitlab: { env: { GITHUB_TOKEN: "gl-token" } },
|
||||
},
|
||||
},
|
||||
agents: [
|
||||
{ id: "coder", tools: { exec: { security: "deny" } } },
|
||||
{ id: "reviewer", tools: { exec: { security: "allowlist" } } },
|
||||
],
|
||||
}),
|
||||
).ast;
|
||||
|
||||
it("expands * in a slash-deep JSON object path", () => {
|
||||
const out = findOcPaths(
|
||||
jsonc,
|
||||
parseOcPath("oc://openclaw.json/mcp/servers/*/env/GITHUB_TOKEN"),
|
||||
);
|
||||
expect(out).toHaveLength(2);
|
||||
const values = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : ""));
|
||||
expect(values.toSorted()).toEqual(["gh-token", "gl-token"]);
|
||||
});
|
||||
|
||||
it("expands * in a slash-deep JSON array path", () => {
|
||||
const out = findOcPaths(jsonc, parseOcPath("oc://openclaw.json/agents/*/tools/exec/security"));
|
||||
expect(out).toHaveLength(2);
|
||||
const values = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : ""));
|
||||
expect(values.toSorted()).toEqual(["allowlist", "deny"]);
|
||||
});
|
||||
|
||||
it("expands predicates in slash-deep JSON array paths", () => {
|
||||
const out = findOcPaths(
|
||||
jsonc,
|
||||
parseOcPath("oc://openclaw.json/agents/[id=reviewer]/tools/exec/security"),
|
||||
);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]?.match.kind === "leaf" && out[0].match.valueText).toBe("allowlist");
|
||||
});
|
||||
|
||||
it("expands ** in slash-deep JSON paths", () => {
|
||||
const out = findOcPaths(jsonc, parseOcPath("oc://openclaw.json/mcp/**/GITHUB_TOKEN"));
|
||||
expect(out).toHaveLength(2);
|
||||
const values = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : ""));
|
||||
expect(values.toSorted()).toEqual(["gh-token", "gl-token"]);
|
||||
});
|
||||
|
||||
it("returns slash-deep JSON matches as concrete paths that resolve", () => {
|
||||
const out = findOcPaths(jsonc, parseOcPath("oc://openclaw.json/agents/*/tools/exec/security"));
|
||||
for (const m of out) {
|
||||
expect(resolveOcPath(jsonc, m.path)?.kind).toBe("leaf");
|
||||
expect(formatOcPath(m.path)).not.toContain("*");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOcPaths — JSONL kind", () => {
|
||||
const jsonl = parseJsonl(
|
||||
'{"event":"start","userId":"u1"}\n' +
|
||||
|
||||
@@ -52,10 +52,7 @@ describe("parseOcPath", () => {
|
||||
});
|
||||
|
||||
it("rejects control chars in ignored query values", () => {
|
||||
expectOcPathError(
|
||||
() => parseOcPath("oc://SOUL.md?ignored=\x00"),
|
||||
"OC_PATH_CONTROL_CHAR",
|
||||
);
|
||||
expectOcPathError(() => parseOcPath("oc://SOUL.md?ignored=\x00"), "OC_PATH_CONTROL_CHAR");
|
||||
});
|
||||
|
||||
it("rejects missing scheme", () => {
|
||||
@@ -74,6 +71,15 @@ describe("parseOcPath", () => {
|
||||
expectOcPathError(() => parseOcPath("oc://SOUL.md/a/b/c/d/e"), "OC_PATH_TOO_DEEP");
|
||||
});
|
||||
|
||||
it("normalizes deep JSON paths into dotted subsegments", () => {
|
||||
expect(parseOcPath("oc://openclaw.json/agents/list/8/tools/exec/security")).toEqual({
|
||||
file: "openclaw.json",
|
||||
section: "agents.list.8.tools",
|
||||
item: "exec",
|
||||
field: "security",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-string input", () => {
|
||||
expectOcPathError(() => parseOcPath(123 as unknown as string), "OC_PATH_NOT_STRING");
|
||||
});
|
||||
|
||||
@@ -265,6 +265,62 @@ describe("setOcPath — jsonc leaf with coercion", () => {
|
||||
expect(r.reason).toBe("parse-error");
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves slash-deep JSONC paths", () => {
|
||||
const ast = parseJsonc(
|
||||
'{ "agents": { "list": [{ "tools": { "exec": { "security": "deny" } } }] } }',
|
||||
).ast;
|
||||
const r = setOcPath(
|
||||
ast,
|
||||
parseOcPath("oc://openclaw.json/agents/list/0/tools/exec/security"),
|
||||
"allowlist",
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const ast2 = r.ast as Parameters<typeof emitJsonc>[0];
|
||||
expect(JSON.parse(emitJsonc(ast2))).toEqual({
|
||||
agents: { list: [{ tools: { exec: { security: "allowlist" } } }] },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps JSON-looking strings as strings by default", () => {
|
||||
const ast = parseJsonc('{ "token": "${TOKEN}" }').ast;
|
||||
const r = setOcPath(ast, parseOcPath("oc://openclaw.json/token"), '{"source":"file"}');
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const ast2 = r.ast as Parameters<typeof emitJsonc>[0];
|
||||
expect(JSON.parse(emitJsonc(ast2))).toEqual({ token: '{"source":"file"}' });
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces a JSONC leaf with parsed JSON when requested", () => {
|
||||
const ast = parseJsonc('{ "token": "${TOKEN}" }').ast;
|
||||
const r = setOcPath(
|
||||
ast,
|
||||
parseOcPath("oc://openclaw.json/token"),
|
||||
'{"source":"file","provider":"secrets","id":"/test"}',
|
||||
{ valueJson: true },
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const ast2 = r.ast as Parameters<typeof emitJsonc>[0];
|
||||
expect(JSON.parse(emitJsonc(ast2))).toEqual({
|
||||
token: { source: "file", provider: "secrets", id: "/test" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-finite parsed JSON replacement values", () => {
|
||||
const ast = parseJsonc('{ "limit": 1 }').ast;
|
||||
const r = setOcPath(ast, parseOcPath("oc://openclaw.json/limit"), "1e999", {
|
||||
valueJson: true,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.reason).toBe("parse-error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("setOcPath — jsonl leaf", () => {
|
||||
|
||||
@@ -98,6 +98,10 @@ export type SetResult =
|
||||
readonly detail?: string;
|
||||
};
|
||||
|
||||
export type SetOcPathOptions = {
|
||||
readonly valueJson?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Insertion marker on the deepest path segment: `+`, `+<key>`, or
|
||||
* `+<index>`. Returns parent path + marker; null for plain paths.
|
||||
@@ -409,7 +413,12 @@ function resolveYamlInsertion(ast: YamlAst, info: InsertionInfo): OcMatch | null
|
||||
* kind-appropriate content (JSON for jsonc/jsonl; raw text for md).
|
||||
* Sentinel-guard violations throw `OcEmitSentinelError`.
|
||||
*/
|
||||
export function setOcPath(ast: OcAst, path: OcPath, value: string): SetResult {
|
||||
export function setOcPath(
|
||||
ast: OcAst,
|
||||
path: OcPath,
|
||||
value: string,
|
||||
options: SetOcPathOptions = {},
|
||||
): SetResult {
|
||||
if (hasWildcard(path)) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -436,21 +445,37 @@ export function setOcPath(ast: OcAst, path: OcPath, value: string): SetResult {
|
||||
return r.ok ? { ok: true, ast: r.ast } : { ok: false, reason: r.reason };
|
||||
}
|
||||
case "jsonc":
|
||||
return setStructuredLeaf(ast, path, value, resolveJsoncOcPath, setJsoncOcPath);
|
||||
return setStructuredLeaf(ast, path, value, options, resolveJsoncOcPath, setJsoncOcPath);
|
||||
case "jsonl":
|
||||
return setStructuredLeaf(ast, path, value, resolveJsonlOcPath, setJsonlOcPath, () => {
|
||||
// jsonl line replacement: value must be JSON for the whole line.
|
||||
const parsed = tryParseJson(value);
|
||||
if (parsed === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "parse-error",
|
||||
detail: "line replacement requires JSON value",
|
||||
};
|
||||
}
|
||||
const r = setJsonlOcPath(ast, path, jsonToJsoncValue(parsed));
|
||||
return r.ok ? { ok: true, ast: r.ast } : { ok: false, reason: r.reason };
|
||||
});
|
||||
return setStructuredLeaf(
|
||||
ast,
|
||||
path,
|
||||
value,
|
||||
options,
|
||||
resolveJsonlOcPath,
|
||||
setJsonlOcPath,
|
||||
() => {
|
||||
// jsonl line replacement: value must be JSON for the whole line.
|
||||
const parsed = tryParseJson(value);
|
||||
if (parsed === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "parse-error",
|
||||
detail: "line replacement requires JSON value",
|
||||
};
|
||||
}
|
||||
const parsedValue = jsonToJsoncValue(parsed);
|
||||
if (parsedValue === null) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "parse-error",
|
||||
detail: "line replacement requires finite JSON value",
|
||||
};
|
||||
}
|
||||
const r = setJsonlOcPath(ast, path, parsedValue);
|
||||
return r.ok ? { ok: true, ast: r.ast } : { ok: false, reason: r.reason };
|
||||
},
|
||||
);
|
||||
case "yaml":
|
||||
return setYamlLeaf(ast, path, value);
|
||||
}
|
||||
@@ -463,6 +488,7 @@ function setStructuredLeaf<A extends OcAst>(
|
||||
ast: A,
|
||||
path: OcPath,
|
||||
value: string,
|
||||
options: SetOcPathOptions,
|
||||
resolve: (a: A, p: OcPath) => StructuredLeafMatch | null,
|
||||
set: (a: A, p: OcPath, c: JsoncValue) => SetOpResult<A>,
|
||||
onLine?: () => SetResult,
|
||||
@@ -482,7 +508,10 @@ function setStructuredLeaf<A extends OcAst>(
|
||||
return onLine !== undefined ? onLine() : { ok: false, reason: "not-writable" };
|
||||
}
|
||||
const leafValue = existing.kind === "object-entry" ? existing.node.value : existing.node;
|
||||
const coerced = coerceJsoncLeaf(value, leafValue);
|
||||
const coerced =
|
||||
options.valueJson === true
|
||||
? parseJsoncReplacement(value, leafValue)
|
||||
: coerceJsoncLeaf(value, leafValue);
|
||||
if (coerced === null) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -494,6 +523,18 @@ function setStructuredLeaf<A extends OcAst>(
|
||||
return r.ok ? { ok: true, ast: r.ast } : { ok: false, reason: r.reason };
|
||||
}
|
||||
|
||||
function parseJsoncReplacement(valueText: string, existing: JsoncValue): JsoncValue | null {
|
||||
const parsed = tryParseJson(valueText);
|
||||
if (parsed === undefined) {
|
||||
return null;
|
||||
}
|
||||
const parsedValue = jsonToJsoncValue(parsed);
|
||||
if (parsedValue === null) {
|
||||
return null;
|
||||
}
|
||||
return existing.line === undefined ? parsedValue : { ...parsedValue, line: existing.line };
|
||||
}
|
||||
|
||||
type StructuredLeafMatch =
|
||||
| { readonly kind: "root" }
|
||||
| { readonly kind: "line" }
|
||||
@@ -595,6 +636,13 @@ function setJsoncInsertion(ast: JsoncAst, info: InsertionInfo, value: string): S
|
||||
return { ok: false, reason: "parse-error", detail: "jsonc insertion requires JSON value" };
|
||||
}
|
||||
const newJsoncValue = jsonToJsoncValue(parsed);
|
||||
if (newJsoncValue === null) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "parse-error",
|
||||
detail: "jsonc insertion requires finite JSON value",
|
||||
};
|
||||
}
|
||||
|
||||
if (containerMatch.kind !== "insertion-point") {
|
||||
return { ok: false, reason: "unresolved" };
|
||||
@@ -656,7 +704,15 @@ function setJsonlInsertion(ast: JsonlAst, info: InsertionInfo, value: string): S
|
||||
if (parsed === undefined) {
|
||||
return { ok: false, reason: "parse-error", detail: "jsonl line append requires JSON value" };
|
||||
}
|
||||
return { ok: true, ast: appendJsonlLine(ast, jsonToJsoncValue(parsed)) };
|
||||
const parsedValue = jsonToJsoncValue(parsed);
|
||||
if (parsedValue === null) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "parse-error",
|
||||
detail: "jsonl line append requires finite JSON value",
|
||||
};
|
||||
}
|
||||
return { ok: true, ast: appendJsonlLine(ast, parsedValue) };
|
||||
}
|
||||
|
||||
function setYamlLeaf(ast: YamlAst, path: OcPath, value: string): SetResult {
|
||||
@@ -754,7 +810,7 @@ function tryParseJson(value: string): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
function jsonToJsoncValue(v: unknown): JsoncValue {
|
||||
function jsonToJsoncValue(v: unknown): JsoncValue | null {
|
||||
// Synthetic values omit `line` — only the parser sets line metadata.
|
||||
if (v === null) {
|
||||
return { kind: "null" };
|
||||
@@ -763,23 +819,38 @@ function jsonToJsoncValue(v: unknown): JsoncValue {
|
||||
return { kind: "string", value: v };
|
||||
}
|
||||
if (typeof v === "number") {
|
||||
if (!Number.isFinite(v)) {
|
||||
return null;
|
||||
}
|
||||
return { kind: "number", value: v };
|
||||
}
|
||||
if (typeof v === "boolean") {
|
||||
return { kind: "boolean", value: v };
|
||||
}
|
||||
if (Array.isArray(v)) {
|
||||
return { kind: "array", items: v.map(jsonToJsoncValue) };
|
||||
const items = v.map(jsonToJsoncValue);
|
||||
if (items.some((item) => item === null)) {
|
||||
return null;
|
||||
}
|
||||
return { kind: "array", items: items as JsoncValue[] };
|
||||
}
|
||||
if (typeof v === "object") {
|
||||
const obj = v as Record<string, unknown>;
|
||||
const entries: JsoncEntry[] = [];
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const jsoncValue = jsonToJsoncValue(value);
|
||||
if (jsoncValue === null) {
|
||||
return null;
|
||||
}
|
||||
entries.push({
|
||||
key,
|
||||
value: jsoncValue,
|
||||
line: 0,
|
||||
});
|
||||
}
|
||||
return {
|
||||
kind: "object",
|
||||
entries: Object.entries(obj).map(([key, value]) => ({
|
||||
key,
|
||||
value: jsonToJsoncValue(value),
|
||||
line: 0,
|
||||
})),
|
||||
entries,
|
||||
};
|
||||
}
|
||||
// JSON.parse never produces undefined / function / symbol.
|
||||
|
||||
Reference in New Issue
Block a user