@geml/geml 1.5.1 → 1.6.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 +21 -21
- package/README.md +248 -217
- package/codemap/adapters/crg.mjs +120 -120
- package/codemap/adapters/joern.mjs +131 -131
- package/codemap/adapters/scip.mjs +658 -658
- package/codemap/browser-stub.mjs +29 -29
- package/codemap/build.mjs +609 -609
- package/codemap/cross-stack.mjs +303 -303
- package/codemap/detect.mjs +399 -399
- package/codemap/emit.mjs +480 -480
- package/codemap/entries.mjs +129 -129
- package/codemap/exclude.mjs +52 -52
- package/codemap/find.mjs +49 -49
- package/codemap/foldings.mjs +110 -110
- package/codemap/joern-export.sc +83 -83
- package/codemap/mcp-server.mjs +431 -431
- package/codemap/normalize.mjs +275 -275
- package/codemap/recipe-trust.mjs +103 -103
- package/codemap/refresh.mjs +310 -310
- package/codemap/render-all.mjs +77 -77
- package/codemap/serve.mjs +585 -585
- package/codemap/sfc-virtualize.mjs +367 -367
- package/codemap/verify.mjs +148 -148
- package/dist/geml.js +507 -268
- package/dist/history.d.ts +11 -8
- package/dist/history.js +20 -15
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +86 -37
- package/dist/render-html.js +35 -35
- package/dist/render.js +136 -136
- package/dist/selector.d.ts +55 -0
- package/dist/selector.js +112 -0
- package/package.json +66 -66
package/dist/geml.js
CHANGED
|
@@ -13,7 +13,7 @@ import { readFileSync, writeFileSync, realpathSync, statSync, existsSync } from
|
|
|
13
13
|
import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { spawnSync } from "node:child_process";
|
|
16
|
-
import {
|
|
16
|
+
import { save, restore, verify, isCurrent, listRevisions, resolveContent, firstChangedContent } from "./history.js";
|
|
17
17
|
import { renderHtml } from "./render-html.js";
|
|
18
18
|
import { normalizeBlockId } from "./block-edit.js";
|
|
19
19
|
import { normalizeSource } from "./diagnostics.js";
|
|
@@ -23,6 +23,7 @@ import { parseTable } from "./table.js";
|
|
|
23
23
|
import { buildChart } from "./chart.js";
|
|
24
24
|
import { mdToGeml } from "./from-md.js";
|
|
25
25
|
import { serialize } from "./serialize.js";
|
|
26
|
+
import { addressUnits, discoveryHint, matchContent, matchType, parseSelector, shortestAddress, } from "./selector.js";
|
|
26
27
|
import { gemlToMd } from "./to-md.js";
|
|
27
28
|
export { mdToGeml } from "./from-md.js";
|
|
28
29
|
export { renderHtml } from "./render-html.js";
|
|
@@ -1077,9 +1078,12 @@ function sectionEnd(lines, i, level) {
|
|
|
1077
1078
|
// `get`/`set` operate on the one the parser actually registered). `base` is the
|
|
1078
1079
|
// absolute line offset of this slice within the whole document.
|
|
1079
1080
|
function collectSpans(lines, base, out, ctx, depth = 0,
|
|
1080
|
-
// Optional second index: every
|
|
1081
|
-
// block the author never named is still
|
|
1082
|
-
|
|
1081
|
+
// Optional second index: every addressable unit in document order — typed
|
|
1082
|
+
// blocks (id-bearing or not, so a block the author never named is still
|
|
1083
|
+
// addressable), headings, footnote definitions. A second SINK on the one
|
|
1084
|
+
// walk, not a second walk: the selector design's "one definition, one
|
|
1085
|
+
// implementation" applies to the scan as much as to the syntax.
|
|
1086
|
+
units) {
|
|
1083
1087
|
const add = (id, start, end) => {
|
|
1084
1088
|
if (!out.has(id))
|
|
1085
1089
|
out.set(id, { start, end });
|
|
@@ -1094,6 +1098,7 @@ types) {
|
|
|
1094
1098
|
const fndef = /^\[\^([^\]]+)\]:[ \t]?(.*)$/.exec(line);
|
|
1095
1099
|
if (fndef) {
|
|
1096
1100
|
add(fndef[1].trim(), base + i, base + i + 1);
|
|
1101
|
+
units?.push({ span: { start: base + i, end: base + i + 1 }, kind: "footnote", id: fndef[1].trim() });
|
|
1097
1102
|
i++;
|
|
1098
1103
|
continue;
|
|
1099
1104
|
}
|
|
@@ -1108,16 +1113,12 @@ types) {
|
|
|
1108
1113
|
const { end, closed } = fenceClose(lines, i, open);
|
|
1109
1114
|
if (id !== undefined)
|
|
1110
1115
|
add(id, base + i, base + end);
|
|
1111
|
-
|
|
1112
|
-
const list = types.get(type) ?? [];
|
|
1113
|
-
list.push({ span: { start: base + i, end: base + end }, id });
|
|
1114
|
-
types.set(type, list);
|
|
1115
|
-
}
|
|
1116
|
+
units?.push({ span: { start: base + i, end: base + end }, kind: "block", type, ...(id !== undefined ? { id } : {}) });
|
|
1116
1117
|
// Only a flow body is scanned for nested blocks (raw/data bodies are
|
|
1117
1118
|
// opaque), so an id inside a `code` body is *not* addressable — exactly
|
|
1118
1119
|
// the parser's contract.
|
|
1119
1120
|
if ((REGISTRY[type] ?? "raw") === "flow" && depth < MAX_NESTING) {
|
|
1120
|
-
collectSpans(lines.slice(i + 1, closed ? end - 1 : end), base + i + 1, out, ctx, depth + 1,
|
|
1121
|
+
collectSpans(lines.slice(i + 1, closed ? end - 1 : end), base + i + 1, out, ctx, depth + 1, units);
|
|
1121
1122
|
}
|
|
1122
1123
|
i = end;
|
|
1123
1124
|
continue;
|
|
@@ -1128,7 +1129,10 @@ types) {
|
|
|
1128
1129
|
// still advances one line at a time so every nested id inside the
|
|
1129
1130
|
// section registers its own span — spans intentionally OVERLAP: #sec
|
|
1130
1131
|
// contains #code, and each remains addressable on its own.
|
|
1131
|
-
|
|
1132
|
+
const hid = idOfHeading(h[3], h[2], base + i + 1, ctx);
|
|
1133
|
+
const hend = base + sectionEnd(lines, i, h[1].length);
|
|
1134
|
+
add(hid, base + i, hend);
|
|
1135
|
+
units?.push({ span: { start: base + i, end: hend }, kind: "heading", id: hid, level: h[1].length, text: h[2] });
|
|
1132
1136
|
i++;
|
|
1133
1137
|
continue;
|
|
1134
1138
|
}
|
|
@@ -1146,12 +1150,23 @@ export function blockSpans(source) {
|
|
|
1146
1150
|
collectSpans(lines, 0, out, ctx);
|
|
1147
1151
|
return out;
|
|
1148
1152
|
}
|
|
1149
|
-
|
|
1153
|
+
// Every addressable unit, in document order, each decorated with its content
|
|
1154
|
+
// address (§3.2). Ids are OPTIONAL in GEML (§1: a block MAY carry one), so
|
|
1155
|
+
// `meta`, a callout `note`, a `table` — anything the author had no reason to
|
|
1156
|
+
// name — has no id to address it by; this index is what makes those addressable
|
|
1157
|
+
// anyway, by type (`=== meta`) or by content (`@<hex>`). No block type is
|
|
1158
|
+
// special-cased; meta is merely the one that is usually unique.
|
|
1159
|
+
//
|
|
1160
|
+
// The ONE index selector matching and the listing both work from, so `get`,
|
|
1161
|
+
// `set` and the listing can never disagree about what exists.
|
|
1162
|
+
function addressedUnits(source) {
|
|
1150
1163
|
const lines = normalizeSource(source).split("\n");
|
|
1151
1164
|
const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines) };
|
|
1152
|
-
const
|
|
1153
|
-
collectSpans(lines, 0, new Map(), ctx, 0,
|
|
1154
|
-
|
|
1165
|
+
const units = [];
|
|
1166
|
+
collectSpans(lines, 0, new Map(), ctx, 0, units);
|
|
1167
|
+
// The address hashes the block's own source text — the exact bytes `get`
|
|
1168
|
+
// would print for it — so an address can be recomputed from `get` output.
|
|
1169
|
+
return addressUnits(units, (u) => lines.slice(u.span.start, u.span.end).join("\n"));
|
|
1155
1170
|
}
|
|
1156
1171
|
// Split into physical lines while *keeping* each line's terminator, so
|
|
1157
1172
|
// join("") is byte-exact and slicing by span never rewrites line endings.
|
|
@@ -1186,6 +1201,32 @@ function toNewline(text, nl) {
|
|
|
1186
1201
|
function narrowToHead(span) {
|
|
1187
1202
|
return { start: span.start, end: span.start + 1 };
|
|
1188
1203
|
}
|
|
1204
|
+
// The unit's CLOSING fence line, or null when it has none — a heading section,
|
|
1205
|
+
// or a fence left unclosed at EOF. Extracted so `get --body` and `set --body`
|
|
1206
|
+
// decide it in ONE place: the selector design's §4 defines HEAD/BODY by the
|
|
1207
|
+
// round-trip invariant `get X --body | set X --body` leaving the file
|
|
1208
|
+
// byte-identical, and two copies of this judgement is exactly how that breaks.
|
|
1209
|
+
function closeFenceLine(lines, span) {
|
|
1210
|
+
const open = FENCE_OPEN.exec(stripEol(lines[span.start] ?? ""));
|
|
1211
|
+
if (!open)
|
|
1212
|
+
return null;
|
|
1213
|
+
const lastText = stripEol(lines[span.end - 1] ?? "").replace(/[ \t]+$/, "");
|
|
1214
|
+
const bid = open[3] ? parseAttrs(open[3]).id : undefined;
|
|
1215
|
+
const labeled = bid !== undefined && new RegExp(`^={3,}[ \\t]+#${reLit(bid)}[ \\t]*$`).test(lastText);
|
|
1216
|
+
return isCloseFence(lastText, open[1].length) || labeled ? lines[span.end - 1] ?? "" : null;
|
|
1217
|
+
}
|
|
1218
|
+
// BODY span: a fenced block's lines BETWEEN the fences; a heading's lines after
|
|
1219
|
+
// the heading through the section boundary — trailing blank lines included,
|
|
1220
|
+
// because that is the span `set --body` replaces (§4's table).
|
|
1221
|
+
function narrowToBody(lines, span) {
|
|
1222
|
+
return { start: span.start + 1, end: closeFenceLine(lines, span) !== null ? span.end - 1 : span.end };
|
|
1223
|
+
}
|
|
1224
|
+
// Slice one unit's output bytes, honouring --head / --body.
|
|
1225
|
+
function sliceUnit(source, span, headOnly, bodyOnly) {
|
|
1226
|
+
const lines = splitLines(source);
|
|
1227
|
+
const s = headOnly ? narrowToHead(span) : bodyOnly ? narrowToBody(lines, span) : span;
|
|
1228
|
+
return lines.slice(s.start, s.end).join("");
|
|
1229
|
+
}
|
|
1189
1230
|
// Depth-first search for the document-model node carrying `id`, descending into
|
|
1190
1231
|
// flow-block children (and list-item children) so a nested id is found too.
|
|
1191
1232
|
// Returns the containing sibling array and index, not just the node: the model
|
|
@@ -1238,13 +1279,9 @@ function flag(args, name) {
|
|
|
1238
1279
|
function historyPathFor(geml) {
|
|
1239
1280
|
return geml.replace(/\.geml$/, "") + ".gemlhistory";
|
|
1240
1281
|
}
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
throw new Error(`bad --at timestamp: ${s} (want YYYYMMDDTHHMMSSZ)`);
|
|
1245
|
-
const [, y, mo, d, h, mi, se] = m;
|
|
1246
|
-
return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +se));
|
|
1247
|
-
}
|
|
1282
|
+
// (A `YYYYMMDDTHHMMSSZ` parser lived here for `history commit --at`. That flag
|
|
1283
|
+
// left the CLI with design §9-Q4 — the library API takes a real Date — so the parser
|
|
1284
|
+
// went with it rather than staying as an uncalled branch.)
|
|
1248
1285
|
const VERSION = "1.0"; // GEML spec version this CLI targets
|
|
1249
1286
|
// The published version, read from package.json rather than restated here.
|
|
1250
1287
|
// "Keep in sync with package.json" was a comment, and comments do not run: this
|
|
@@ -1272,95 +1309,104 @@ export const PARSER_VERSION = (() => {
|
|
|
1272
1309
|
}
|
|
1273
1310
|
return "0.0.0";
|
|
1274
1311
|
})();
|
|
1275
|
-
const USAGE = `geml — GEML reference CLI
|
|
1276
|
-
|
|
1277
|
-
Usage:
|
|
1278
|
-
geml <file.geml|-> [--to <fmt>] [--from <fmt>] [--root d] [-o out] transform a document (default: --to json)
|
|
1279
|
-
(--root widens cross-doc resolution to dir d, as on check — an
|
|
1280
|
-
=== embed whose target sits above the file's own directory
|
|
1281
|
-
needs it, or it renders unresolved)
|
|
1282
|
-
--to <output>: json | html | md | geml
|
|
1283
|
-
--to md -> Markdown (lossy)
|
|
1284
|
-
--to html -> self-contained HTML
|
|
1285
|
-
--to geml -> canonical re-format
|
|
1286
|
-
--to json -> document-model JSON (default)
|
|
1287
|
-
--from <input>: geml | md | json (overrides extension; html is output-only)
|
|
1288
|
-
geml notes.md -> GEML (md inferred from extension)
|
|
1289
|
-
geml model.json --to geml -> GEML (round-trips a prior --to json)
|
|
1290
|
-
geml - --from md read Markdown on stdin
|
|
1291
|
-
geml get <file.geml|-> [#id] [--json] [--head] with #id: print that block
|
|
1292
|
-
(a heading id = its whole section; --head = head line;
|
|
1293
|
-
--json = model node). Without #id: list all addressable
|
|
1294
|
-
ids (--json = array).
|
|
1295
|
-
geml set <file.geml|-> #id [--head|--body] [--in f[#src]|-] [-o f] replace ONE block by id
|
|
1296
|
-
(--in F takes F's block #id, F#src takes #src, else stdin raw;
|
|
1297
|
-
default = whole block · --head = head line · --body = body)
|
|
1298
|
-
geml add <file.geml|-> (--append | --before #id | --after #id) [--in f[#src]|-] [-o f] insert a fragment
|
|
1299
|
-
(1+ blocks and/or prose; content keeps its own ids, a clash is refused)
|
|
1300
|
-
geml delete <file.geml|-> #id [#id2 …] [-o f] remove one or more blocks
|
|
1301
|
-
(a missing id is skipped; a dangling reference is a warning, not a refusal)
|
|
1302
|
-
geml rename <file.geml|-> #old #new [-o f] rename an id and every reference to it (id-boundary safe)
|
|
1303
|
-
geml revert <file.geml> #id [--rev <sel>] [--head] undo one block to a past revision (splice / resurrect / remove)
|
|
1304
|
-
(sel: 0 | -N | id-prefix | changed; default -1)
|
|
1305
|
-
geml check <file.geml|-> [--root d] [--json] validate only: diagnostics + exit code
|
|
1306
|
-
(--root widens cross-doc refs to dir d, e.g. the repo root)
|
|
1307
|
-
geml history <
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1312
|
+
const USAGE = `geml — GEML reference CLI
|
|
1313
|
+
|
|
1314
|
+
Usage:
|
|
1315
|
+
geml <file.geml|-> [--to <fmt>] [--from <fmt>] [--root d] [-o out] transform a document (default: --to json)
|
|
1316
|
+
(--root widens cross-doc resolution to dir d, as on check — an
|
|
1317
|
+
=== embed whose target sits above the file's own directory
|
|
1318
|
+
needs it, or it renders unresolved)
|
|
1319
|
+
--to <output>: json | html | md | geml
|
|
1320
|
+
--to md -> Markdown (lossy)
|
|
1321
|
+
--to html -> self-contained HTML
|
|
1322
|
+
--to geml -> canonical re-format
|
|
1323
|
+
--to json -> document-model JSON (default)
|
|
1324
|
+
--from <input>: geml | md | json (overrides extension; html is output-only)
|
|
1325
|
+
geml notes.md -> GEML (md inferred from extension)
|
|
1326
|
+
geml model.json --to geml -> GEML (round-trips a prior --to json)
|
|
1327
|
+
geml - --from md read Markdown on stdin
|
|
1328
|
+
geml get <file.geml|-> [#id] [--json] [--head] with #id: print that block
|
|
1329
|
+
(a heading id = its whole section; --head = head line;
|
|
1330
|
+
--json = model node). Without #id: list all addressable
|
|
1331
|
+
ids (--json = array).
|
|
1332
|
+
geml set <file.geml|-> #id [--head|--body] [--in f[#src]|-] [-o f] replace ONE block by id
|
|
1333
|
+
(--in F takes F's block #id, F#src takes #src, else stdin raw;
|
|
1334
|
+
default = whole block · --head = head line · --body = body)
|
|
1335
|
+
geml add <file.geml|-> (--append | --before #id | --after #id) [--in f[#src]|-] [-o f] insert a fragment
|
|
1336
|
+
(1+ blocks and/or prose; content keeps its own ids, a clash is refused)
|
|
1337
|
+
geml delete <file.geml|-> #id [#id2 …] [-o f] remove one or more blocks
|
|
1338
|
+
(a missing id is skipped; a dangling reference is a warning, not a refusal)
|
|
1339
|
+
geml rename <file.geml|-> #old #new [-o f] rename an id and every reference to it (id-boundary safe)
|
|
1340
|
+
geml revert <file.geml> #id [--rev <sel>] [--head] undo one block to a past revision (splice / resurrect / remove)
|
|
1341
|
+
(sel: 0 | -N | id-prefix | changed; default -1)
|
|
1342
|
+
geml check <file.geml|-> [--root d] [--json] validate only: diagnostics + exit code
|
|
1343
|
+
(--root widens cross-doc refs to dir d, e.g. the repo root)
|
|
1344
|
+
geml history <save|get|restore|verify> <file.geml> [...] .gemlhistory version sidecar
|
|
1345
|
+
(save = append the file as a revision · get = list revisions, or
|
|
1346
|
+
print one · restore = overwrite the file with one · verify = rebuild
|
|
1347
|
+
and re-hash the whole chain)
|
|
1348
|
+
geml codemap <build|verify|render|serve|refresh|find> [...] code-graph toolkit (alias: codegraph)
|
|
1349
|
+
geml mcp --root <dir> [--graph <dir>] [--no-history] serve documents (and the code graph) over MCP (stdio)
|
|
1350
|
+
(10 tools, each geml_ + its CLI command path: list/get/check/history/to +
|
|
1351
|
+
set/add/delete/rename/revert; every write is validated before it
|
|
1352
|
+
reaches disk. A code graph under --root adds four read-only
|
|
1353
|
+
geml_codemap_* tools to the same server)
|
|
1354
|
+
geml --help | --version [--json]
|
|
1355
|
+
|
|
1356
|
+
Use '-' as the file to read from stdin.
|
|
1357
|
+
Mutations (set/add/delete/rename) write the whole updated document in place for a
|
|
1358
|
+
file, or to stdout for '-' input; -o redirects it (-o - = stdout).
|
|
1359
|
+
Exit codes:
|
|
1360
|
+
0 ok
|
|
1361
|
+
1 document/operation error
|
|
1362
|
+
2 command usage error.
|
|
1323
1363
|
`;
|
|
1324
1364
|
// One-line usage for each subcommand — the single source for both the error
|
|
1325
1365
|
// shown on misuse and the `<cmd> --help` text.
|
|
1326
1366
|
const SUBHELP = {
|
|
1327
|
-
get: "usage: geml get <file.geml|-> [
|
|
1328
|
-
set: "usage: geml set <file.geml|->
|
|
1367
|
+
get: "usage: geml get <file.geml|-> [<selector>] [--head|--body] [--json] (selector = a filter over blocks: #id | '## Heading' (its whole section) | '=== type' (every block of that type — N matches print N contents, count on stderr) | '=== type@<hex>[~n]' or '@<hex>[~n]' (content address, for blocks with no #id); --head = head line, --body = body; without a selector: list every addressable block with its shortest unique address, --json = array)",
|
|
1368
|
+
set: "usage: geml set <file.geml|-> <selector> [--head|--body] [--in F | --in F#src | --in -] [-o out.geml] (selector as in `get`, but it must match exactly ONE block — '=== type' matching several is refused; content: --in F takes F's block #id, --in F#src takes #src, else stdin raw; default = whole block, --head = head line — both normalize the id when the target has one — --body = body; guarded splice, refused if it breaks the doc; writing through an @<hex> address prints the new address on stderr)",
|
|
1329
1369
|
add: "usage: geml add <file.geml|-> (--append | --before #id | --after #id) [--in F | --in F#src | --in -] [-o out.geml] (insert a GEML fragment — 1+ blocks and/or prose — at a position; --in F takes all of F, --in F#src takes #src, else stdin raw; content keeps its own ids, a collision is refused)",
|
|
1330
1370
|
delete: "usage: geml delete <file.geml|-> #id [#id2 …] [-o out.geml] (remove one or more blocks; a missing id is skipped with a note, not an error; a reference left dangling is a warning, not a refusal — delete never fails on a live reference)",
|
|
1331
1371
|
rename: "usage: geml rename <file.geml|-> #old #new [-o out.geml] (rewrite an id's declaration AND every reference — [[#id]], [text](#id), chart data=#id, footnote [^id] — id-boundary safe, skipping raw block bodies; #new must be free; refused if it breaks the doc)",
|
|
1332
1372
|
check: "usage: geml check <file.geml|-> [--root <dir>] [--json] (--root: resolve cross-doc refs within <dir> instead of the file's own directory)",
|
|
1333
1373
|
revert: "usage: geml revert <file.geml> #id [--rev <sel>] [--append|--before #x|--after #x] [--head] [--dry-run] [-o out] (reconcile #id to a revision: splice / resurrect / remove; sel: 0 | -N | id-prefix | changed; default -1)",
|
|
1334
|
-
history:
|
|
1335
|
-
|
|
1336
|
-
geml
|
|
1337
|
-
geml
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
geml codemap
|
|
1374
|
+
history: `usage: geml history save <file.geml> [-m <msg>] append the working file as a new revision (identical to the tip = no-op)
|
|
1375
|
+
geml history get <file.geml> [<rev>] [--json] NO <rev>: every revision, newest first, first column = the selector; WITH <rev>: that revision's full text
|
|
1376
|
+
geml history restore <file.geml> <rev> [--force] overwrite the working file with a revision (--force discards unsaved changes)
|
|
1377
|
+
geml history verify <file.geml> rebuild and re-hash every revision in the chain
|
|
1378
|
+
(<rev>: 0 = the tip | -N = N revisions back | an unambiguous revision id — the strings 'get' prints.
|
|
1379
|
+
All four take --history <path> to point at a sidecar other than <file>.gemlhistory.)`,
|
|
1380
|
+
codemap: `usage: geml codemap build [--root <repo>] # auto-detect languages, run the indexer(s), and merge into one codemap (--root defaults to the current directory)
|
|
1381
|
+
geml codemap build (--db <graph.db> | --adapter joern|scip --raw <in>)+ [--root <repo>] [--out .geml-code-graph] [--container module|dir|file] [--lang <JAVASRC|NEWC|…>] [--joern <path>] [--history [-m msg]]
|
|
1382
|
+
geml codemap verify [dir] geml check + profile reference checks
|
|
1383
|
+
geml codemap render [dir] every doc -> sibling .html (open index.html from disk)
|
|
1384
|
+
geml codemap serve [dir] [--port 8140] [--watch] [--background|--stop] live viewer: pages render from .geml on request; --watch re-runs the recipe when sources change
|
|
1385
|
+
geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
|
|
1386
|
+
geml codemap find <name> [dir] locate a symbol by substring name -> doc#id + src (stdout, no browser)
|
|
1342
1387
|
(<dir> for verify/render/serve/refresh/find defaults to ./.geml-code-graph; codegraph and code-graph are accepted as aliases of codemap)`,
|
|
1343
|
-
mcp: `usage: geml mcp --root <dir> [--graph <dir>] [--no-history]
|
|
1344
|
-
|
|
1345
|
-
Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
|
|
1346
|
-
Every tool is geml_ + its CLI
|
|
1347
|
-
one vocabulary
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1388
|
+
mcp: `usage: geml mcp --root <dir> [--graph <dir>] [--no-history]
|
|
1389
|
+
|
|
1390
|
+
Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
|
|
1391
|
+
Every tool is geml_ + its CLI COMMAND PATH, so the terminal and the assistant
|
|
1392
|
+
share one vocabulary — geml_history mirrors the "geml history" command group,
|
|
1393
|
+
whose read verb (get) is the only one of the four served here.
|
|
1394
|
+
Ten tools: geml_list · geml_get · geml_check · geml_history · geml_to
|
|
1395
|
+
geml_set · geml_add · geml_delete · geml_rename · geml_revert
|
|
1396
|
+
With a code graph under --root, four more (read-only), so one client entry
|
|
1397
|
+
covers both: geml_codemap_search · geml_codemap_callchain
|
|
1398
|
+
geml_codemap_list · geml_codemap_node
|
|
1399
|
+
|
|
1400
|
+
--root <dir> REQUIRED. Root holding the .geml documents. Every path a
|
|
1401
|
+
client names is confined here; a client cannot widen it.
|
|
1402
|
+
--graph <dir> Code-graph directory, inside --root. Defaults to
|
|
1403
|
+
<root>/.geml-code-graph when it holds an index.geml; with
|
|
1404
|
+
no graph the four graph tools are not served at all.
|
|
1405
|
+
--no-history Skip the .gemlhistory revision saved before each write
|
|
1406
|
+
(default: save one, so geml_revert always has a revision
|
|
1407
|
+
to undo to).
|
|
1408
|
+
|
|
1409
|
+
Register with a client:
|
|
1364
1410
|
claude mcp add geml -- geml mcp --root /abs/path/to/repo`,
|
|
1365
1411
|
};
|
|
1366
1412
|
// Set from argv at dispatch time; when true, errors are emitted as a JSON
|
|
@@ -1510,23 +1556,144 @@ function historyError(e, file, historyPath) {
|
|
|
1510
1556
|
}
|
|
1511
1557
|
return err?.message ?? String(e);
|
|
1512
1558
|
}
|
|
1559
|
+
// The three verbs the four-verb collapse removed (design §2/§6). They are HARD
|
|
1560
|
+
// deletions, not aliases — the same call this repo already made for `geml
|
|
1561
|
+
// codemap mcp` and `geml mcp --workspace`: one word, one meaning, and a stale
|
|
1562
|
+
// spelling that keeps working is a second vocabulary the docs and an agent's
|
|
1563
|
+
// memory then both carry. So each names its replacement rather than falling into
|
|
1564
|
+
// `unknown history subcommand`, which would leave the caller guessing which of
|
|
1565
|
+
// four verbs took over.
|
|
1566
|
+
const RETIRED_HISTORY = {
|
|
1567
|
+
commit: "geml history commit was renamed: use `geml history save <file.geml> [-m msg]` (same behaviour, except that a file identical to the tip is now a no-op instead of an empty revision).",
|
|
1568
|
+
log: "geml history log was removed: use `geml history get <file.geml>` — no revision selector lists every revision, newest first, with the same copy-pasteable first column.",
|
|
1569
|
+
show: "geml history show was removed: use `geml history get <file.geml> <rev>` — a revision selector prints that revision's full text (`--json` wraps it as {id, text}).",
|
|
1570
|
+
};
|
|
1571
|
+
// Subcommand, file and revision, read positionally around the options —
|
|
1572
|
+
// `--history <path>` and `-m <msg>` may sit anywhere, and the old args[0..2]
|
|
1573
|
+
// indexing read `--history` itself as the file.
|
|
1574
|
+
//
|
|
1575
|
+
// The generic `positionals()` cannot be reused: it drops every `-`-leading token,
|
|
1576
|
+
// and a revision selector `-N` LOOKS exactly like a flag. That is the whole point
|
|
1577
|
+
// of the first column `history get` prints, so `-N` is admitted and every other
|
|
1578
|
+
// `-`-leading token is treated as an option.
|
|
1579
|
+
function historyPositionals(args) {
|
|
1580
|
+
const out = [];
|
|
1581
|
+
for (let i = 0; i < args.length; i++) {
|
|
1582
|
+
const a = args[i];
|
|
1583
|
+
if (a === "--history" || a === "-m" || a === "--message") {
|
|
1584
|
+
i++;
|
|
1585
|
+
continue;
|
|
1586
|
+
} // flag AND its value
|
|
1587
|
+
if (a.startsWith("-") && !/^-\d+$/.test(a))
|
|
1588
|
+
continue; // --json, --force, …
|
|
1589
|
+
out.push(a);
|
|
1590
|
+
}
|
|
1591
|
+
return out;
|
|
1592
|
+
}
|
|
1513
1593
|
function runHistory(args) {
|
|
1514
|
-
const sub = args
|
|
1515
|
-
const file = args[1];
|
|
1594
|
+
const [sub, file, rev, ...extra] = historyPositionals(args);
|
|
1516
1595
|
if (!sub || !file)
|
|
1517
1596
|
fail(SUBHELP.history);
|
|
1597
|
+
if (RETIRED_HISTORY[sub])
|
|
1598
|
+
fail(RETIRED_HISTORY[sub]);
|
|
1518
1599
|
const historyPath = flag(args, "--history") ?? historyPathFor(file);
|
|
1600
|
+
const json = args.includes("--json");
|
|
1519
1601
|
try {
|
|
1520
|
-
if (sub === "
|
|
1521
|
-
|
|
1522
|
-
|
|
1602
|
+
if (sub === "save") {
|
|
1603
|
+
// design §3.1/§9-Q4: `--author` and `--at` were withdrawn from the CLI (nothing
|
|
1604
|
+
// outside tests ever passed either). Refusing beats ignoring for the same
|
|
1605
|
+
// reason the retired verbs above refuse: a silently dropped `--author
|
|
1606
|
+
// alice` discards precisely the value the caller went out of their way to
|
|
1607
|
+
// type. Both stay on the library API (save({ author, at })).
|
|
1608
|
+
for (const gone of ["--author", "--at"]) {
|
|
1609
|
+
if (args.some((a) => a === gone || a.startsWith(`${gone}=`))) {
|
|
1610
|
+
fail(`${gone} is no longer accepted by 'geml history save' — the only option is -m/--message. (Both remain on the library API, save({ author, at }), for embedders and for tests that pin a revision id.)`);
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
// design §3.1: an empty save is a NO-OP. `save` is the one non-idempotent verb,
|
|
1614
|
+
// so an agent retrying a save it is unsure landed must not lengthen the
|
|
1615
|
+
// chain by a revision with no ops. `geml mcp` already gated its
|
|
1616
|
+
// pre-write snapshot on this exact predicate (mcp.ts snapshot()); this is
|
|
1617
|
+
// the same `isCurrent()`, not a second hash comparison.
|
|
1618
|
+
if (existsSync(historyPath) && isCurrent(historyPath, file)) {
|
|
1619
|
+
console.log(`already saved as ${listRevisions(historyPath)[0].id} (no changes)`);
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
const r = save({
|
|
1523
1623
|
gemlPath: file,
|
|
1524
1624
|
historyPath,
|
|
1525
1625
|
summary: flag(args, "-m") ?? flag(args, "--message") ?? "",
|
|
1526
|
-
author: flag(args, "--author"),
|
|
1527
|
-
at: at ? parseStamp(at) : undefined,
|
|
1528
1626
|
});
|
|
1529
|
-
console.log(`
|
|
1627
|
+
console.log(`saved ${r.id}`);
|
|
1628
|
+
}
|
|
1629
|
+
else if (sub === "get") {
|
|
1630
|
+
// Three tiers, split by how many addresses were given — the same rule the
|
|
1631
|
+
// top-level `geml get` follows (design §1.2). Tier 2 takes a BLOCK
|
|
1632
|
+
// selector inside the revision and reuses the top-level grammar verbatim
|
|
1633
|
+
// (§10.1): a revision rebuilt is just a document's text, so there is no
|
|
1634
|
+
// new algorithm here, and the two selector namespaces cannot collide —
|
|
1635
|
+
// position is fixed and the lexis does not overlap (§10.2).
|
|
1636
|
+
if (extra.length > 1) {
|
|
1637
|
+
fail(`history get takes ONE revision selector and ONE block selector; got ${extra.length + 1} positionals after the file`, 2);
|
|
1638
|
+
}
|
|
1639
|
+
if (rev === undefined) {
|
|
1640
|
+
// Newest-first, with each row's selector in the first column (`0` for
|
|
1641
|
+
// the tip, then `-1`, `-2`, …) so the output is copy-paste into `get`,
|
|
1642
|
+
// `restore` and `revert --rev` alike.
|
|
1643
|
+
const revs = listRevisions(historyPath);
|
|
1644
|
+
if (json) {
|
|
1645
|
+
console.log(JSON.stringify(revs, null, 2));
|
|
1646
|
+
}
|
|
1647
|
+
else {
|
|
1648
|
+
for (const r of revs) {
|
|
1649
|
+
const sel = r.current ? "0" : `-${r.offset}`;
|
|
1650
|
+
console.log(`${sel.padEnd(7)} ${r.id} ${r.author ?? "-"} ${r.summary ?? ""}`.replace(/\s+$/, ""));
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
else {
|
|
1655
|
+
// resolveContent() routes through the ONE selector grammar
|
|
1656
|
+
// (resolveRevision) that the list above prints — see its comment for
|
|
1657
|
+
// what happened the last time that was written twice.
|
|
1658
|
+
const { id, text } = resolveContent(historyPath, rev);
|
|
1659
|
+
const blockSel = extra[0];
|
|
1660
|
+
if (blockSel === undefined) {
|
|
1661
|
+
if (json)
|
|
1662
|
+
console.log(JSON.stringify({ id, text }, null, 2));
|
|
1663
|
+
else
|
|
1664
|
+
process.stdout.write(text);
|
|
1665
|
+
}
|
|
1666
|
+
else {
|
|
1667
|
+
// Tier 2 (§10.1). Cardinality and the flag rules are the top-level
|
|
1668
|
+
// ones, checked here because this tier has its own argument list.
|
|
1669
|
+
const headOnly = args.includes("--head");
|
|
1670
|
+
const bodyOnly = args.includes("--body");
|
|
1671
|
+
if (headOnly && bodyOnly)
|
|
1672
|
+
fail("--head and --body are mutually exclusive", 2);
|
|
1673
|
+
if (json && (headOnly || bodyOnly)) {
|
|
1674
|
+
fail(`--json cannot be combined with ${headOnly ? "--head" : "--body"} — --json returns the model node, which has no sub-node for one part of a block`, 2);
|
|
1675
|
+
}
|
|
1676
|
+
const { units, all } = selectUnits(text, file, blockSel, `revision ${id}`);
|
|
1677
|
+
if (json) {
|
|
1678
|
+
// §3.2's tier table: the revision id travels with the block, so the
|
|
1679
|
+
// caller can tell WHICH version it is holding.
|
|
1680
|
+
const nodes = units.map((u) => unitNode(text, file, u, all));
|
|
1681
|
+
console.log(JSON.stringify({ id, block: units.length === 1 ? nodes[0] : nodes }, null, 2));
|
|
1682
|
+
}
|
|
1683
|
+
else {
|
|
1684
|
+
if (units.length > 1)
|
|
1685
|
+
reportMatches(units[0].type ?? "", units);
|
|
1686
|
+
for (const u of units)
|
|
1687
|
+
process.stdout.write(sliceUnit(text, u.span, headOnly, bodyOnly));
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
else if (sub === "restore") {
|
|
1693
|
+
if (!rev)
|
|
1694
|
+
fail("usage: geml history restore <file.geml> <revision> [--force]");
|
|
1695
|
+
restore({ historyPath, gemlPath: file, revision: rev, write: true, force: args.includes("--force") });
|
|
1696
|
+
console.log(`restored ${file} to ${rev}`);
|
|
1530
1697
|
}
|
|
1531
1698
|
else if (sub === "verify") {
|
|
1532
1699
|
const res = verify(historyPath, file);
|
|
@@ -1538,27 +1705,6 @@ function runHistory(args) {
|
|
|
1538
1705
|
if (!res.ok)
|
|
1539
1706
|
process.exit(1);
|
|
1540
1707
|
}
|
|
1541
|
-
else if (sub === "show") {
|
|
1542
|
-
const rev = args[2];
|
|
1543
|
-
if (!rev)
|
|
1544
|
-
fail("usage: geml history show <file.geml> <revision>");
|
|
1545
|
-
process.stdout.write(restore({ historyPath, gemlPath: file, revision: rev }));
|
|
1546
|
-
}
|
|
1547
|
-
else if (sub === "restore") {
|
|
1548
|
-
const rev = args[2];
|
|
1549
|
-
if (!rev)
|
|
1550
|
-
fail("usage: geml history restore <file.geml> <revision> [--force]");
|
|
1551
|
-
restore({ historyPath, gemlPath: file, revision: rev, write: true, force: args.includes("--force") });
|
|
1552
|
-
console.log(`restored ${file} to ${rev}`);
|
|
1553
|
-
}
|
|
1554
|
-
else if (sub === "log") {
|
|
1555
|
-
// Newest-first, with the `--rev` selector for each row in the first column
|
|
1556
|
-
// (`0` for the tip, then `-1`, `-2`, …) so the output is copy-paste.
|
|
1557
|
-
for (const r of listRevisions(historyPath)) {
|
|
1558
|
-
const sel = r.current ? "0" : `-${r.offset}`;
|
|
1559
|
-
console.log(`${sel.padEnd(7)} ${r.id} ${r.author ?? "-"} ${r.summary ?? ""}`.replace(/\s+$/, ""));
|
|
1560
|
-
}
|
|
1561
|
-
}
|
|
1562
1708
|
else {
|
|
1563
1709
|
fail(`unknown history subcommand: ${sub}. Run 'geml --help'.`);
|
|
1564
1710
|
}
|
|
@@ -1822,39 +1968,55 @@ function resolveSelector(source, file, raw) {
|
|
|
1822
1968
|
// resolves against: typed blocks and headings. A `[^id]` reference names one
|
|
1823
1969
|
// of those (§5.2); the `[^id]: text` definition line was withdrawn.
|
|
1824
1970
|
function listIds(source, file, json) {
|
|
1971
|
+
const where = file === "-" ? "stdin" : file;
|
|
1972
|
+
const all = addressedUnits(source);
|
|
1825
1973
|
const doc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
|
|
1826
|
-
const rows =
|
|
1827
|
-
const
|
|
1828
|
-
const
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1974
|
+
const rows = all.map((a) => {
|
|
1975
|
+
const u = a.unit;
|
|
1976
|
+
const row = {
|
|
1977
|
+
address: shortestAddress(a, all),
|
|
1978
|
+
kind: u.kind === "block" ? u.type ?? "block" : u.kind,
|
|
1979
|
+
lines: [u.span.start + 1, u.span.end],
|
|
1980
|
+
};
|
|
1981
|
+
// §6.3: EVERY id-less block is flagged, including one whose address works
|
|
1982
|
+
// only because its type happens to be unique (`=== meta`) — that it has no
|
|
1983
|
+
// id yet is precisely the fact you might want to act on (§5.2).
|
|
1984
|
+
if (u.id === undefined)
|
|
1985
|
+
row.anon = true;
|
|
1986
|
+
else
|
|
1987
|
+
row.id = u.id;
|
|
1988
|
+
if (u.kind === "heading") {
|
|
1989
|
+
row.level = u.level;
|
|
1990
|
+
row.text = u.text;
|
|
1991
|
+
}
|
|
1992
|
+
// `.footnote` is authored, not synthesized (the `[^id]: text` definition
|
|
1993
|
+
// line was withdrawn) — but it still marks a block meant as a footnote.
|
|
1994
|
+
if (u.id !== undefined) {
|
|
1995
|
+
const site = findBlockSite(doc.children, u.id);
|
|
1996
|
+
const b = site?.siblings[site.index];
|
|
1997
|
+
if (b?.kind === "block" && b.classes.includes("footnote"))
|
|
1836
1998
|
row.footnote = true;
|
|
1837
|
-
return row;
|
|
1838
1999
|
}
|
|
1839
|
-
return
|
|
2000
|
+
return row;
|
|
1840
2001
|
});
|
|
2002
|
+
// §6.6: the empty document is a legitimate empty answer to "list everything",
|
|
2003
|
+
// not a lookup failure — exit 0, and `--json` prints `[]` so a `| jq length`
|
|
2004
|
+
// over a prose-only document does not blow up.
|
|
1841
2005
|
if (json) {
|
|
1842
2006
|
console.log(JSON.stringify(rows, null, 2));
|
|
1843
2007
|
return;
|
|
1844
2008
|
}
|
|
1845
2009
|
if (rows.length === 0) {
|
|
1846
|
-
console.error(`no addressable
|
|
2010
|
+
console.error(`no addressable blocks in ${where}`);
|
|
1847
2011
|
return;
|
|
1848
2012
|
}
|
|
1849
|
-
|
|
1850
|
-
const idW = Math.max(...rows.map((r) => r.id.length + 1));
|
|
2013
|
+
const addrW = Math.max(...rows.map((r) => r.address.length));
|
|
1851
2014
|
const kindW = Math.max(...rows.map((r) => r.kind.length));
|
|
1852
2015
|
for (const r of rows) {
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
line += " footnote";
|
|
2016
|
+
const mark = r.kind === "heading" ? `h${r.level}` : r.anon ? "anon" : "";
|
|
2017
|
+
const tail = r.kind === "heading" ? r.text ?? "" : `L${r.lines[0]}-${r.lines[1]}`;
|
|
2018
|
+
const line = `${r.address.padEnd(addrW)} ${r.kind.padEnd(kindW)} ${mark.padEnd(4)} ${tail}`
|
|
2019
|
+
+ (r.footnote ? " footnote" : "");
|
|
1858
2020
|
console.log(line.replace(/\s+$/, ""));
|
|
1859
2021
|
}
|
|
1860
2022
|
}
|
|
@@ -1871,42 +2033,11 @@ function listIds(source, file, json) {
|
|
|
1871
2033
|
// guessed between, so a document with three notes answers "which one" instead
|
|
1872
2034
|
// of failing. The uniqueness that makes `=== meta` work is checked here, at
|
|
1873
2035
|
// resolve time — nothing in the format has to promise a document holds only one.
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
}
|
|
1880
|
-
if (matches.length === 1) {
|
|
1881
|
-
const m = matches[0];
|
|
1882
|
-
if (json) {
|
|
1883
|
-
// The ONLY block of its type: locating it in the model needs no index, so
|
|
1884
|
-
// --json can still answer with the parsed node (meta's key/values, a
|
|
1885
|
-
// table's model) rather than a mere location.
|
|
1886
|
-
const node = onlyBlockOfType(parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) }).children, type);
|
|
1887
|
-
if (node) {
|
|
1888
|
-
console.log(JSON.stringify(node, null, 2));
|
|
1889
|
-
return;
|
|
1890
|
-
}
|
|
1891
|
-
}
|
|
1892
|
-
const span = headOnly ? narrowToHead(m.span) : m.span;
|
|
1893
|
-
process.stdout.write(splitLines(source).slice(span.start, span.end).join(""));
|
|
1894
|
-
return;
|
|
1895
|
-
}
|
|
1896
|
-
// Several: report WHERE they are (data on stdout, the explanation on stderr),
|
|
1897
|
-
// so the caller can name one — by adding an #id, or via its section.
|
|
1898
|
-
if (json) {
|
|
1899
|
-
console.log(JSON.stringify({ kind: "blocks", type, matches: matches.map((m) => ({ ...(m.id ? { id: m.id } : {}), lines: [m.span.start + 1, m.span.end] })) }, null, 2));
|
|
1900
|
-
return;
|
|
1901
|
-
}
|
|
1902
|
-
console.error(`${matches.length} \`${type}\` blocks in ${where} — give one an #id, or address its section:`);
|
|
1903
|
-
for (const m of matches) {
|
|
1904
|
-
console.log(`=== ${type}${m.id ? ` {#${m.id}}` : ""} L${m.span.start + 1}-${m.span.end}`);
|
|
1905
|
-
}
|
|
1906
|
-
}
|
|
1907
|
-
// The single block of `type` in a document, or undefined when there is not
|
|
1908
|
-
// exactly one (nested flow children included, matching the span scan's reach).
|
|
1909
|
-
function onlyBlockOfType(blocks, type) {
|
|
2036
|
+
// Every block of `type` in document order, nested flow children included —
|
|
2037
|
+
// exactly the span scan's reach and order, so the k-th scan match and the k-th
|
|
2038
|
+
// model node are the same block. That correspondence is what lets an ANONYMOUS
|
|
2039
|
+
// block's `--json` find its node without an id to look it up by.
|
|
2040
|
+
function blocksOfType(blocks, type) {
|
|
1910
2041
|
const hits = [];
|
|
1911
2042
|
const walk = (list) => {
|
|
1912
2043
|
for (const b of list) {
|
|
@@ -1919,65 +2050,139 @@ function onlyBlockOfType(blocks, type) {
|
|
|
1919
2050
|
}
|
|
1920
2051
|
};
|
|
1921
2052
|
walk(blocks);
|
|
1922
|
-
return hits
|
|
1923
|
-
}
|
|
2053
|
+
return hits;
|
|
2054
|
+
}
|
|
2055
|
+
// A unit's index among the units of its own type, for the positional lookup above.
|
|
2056
|
+
function typeIndex(all, u) {
|
|
2057
|
+
return all.filter((a) => a.unit.type === u.type).findIndex((a) => a.unit === u);
|
|
2058
|
+
}
|
|
2059
|
+
// Resolve a NON-list selector to the units it matches, or fail with the reason.
|
|
2060
|
+
// `where` names the haystack for the error messages — a file for `geml get`, a
|
|
2061
|
+
// revision for `geml history get`'s tier 2. Shared by both so the one selector
|
|
2062
|
+
// grammar has one implementation: history's design §10.1 asks for exactly this,
|
|
2063
|
+
// and its §3.2 records what happened the last time a selector grammar was
|
|
2064
|
+
// written twice (the printed selectors stopped being readable back).
|
|
2065
|
+
function selectUnits(source, file, rawSel, where) {
|
|
2066
|
+
const sel = parseSelector(rawSel, (braces) => parseAttrs(braces).id);
|
|
2067
|
+
// Callers handle the empty selector themselves (list for `get`, usage error
|
|
2068
|
+
// for `set`); reaching here with one is a caller bug surfaced as usage.
|
|
2069
|
+
if (sel.form === "list")
|
|
2070
|
+
fail(`no selector given — run \`geml get ${where}\` to list addressable blocks`, 2);
|
|
2071
|
+
if (sel.form === "attr") {
|
|
2072
|
+
// §7: the wording says "not implemented yet", not "braces are meaningless" —
|
|
2073
|
+
// §2 declares attribute keys as part of the model, so implementing them
|
|
2074
|
+
// later fills in a declared slot rather than reversing this message.
|
|
2075
|
+
fail(`only \`#id\` is supported as a filter key today (got \`${sel.key}\`) — use \`=== ${sel.type}\` for every ${sel.type} block, or address one by \`#id\` / \`@<hex>\``, 2);
|
|
2076
|
+
}
|
|
2077
|
+
const all = addressedUnits(source);
|
|
2078
|
+
if (sel.form === "content") {
|
|
2079
|
+
const hit = matchContent(sel, all);
|
|
2080
|
+
if (!hit.ok) {
|
|
2081
|
+
if (hit.why === "wrong-type") {
|
|
2082
|
+
// §3.3: the type prefix is a CHECK. Ignoring a wrong one would make it
|
|
2083
|
+
// a decoration that is allowed to lie, and would silently accept a
|
|
2084
|
+
// hand-edited address.
|
|
2085
|
+
fail(`\`@${sel.hex}\` addresses a \`${hit.found}\` block, not \`${sel.type}\` — drop the type prefix to address it by content alone`, 1);
|
|
2086
|
+
}
|
|
2087
|
+
const suffix = sel.nth ? `~${sel.nth}` : "";
|
|
2088
|
+
fail(`no block matching \`@${sel.hex}${suffix}\` in ${where} — a content address goes stale when the block's content changes (that is the point: §3.2); run \`geml get ${where}\` for current addresses`, 1);
|
|
2089
|
+
}
|
|
2090
|
+
return { units: [hit.unit], all };
|
|
2091
|
+
}
|
|
2092
|
+
if (sel.form === "type") {
|
|
2093
|
+
const hits = matchType(sel.type, all);
|
|
2094
|
+
if (!hits.length)
|
|
2095
|
+
fail(`no \`${sel.type}\` block in ${where}${discoveryHint(where)}`, 1);
|
|
2096
|
+
return { units: hits, all };
|
|
2097
|
+
}
|
|
2098
|
+
// `#id` / bare id / a pasted `## Heading` line — resolveSelector needs a parse
|
|
2099
|
+
// to match heading TEXT, so it stays the one path that reaches the model.
|
|
2100
|
+
const id = resolveSelector(source, file, sel.raw);
|
|
2101
|
+
const unit = all.find((a) => a.unit.id === id)?.unit;
|
|
2102
|
+
// Bare `no block with id \`x\`` — the phrasing every caller of a missing id
|
|
2103
|
+
// has always seen, and which `set`'s own tests pin. `where` is appended only
|
|
2104
|
+
// when it is NOT the file the caller already named (a revision), so the
|
|
2105
|
+
// common case reads the same as before this selector grammar existed.
|
|
2106
|
+
if (!unit)
|
|
2107
|
+
fail(`no block with id \`${id}\`${where.startsWith("revision ") ? ` in ${where}` : ""}`, 1);
|
|
2108
|
+
return { units: [unit], all };
|
|
2109
|
+
}
|
|
2110
|
+
// The document-model node for one unit; a heading yields its SECTION envelope,
|
|
2111
|
+
// so --json covers the same content as the raw span. `kind:"section"` lets a
|
|
2112
|
+
// consumer branch — every other unit yields the single node (the model is flat).
|
|
2113
|
+
function unitNode(source, file, unit, all) {
|
|
2114
|
+
const doc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
|
|
2115
|
+
if (unit.id !== undefined) {
|
|
2116
|
+
const site = findBlockSite(doc.children, unit.id);
|
|
2117
|
+
if (!site)
|
|
2118
|
+
fail(`no block with id \`${unit.id}\``, 1);
|
|
2119
|
+
const block = site.siblings[site.index];
|
|
2120
|
+
if (block.kind !== "heading")
|
|
2121
|
+
return block;
|
|
2122
|
+
const end = sectionEndIndex(site.siblings, site.index);
|
|
2123
|
+
return { kind: "section", id: block.id, level: block.level, blocks: site.siblings.slice(site.index, end) };
|
|
2124
|
+
}
|
|
2125
|
+
const node = blocksOfType(doc.children, unit.type ?? "")[typeIndex(all, unit)];
|
|
2126
|
+
if (!node)
|
|
2127
|
+
fail(`could not locate the \`${unit.type}\` block in the document model`, 1);
|
|
2128
|
+
return node;
|
|
2129
|
+
}
|
|
2130
|
+
// stderr line for an N-match selector: content stays on stdout, so a redirect
|
|
2131
|
+
// captures document bytes only, and the caller still learns how many it got (§5).
|
|
2132
|
+
function reportMatches(type, units) {
|
|
2133
|
+
const at = units.map((u) => `L${u.span.start + 1}-${u.span.end}${u.id ? ` #${u.id}` : ""}`).join(" · ");
|
|
2134
|
+
console.error(`${units.length} \`${type}\` blocks (${at})`);
|
|
2135
|
+
}
|
|
2136
|
+
// `geml get <file.geml|-> [<selector>] [--head|--body] [--json]` — read the
|
|
2137
|
+
// document's addressable structure, or one/several blocks out of it.
|
|
2138
|
+
//
|
|
2139
|
+
// The selector is a FILTER (§2 of the get/set selector design): no selector
|
|
2140
|
+
// LISTS every addressable block with its shortest unique address; `#id` /
|
|
2141
|
+
// `## Heading` / `=== type@<hex>` name at most one; `=== type` matches 0..N.
|
|
2142
|
+
// Cardinality is uniform (§5): 0 → exit 1, 1 → the content, N → N contents in
|
|
2143
|
+
// document order with the count on stderr. `--head`/`--body` narrow to one part
|
|
2144
|
+
// of each match, and every flag combination that used to be half-honoured is
|
|
2145
|
+
// now a usage error (§7) — a discarded flag is a command that quietly did
|
|
2146
|
+
// something else.
|
|
1924
2147
|
function runGet(args) {
|
|
1925
2148
|
const json = args.includes("--json");
|
|
1926
2149
|
const headOnly = args.includes("--head");
|
|
1927
|
-
const
|
|
2150
|
+
const bodyOnly = args.includes("--body");
|
|
2151
|
+
const [file, rawSel] = positionals(args, []);
|
|
1928
2152
|
if (!file)
|
|
1929
2153
|
fail(SUBHELP.get);
|
|
1930
|
-
|
|
1931
|
-
|
|
2154
|
+
if (headOnly && bodyOnly)
|
|
2155
|
+
fail("--head and --body are mutually exclusive", 2);
|
|
2156
|
+
if (json && (headOnly || bodyOnly)) {
|
|
2157
|
+
fail(`--json cannot be combined with ${headOnly ? "--head" : "--body"} — --json returns the model node, which has no sub-node for one part of a block`, 2);
|
|
2158
|
+
}
|
|
1932
2159
|
// One read: stdin can only be consumed once, and the selector resolver needs
|
|
1933
2160
|
// the same bytes the slice below works on.
|
|
1934
2161
|
const source = readInput(file);
|
|
1935
|
-
|
|
2162
|
+
const where = file === "-" ? "stdin" : file;
|
|
2163
|
+
const sel = parseSelector(rawSel, (braces) => parseAttrs(braces).id);
|
|
2164
|
+
if (sel.form === "list") {
|
|
2165
|
+
// §5.1: nothing here to narrow, and ignoring the flag would make
|
|
2166
|
+
// `get f --head` print byte-for-byte what `get f` prints.
|
|
2167
|
+
if (headOnly || bodyOnly) {
|
|
2168
|
+
fail(`${headOnly ? "--head" : "--body"} names part of ONE block, so it needs a selector — run \`geml get ${where}\` to list what to address`, 2);
|
|
2169
|
+
}
|
|
1936
2170
|
listIds(source, file, json);
|
|
1937
2171
|
return;
|
|
1938
2172
|
}
|
|
1939
|
-
|
|
1940
|
-
// the document" move as a heading line, for the blocks that carry no id.
|
|
1941
|
-
// A pasted fence that DOES declare an id defers to the id path below.
|
|
1942
|
-
const fence = /^={3,}[ \t]*([A-Za-z][A-Za-z0-9_-]*)[ \t]*(\{.*\})?[ \t]*$/.exec(rawId.trim());
|
|
1943
|
-
const fenceId = fence?.[2] ? parseAttrs(fence[2]).id : undefined;
|
|
1944
|
-
if (fence && fenceId === undefined) {
|
|
1945
|
-
getByType(source, file, fence[1], json, headOnly);
|
|
1946
|
-
return;
|
|
1947
|
-
}
|
|
1948
|
-
const id = fenceId ?? resolveSelector(source, file, rawId);
|
|
2173
|
+
const { units, all } = selectUnits(source, file, rawSel, where);
|
|
1949
2174
|
if (json) {
|
|
1950
|
-
//
|
|
1951
|
-
//
|
|
1952
|
-
|
|
1953
|
-
const
|
|
1954
|
-
|
|
1955
|
-
fail(`no block with id \`${id}\``, 1);
|
|
1956
|
-
const block = site.siblings[site.index];
|
|
1957
|
-
// `--head` on a heading suppresses the section envelope (the lone heading
|
|
1958
|
-
// node IS the head). On a block/footnote id there is nothing finer than
|
|
1959
|
-
// the single node — the model has no sub-node for "just the fence line" —
|
|
1960
|
-
// so --head refines only the RAW output there.
|
|
1961
|
-
if (block.kind === "heading" && !headOnly) {
|
|
1962
|
-
// A heading id addresses its SECTION, so `--json` covers the same
|
|
1963
|
-
// content as the raw span: a self-describing envelope whose blocks[0]
|
|
1964
|
-
// is the heading node followed by its siblings up to the boundary.
|
|
1965
|
-
// `kind: "section"` lets a consumer branch — a block/footnote id still
|
|
1966
|
-
// yields the single model node (the model itself stays flat).
|
|
1967
|
-
const end = sectionEndIndex(site.siblings, site.index);
|
|
1968
|
-
console.log(JSON.stringify({ kind: "section", id: block.id, level: block.level, blocks: site.siblings.slice(site.index, end) }, null, 2));
|
|
1969
|
-
return;
|
|
1970
|
-
}
|
|
1971
|
-
console.log(JSON.stringify(block, null, 2));
|
|
2175
|
+
// §7: N matches yield N model nodes. The old `{kind:"blocks",
|
|
2176
|
+
// matches:[{lines}]}` coordinate envelope is gone — it answered "where are
|
|
2177
|
+
// they" when the question is "what are they" (§9 change 2).
|
|
2178
|
+
const nodes = units.map((u) => unitNode(source, file, u, all));
|
|
2179
|
+
console.log(JSON.stringify(units.length === 1 ? nodes[0] : nodes, null, 2));
|
|
1972
2180
|
return;
|
|
1973
2181
|
}
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
const
|
|
1977
|
-
|
|
1978
|
-
fail(`no block with id \`${id}\``, 1);
|
|
1979
|
-
const span = headOnly ? narrowToHead(found) : found;
|
|
1980
|
-
process.stdout.write(splitLines(source).slice(span.start, span.end).join(""));
|
|
2182
|
+
if (units.length > 1)
|
|
2183
|
+
reportMatches(units[0].type ?? "", units);
|
|
2184
|
+
for (const u of units)
|
|
2185
|
+
process.stdout.write(sliceUnit(source, u.span, headOnly, bodyOnly));
|
|
1981
2186
|
}
|
|
1982
2187
|
const NO_CONTENT = "no replacement content (use --in FILE or pipe it on stdin)";
|
|
1983
2188
|
// `geml set <file.geml|-> #id [--head|--body] [--in F|F#src|-] [-o out]` —
|
|
@@ -2003,14 +2208,13 @@ function runSet(args) {
|
|
|
2003
2208
|
const bodyOnly = args.includes("--body");
|
|
2004
2209
|
if (headOnly && bodyOnly)
|
|
2005
2210
|
fail("--head and --body are mutually exclusive", 2);
|
|
2006
|
-
const [file,
|
|
2211
|
+
const [file, rawSel] = positionals(args, ["-o", "--out", "--in"]);
|
|
2007
2212
|
if (!file)
|
|
2008
2213
|
fail(SUBHELP.set);
|
|
2009
|
-
// No
|
|
2010
|
-
// usage line — `geml get <file>` lists every
|
|
2011
|
-
if (!
|
|
2012
|
-
fail(`no
|
|
2013
|
-
const id = rawId.replace(/^#/, "");
|
|
2214
|
+
// No selector: there is no block to replace. Point the way to discovery, not a
|
|
2215
|
+
// bare usage line — `geml get <file>` lists every address `set` can target.
|
|
2216
|
+
if (!rawSel)
|
|
2217
|
+
fail(`no selector given — run 'geml get ${file === "-" ? "<file>" : file}' to list addressable blocks`, 2);
|
|
2014
2218
|
// The raw channel is stdin — `--in` omitted or `--in -`; anything else sources
|
|
2015
2219
|
// a block from a file. Document and content can't BOTH be stdin: reject that
|
|
2016
2220
|
// up front, before consuming stdin, so the document read below is unambiguous.
|
|
@@ -2019,16 +2223,11 @@ function runSet(args) {
|
|
|
2019
2223
|
fail("reading the document from stdin needs --in for the new content", 2);
|
|
2020
2224
|
}
|
|
2021
2225
|
const source = readInput(file);
|
|
2226
|
+
const target = resolveSetTarget(source, file, rawSel);
|
|
2022
2227
|
if (bodyOnly) {
|
|
2023
|
-
runSetBody(source,
|
|
2228
|
+
runSetBody(source, target, from, rawChannel, file, out);
|
|
2024
2229
|
return;
|
|
2025
2230
|
}
|
|
2026
|
-
// default / --head: content is a whole block (default) or a bare head line.
|
|
2027
|
-
// Does the target exist? Asked FIRST: the shape checks below name the id in
|
|
2028
|
-
// their advice ("use --body to set the body of #far"), which reads as though the
|
|
2029
|
-
// id were there. Whether the content is prose is the second question.
|
|
2030
|
-
if (!blockSpans(source).has(id))
|
|
2031
|
-
fail(`no block with id \`${id}\``, 1);
|
|
2032
2231
|
let content;
|
|
2033
2232
|
if (rawChannel) {
|
|
2034
2233
|
content = readInput("-");
|
|
@@ -2042,39 +2241,71 @@ function runSet(args) {
|
|
|
2042
2241
|
if (shape === "empty")
|
|
2043
2242
|
fail(NO_CONTENT, 1);
|
|
2044
2243
|
if (shape === "prose")
|
|
2045
|
-
fail(`content is prose, not a block — use --body to set the body of
|
|
2244
|
+
fail(`content is prose, not a block — use --body to set the body of ${target.label}`, 1);
|
|
2046
2245
|
if (shape === "multi")
|
|
2047
2246
|
fail("set replaces ONE block, but the content has multiple blocks (use add)", 1);
|
|
2048
2247
|
}
|
|
2049
2248
|
}
|
|
2050
2249
|
else {
|
|
2051
|
-
content = extractBlock(from, id, headOnly ? "head" : "whole");
|
|
2052
|
-
}
|
|
2053
|
-
|
|
2054
|
-
|
|
2250
|
+
content = extractBlock(from, target.unit.id ?? "", headOnly ? "head" : "whole");
|
|
2251
|
+
}
|
|
2252
|
+
// §5.2: `@<hex>` is not an id, so "normalize the content's id to the target's"
|
|
2253
|
+
// has no subject — the content is used verbatim, and an id it brings that
|
|
2254
|
+
// collides is caught by the splice guard like any other. An id target keeps
|
|
2255
|
+
// normalizing: naming an id on the command line IS the instruction that the
|
|
2256
|
+
// result carries that id (block-mutation design §4.0).
|
|
2257
|
+
const replacement = target.unit.id !== undefined ? normalizeBlockId(content, target.unit.id) : content;
|
|
2258
|
+
const updated = spliceSpan(source, target.unit.span, replacement, file, headOnly, false, target.unit.id);
|
|
2055
2259
|
resolveOutTarget(file, out).write(updated);
|
|
2260
|
+
reportNewAddress(updated, target);
|
|
2261
|
+
}
|
|
2262
|
+
// Resolve a selector to the ONE unit `set` will overwrite. `get` may answer with
|
|
2263
|
+
// N blocks; `set` may not — §5: with N targets there is no single id to
|
|
2264
|
+
// normalize the content to, so multi-target `set` is undefined, not merely
|
|
2265
|
+
// risky. Refused with exit 2 (a usage error), not exit 1.
|
|
2266
|
+
function resolveSetTarget(source, file, rawSel) {
|
|
2267
|
+
const where = file === "-" ? "<file>" : file;
|
|
2268
|
+
const sel = parseSelector(rawSel, (braces) => parseAttrs(braces).id);
|
|
2269
|
+
if (sel.form === "list")
|
|
2270
|
+
fail(`no selector given — run 'geml get ${where}' to list addressable blocks`, 2);
|
|
2271
|
+
const { units, all } = selectUnits(source, file, rawSel, where);
|
|
2272
|
+
if (units.length > 1) {
|
|
2273
|
+
// §5: with N targets there is no single id to normalize the content to, so
|
|
2274
|
+
// multi-target `set` is UNDEFINED, not merely risky. The addresses are
|
|
2275
|
+
// printed because they ARE the fix — each is unique and pastes straight
|
|
2276
|
+
// back into this same command (§6.2).
|
|
2277
|
+
const opts = units.map((u) => {
|
|
2278
|
+
const a = all.find((x) => x.unit === u);
|
|
2279
|
+
return ` ${shortestAddress(a, all)} L${u.span.start + 1}-${u.span.end}`;
|
|
2280
|
+
}).join("\n");
|
|
2281
|
+
fail(`\`${rawSel.trim()}\` matches ${units.length} blocks — set writes ONE; address it uniquely:\n${opts}`, 2);
|
|
2282
|
+
}
|
|
2283
|
+
const unit = units[0];
|
|
2284
|
+
const label = unit.id !== undefined && sel.form === "id" ? `#${unit.id}` : `\`${rawSel.trim()}\``;
|
|
2285
|
+
return { unit, label, byContent: sel.form === "content" };
|
|
2286
|
+
}
|
|
2287
|
+
// §5.3: writing through a content address CHANGES it, so print the new one —
|
|
2288
|
+
// otherwise a script editing the same block twice has to re-list in between.
|
|
2289
|
+
// stderr, because stdout may be the document itself (`-o -`).
|
|
2290
|
+
function reportNewAddress(updated, target) {
|
|
2291
|
+
if (!target.byContent)
|
|
2292
|
+
return;
|
|
2293
|
+
const after = addressedUnits(updated).find((a) => a.unit.span.start === target.unit.span.start);
|
|
2294
|
+
if (after)
|
|
2295
|
+
console.error(`new address: ${shortestAddress(after, addressedUnits(updated))}`);
|
|
2056
2296
|
}
|
|
2057
2297
|
// `--body`: swap ONLY the target block's body, keeping its head (and #id) and,
|
|
2058
2298
|
// for a typed block, its close fence. Assembles head + new body + close and
|
|
2059
2299
|
// reuses the guarded spliceBlock — the head carries #id, so the id survives
|
|
2060
2300
|
// with no normalization needed.
|
|
2061
|
-
function runSetBody(source,
|
|
2062
|
-
const found =
|
|
2063
|
-
if (!found)
|
|
2064
|
-
fail(`no block with id \`${id}\``, 1);
|
|
2301
|
+
function runSetBody(source, target, from, rawChannel, file, out) {
|
|
2302
|
+
const found = target.unit.span;
|
|
2065
2303
|
const lines = splitLines(source);
|
|
2066
2304
|
const headLine = lines[found.start] ?? "";
|
|
2067
|
-
|
|
2068
|
-
//
|
|
2069
|
-
|
|
2070
|
-
const
|
|
2071
|
-
if (open) {
|
|
2072
|
-
const lastText = stripEol(lines[found.end - 1] ?? "").replace(/[ \t]+$/, "");
|
|
2073
|
-
const bid = open[3] ? parseAttrs(open[3]).id : undefined;
|
|
2074
|
-
const labeled = bid !== undefined && new RegExp(`^={3,}[ \\t]+#${reLit(bid)}[ \\t]*$`).test(lastText);
|
|
2075
|
-
if (isCloseFence(lastText, open[1].length) || labeled)
|
|
2076
|
-
closeLine = lines[found.end - 1] ?? "";
|
|
2077
|
-
}
|
|
2305
|
+
// A typed block keeps its closing fence; a heading section has none. Decided
|
|
2306
|
+
// by the same helper `get --body` uses, so the two agree on the span and the
|
|
2307
|
+
// §4 round-trip invariant holds.
|
|
2308
|
+
const closeLine = closeFenceLine(lines, found);
|
|
2078
2309
|
let body;
|
|
2079
2310
|
if (rawChannel) {
|
|
2080
2311
|
body = readInput("-");
|
|
@@ -2082,7 +2313,7 @@ function runSetBody(source, id, from, rawChannel, file, out) {
|
|
|
2082
2313
|
fail(NO_CONTENT, 1);
|
|
2083
2314
|
}
|
|
2084
2315
|
else {
|
|
2085
|
-
body = extractBlock(from, id, "body");
|
|
2316
|
+
body = extractBlock(from, target.unit.id ?? "", "body");
|
|
2086
2317
|
}
|
|
2087
2318
|
let head = headLine;
|
|
2088
2319
|
if (head !== "" && !/(\r\n|\r|\n)$/.test(head))
|
|
@@ -2095,8 +2326,9 @@ function runSetBody(source, id, from, rawChannel, file, out) {
|
|
|
2095
2326
|
// block-count invariant so a `===` fence in the raw body can't close it early
|
|
2096
2327
|
// and inject siblings (SEC F2). A heading section body has no close fence and
|
|
2097
2328
|
// may legitimately contain blocks, so it is not count-guarded.
|
|
2098
|
-
const updated =
|
|
2329
|
+
const updated = spliceSpan(source, found, replacement, file, false, closeLine !== null, target.unit.id);
|
|
2099
2330
|
resolveOutTarget(file, out).write(updated);
|
|
2331
|
+
reportNewAddress(updated, target);
|
|
2100
2332
|
}
|
|
2101
2333
|
// `geml add <file|-> (--append | --before #x | --after #x) [--in F|F#src|-] [-o]`
|
|
2102
2334
|
// — insert a GEML fragment (1+ blocks and/or prose) at a position. Unlike `set`,
|
|
@@ -2397,6 +2629,13 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
|
|
|
2397
2629
|
const found = blockSpans(source).get(id);
|
|
2398
2630
|
if (!found)
|
|
2399
2631
|
fail(`no block with id \`${id}\``, 1);
|
|
2632
|
+
return spliceSpan(source, found, replacement, file, headOnly, guardCount, id);
|
|
2633
|
+
}
|
|
2634
|
+
// The same guarded splice addressed by SPAN rather than by id, because an
|
|
2635
|
+
// anonymous block (addressed by `@<hex>`) has no id to look one up with. `id`
|
|
2636
|
+
// is the survival guard's subject and is simply absent for those: every OTHER
|
|
2637
|
+
// pre-existing id must still survive, which the `dropped` check below covers.
|
|
2638
|
+
function spliceSpan(source, found, replacement, file, headOnly = false, guardCount = false, id) {
|
|
2400
2639
|
const beforeDoc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
|
|
2401
2640
|
const beforeIds = beforeDoc.ids;
|
|
2402
2641
|
// Keep the bytes before and after the target span exactly; give the new block
|
|
@@ -2428,7 +2667,7 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
|
|
|
2428
2667
|
refuseBroken(`replacement would break the document: ${first.message} (line ${first.line}); not written`, errs);
|
|
2429
2668
|
}
|
|
2430
2669
|
const now = new Set(reparsed.ids);
|
|
2431
|
-
if (!now.has(id))
|
|
2670
|
+
if (id !== undefined && !now.has(id))
|
|
2432
2671
|
fail(`replacement removes id \`${id}\`; not written`, 1);
|
|
2433
2672
|
const dropped = beforeIds.find((x) => x !== id && !now.has(x));
|
|
2434
2673
|
if (dropped !== undefined) {
|
|
@@ -2443,7 +2682,7 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
|
|
|
2443
2682
|
// (Not enforced for heading sections / whole-block set, whose replacement may
|
|
2444
2683
|
// legitimately span several top-level blocks.)
|
|
2445
2684
|
if (guardCount && reparsed.children.length !== beforeDoc.children.length) {
|
|
2446
|
-
fail(`replacement changes the block count (a fence in the body closed
|
|
2685
|
+
fail(`replacement changes the block count (a fence in the body closed ${id !== undefined ? `#${id}` : "the target"} early and injected sibling block(s)?); not written`, 1);
|
|
2447
2686
|
}
|
|
2448
2687
|
return updated;
|
|
2449
2688
|
}
|