@geml/geml 1.7.1 → 1.7.2
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 +20 -4
- package/dist/block-edit.js +15 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +2433 -0
- package/dist/from-md.js +1 -1
- package/dist/geml.d.ts +32 -0
- package/dist/geml.js +172 -2038
- package/dist/history.js +7 -3
- package/dist/mcp.js +46 -8
- package/dist/render-html.d.ts +1 -1
- package/dist/render.d.ts +1 -1
- package/dist/render.js +180 -15
- package/dist/selector.d.ts +7 -0
- package/dist/selector.js +41 -1
- package/package.json +67 -67
- package/skill/SKILL.md +25 -0
- package/skill/references/authoring.geml +6 -1
package/dist/geml.js
CHANGED
|
@@ -9,23 +9,20 @@
|
|
|
9
9
|
// math, media embeds, links, auto-references, footnotes) and build-time
|
|
10
10
|
// reference validation (§8 — unique ids, resolvable internal/cross-document
|
|
11
11
|
// references).
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
// The library needs almost nothing from Node: PARSER_VERSION reads the
|
|
13
|
+
// package.json beside dist/ at runtime (so it never needs hand-bumping), and
|
|
14
|
+
// path joins are pure string math. Everything that touches the filesystem, the
|
|
15
|
+
// home directory or a child process now lives in cli.ts — which is what keeps
|
|
16
|
+
// this module safe to bundle for a browser.
|
|
17
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
18
|
+
import { dirname, join, resolve as resolvePath } from "node:path";
|
|
15
19
|
import { fileURLToPath } from "node:url";
|
|
16
|
-
import { spawnSync } from "node:child_process";
|
|
17
|
-
import { save, restore, verify, isCurrent, listRevisions, resolveContent, firstChangedContent } from "./history.js";
|
|
18
|
-
import { renderHtml } from "./render-html.js";
|
|
19
|
-
import { normalizeBlockId } from "./block-edit.js";
|
|
20
20
|
import { normalizeSource } from "./diagnostics.js";
|
|
21
21
|
import { coerce, parseAttrs } from "./attrs.js";
|
|
22
22
|
import { META_REF_SRC, parseInline, isSafeUrl, schemeOf } from "./inline.js";
|
|
23
23
|
import { parseTable } from "./table.js";
|
|
24
24
|
import { USES, buildChart } from "./chart.js";
|
|
25
|
-
import {
|
|
26
|
-
import { serialize } from "./serialize.js";
|
|
27
|
-
import { addressUnits, discoveryHint, matchContent, matchType, parseSelector, shortestAddress, } from "./selector.js";
|
|
28
|
-
import { gemlToMd } from "./to-md.js";
|
|
25
|
+
import { addressUnits, } from "./selector.js";
|
|
29
26
|
export { mdToGeml } from "./from-md.js";
|
|
30
27
|
export { renderHtml, pageAssets } from "./render-html.js";
|
|
31
28
|
export { serialize } from "./serialize.js";
|
|
@@ -35,7 +32,7 @@ export { gemlToMd } from "./to-md.js";
|
|
|
35
32
|
// through this first, or a crafted id (`#a(`, `#(x+x+)+y`) turns a labeled-close
|
|
36
33
|
// or reference match into an uncaught `SyntaxError` or a ReDoS on the main
|
|
37
34
|
// parse path (SEC: document-controlled RegExp injection).
|
|
38
|
-
function reLit(s) {
|
|
35
|
+
export function reLit(s) {
|
|
39
36
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
40
37
|
}
|
|
41
38
|
// The `data` block's format engines (GEP-0005), shared by the inline-body
|
|
@@ -111,8 +108,45 @@ const DIAGRAM_RENDERERS = new Set(["mermaid", "graphviz", "dot", "d2", "plantuml
|
|
|
111
108
|
// ---------------------------------------------------------------------------
|
|
112
109
|
// Lexical helpers
|
|
113
110
|
// ---------------------------------------------------------------------------
|
|
114
|
-
|
|
115
|
-
|
|
111
|
+
// The trailing `[ \t]*` lives INSIDE the optional attrs group on purpose. As
|
|
112
|
+
// `…[ \t]*(\{.*\})?[ \t]*$` a head with no attrs had TWO runs competing for the
|
|
113
|
+
// same whitespace, and the engine tried every split of it: `=== note` plus 40k
|
|
114
|
+
// tabs and one stray byte took 750 ms, growing with the square. With the run
|
|
115
|
+
// nested, the no-attrs case has exactly one way to match. Same language —
|
|
116
|
+
// checked over a case set plus 60k random strings, byte-identical groups.
|
|
117
|
+
export const FENCE_OPEN = /^(={3,})[ \t]+([A-Za-z][A-Za-z0-9_-]*)[ \t]*(?:(\{.*\})[ \t]*)?$/;
|
|
118
|
+
// Heading head, matched by SCAN rather than by one regular expression. As
|
|
119
|
+
// `^(#{1,6})[ \t]+(.*?)[ \t]*(\{[^}]*\})?[ \t]*$` this was the worst expression
|
|
120
|
+
// in the parser: a lazy run and two whitespace runs all competing for the same
|
|
121
|
+
// characters, so the engine tried every way to divide them. `# T` followed by
|
|
122
|
+
// 8k tabs and one `{` took 84 SECONDS — an 8 KB line is a denial-of-service
|
|
123
|
+
// payload, and headings are tested against every line of every document.
|
|
124
|
+
// Nesting the trailing run only takes it from cubic to quadratic (still 18 s at
|
|
125
|
+
// 128k), so the ambiguity has to go, not merely shrink.
|
|
126
|
+
//
|
|
127
|
+
// The scan reproduces the expression EXACTLY, and the two rules that make it
|
|
128
|
+
// exact are both easy to get wrong:
|
|
129
|
+
// * `[^}]*` forbids a `}` INSIDE the group but happily allows `{`, so the
|
|
130
|
+
// group may swallow further open braces;
|
|
131
|
+
// * `(.*?)` is LAZY, so among the possible groups the engine takes the one
|
|
132
|
+
// leaving the SHORTEST text — the FIRST `{` that still works, not the last.
|
|
133
|
+
// Together: the group runs to the end of the line and starts at the first `{`
|
|
134
|
+
// after the last OTHER `}`. Returns the RegExpExecArray shape the call sites
|
|
135
|
+
// already destructure.
|
|
136
|
+
const HEADING_HEAD = /^(#{1,6})[ \t]+/;
|
|
137
|
+
function matchHeading(line) {
|
|
138
|
+
const m = HEADING_HEAD.exec(line);
|
|
139
|
+
if (!m)
|
|
140
|
+
return null;
|
|
141
|
+
const rest = trimSpaceTabEnd(line.slice(m[0].length));
|
|
142
|
+
if (rest.endsWith("}")) {
|
|
143
|
+
const lastClose = rest.lastIndexOf("}", rest.length - 2); // the final `}` is the group's own
|
|
144
|
+
const open = rest.indexOf("{", lastClose + 1);
|
|
145
|
+
if (open >= 0)
|
|
146
|
+
return [line, m[1], trimSpaceTabEnd(rest.slice(0, open)), rest.slice(open)];
|
|
147
|
+
}
|
|
148
|
+
return [line, m[1], rest, undefined];
|
|
149
|
+
}
|
|
116
150
|
// A line with the exact shape of a labeled close (§3): a `=` run and a `#id`,
|
|
117
151
|
// nothing else. Matched against lines that fell through to paragraph text,
|
|
118
152
|
// where such a line means the close closed nothing (stray-labeled-fence).
|
|
@@ -137,8 +171,13 @@ const LIST_ITEM = /^[ \t]*(?:[-*]|\d+\.)[ \t]+(.*)$/;
|
|
|
137
171
|
// (scanBlocks / parseList) and, in step, the renderer against a deeply nested
|
|
138
172
|
// document overflowing the call stack (DoS). 256 is far past any real document.
|
|
139
173
|
const MAX_NESTING = 256;
|
|
140
|
-
function isCloseFence(line, openLen) {
|
|
141
|
-
|
|
174
|
+
export function isCloseFence(line, openLen) {
|
|
175
|
+
// trimEnd(), not /\s+$/: the regex is polynomial on a whitespace run that
|
|
176
|
+
// never reaches the end of the line, and this runs once PER LINE of every
|
|
177
|
+
// document parsed. Both strip the same set — JS `\s` and trimEnd's
|
|
178
|
+
// WhiteSpace ∪ LineTerminator are the same code points — so this is an exact
|
|
179
|
+
// swap, just without the backtracking.
|
|
180
|
+
const t = line.trimEnd();
|
|
142
181
|
return /^=+$/.test(t) && t.length === openLen;
|
|
143
182
|
}
|
|
144
183
|
function slug(text) {
|
|
@@ -618,7 +657,7 @@ function scanBlocks(lines, base, ctx, depth = 0) {
|
|
|
618
657
|
i = closed ? j + 1 : j;
|
|
619
658
|
continue;
|
|
620
659
|
}
|
|
621
|
-
const h =
|
|
660
|
+
const h = matchHeading(line);
|
|
622
661
|
if (h) {
|
|
623
662
|
const lineNo = base + i + 1;
|
|
624
663
|
const level = h[1].length;
|
|
@@ -649,7 +688,7 @@ function scanBlocks(lines, base, ctx, depth = 0) {
|
|
|
649
688
|
lines[i].trim() !== "" &&
|
|
650
689
|
!/^[ \t]*%%/.test(lines[i]) &&
|
|
651
690
|
!FENCE_OPEN.test(lines[i]) &&
|
|
652
|
-
|
|
691
|
+
matchHeading(lines[i]) === null &&
|
|
653
692
|
!LIST_ITEM.test(lines[i])) {
|
|
654
693
|
para.push(lines[i]);
|
|
655
694
|
i++;
|
|
@@ -753,7 +792,7 @@ function chartSourceTable(ctx, opts, block, target, line) {
|
|
|
753
792
|
const inferDataFormat = (target) => (/\.tsv$/i.test(target) ? "tsv" : "csv");
|
|
754
793
|
// The renderer's own cap (render.ts EMBED_DEPTH_CAP). Kept in step here so the
|
|
755
794
|
// check and the render agree on which documents are reachable at all.
|
|
756
|
-
const EMBED_DEPTH_LIMIT = 8;
|
|
795
|
+
export const EMBED_DEPTH_LIMIT = 8;
|
|
757
796
|
function detectTransclusionCycles(ctx, opts) {
|
|
758
797
|
if (!opts.resolveDoc || ctx.embeds === undefined || ctx.embeds.length === 0)
|
|
759
798
|
return;
|
|
@@ -906,7 +945,7 @@ function validateProjections(children, ctx, opts) {
|
|
|
906
945
|
}
|
|
907
946
|
}
|
|
908
947
|
// Same pure-string path composition the renderer uses (relJoin/relDir there).
|
|
909
|
-
function relJoinPath(base, target) {
|
|
948
|
+
export function relJoinPath(base, target) {
|
|
910
949
|
if (base === "" || target === "" || target.startsWith("/") || /^[a-z][a-z0-9+.-]*:/i.test(target))
|
|
911
950
|
return target;
|
|
912
951
|
// A POSIX-absolute base must stay absolute. The segment loop below drops empty
|
|
@@ -926,116 +965,11 @@ function relJoinPath(base, target) {
|
|
|
926
965
|
}
|
|
927
966
|
return (rooted ? "/" : "") + out.join("/");
|
|
928
967
|
}
|
|
929
|
-
function relDirPath(p) {
|
|
968
|
+
export function relDirPath(p) {
|
|
930
969
|
const i = p.lastIndexOf("/");
|
|
931
970
|
return i < 0 ? "" : p.slice(0, i);
|
|
932
971
|
}
|
|
933
|
-
|
|
934
|
-
// corresponds to (§3) so the message can name it without inventing a new one.
|
|
935
|
-
class ViewError extends Error {
|
|
936
|
-
code;
|
|
937
|
-
constructor(code, message) {
|
|
938
|
-
super(message);
|
|
939
|
-
this.code = code;
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
// Walking a chain is DOCUMENT-DRIVEN file access: `src=` comes from file
|
|
943
|
-
// content, so without a confinement root a document could name any path on the
|
|
944
|
-
// machine. And never a URL — `geml get` is a read command that agents and
|
|
945
|
-
// editors call constantly, so letting content steer it at the network would turn
|
|
946
|
-
// it into an SSRF entry point (§3.1). Both refusals reuse existing codes (§3).
|
|
947
|
-
function readConfined(rel, root) {
|
|
948
|
-
if (!/\.geml$/i.test(rel)) {
|
|
949
|
-
throw new ViewError("embed-target-not-geml", `embed-target-not-geml: \`${rel}\` is not a \`.geml\` document`);
|
|
950
|
-
}
|
|
951
|
-
const base = resolvePath(root);
|
|
952
|
-
const abs = resolvePath(root, rel);
|
|
953
|
-
if (abs !== base && !abs.startsWith(base + sep)) {
|
|
954
|
-
throw new ViewError("unresolvable-document", `unresolvable-document: \`${rel}\` lies outside the confinement root \`${root}\``);
|
|
955
|
-
}
|
|
956
|
-
try {
|
|
957
|
-
return readFileSync(abs, "utf8");
|
|
958
|
-
}
|
|
959
|
-
catch {
|
|
960
|
-
throw new ViewError("unresolvable-document", `unresolvable-document: cannot resolve \`${rel}\``);
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
// One hop: read the target document and select what the fragment names. Several
|
|
964
|
-
// units come back when the fragment names a section (§4.3).
|
|
965
|
-
function oneHop(file, src, root) {
|
|
966
|
-
const hash = src.indexOf("#");
|
|
967
|
-
const docPath = hash < 0 ? src : src.slice(0, hash);
|
|
968
|
-
const frag = hash < 0 ? undefined : src.slice(hash + 1);
|
|
969
|
-
// Check the scheme on what the DOCUMENT wrote, before composition: a URL can
|
|
970
|
-
// only arrive through `src=`, never from joining relative paths — and testing
|
|
971
|
-
// the composed path instead would read a Windows drive letter (`C:/…`) as a
|
|
972
|
-
// scheme and refuse every absolute path, which is exactly what the MCP layer
|
|
973
|
-
// hands the CLI.
|
|
974
|
-
if (schemeOf(docPath) !== null) {
|
|
975
|
-
throw new ViewError("unchecked-cross-document-reference", `unchecked-cross-document-reference: \`${docPath}\` is not local; \`--view\` never fetches over the network`);
|
|
976
|
-
}
|
|
977
|
-
const rel = relJoinPath(relDirPath(file), docPath);
|
|
978
|
-
const text = readConfined(rel, root);
|
|
979
|
-
if (frag === undefined) {
|
|
980
|
-
// `src=other.geml`: the frame looks onto the WHOLE document. Every block
|
|
981
|
-
// comes from the same target, so the resolution base stays uniform — unlike
|
|
982
|
-
// a host-side section selector, where splicing would mix two documents.
|
|
983
|
-
// `meta` is frontmatter, not content (render.ts's selectEmbed).
|
|
984
|
-
//
|
|
985
|
-
// Only TOP-LEVEL units: a heading's unit spans its whole section, so taking
|
|
986
|
-
// every addressed unit would emit the blocks inside a section twice.
|
|
987
|
-
const every = addressedUnits(text).map((a) => a.unit);
|
|
988
|
-
const top = every.filter((u) => !every.some((o) => o !== u && o.span.start <= u.span.start && o.span.end >= u.span.end
|
|
989
|
-
&& (o.span.start < u.span.start || o.span.end > u.span.end)));
|
|
990
|
-
return { doc: rel, text, units: top.filter((u) => !(u.kind === "block" && u.type === "meta")), all: [], from: shownPath(rel, root) };
|
|
991
|
-
}
|
|
992
|
-
const { units, all } = selectUnits(text, rel, `#${frag}`, rel);
|
|
993
|
-
return { doc: rel, text, units, all, from: `${shownPath(rel, root)}#${frag}` };
|
|
994
|
-
}
|
|
995
|
-
// Provenance is stated relative to the confinement root, not as the path the
|
|
996
|
-
// walk happens to have composed. The MCP layer hands the CLI an ABSOLUTE path,
|
|
997
|
-
// so without this `from` would be `C:/Users/…/part.geml#tip` — leaking the
|
|
998
|
-
// server's layout, and not a path any caller could pass back in.
|
|
999
|
-
function shownPath(rel, root) {
|
|
1000
|
-
const r = relative(root, rel).replace(/\\/g, "/");
|
|
1001
|
-
return r === "" ? rel : r;
|
|
1002
|
-
}
|
|
1003
|
-
function viewResolve(source, file, unit, root, depth = 0, seen = new Set()) {
|
|
1004
|
-
const src = unit.kind === "block" && unit.type === "embed" ? embedSrcOf(source, unit) : undefined;
|
|
1005
|
-
if (src === undefined)
|
|
1006
|
-
return [{ doc: file, text: source, unit, all: [], from: "" }];
|
|
1007
|
-
// The renderer expands no deeper either (EMBED_DEPTH_LIMIT), but where the
|
|
1008
|
-
// cycle detector may stop SILENTLY — a 9-deep chain is legal and simply is
|
|
1009
|
-
// not expanded — `--view` may not: stopping here means what we are holding is
|
|
1010
|
-
// still a frame, and returning it would break the contract silently.
|
|
1011
|
-
if (depth >= EMBED_DEPTH_LIMIT) {
|
|
1012
|
-
throw new ViewError("depth", `chain still not on an entity block after ${EMBED_DEPTH_LIMIT} hops (the renderer expands no deeper either)`);
|
|
1013
|
-
}
|
|
1014
|
-
const hop = oneHop(file, src, root);
|
|
1015
|
-
// Same key shape as the check's cycle detector: a document plus what was
|
|
1016
|
-
// selected in it.
|
|
1017
|
-
const key = `${hop.doc}#${hop.units.map((u) => u.id ?? "").join(",")}`;
|
|
1018
|
-
if (seen.has(key)) {
|
|
1019
|
-
throw new ViewError("transclusion-cycle", `transclusion-cycle: \`${hop.from}\` is already being expanded in this chain`);
|
|
1020
|
-
}
|
|
1021
|
-
const nextSeen = new Set(seen).add(key);
|
|
1022
|
-
// Per-unit application, recursively: what a frame looks onto may itself be a
|
|
1023
|
-
// frame, and a section may hold a mix (§4.3).
|
|
1024
|
-
return hop.units.flatMap((u) => viewResolve(hop.text, hop.doc, u, root, depth + 1, nextSeen)
|
|
1025
|
-
// An inner identity step has no provenance of its own, so carry this hop's:
|
|
1026
|
-
// `from` must always name where the bytes actually came from.
|
|
1027
|
-
.map((r) => (r.from === "" ? { ...r, from: hop.from } : r)));
|
|
1028
|
-
}
|
|
1029
|
-
// The `src=` of an embed unit, read off its head line: a Unit carries the span,
|
|
1030
|
-
// not parsed attributes.
|
|
1031
|
-
function embedSrcOf(source, unit) {
|
|
1032
|
-
const braces = /\{[^}]*\}/.exec(sliceUnit(source, unit.span, true, false));
|
|
1033
|
-
if (!braces)
|
|
1034
|
-
return undefined;
|
|
1035
|
-
const v = parseAttrs(braces[0]).attrs["src"];
|
|
1036
|
-
return typeof v === "string" ? v : undefined;
|
|
1037
|
-
}
|
|
1038
|
-
function gatherEmbeds(source) {
|
|
972
|
+
export function gatherEmbeds(source) {
|
|
1039
973
|
const ctx = { diags: [], ids: new Map(), refs: [], meta: new Map(), embeds: [] };
|
|
1040
974
|
scanBlocks(normalizeSource(source).split("\n"), 0, ctx);
|
|
1041
975
|
return (ctx.embeds ?? []).map((e) => (e.anchor === undefined ? { doc: e.doc } : { doc: e.doc, anchor: e.anchor }));
|
|
@@ -1211,6 +1145,20 @@ function validateRefs(ctx, opts) {
|
|
|
1211
1145
|
if (ids === undefined) {
|
|
1212
1146
|
const src = opts.resolveDoc(ref.doc);
|
|
1213
1147
|
if (src === null) {
|
|
1148
|
+
// A LINK may point at something that exists but has no text to read —
|
|
1149
|
+
// a directory, above all: `[the extension](integrations/vscode/)` is
|
|
1150
|
+
// an ordinary link that a forge renders as a listing, and calling it
|
|
1151
|
+
// broken would fail every real project's README. It carries no ids,
|
|
1152
|
+
// so an anchor into it is still an error, and a target that is simply
|
|
1153
|
+
// absent still is too. Content routes (`src=`, `data=`, `embed`) do
|
|
1154
|
+
// not come through here: they need bytes, and a directory has none.
|
|
1155
|
+
if (opts.docExists?.(ref.doc)) {
|
|
1156
|
+
docIds.set(ref.doc, new Set());
|
|
1157
|
+
if (ref.anchor !== undefined) {
|
|
1158
|
+
ctx.diags.push({ severity: "error", code: "unresolved-cross-document-reference", message: `unresolved reference \`${ref.doc}#${ref.anchor}\` (\`${ref.doc}\` has no addressable content)`, line: ref.line });
|
|
1159
|
+
}
|
|
1160
|
+
continue;
|
|
1161
|
+
}
|
|
1214
1162
|
ctx.diags.push({ severity: "error", code: "unresolvable-document", message: `cannot resolve document \`${ref.doc}\``, line: ref.line });
|
|
1215
1163
|
docIds.set(ref.doc, new Set());
|
|
1216
1164
|
continue;
|
|
@@ -1633,7 +1581,7 @@ function sectionEnd(lines, i, level) {
|
|
|
1633
1581
|
j = fenceClose(lines, j, open).end;
|
|
1634
1582
|
continue;
|
|
1635
1583
|
}
|
|
1636
|
-
const h =
|
|
1584
|
+
const h = matchHeading(lines[j]);
|
|
1637
1585
|
if (h && h[1].length <= level)
|
|
1638
1586
|
return j;
|
|
1639
1587
|
j++;
|
|
@@ -1692,7 +1640,7 @@ units) {
|
|
|
1692
1640
|
i = end;
|
|
1693
1641
|
continue;
|
|
1694
1642
|
}
|
|
1695
|
-
const h =
|
|
1643
|
+
const h = matchHeading(line);
|
|
1696
1644
|
if (h) {
|
|
1697
1645
|
// Section span (heading through its prose and nested blocks). The walk
|
|
1698
1646
|
// still advances one line at a time so every nested id inside the
|
|
@@ -1710,6 +1658,26 @@ units) {
|
|
|
1710
1658
|
}
|
|
1711
1659
|
// Map every addressable id in `source` to its source span. Line indices align
|
|
1712
1660
|
// with the physical lines produced by splitLines(source).
|
|
1661
|
+
export function stripEol(line) {
|
|
1662
|
+
return line.replace(/(\r\n|\r|\n)$/, "");
|
|
1663
|
+
}
|
|
1664
|
+
// Drop trailing spaces and tabs, in LINEAR time. `/[ \t]+$/` is polynomial: on
|
|
1665
|
+
// a line whose run of tabs does not reach the end, the engine starts the run
|
|
1666
|
+
// again at every index inside it — 40k tabs took 750 ms here, and the cost
|
|
1667
|
+
// grows with the SQUARE, so a document is a denial-of-service payload rather
|
|
1668
|
+
// than a slow parse. `trimEnd()` is not the substitute: it also strips \v, \f,
|
|
1669
|
+
// NBSP and the Unicode spaces, which would silently widen what counts as a
|
|
1670
|
+
// closing fence. This strips exactly the two bytes the callers mean.
|
|
1671
|
+
export function trimSpaceTabEnd(s) {
|
|
1672
|
+
let i = s.length;
|
|
1673
|
+
while (i > 0) {
|
|
1674
|
+
const c = s.charCodeAt(i - 1);
|
|
1675
|
+
if (c !== 0x20 && c !== 0x09)
|
|
1676
|
+
break;
|
|
1677
|
+
i--;
|
|
1678
|
+
}
|
|
1679
|
+
return i === s.length ? s : s.slice(0, i);
|
|
1680
|
+
}
|
|
1713
1681
|
export function blockSpans(source) {
|
|
1714
1682
|
const out = new Map();
|
|
1715
1683
|
const lines = normalizeSource(source).split("\n");
|
|
@@ -1728,7 +1696,7 @@ export function blockSpans(source) {
|
|
|
1728
1696
|
//
|
|
1729
1697
|
// The ONE index selector matching and the listing both work from, so `get`,
|
|
1730
1698
|
// `set` and the listing can never disagree about what exists.
|
|
1731
|
-
function addressedUnits(source) {
|
|
1699
|
+
export function addressedUnits(source) {
|
|
1732
1700
|
const lines = normalizeSource(source).split("\n");
|
|
1733
1701
|
const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines) };
|
|
1734
1702
|
const units = [];
|
|
@@ -1742,7 +1710,7 @@ function addressedUnits(source) {
|
|
|
1742
1710
|
// A line ends at `\n` or at a LONE `\r` (old-Mac style) — the same boundaries
|
|
1743
1711
|
// the span scan's `\r\n?` -> `\n` normalization sees, so span indices always
|
|
1744
1712
|
// address the same lines the parser counted.
|
|
1745
|
-
function splitLines(source) {
|
|
1713
|
+
export function splitLines(source) {
|
|
1746
1714
|
return source.split(/(?<=\n|\r(?!\n))/);
|
|
1747
1715
|
}
|
|
1748
1716
|
// Newline handling lives HERE, in one place, because it is easy to get subtly
|
|
@@ -1751,13 +1719,13 @@ function splitLines(source) {
|
|
|
1751
1719
|
// come from either kind of file, stdin from anywhere. So: detect the DOCUMENT's
|
|
1752
1720
|
// style, compare on the normalized (LF) form, and convert back on the way in —
|
|
1753
1721
|
// which is what keeps a CRLF document from ending up half CRLF, half LF.
|
|
1754
|
-
function newlineOf(text) {
|
|
1722
|
+
export function newlineOf(text) {
|
|
1755
1723
|
return /\r\n/.test(text) ? "\r\n" : "\n";
|
|
1756
1724
|
}
|
|
1757
|
-
function toLf(text) {
|
|
1725
|
+
export function toLf(text) {
|
|
1758
1726
|
return text.replace(/\r\n?/g, "\n");
|
|
1759
1727
|
}
|
|
1760
|
-
function toNewline(text, nl) {
|
|
1728
|
+
export function toNewline(text, nl) {
|
|
1761
1729
|
const lf = toLf(text);
|
|
1762
1730
|
return nl === "\n" ? lf : lf.replace(/\n/g, nl);
|
|
1763
1731
|
}
|
|
@@ -1767,7 +1735,7 @@ function toNewline(text, nl) {
|
|
|
1767
1735
|
// the narrowing is parse-free and needs no type check. Main use: `set --head`
|
|
1768
1736
|
// edits a block's attributes (caption/compute/lang/…) without re-sending its
|
|
1769
1737
|
// body, or renames a heading without rewriting its section.
|
|
1770
|
-
function narrowToHead(span) {
|
|
1738
|
+
export function narrowToHead(span) {
|
|
1771
1739
|
return { start: span.start, end: span.start + 1 };
|
|
1772
1740
|
}
|
|
1773
1741
|
// The unit's CLOSING fence line, or null when it has none — a heading section,
|
|
@@ -1775,11 +1743,11 @@ function narrowToHead(span) {
|
|
|
1775
1743
|
// decide it in ONE place: the selector design's §4 defines HEAD/BODY by the
|
|
1776
1744
|
// round-trip invariant `get X --body | set X --body` leaving the file
|
|
1777
1745
|
// byte-identical, and two copies of this judgement is exactly how that breaks.
|
|
1778
|
-
function closeFenceLine(lines, span) {
|
|
1746
|
+
export function closeFenceLine(lines, span) {
|
|
1779
1747
|
const open = FENCE_OPEN.exec(stripEol(lines[span.start] ?? ""));
|
|
1780
1748
|
if (!open)
|
|
1781
1749
|
return null;
|
|
1782
|
-
const lastText = stripEol(lines[span.end - 1] ?? "")
|
|
1750
|
+
const lastText = trimSpaceTabEnd(stripEol(lines[span.end - 1] ?? ""));
|
|
1783
1751
|
const bid = open[3] ? parseAttrs(open[3]).id : undefined;
|
|
1784
1752
|
const labeled = bid !== undefined && new RegExp(`^={3,}[ \\t]+#${reLit(bid)}[ \\t]*$`).test(lastText);
|
|
1785
1753
|
return isCloseFence(lastText, open[1].length) || labeled ? lines[span.end - 1] ?? "" : null;
|
|
@@ -1790,10 +1758,36 @@ function closeFenceLine(lines, span) {
|
|
|
1790
1758
|
function narrowToBody(lines, span) {
|
|
1791
1759
|
return { start: span.start + 1, end: closeFenceLine(lines, span) !== null ? span.end - 1 : span.end };
|
|
1792
1760
|
}
|
|
1793
|
-
//
|
|
1794
|
-
|
|
1761
|
+
// The INTRO sub-range of a heading's section: what the heading says before it
|
|
1762
|
+
// says anything under a subheading. Bounded by the next heading of ANY level,
|
|
1763
|
+
// which is the same line either way — a deeper heading opens a subsection, a
|
|
1764
|
+
// same-or-higher one ends this section. Empty when a heading follows
|
|
1765
|
+
// immediately; the whole body when the section has no further heading.
|
|
1766
|
+
//
|
|
1767
|
+
// The bound comes from the parsed units, never from scanning for `#`: a `#`
|
|
1768
|
+
// inside a fenced block is body text, and a line scan would cut the section in
|
|
1769
|
+
// half there.
|
|
1770
|
+
export function narrowToIntro(source, span) {
|
|
1771
|
+
const body = narrowToBody(splitLines(source), span);
|
|
1772
|
+
let end = body.end;
|
|
1773
|
+
for (const a of addressedUnits(source)) {
|
|
1774
|
+
const u = a.unit;
|
|
1775
|
+
if (u.kind !== "heading")
|
|
1776
|
+
continue;
|
|
1777
|
+
if (u.span.start > span.start && u.span.start < body.end) {
|
|
1778
|
+
end = u.span.start;
|
|
1779
|
+
break;
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
return { start: body.start, end: Math.max(body.start, end) };
|
|
1783
|
+
}
|
|
1784
|
+
// Slice one unit's output bytes, honouring --head / --body / --intro.
|
|
1785
|
+
export function sliceUnit(source, span, part = "whole") {
|
|
1795
1786
|
const lines = splitLines(source);
|
|
1796
|
-
const s =
|
|
1787
|
+
const s = part === "head" ? narrowToHead(span)
|
|
1788
|
+
: part === "body" ? narrowToBody(lines, span)
|
|
1789
|
+
: part === "intro" ? narrowToIntro(source, span)
|
|
1790
|
+
: span;
|
|
1797
1791
|
return lines.slice(s.start, s.end).join("");
|
|
1798
1792
|
}
|
|
1799
1793
|
// Depth-first search for the document-model node carrying `id`, descending into
|
|
@@ -1801,7 +1795,7 @@ function sliceUnit(source, span, headOnly, bodyOnly) {
|
|
|
1801
1795
|
// Returns the containing sibling array and index, not just the node: the model
|
|
1802
1796
|
// is FLAT — a heading does not own its section; the section's prose and blocks
|
|
1803
1797
|
// are its FOLLOWING SIBLINGS — so a section consumer needs the array.
|
|
1804
|
-
function findBlockSite(blocks, id) {
|
|
1798
|
+
export function findBlockSite(blocks, id) {
|
|
1805
1799
|
for (let i = 0; i < blocks.length; i++) {
|
|
1806
1800
|
const b = blocks[i];
|
|
1807
1801
|
if ((b.kind === "heading" || b.kind === "block") && b.id === id)
|
|
@@ -1829,7 +1823,7 @@ function findBlockSite(blocks, id) {
|
|
|
1829
1823
|
// source lines (where skipping fenced bodies makes "next heading" well-defined)
|
|
1830
1824
|
// — the two sides must stay in lockstep; the get-set suite pins their parity
|
|
1831
1825
|
// (ids covered by the raw slice == ids covered by the --json envelope).
|
|
1832
|
-
function sectionEndIndex(siblings, k) {
|
|
1826
|
+
export function sectionEndIndex(siblings, k) {
|
|
1833
1827
|
const level = siblings[k].level;
|
|
1834
1828
|
for (let m = k + 1; m < siblings.length; m++) {
|
|
1835
1829
|
const b = siblings[m];
|
|
@@ -1841,17 +1835,13 @@ function sectionEndIndex(siblings, k) {
|
|
|
1841
1835
|
// ---------------------------------------------------------------------------
|
|
1842
1836
|
// CLI
|
|
1843
1837
|
// ---------------------------------------------------------------------------
|
|
1844
|
-
function
|
|
1845
|
-
const i = args.indexOf(name);
|
|
1846
|
-
return i >= 0 ? args[i + 1] : undefined;
|
|
1847
|
-
}
|
|
1848
|
-
function historyPathFor(geml) {
|
|
1838
|
+
export function historyPathFor(geml) {
|
|
1849
1839
|
return geml.replace(/\.geml$/, "") + ".gemlhistory";
|
|
1850
1840
|
}
|
|
1851
1841
|
// (A `YYYYMMDDTHHMMSSZ` parser lived here for `history commit --at`. That flag
|
|
1852
1842
|
// left the CLI with design §9-Q4 — the library API takes a real Date — so the parser
|
|
1853
1843
|
// went with it rather than staying as an uncalled branch.)
|
|
1854
|
-
const VERSION = "1.0"; // GEML spec version this CLI targets
|
|
1844
|
+
export const VERSION = "1.0"; // GEML spec version this CLI targets
|
|
1855
1845
|
// The published version, read from package.json rather than restated here.
|
|
1856
1846
|
// "Keep in sync with package.json" was a comment, and comments do not run: this
|
|
1857
1847
|
// literal said 1.4.3 while the MCP server's own copy still said 0.1.0.
|
|
@@ -1878,1904 +1868,48 @@ export const PARSER_VERSION = (() => {
|
|
|
1878
1868
|
}
|
|
1879
1869
|
return "0.0.0";
|
|
1880
1870
|
})();
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
--json = model node). Without #id: list all addressable
|
|
1902
|
-
ids (--json = array).
|
|
1903
|
-
geml set <file.geml|-> #id [--head|--body] [--in f[#src]|-] [-o f] replace ONE block by id
|
|
1904
|
-
(--in F takes F's block #id, F#src takes #src, else stdin raw;
|
|
1905
|
-
default = whole block · --head = head line · --body = body)
|
|
1906
|
-
geml add <file.geml|-> (--append | --before #id | --after #id) [--in f[#src]|-] [-o f] insert a fragment
|
|
1907
|
-
(1+ blocks and/or prose; content keeps its own ids, a clash is refused)
|
|
1908
|
-
geml delete <file.geml|-> #id [#id2 …] [-o f] remove one or more blocks
|
|
1909
|
-
(a missing id is skipped; a dangling reference is a warning, not a refusal)
|
|
1910
|
-
geml rename <file.geml|-> #old #new [-o f] rename an id and every reference to it (id-boundary safe)
|
|
1911
|
-
geml revert <file.geml> #id [--rev <sel>] [--head] undo one block to a past revision (splice / resurrect / remove)
|
|
1912
|
-
(sel: 0 | -N | id-prefix | changed; default -1)
|
|
1913
|
-
geml check <file.geml|-> [--root d] [--json] validate only: diagnostics + exit code
|
|
1914
|
-
(--root widens cross-doc refs to dir d, e.g. the repo root)
|
|
1915
|
-
geml history <save|get|restore|verify> <file.geml> [...] .gemlhistory version sidecar
|
|
1916
|
-
(save = append the file as a revision · get = list revisions, or
|
|
1917
|
-
print one · restore = overwrite the file with one · verify = rebuild
|
|
1918
|
-
and re-hash the whole chain)
|
|
1919
|
-
geml codemap <build|verify|render|serve|refresh|find> [...] code-graph toolkit (alias: codegraph)
|
|
1920
|
-
geml mcp --root <dir> [--graph <dir>] [--no-history] serve documents (and the code graph) over MCP (stdio)
|
|
1921
|
-
(10 tools, each geml_ + its CLI command path: list/get/check/history/to +
|
|
1922
|
-
set/add/delete/rename/revert; every write is validated before it
|
|
1923
|
-
reaches disk. A code graph under --root adds four read-only
|
|
1924
|
-
geml_codemap_* tools to the same server)
|
|
1925
|
-
geml skill install [--dest <dir>] [--no-global] [--no-mcp] set up GEML for Claude Code, user-global
|
|
1926
|
-
(authoring skill -> ~/.claude/skills/geml, CLI -> npm i -g,
|
|
1927
|
-
MCP server registered at user scope; touches no settings.json,
|
|
1928
|
-
installs no hooks; idempotent — re-run to update)
|
|
1929
|
-
geml --help | --version [--json]
|
|
1930
|
-
|
|
1931
|
-
Use '-' as the file to read from stdin.
|
|
1932
|
-
Mutations (set/add/delete/rename) write the whole updated document in place for a
|
|
1933
|
-
file, or to stdout for '-' input; -o redirects it (-o - = stdout).
|
|
1934
|
-
Exit codes:
|
|
1935
|
-
0 ok
|
|
1936
|
-
1 document/operation error
|
|
1937
|
-
2 command usage error.
|
|
1938
|
-
`;
|
|
1939
|
-
// One-line usage for each subcommand — the single source for both the error
|
|
1940
|
-
// shown on misuse and the `<cmd> --help` text.
|
|
1941
|
-
const SUBHELP = {
|
|
1942
|
-
get: "usage: geml get <file.geml|-> [<selector>] [--head|--body] [--view [--root <dir>]] [--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; --view = read THROUGH an `embed` to the entity block it stands for, following a chain to its end (the identity on any other block, and on a section selector — it never splices two documents' bytes together); provenance goes to stderr as `view: <sel> -> <doc>[#<id>]`; read-only, `set` refuses it; chain reads are confined to --root (default: the document's own directory) and never fetched over the network; without a selector: list every addressable block with its shortest unique address, --json = array)",
|
|
1943
|
-
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)",
|
|
1944
|
-
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)",
|
|
1945
|
-
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)",
|
|
1946
|
-
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)",
|
|
1947
|
-
check: "usage: geml check <file.geml|-> [--root <dir>] [--json] (--root: resolve cross-doc refs within <dir> instead of the file's own directory)",
|
|
1948
|
-
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)",
|
|
1949
|
-
history: `usage: geml history save <file.geml> [-m <msg>] append the working file as a new revision (identical to the tip = no-op)
|
|
1950
|
-
geml history get <file.geml> [<rev>] [--json] NO <rev>: every revision, newest first, first column = the selector; WITH <rev>: that revision's full text
|
|
1951
|
-
geml history restore <file.geml> <rev> [--force] overwrite the working file with a revision (--force discards unsaved changes)
|
|
1952
|
-
geml history verify <file.geml> rebuild and re-hash every revision in the chain
|
|
1953
|
-
(<rev>: 0 = the tip | -N = N revisions back | an unambiguous revision id — the strings 'get' prints.
|
|
1954
|
-
All four take --history <path> to point at a sidecar other than <file>.gemlhistory.)`,
|
|
1955
|
-
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)
|
|
1956
|
-
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]]
|
|
1957
|
-
geml codemap verify [dir] geml check + profile reference checks
|
|
1958
|
-
geml codemap render [dir] every doc -> sibling .html (open index.html from disk)
|
|
1959
|
-
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
|
|
1960
|
-
geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
|
|
1961
|
-
geml codemap find <name> [dir] locate a symbol by substring name -> doc#id + src (stdout, no browser)
|
|
1962
|
-
(<dir> for verify/render/serve/refresh/find defaults to ./.geml-code-graph; codegraph and code-graph are accepted as aliases of codemap)`,
|
|
1963
|
-
mcp: `usage: geml mcp --root <dir> [--graph <dir>] [--no-history]
|
|
1964
|
-
|
|
1965
|
-
Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
|
|
1966
|
-
Every tool is geml_ + its CLI COMMAND PATH, so the terminal and the assistant
|
|
1967
|
-
share one vocabulary — geml_history mirrors the "geml history" command group,
|
|
1968
|
-
whose read verb (get) is the only one of the four served here.
|
|
1969
|
-
Ten tools: geml_list · geml_get · geml_check · geml_history · geml_to
|
|
1970
|
-
geml_set · geml_add · geml_delete · geml_rename · geml_revert
|
|
1971
|
-
With a code graph under --root, four more (read-only), so one client entry
|
|
1972
|
-
covers both: geml_codemap_search · geml_codemap_callchain
|
|
1973
|
-
geml_codemap_list · geml_codemap_node
|
|
1974
|
-
|
|
1975
|
-
--root <dir> REQUIRED. Root holding the .geml documents. Every path a
|
|
1976
|
-
client names is confined here; a client cannot widen it.
|
|
1977
|
-
--graph <dir> Code-graph directory, inside --root. Defaults to
|
|
1978
|
-
<root>/.geml-code-graph when it holds an index.geml; with
|
|
1979
|
-
no graph the four graph tools are not served at all.
|
|
1980
|
-
--no-history Skip the .gemlhistory revision saved before each write
|
|
1981
|
-
(default: save one, so geml_revert always has a revision
|
|
1982
|
-
to undo to).
|
|
1983
|
-
|
|
1984
|
-
Register with a client:
|
|
1985
|
-
claude mcp add geml -- geml mcp --root /abs/path/to/repo`,
|
|
1986
|
-
skill: `usage: geml skill install [--dest <skillsDir>] [--no-global] [--no-mcp]
|
|
1987
|
-
|
|
1988
|
-
One command, three things, all user-global — so any Claude Code session can
|
|
1989
|
-
author, validate, and blockwise-edit GEML:
|
|
1990
|
-
1. the authoring skill -> <skillsDir>/geml (default ~/.claude/skills/geml)
|
|
1991
|
-
2. the geml CLI -> npm i -g @geml/geml (skipped when already on PATH)
|
|
1992
|
-
3. the MCP server -> claude mcp add --scope user geml -- npx -y @geml/geml mcp --root .
|
|
1993
|
-
Touches no settings.json and installs no hooks. Idempotent — re-run after an
|
|
1994
|
-
upgrade to refresh the skill text alongside the CLI it teaches.
|
|
1995
|
-
|
|
1996
|
-
--dest <dir> install the skill under <dir> instead of ~/.claude/skills
|
|
1997
|
-
--no-global skip the global npm install
|
|
1998
|
-
--no-mcp skip the MCP server registration`,
|
|
1999
|
-
};
|
|
2000
|
-
// Set from argv at dispatch time; when true, errors are emitted as a JSON
|
|
2001
|
-
// envelope so an agent that standardizes on --json never has to parse text.
|
|
2002
|
-
let jsonMode = false;
|
|
2003
|
-
// Clean one-line error + non-zero exit — never a raw Node stack trace. `code`
|
|
2004
|
-
// is the process exit status: 2 for a usage error (the default), 1 for a
|
|
2005
|
-
// document/operation error. `--json` wraps it in the same {error, code} envelope.
|
|
2006
|
-
function fail(msg, code = 2) {
|
|
2007
|
-
if (jsonMode)
|
|
2008
|
-
console.error(JSON.stringify({ error: msg, code }));
|
|
2009
|
-
else
|
|
2010
|
-
console.error(`error: ${msg}`);
|
|
2011
|
-
process.exit(code);
|
|
2012
|
-
}
|
|
2013
|
-
// Refuse a mutation whose RESULT would be broken (the pre-write check every
|
|
2014
|
-
// mutation runs). Prose mode is the long-standing wording: the first error,
|
|
2015
|
-
// phrased by the call site. `--json` additionally carries the FULL diagnostic
|
|
2016
|
-
// list with the stable codes of spec Appendix A, so a programmatic caller —
|
|
2017
|
-
// `geml mcp` above all — reports what actually broke instead of re-parsing
|
|
2018
|
-
// English out of stderr.
|
|
2019
|
-
function refuseBroken(prose, errs) {
|
|
2020
|
-
if (jsonMode) {
|
|
2021
|
-
console.error(JSON.stringify({ error: prose, code: 1, diagnostics: errs }));
|
|
2022
|
-
process.exit(1);
|
|
2023
|
-
}
|
|
2024
|
-
fail(prose, 1);
|
|
2025
|
-
}
|
|
2026
|
-
// Read a file, or stdin when the path is "-". On failure emit a clean error.
|
|
2027
|
-
function readInput(file) {
|
|
2028
|
-
try {
|
|
2029
|
-
return readFileSync(file === "-" ? 0 : file, "utf8");
|
|
2030
|
-
}
|
|
2031
|
-
catch {
|
|
2032
|
-
fail(file === "-" ? "cannot read stdin" : `cannot read ${file}`);
|
|
2033
|
-
}
|
|
2034
|
-
}
|
|
2035
|
-
// A cross-document resolver rooted at the input's directory (cwd for stdin),
|
|
2036
|
-
// CONFINED to that directory's subtree. A reference that resolves outside the
|
|
2037
|
-
// base — via a `..` escape, an absolute path, or (on Windows) a different drive
|
|
2038
|
-
// — is refused (returns null, i.e. an unresolvable ref) so a crafted document
|
|
2039
|
-
// cannot turn `geml check`/parse into an arbitrary local-file read oracle. §8.
|
|
2040
|
-
//
|
|
2041
|
-
// A purely LEXICAL check is not enough: a symlink that sits lexically inside the
|
|
2042
|
-
// subtree but points to `../../outside.geml` passes `path.relative` yet reads an
|
|
2043
|
-
// external target. So after the cheap lexical gate we resolve BOTH the base and
|
|
2044
|
-
// the target through `realpathSync` (following every symlink component) and
|
|
2045
|
-
// re-check that the REAL target still lies within the REAL base subtree before
|
|
2046
|
-
// reading. A target that does not exist makes `realpathSync` throw — handled as
|
|
2047
|
-
// an ordinary unresolvable ref (null), never a crash.
|
|
2048
|
-
//
|
|
2049
|
-
// `root` (CLI `--root`, an explicit per-invocation user grant — never
|
|
2050
|
-
// document-controlled) widens the confinement base from the input's own
|
|
2051
|
-
// directory to an ancestor the user names, so repo-relative `../` references
|
|
2052
|
-
// between sibling directories can be checked. It moves WHERE the boundary
|
|
2053
|
-
// stands, never whether it is enforced: both gates below run against the
|
|
2054
|
-
// widened base, so escapes past the root are refused exactly as above. The
|
|
2055
|
-
// viewer/web surfaces never pass a root — their boundary is unchanged.
|
|
2056
|
-
function resolverFor(file, root) {
|
|
2057
|
-
const dirAbs = resolvePath(file === "-" ? "." : dirname(file));
|
|
2058
|
-
const baseAbs = root === undefined ? dirAbs : resolvePath(root);
|
|
2059
|
-
// Canonicalise the base once. If the base itself cannot be realpath'd, no
|
|
2060
|
-
// cross-doc ref can be safely confined — resolve nothing.
|
|
2061
|
-
let realBase = null;
|
|
1871
|
+
// Is this process being run as the `geml` command? npm's unix bin shim is a
|
|
1872
|
+
// symlink named plain `geml`, so the test resolves argv[1] rather than
|
|
1873
|
+
// comparing spellings. In a browser bundle argv is [] and this is false, which
|
|
1874
|
+
// is what keeps the CLI hand-off below out of a page.
|
|
1875
|
+
function isCliInvocation() {
|
|
1876
|
+
const argv1 = process.argv[1];
|
|
1877
|
+
if (!argv1)
|
|
1878
|
+
return false;
|
|
1879
|
+
// THIS file, run as the script — not "a file whose name ends in geml.js".
|
|
1880
|
+
// Someone's own `geml.js` that imports the library would otherwise trip the
|
|
1881
|
+
// hand-off below and have the CLI exit their process. It also keeps
|
|
1882
|
+
// `dist/cli.js` from coming back through here: cli.js imports this module,
|
|
1883
|
+
// so re-importing it would be a cycle.
|
|
1884
|
+
//
|
|
1885
|
+
// Both sides go through realpathSync, because the shim this has to recognise
|
|
1886
|
+
// IS a symlink: `path.resolve` only absolutises the spelling it is handed, so
|
|
1887
|
+
// `/tmp/x/geml -> …/dist/geml.js` compared unequal, the hand-off never ran,
|
|
1888
|
+
// and `geml --version` through npm's bin exited 0 having printed nothing.
|
|
1889
|
+
// Canonicalising this file too covers a dist/ reached through a symlinked
|
|
1890
|
+
// directory, and the macOS /tmp -> /private/tmp case the test walks into.
|
|
2062
1891
|
try {
|
|
2063
|
-
|
|
1892
|
+
return realOf(argv1) === realOf(fileURLToPath(import.meta.url));
|
|
2064
1893
|
}
|
|
2065
1894
|
catch {
|
|
2066
|
-
|
|
2067
|
-
}
|
|
2068
|
-
const outside = (from, to) => {
|
|
2069
|
-
const rel = relative(from, to);
|
|
2070
|
-
return rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel);
|
|
2071
|
-
};
|
|
2072
|
-
return (d) => {
|
|
2073
|
-
if (realBase === null)
|
|
2074
|
-
return null;
|
|
2075
|
-
// References resolve FROM the document's own directory; the gates below
|
|
2076
|
-
// confine them to the (possibly widened) base.
|
|
2077
|
-
let targetAbs = resolvePath(dirAbs, d);
|
|
2078
|
-
// A SOURCE route (`code`/`data` `src=`) may instead be written relative to
|
|
2079
|
-
// the resolution root — that is how the code-graph profile writes them
|
|
2080
|
-
// (`geml-parser/src/attrs.ts` from a document two levels down). So when
|
|
2081
|
-
// the document-relative path does not exist and a root was named, try the
|
|
2082
|
-
// root as the base. Only a widened `--root` can enable this, and both
|
|
2083
|
-
// confinement gates below still apply, so it cannot reach further than a
|
|
2084
|
-
// document-relative reference already could.
|
|
2085
|
-
if (baseAbs !== dirAbs && !existsSync(targetAbs)) {
|
|
2086
|
-
const fromBase = resolvePath(baseAbs, d);
|
|
2087
|
-
if (existsSync(fromBase))
|
|
2088
|
-
targetAbs = fromBase;
|
|
2089
|
-
}
|
|
2090
|
-
// Cheap lexical gate: reject an obvious `..`/absolute/other-drive escape
|
|
2091
|
-
// before touching the filesystem.
|
|
2092
|
-
if (outside(baseAbs, targetAbs))
|
|
2093
|
-
return null;
|
|
2094
|
-
// Real (symlink-resolved) gate: a symlink pointing out of the subtree
|
|
2095
|
-
// resolves to a real path outside `realBase` and is refused here.
|
|
2096
|
-
let realTarget;
|
|
2097
|
-
try {
|
|
2098
|
-
realTarget = realpathSync(targetAbs);
|
|
2099
|
-
}
|
|
2100
|
-
catch {
|
|
2101
|
-
return null;
|
|
2102
|
-
}
|
|
2103
|
-
if (outside(realBase, realTarget))
|
|
2104
|
-
return null;
|
|
2105
|
-
try {
|
|
2106
|
-
return readFileSync(realTarget, "utf8");
|
|
2107
|
-
}
|
|
2108
|
-
catch {
|
|
2109
|
-
return null;
|
|
2110
|
-
}
|
|
2111
|
-
};
|
|
2112
|
-
}
|
|
2113
|
-
// `geml check <file>` — validate only: diagnostics + exit code, no document
|
|
2114
|
-
// dump (cheap for agents). `--json` prints the diagnostics array for machines.
|
|
2115
|
-
function runCheck(args) {
|
|
2116
|
-
const json = args.includes("--json");
|
|
2117
|
-
const root = flag(args, "--root");
|
|
2118
|
-
const file = args.find((a) => a === "-" || (!a.startsWith("-") && a !== root));
|
|
2119
|
-
if (!file)
|
|
2120
|
-
fail(SUBHELP.check);
|
|
2121
|
-
// A mistyped --root must be a usage error (exit 2), not a wall of misleading
|
|
2122
|
-
// "cannot resolve document" errors from a resolver confined to nothing.
|
|
2123
|
-
if (root !== undefined) {
|
|
2124
|
-
let isDir = false;
|
|
2125
|
-
try {
|
|
2126
|
-
isDir = statSync(root).isDirectory();
|
|
2127
|
-
}
|
|
2128
|
-
catch { /* missing -> not a dir */ }
|
|
2129
|
-
if (!isDir)
|
|
2130
|
-
fail(`--root ${root} is not a directory`);
|
|
2131
|
-
}
|
|
2132
|
-
const doc = parse(readInput(file), { resolveDoc: resolverFor(file, root), self: file === "-" ? undefined : basename(file) });
|
|
2133
|
-
if (json) {
|
|
2134
|
-
console.log(JSON.stringify(doc.diagnostics, null, 2));
|
|
2135
|
-
}
|
|
2136
|
-
else {
|
|
2137
|
-
for (const d of doc.diagnostics)
|
|
2138
|
-
console.error(`${d.severity}: ${d.message} (line ${d.line})`);
|
|
2139
|
-
const errs = doc.diagnostics.filter((d) => d.severity === "error").length;
|
|
2140
|
-
const warns = doc.diagnostics.filter((d) => d.severity === "warning").length;
|
|
2141
|
-
console.error(errs || warns ? `${errs} error(s), ${warns} warning(s)` : "ok: no diagnostics");
|
|
2142
|
-
}
|
|
2143
|
-
if (doc.diagnostics.some((d) => d.severity === "error"))
|
|
2144
|
-
process.exit(1);
|
|
2145
|
-
}
|
|
2146
|
-
// Map a thrown error from the history layer to a clean one-line message —
|
|
2147
|
-
// never a raw node:fs stack trace, and without leaking the absolute path the
|
|
2148
|
-
// runtime resolved (we report the relative path the user actually passed).
|
|
2149
|
-
function historyError(e, file, historyPath) {
|
|
2150
|
-
const err = e;
|
|
2151
|
-
if (err?.code === "ENOENT") {
|
|
2152
|
-
const p = err.path ?? "";
|
|
2153
|
-
if (p.endsWith(basename(historyPath)))
|
|
2154
|
-
return `cannot read history ${historyPath}`;
|
|
2155
|
-
return `cannot read ${file}`;
|
|
2156
|
-
}
|
|
2157
|
-
return err?.message ?? String(e);
|
|
2158
|
-
}
|
|
2159
|
-
// Subcommand, file and revision, read positionally around the options —
|
|
2160
|
-
// `--history <path>` and `-m <msg>` may sit anywhere, and the old args[0..2]
|
|
2161
|
-
// indexing read `--history` itself as the file.
|
|
2162
|
-
//
|
|
2163
|
-
// The generic `positionals()` cannot be reused: it drops every `-`-leading token,
|
|
2164
|
-
// and a revision selector `-N` LOOKS exactly like a flag. That is the whole point
|
|
2165
|
-
// of the first column `history get` prints, so `-N` is admitted and every other
|
|
2166
|
-
// `-`-leading token is treated as an option.
|
|
2167
|
-
function historyPositionals(args) {
|
|
2168
|
-
const out = [];
|
|
2169
|
-
for (let i = 0; i < args.length; i++) {
|
|
2170
|
-
const a = args[i];
|
|
2171
|
-
if (a === "--history" || a === "-m" || a === "--message") {
|
|
2172
|
-
i++;
|
|
2173
|
-
continue;
|
|
2174
|
-
} // flag AND its value
|
|
2175
|
-
if (a.startsWith("-") && !/^-\d+$/.test(a))
|
|
2176
|
-
continue; // --json, --force, …
|
|
2177
|
-
out.push(a);
|
|
2178
|
-
}
|
|
2179
|
-
return out;
|
|
2180
|
-
}
|
|
2181
|
-
function runHistory(args) {
|
|
2182
|
-
const [sub, file, rev, ...extra] = historyPositionals(args);
|
|
2183
|
-
if (!sub || !file)
|
|
2184
|
-
fail(SUBHELP.history);
|
|
2185
|
-
const historyPath = flag(args, "--history") ?? historyPathFor(file);
|
|
2186
|
-
const json = args.includes("--json");
|
|
2187
|
-
try {
|
|
2188
|
-
if (sub === "save") {
|
|
2189
|
-
// design §3.1/§9-Q4: `--author` and `--at` were withdrawn from the CLI (nothing
|
|
2190
|
-
// outside tests ever passed either). Refusing beats ignoring for the same
|
|
2191
|
-
// reason the retired verbs above refuse: a silently dropped `--author
|
|
2192
|
-
// alice` discards precisely the value the caller went out of their way to
|
|
2193
|
-
// type. Both stay on the library API (save({ author, at })).
|
|
2194
|
-
for (const gone of ["--author", "--at"]) {
|
|
2195
|
-
if (args.some((a) => a === gone || a.startsWith(`${gone}=`))) {
|
|
2196
|
-
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.)`);
|
|
2197
|
-
}
|
|
2198
|
-
}
|
|
2199
|
-
// design §3.1: an empty save is a NO-OP. `save` is the one non-idempotent verb,
|
|
2200
|
-
// so an agent retrying a save it is unsure landed must not lengthen the
|
|
2201
|
-
// chain by a revision with no ops. `geml mcp` already gated its
|
|
2202
|
-
// pre-write snapshot on this exact predicate (mcp.ts snapshot()); this is
|
|
2203
|
-
// the same `isCurrent()`, not a second hash comparison.
|
|
2204
|
-
if (existsSync(historyPath) && isCurrent(historyPath, file)) {
|
|
2205
|
-
console.log(`already saved as ${listRevisions(historyPath)[0].id} (no changes)`);
|
|
2206
|
-
return;
|
|
2207
|
-
}
|
|
2208
|
-
const r = save({
|
|
2209
|
-
gemlPath: file,
|
|
2210
|
-
historyPath,
|
|
2211
|
-
summary: flag(args, "-m") ?? flag(args, "--message") ?? "",
|
|
2212
|
-
});
|
|
2213
|
-
console.log(`saved ${r.id}`);
|
|
2214
|
-
}
|
|
2215
|
-
else if (sub === "get") {
|
|
2216
|
-
// Three tiers, split by how many addresses were given — the same rule the
|
|
2217
|
-
// top-level `geml get` follows (design §1.2). Tier 2 takes a BLOCK
|
|
2218
|
-
// selector inside the revision and reuses the top-level grammar verbatim
|
|
2219
|
-
// (§10.1): a revision rebuilt is just a document's text, so there is no
|
|
2220
|
-
// new algorithm here, and the two selector namespaces cannot collide —
|
|
2221
|
-
// position is fixed and the lexis does not overlap (§10.2).
|
|
2222
|
-
if (extra.length > 1) {
|
|
2223
|
-
fail(`history get takes ONE revision selector and ONE block selector; got ${extra.length + 1} positionals after the file`, 2);
|
|
2224
|
-
}
|
|
2225
|
-
if (rev === undefined) {
|
|
2226
|
-
// Newest-first, with each row's selector in the first column (`0` for
|
|
2227
|
-
// the tip, then `-1`, `-2`, …) so the output is copy-paste into `get`,
|
|
2228
|
-
// `restore` and `revert --rev` alike.
|
|
2229
|
-
const revs = listRevisions(historyPath);
|
|
2230
|
-
if (json) {
|
|
2231
|
-
console.log(JSON.stringify(revs, null, 2));
|
|
2232
|
-
}
|
|
2233
|
-
else {
|
|
2234
|
-
for (const r of revs) {
|
|
2235
|
-
const sel = r.current ? "0" : `-${r.offset}`;
|
|
2236
|
-
console.log(`${sel.padEnd(7)} ${r.id} ${r.author ?? "-"} ${r.summary ?? ""}`.replace(/\s+$/, ""));
|
|
2237
|
-
}
|
|
2238
|
-
}
|
|
2239
|
-
}
|
|
2240
|
-
else {
|
|
2241
|
-
// resolveContent() routes through the ONE selector grammar
|
|
2242
|
-
// (resolveRevision) that the list above prints — see its comment for
|
|
2243
|
-
// what happened the last time that was written twice.
|
|
2244
|
-
const { id, text } = resolveContent(historyPath, rev);
|
|
2245
|
-
const blockSel = extra[0];
|
|
2246
|
-
if (blockSel === undefined) {
|
|
2247
|
-
if (json)
|
|
2248
|
-
console.log(JSON.stringify({ id, text }, null, 2));
|
|
2249
|
-
else
|
|
2250
|
-
process.stdout.write(text);
|
|
2251
|
-
}
|
|
2252
|
-
else {
|
|
2253
|
-
// Tier 2 (§10.1). Cardinality and the flag rules are the top-level
|
|
2254
|
-
// ones, checked here because this tier has its own argument list.
|
|
2255
|
-
const headOnly = args.includes("--head");
|
|
2256
|
-
const bodyOnly = args.includes("--body");
|
|
2257
|
-
if (headOnly && bodyOnly)
|
|
2258
|
-
fail("--head and --body are mutually exclusive", 2);
|
|
2259
|
-
if (json && (headOnly || bodyOnly)) {
|
|
2260
|
-
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);
|
|
2261
|
-
}
|
|
2262
|
-
const { units, all } = selectUnits(text, file, blockSel, `revision ${id}`);
|
|
2263
|
-
if (json) {
|
|
2264
|
-
// §3.2's tier table: the revision id travels with the block, so the
|
|
2265
|
-
// caller can tell WHICH version it is holding.
|
|
2266
|
-
const nodes = units.map((u) => unitNode(text, file, u, all));
|
|
2267
|
-
console.log(JSON.stringify({ id, block: units.length === 1 ? nodes[0] : nodes }, null, 2));
|
|
2268
|
-
}
|
|
2269
|
-
else {
|
|
2270
|
-
if (units.length > 1)
|
|
2271
|
-
reportMatches(units[0].type ?? "", units);
|
|
2272
|
-
for (const u of units)
|
|
2273
|
-
process.stdout.write(sliceUnit(text, u.span, headOnly, bodyOnly));
|
|
2274
|
-
}
|
|
2275
|
-
}
|
|
2276
|
-
}
|
|
2277
|
-
}
|
|
2278
|
-
else if (sub === "restore") {
|
|
2279
|
-
if (!rev)
|
|
2280
|
-
fail("usage: geml history restore <file.geml> <revision> [--force]");
|
|
2281
|
-
restore({ historyPath, gemlPath: file, revision: rev, write: true, force: args.includes("--force") });
|
|
2282
|
-
console.log(`restored ${file} to ${rev}`);
|
|
2283
|
-
}
|
|
2284
|
-
else if (sub === "verify") {
|
|
2285
|
-
const res = verify(historyPath, file);
|
|
2286
|
-
for (const e of res.errors)
|
|
2287
|
-
console.error(`error: ${e}`);
|
|
2288
|
-
for (const w of res.warnings)
|
|
2289
|
-
console.error(`warning: ${w}`);
|
|
2290
|
-
console.log(`verify: ${res.ok ? "OK" : "FAILED"} (${res.checked} revisions reconstructed & hashed)`);
|
|
2291
|
-
if (!res.ok)
|
|
2292
|
-
process.exit(1);
|
|
2293
|
-
}
|
|
2294
|
-
else {
|
|
2295
|
-
fail(`unknown history subcommand: ${sub}. Run 'geml --help'.`);
|
|
2296
|
-
}
|
|
2297
|
-
}
|
|
2298
|
-
catch (e) {
|
|
2299
|
-
fail(historyError(e, file, historyPath));
|
|
2300
|
-
}
|
|
2301
|
-
}
|
|
2302
|
-
function runTransform(argv) {
|
|
2303
|
-
const out = flag(argv, "-o") ?? flag(argv, "--out");
|
|
2304
|
-
const fromRaw = flag(argv, "--from");
|
|
2305
|
-
const toRaw = flag(argv, "--to");
|
|
2306
|
-
// `--to html --fragment`: body-only markup for embedding in an existing
|
|
2307
|
-
// layout (library parity: RenderOptions.fragment). Consumed here so it can
|
|
2308
|
-
// be rejected on any other target — a discarded flag is a silent lie.
|
|
2309
|
-
const fragIdx = argv.indexOf("--fragment");
|
|
2310
|
-
const fragment = fragIdx >= 0;
|
|
2311
|
-
if (fragment)
|
|
2312
|
-
argv.splice(fragIdx, 1);
|
|
2313
|
-
// Same `--root` as `check`, and for the same reason: cross-document resolution is
|
|
2314
|
-
// fail-closed at the document's own directory, so a reference that climbs out of
|
|
2315
|
-
// it needs the tree's root named. Without this the transform silently ignored the
|
|
2316
|
-
// flag — a document whose embeds `check --root .` validated still rendered with
|
|
2317
|
-
// every one of them unresolved, which reads as "transclusion does not work".
|
|
2318
|
-
const root = flag(argv, "--root");
|
|
2319
|
-
if (argv.includes("--root") && root === undefined)
|
|
2320
|
-
fail("--root needs a directory", 2);
|
|
2321
|
-
const [file] = positionals(argv, ["-o", "--out", "--from", "--to", "--root"]);
|
|
2322
|
-
if (!file)
|
|
2323
|
-
fail("no input file (use '-' to read from stdin)", 2);
|
|
2324
|
-
// A bare `--to`/`--from` (no following value) is a mistyped flag, not a
|
|
2325
|
-
// silent fall-through to the default — flag() would return undefined and we
|
|
2326
|
-
// must not quietly ignore it.
|
|
2327
|
-
if (argv.includes("--from") && fromRaw === undefined)
|
|
2328
|
-
fail("--from needs a format (geml | md | json)", 2);
|
|
2329
|
-
if (argv.includes("--to") && toRaw === undefined)
|
|
2330
|
-
fail("--to needs a format (json | html | md | geml)", 2);
|
|
2331
|
-
// Input format: an explicit --from wins (for any input, file or stdin), else
|
|
2332
|
-
// the file extension, else GEML (covers .geml, unknown extensions, and stdin).
|
|
2333
|
-
let inFmt;
|
|
2334
|
-
if (fromRaw !== undefined) {
|
|
2335
|
-
if (fromRaw !== "geml" && fromRaw !== "md" && fromRaw !== "json") {
|
|
2336
|
-
fail(`--from: unknown input format '${fromRaw}' (want geml | md | json)`, 2);
|
|
2337
|
-
}
|
|
2338
|
-
inFmt = fromRaw;
|
|
2339
|
-
}
|
|
2340
|
-
else if (/\.(md|markdown)$/i.test(file)) {
|
|
2341
|
-
inFmt = "md";
|
|
2342
|
-
}
|
|
2343
|
-
else if (/\.json$/i.test(file)) {
|
|
2344
|
-
inFmt = "json";
|
|
2345
|
-
}
|
|
2346
|
-
else {
|
|
2347
|
-
inFmt = "geml";
|
|
2348
|
-
}
|
|
2349
|
-
// Output format: an explicit --to wins, else md input -> geml, geml -> json.
|
|
2350
|
-
let outFmt;
|
|
2351
|
-
if (toRaw !== undefined) {
|
|
2352
|
-
if (toRaw !== "json" && toRaw !== "html" && toRaw !== "md" && toRaw !== "geml") {
|
|
2353
|
-
fail(`--to: unknown output format '${toRaw}' (want json | html | md | geml)`, 2);
|
|
2354
|
-
}
|
|
2355
|
-
outFmt = toRaw;
|
|
2356
|
-
}
|
|
2357
|
-
else {
|
|
2358
|
-
outFmt = inFmt === "geml" ? "json" : "geml"; // geml->json; md/json->geml
|
|
2359
|
-
}
|
|
2360
|
-
if (fragment && outFmt !== "html")
|
|
2361
|
-
fail("--fragment only applies to --to html", 2);
|
|
2362
|
-
const src = readInput(file);
|
|
2363
|
-
// md -> geml is a direct projection, not a parse/serialize round-trip: emit
|
|
2364
|
-
// the converter's GEML verbatim (the old `convert`; no diagnostics to raise).
|
|
2365
|
-
if (inFmt === "md" && outFmt === "geml") {
|
|
2366
|
-
const { geml, notes } = mdToGeml(src);
|
|
2367
|
-
writeOut(geml, out);
|
|
2368
|
-
for (const n of notes)
|
|
2369
|
-
console.error(`note: ${n}`);
|
|
2370
|
-
return;
|
|
2371
|
-
}
|
|
2372
|
-
// Otherwise load a document — a md input is converted to GEML first — and
|
|
2373
|
-
// project it to the target.
|
|
2374
|
-
let notes = [];
|
|
2375
|
-
let doc;
|
|
2376
|
-
if (inFmt === "json") {
|
|
2377
|
-
doc = loadModelJson(src, file); // the inverse of `--to json`
|
|
2378
|
-
}
|
|
2379
|
-
else if (inFmt === "md") {
|
|
2380
|
-
const conv = mdToGeml(src);
|
|
2381
|
-
notes = conv.notes;
|
|
2382
|
-
doc = parse(conv.geml, { resolveDoc: resolverFor(file, root), self: file === "-" ? undefined : basename(file) });
|
|
2383
|
-
}
|
|
2384
|
-
else {
|
|
2385
|
-
doc = parse(src, { resolveDoc: resolverFor(file, root), self: file === "-" ? undefined : basename(file) });
|
|
2386
|
-
}
|
|
2387
|
-
let output;
|
|
2388
|
-
switch (outFmt) {
|
|
2389
|
-
case "json":
|
|
2390
|
-
output = JSON.stringify(doc, null, 2) + "\n"; // == the former bare parse
|
|
2391
|
-
break;
|
|
2392
|
-
case "geml":
|
|
2393
|
-
output = serialize(doc); // == the former `fmt`
|
|
2394
|
-
break;
|
|
2395
|
-
case "html":
|
|
2396
|
-
output = renderHtml(doc, {
|
|
2397
|
-
source: file === "-" ? "stdin" : basename(file),
|
|
2398
|
-
fragment,
|
|
2399
|
-
// geml-code-graph embeds load + parse sibling codemap docs on demand.
|
|
2400
|
-
loadDoc: resolverFor(file, root),
|
|
2401
|
-
parseDoc: (s) => parse(s, { resolveDoc: resolverFor(file, root) }),
|
|
2402
|
-
});
|
|
2403
|
-
break;
|
|
2404
|
-
case "md": {
|
|
2405
|
-
const r = gemlToMd(doc); // == the former `export`
|
|
2406
|
-
notes = notes.concat(r.notes);
|
|
2407
|
-
output = r.md;
|
|
2408
|
-
break;
|
|
2409
|
-
}
|
|
2410
|
-
}
|
|
2411
|
-
writeOut(output, out);
|
|
2412
|
-
for (const n of notes)
|
|
2413
|
-
console.error(`note: ${n}`);
|
|
2414
|
-
for (const d of doc.diagnostics)
|
|
2415
|
-
console.error(`${d.severity}: ${d.message} (line ${d.line})`);
|
|
2416
|
-
if (doc.diagnostics.some((d) => d.severity === "error"))
|
|
2417
|
-
process.exit(1);
|
|
2418
|
-
}
|
|
2419
|
-
// Load a document-model JSON (the exact output of `--to json`) back into a
|
|
2420
|
-
// Document, so `--from json --to geml` is the inverse of a prior `--to json`.
|
|
2421
|
-
// The model is trusted as-is — no re-parse — so a clean round-trip is byte-stable
|
|
2422
|
-
// with `--to geml`. Anything that is not a document model is refused, and any
|
|
2423
|
-
// carried diagnostics are preserved (so a broken doc's JSON stays flagged).
|
|
2424
|
-
function loadModelJson(src, file) {
|
|
2425
|
-
let obj;
|
|
2426
|
-
try {
|
|
2427
|
-
obj = JSON.parse(src);
|
|
2428
|
-
}
|
|
2429
|
-
catch (e) {
|
|
2430
|
-
fail(`--from json: ${file === "-" ? "stdin" : file} is not valid JSON (${e.message})`, 1);
|
|
2431
|
-
}
|
|
2432
|
-
const d = obj;
|
|
2433
|
-
if (!d || typeof d !== "object" || d.kind !== "document" || !Array.isArray(d.children)) {
|
|
2434
|
-
fail(`--from json: not a GEML document-model JSON (expected {"kind":"document","children":[…]})`, 1);
|
|
2435
|
-
}
|
|
2436
|
-
const doc = d;
|
|
2437
|
-
if (!Array.isArray(doc.diagnostics))
|
|
2438
|
-
doc.diagnostics = [];
|
|
2439
|
-
return doc;
|
|
2440
|
-
}
|
|
2441
|
-
// Write to `-o out` (with a `wrote` note on stderr) or to stdout.
|
|
2442
|
-
function writeOut(text, out) {
|
|
2443
|
-
if (out) {
|
|
2444
|
-
writeFileSync(out, text);
|
|
2445
|
-
console.error(`wrote ${out}`);
|
|
2446
|
-
}
|
|
2447
|
-
else
|
|
2448
|
-
process.stdout.write(text);
|
|
2449
|
-
}
|
|
2450
|
-
// Output-target rule shared by the MUTATION verbs (set, and — soon — add,
|
|
2451
|
-
// delete, rename, revert): a real file input with no `-o` is edited IN PLACE
|
|
2452
|
-
// (it's the obvious target, and it's what lets an agent chain edits without
|
|
2453
|
-
// re-reading a path back out of stdout); stdin (`file === "-"`) has no such
|
|
2454
|
-
// target, so it falls back to stdout. `-o` always wins when given: `-o -`
|
|
2455
|
-
// explicitly requests stdout (even for a file input), `-o <path>` writes
|
|
2456
|
-
// there. Every write announces itself with `wrote <path>` on stderr; stdout
|
|
2457
|
-
// stays reserved for the document bytes so it's still pipeable.
|
|
2458
|
-
function resolveOutTarget(file, oFlag) {
|
|
2459
|
-
const toFile = (path) => ({
|
|
2460
|
-
write(text) { writeFileSync(path, text); console.error(`wrote ${path}`); },
|
|
2461
|
-
});
|
|
2462
|
-
const toStdout = { write(text) { process.stdout.write(text); } };
|
|
2463
|
-
if (oFlag === "-")
|
|
2464
|
-
return toStdout;
|
|
2465
|
-
if (oFlag !== undefined)
|
|
2466
|
-
return toFile(oFlag);
|
|
2467
|
-
if (file === "-")
|
|
2468
|
-
return toStdout;
|
|
2469
|
-
return toFile(file);
|
|
2470
|
-
}
|
|
2471
|
-
// Positional args (a file, an id) are the non-flag tokens that aren't the value
|
|
2472
|
-
// of a value-taking flag. `-` (stdin) is a positional, not a flag. An id may be
|
|
2473
|
-
// written `#id` or `id`; a leading `-` never begins an id, so this stays
|
|
2474
|
-
// unambiguous. `valued` lists the flags that consume the following token.
|
|
2475
|
-
function positionals(args, valued) {
|
|
2476
|
-
const out = [];
|
|
2477
|
-
for (let i = 0; i < args.length; i++) {
|
|
2478
|
-
const a = args[i];
|
|
2479
|
-
if (valued.includes(a)) {
|
|
2480
|
-
i++;
|
|
2481
|
-
continue;
|
|
2482
|
-
} // skip the flag *and* its value
|
|
2483
|
-
if (a === "-") {
|
|
2484
|
-
out.push(a);
|
|
2485
|
-
continue;
|
|
2486
|
-
}
|
|
2487
|
-
if (a.startsWith("-"))
|
|
2488
|
-
continue; // a bare flag (e.g. --json)
|
|
2489
|
-
out.push(a);
|
|
2490
|
-
}
|
|
2491
|
-
return out;
|
|
2492
|
-
}
|
|
2493
|
-
// Resolve a block SELECTOR to an id. Three spellings address the same block:
|
|
2494
|
-
//
|
|
2495
|
-
// `#intro` / `intro` the id — the CANONICAL address
|
|
2496
|
-
// `## Getting Started` the heading LINE, copied out of the document
|
|
2497
|
-
// `##Getting Started` …the space after the `#` run is optional
|
|
2498
|
-
//
|
|
2499
|
-
// Why more than one form: the id is what `[[#id]]` references, codemap tables
|
|
2500
|
-
// and URL fragments (§0.6) all carry, so it must stay accepted verbatim — an id
|
|
2501
|
-
// copied out of a reference or out of `geml get <file>` has to work. But a
|
|
2502
|
-
// heading's id is AUTO-DERIVED from its text (`## API 设计 (v1)` → `#api-设计-v1`),
|
|
2503
|
-
// and nobody can be expected to hand-derive that slug for a heading they can
|
|
2504
|
-
// read on screen. So the heading line itself is accepted too.
|
|
2505
|
-
//
|
|
2506
|
-
// Resolution order, first match wins:
|
|
2507
|
-
// 1. the id, exactly — a pasted id is NEVER reinterpreted as prose. (When a
|
|
2508
|
-
// heading's TEXT happens to equal another block's ID, the id wins.)
|
|
2509
|
-
// 2. the exact heading LINE: `#` count AND text both match.
|
|
2510
|
-
// 3. the text alone, at any level — a heading remembered at the wrong depth
|
|
2511
|
-
// still resolves while its text is unique.
|
|
2512
|
-
// 4. text shared by several headings: the `#` count picks one, or the
|
|
2513
|
-
// candidates are listed. Never guessed at.
|
|
2514
|
-
function resolveSelector(source, file, raw) {
|
|
2515
|
-
const bare = raw.replace(/^#/, "");
|
|
2516
|
-
const m = /^(#{1,6})[ \t]*(.+?)[ \t]*$/.exec(raw);
|
|
2517
|
-
if (!m)
|
|
2518
|
-
return bare; // not a `#`-run form: an id, verbatim
|
|
2519
|
-
// 1. The id is canonical and always wins. Checked without a parse, so the
|
|
2520
|
-
// common `get #id` stays a byte-slice on a document with diagnostics.
|
|
2521
|
-
if (blockSpans(source).has(bare))
|
|
2522
|
-
return bare;
|
|
2523
|
-
const level = m[1].length;
|
|
2524
|
-
const want = m[2];
|
|
2525
|
-
const doc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
|
|
2526
|
-
const heads = doc.ids.flatMap((id) => {
|
|
2527
|
-
const site = findBlockSite(doc.children, id);
|
|
2528
|
-
const b = site?.siblings[site.index];
|
|
2529
|
-
return b?.kind === "heading" ? [{ id, level: b.level, text: b.text.trim() }] : [];
|
|
2530
|
-
});
|
|
2531
|
-
// 2. exact line — what the caller actually typed.
|
|
2532
|
-
const line = heads.find((h) => h.level === level && h.text === want);
|
|
2533
|
-
if (line)
|
|
2534
|
-
return line.id;
|
|
2535
|
-
// 3. the text alone (exact, then case-insensitive).
|
|
2536
|
-
let byText = heads.filter((h) => h.text === want);
|
|
2537
|
-
if (!byText.length) {
|
|
2538
|
-
const lc = want.toLocaleLowerCase();
|
|
2539
|
-
byText = heads.filter((h) => h.text.toLocaleLowerCase() === lc);
|
|
2540
|
-
}
|
|
2541
|
-
if (byText.length === 1)
|
|
2542
|
-
return byText[0].id;
|
|
2543
|
-
// 4. shared text: the level disambiguates, else show the candidates.
|
|
2544
|
-
if (byText.length > 1) {
|
|
2545
|
-
const atLevel = byText.filter((h) => h.level === level);
|
|
2546
|
-
if (atLevel.length === 1)
|
|
2547
|
-
return atLevel[0].id;
|
|
2548
|
-
const list = byText.map((h) => ` #${h.id} (h${h.level})`).join("\n");
|
|
2549
|
-
fail(`\`${want}\` matches ${byText.length} headings — address one by its id:\n${list}`, 1);
|
|
2550
|
-
}
|
|
2551
|
-
// Nothing matched. A lone `#` with no whitespace was almost certainly meant as
|
|
2552
|
-
// an id, so hand it back and let the caller's own `no block with id` error
|
|
2553
|
-
// stand — the precise diagnosis for a typo'd id. Only a heading-SHAPED
|
|
2554
|
-
// selector gets the heading-flavoured message.
|
|
2555
|
-
if (level === 1 && !/\s/.test(bare))
|
|
2556
|
-
return bare;
|
|
2557
|
-
fail(`no id or heading matches \`${raw}\` — run \`geml get ${file === "-" ? "-" : file}\` to list every addressable id`, 1);
|
|
2558
|
-
}
|
|
2559
|
-
// `geml get <file>` with no id: list every addressable id — the document's
|
|
2560
|
-
// table of contents. Default output is one id per line with its kind (and, for
|
|
2561
|
-
// a heading, its level and text); `--json` is a machine-readable array so an
|
|
2562
|
-
// agent can pick its next `get #id` target. Ids are listed in document order
|
|
2563
|
-
// (the registration order parse() records), covering the same set `get #id`
|
|
2564
|
-
// resolves against: typed blocks and headings. A `[^id]` reference names one
|
|
2565
|
-
// of those (§5.2); the `[^id]: text` definition line was withdrawn.
|
|
2566
|
-
function listIds(source, file, json) {
|
|
2567
|
-
const where = file === "-" ? "stdin" : file;
|
|
2568
|
-
const all = addressedUnits(source);
|
|
2569
|
-
const doc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
|
|
2570
|
-
const rows = all.map((a) => {
|
|
2571
|
-
const u = a.unit;
|
|
2572
|
-
const row = {
|
|
2573
|
-
address: shortestAddress(a, all),
|
|
2574
|
-
kind: u.kind === "block" ? u.type ?? "block" : u.kind,
|
|
2575
|
-
lines: [u.span.start + 1, u.span.end],
|
|
2576
|
-
};
|
|
2577
|
-
// §6.3: EVERY id-less block is flagged, including one whose address works
|
|
2578
|
-
// only because its type happens to be unique (`=== meta`) — that it has no
|
|
2579
|
-
// id yet is precisely the fact you might want to act on (§5.2).
|
|
2580
|
-
if (u.id === undefined)
|
|
2581
|
-
row.anon = true;
|
|
2582
|
-
else
|
|
2583
|
-
row.id = u.id;
|
|
2584
|
-
if (u.kind === "heading") {
|
|
2585
|
-
row.level = u.level;
|
|
2586
|
-
row.text = u.text;
|
|
2587
|
-
}
|
|
2588
|
-
// `.footnote` is authored, not synthesized (the `[^id]: text` definition
|
|
2589
|
-
// line was withdrawn) — but it still marks a block meant as a footnote.
|
|
2590
|
-
if (u.id !== undefined) {
|
|
2591
|
-
const site = findBlockSite(doc.children, u.id);
|
|
2592
|
-
const b = site?.siblings[site.index];
|
|
2593
|
-
if (b?.kind === "block" && b.classes.includes("footnote"))
|
|
2594
|
-
row.footnote = true;
|
|
2595
|
-
}
|
|
2596
|
-
return row;
|
|
2597
|
-
});
|
|
2598
|
-
// §6.6: the empty document is a legitimate empty answer to "list everything",
|
|
2599
|
-
// not a lookup failure — exit 0, and `--json` prints `[]` so a `| jq length`
|
|
2600
|
-
// over a prose-only document does not blow up.
|
|
2601
|
-
if (json) {
|
|
2602
|
-
console.log(JSON.stringify(rows, null, 2));
|
|
2603
|
-
return;
|
|
2604
|
-
}
|
|
2605
|
-
if (rows.length === 0) {
|
|
2606
|
-
console.error(`no addressable blocks in ${where}`);
|
|
2607
|
-
return;
|
|
2608
|
-
}
|
|
2609
|
-
const addrW = Math.max(...rows.map((r) => r.address.length));
|
|
2610
|
-
const kindW = Math.max(...rows.map((r) => r.kind.length));
|
|
2611
|
-
for (const r of rows) {
|
|
2612
|
-
const mark = r.kind === "heading" ? `h${r.level}` : r.anon ? "anon" : "";
|
|
2613
|
-
const tail = r.kind === "heading" ? r.text ?? "" : `L${r.lines[0]}-${r.lines[1]}`;
|
|
2614
|
-
const line = `${r.address.padEnd(addrW)} ${r.kind.padEnd(kindW)} ${mark.padEnd(4)} ${tail}`
|
|
2615
|
-
+ (r.footnote ? " footnote" : "");
|
|
2616
|
-
console.log(line.replace(/\s+$/, ""));
|
|
2617
|
-
}
|
|
2618
|
-
}
|
|
2619
|
-
// `geml get <file.geml|-> #id [--json]` — print ONE block, addressed by id,
|
|
2620
|
-
// without loading the rest of the document into context. Default output is the
|
|
2621
|
-
// block's exact source bytes: a typed block's full `=== … ===` span, a
|
|
2622
|
-
// footnote's line, or — for a heading — its whole SECTION (heading line through
|
|
2623
|
-
// the line before the next same-or-higher heading). `--json` covers the same
|
|
2624
|
-
// content: a block/footnote id prints its document-model node; a heading id
|
|
2625
|
-
// prints a section envelope `{kind:"section", id, level, blocks:[heading,
|
|
2626
|
-
// …siblings up to the boundary]}`.
|
|
2627
|
-
// `geml get <file> '=== <type>'` — address a block by its TYPE. One match is
|
|
2628
|
-
// the block itself; several are LISTED with their line ranges rather than
|
|
2629
|
-
// guessed between, so a document with three notes answers "which one" instead
|
|
2630
|
-
// of failing. The uniqueness that makes `=== meta` work is checked here, at
|
|
2631
|
-
// resolve time — nothing in the format has to promise a document holds only one.
|
|
2632
|
-
// Every block of `type` in document order, nested flow children included —
|
|
2633
|
-
// exactly the span scan's reach and order, so the k-th scan match and the k-th
|
|
2634
|
-
// model node are the same block. That correspondence is what lets an ANONYMOUS
|
|
2635
|
-
// block's `--json` find its node without an id to look it up by.
|
|
2636
|
-
function blocksOfType(blocks, type) {
|
|
2637
|
-
const hits = [];
|
|
2638
|
-
const walk = (list) => {
|
|
2639
|
-
for (const b of list) {
|
|
2640
|
-
if (b.kind === "block") {
|
|
2641
|
-
if (b.type === type)
|
|
2642
|
-
hits.push(b);
|
|
2643
|
-
if (b.children)
|
|
2644
|
-
walk(b.children);
|
|
2645
|
-
}
|
|
2646
|
-
}
|
|
2647
|
-
};
|
|
2648
|
-
walk(blocks);
|
|
2649
|
-
return hits;
|
|
2650
|
-
}
|
|
2651
|
-
// A unit's index among the units of its own type, for the positional lookup above.
|
|
2652
|
-
function typeIndex(all, u) {
|
|
2653
|
-
return all.filter((a) => a.unit.type === u.type).findIndex((a) => a.unit === u);
|
|
2654
|
-
}
|
|
2655
|
-
// Resolve a NON-list selector to the units it matches, or fail with the reason.
|
|
2656
|
-
// `where` names the haystack for the error messages — a file for `geml get`, a
|
|
2657
|
-
// revision for `geml history get`'s tier 2. Shared by both so the one selector
|
|
2658
|
-
// grammar has one implementation: history's design §10.1 asks for exactly this,
|
|
2659
|
-
// and its §3.2 records what happened the last time a selector grammar was
|
|
2660
|
-
// written twice (the printed selectors stopped being readable back).
|
|
2661
|
-
function selectUnits(source, file, rawSel, where) {
|
|
2662
|
-
const sel = parseSelector(rawSel, (braces) => parseAttrs(braces).id);
|
|
2663
|
-
// Callers handle the empty selector themselves (list for `get`, usage error
|
|
2664
|
-
// for `set`); reaching here with one is a caller bug surfaced as usage.
|
|
2665
|
-
if (sel.form === "list")
|
|
2666
|
-
fail(`no selector given — run \`geml get ${where}\` to list addressable blocks`, 2);
|
|
2667
|
-
if (sel.form === "attr") {
|
|
2668
|
-
// §7: the wording says "not implemented yet", not "braces are meaningless" —
|
|
2669
|
-
// §2 declares attribute keys as part of the model, so implementing them
|
|
2670
|
-
// later fills in a declared slot rather than reversing this message.
|
|
2671
|
-
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);
|
|
2672
|
-
}
|
|
2673
|
-
const all = addressedUnits(source);
|
|
2674
|
-
if (sel.form === "content") {
|
|
2675
|
-
const hit = matchContent(sel, all);
|
|
2676
|
-
if (!hit.ok) {
|
|
2677
|
-
if (hit.why === "wrong-type") {
|
|
2678
|
-
// §3.3: the type prefix is a CHECK. Ignoring a wrong one would make it
|
|
2679
|
-
// a decoration that is allowed to lie, and would silently accept a
|
|
2680
|
-
// hand-edited address.
|
|
2681
|
-
fail(`\`@${sel.hex}\` addresses a \`${hit.found}\` block, not \`${sel.type}\` — drop the type prefix to address it by content alone`, 1);
|
|
2682
|
-
}
|
|
2683
|
-
const suffix = sel.nth ? `~${sel.nth}` : "";
|
|
2684
|
-
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);
|
|
2685
|
-
}
|
|
2686
|
-
return { units: [hit.unit], all };
|
|
2687
|
-
}
|
|
2688
|
-
if (sel.form === "type") {
|
|
2689
|
-
const hits = matchType(sel.type, all);
|
|
2690
|
-
if (!hits.length)
|
|
2691
|
-
fail(`no \`${sel.type}\` block in ${where}${discoveryHint(where)}`, 1);
|
|
2692
|
-
return { units: hits, all };
|
|
2693
|
-
}
|
|
2694
|
-
// `#id` / bare id / a pasted `## Heading` line — resolveSelector needs a parse
|
|
2695
|
-
// to match heading TEXT, so it stays the one path that reaches the model.
|
|
2696
|
-
const id = resolveSelector(source, file, sel.raw);
|
|
2697
|
-
const unit = all.find((a) => a.unit.id === id)?.unit;
|
|
2698
|
-
// Bare `no block with id \`x\`` — the phrasing every caller of a missing id
|
|
2699
|
-
// has always seen, and which `set`'s own tests pin. `where` is appended only
|
|
2700
|
-
// when it is NOT the file the caller already named (a revision), so the
|
|
2701
|
-
// common case reads the same as before this selector grammar existed.
|
|
2702
|
-
if (!unit)
|
|
2703
|
-
fail(`no block with id \`${id}\`${where.startsWith("revision ") ? ` in ${where}` : ""}`, 1);
|
|
2704
|
-
return { units: [unit], all };
|
|
2705
|
-
}
|
|
2706
|
-
// The document-model node for one unit; a heading yields its SECTION envelope,
|
|
2707
|
-
// so --json covers the same content as the raw span. `kind:"section"` lets a
|
|
2708
|
-
// consumer branch — every other unit yields the single node (the model is flat).
|
|
2709
|
-
function unitNode(source, file, unit, all) {
|
|
2710
|
-
const doc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
|
|
2711
|
-
if (unit.id !== undefined) {
|
|
2712
|
-
const site = findBlockSite(doc.children, unit.id);
|
|
2713
|
-
if (!site)
|
|
2714
|
-
fail(`no block with id \`${unit.id}\``, 1);
|
|
2715
|
-
const block = site.siblings[site.index];
|
|
2716
|
-
if (block.kind !== "heading")
|
|
2717
|
-
return block;
|
|
2718
|
-
const end = sectionEndIndex(site.siblings, site.index);
|
|
2719
|
-
return { kind: "section", id: block.id, level: block.level, blocks: site.siblings.slice(site.index, end) };
|
|
2720
|
-
}
|
|
2721
|
-
const node = blocksOfType(doc.children, unit.type ?? "")[typeIndex(all, unit)];
|
|
2722
|
-
if (!node)
|
|
2723
|
-
fail(`could not locate the \`${unit.type}\` block in the document model`, 1);
|
|
2724
|
-
return node;
|
|
2725
|
-
}
|
|
2726
|
-
// stderr line for an N-match selector: content stays on stdout, so a redirect
|
|
2727
|
-
// captures document bytes only, and the caller still learns how many it got (§5).
|
|
2728
|
-
function reportMatches(type, units) {
|
|
2729
|
-
const at = units.map((u) => `L${u.span.start + 1}-${u.span.end}${u.id ? ` #${u.id}` : ""}`).join(" · ");
|
|
2730
|
-
console.error(`${units.length} \`${type}\` blocks (${at})`);
|
|
2731
|
-
}
|
|
2732
|
-
// `geml get <file.geml|-> [<selector>] [--head|--body] [--json]` — read the
|
|
2733
|
-
// document's addressable structure, or one/several blocks out of it.
|
|
2734
|
-
//
|
|
2735
|
-
// The selector is a FILTER (§2 of the get/set selector design): no selector
|
|
2736
|
-
// LISTS every addressable block with its shortest unique address; `#id` /
|
|
2737
|
-
// `## Heading` / `=== type@<hex>` name at most one; `=== type` matches 0..N.
|
|
2738
|
-
// Cardinality is uniform (§5): 0 → exit 1, 1 → the content, N → N contents in
|
|
2739
|
-
// document order with the count on stderr. `--head`/`--body` narrow to one part
|
|
2740
|
-
// of each match, and every flag combination that used to be half-honoured is
|
|
2741
|
-
// now a usage error (§7) — a discarded flag is a command that quietly did
|
|
2742
|
-
// something else.
|
|
2743
|
-
function runGet(args) {
|
|
2744
|
-
const json = args.includes("--json");
|
|
2745
|
-
const headOnly = args.includes("--head");
|
|
2746
|
-
const bodyOnly = args.includes("--body");
|
|
2747
|
-
const view = args.includes("--view");
|
|
2748
|
-
const [file, rawSel] = positionals(args, ["--root"]);
|
|
2749
|
-
if (!file)
|
|
2750
|
-
fail(SUBHELP.get);
|
|
2751
|
-
if (headOnly && bodyOnly)
|
|
2752
|
-
fail("--head and --body are mutually exclusive", 2);
|
|
2753
|
-
if (json && (headOnly || bodyOnly)) {
|
|
2754
|
-
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);
|
|
2755
|
-
}
|
|
2756
|
-
// One read: stdin can only be consumed once, and the selector resolver needs
|
|
2757
|
-
// the same bytes the slice below works on.
|
|
2758
|
-
const source = readInput(file);
|
|
2759
|
-
const where = file === "-" ? "stdin" : file;
|
|
2760
|
-
const sel = parseSelector(rawSel, (braces) => parseAttrs(braces).id);
|
|
2761
|
-
if (sel.form === "list") {
|
|
2762
|
-
// §5.1: nothing here to narrow, and ignoring the flag would make
|
|
2763
|
-
// `get f --head` print byte-for-byte what `get f` prints.
|
|
2764
|
-
if (headOnly || bodyOnly) {
|
|
2765
|
-
fail(`${headOnly ? "--head" : "--body"} names part of ONE block, so it needs a selector — run \`geml get ${where}\` to list what to address`, 2);
|
|
2766
|
-
}
|
|
2767
|
-
listIds(source, file, json);
|
|
2768
|
-
return;
|
|
2769
|
-
}
|
|
2770
|
-
const { units, all } = selectUnits(source, file, rawSel, where);
|
|
2771
|
-
// The chain is composed with `/` — relJoinPath's rule, and `src=` values are
|
|
2772
|
-
// always `/`-separated — so normalize the PLATFORM path at this boundary. On
|
|
2773
|
-
// Windows `sub\host.geml` otherwise has no directory as far as relDirPath can
|
|
2774
|
-
// tell, and a relative `src=` resolves against the wrong base.
|
|
2775
|
-
const startDoc = where.replace(/\\/g, "/");
|
|
2776
|
-
const viewRoot = flag(args, "--root") ?? (relDirPath(startDoc) || ".");
|
|
2777
|
-
if (json) {
|
|
2778
|
-
// §7: N matches yield N model nodes. The old `{kind:"blocks",
|
|
2779
|
-
// matches:[{lines}]}` coordinate envelope is gone — it answered "where are
|
|
2780
|
-
// they" when the question is "what are they" (§9 change 2).
|
|
2781
|
-
let nodes;
|
|
2782
|
-
try {
|
|
2783
|
-
nodes = units.flatMap((u) => {
|
|
2784
|
-
if (!view)
|
|
2785
|
-
return [unitNode(source, file, u, all)];
|
|
2786
|
-
return viewResolve(source, startDoc, u, viewRoot).map((res) => {
|
|
2787
|
-
const node = unitNode(res.text, res.doc, res.unit, res.all);
|
|
2788
|
-
// Provenance is mandatory (§4): the node's references and relative
|
|
2789
|
-
// paths resolve against ITS document, not the one asked about. A
|
|
2790
|
-
// whole-document target has no `#`, so it carries `doc` alone.
|
|
2791
|
-
if (res.from !== "") {
|
|
2792
|
-
const h = res.from.lastIndexOf("#");
|
|
2793
|
-
node["from"] = h < 0 ? { doc: res.from }
|
|
2794
|
-
: { doc: res.from.slice(0, h), id: res.from.slice(h + 1) };
|
|
2795
|
-
}
|
|
2796
|
-
return node;
|
|
2797
|
-
});
|
|
2798
|
-
});
|
|
2799
|
-
}
|
|
2800
|
-
catch (e) {
|
|
2801
|
-
if (e instanceof ViewError)
|
|
2802
|
-
fail(e.message, 1);
|
|
2803
|
-
throw e;
|
|
2804
|
-
}
|
|
2805
|
-
console.log(JSON.stringify(nodes.length === 1 ? nodes[0] : nodes, null, 2));
|
|
2806
|
-
return;
|
|
2807
|
-
}
|
|
2808
|
-
if (units.length > 1)
|
|
2809
|
-
reportMatches(units[0].type ?? "", units);
|
|
2810
|
-
if (view) {
|
|
2811
|
-
// All-or-nothing (§3.3): resolve EVERYTHING before writing a byte, so a
|
|
2812
|
-
// chain that breaks halfway cannot leave a partial read on stdout for a
|
|
2813
|
-
// caller that ignores the exit code. Partial scenery is not scenery.
|
|
2814
|
-
const out = [];
|
|
2815
|
-
const notes = [];
|
|
2816
|
-
try {
|
|
2817
|
-
for (const u of units) {
|
|
2818
|
-
for (const res of viewResolve(source, startDoc, u, viewRoot)) {
|
|
2819
|
-
if (res.from !== "")
|
|
2820
|
-
notes.push(`view: ${rawSel} -> ${res.from}`);
|
|
2821
|
-
out.push(sliceUnit(res.text, res.unit.span, headOnly, bodyOnly));
|
|
2822
|
-
}
|
|
2823
|
-
}
|
|
2824
|
-
}
|
|
2825
|
-
catch (e) {
|
|
2826
|
-
// A chain that cannot reach an entity block is a failed READ, reported the
|
|
2827
|
-
// way `get` reports a selector that matches nothing: one line, exit 1.
|
|
2828
|
-
if (e instanceof ViewError)
|
|
2829
|
-
fail(e.message, 1);
|
|
2830
|
-
throw e;
|
|
2831
|
-
}
|
|
2832
|
-
for (const n of notes)
|
|
2833
|
-
console.error(n);
|
|
2834
|
-
process.stdout.write(out.join(""));
|
|
2835
|
-
return;
|
|
2836
|
-
}
|
|
2837
|
-
for (const u of units)
|
|
2838
|
-
process.stdout.write(sliceUnit(source, u.span, headOnly, bodyOnly));
|
|
2839
|
-
}
|
|
2840
|
-
const NO_CONTENT = "no replacement content (use --in FILE or pipe it on stdin)";
|
|
2841
|
-
// `geml set <file.geml|-> #id [--head|--body] [--in F|F#src|-] [-o out]` —
|
|
2842
|
-
// replace ONE existing block, addressed by #id, with new content, preserving
|
|
2843
|
-
// every other byte. Two content CHANNELS × three MODES:
|
|
2844
|
-
//
|
|
2845
|
-
// channels · `--in F[#src]` extracts a BLOCK from GEML file F (F is always
|
|
2846
|
-
// read as GEML — extension ignored, no md conversion): `--in F`
|
|
2847
|
-
// takes the block whose id == the target #id; `--in F#src` takes
|
|
2848
|
-
// #src. stdin (default, or `--in -`) is raw bytes.
|
|
2849
|
-
// modes · default replaces the WHOLE block, `--head` only the head line,
|
|
2850
|
-
// `--body` only the body. Default and `--head` NORMALIZE the
|
|
2851
|
-
// content's id to #id (its source id is irrelevant); `--body`
|
|
2852
|
-
// keeps the target's head verbatim, so #id is preserved naturally.
|
|
2853
|
-
//
|
|
2854
|
-
// Output follows resolveOutTarget (file -> in place, stdin -> stdout, `-o`/`-o -`
|
|
2855
|
-
// override) and every splice is guarded — re-parsed and rejected if it broke
|
|
2856
|
-
// the doc, so `set` never writes a corrupt file.
|
|
2857
|
-
function runSet(args) {
|
|
2858
|
-
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
2859
|
-
const from = flag(args, "--in");
|
|
2860
|
-
const headOnly = args.includes("--head");
|
|
2861
|
-
const bodyOnly = args.includes("--body");
|
|
2862
|
-
if (headOnly && bodyOnly)
|
|
2863
|
-
fail("--head and --body are mutually exclusive", 2);
|
|
2864
|
-
// `--view` reads THROUGH an embed (see runGet). Writing through one would mean
|
|
2865
|
-
// one `set` silently editing a different file, so it is refused rather than
|
|
2866
|
-
// ignored — and the message has to point the way, not just say no.
|
|
2867
|
-
if (args.includes("--view")) {
|
|
2868
|
-
fail("--view is read-only. To edit the target, read the frame's `src` and edit that document.", 2);
|
|
2869
|
-
}
|
|
2870
|
-
const [file, rawSel] = positionals(args, ["-o", "--out", "--in"]);
|
|
2871
|
-
if (!file)
|
|
2872
|
-
fail(SUBHELP.set);
|
|
2873
|
-
// No selector: there is no block to replace. Point the way to discovery, not a
|
|
2874
|
-
// bare usage line — `geml get <file>` lists every address `set` can target.
|
|
2875
|
-
if (!rawSel)
|
|
2876
|
-
fail(`no selector given — run 'geml get ${file === "-" ? "<file>" : file}' to list addressable blocks`, 2);
|
|
2877
|
-
// The raw channel is stdin — `--in` omitted or `--in -`; anything else sources
|
|
2878
|
-
// a block from a file. Document and content can't BOTH be stdin: reject that
|
|
2879
|
-
// up front, before consuming stdin, so the document read below is unambiguous.
|
|
2880
|
-
const rawChannel = from === undefined || from === "-";
|
|
2881
|
-
if (file === "-" && rawChannel) {
|
|
2882
|
-
fail("reading the document from stdin needs --in for the new content", 2);
|
|
2883
|
-
}
|
|
2884
|
-
const source = readInput(file);
|
|
2885
|
-
const target = resolveSetTarget(source, file, rawSel);
|
|
2886
|
-
if (bodyOnly) {
|
|
2887
|
-
runSetBody(source, target, from, rawChannel, file, out);
|
|
2888
|
-
return;
|
|
2889
|
-
}
|
|
2890
|
-
let content;
|
|
2891
|
-
if (rawChannel) {
|
|
2892
|
-
content = readInput("-");
|
|
2893
|
-
if (content === "")
|
|
2894
|
-
fail(NO_CONTENT, 1);
|
|
2895
|
-
// Default mode wants exactly ONE block. Pure prose has no head to carry the
|
|
2896
|
-
// id (steer to --body); multiple blocks are `add`'s job. --head takes a
|
|
2897
|
-
// lone head line, so it skips the whole-block shape check.
|
|
2898
|
-
if (!headOnly) {
|
|
2899
|
-
const shape = contentShape(content);
|
|
2900
|
-
if (shape === "empty")
|
|
2901
|
-
fail(NO_CONTENT, 1);
|
|
2902
|
-
if (shape === "prose")
|
|
2903
|
-
fail(`content is prose, not a block — use --body to set the body of ${target.label}`, 1);
|
|
2904
|
-
if (shape === "multi")
|
|
2905
|
-
fail("set replaces ONE block, but the content has multiple blocks (use add)", 1);
|
|
2906
|
-
}
|
|
2907
|
-
}
|
|
2908
|
-
else {
|
|
2909
|
-
content = extractBlock(from, target.unit.id ?? "", headOnly ? "head" : "whole");
|
|
2910
|
-
}
|
|
2911
|
-
// §5.2: `@<hex>` is not an id, so "normalize the content's id to the target's"
|
|
2912
|
-
// has no subject — the content is used verbatim, and an id it brings that
|
|
2913
|
-
// collides is caught by the splice guard like any other. An id target keeps
|
|
2914
|
-
// normalizing: naming an id on the command line IS the instruction that the
|
|
2915
|
-
// result carries that id (block-mutation design §4.0).
|
|
2916
|
-
const replacement = target.unit.id !== undefined ? normalizeBlockId(content, target.unit.id) : content;
|
|
2917
|
-
const updated = spliceSpan(source, target.unit.span, replacement, file, headOnly, false, target.unit.id);
|
|
2918
|
-
resolveOutTarget(file, out).write(updated);
|
|
2919
|
-
reportNewAddress(updated, target);
|
|
2920
|
-
}
|
|
2921
|
-
// Resolve a selector to the ONE unit `set` will overwrite. `get` may answer with
|
|
2922
|
-
// N blocks; `set` may not — §5: with N targets there is no single id to
|
|
2923
|
-
// normalize the content to, so multi-target `set` is undefined, not merely
|
|
2924
|
-
// risky. Refused with exit 2 (a usage error), not exit 1.
|
|
2925
|
-
function resolveSetTarget(source, file, rawSel) {
|
|
2926
|
-
const where = file === "-" ? "<file>" : file;
|
|
2927
|
-
const sel = parseSelector(rawSel, (braces) => parseAttrs(braces).id);
|
|
2928
|
-
if (sel.form === "list")
|
|
2929
|
-
fail(`no selector given — run 'geml get ${where}' to list addressable blocks`, 2);
|
|
2930
|
-
const { units, all } = selectUnits(source, file, rawSel, where);
|
|
2931
|
-
if (units.length > 1) {
|
|
2932
|
-
// §5: with N targets there is no single id to normalize the content to, so
|
|
2933
|
-
// multi-target `set` is UNDEFINED, not merely risky. The addresses are
|
|
2934
|
-
// printed because they ARE the fix — each is unique and pastes straight
|
|
2935
|
-
// back into this same command (§6.2).
|
|
2936
|
-
const opts = units.map((u) => {
|
|
2937
|
-
const a = all.find((x) => x.unit === u);
|
|
2938
|
-
return ` ${shortestAddress(a, all)} L${u.span.start + 1}-${u.span.end}`;
|
|
2939
|
-
}).join("\n");
|
|
2940
|
-
fail(`\`${rawSel.trim()}\` matches ${units.length} blocks — set writes ONE; address it uniquely:\n${opts}`, 2);
|
|
2941
|
-
}
|
|
2942
|
-
const unit = units[0];
|
|
2943
|
-
const label = unit.id !== undefined && sel.form === "id" ? `#${unit.id}` : `\`${rawSel.trim()}\``;
|
|
2944
|
-
return { unit, label, byContent: sel.form === "content" };
|
|
2945
|
-
}
|
|
2946
|
-
// §5.3: writing through a content address CHANGES it, so print the new one —
|
|
2947
|
-
// otherwise a script editing the same block twice has to re-list in between.
|
|
2948
|
-
// stderr, because stdout may be the document itself (`-o -`).
|
|
2949
|
-
function reportNewAddress(updated, target) {
|
|
2950
|
-
if (!target.byContent)
|
|
2951
|
-
return;
|
|
2952
|
-
const after = addressedUnits(updated).find((a) => a.unit.span.start === target.unit.span.start);
|
|
2953
|
-
if (after)
|
|
2954
|
-
console.error(`new address: ${shortestAddress(after, addressedUnits(updated))}`);
|
|
2955
|
-
}
|
|
2956
|
-
// `--body`: swap ONLY the target block's body, keeping its head (and #id) and,
|
|
2957
|
-
// for a typed block, its close fence. Assembles head + new body + close and
|
|
2958
|
-
// reuses the guarded spliceBlock — the head carries #id, so the id survives
|
|
2959
|
-
// with no normalization needed.
|
|
2960
|
-
function runSetBody(source, target, from, rawChannel, file, out) {
|
|
2961
|
-
const found = target.unit.span;
|
|
2962
|
-
const lines = splitLines(source);
|
|
2963
|
-
const headLine = lines[found.start] ?? "";
|
|
2964
|
-
// A typed block keeps its closing fence; a heading section has none. Decided
|
|
2965
|
-
// by the same helper `get --body` uses, so the two agree on the span and the
|
|
2966
|
-
// §4 round-trip invariant holds.
|
|
2967
|
-
const closeLine = closeFenceLine(lines, found);
|
|
2968
|
-
let body;
|
|
2969
|
-
if (rawChannel) {
|
|
2970
|
-
body = readInput("-");
|
|
2971
|
-
if (body === "")
|
|
2972
|
-
fail(NO_CONTENT, 1);
|
|
2973
|
-
}
|
|
2974
|
-
else {
|
|
2975
|
-
body = extractBlock(from, target.unit.id ?? "", "body");
|
|
2976
|
-
}
|
|
2977
|
-
let head = headLine;
|
|
2978
|
-
if (head !== "" && !/(\r\n|\r|\n)$/.test(head))
|
|
2979
|
-
head += "\n";
|
|
2980
|
-
let b = toLf(body); // spliceBlock converts the result to the document's style
|
|
2981
|
-
if (closeLine !== null && b !== "" && !b.endsWith("\n"))
|
|
2982
|
-
b += "\n";
|
|
2983
|
-
const replacement = closeLine !== null ? head + b + closeLine : head + b;
|
|
2984
|
-
// A typed block (closeLine !== null) must stay ONE block: enforce the
|
|
2985
|
-
// block-count invariant so a `===` fence in the raw body can't close it early
|
|
2986
|
-
// and inject siblings (SEC F2). A heading section body has no close fence and
|
|
2987
|
-
// may legitimately contain blocks, so it is not count-guarded.
|
|
2988
|
-
const updated = spliceSpan(source, found, replacement, file, false, closeLine !== null, target.unit.id);
|
|
2989
|
-
resolveOutTarget(file, out).write(updated);
|
|
2990
|
-
reportNewAddress(updated, target);
|
|
2991
|
-
}
|
|
2992
|
-
// `geml add <file|-> (--append | --before #x | --after #x) [--in F|F#src|-] [-o]`
|
|
2993
|
-
// — insert a GEML fragment (1+ blocks and/or prose) at a position. Unlike `set`,
|
|
2994
|
-
// `add` names no target id, so content keeps its OWN ids (no normalization); an
|
|
2995
|
-
// id colliding with the document (or duplicated within the fragment) makes the
|
|
2996
|
-
// re-parse fail and nothing is written. Bare prose is a valid fragment.
|
|
2997
|
-
function runAdd(args) {
|
|
2998
|
-
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
2999
|
-
const from = flag(args, "--in");
|
|
3000
|
-
const before = flag(args, "--before");
|
|
3001
|
-
const after = flag(args, "--after");
|
|
3002
|
-
const append = args.includes("--append");
|
|
3003
|
-
const posCount = (append ? 1 : 0) + (before !== undefined ? 1 : 0) + (after !== undefined ? 1 : 0);
|
|
3004
|
-
if (posCount !== 1)
|
|
3005
|
-
fail("add needs exactly one position: --append | --before #id | --after #id", 2);
|
|
3006
|
-
const [file] = positionals(args, ["-o", "--out", "--in", "--before", "--after"]);
|
|
3007
|
-
if (!file)
|
|
3008
|
-
fail(SUBHELP.add);
|
|
3009
|
-
const rawChannel = from === undefined || from === "-";
|
|
3010
|
-
if (file === "-" && rawChannel)
|
|
3011
|
-
fail("reading the document from stdin needs --in for the new content", 2);
|
|
3012
|
-
const source = readInput(file);
|
|
3013
|
-
// Content: --in F#src -> block #src; --in F -> all of F (a multi-block
|
|
3014
|
-
// fragment is fine here); stdin -> raw. No id-normalization: add keeps ids.
|
|
3015
|
-
let content;
|
|
3016
|
-
if (rawChannel)
|
|
3017
|
-
content = readInput("-");
|
|
3018
|
-
else if (from.includes("#"))
|
|
3019
|
-
content = extractBlock(from, "", "whole");
|
|
3020
|
-
else
|
|
3021
|
-
content = readInput(from);
|
|
3022
|
-
if (content.trim() === "")
|
|
3023
|
-
fail("no content to add (use --in FILE or pipe it on stdin)", 1);
|
|
3024
|
-
// Resolve the physical-line insertion point.
|
|
3025
|
-
const lines = splitLines(source);
|
|
3026
|
-
let at;
|
|
3027
|
-
if (append) {
|
|
3028
|
-
at = lines.length;
|
|
3029
|
-
}
|
|
3030
|
-
else {
|
|
3031
|
-
const anchorId = (before ?? after).replace(/^#/, "");
|
|
3032
|
-
const span = blockSpans(source).get(anchorId);
|
|
3033
|
-
if (!span)
|
|
3034
|
-
fail(`no block with id \`${anchorId}\` in ${file === "-" ? "stdin" : file}`, 1);
|
|
3035
|
-
at = before !== undefined ? span.start : span.end;
|
|
3036
|
-
}
|
|
3037
|
-
const updated = insertFragment(source, lines, at, content, file);
|
|
3038
|
-
resolveOutTarget(file, out).write(updated);
|
|
3039
|
-
}
|
|
3040
|
-
// Splice `fragment` into `source` at physical-line index `at` (splitLines
|
|
3041
|
-
// coords), separating it from adjacent content with a single blank line so
|
|
3042
|
-
// blocks don't fuse, then GUARD: the re-parse must be error-free (a colliding
|
|
3043
|
-
// or duplicate id surfaces as an error diagnostic) and no pre-existing id may
|
|
3044
|
-
// vanish. Returns the updated text; on any violation fail()s and writes nothing.
|
|
3045
|
-
function insertFragment(source, lines, at, fragment, file) {
|
|
3046
|
-
const beforeIds = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) }).ids;
|
|
3047
|
-
const before = lines.slice(0, at);
|
|
3048
|
-
const after = lines.slice(at);
|
|
3049
|
-
const nl = newlineOf(source); // the fragment AND every separator we add
|
|
3050
|
-
// The preceding line must end in a newline so the fragment starts on its own.
|
|
3051
|
-
if (before.length && !/(\r\n|\r|\n)$/.test(before[before.length - 1])) {
|
|
3052
|
-
before[before.length - 1] += nl;
|
|
3053
|
-
}
|
|
3054
|
-
let frag = toNewline(fragment, nl);
|
|
3055
|
-
if (!frag.endsWith("\n"))
|
|
3056
|
-
frag += nl;
|
|
3057
|
-
// A single blank separator on each side that has adjacent content and isn't
|
|
3058
|
-
// already blank — keeps a following head / preceding block from fusing.
|
|
3059
|
-
const blank = (s) => stripEol(s).trim() === "";
|
|
3060
|
-
const sepBefore = before.length && !blank(before[before.length - 1]) ? nl : "";
|
|
3061
|
-
const sepAfter = after.length && !blank(after[0]) ? nl : "";
|
|
3062
|
-
const updated = before.join("") + sepBefore + frag + sepAfter + after.join("");
|
|
3063
|
-
const reparsed = parse(updated, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
|
|
3064
|
-
const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
|
|
3065
|
-
if (errs.length) {
|
|
3066
|
-
const first = errs[0];
|
|
3067
|
-
refuseBroken(`adding the content would break the document: ${first.message} (line ${first.line}); not written`, errs);
|
|
3068
|
-
}
|
|
3069
|
-
const now = new Set(reparsed.ids);
|
|
3070
|
-
const dropped = beforeIds.find((x) => !now.has(x));
|
|
3071
|
-
if (dropped !== undefined)
|
|
3072
|
-
fail(`adding the content would drop block \`#${dropped}\`; not written`, 1);
|
|
3073
|
-
return updated;
|
|
3074
|
-
}
|
|
3075
|
-
// `geml delete <file|-> #id [#id2 …] [-o]` — remove one or more blocks. A
|
|
3076
|
-
// missing id is SKIPPED with a note (declarative "ensure absent", not an
|
|
3077
|
-
// error). Unlike set/add, delete's write is LENIENT: removing a complete block
|
|
3078
|
-
// can't break the parse structurally, but it may leave a reference dangling —
|
|
3079
|
-
// that is a WARNING, never a refusal (delete is reversible via revert + history,
|
|
3080
|
-
// and `geml check` still flags the dangling ref afterward). Contained/overlapping
|
|
3081
|
-
// spans (a nested block inside a deleted heading section) are handled by deleting
|
|
3082
|
-
// the UNION of target lines, so a line is never spliced twice.
|
|
3083
|
-
function runDelete(args) {
|
|
3084
|
-
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
3085
|
-
const pos = positionals(args, ["-o", "--out"]);
|
|
3086
|
-
const file = pos[0];
|
|
3087
|
-
if (!file)
|
|
3088
|
-
fail(SUBHELP.delete);
|
|
3089
|
-
const ids = pos.slice(1).map((s) => s.replace(/^#/, ""));
|
|
3090
|
-
if (ids.length === 0)
|
|
3091
|
-
fail("delete needs at least one #id (run 'geml get <file>' to list ids)", 2);
|
|
3092
|
-
const source = readInput(file);
|
|
3093
|
-
const spans = blockSpans(source);
|
|
3094
|
-
const toDelete = new Set();
|
|
3095
|
-
let found = 0;
|
|
3096
|
-
for (const id of ids) {
|
|
3097
|
-
const span = spans.get(id);
|
|
3098
|
-
if (!span) {
|
|
3099
|
-
console.error(`skipped #${id}: no such block`);
|
|
3100
|
-
continue;
|
|
3101
|
-
}
|
|
3102
|
-
found++;
|
|
3103
|
-
for (let i = span.start; i < span.end; i++)
|
|
3104
|
-
toDelete.add(i);
|
|
1895
|
+
return false; // no URL support (a bundle) — never the CLI
|
|
3105
1896
|
}
|
|
3106
|
-
if (found === 0) {
|
|
3107
|
-
resolveOutTarget(file, out).write(source);
|
|
3108
|
-
return;
|
|
3109
|
-
} // nothing to remove
|
|
3110
|
-
const updated = splitLines(source).filter((_, i) => !toDelete.has(i)).join("");
|
|
3111
|
-
// Lenient guard: surface any resulting error diagnostic (a reference now
|
|
3112
|
-
// dangling) as a WARNING, but write regardless.
|
|
3113
|
-
const reparsed = parse(updated, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
|
|
3114
|
-
for (const d of reparsed.diagnostics.filter((x) => x.severity === "error")) {
|
|
3115
|
-
console.error(`warning: ${d.message} (line ${d.line}) — left dangling by delete; run 'geml check' to see it as an error`);
|
|
3116
|
-
}
|
|
3117
|
-
resolveOutTarget(file, out).write(updated);
|
|
3118
1897
|
}
|
|
3119
|
-
//
|
|
3120
|
-
//
|
|
3121
|
-
|
|
3122
|
-
function runRename(args) {
|
|
3123
|
-
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
3124
|
-
const [file, rawOld, rawNew] = positionals(args, ["-o", "--out"]);
|
|
3125
|
-
if (!file || !rawOld || !rawNew)
|
|
3126
|
-
fail(SUBHELP.rename);
|
|
3127
|
-
const oldId = rawOld.replace(/^#/, "");
|
|
3128
|
-
const newId = rawNew.replace(/^#/, "");
|
|
3129
|
-
if (oldId === newId)
|
|
3130
|
-
fail("#old and #new are the same id — nothing to rename", 2);
|
|
3131
|
-
const source = readInput(file);
|
|
3132
|
-
const before = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
|
|
3133
|
-
if (!before.ids.includes(oldId))
|
|
3134
|
-
fail(`no block with id \`${oldId}\``, 1);
|
|
3135
|
-
if (before.ids.includes(newId))
|
|
3136
|
-
fail(`id \`${newId}\` already exists; not written`, 1);
|
|
3137
|
-
// Renaming an id that has recorded history breaks the revert-lineage for it
|
|
3138
|
-
// (revert keys by id and can't follow #old -> #new across the boundary). Warn
|
|
3139
|
-
// so the user knows a later `revert #new` won't reach pre-rename revisions.
|
|
3140
|
-
if (file !== "-") {
|
|
3141
|
-
const hp = historyPathFor(file);
|
|
3142
|
-
if (existsSync(hp)) {
|
|
3143
|
-
try {
|
|
3144
|
-
if (blockSpans(resolveContent(hp, "0").text).has(oldId)) {
|
|
3145
|
-
console.error(`warning: #${oldId} has history; revert across this rename is not tracked — see docs`);
|
|
3146
|
-
}
|
|
3147
|
-
}
|
|
3148
|
-
catch { /* unreadable/empty history: no warning */ }
|
|
3149
|
-
}
|
|
3150
|
-
}
|
|
3151
|
-
const updated = rewriteId(source, oldId, newId, file);
|
|
3152
|
-
const reparsed = parse(updated, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
|
|
3153
|
-
const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
|
|
3154
|
-
if (errs.length) {
|
|
3155
|
-
const e = errs[0];
|
|
3156
|
-
refuseBroken(`rename would break the document: ${e.message} (line ${e.line}); not written`, errs);
|
|
3157
|
-
}
|
|
3158
|
-
if (!reparsed.ids.includes(newId))
|
|
3159
|
-
fail(`rename did not produce #${newId}; not written`, 1);
|
|
3160
|
-
if (reparsed.ids.includes(oldId))
|
|
3161
|
-
fail(`#${oldId} still present after rename; not written`, 1);
|
|
3162
|
-
// Every OTHER id must be untouched. The `#old` match boundary treats a char
|
|
3163
|
-
// outside [A-Za-z0-9_-] as an id terminator, but ids may contain e.g. `.`
|
|
3164
|
-
// (`#foo.bar`), so renaming `#foo` could silently rewrite the *different* id
|
|
3165
|
-
// `#foo.bar` -> `#baz.bar`. Reject when the set of ids other than the rename
|
|
3166
|
-
// pair changed at all (SEC/correctness: collateral id corruption).
|
|
3167
|
-
const othersBefore = before.ids.filter((id) => id !== oldId).sort().join("\n");
|
|
3168
|
-
const othersAfter = reparsed.ids.filter((id) => id !== newId).sort().join("\n");
|
|
3169
|
-
if (othersBefore !== othersAfter) {
|
|
3170
|
-
fail(`rename would also change other ids sharing the \`${oldId}\` prefix (e.g. \`#${oldId}…\`); not written`, 1);
|
|
3171
|
-
}
|
|
3172
|
-
resolveOutTarget(file, out).write(updated);
|
|
3173
|
-
}
|
|
3174
|
-
// Rewrite id `old` -> `new` everywhere it is a declaration or reference, id-
|
|
3175
|
-
// boundary-safe: `#old` is replaced only when NOT followed by an id char, so a
|
|
3176
|
-
// longer id like `#old2` / `#old-x` is untouched. Covers the declaration
|
|
3177
|
-
// (`{#old …}`, labeled close `=== #old`), block references (`[[#old]]`,
|
|
3178
|
-
// `[t](#old)`, chart `data=#old`) and footnotes (`[^old]`). RAW / data block
|
|
3179
|
-
// BODIES (code/diagram/math/table/meta) are skipped — a `#old` there is literal
|
|
3180
|
-
// text, not a reference. (Known residual: id-less raw bodies and inline
|
|
3181
|
-
// code/math spans in flow content — see design §8.)
|
|
3182
|
-
function rewriteId(source, oldId, newId, file) {
|
|
3183
|
-
const doc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
|
|
3184
|
-
const spans = blockSpans(source);
|
|
3185
|
-
const protectedLines = new Set();
|
|
3186
|
-
for (const b of doc.children) {
|
|
3187
|
-
if (b.kind === "block" && (b.mode === "raw" || b.mode === "data") && b.id) {
|
|
3188
|
-
const span = spans.get(b.id);
|
|
3189
|
-
if (span) {
|
|
3190
|
-
const br = bodyRange(source, span);
|
|
3191
|
-
for (let i = br.start; i < br.end; i++)
|
|
3192
|
-
protectedLines.add(i);
|
|
3193
|
-
}
|
|
3194
|
-
}
|
|
3195
|
-
}
|
|
3196
|
-
const esc = reLit(oldId);
|
|
3197
|
-
const hashRe = new RegExp(`#${esc}(?![A-Za-z0-9_-])`, "g");
|
|
3198
|
-
const fnRe = new RegExp(`(\\[\\^)${esc}(?![A-Za-z0-9_-])`, "g");
|
|
3199
|
-
const lines = splitLines(source);
|
|
3200
|
-
for (let i = 0; i < lines.length; i++) {
|
|
3201
|
-
if (protectedLines.has(i))
|
|
3202
|
-
continue;
|
|
3203
|
-
lines[i] = lines[i].replace(hashRe, `#${newId}`).replace(fnRe, `$1${newId}`);
|
|
3204
|
-
}
|
|
3205
|
-
return lines.join("");
|
|
3206
|
-
}
|
|
3207
|
-
// Extract one block from a GEML file for `--in`. `spec` is `F` (block whose id
|
|
3208
|
-
// == the target) or `F#src` (block #src) — the last `#` splits path from id, so
|
|
3209
|
-
// a `#` inside the path is tolerated; F is read as GEML regardless of extension
|
|
3210
|
-
// (blockSpans + splitLines, no parse — same slice `geml get` prints). `part`
|
|
3211
|
-
// selects the whole span, its head line, or its body. A missing file or absent
|
|
3212
|
-
// id is an operation error (exit 1); the caller writes nothing.
|
|
3213
|
-
function extractBlock(spec, targetId, part) {
|
|
3214
|
-
const hash = spec.lastIndexOf("#");
|
|
3215
|
-
const fragFile = hash >= 0 ? spec.slice(0, hash) : spec;
|
|
3216
|
-
const fragId = hash >= 0 ? spec.slice(hash + 1).replace(/^#/, "") : targetId;
|
|
3217
|
-
let text;
|
|
1898
|
+
// Canonical path, falling back to the absolute spelling when the target cannot
|
|
1899
|
+
// be realpath'd (it may not exist — that is not an error here, just a miss).
|
|
1900
|
+
function realOf(p) {
|
|
3218
1901
|
try {
|
|
3219
|
-
|
|
1902
|
+
return realpathSync(p);
|
|
3220
1903
|
}
|
|
3221
1904
|
catch {
|
|
3222
|
-
|
|
3223
|
-
}
|
|
3224
|
-
const span = blockSpans(text).get(fragId);
|
|
3225
|
-
if (!span)
|
|
3226
|
-
fail(`no block with id \`${fragId}\` in ${fragFile}`, 1);
|
|
3227
|
-
const lines = splitLines(text);
|
|
3228
|
-
if (part === "head")
|
|
3229
|
-
return lines.slice(span.start, span.start + 1).join("");
|
|
3230
|
-
if (part === "body") {
|
|
3231
|
-
const b = bodyRange(text, span);
|
|
3232
|
-
return lines.slice(b.start, b.end).join("");
|
|
3233
|
-
}
|
|
3234
|
-
return lines.slice(span.start, span.end).join("");
|
|
3235
|
-
}
|
|
3236
|
-
// Strip a single trailing terminator (`\r\n`, `\r`, or `\n`) from one line.
|
|
3237
|
-
function stripEol(line) {
|
|
3238
|
-
return line.replace(/(\r\n|\r|\n)$/, "");
|
|
3239
|
-
}
|
|
3240
|
-
// The body sub-range of a block span: [head+1, close) for a closed typed block,
|
|
3241
|
-
// otherwise [head+1, end) — a heading section (no close fence) or an
|
|
3242
|
-
// unterminated block whose span already runs to end-of-scope.
|
|
3243
|
-
function bodyRange(text, span) {
|
|
3244
|
-
const lines = splitLines(text);
|
|
3245
|
-
const open = FENCE_OPEN.exec(stripEol(lines[span.start] ?? ""));
|
|
3246
|
-
if (open) {
|
|
3247
|
-
const lastText = stripEol(lines[span.end - 1] ?? "").replace(/[ \t]+$/, "");
|
|
3248
|
-
const bid = open[3] ? parseAttrs(open[3]).id : undefined;
|
|
3249
|
-
const labeled = bid !== undefined && new RegExp(`^={3,}[ \\t]+#${reLit(bid)}[ \\t]*$`).test(lastText);
|
|
3250
|
-
const closed = isCloseFence(lastText, open[1].length) || labeled;
|
|
3251
|
-
return { start: span.start + 1, end: closed ? span.end - 1 : span.end };
|
|
3252
|
-
}
|
|
3253
|
-
return { start: span.start + 1, end: span.end };
|
|
3254
|
-
}
|
|
3255
|
-
// The shape of default-mode stdin content, section-aware: a heading OWNS its
|
|
3256
|
-
// section (`# H …blocks…` is ONE unit, not many), matching sectionEnd/blockSpans.
|
|
3257
|
-
// Used to reject pure prose (-> --body) and multi-block content (-> add) before
|
|
3258
|
-
// the splice — extraction via --in is inherently one block and skips this.
|
|
3259
|
-
function contentShape(content) {
|
|
3260
|
-
const bs = parse(content).children;
|
|
3261
|
-
let blockUnits = 0, proseUnits = 0, i = 0;
|
|
3262
|
-
while (i < bs.length) {
|
|
3263
|
-
const b = bs[i];
|
|
3264
|
-
if (b.kind === "heading") {
|
|
3265
|
-
i = sectionEndIndex(bs, i);
|
|
3266
|
-
blockUnits++;
|
|
3267
|
-
}
|
|
3268
|
-
else if (b.kind === "block") {
|
|
3269
|
-
i++;
|
|
3270
|
-
blockUnits++;
|
|
3271
|
-
}
|
|
3272
|
-
else {
|
|
3273
|
-
i++;
|
|
3274
|
-
proseUnits++;
|
|
3275
|
-
}
|
|
3276
|
-
}
|
|
3277
|
-
if (blockUnits === 0)
|
|
3278
|
-
return proseUnits === 0 ? "empty" : "prose";
|
|
3279
|
-
return blockUnits + proseUnits === 1 ? "single" : "multi";
|
|
3280
|
-
}
|
|
3281
|
-
// Replace block #id's source span in `source` with `replacement`, preserving
|
|
3282
|
-
// every other byte, and GUARD the result: the re-parse must be error-free, #id
|
|
3283
|
-
// must survive, and no other pre-existing id may vanish (a malformed replacement
|
|
3284
|
-
// can silently swallow a neighbour). Returns the updated document text; on any
|
|
3285
|
-
// violation it calls fail() and never returns a corrupt document. Shared by
|
|
3286
|
-
// `set` and `revert`.
|
|
3287
|
-
function spliceBlock(source, id, replacement, file, headOnly = false, guardCount = false) {
|
|
3288
|
-
const found = blockSpans(source).get(id);
|
|
3289
|
-
if (!found)
|
|
3290
|
-
fail(`no block with id \`${id}\``, 1);
|
|
3291
|
-
return spliceSpan(source, found, replacement, file, headOnly, guardCount, id);
|
|
3292
|
-
}
|
|
3293
|
-
// The same guarded splice addressed by SPAN rather than by id, because an
|
|
3294
|
-
// anonymous block (addressed by `@<hex>`) has no id to look one up with. `id`
|
|
3295
|
-
// is the survival guard's subject and is simply absent for those: every OTHER
|
|
3296
|
-
// pre-existing id must still survive, which the `dropped` check below covers.
|
|
3297
|
-
function spliceSpan(source, found, replacement, file, headOnly = false, guardCount = false, id) {
|
|
3298
|
-
const beforeDoc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
|
|
3299
|
-
const beforeIds = beforeDoc.ids;
|
|
3300
|
-
// Keep the bytes before and after the target span exactly; give the new block
|
|
3301
|
-
// a single trailing newline so the following block still starts on its own
|
|
3302
|
-
// line (unless it is the file's last line, which may legitimately lack one).
|
|
3303
|
-
const orig = splitLines(source);
|
|
3304
|
-
// `--head`: splice only the id's head line; everything below stays
|
|
3305
|
-
// byte-identical. The guard below still applies — the replacement must
|
|
3306
|
-
// re-declare `{#id}` and, for a typed block, keep the fence pairing intact
|
|
3307
|
-
// (an opening line that no longer matches the untouched close fence breaks
|
|
3308
|
-
// the re-parse), or the splice is refused.
|
|
3309
|
-
const span = headOnly ? narrowToHead(found) : found;
|
|
3310
|
-
const before = orig.slice(0, span.start);
|
|
3311
|
-
const after = orig.slice(span.end);
|
|
3312
|
-
const nl = newlineOf(source); // adopt the document's style, not LF
|
|
3313
|
-
let inject = toNewline(replacement, nl);
|
|
3314
|
-
const lastLine = span.end >= orig.length;
|
|
3315
|
-
if (!inject.endsWith("\n") && !lastLine)
|
|
3316
|
-
inject += nl;
|
|
3317
|
-
const updated = before.join("") + inject + after.join("");
|
|
3318
|
-
// Re-parse and refuse a broken result. A parse error or a duplicate id both
|
|
3319
|
-
// surface as error diagnostics (registerId flags dups); one check covers both.
|
|
3320
|
-
// Then require the target id to survive, and — because a malformed replacement
|
|
3321
|
-
// can swallow a neighbour — that every other pre-existing id survives too.
|
|
3322
|
-
const reparsed = parse(updated, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
|
|
3323
|
-
const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
|
|
3324
|
-
if (errs.length) {
|
|
3325
|
-
const first = errs[0];
|
|
3326
|
-
refuseBroken(`replacement would break the document: ${first.message} (line ${first.line}); not written`, errs);
|
|
1905
|
+
return resolvePath(p);
|
|
3327
1906
|
}
|
|
3328
|
-
const now = new Set(reparsed.ids);
|
|
3329
|
-
if (id !== undefined && !now.has(id))
|
|
3330
|
-
fail(`replacement removes id \`${id}\`; not written`, 1);
|
|
3331
|
-
const dropped = beforeIds.find((x) => x !== id && !now.has(x));
|
|
3332
|
-
if (dropped !== undefined) {
|
|
3333
|
-
fail(`replacement would drop block \`#${dropped}\` (malformed content?); not written`, 1);
|
|
3334
|
-
}
|
|
3335
|
-
// For a typed block with a close fence, the body is opaque and swapping it
|
|
3336
|
-
// keeps exactly ONE block. A raw `--body` can embed a `===` fence of the
|
|
3337
|
-
// block's length that closes the target early and turns the remainder — plus
|
|
3338
|
-
// the close line we re-appended — into NEW sibling blocks, including an id-less
|
|
3339
|
-
// `=== meta` that redefines document metadata (the dropped-id check above
|
|
3340
|
-
// cannot see an id-less injection). Guarded callers refuse any count change.
|
|
3341
|
-
// (Not enforced for heading sections / whole-block set, whose replacement may
|
|
3342
|
-
// legitimately span several top-level blocks.)
|
|
3343
|
-
if (guardCount && reparsed.children.length !== beforeDoc.children.length) {
|
|
3344
|
-
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);
|
|
3345
|
-
}
|
|
3346
|
-
return updated;
|
|
3347
|
-
}
|
|
3348
|
-
// `geml revert <file.geml> #id [--rev <sel>] [--dry-run] [-o out] [--history PATH]`
|
|
3349
|
-
// Restore ONE block to a past revision's version — a targeted, guarded splice
|
|
3350
|
-
// that leaves the rest of the document untouched. <sel> (default `-1`): `0` (the
|
|
3351
|
-
// tip), `-N` (N revisions back), an id prefix/suffix, or `changed` — a content
|
|
3352
|
-
// selector that skips revisions which never touched the block, landing on its
|
|
3353
|
-
// previous *distinct* version. `--dry-run` prints what would be spliced in,
|
|
3354
|
-
// writing nothing. Writes in place by default (revert is a mutation); `-o` redirects.
|
|
3355
|
-
function runRevert(args) {
|
|
3356
|
-
const dryRun = args.includes("--dry-run");
|
|
3357
|
-
const headOnly = args.includes("--head");
|
|
3358
|
-
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
3359
|
-
const to = flag(args, "--rev") ?? "-1";
|
|
3360
|
-
// `--rev changed` is a CONTENT selector, not a position: skip commits that
|
|
3361
|
-
// never touched this block, landing on its previous *distinct* version. It is
|
|
3362
|
-
// just a `--rev` value, so it cannot conflict with a positional `-N`.
|
|
3363
|
-
const changed = to === "changed";
|
|
3364
|
-
// The former standalone `--changed` flag is now this value; refuse the old
|
|
3365
|
-
// spelling loudly rather than silently ignoring it (and reverting to -1).
|
|
3366
|
-
if (args.includes("--changed"))
|
|
3367
|
-
fail("--changed is now `--rev changed`", 2);
|
|
3368
|
-
const before = flag(args, "--before");
|
|
3369
|
-
const after = flag(args, "--after");
|
|
3370
|
-
const append = args.includes("--append");
|
|
3371
|
-
if ((append ? 1 : 0) + (before !== undefined ? 1 : 0) + (after !== undefined ? 1 : 0) > 1) {
|
|
3372
|
-
fail("revert takes at most one position: --append | --before #id | --after #id", 2);
|
|
3373
|
-
}
|
|
3374
|
-
const [file, rawId] = positionals(args, ["--rev", "--history", "-o", "--out", "--before", "--after"]);
|
|
3375
|
-
if (!file || !rawId)
|
|
3376
|
-
fail(SUBHELP.revert);
|
|
3377
|
-
if (file === "-")
|
|
3378
|
-
fail("revert needs a real file (it reads that file's .gemlhistory)", 2);
|
|
3379
|
-
const id = rawId.replace(/^#/, "");
|
|
3380
|
-
const historyPath = flag(args, "--history") ?? historyPathFor(file);
|
|
3381
|
-
const source = readInput(file);
|
|
3382
|
-
// The sidecar stores every revision newline-NORMALIZED (history.ts), so a
|
|
3383
|
-
// revision's text always comes back LF while the working file may be CRLF.
|
|
3384
|
-
// Comparing those raw would make EVERY block look changed on a CRLF document
|
|
3385
|
-
// (`--rev changed` reverting blocks nobody touched, and the no-op check never
|
|
3386
|
-
// firing), so compare normalized and write back in the file's own style.
|
|
3387
|
-
const norm = toLf; // compare on the LF form
|
|
3388
|
-
const toFileNl = (s) => toNewline(s, newlineOf(source));
|
|
3389
|
-
const curFull = blockSpans(source).get(id); // undefined => absent now
|
|
3390
|
-
const curBlock = curFull === undefined ? undefined : (() => {
|
|
3391
|
-
const span = headOnly ? narrowToHead(curFull) : curFull;
|
|
3392
|
-
return splitLines(source).slice(span.start, span.end).join("");
|
|
3393
|
-
})();
|
|
3394
|
-
// Extract #id's block from a reconstructed revision (undefined => absent
|
|
3395
|
-
// there). Under `--head`, extract only the head line.
|
|
3396
|
-
const pick = (text) => {
|
|
3397
|
-
const s = blockSpans(text).get(id);
|
|
3398
|
-
if (!s)
|
|
3399
|
-
return undefined;
|
|
3400
|
-
const span = headOnly ? narrowToHead(s) : s;
|
|
3401
|
-
return splitLines(text).slice(span.start, span.end).join("");
|
|
3402
|
-
};
|
|
3403
|
-
// Resolve the source revision, formatting any history-layer error cleanly.
|
|
3404
|
-
const target = (() => {
|
|
3405
|
-
try {
|
|
3406
|
-
if (changed) {
|
|
3407
|
-
// `pick` reads normalized revision text, so normalize this side too.
|
|
3408
|
-
const found = firstChangedContent(historyPath, curBlock === undefined ? "" : norm(curBlock), pick);
|
|
3409
|
-
if (!found)
|
|
3410
|
-
fail(`no earlier revision changes \`${id}\``, 1);
|
|
3411
|
-
return found;
|
|
3412
|
-
}
|
|
3413
|
-
return resolveContent(historyPath, to);
|
|
3414
|
-
}
|
|
3415
|
-
catch (e) {
|
|
3416
|
-
fail(historyError(e, file, historyPath), 1);
|
|
3417
|
-
}
|
|
3418
|
-
})();
|
|
3419
|
-
const oldBlock = pick(target.text); // undefined => absent at R
|
|
3420
|
-
// Common write path (bespoke message; -o path redirects; -o - -> stdout).
|
|
3421
|
-
const emit = (updated, verb) => {
|
|
3422
|
-
const dest = out ?? file;
|
|
3423
|
-
if (dest === "-")
|
|
3424
|
-
process.stdout.write(updated);
|
|
3425
|
-
else
|
|
3426
|
-
writeFileSync(dest, updated);
|
|
3427
|
-
console.error(`${verb}${dest === file ? "" : dest === "-" ? " -> stdout" : ` -> ${dest}`}`);
|
|
3428
|
-
};
|
|
3429
|
-
// Reconcile #id between now and revision R across the four presence cells.
|
|
3430
|
-
if (curBlock === undefined && oldBlock === undefined) {
|
|
3431
|
-
fail(`\`${id}\` exists in neither the document nor ${target.id} (try --rev changed)`, 1);
|
|
3432
|
-
}
|
|
3433
|
-
// both present -> SPLICE (undo set)
|
|
3434
|
-
if (curBlock !== undefined && oldBlock !== undefined) {
|
|
3435
|
-
if (norm(oldBlock) === norm(curBlock)) {
|
|
3436
|
-
console.error(`#${id} is unchanged at ${target.id}; nothing to revert${changed ? "" : " (try --rev -2, or --rev changed)"}`);
|
|
3437
|
-
// A no-op still has to PRODUCE the document when an output destination was
|
|
3438
|
-
// asked for: `-o` means "write the result somewhere", and the result of a
|
|
3439
|
-
// no-op revert is the unchanged document. Returning silently here left
|
|
3440
|
-
// `-o -` consumers with exit 0 and empty stdout, which reads as "success,
|
|
3441
|
-
// and the document is now empty".
|
|
3442
|
-
if (out !== undefined)
|
|
3443
|
-
emit(source, `#${id} unchanged`);
|
|
3444
|
-
return;
|
|
3445
|
-
}
|
|
3446
|
-
const replacement = toFileNl(oldBlock); // keep the file's newline style
|
|
3447
|
-
if (dryRun) {
|
|
3448
|
-
console.error(`would revert #${id} to ${target.id}:`);
|
|
3449
|
-
process.stdout.write(replacement.endsWith("\n") ? replacement : replacement + "\n");
|
|
3450
|
-
return;
|
|
3451
|
-
}
|
|
3452
|
-
emit(spliceBlock(source, id, replacement, file, headOnly), `reverted #${id} to ${target.id}`);
|
|
3453
|
-
return;
|
|
3454
|
-
}
|
|
3455
|
-
// --head is only meaningful for the splice cell (it can't resurrect or remove).
|
|
3456
|
-
if (headOnly) {
|
|
3457
|
-
fail("--head only applies when the block exists in both the document and the target revision", 2);
|
|
3458
|
-
}
|
|
3459
|
-
// absent now, present at R -> RESURRECT (undo delete)
|
|
3460
|
-
if (curBlock === undefined && oldBlock !== undefined) {
|
|
3461
|
-
// Guard: if the block we'd resurrect is the same (modulo id) as one already
|
|
3462
|
-
// present under a different id, #id was likely renamed away — resurrecting
|
|
3463
|
-
// would duplicate it. Point at `rename` instead of writing.
|
|
3464
|
-
const cmpKey = normalizeBlockId(norm(oldBlock), "__cmp__");
|
|
3465
|
-
for (const [cid, cs] of blockSpans(source)) {
|
|
3466
|
-
if (cid === id)
|
|
3467
|
-
continue;
|
|
3468
|
-
const csrc = splitLines(source).slice(cs.start, cs.end).join("");
|
|
3469
|
-
if (normalizeBlockId(norm(csrc), "__cmp__") === cmpKey) {
|
|
3470
|
-
fail(`#${id} looks renamed to #${cid}; use 'rename #${cid} #${id}' to undo the rename`, 1);
|
|
3471
|
-
}
|
|
3472
|
-
}
|
|
3473
|
-
const { at, where, warn } = resurrectPosition(source, target.text, id, before, after, append, file);
|
|
3474
|
-
const fragment = toFileNl(oldBlock); // keep the file's newline style
|
|
3475
|
-
if (dryRun) {
|
|
3476
|
-
console.error(`would resurrect #${id} from ${target.id} at ${where}:`);
|
|
3477
|
-
process.stdout.write(fragment.endsWith("\n") ? fragment : fragment + "\n");
|
|
3478
|
-
return;
|
|
3479
|
-
}
|
|
3480
|
-
if (warn)
|
|
3481
|
-
console.error(`warning: anchors for #${id} are gone; appended at end`);
|
|
3482
|
-
emit(insertFragment(source, splitLines(source), at, fragment, file), `resurrected #${id} from ${target.id} at ${where}`);
|
|
3483
|
-
return;
|
|
3484
|
-
}
|
|
3485
|
-
// present now, absent at R -> REMOVE (undo add)
|
|
3486
|
-
// Guard: if the block we'd remove is the same (modulo id) as one present at R
|
|
3487
|
-
// under a different id, #id was likely renamed IN — removing would delete a
|
|
3488
|
-
// renamed block. Point at `rename` instead (the dangerous direction).
|
|
3489
|
-
{
|
|
3490
|
-
const cmpKey = normalizeBlockId(norm(curBlock), "__cmp__");
|
|
3491
|
-
for (const [rid, rs] of blockSpans(target.text)) {
|
|
3492
|
-
if (rid === id)
|
|
3493
|
-
continue;
|
|
3494
|
-
const rsrc = splitLines(target.text).slice(rs.start, rs.end).join("");
|
|
3495
|
-
if (normalizeBlockId(rsrc, "__cmp__") === cmpKey) {
|
|
3496
|
-
fail(`#${id} looks renamed from #${rid}; revert would delete it — use 'rename #${id} #${rid}'`, 1);
|
|
3497
|
-
}
|
|
3498
|
-
}
|
|
3499
|
-
}
|
|
3500
|
-
if (dryRun) {
|
|
3501
|
-
console.error(`would remove #${id} (absent at ${target.id})`);
|
|
3502
|
-
return;
|
|
3503
|
-
}
|
|
3504
|
-
const span = curFull;
|
|
3505
|
-
const beforeIds = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) }).ids;
|
|
3506
|
-
const updated = splitLines(source).filter((_, i) => i < span.start || i >= span.end).join("");
|
|
3507
|
-
const reparsed = parse(updated, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
|
|
3508
|
-
const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
|
|
3509
|
-
if (errs.length) {
|
|
3510
|
-
const first = errs[0];
|
|
3511
|
-
refuseBroken(`removing #${id} would break the document: ${first.message} (line ${first.line}); not written`, errs);
|
|
3512
|
-
}
|
|
3513
|
-
const now = new Set(reparsed.ids);
|
|
3514
|
-
const dropped = beforeIds.find((x) => x !== id && !now.has(x));
|
|
3515
|
-
if (dropped !== undefined)
|
|
3516
|
-
fail(`removing #${id} would drop block \`#${dropped}\`; not written`, 1);
|
|
3517
|
-
emit(updated, `removed #${id} (absent at ${target.id})`);
|
|
3518
|
-
}
|
|
3519
|
-
// Choose the physical-line insertion point for a resurrected block. Explicit
|
|
3520
|
-
// --append/--before/--after win; otherwise infer from the block's neighbours in
|
|
3521
|
-
// revision R: the nearest id BEFORE it that still exists now (insert after it),
|
|
3522
|
-
// else the nearest id AFTER it that still exists (insert before it), else append
|
|
3523
|
-
// at end (warn=true). The deleted block's own former descendants are absent now
|
|
3524
|
-
// too, so they are naturally skipped as anchors.
|
|
3525
|
-
function resurrectPosition(source, revText, id, before, after, append, file) {
|
|
3526
|
-
const lines = splitLines(source);
|
|
3527
|
-
const here = blockSpans(source);
|
|
3528
|
-
if (append)
|
|
3529
|
-
return { at: lines.length, where: "end", warn: false };
|
|
3530
|
-
if (before !== undefined) {
|
|
3531
|
-
const a = before.replace(/^#/, "");
|
|
3532
|
-
const s = here.get(a);
|
|
3533
|
-
if (!s)
|
|
3534
|
-
fail(`no block with id \`${a}\` in ${file}`, 1);
|
|
3535
|
-
return { at: s.start, where: `before #${a}`, warn: false };
|
|
3536
|
-
}
|
|
3537
|
-
if (after !== undefined) {
|
|
3538
|
-
const a = after.replace(/^#/, "");
|
|
3539
|
-
const s = here.get(a);
|
|
3540
|
-
if (!s)
|
|
3541
|
-
fail(`no block with id \`${a}\` in ${file}`, 1);
|
|
3542
|
-
return { at: s.end, where: `after #${a}`, warn: false };
|
|
3543
|
-
}
|
|
3544
|
-
const revIds = [...blockSpans(revText).keys()];
|
|
3545
|
-
const idx = revIds.indexOf(id);
|
|
3546
|
-
for (let i = idx - 1; i >= 0; i--) {
|
|
3547
|
-
const s = here.get(revIds[i]);
|
|
3548
|
-
if (s)
|
|
3549
|
-
return { at: s.end, where: `after #${revIds[i]}`, warn: false };
|
|
3550
|
-
}
|
|
3551
|
-
for (let i = idx + 1; i < revIds.length; i++) {
|
|
3552
|
-
const s = here.get(revIds[i]);
|
|
3553
|
-
if (s)
|
|
3554
|
-
return { at: s.start, where: `before #${revIds[i]}`, warn: false };
|
|
3555
|
-
}
|
|
3556
|
-
return { at: lines.length, where: "end", warn: true };
|
|
3557
|
-
}
|
|
3558
|
-
// geml codemap <sub>: the code-graph toolkit ships as plain scripts in the
|
|
3559
|
-
// package's codemap/ directory (they are argv-driven programs, some
|
|
3560
|
-
// long-running like `serve`) — dispatch = run the script in a child node
|
|
3561
|
-
// with the remaining arguments, propagating the exit code.
|
|
3562
|
-
function runCodemap(args) {
|
|
3563
|
-
const scripts = {
|
|
3564
|
-
build: "build.mjs",
|
|
3565
|
-
verify: "verify.mjs",
|
|
3566
|
-
render: "render-all.mjs",
|
|
3567
|
-
serve: "serve.mjs",
|
|
3568
|
-
refresh: "refresh.mjs",
|
|
3569
|
-
find: "find.mjs",
|
|
3570
|
-
};
|
|
3571
|
-
const sub = args[0] ?? "";
|
|
3572
|
-
// `codemap mcp` was a second stdio server over the same repository. It is
|
|
3573
|
-
// gone, not renamed, so name the replacement instead of letting it fall into
|
|
3574
|
-
// `unknown codemap subcommand`: this string is what an operator sees in a
|
|
3575
|
-
// client's server log when the entry they registered stops starting.
|
|
3576
|
-
if (sub === "mcp") {
|
|
3577
|
-
fail("geml codemap mcp was removed: use `geml mcp --root <dir>`, which serves the three code-graph tools alongside the document tools (graph: <root>/.geml-code-graph, or --graph <dir>).");
|
|
3578
|
-
}
|
|
3579
|
-
const script = scripts[sub];
|
|
3580
|
-
if (!script)
|
|
3581
|
-
fail(`unknown codemap subcommand '${sub}'.\n${SUBHELP.codemap}`);
|
|
3582
|
-
const mod = join(dirname(fileURLToPath(import.meta.url)), "..", "codemap", script);
|
|
3583
|
-
const r = spawnSync(process.execPath, [mod, ...args.slice(1)], { stdio: "inherit" });
|
|
3584
|
-
process.exit(r.status ?? 1);
|
|
3585
|
-
}
|
|
3586
|
-
// geml mcp: the MCP server — document CRUD, plus the code-graph tools when the
|
|
3587
|
-
// root holds a graph. It runs as a child's MAIN module because it owns
|
|
3588
|
-
// stdin/stdout for the whole session (the stdio transport), and dispatching by
|
|
3589
|
-
// spawn keeps this module free of a runtime import cycle (mcp.js imports the
|
|
3590
|
-
// parser from here).
|
|
3591
|
-
function runMcp(args) {
|
|
3592
|
-
const mod = join(dirname(fileURLToPath(import.meta.url)), "mcp.js");
|
|
3593
|
-
const r = spawnSync(process.execPath, [mod, ...args], { stdio: "inherit" });
|
|
3594
|
-
process.exit(r.status ?? 1);
|
|
3595
|
-
}
|
|
3596
|
-
// geml skill install: one command that makes GEML usable everywhere for a
|
|
3597
|
-
// Claude Code user — the authoring skill resident under ~/.claude/skills/geml,
|
|
3598
|
-
// the CLI on the global PATH, and the MCP server registered at user scope.
|
|
3599
|
-
// Deliberately quiet: no settings.json edits, no hooks, no .gemlhistory
|
|
3600
|
-
// sidecars. Idempotent, so re-running after an upgrade refreshes everything.
|
|
3601
|
-
function runSkill(args) {
|
|
3602
|
-
const sub = args[0];
|
|
3603
|
-
if (sub !== "install")
|
|
3604
|
-
fail(`unknown skill subcommand '${sub ?? ""}'.\n${SUBHELP.skill}`);
|
|
3605
|
-
const rest = args.slice(1);
|
|
3606
|
-
const flag = (name) => {
|
|
3607
|
-
const i = rest.indexOf(name);
|
|
3608
|
-
if (i >= 0)
|
|
3609
|
-
rest.splice(i, 1);
|
|
3610
|
-
return i >= 0;
|
|
3611
|
-
};
|
|
3612
|
-
const opt = (name) => {
|
|
3613
|
-
const i = rest.indexOf(name);
|
|
3614
|
-
if (i < 0)
|
|
3615
|
-
return undefined;
|
|
3616
|
-
const v = rest[i + 1];
|
|
3617
|
-
if (!v)
|
|
3618
|
-
fail(`${name} needs a value.\n${SUBHELP.skill}`);
|
|
3619
|
-
rest.splice(i, 2);
|
|
3620
|
-
return v;
|
|
3621
|
-
};
|
|
3622
|
-
const noGlobal = flag("--no-global");
|
|
3623
|
-
const noMcp = flag("--no-mcp");
|
|
3624
|
-
const dest = opt("--dest") ?? join(homedir(), ".claude", "skills");
|
|
3625
|
-
if (rest.length)
|
|
3626
|
-
fail(`unexpected argument '${rest[0]}'.\n${SUBHELP.skill}`);
|
|
3627
|
-
// The skill ships inside the npm package, next to dist/ — the installed
|
|
3628
|
-
// skill text always matches the CLI version it teaches.
|
|
3629
|
-
const src = join(dirname(fileURLToPath(import.meta.url)), "..", "skill");
|
|
3630
|
-
if (!existsSync(join(src, "SKILL.md")))
|
|
3631
|
-
fail(`bundled skill not found at ${src} (broken install?)`, 1);
|
|
3632
|
-
const target = join(dest, "geml");
|
|
3633
|
-
const copied = [];
|
|
3634
|
-
const copyTree = (from, to) => {
|
|
3635
|
-
mkdirSync(to, { recursive: true });
|
|
3636
|
-
for (const e of readdirSync(from, { withFileTypes: true })) {
|
|
3637
|
-
// Never ship a history sidecar — skill and config docs carry none.
|
|
3638
|
-
if (e.name.endsWith(".gemlhistory"))
|
|
3639
|
-
continue;
|
|
3640
|
-
const f = join(from, e.name);
|
|
3641
|
-
const t = join(to, e.name);
|
|
3642
|
-
if (e.isDirectory())
|
|
3643
|
-
copyTree(f, t);
|
|
3644
|
-
else {
|
|
3645
|
-
copyFileSync(f, t);
|
|
3646
|
-
copied.push(relative(dest, t));
|
|
3647
|
-
}
|
|
3648
|
-
}
|
|
3649
|
-
};
|
|
3650
|
-
try {
|
|
3651
|
-
copyTree(src, target);
|
|
3652
|
-
}
|
|
3653
|
-
catch (e) {
|
|
3654
|
-
// A clean one-liner, never a raw stack: --dest may name a file, a
|
|
3655
|
-
// read-only tree, or a path whose ancestor is not a directory.
|
|
3656
|
-
fail(`cannot install skill to ${target}: ${e instanceof Error ? e.message : String(e)}`, 1);
|
|
3657
|
-
}
|
|
3658
|
-
console.log(`skill installed -> ${target} (${copied.join(", ")})`);
|
|
3659
|
-
// Windows npm/claude/geml are .cmd shims: they need a shell. Every argument
|
|
3660
|
-
// below is a fixed literal, so shell:true adds no injection surface.
|
|
3661
|
-
const sh = process.platform === "win32";
|
|
3662
|
-
const run = (cmd, a, inherit = false) => spawnSync(cmd, a, { shell: sh, encoding: "utf8", ...(inherit ? { stdio: "inherit" } : {}) });
|
|
3663
|
-
if (!noGlobal) {
|
|
3664
|
-
const have = run("geml", ["--version"]);
|
|
3665
|
-
if (have.status === 0) {
|
|
3666
|
-
console.log(`cli ${String(have.stdout ?? "").trim()} already on PATH`);
|
|
3667
|
-
}
|
|
3668
|
-
else {
|
|
3669
|
-
console.log("cli installing @geml/geml globally (npm i -g)...");
|
|
3670
|
-
const r = run("npm", ["install", "-g", "@geml/geml", "--no-audit", "--no-fund", "--loglevel=error"], true);
|
|
3671
|
-
if (r.status !== 0)
|
|
3672
|
-
console.error("cli global install failed — install later with: npm i -g @geml/geml");
|
|
3673
|
-
}
|
|
3674
|
-
}
|
|
3675
|
-
if (!noMcp) {
|
|
3676
|
-
const REG = "claude mcp add --scope user geml -- npx -y @geml/geml mcp --root .";
|
|
3677
|
-
const claude = run("claude", ["--version"]);
|
|
3678
|
-
if (claude.status !== 0) {
|
|
3679
|
-
console.log(`mcp claude CLI not found — register later with: ${REG}`);
|
|
3680
|
-
}
|
|
3681
|
-
else if (run("claude", ["mcp", "get", "geml"]).status === 0) {
|
|
3682
|
-
console.log("mcp server 'geml' already registered");
|
|
3683
|
-
}
|
|
3684
|
-
else {
|
|
3685
|
-
const r = run("claude", ["mcp", "add", "--scope", "user", "geml", "--", "npx", "-y", "@geml/geml", "mcp", "--root", "."]);
|
|
3686
|
-
if (r.status === 0)
|
|
3687
|
-
console.log("mcp registered user-scope server 'geml' (confined to each session's project directory)");
|
|
3688
|
-
else
|
|
3689
|
-
console.error(`mcp registration failed (${String(r.stderr ?? "").trim() || "unknown"}) — register later with: ${REG}`);
|
|
3690
|
-
}
|
|
3691
|
-
}
|
|
3692
|
-
console.log("done — new Claude Code sessions pick up the skill.");
|
|
3693
|
-
process.exit(0);
|
|
3694
1907
|
}
|
|
3695
|
-
//
|
|
3696
|
-
//
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
return realpathSync(argv1);
|
|
3703
|
-
}
|
|
3704
|
-
catch {
|
|
3705
|
-
return argv1;
|
|
3706
|
-
}
|
|
3707
|
-
})();
|
|
3708
|
-
// `entry` must be non-empty: in a browser bundle both sides degenerate to ""
|
|
3709
|
-
// (esbuild defines process.argv=[] and import.meta.url="", and the node-stub's
|
|
3710
|
-
// fileURLToPath is String()), which would run the CLI at import time and crash
|
|
3711
|
-
// the page. A real CLI invocation always has argv[1].
|
|
3712
|
-
if (entry && (entry === fileURLToPath(import.meta.url) || entry.endsWith("geml.ts"))) {
|
|
3713
|
-
const argv = process.argv.slice(2);
|
|
3714
|
-
// The on-disk artifact is `.geml-code-graph/`, so people reconstruct the
|
|
3715
|
-
// command from the directory name — accept those spellings as `codemap`.
|
|
3716
|
-
const cmd = argv[0] === "codegraph" || argv[0] === "code-graph" ? "codemap" : argv[0];
|
|
3717
|
-
jsonMode = argv.includes("--json");
|
|
3718
|
-
const rest = argv.slice(1);
|
|
3719
|
-
if (cmd === "--help" || cmd === "-h") {
|
|
3720
|
-
console.log(USAGE);
|
|
3721
|
-
}
|
|
3722
|
-
else if (cmd === "--version" || cmd === "-V") {
|
|
3723
|
-
if (jsonMode)
|
|
3724
|
-
console.log(JSON.stringify({ parser: PARSER_VERSION, spec: VERSION }));
|
|
3725
|
-
else
|
|
3726
|
-
console.log(`geml ${PARSER_VERSION} (GEML spec ${VERSION})`);
|
|
3727
|
-
}
|
|
3728
|
-
else if (cmd === undefined) {
|
|
3729
|
-
console.error(USAGE);
|
|
3730
|
-
process.exit(2);
|
|
3731
|
-
}
|
|
3732
|
-
else if (SUBHELP[cmd] && (rest.includes("--help") || rest.includes("-h"))) {
|
|
3733
|
-
// `geml <cmd> --help` is a help request, not a usage error: usage to
|
|
3734
|
-
// stdout, exit 0 — never the `error:`-prefixed exit-2 path.
|
|
3735
|
-
console.log(SUBHELP[cmd]);
|
|
3736
|
-
}
|
|
3737
|
-
else if (cmd === "get") {
|
|
3738
|
-
runGet(argv.slice(1));
|
|
3739
|
-
}
|
|
3740
|
-
else if (cmd === "set") {
|
|
3741
|
-
runSet(argv.slice(1));
|
|
3742
|
-
}
|
|
3743
|
-
else if (cmd === "add") {
|
|
3744
|
-
runAdd(argv.slice(1));
|
|
3745
|
-
}
|
|
3746
|
-
else if (cmd === "delete") {
|
|
3747
|
-
runDelete(argv.slice(1));
|
|
3748
|
-
}
|
|
3749
|
-
else if (cmd === "rename") {
|
|
3750
|
-
runRename(argv.slice(1));
|
|
3751
|
-
}
|
|
3752
|
-
else if (cmd === "revert") {
|
|
3753
|
-
runRevert(argv.slice(1));
|
|
3754
|
-
}
|
|
3755
|
-
else if (cmd === "history") {
|
|
3756
|
-
runHistory(argv.slice(1));
|
|
3757
|
-
}
|
|
3758
|
-
else if (cmd === "check") {
|
|
3759
|
-
runCheck(argv.slice(1));
|
|
3760
|
-
}
|
|
3761
|
-
else if (cmd === "codemap") {
|
|
3762
|
-
runCodemap(argv.slice(1));
|
|
3763
|
-
}
|
|
3764
|
-
else if (cmd === "mcp") {
|
|
3765
|
-
runMcp(argv.slice(1));
|
|
3766
|
-
}
|
|
3767
|
-
else if (cmd === "skill") {
|
|
3768
|
-
runSkill(argv.slice(1));
|
|
3769
|
-
}
|
|
3770
|
-
else if (cmd !== "-" && !/[.\/\\]/.test(cmd)) {
|
|
3771
|
-
// A bare word that is neither a known command nor a path is almost always
|
|
3772
|
-
// a mistyped command — say so, don't try to read it as a file. (The
|
|
3773
|
-
// reclaimed verbs render/export/fmt/convert land here too.)
|
|
3774
|
-
fail(`unknown command '${cmd}'. Run 'geml --help'.`);
|
|
3775
|
-
}
|
|
3776
|
-
else {
|
|
3777
|
-
// A file (or stdin via '-') is the transform entry: `--to`/`--from`/`-o`,
|
|
3778
|
-
// default `--to json`. The single door for every format conversion.
|
|
3779
|
-
runTransform(argv);
|
|
3780
|
-
}
|
|
1908
|
+
// Backwards compatibility: the CLI moved to cli.ts, but `node …/dist/geml.js`
|
|
1909
|
+
// is what this repo's hooks, the codemap recipes and older instructions all
|
|
1910
|
+
// invoke. Hand those off — a dynamic, non-literal specifier, so a bundler
|
|
1911
|
+
// leaves it alone and the browser never resolves it (argv is [] there, so the
|
|
1912
|
+
// guard is false and this line never runs).
|
|
1913
|
+
if (isCliInvocation()) {
|
|
1914
|
+
void import(new URL("./cli.js", import.meta.url).href);
|
|
3781
1915
|
}
|