@geml/geml 1.5.0 → 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/README.md +37 -6
- package/codemap/build.mjs +5 -5
- package/dist/diagnostics.d.ts +1 -1
- package/dist/diagnostics.js +3 -3
- package/dist/geml.js +517 -250
- package/dist/history.d.ts +11 -8
- package/dist/history.js +20 -15
- package/dist/inline.js +7 -2
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +69 -20
- package/dist/render.js +1 -22
- package/dist/selector.d.ts +55 -0
- package/dist/selector.js +112 -0
- package/dist/table.d.ts +0 -4
- package/dist/table.js +57 -50
- package/dist/to-md.js +4 -1
- package/package.json +1 -1
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";
|
|
@@ -154,7 +155,7 @@ function registerId(ctx, id, line) {
|
|
|
154
155
|
}
|
|
155
156
|
}
|
|
156
157
|
// §5: a list marker — `-`/`*` (unordered) or `N.` (ordered) — capturing the
|
|
157
|
-
// leading indent (in spaces; a tab counts as
|
|
158
|
+
// leading indent (in spaces; a tab counts as 4) and the item content. Nesting
|
|
158
159
|
// is decided by that indent.
|
|
159
160
|
const MARKER = /^([ \t]*)(?:[-*]|(\d+)\.)[ \t]+(.*)$/;
|
|
160
161
|
function matchMarker(line) {
|
|
@@ -162,7 +163,14 @@ function matchMarker(line) {
|
|
|
162
163
|
if (!m)
|
|
163
164
|
return null;
|
|
164
165
|
const ordered = m[2] !== undefined;
|
|
165
|
-
|
|
166
|
+
let indent = 0;
|
|
167
|
+
for (const ch of m[1]) {
|
|
168
|
+
if (ch === '\t')
|
|
169
|
+
indent += 4;
|
|
170
|
+
else
|
|
171
|
+
indent += 1;
|
|
172
|
+
}
|
|
173
|
+
const mk = { indent, ordered, rest: m[3] };
|
|
166
174
|
if (ordered)
|
|
167
175
|
mk.start = parseInt(m[2], 10);
|
|
168
176
|
return mk;
|
|
@@ -249,7 +257,26 @@ function scanBlocks(lines, base, ctx, depth = 0) {
|
|
|
249
257
|
const diags = ctx.diags;
|
|
250
258
|
let i = 0;
|
|
251
259
|
while (i < lines.length) {
|
|
252
|
-
|
|
260
|
+
let line = lines[i];
|
|
261
|
+
let consumed = 1;
|
|
262
|
+
// C-01: Attribute line continuation via `\`.
|
|
263
|
+
// If a line looks like a fence or heading and ends with `\`, fold subsequent lines.
|
|
264
|
+
if ((line.startsWith("===") || line.startsWith("#")) && line.endsWith("\\")) {
|
|
265
|
+
let folded = line.slice(0, -1).trimEnd();
|
|
266
|
+
while (i + consumed < lines.length) {
|
|
267
|
+
const next = lines[i + consumed].trim();
|
|
268
|
+
if (next.endsWith("\\")) {
|
|
269
|
+
folded += " " + next.slice(0, -1).trimEnd();
|
|
270
|
+
consumed++;
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
folded += " " + next;
|
|
274
|
+
consumed++;
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
line = folded;
|
|
279
|
+
}
|
|
253
280
|
if (line.trim() === "") {
|
|
254
281
|
i++;
|
|
255
282
|
continue;
|
|
@@ -259,24 +286,7 @@ function scanBlocks(lines, base, ctx, depth = 0) {
|
|
|
259
286
|
const hid = /^[ \t]*%%[ \t]?(.*)$/.exec(line);
|
|
260
287
|
if (hid) {
|
|
261
288
|
blocks.push({ kind: "hidden", text: hid[1] });
|
|
262
|
-
i
|
|
263
|
-
continue;
|
|
264
|
-
}
|
|
265
|
-
// §5.2: a Markdown-style footnote definition `[^id]: text` defines the
|
|
266
|
-
// target a `[^id]` reference points at — recorded as a note block with that
|
|
267
|
-
// id, so the reference resolves. (A model that reaches for Markdown
|
|
268
|
-
// footnotes by habit then "just works" instead of leaving a dangling ref.)
|
|
269
|
-
const fndef = /^\[\^([^\]]+)\]:[ \t]?(.*)$/.exec(line);
|
|
270
|
-
if (fndef) {
|
|
271
|
-
const id = fndef[1].trim();
|
|
272
|
-
const lineNo = base + i + 1;
|
|
273
|
-
registerId(ctx, id, lineNo);
|
|
274
|
-
const text = interpolate(fndef[2], lineNo, ctx);
|
|
275
|
-
blocks.push({
|
|
276
|
-
kind: "block", type: "note", mode: "flow", id, classes: ["footnote"], attrs: {},
|
|
277
|
-
children: [{ kind: "paragraph", text, inlines: parseInline(text, lineNo, ctx) }],
|
|
278
|
-
});
|
|
279
|
-
i++;
|
|
289
|
+
i += consumed;
|
|
280
290
|
continue;
|
|
281
291
|
}
|
|
282
292
|
const open = FENCE_OPEN.exec(line);
|
|
@@ -292,7 +302,7 @@ function scanBlocks(lines, base, ctx, depth = 0) {
|
|
|
292
302
|
// safe way to nest (§3).
|
|
293
303
|
const labeled = attrs.id !== undefined ? new RegExp(`^={3,}[ \\t]+#${reLit(attrs.id)}[ \\t]*$`) : null;
|
|
294
304
|
const body = [];
|
|
295
|
-
let j = i +
|
|
305
|
+
let j = i + consumed;
|
|
296
306
|
let closed = false;
|
|
297
307
|
for (; j < lines.length; j++) {
|
|
298
308
|
if (isCloseFence(lines[j], openLen) || (labeled && labeled.test(lines[j]))) {
|
|
@@ -310,6 +320,31 @@ function scanBlocks(lines, base, ctx, depth = 0) {
|
|
|
310
320
|
diags.push({ severity: "warning", code: "unknown-block-type", message: `unknown block type \`${type}\`; body kept as raw`, line: openLineNo });
|
|
311
321
|
mode = "raw";
|
|
312
322
|
}
|
|
323
|
+
else {
|
|
324
|
+
// `hidden` (§4) and `caption` (§4, and the label an auto-reference takes
|
|
325
|
+
// per §5.2) are not type-specific: every typed block may carry them. Only
|
|
326
|
+
// the extras below are per type.
|
|
327
|
+
let validRe;
|
|
328
|
+
if (type === "table")
|
|
329
|
+
validRe = /^(src|format|header|format-data|compute\d*|summary\d*|span\d*)$/;
|
|
330
|
+
else if (type === "embed")
|
|
331
|
+
validRe = /^(src)$/;
|
|
332
|
+
else if (type === "diagram")
|
|
333
|
+
validRe = /^(src|data|format|type|rows|x|y|size|series)$/;
|
|
334
|
+
// `src`/`anchor` on a `code` block are the code-graph profile's
|
|
335
|
+
// (docs/codemap-profile.md): every document `geml codemap build` writes
|
|
336
|
+
// carries them, so warning on them would warn on our own output.
|
|
337
|
+
else if (type === "code")
|
|
338
|
+
validRe = /^(lang|src|anchor|name|entry-via)$/;
|
|
339
|
+
else
|
|
340
|
+
validRe = /^$/;
|
|
341
|
+
const universal = /^(hidden|caption)$/;
|
|
342
|
+
for (const key of Object.keys(attrs.attrs)) {
|
|
343
|
+
if (!universal.test(key) && !validRe.test(key)) {
|
|
344
|
+
diags.push({ severity: "warning", code: "unknown-attribute", message: `unknown attribute \`${key}\` for block type \`${type}\``, line: openLineNo });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
313
348
|
const block = {
|
|
314
349
|
kind: "block", type, mode, classes: attrs.classes, attrs: attrs.attrs,
|
|
315
350
|
};
|
|
@@ -382,18 +417,11 @@ function scanBlocks(lines, base, ctx, depth = 0) {
|
|
|
382
417
|
else {
|
|
383
418
|
block.raw = body;
|
|
384
419
|
if (type === "table") {
|
|
385
|
-
// `src=` and `data=` are one attribute in two spellings: where this
|
|
386
|
-
// table's data comes from. Recorded for the post-scan pass.
|
|
387
420
|
const srcAttr = typeof attrs.attrs["src"] === "string" ? attrs.attrs["src"].trim() : undefined;
|
|
388
|
-
const dataAttr = typeof attrs.attrs["data"] === "string" ? attrs.attrs["data"].trim() : undefined;
|
|
389
|
-
if (srcAttr !== undefined && dataAttr !== undefined) {
|
|
390
|
-
diags.push({ severity: "error", code: "source-attr-conflict", message: "table has both `src=` and `data=`; they mean the same thing — use one", line: openLineNo });
|
|
391
|
-
}
|
|
392
|
-
const target = srcAttr ?? dataAttr;
|
|
393
421
|
// §6: parse the raw body (visual or csv/tsv) into one table model.
|
|
394
|
-
const { model, diagnostics } = parseTable(body,
|
|
395
|
-
if (
|
|
396
|
-
(ctx.tableSources ??= []).push({ block, line: openLineNo, target });
|
|
422
|
+
const { model, diagnostics } = parseTable(body, attrs.attrs, openLineNo, ctx);
|
|
423
|
+
if (srcAttr !== undefined)
|
|
424
|
+
(ctx.tableSources ??= []).push({ block, line: openLineNo, target: srcAttr });
|
|
397
425
|
block.table = model;
|
|
398
426
|
for (const d of diagnostics)
|
|
399
427
|
diags.push({ ...d, line: openLineNo });
|
|
@@ -442,9 +470,10 @@ function scanBlocks(lines, base, ctx, depth = 0) {
|
|
|
442
470
|
if (h) {
|
|
443
471
|
const lineNo = base + i + 1;
|
|
444
472
|
const level = h[1].length;
|
|
445
|
-
const
|
|
446
|
-
const
|
|
447
|
-
const
|
|
473
|
+
const rawText = h[2];
|
|
474
|
+
const a = parseAttrs(h[3] ?? "");
|
|
475
|
+
const text = interpolate(rawText, lineNo, ctx);
|
|
476
|
+
const id = a.id ?? slug(rawText);
|
|
448
477
|
registerId(ctx, id, lineNo);
|
|
449
478
|
const block = {
|
|
450
479
|
kind: "heading", level, text, inlines: parseInline(text, lineNo, ctx), id, classes: a.classes, attrs: a.attrs,
|
|
@@ -452,7 +481,7 @@ function scanBlocks(lines, base, ctx, depth = 0) {
|
|
|
452
481
|
if (a.attrs["hidden"] === true)
|
|
453
482
|
block.hidden = true;
|
|
454
483
|
blocks.push(block);
|
|
455
|
-
i
|
|
484
|
+
i += consumed;
|
|
456
485
|
continue;
|
|
457
486
|
}
|
|
458
487
|
if (LIST_ITEM.test(line)) {
|
|
@@ -711,7 +740,7 @@ function gatherEmbeds(source) {
|
|
|
711
740
|
scanBlocks(normalizeSource(source).split("\n"), 0, ctx);
|
|
712
741
|
return (ctx.embeds ?? []).map((e) => (e.anchor === undefined ? { doc: e.doc } : { doc: e.doc, anchor: e.anchor }));
|
|
713
742
|
}
|
|
714
|
-
// One rule for "where this data comes from", shared by a table's `src
|
|
743
|
+
// One rule for "where this data comes from", shared by a table's `src=`
|
|
715
744
|
// and a chart's `data=`. Three target forms: a data file, `#id` naming a table
|
|
716
745
|
// block in this document, or `doc.geml#id` naming one in another document. An
|
|
717
746
|
// unresolvable target is an error — a table whose source silently produced no
|
|
@@ -773,11 +802,10 @@ function resolveTableSources(ctx, opts) {
|
|
|
773
802
|
err(line, "unresolvable-table-source", `cannot resolve table source \`${target}\``);
|
|
774
803
|
continue;
|
|
775
804
|
}
|
|
776
|
-
// Reuse the body parser: with `src
|
|
805
|
+
// Reuse the body parser: with `src` dropped, the file's lines are just
|
|
777
806
|
// this table's body, so format/header/compute/summary all behave identically.
|
|
778
807
|
const attrs = { ...block.attrs };
|
|
779
808
|
delete attrs["src"];
|
|
780
|
-
delete attrs["data"];
|
|
781
809
|
const { model, diagnostics } = parseTable(normalizeSource(text).split("\n"), attrs, line, ctx);
|
|
782
810
|
model.src = target;
|
|
783
811
|
block.table = model;
|
|
@@ -1004,13 +1032,11 @@ export function parse(source, opts = {}) {
|
|
|
1004
1032
|
}
|
|
1005
1033
|
// The id that a fence/heading line defines, matching how scanBlocks derives it
|
|
1006
1034
|
// (parseAttrs for the attribute object; heading text slug when no explicit id).
|
|
1007
|
-
// The slug MUST come from the
|
|
1008
|
-
//
|
|
1009
|
-
//
|
|
1010
|
-
// make the real one unaddressable. `ctx` is an inert context carrying the
|
|
1011
|
-
// document's meta (diagnostics are discarded — spans never report).
|
|
1035
|
+
// The slug MUST come from the RAW text, before interpolation, so that changing
|
|
1036
|
+
// a meta variable does not silently change the block's addressable id.
|
|
1037
|
+
// `ctx` is passed just in case future features need context.
|
|
1012
1038
|
function idOfHeading(braces, text, line, ctx) {
|
|
1013
|
-
return (braces ? parseAttrs(braces).id : undefined) ?? slug(
|
|
1039
|
+
return (braces ? parseAttrs(braces).id : undefined) ?? slug(text);
|
|
1014
1040
|
}
|
|
1015
1041
|
// The matching close of the fence opened at lines[i] (equal-length run, or the
|
|
1016
1042
|
// labeled `=== #id` close when the block carries an id): the index just past
|
|
@@ -1047,14 +1073,17 @@ function sectionEnd(lines, i, level) {
|
|
|
1047
1073
|
}
|
|
1048
1074
|
// Walk `lines` exactly as scanBlocks does — same fence close rules (equal-length
|
|
1049
1075
|
// or labeled `=== #id`), same flow-only recursion via REGISTRY — recording the
|
|
1050
|
-
// source span of every addressable id (typed block, heading
|
|
1076
|
+
// source span of every addressable id (typed block, heading).
|
|
1051
1077
|
// First definition wins, mirroring ctx.ids (a duplicate id is a build error, so
|
|
1052
1078
|
// `get`/`set` operate on the one the parser actually registered). `base` is the
|
|
1053
1079
|
// absolute line offset of this slice within the whole document.
|
|
1054
1080
|
function collectSpans(lines, base, out, ctx, depth = 0,
|
|
1055
|
-
// Optional second index: every
|
|
1056
|
-
// block the author never named is still
|
|
1057
|
-
|
|
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) {
|
|
1058
1087
|
const add = (id, start, end) => {
|
|
1059
1088
|
if (!out.has(id))
|
|
1060
1089
|
out.set(id, { start, end });
|
|
@@ -1069,6 +1098,7 @@ types) {
|
|
|
1069
1098
|
const fndef = /^\[\^([^\]]+)\]:[ \t]?(.*)$/.exec(line);
|
|
1070
1099
|
if (fndef) {
|
|
1071
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() });
|
|
1072
1102
|
i++;
|
|
1073
1103
|
continue;
|
|
1074
1104
|
}
|
|
@@ -1083,16 +1113,12 @@ types) {
|
|
|
1083
1113
|
const { end, closed } = fenceClose(lines, i, open);
|
|
1084
1114
|
if (id !== undefined)
|
|
1085
1115
|
add(id, base + i, base + end);
|
|
1086
|
-
|
|
1087
|
-
const list = types.get(type) ?? [];
|
|
1088
|
-
list.push({ span: { start: base + i, end: base + end }, id });
|
|
1089
|
-
types.set(type, list);
|
|
1090
|
-
}
|
|
1116
|
+
units?.push({ span: { start: base + i, end: base + end }, kind: "block", type, ...(id !== undefined ? { id } : {}) });
|
|
1091
1117
|
// Only a flow body is scanned for nested blocks (raw/data bodies are
|
|
1092
1118
|
// opaque), so an id inside a `code` body is *not* addressable — exactly
|
|
1093
1119
|
// the parser's contract.
|
|
1094
1120
|
if ((REGISTRY[type] ?? "raw") === "flow" && depth < MAX_NESTING) {
|
|
1095
|
-
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);
|
|
1096
1122
|
}
|
|
1097
1123
|
i = end;
|
|
1098
1124
|
continue;
|
|
@@ -1103,7 +1129,10 @@ types) {
|
|
|
1103
1129
|
// still advances one line at a time so every nested id inside the
|
|
1104
1130
|
// section registers its own span — spans intentionally OVERLAP: #sec
|
|
1105
1131
|
// contains #code, and each remains addressable on its own.
|
|
1106
|
-
|
|
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] });
|
|
1107
1136
|
i++;
|
|
1108
1137
|
continue;
|
|
1109
1138
|
}
|
|
@@ -1115,18 +1144,29 @@ types) {
|
|
|
1115
1144
|
export function blockSpans(source) {
|
|
1116
1145
|
const out = new Map();
|
|
1117
1146
|
const lines = normalizeSource(source).split("\n");
|
|
1118
|
-
// Inert context: heading auto-ids slug the
|
|
1119
|
-
//
|
|
1147
|
+
// Inert context: heading auto-ids slug the raw text, but parseDoc still
|
|
1148
|
+
// requires a valid context to parse the document.
|
|
1120
1149
|
const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines) };
|
|
1121
1150
|
collectSpans(lines, 0, out, ctx);
|
|
1122
1151
|
return out;
|
|
1123
1152
|
}
|
|
1124
|
-
|
|
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) {
|
|
1125
1163
|
const lines = normalizeSource(source).split("\n");
|
|
1126
1164
|
const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines) };
|
|
1127
|
-
const
|
|
1128
|
-
collectSpans(lines, 0, new Map(), ctx, 0,
|
|
1129
|
-
|
|
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"));
|
|
1130
1170
|
}
|
|
1131
1171
|
// Split into physical lines while *keeping* each line's terminator, so
|
|
1132
1172
|
// join("") is byte-exact and slicing by span never rewrites line endings.
|
|
@@ -1153,14 +1193,40 @@ function toNewline(text, nl) {
|
|
|
1153
1193
|
return nl === "\n" ? lf : lf.replace(/\n/g, nl);
|
|
1154
1194
|
}
|
|
1155
1195
|
// `--head`: narrow any id's span to its HEAD line — the single declaring line
|
|
1156
|
-
// (a heading's `# … {#id}` line, a typed block's opening fence
|
|
1157
|
-
//
|
|
1196
|
+
// (a heading's `# … {#id}` line, or a typed block's opening fence). The head is
|
|
1197
|
+
// by construction the FIRST line of the span, so
|
|
1158
1198
|
// the narrowing is parse-free and needs no type check. Main use: `set --head`
|
|
1159
1199
|
// edits a block's attributes (caption/compute/lang/…) without re-sending its
|
|
1160
1200
|
// body, or renames a heading without rewriting its section.
|
|
1161
1201
|
function narrowToHead(span) {
|
|
1162
1202
|
return { start: span.start, end: span.start + 1 };
|
|
1163
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
|
+
}
|
|
1164
1230
|
// Depth-first search for the document-model node carrying `id`, descending into
|
|
1165
1231
|
// flow-block children (and list-item children) so a nested id is found too.
|
|
1166
1232
|
// Returns the containing sibling array and index, not just the node: the model
|
|
@@ -1213,13 +1279,9 @@ function flag(args, name) {
|
|
|
1213
1279
|
function historyPathFor(geml) {
|
|
1214
1280
|
return geml.replace(/\.geml$/, "") + ".gemlhistory";
|
|
1215
1281
|
}
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
throw new Error(`bad --at timestamp: ${s} (want YYYYMMDDTHHMMSSZ)`);
|
|
1220
|
-
const [, y, mo, d, h, mi, se] = m;
|
|
1221
|
-
return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +se));
|
|
1222
|
-
}
|
|
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.)
|
|
1223
1285
|
const VERSION = "1.0"; // GEML spec version this CLI targets
|
|
1224
1286
|
// The published version, read from package.json rather than restated here.
|
|
1225
1287
|
// "Keep in sync with package.json" was a comment, and comments do not run: this
|
|
@@ -1279,10 +1341,13 @@ Usage:
|
|
|
1279
1341
|
(sel: 0 | -N | id-prefix | changed; default -1)
|
|
1280
1342
|
geml check <file.geml|-> [--root d] [--json] validate only: diagnostics + exit code
|
|
1281
1343
|
(--root widens cross-doc refs to dir d, e.g. the repo root)
|
|
1282
|
-
geml history <
|
|
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)
|
|
1283
1348
|
geml codemap <build|verify|render|serve|refresh|find> [...] code-graph toolkit (alias: codegraph)
|
|
1284
1349
|
geml mcp --root <dir> [--graph <dir>] [--no-history] serve documents (and the code graph) over MCP (stdio)
|
|
1285
|
-
(10 tools, each geml_ + its CLI
|
|
1350
|
+
(10 tools, each geml_ + its CLI command path: list/get/check/history/to +
|
|
1286
1351
|
set/add/delete/rename/revert; every write is validated before it
|
|
1287
1352
|
reaches disk. A code graph under --root adds four read-only
|
|
1288
1353
|
geml_codemap_* tools to the same server)
|
|
@@ -1299,14 +1364,19 @@ Exit codes:
|
|
|
1299
1364
|
// One-line usage for each subcommand — the single source for both the error
|
|
1300
1365
|
// shown on misuse and the `<cmd> --help` text.
|
|
1301
1366
|
const SUBHELP = {
|
|
1302
|
-
get: "usage: geml get <file.geml|-> [
|
|
1303
|
-
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)",
|
|
1304
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)",
|
|
1305
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)",
|
|
1306
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)",
|
|
1307
1372
|
check: "usage: geml check <file.geml|-> [--root <dir>] [--json] (--root: resolve cross-doc refs within <dir> instead of the file's own directory)",
|
|
1308
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)",
|
|
1309
|
-
history:
|
|
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.)`,
|
|
1310
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)
|
|
1311
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]]
|
|
1312
1382
|
geml codemap verify [dir] geml check + profile reference checks
|
|
@@ -1318,8 +1388,9 @@ const SUBHELP = {
|
|
|
1318
1388
|
mcp: `usage: geml mcp --root <dir> [--graph <dir>] [--no-history]
|
|
1319
1389
|
|
|
1320
1390
|
Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
|
|
1321
|
-
Every tool is geml_ + its CLI
|
|
1322
|
-
one vocabulary
|
|
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.
|
|
1323
1394
|
Ten tools: geml_list · geml_get · geml_check · geml_history · geml_to
|
|
1324
1395
|
geml_set · geml_add · geml_delete · geml_rename · geml_revert
|
|
1325
1396
|
With a code graph under --root, four more (read-only), so one client entry
|
|
@@ -1331,9 +1402,9 @@ const SUBHELP = {
|
|
|
1331
1402
|
--graph <dir> Code-graph directory, inside --root. Defaults to
|
|
1332
1403
|
<root>/.geml-code-graph when it holds an index.geml; with
|
|
1333
1404
|
no graph the four graph tools are not served at all.
|
|
1334
|
-
--no-history Skip the .gemlhistory
|
|
1335
|
-
(default:
|
|
1336
|
-
undo to).
|
|
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).
|
|
1337
1408
|
|
|
1338
1409
|
Register with a client:
|
|
1339
1410
|
claude mcp add geml -- geml mcp --root /abs/path/to/repo`,
|
|
@@ -1485,23 +1556,144 @@ function historyError(e, file, historyPath) {
|
|
|
1485
1556
|
}
|
|
1486
1557
|
return err?.message ?? String(e);
|
|
1487
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
|
+
}
|
|
1488
1593
|
function runHistory(args) {
|
|
1489
|
-
const sub = args
|
|
1490
|
-
const file = args[1];
|
|
1594
|
+
const [sub, file, rev, ...extra] = historyPositionals(args);
|
|
1491
1595
|
if (!sub || !file)
|
|
1492
1596
|
fail(SUBHELP.history);
|
|
1597
|
+
if (RETIRED_HISTORY[sub])
|
|
1598
|
+
fail(RETIRED_HISTORY[sub]);
|
|
1493
1599
|
const historyPath = flag(args, "--history") ?? historyPathFor(file);
|
|
1600
|
+
const json = args.includes("--json");
|
|
1494
1601
|
try {
|
|
1495
|
-
if (sub === "
|
|
1496
|
-
|
|
1497
|
-
|
|
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({
|
|
1498
1623
|
gemlPath: file,
|
|
1499
1624
|
historyPath,
|
|
1500
1625
|
summary: flag(args, "-m") ?? flag(args, "--message") ?? "",
|
|
1501
|
-
author: flag(args, "--author"),
|
|
1502
|
-
at: at ? parseStamp(at) : undefined,
|
|
1503
1626
|
});
|
|
1504
|
-
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}`);
|
|
1505
1697
|
}
|
|
1506
1698
|
else if (sub === "verify") {
|
|
1507
1699
|
const res = verify(historyPath, file);
|
|
@@ -1513,27 +1705,6 @@ function runHistory(args) {
|
|
|
1513
1705
|
if (!res.ok)
|
|
1514
1706
|
process.exit(1);
|
|
1515
1707
|
}
|
|
1516
|
-
else if (sub === "show") {
|
|
1517
|
-
const rev = args[2];
|
|
1518
|
-
if (!rev)
|
|
1519
|
-
fail("usage: geml history show <file.geml> <revision>");
|
|
1520
|
-
process.stdout.write(restore({ historyPath, gemlPath: file, revision: rev }));
|
|
1521
|
-
}
|
|
1522
|
-
else if (sub === "restore") {
|
|
1523
|
-
const rev = args[2];
|
|
1524
|
-
if (!rev)
|
|
1525
|
-
fail("usage: geml history restore <file.geml> <revision> [--force]");
|
|
1526
|
-
restore({ historyPath, gemlPath: file, revision: rev, write: true, force: args.includes("--force") });
|
|
1527
|
-
console.log(`restored ${file} to ${rev}`);
|
|
1528
|
-
}
|
|
1529
|
-
else if (sub === "log") {
|
|
1530
|
-
// Newest-first, with the `--rev` selector for each row in the first column
|
|
1531
|
-
// (`0` for the tip, then `-1`, `-2`, …) so the output is copy-paste.
|
|
1532
|
-
for (const r of listRevisions(historyPath)) {
|
|
1533
|
-
const sel = r.current ? "0" : `-${r.offset}`;
|
|
1534
|
-
console.log(`${sel.padEnd(7)} ${r.id} ${r.author ?? "-"} ${r.summary ?? ""}`.replace(/\s+$/, ""));
|
|
1535
|
-
}
|
|
1536
|
-
}
|
|
1537
1708
|
else {
|
|
1538
1709
|
fail(`unknown history subcommand: ${sub}. Run 'geml --help'.`);
|
|
1539
1710
|
}
|
|
@@ -1794,39 +1965,58 @@ function resolveSelector(source, file, raw) {
|
|
|
1794
1965
|
// a heading, its level and text); `--json` is a machine-readable array so an
|
|
1795
1966
|
// agent can pick its next `get #id` target. Ids are listed in document order
|
|
1796
1967
|
// (the registration order parse() records), covering the same set `get #id`
|
|
1797
|
-
// resolves against: typed blocks
|
|
1968
|
+
// resolves against: typed blocks and headings. A `[^id]` reference names one
|
|
1969
|
+
// of those (§5.2); the `[^id]: text` definition line was withdrawn.
|
|
1798
1970
|
function listIds(source, file, json) {
|
|
1971
|
+
const where = file === "-" ? "stdin" : file;
|
|
1972
|
+
const all = addressedUnits(source);
|
|
1799
1973
|
const doc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
|
|
1800
|
-
const rows =
|
|
1801
|
-
const
|
|
1802
|
-
const
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
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"))
|
|
1998
|
+
row.footnote = true;
|
|
1999
|
+
}
|
|
2000
|
+
return row;
|
|
1812
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.
|
|
1813
2005
|
if (json) {
|
|
1814
2006
|
console.log(JSON.stringify(rows, null, 2));
|
|
1815
2007
|
return;
|
|
1816
2008
|
}
|
|
1817
2009
|
if (rows.length === 0) {
|
|
1818
|
-
console.error(`no addressable
|
|
2010
|
+
console.error(`no addressable blocks in ${where}`);
|
|
1819
2011
|
return;
|
|
1820
2012
|
}
|
|
1821
|
-
|
|
1822
|
-
const idW = Math.max(...rows.map((r) => r.id.length + 1));
|
|
2013
|
+
const addrW = Math.max(...rows.map((r) => r.address.length));
|
|
1823
2014
|
const kindW = Math.max(...rows.map((r) => r.kind.length));
|
|
1824
2015
|
for (const r of rows) {
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
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" : "");
|
|
1830
2020
|
console.log(line.replace(/\s+$/, ""));
|
|
1831
2021
|
}
|
|
1832
2022
|
}
|
|
@@ -1843,42 +2033,11 @@ function listIds(source, file, json) {
|
|
|
1843
2033
|
// guessed between, so a document with three notes answers "which one" instead
|
|
1844
2034
|
// of failing. The uniqueness that makes `=== meta` work is checked here, at
|
|
1845
2035
|
// resolve time — nothing in the format has to promise a document holds only one.
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
}
|
|
1852
|
-
if (matches.length === 1) {
|
|
1853
|
-
const m = matches[0];
|
|
1854
|
-
if (json) {
|
|
1855
|
-
// The ONLY block of its type: locating it in the model needs no index, so
|
|
1856
|
-
// --json can still answer with the parsed node (meta's key/values, a
|
|
1857
|
-
// table's model) rather than a mere location.
|
|
1858
|
-
const node = onlyBlockOfType(parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) }).children, type);
|
|
1859
|
-
if (node) {
|
|
1860
|
-
console.log(JSON.stringify(node, null, 2));
|
|
1861
|
-
return;
|
|
1862
|
-
}
|
|
1863
|
-
}
|
|
1864
|
-
const span = headOnly ? narrowToHead(m.span) : m.span;
|
|
1865
|
-
process.stdout.write(splitLines(source).slice(span.start, span.end).join(""));
|
|
1866
|
-
return;
|
|
1867
|
-
}
|
|
1868
|
-
// Several: report WHERE they are (data on stdout, the explanation on stderr),
|
|
1869
|
-
// so the caller can name one — by adding an #id, or via its section.
|
|
1870
|
-
if (json) {
|
|
1871
|
-
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));
|
|
1872
|
-
return;
|
|
1873
|
-
}
|
|
1874
|
-
console.error(`${matches.length} \`${type}\` blocks in ${where} — give one an #id, or address its section:`);
|
|
1875
|
-
for (const m of matches) {
|
|
1876
|
-
console.log(`=== ${type}${m.id ? ` {#${m.id}}` : ""} L${m.span.start + 1}-${m.span.end}`);
|
|
1877
|
-
}
|
|
1878
|
-
}
|
|
1879
|
-
// The single block of `type` in a document, or undefined when there is not
|
|
1880
|
-
// exactly one (nested flow children included, matching the span scan's reach).
|
|
1881
|
-
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) {
|
|
1882
2041
|
const hits = [];
|
|
1883
2042
|
const walk = (list) => {
|
|
1884
2043
|
for (const b of list) {
|
|
@@ -1891,65 +2050,139 @@ function onlyBlockOfType(blocks, type) {
|
|
|
1891
2050
|
}
|
|
1892
2051
|
};
|
|
1893
2052
|
walk(blocks);
|
|
1894
|
-
return hits
|
|
1895
|
-
}
|
|
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.
|
|
1896
2147
|
function runGet(args) {
|
|
1897
2148
|
const json = args.includes("--json");
|
|
1898
2149
|
const headOnly = args.includes("--head");
|
|
1899
|
-
const
|
|
2150
|
+
const bodyOnly = args.includes("--body");
|
|
2151
|
+
const [file, rawSel] = positionals(args, []);
|
|
1900
2152
|
if (!file)
|
|
1901
2153
|
fail(SUBHELP.get);
|
|
1902
|
-
|
|
1903
|
-
|
|
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
|
+
}
|
|
1904
2159
|
// One read: stdin can only be consumed once, and the selector resolver needs
|
|
1905
2160
|
// the same bytes the slice below works on.
|
|
1906
2161
|
const source = readInput(file);
|
|
1907
|
-
|
|
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
|
+
}
|
|
1908
2170
|
listIds(source, file, json);
|
|
1909
2171
|
return;
|
|
1910
2172
|
}
|
|
1911
|
-
|
|
1912
|
-
// the document" move as a heading line, for the blocks that carry no id.
|
|
1913
|
-
// A pasted fence that DOES declare an id defers to the id path below.
|
|
1914
|
-
const fence = /^={3,}[ \t]*([A-Za-z][A-Za-z0-9_-]*)[ \t]*(\{.*\})?[ \t]*$/.exec(rawId.trim());
|
|
1915
|
-
const fenceId = fence?.[2] ? parseAttrs(fence[2]).id : undefined;
|
|
1916
|
-
if (fence && fenceId === undefined) {
|
|
1917
|
-
getByType(source, file, fence[1], json, headOnly);
|
|
1918
|
-
return;
|
|
1919
|
-
}
|
|
1920
|
-
const id = fenceId ?? resolveSelector(source, file, rawId);
|
|
2173
|
+
const { units, all } = selectUnits(source, file, rawSel, where);
|
|
1921
2174
|
if (json) {
|
|
1922
|
-
//
|
|
1923
|
-
//
|
|
1924
|
-
|
|
1925
|
-
const
|
|
1926
|
-
|
|
1927
|
-
fail(`no block with id \`${id}\``, 1);
|
|
1928
|
-
const block = site.siblings[site.index];
|
|
1929
|
-
// `--head` on a heading suppresses the section envelope (the lone heading
|
|
1930
|
-
// node IS the head). On a block/footnote id there is nothing finer than
|
|
1931
|
-
// the single node — the model has no sub-node for "just the fence line" —
|
|
1932
|
-
// so --head refines only the RAW output there.
|
|
1933
|
-
if (block.kind === "heading" && !headOnly) {
|
|
1934
|
-
// A heading id addresses its SECTION, so `--json` covers the same
|
|
1935
|
-
// content as the raw span: a self-describing envelope whose blocks[0]
|
|
1936
|
-
// is the heading node followed by its siblings up to the boundary.
|
|
1937
|
-
// `kind: "section"` lets a consumer branch — a block/footnote id still
|
|
1938
|
-
// yields the single model node (the model itself stays flat).
|
|
1939
|
-
const end = sectionEndIndex(site.siblings, site.index);
|
|
1940
|
-
console.log(JSON.stringify({ kind: "section", id: block.id, level: block.level, blocks: site.siblings.slice(site.index, end) }, null, 2));
|
|
1941
|
-
return;
|
|
1942
|
-
}
|
|
1943
|
-
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));
|
|
1944
2180
|
return;
|
|
1945
2181
|
}
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
const
|
|
1949
|
-
|
|
1950
|
-
fail(`no block with id \`${id}\``, 1);
|
|
1951
|
-
const span = headOnly ? narrowToHead(found) : found;
|
|
1952
|
-
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));
|
|
1953
2186
|
}
|
|
1954
2187
|
const NO_CONTENT = "no replacement content (use --in FILE or pipe it on stdin)";
|
|
1955
2188
|
// `geml set <file.geml|-> #id [--head|--body] [--in F|F#src|-] [-o out]` —
|
|
@@ -1975,14 +2208,13 @@ function runSet(args) {
|
|
|
1975
2208
|
const bodyOnly = args.includes("--body");
|
|
1976
2209
|
if (headOnly && bodyOnly)
|
|
1977
2210
|
fail("--head and --body are mutually exclusive", 2);
|
|
1978
|
-
const [file,
|
|
2211
|
+
const [file, rawSel] = positionals(args, ["-o", "--out", "--in"]);
|
|
1979
2212
|
if (!file)
|
|
1980
2213
|
fail(SUBHELP.set);
|
|
1981
|
-
// No
|
|
1982
|
-
// usage line — `geml get <file>` lists every
|
|
1983
|
-
if (!
|
|
1984
|
-
fail(`no
|
|
1985
|
-
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);
|
|
1986
2218
|
// The raw channel is stdin — `--in` omitted or `--in -`; anything else sources
|
|
1987
2219
|
// a block from a file. Document and content can't BOTH be stdin: reject that
|
|
1988
2220
|
// up front, before consuming stdin, so the document read below is unambiguous.
|
|
@@ -1991,16 +2223,11 @@ function runSet(args) {
|
|
|
1991
2223
|
fail("reading the document from stdin needs --in for the new content", 2);
|
|
1992
2224
|
}
|
|
1993
2225
|
const source = readInput(file);
|
|
2226
|
+
const target = resolveSetTarget(source, file, rawSel);
|
|
1994
2227
|
if (bodyOnly) {
|
|
1995
|
-
runSetBody(source,
|
|
2228
|
+
runSetBody(source, target, from, rawChannel, file, out);
|
|
1996
2229
|
return;
|
|
1997
2230
|
}
|
|
1998
|
-
// default / --head: content is a whole block (default) or a bare head line.
|
|
1999
|
-
// Does the target exist? Asked FIRST: the shape checks below name the id in
|
|
2000
|
-
// their advice ("use --body to set the body of #far"), which reads as though the
|
|
2001
|
-
// id were there. Whether the content is prose is the second question.
|
|
2002
|
-
if (!blockSpans(source).has(id))
|
|
2003
|
-
fail(`no block with id \`${id}\``, 1);
|
|
2004
2231
|
let content;
|
|
2005
2232
|
if (rawChannel) {
|
|
2006
2233
|
content = readInput("-");
|
|
@@ -2014,39 +2241,71 @@ function runSet(args) {
|
|
|
2014
2241
|
if (shape === "empty")
|
|
2015
2242
|
fail(NO_CONTENT, 1);
|
|
2016
2243
|
if (shape === "prose")
|
|
2017
|
-
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);
|
|
2018
2245
|
if (shape === "multi")
|
|
2019
2246
|
fail("set replaces ONE block, but the content has multiple blocks (use add)", 1);
|
|
2020
2247
|
}
|
|
2021
2248
|
}
|
|
2022
2249
|
else {
|
|
2023
|
-
content = extractBlock(from, id, headOnly ? "head" : "whole");
|
|
2024
|
-
}
|
|
2025
|
-
|
|
2026
|
-
|
|
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);
|
|
2027
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))}`);
|
|
2028
2296
|
}
|
|
2029
2297
|
// `--body`: swap ONLY the target block's body, keeping its head (and #id) and,
|
|
2030
2298
|
// for a typed block, its close fence. Assembles head + new body + close and
|
|
2031
2299
|
// reuses the guarded spliceBlock — the head carries #id, so the id survives
|
|
2032
2300
|
// with no normalization needed.
|
|
2033
|
-
function runSetBody(source,
|
|
2034
|
-
const found =
|
|
2035
|
-
if (!found)
|
|
2036
|
-
fail(`no block with id \`${id}\``, 1);
|
|
2301
|
+
function runSetBody(source, target, from, rawChannel, file, out) {
|
|
2302
|
+
const found = target.unit.span;
|
|
2037
2303
|
const lines = splitLines(source);
|
|
2038
2304
|
const headLine = lines[found.start] ?? "";
|
|
2039
|
-
|
|
2040
|
-
//
|
|
2041
|
-
|
|
2042
|
-
const
|
|
2043
|
-
if (open) {
|
|
2044
|
-
const lastText = stripEol(lines[found.end - 1] ?? "").replace(/[ \t]+$/, "");
|
|
2045
|
-
const bid = open[3] ? parseAttrs(open[3]).id : undefined;
|
|
2046
|
-
const labeled = bid !== undefined && new RegExp(`^={3,}[ \\t]+#${reLit(bid)}[ \\t]*$`).test(lastText);
|
|
2047
|
-
if (isCloseFence(lastText, open[1].length) || labeled)
|
|
2048
|
-
closeLine = lines[found.end - 1] ?? "";
|
|
2049
|
-
}
|
|
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);
|
|
2050
2309
|
let body;
|
|
2051
2310
|
if (rawChannel) {
|
|
2052
2311
|
body = readInput("-");
|
|
@@ -2054,7 +2313,7 @@ function runSetBody(source, id, from, rawChannel, file, out) {
|
|
|
2054
2313
|
fail(NO_CONTENT, 1);
|
|
2055
2314
|
}
|
|
2056
2315
|
else {
|
|
2057
|
-
body = extractBlock(from, id, "body");
|
|
2316
|
+
body = extractBlock(from, target.unit.id ?? "", "body");
|
|
2058
2317
|
}
|
|
2059
2318
|
let head = headLine;
|
|
2060
2319
|
if (head !== "" && !/(\r\n|\r|\n)$/.test(head))
|
|
@@ -2067,8 +2326,9 @@ function runSetBody(source, id, from, rawChannel, file, out) {
|
|
|
2067
2326
|
// block-count invariant so a `===` fence in the raw body can't close it early
|
|
2068
2327
|
// and inject siblings (SEC F2). A heading section body has no close fence and
|
|
2069
2328
|
// may legitimately contain blocks, so it is not count-guarded.
|
|
2070
|
-
const updated =
|
|
2329
|
+
const updated = spliceSpan(source, found, replacement, file, false, closeLine !== null, target.unit.id);
|
|
2071
2330
|
resolveOutTarget(file, out).write(updated);
|
|
2331
|
+
reportNewAddress(updated, target);
|
|
2072
2332
|
}
|
|
2073
2333
|
// `geml add <file|-> (--append | --before #x | --after #x) [--in F|F#src|-] [-o]`
|
|
2074
2334
|
// — insert a GEML fragment (1+ blocks and/or prose) at a position. Unlike `set`,
|
|
@@ -2369,6 +2629,13 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
|
|
|
2369
2629
|
const found = blockSpans(source).get(id);
|
|
2370
2630
|
if (!found)
|
|
2371
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) {
|
|
2372
2639
|
const beforeDoc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
|
|
2373
2640
|
const beforeIds = beforeDoc.ids;
|
|
2374
2641
|
// Keep the bytes before and after the target span exactly; give the new block
|
|
@@ -2400,7 +2667,7 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
|
|
|
2400
2667
|
refuseBroken(`replacement would break the document: ${first.message} (line ${first.line}); not written`, errs);
|
|
2401
2668
|
}
|
|
2402
2669
|
const now = new Set(reparsed.ids);
|
|
2403
|
-
if (!now.has(id))
|
|
2670
|
+
if (id !== undefined && !now.has(id))
|
|
2404
2671
|
fail(`replacement removes id \`${id}\`; not written`, 1);
|
|
2405
2672
|
const dropped = beforeIds.find((x) => x !== id && !now.has(x));
|
|
2406
2673
|
if (dropped !== undefined) {
|
|
@@ -2415,7 +2682,7 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
|
|
|
2415
2682
|
// (Not enforced for heading sections / whole-block set, whose replacement may
|
|
2416
2683
|
// legitimately span several top-level blocks.)
|
|
2417
2684
|
if (guardCount && reparsed.children.length !== beforeDoc.children.length) {
|
|
2418
|
-
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);
|
|
2419
2686
|
}
|
|
2420
2687
|
return updated;
|
|
2421
2688
|
}
|