on master help sign in
ordered children: text and nodes interleave under (text | item)* section*, every verb clamps placement and leaves text in place, heading embeds for section blocks, merge compares text children (spec 0.39)
commit75d65c07764eb0a4e73da648eb8bd033b2d1a522 authoriko <ilyakooo0@gmail.com> dateSat 26 Sep 2026, 03:15 (1 month ago) parent00c71f7
11 files changed+1310−488
SPEC.md+142−49 crates/fold-core/src/check.rs+63−14 crates/fold-core/src/edit.rs+24−5 crates/fold-core/src/merge.rs+192−49 crates/fold-core/src/ops.rs+314−277 crates/fold-core/src/parse.rs+87−62 crates/fold-core/src/render.rs+24−16 crates/fold-core/src/tree.rs+37−15 crates/fold-core/tests/ordering.rs+415 crates/fold-tui/src/app.rs+1−1 crates/fold-tui/tests/fixes.rs+11
SPEC.md+142−49
@@ -1,9 +1,19 @@
11# SPEC — a tree-shaped plain-text notes and task manager for the terminal
22
3−Status: draft 0.38 · 2026-09-12
3+Status: draft 0.39 · 2026-09-26
44Working name: not chosen yet. This document uses `notes` as the binary name; rename freely.
55Language: Rust · TUI: ratatui · Sync: Syncthing · History: file versioning on one node
66
7+Changes in 0.39: a node's children are an ordered list of **text children** and **child
8+nodes**, interleaved as they are in the file; the body is just the leading text (§3.1, §3.3).
9+Children obey one ordering rule, `(text | item)* section*`, because Markdown cannot close a
10+heading: every verb that places a node keeps it, and `~` moves the node it respells to the
11+boundary between items and sections instead of silently re-parenting its siblings (§3.1,
12+§10.3). Text children are not outline rows and stay where they are when nodes around them
13+move. A section block's embed is a **heading embed**, `## ![[id]]`, so it keeps its place
14+among section siblings (§4.7). Merge compares a node's text children as one field and places
15+insertions by the ordering rule (§12.4).
16+
717Changes in 0.38: no external editor in 1.0. `E`, the inline-frontmatter spelling, the
818`inline` render mode and `$EDITOR` are gone; the built-in editor is the only editor (§10.6,
919§17). Frontmatter never appears as text anywhere.
@@ -221,7 +231,9 @@ Principles, in priority order:
221231|---|---|
222232| Vault | A directory containing `root.md` and, optionally, block files. |
223233| Tree | The single logical outline formed by `root.md` plus all block files stitched in. |
224−| Node | One thing in the outline: a title line, a body, children. Spelled as a section or an item. |
234+| Node | One thing in the outline: a title line and its children. Spelled as a section or an item. |
235+| Children | A node's ordered content: text children and child nodes, interleaved (§3.1). |
236+| Text child | A run of lines among a node's children that is not a node: prose, code, a quote (§3.3). |
225237| Section | A node spelled as an ATX heading `#`, `##`, … (no upper bound). |
226238| Item | A node spelled as a `- ` bullet. |
227239| Spelling | Whether a node is written as a heading or a bullet. Presentation only (§3.1). |
@@ -230,11 +242,11 @@ Principles, in priority order:
230242| Id | A random four-word phonemic name (`racfer-hattes-dozzod-binwes`) in a block's `id:` frontmatter; its stable identity. |
231243| Name | The slug of a block's title. With a prefix of the id in front, its filename. Decoration, not identity. |
232244| Property | A key in a block's YAML frontmatter. |
233−| Body | Block content belonging to a node before its first child. |
245+| Body | A node's leading text children: what comes before its first child node. |
234246| Zoom | Viewing a node as a standalone block via `render(node, base)`. |
235247| Splice | Writing an edited zoomed block back into its source span. |
236248| Make a block | Giving a node an id and its own file, leaving an embed in the parent. |
237−| Embed | `![[id]]` alone on a line at the node's position in the parent. |
249+| Embed | `![[id]]` alone on a line (or as a heading, `## ![[id]]`) at the node's position in the parent. |
238250| Span | A byte range in a file. Every node knows its span. |
239251| Path | A node's address by ancestor titles: `Homelab/NAS/ZFS layout`. |
240252
@@ -252,16 +264,47 @@ Section — spelled as a heading; may hold prose; children of either spelling
252264Item — spelled as a bullet; may hold note lines; children of either spelling
253265```
254266
267+A node's **children** are an ordered list in which two things interleave: **child nodes**
268+and **text children** (§3.3), in the order the file has them:
269+
270+```
271+Trip ← section
272+├─ text "Plan below."
273+├─ item book flights
274+├─ item book hotel
275+└─ text "Budget is tight, so check both before paying."
276+```
277+
255278Rules:
256279
257280- Any node may be the child of any node. A heading under a bullet is a section whose parent
258281 is an item; a bullet under a heading is an item whose parent is a section.
282+- **Ordering rule.** Every node's children match
283+
284+ ```
285+ children := (text | item)* section*
286+ ```
287+
288+ Markdown has no way to close a heading: anything written after a section child — text or
289+ an item at that position — belongs to that section. So once a section child appears, only
290+ section children follow it. The parser produces nothing else; every verb that places a
291+ node (§6.5, §10.3, §12.4) keeps the rule by **clamping**: an item goes no later than just
292+ before its new parent's first section child, and a section no earlier than just after its
293+ last item child. The *boundary* of a node's children is the position between its last
294+ item or text child and its first section child.
295+- **Text children are content, not outline.** They are not rows in the outline pane, not
296+ targets, never blocks or tasks, and are never moved on their own. A verb that moves,
297+ deletes or refiles a node moves exactly that node's own lines; the text children around it
298+ stay where they are, with the siblings they were between. They move only with the node
299+ that contains them.
259300- Heading level and indent are **derived from position**, never stored:
260301 `level(n) = 1 + number of section ancestors of n`, and `indent(n) = 2 × number of item
261302 ancestors of n`. A section under an item is written at that item's child indent with its
262303 own level. There is no upper bound on levels.
263−- Spelling is presentation: `~` toggles it (§10.3) and nothing else changes. Both spellings
264− can be tasks, hold bodies, be refiled anywhere, and be made into blocks.
304+- Spelling is presentation: `~` toggles it (§10.3) and nothing else about the node changes.
305+ Both spellings can be tasks, hold text, be refiled anywhere, and be made into blocks. The
306+ one consequence of spelling is position: under the ordering rule a node respelled by `~`
307+ moves to its parent's boundary, so its siblings keep their parent.
265308- Sibling order is block order and is meaningful.
266309- Any node may be a **block** (the root of its own file). Its file starts with the node
267310 as it is spelled — a heading or a bullet — so the file is the only place its spelling
@@ -302,16 +345,32 @@ nothing reorders it on insert.
302345Everything else (`tags`, `priority`, `est`, `waiting`, `since`, `author`, `source`, …) is
303346user-defined, and the app never looks at it.
304347
305−### 3.3 Bodies
348+### 3.3 Text children
306349
307−A body is an ordered list of raw lines, opaque to the tree. The parser only needs to know
350+A text child is a run of consecutive raw lines, opaque to the tree, between two structural
351+points: the title line, a child node, or the end of the node. The parser only needs to know
308352enough to find the *next title line*: it tracks fenced code blocks (```` ``` ```` and `~~~`,
309353any indent) so that `#` or `- ` inside a fence never starts a node. Paragraphs, quotes,
310−tables, images, HTML and code are all body.
354+tables, images, HTML and code are all text.
355+
356+A text line belongs to the deepest open node whose region it reaches:
357+
358+- an item's region is lines indented at least `indent + 2`;
359+- a section's region is lines at or beyond its own indent;
360+- the root's region is everything.
311361
312−For items, body lines are those indented at least `indent + 2` that are not child title
313−lines (bullets or headings). For sections, body is everything from the title line to the
314−first child, at the section's own indent.
362+So a line at column 0 after `- b` under `# Trip` is Trip's text, not b's: it ends the list.
363+(CommonMark would read an unindented line right after a list item with no blank line between
364+as a lazy continuation of that item; this format does not. Write a blank line before it and
365+both agree.)
366+
367+A blank line belongs to the text run it is in, or — between nodes — to the deepest open node
368+before it: the blank line after `- a` is `a`'s trailing separator. Blank lines are therefore
369+never lost or invented when a node is rendered or moved.
370+
371+The **body** of a node is its leading text: the text children before its first child node.
372+It is what the reading pane shows under the title before any child, and what "a node with a
373+body" means elsewhere in this spec.
315374
316375### 3.4 Identity and addressing
317376
@@ -412,8 +471,8 @@ Canonicalization is **lazy**: a node is rewritten in canonical form only when it
412471everything at once.
413472
414473**What is ours.** A standard Markdown renderer will mishandle exactly these four things:
415−`![[id]]` embeds, heading levels beyond six, checkboxes on headings, and headings indented
416−under bullets. Nothing else in a vault is non-standard.
474+`![[id]]` embeds (bare or as a heading), heading levels beyond six, checkboxes on headings,
475+and headings indented under bullets. Nothing else in a vault is non-standard.
417476
418477### 4.3 Title lines
419478
@@ -475,17 +534,31 @@ External links are ordinary Markdown `[text](url)` and are opened with `xdg-open
475534
476535### 4.7 Embeds
477536
478−An embed is where a block is stitched into the tree: `![[id]]` alone on a line, at the
479−indent the node has at that position.
537+An embed is where a block is stitched into the tree. It has two forms, one per position in
538+the ordering rule (§3.1):
539+
540+- **bare**, `![[id]]` alone on a line at the node's indent — an item position;
541+- **heading**, `## ![[id]]` — a heading line holding nothing but the embed, at the level and
542+ indent a section has at that position.
480543
481544```
482545![[dozzod-binwes-talsun-worbec]]
483546 ![[racfer-hattes-mislup-nodrys]] -- a child of the item above it
547+## ![[lacnum-walbyn-dirlyn-havtyp]] -- a section-position embed
484548```
485549
486−The embed names the block by id and nothing else; title, spelling, checkbox or `todo`,
487−properties, body and children all live in the file. An embed line has no children in the
488−parent file; child lines indented under an embed are a diagnostic.
550+The app writes the form that matches the block's spelling: a block whose root is a section
551+is embedded with a heading embed, a block whose root is an item with a bare one. The
552+heading form exists because a bare embed after a section sibling would, like any line
553+there, belong to that section (§3.1). Either form is read anywhere; an embed whose form
554+does not match its block's spelling is a diagnostic (§15.7) that `notes check --fix`
555+rewrites, and it is placed where its form puts it.
556+
557+The embed names the block by id and nothing else; title, checkbox or `todo`, properties,
558+text and children all live in the file. The embed's form repeats the block's spelling so
559+that the parent file parses into the right shape without opening the block. An embed line
560+has no children in the parent file; lines indented under a bare embed, or nested under a
561+heading embed, are a diagnostic.
489562
490563`![[` anywhere else is body text. The app does not implement general transclusion.
491564
@@ -501,8 +574,7 @@ A block's file is exactly `render(node, 1, resolve_blocks = false)`:
501574```
502575[frontmatter, always beginning with id; todo: if the block is a task]
503576[the node's title line, as spelled: "# Title" or "- Title"]
504−[body]
505−[children]
577+[its children: text and nodes, in order]
506578```
507579
508580- The root is written as it is spelled in the tree. A block that is a heading is a `#` at column 0; one
@@ -527,7 +599,7 @@ Two boxes in the closet, one at Hetzner.
527599
528600## NAS
529601
530−![[dozzod-binwes-talsun-worbec]]
602+### ![[dozzod-binwes-talsun-worbec]]
531603
532604## Networking
533605
@@ -576,8 +648,10 @@ due: 2026-09-20
576648 Two options, noted under Networking.
577649```
578650
579−Note that "Replace the flaky switch" is a one-line task with no file; "Order new switch"
580−became a block the moment it got a due date, its checkbox became `todo: open`, and its
651+Note that "Replace the flaky switch" is a one-line task with no file; "ZFS layout" is a
652+section block, so its embed is a heading at the level it has under NAS, while "Order new
653+switch" is an item block and is embedded bare. "Order new switch" became a block the moment
654+it got a due date, its checkbox became `todo: open`, and its
581655file starts with a bullet because that is what it is. "Options" is
582656a section nested under an item, and "Snapshot policy" is a section that is itself a task.
583657
@@ -592,10 +666,11 @@ Emits the node and its subtree, in document order, as a standalone Markdown docu
5926661. If the node is a block and `base == 1` and `resolve_blocks = false`: its frontmatter.
5936672. The node's title line — checkbox included — at level `base` for a section, or at
594668 indent 0 for an item.
595−3. The node's body, verbatim (bodies dedented by the node's original indent).
596−4. Each descendant, recursively: sections re-levelled by `base − level(node)`; everything
597− re-indented by `−indent(node)`.
598−5. Embeds: with `resolve_blocks = false`, written as embeds. With `resolve_blocks = true`, the
669+3. The node's children in order: each text child verbatim (dedented by the node's original
670+ indent), each child node recursively — sections re-levelled by `base − level(node)`,
671+ everything re-indented by `−indent(node)`.
672+4. Blank lines are text and are reproduced where they are (§3.3); `render` adds none.
673+5. Embeds: with `resolve_blocks = false`, written as embeds in their form (§4.7). With `resolve_blocks = true`, the
599674 block's subtree is inlined at the embed's position, its frontmatter omitted, its own
600675 embeds resolved in turn. The resolved text contains no embed, no id, no frontmatter and no
601676 file boundary: it is titles, checkboxes, bodies and nesting, nothing else.
@@ -628,7 +703,8 @@ A block is **dirty** when any line it owns changed, or when the position of a bl
628703in it moved. `splice(block)` writes one dirty block:
629704
6307051. Collect the block's owned lines in buffer order, and put each nested block back as an
631− embed at the position of that block's title line. The result is exactly an edited
706+ embed at the position of that block's title line — a heading embed if that title line is
707+ a heading, a bare one if it is a bullet (§4.7). The result is exactly an edited
632708 `render(block, 1, false)`: one file's text, embeds intact, ids never typed.
6337092. Parse it: title lines, bodies, nesting, embeds. Exactly one root-level node; a `---`
634710 line is body text.
@@ -683,8 +759,10 @@ demotes anything. The level-overflow rule of earlier drafts is gone.
6837591. Generate an id and a name (§6.4). The file is `<prefix>~<name>.md` in the vault root.
6847602. Write `render(node, 1, resolve_blocks = false)` to the file atomically, with frontmatter
685761 `id: <id>` and, if the block was made by setting a property, that property.
686−3. Replace the node's span in its parent file with an embed, `![[id]]`, at the node's
687− indent. A checkbox on the node becomes `todo: <state>` in the frontmatter (§4.5).
762+3. Replace the node's span in its parent file with an embed at the node's position: a
763+ heading embed at its level for a section, a bare `![[id]]` at its indent for an item
764+ (§4.7). The text children around the node stay in the parent. A checkbox on the node
765+ becomes `todo: <state>` in the frontmatter (§4.5).
6887664. Update the index.
689767
690768There is no inverse. A block stays a file until it is deleted (`d`, *clear done*, or
@@ -769,6 +847,12 @@ the re-levelled text at the destination (§5). Refiling a block moves only its e
769847the file does not move. Any node may be refiled under any node;
770848spelling is preserved. A task block's state travels with its file, not its embed.
771849
850+The subtree becomes the destination's **last child, clamped** by the ordering rule (§3.1): a
851+section goes after everything; an item goes after the destination's last item or text child,
852+just before its first section child. `Ctrl-Enter` (first child) is clamped the same way: a
853+section as first child lands just after the destination's last item. Text children of the
854+source's old parent stay behind.
855+
772856**Archive.** Archiving is refile with a fixed destination: the subtree is appended as the
773857last child of the top-level section titled `Archive` (case-insensitive, like `Inbox`;
774858created as the last top-level section of `root.md` if missing). Nothing else happens — no
@@ -786,7 +870,9 @@ delete it. Nothing in the archive is hidden or dimmed.
786870- **Capture** (`c` in the TUI, `notes capture`, a global hotkey via the CLI) appends an item
787871 under today's day: the child section of `Inbox` titled with today's date, `YYYY-MM-DD`,
788872 created on demand as the last child. `--task` or a leading `[ ]` makes it a task.
789− `--to <target>` captures under any node instead. Capture never creates a block; dates
873+ `--to <target>` captures under any node instead. A captured item is clamped like any
874+ inserted item (§3.1): under a target with section children it lands just before the first
875+ of them. Capture never creates a block; dates
790876 and properties are set in the TUI afterwards.
791877- Days are ordinary sections, appended in order, so the inbox reads oldest to newest. They
792878 can hold prose, tasks and sub-sections like any section, and `2026-09-11` addresses
@@ -917,17 +1003,17 @@ aside (§17, open decision 16); the ```` ```query ```` fence is reserved for it
9171003| `Ctrl-d` / `Ctrl-u` | half-page down / up |
9181004| `Enter` | zoom: reading pane shows the cursor node; reading pane takes focus |
9191005| `Backspace` | zoom out to parent |
920−| `>` / `<` | demote / promote: become the last child of the previous sibling / the next sibling of the parent. Spelling unchanged |
921−| `~` | toggle spelling, section ↔ item (subtree unchanged) |
922−| `J` / `K` | move node down / up among siblings |
923−| `n` / `N` | new sibling after cursor / new last child: inserts an empty node and opens it with `e` |
1006+| `>` / `<` | demote / promote: become the last child of the previous sibling node / the next sibling of the parent, both clamped by the ordering rule (§3.1): an item promoted out of a section lands just before the parent's first section sibling. Spelling unchanged |
1007+| `~` | toggle spelling, section ↔ item (subtree unchanged). The node moves to its parent's boundary (§3.1): an item respelled as a section becomes the first section child, a section respelled as an item the last item child, so no sibling changes parent |
1008+| `J` / `K` | move node down / up past the next / previous sibling node; text children stay put. An item never moves below a section sibling, nor a section above an item: the move is refused with a message |
1009+| `n` / `N` | new sibling after cursor / new last child, spelled like the cursor node / like the last child node (a section if the parent has section children): inserts an empty node and opens it with `e` |
9241010| `e` | edit the subtree's Markdown in the built-in editor; saves as you go (§10.6) |
9251011| `a` | property editor: a form over the node's properties (§10.6); first property on a plain node makes it a block |
9261012| `x` | toggle task open / done (checkbox, or `todo:` plus `done:` on a block) |
9271013| `t` | toggle task-ness: adds or removes the checkbox, or the `todo` key on a block |
9281014| `s` | make the node a block |
9291015| `y` / `d` | yank / delete subtree into the register (delete goes to trash too) |
930−| `p` / `P` | paste register after / before cursor as sibling |
1016+| `p` / `P` | paste register after / before cursor as sibling, clamped by the ordering rule (§3.1) |
9311017| `r` | refile: fuzzy-pick a destination; `Ctrl-Enter` = as first child |
9321018| `c` / `C` | capture to the inbox as bullet / as task |
9331019| `/` | filter box (§10.5) |
@@ -951,7 +1037,8 @@ aside (§17, open decision 16); the ```` ```query ```` fence is reserved for it
9511037### 10.5 Filter box
9521038
9531039`/` opens a single input over the outline. Typing filters the outline live by fuzzy title
954−match (nucleo) and, after a 150 ms pause, by full-text match, showing ancestors of hits.
1040+match (nucleo) and, after a 150 ms pause, by full-text match over every text child, showing
1041+ancestors of hits.
9551042`Enter` on a highlighted hit zooms to it; `Enter` with no hit does nothing. `Esc` clears.
9561043Creating nodes is `n` / `N` (§10.3).
9571044
@@ -1127,21 +1214,23 @@ Both versions are parsed into trees (embeds unresolved; each file merges on its
11271214A retitled node therefore appears twice after a merge — once per title. Two-way merging
11281215never deletes, so this is the safe failure; delete the copy you don't want.
11291216
1130−**Per matched node**, for each of title, checkbox state, body (as text), and — for the
1131−file's block — each frontmatter key independently (`todo` included): `O == T → take O`,
1132−otherwise **conflict**. A conflict pair is raised for every field that differs, even when
1217+**Per matched node**, for each of title, checkbox state, text (all its text children, with
1218+their positions among its child nodes, as one field), and — for the file's block — each
1219+frontmatter key independently (`todo` included): `O == T → take O`, otherwise **conflict**. A conflict pair is raised for every field that differs, even when
11331220only one side touched it; the cost of having no per-device state is paid here, in
11341221resolution clicks, never in lost text.
11351222
11361223**Children** are merged as sequences of matched nodes: nodes present on one side only are
1137−insertions, placed relative to their matched neighbours. Nothing is ever treated as
1224+insertions, placed relative to their matched neighbours and clamped by the ordering rule
1225+(§3.1). `O`'s text children are emitted where `O` has them. Nothing is ever treated as
11381226deleted.
11391227
11401228**Conflict output**: the `O` version stays in place. The `T` version is written as a new
11411229block `<prefix>~<name>.md` (a fresh id; the name is `O`'s name) with frontmatter
11421230`conflict: "<device> <timestamp>"` (plus, for a block conflict, `T`'s own properties),
1143−and an embed to it is inserted as the next sibling of `O`. The conflict block is
1144−therefore always the node immediately after the one it conflicts with; no other link
1231+and an embed to it is inserted as the next sibling of `O`, in the form matching `O`'s
1232+spelling (§4.7) — for a section, a heading embed after its whole subtree. The conflict block
1233+is therefore always the node immediately after the one it conflicts with; no other link
11451234between them is stored. No text is ever lost: every node from `O` and `T` appears in the
11461235result exactly once.
11471236
@@ -1154,7 +1243,7 @@ The conflict view (§10.8) lists every `conflict:` block, each shown against the
11541243before it, and the status line counts them. Resolving a pair:
11551244
11561245- **keep ours** — delete the conflict block and its embed (to trash);
1157−- **keep theirs** — replace ours' title, body and children with the conflict block's,
1246+- **keep theirs** — replace ours' title and children (text and nodes) with the conflict block's,
11581247 and its frontmatter with the conflict block's minus `id` and `conflict`; then delete
11591248 the conflict block and its embed (to trash);
11601249- **keep both** — drop the `conflict` key; the block stays where it is as an ordinary
@@ -1236,8 +1325,7 @@ pub struct Node {
12361325 kind: Kind,
12371326 title: String,
12381327 task: Option<TaskState>, // any node; from the checkbox, or `todo` for a block
1239− body: Vec<String>, // raw lines, opaque
1240− children: Vec<NodeId>,
1328+ content: Vec<Content>, // ordered children: text runs and nodes (§3.1)
12411329 parent: Option<NodeId>,
12421330 file: FileId,
12431331 span: Span, // bytes in `file`: title line through subtree
@@ -1253,6 +1341,8 @@ pub struct Block {
12531341 edge_span: Span, // the embed line in the parent file
12541342}
12551343
1344+pub enum Content { Text(Span), Node(NodeId) } // `(Text | Item)* Section*` (§3.1)
1345+
12561346pub struct Span { start: usize, end: usize }
12571347
12581348pub struct Tree { nodes: Vec<Node>, root: NodeId, files: Vec<FileState> }
@@ -1302,6 +1392,9 @@ Property-based, since the whole design rests on a few laws:
13021392- `parse(render(t, 1, false))` equals `t` re-levelled, for all generated trees `t`.
13031393- `render(parse(s), 1, false) == s` for all canonical blocks `s`, frontmatter included.
13041394- `canonicalize` is idempotent.
1395+- Every verb (`J`/`K`, `>`/`<`, `~`, `p`/`P`, refile, capture, `n`/`N`, make block, merge)
1396+ leaves every node's children satisfying `(text | item)* section*`, keeps every other node's
1397+ parent, and moves no text child it was not asked to move.
13051398- `splice(node, render(node, 1, true))` is a no-op on disk, for any node.
13061399- Every splice writes exactly one file; a save writes one splice per dirty block.
13071400- `render(make_block(n), 1, false)` equals `render(n, 1, false)` plus the generated frontmatter,
@@ -1316,7 +1409,7 @@ of real-world Markdown (Obsidian, Logseq, FSNotes exports) that must parse witho
13161409
13171410`notes check` reports, with `ariadne`-rendered source spans: non-canonical syntax, empty
13181411titles, titles containing `/`, broken, duplicate or cyclic embeds, embeds with children in the
1319−parent file, malformed block files, invalid or duplicate `id` keys, ignored `.md` files,
1412+parent file, embeds whose form (bare or heading) does not match their block's spelling, malformed block files, invalid or duplicate `id` keys, ignored `.md` files,
13201413filenames whose prefix or name no longer match their id or title, frontmatter outside
13211414byte 0, `due` or `done` values that are not ISO dates, and unresolved conflict blocks.
13221415The TUI
@@ -1333,7 +1426,7 @@ gets wrong. What other tools see:
13331426|---|---|---|
13341427| `rg`, `fd`, Helix | plain text | `rg '\[ \]'` and `rg '^todo: open'` together are the open task list; `rg '^done: 2026-09'` the blocks finished this month; `rg '^due:'` the dated ones |
13351428| Any phone editor | plain text | capture by appending a `- ` line to the inbox block's file; the app adds nothing to it |
1336−| Obsidian, Logseq, FSNotes | mostly readable, structurally wrong | frontmatter as properties and `- [ ]` bullets render; `![[id]]` shows as a broken embed, `## [ ]` as a literal heading, `#######` as a paragraph, indented headings as code. Fine for reading a file; do not edit structure there |
1429+| Obsidian, Logseq, FSNotes | mostly readable, structurally wrong | frontmatter as properties and `- [ ]` bullets render; `![[id]]` shows as a broken embed (a heading embed as a heading holding one), `## [ ]` as a literal heading, `#######` as a paragraph, indented headings as code. Fine for reading a file; do not edit structure there |
13371430
13381431---
13391432
crates/fold-core/src/check.rs+63−14
@@ -1,7 +1,7 @@
11//! `notes check` diagnostics (§15.7) and `--fix` canonicalization (§4.2).
22
33use crate::ident::{filename, slug, split_filename, Id};
4−use crate::parse::Kind;
4+use crate::parse::{Content, Kind};
55use crate::render::render;
66use crate::tree::NRef;
77use crate::vault::Vault;
@@ -52,6 +52,13 @@ pub fn check(vault: &Vault) -> Vec<Diagnostic> {
5252 file: f.path.clone(),
5353 message: format!("broken embed: no block with id {}", id),
5454 }),
55+ Some(b) if form_mismatch(n.kind, t.node(b).kind) => out.push(Diagnostic {
56+ file: f.path.clone(),
57+ message: format!(
58+ "embed form does not match the block's spelling: {} (§4.7)",
59+ id
60+ ),
61+ }),
5562 Some(_) => {}
5663 }
5764 if !n.children.is_empty() {
@@ -176,6 +183,11 @@ pub fn check(vault: &Vault) -> Vec<Diagnostic> {
176183 out
177184}
178185
186+/// A heading embed must embed a section, a bare one an item (§4.7).
187+fn form_mismatch(embed: Kind, block: Kind) -> bool {
188+ (embed == Kind::Section) != (block == Kind::Section)
189+}
190+
179191pub fn is_iso_date(v: &str) -> bool {
180192 v.len() == 10
181193 && v.as_bytes()[4] == b'-'
@@ -198,21 +210,45 @@ pub fn fix(vault: &mut Vault) -> std::io::Result<usize> {
198210 let top: Vec<usize> = f.nodes[f.root_node].children.clone();
199211 let canonical = if i == 0 {
200212 let Some(&first) = top.first() else { continue };
201− // frontmatter and intro text, exactly as found
213+ // frontmatter and intro text, exactly as found; then the root's
214+ // children in order — nodes rendered, text children verbatim,
215+ // one blank line between a node and what follows it unless both
216+ // are items of a tight list (§4.2)
202217 let mut s = f.text[..f.nodes[first].span.start].to_string();
203− for (idx, &k) in top.iter().enumerate() {
204− let kr = (i, k);
205− let kn = vault.tree.node(kr);
206− if idx > 0 && (kn.kind == Kind::Section || vault.tree.node((i, top[idx - 1])).kind == Kind::Section) {
207− s.push('\n');
208− }
209− match &kn.embed {
210− Some(id) => {
211− s.push_str("![[");
212− s.push_str(id.as_str());
213− s.push_str("]]\n");
218+ let root = &f.nodes[f.root_node];
219+ let from = root
220+ .content
221+ .iter()
222+ .position(|c| *c == Content::Node(first))
223+ .unwrap_or(0);
224+ let mut prev: Option<usize> = None;
225+ for c in &root.content[from..] {
226+ let loose_after_prev = prev.map(|p| {
227+ let pn = &f.nodes[p];
228+ pn.kind == Kind::Section || pn.span.text(&f.text).ends_with("\n\n")
229+ });
230+ match *c {
231+ Content::Node(k) => {
232+ let kn = &f.nodes[k];
233+ let blank = match loose_after_prev {
234+ Some(loose) => loose || kn.kind == Kind::Section,
235+ None => !s.is_empty() && !s.ends_with("\n\n"),
236+ };
237+ if blank && !s.ends_with("\n\n") {
238+ s.push('\n');
239+ }
240+ s.push_str(&render(&vault.tree, (i, k), 1, false));
241+ prev = Some(k);
242+ }
243+ Content::Text(sp) => {
244+ if prev.is_some() && !s.ends_with("\n\n") {
245+ s.push('\n');
246+ }
247+ let t = sp.text(&f.text);
248+ s.push_str(t.trim_end_matches('\n'));
249+ s.push('\n');
250+ prev = None;
214251 }
215− None => s.push_str(&render(&vault.tree, kr, 1, false)),
216252 }
217253 }
218254 s
@@ -231,6 +267,19 @@ pub fn fix(vault: &mut Vault) -> std::io::Result<usize> {
231267 count += 1;
232268 }
233269 }
270+ // embeds in the form of their block's spelling (§4.7), one at a time:
271+ // each respell may move an embed and re-parse its file
272+ for _ in 0..vault.tree.blocks.len() {
273+ let t = &vault.tree;
274+ let wrong = t.blocks.iter().find_map(|(b, id)| {
275+ let e = t.embed_of(id)?;
276+ form_mismatch(t.node(e).kind, t.node(*b).kind)
277+ .then(|| (e, t.node(*b).kind == Kind::Section))
278+ });
279+ let Some((e, to_section)) = wrong else { break };
280+ crate::ops::respell_embed(vault, e, to_section)?;
281+ count += 1;
282+ }
234283 // repair filenames (§6.4): an existing prefix that is a leading run of
235284 // the id is kept — prefixes are never lengthened — unless a file decided
236285 // earlier already holds it (a collision: the later file moves)
crates/fold-core/src/edit.rs+24−5
@@ -241,8 +241,22 @@ impl EditBuffer {
241241 if !emitted.contains(&nested) {
242242 emitted.push(nested);
243243 if let Some(id) = self.owners.get(&nested).and_then(|i| i.id.clone()) {
244− let indent = l.text.len() - l.text.trim_start_matches(' ').len();
245− text_lines.push(Line::Embed(indent, id));
244+ // the embed takes the form of the block's title line:
245+ // a heading embed for a heading (§4.7)
246+ let trimmed = l.text.trim_start_matches(' ');
247+ let indent = l.text.len() - trimmed.len();
248+ let hashes = trimmed.chars().take_while(|&c| c == '#').count();
249+ let is_heading = hashes > 0 && trimmed[hashes..].starts_with(' ');
250+ // an existing embed keeps its form, so an unchanged
251+ // splice writes it back byte for byte
252+ let heading = match vault.tree.embed_of(&id) {
253+ Some(e) if vault.tree.node(e).kind == Kind::Section => {
254+ Some(if is_heading { hashes } else { info.level + 1 })
255+ }
256+ Some(_) => None,
257+ None => is_heading.then_some(hashes),
258+ };
259+ text_lines.push(Line::Embed(indent, heading, id));
246260 }
247261 }
248262 }
@@ -259,8 +273,12 @@ impl EditBuffer {
259273 let mut fence: Option<(char, usize)> = None;
260274 for l in &text_lines {
261275 let l = match l {
262− Line::Embed(indent, id) => {
276+ Line::Embed(indent, heading, id) => {
263277 out.push_str(&" ".repeat(shift(*indent)));
278+ if let Some(h) = heading {
279+ out.push_str(&"#".repeat((*h as isize + level_delta).max(1) as usize));
280+ out.push(' ');
281+ }
264282 out.push_str("![[");
265283 out.push_str(id.as_str());
266284 out.push_str("]]\n");
@@ -352,10 +370,11 @@ impl EditBuffer {
352370}
353371
354372/// A line collected for splice: owned text, or a nested block's embed at
355−/// the display indent of its first line.
373+/// the display indent of its first line, with the heading level of that line
374+/// if it is a heading.
356375enum Line {
357376 Text(String),
358− Embed(usize, Id),
377+ Embed(usize, Option<usize>, Id),
359378}
360379
361380fn hash(text: &str) -> String {
crates/fold-core/src/merge.rs+192−49
@@ -111,8 +111,114 @@ fn standalone_tree(text: &str) -> Tree {
111111
112112fn node_sig(t: &Tree, r: NRef) -> (String, Option<TaskState>, String) {
113113 let n = t.node(r);
114− let body = n.body_lines(t.text_of(r)).join("\n");
115− (n.title.clone(), n.task, body)
114+ (n.title.clone(), n.task, text_sig(t, r))
115+}
116+
117+/// A node's text children as one field (§12.4): every run's lines, with the
118+/// number of child nodes before it, blank separator lines ignored.
119+fn text_sig(t: &Tree, r: NRef) -> String {
120+ let n = t.node(r);
121+ let mut out = String::new();
122+ for (before, sp) in n.text_runs() {
123+ let lines = run_lines(t, r, sp);
124+ if lines.is_empty() {
125+ continue;
126+ }
127+ out.push_str(&format!("{}\u{1}{}\u{2}", before, lines.join("\n")));
128+ }
129+ out
130+}
131+
132+/// A text run's lines, dedented from the node's content indent, without
133+/// leading or trailing blank lines.
134+fn run_lines(t: &Tree, r: NRef, sp: crate::parse::Span) -> Vec<String> {
135+ let n = t.node(r);
136+ let from = match n.kind {
137+ Kind::Item => n.indent + 2,
138+ Kind::Section => n.indent,
139+ Kind::Root => 0,
140+ };
141+ let mut lines: Vec<String> = sp
142+ .text(t.text_of(r))
143+ .lines()
144+ .map(|l| {
145+ let l = l.strip_suffix('\r').unwrap_or(l);
146+ if l.trim().is_empty() {
147+ String::new()
148+ } else {
149+ dedent(l, from).to_string()
150+ }
151+ })
152+ .collect();
153+ while lines.first().map(|l| l.is_empty()) == Some(true) {
154+ lines.remove(0);
155+ }
156+ while lines.last().map(|l| l.is_empty()) == Some(true) {
157+ lines.pop();
158+ }
159+ lines
160+}
161+
162+/// A text run emitted at `indent`, ending in a newline ("" if it is blank).
163+fn emit_text(t: &Tree, r: NRef, sp: crate::parse::Span, indent: usize) -> String {
164+ let ind = " ".repeat(indent);
165+ let mut out = String::new();
166+ for l in run_lines(t, r, sp) {
167+ if !l.is_empty() {
168+ out.push_str(&ind);
169+ out.push_str(&l);
170+ }
171+ out.push('\n');
172+ }
173+ out
174+}
175+
176+/// One emitted child: a node's text or a text child.
177+enum Piece {
178+ Node(Kind, String),
179+ Text(String),
180+}
181+
182+/// Join emitted children: a blank line before and after text and before a
183+/// section; items follow each other tightly.
184+fn join_pieces(pieces: Vec<Piece>) -> String {
185+ let mut out = String::new();
186+ let mut prev_node: Option<Option<Kind>> = None; // Some(None) = text
187+ for p in pieces {
188+ let (kind, text) = match p {
189+ Piece::Node(k, t) => (Some(k), t),
190+ Piece::Text(t) => (None, t),
191+ };
192+ if text.trim().is_empty() {
193+ continue;
194+ }
195+ let blank = match prev_node {
196+ None => false,
197+ Some(prev) => {
198+ kind.is_none()
199+ || prev.is_none()
200+ || kind == Some(Kind::Section)
201+ || prev == Some(Kind::Section)
202+ }
203+ };
204+ if blank && !out.ends_with("\n\n") {
205+ out.push('\n');
206+ }
207+ out.push_str(&text);
208+ prev_node = Some(kind);
209+ }
210+ out
211+}
212+
213+/// Text runs of `r` after its child nodes: child position (0-based) → the
214+/// run that follows that child.
215+fn trailing_runs(t: &Tree, r: NRef) -> Vec<(usize, crate::parse::Span)> {
216+ t.node(r)
217+ .text_runs()
218+ .into_iter()
219+ .filter(|(before, _)| *before > 0)
220+ .map(|(before, sp)| (before - 1, sp))
221+ .collect()
116222}
117223
118224/// Match children of two parents: by embed id, then exact title (§12.4).
@@ -181,31 +287,59 @@ fn merge_children(
181287 }
182288 }
183289 // Emit: matched pairs merge field-by-field; single-sided nodes are
184− // insertions; differing fields raise conflict pairs (§12.4).
185− let mut out = String::new();
186− for (ok, tk) in pairs {
187− let first_is_section = ok
188− .map(|a| o.node(a).kind == Kind::Section)
189− .or_else(|| tk.map(|b| t.node(b).kind == Kind::Section))
190− .unwrap_or(false);
191− if !out.is_empty() && first_is_section && !out.ends_with("\n\n") {
192− out.push('\n');
290+ // insertions; differing fields raise conflict pairs (§12.4). O's text
291+ // children stay where O has them; T-only nodes are placed by the
292+ // ordering rule (§3.1): items before the first section, sections last.
293+ let mut pieces: Vec<Piece> = Vec::new();
294+ if o.node(op).kind == Kind::Root {
295+ // the root's own text: O's, plus T's when it differs (never lost)
296+ if let Some(sp) = o.node(op).body_span() {
297+ pieces.push(Piece::Text(emit_text(o, op, sp, indent)));
193298 }
194− match (ok, tk) {
195− (Some(a), Some(b)) => {
196− out.push_str(&merge_pair(o, t, a, b, ctx, level, indent));
299+ if text_sig(o, op) != text_sig(t, tp) {
300+ ctx.insertions += 1;
301+ for (_, sp) in t.node(tp).text_runs() {
302+ pieces.push(Piece::Text(emit_text(t, tp, sp, indent)));
197303 }
198− (Some(a), None) => {
199− out.push_str(&emit_subtree(o, a, level, indent));
304+ }
305+ }
306+ let o_trailing = trailing_runs(o, op);
307+ let mut t_items: Vec<Piece> = Vec::new();
308+ let mut t_sections: Vec<Piece> = Vec::new();
309+ for (ok, tk) in pairs {
310+ match (ok, tk) {
311+ (Some(a), tk) => {
312+ let text = match tk {
313+ Some(b) => merge_pair(o, t, a, b, ctx, level, indent),
314+ None => emit_subtree(o, a, level, indent),
315+ };
316+ pieces.push(Piece::Node(o.node(a).kind, text));
317+ let j = o_kids.iter().position(|&k| k == a).unwrap_or(usize::MAX);
318+ for (_, sp) in o_trailing.iter().filter(|(pos, _)| *pos == j) {
319+ pieces.push(Piece::Text(emit_text(o, op, *sp, indent)));
320+ }
200321 }
201322 (None, Some(b)) => {
202323 ctx.insertions += 1;
203− out.push_str(&emit_subtree(t, b, level, indent));
324+ let piece = Piece::Node(t.node(b).kind, emit_subtree(t, b, level, indent));
325+ if t.node(b).kind == Kind::Section {
326+ t_sections.push(piece);
327+ } else {
328+ t_items.push(piece);
329+ }
204330 }
205331 (None, None) => {}
206332 }
207333 }
208− out
334+ let first_section = pieces
335+ .iter()
336+ .position(|p| matches!(p, Piece::Node(Kind::Section, _)))
337+ .unwrap_or(pieces.len());
338+ let tail = pieces.split_off(first_section);
339+ pieces.extend(t_items);
340+ pieces.extend(tail);
341+ pieces.extend(t_sections);
342+ join_pieces(pieces)
209343}
210344
211345fn merge_pair(
@@ -283,31 +417,24 @@ fn merge_pair(
283417 block_text.push_str("---\n\n");
284418 block_text.push_str(&emit_subtree_with_state(t, b, 1, 0));
285419 ctx.conflict_blocks.push((fname, block_text));
286− let embed = format!("{}![[{}]]\n", " ".repeat(indent), id);
287420 if is_file_block {
288421 // a block file has one column-0 node: the embed goes after this
289422 // file's own embed in the parent file (the caller places it)
290423 ctx.sibling_embeds.push(id);
291− emit_node_with(o, a, level, indent, &merged_kids)
292− } else if on.kind == Kind::Section {
293− // An embed after a section's children would parse as its last
294− // child; one right after its title and body, as its first child,
295− // is the closest a section's "next" position can be. The conflict
296− // view pairs a first-child conflict with its parent (§10.8).
297− let mut kids = embed;
298− if !merged_kids.trim().is_empty() {
299− if merged_kids.trim_start().starts_with('#') {
300− kids.push('\n');
301− }
302− kids.push_str(&merged_kids);
424+ return emit_node_with(o, a, level, indent, &merged_kids);
425+ }
426+ // the embed goes right after ours, as its next sibling, in the form of
427+ // ours' spelling (§4.7): a heading embed after a section's subtree
428+ let mut out = emit_node_with(o, a, level, indent, &merged_kids);
429+ if on.kind == Kind::Section {
430+ if !out.ends_with("\n\n") {
431+ out.push('\n');
303432 }
304− emit_node_with(o, a, level, indent, &kids)
433+ out.push_str(&format!("{}{} ![[{}]]\n", " ".repeat(indent), "#".repeat(level.max(1)), id));
305434 } else {
306− // the embed goes right after ours, as its next sibling
307− let mut out = emit_node_with(o, a, level, indent, &merged_kids);
308− out.push_str(&embed);
309− out
435+ out.push_str(&format!("{}![[{}]]\n", " ".repeat(indent), id));
310436 }
437+ out
311438}
312439
313440/// Render a node at (level, indent) with given already-rendered children.
@@ -398,14 +525,20 @@ fn emit_subtree_impl(t: &Tree, r: NRef, level: usize, indent: usize, show_block_
398525 Kind::Item => indent + 2,
399526 _ => indent,
400527 };
401− let mut kid_text = String::new();
402− for (i, &k) in kids.iter().enumerate() {
403− if i > 0 && t.node(k).kind == Kind::Section {
404− kid_text.push('\n');
528+ // children in order: nodes and the text runs after them (the leading
529+ // run is the body, emitted with the title)
530+ let trailing = trailing_runs(t, r);
531+ let mut pieces = Vec::new();
532+ for (j, &k) in kids.iter().enumerate() {
533+ pieces.push(Piece::Node(
534+ t.node(k).kind,
535+ emit_subtree_impl(t, k, child_level, child_indent, show_block_state),
536+ ));
537+ for (_, sp) in trailing.iter().filter(|(pos, _)| *pos == j) {
538+ pieces.push(Piece::Text(emit_text(t, r, *sp, child_indent)));
405539 }
406− kid_text.push_str(&emit_subtree_impl(t, k, child_level, child_indent, show_block_state));
407540 }
408− emit_node_with_impl(t, r, level, indent, &kid_text, show_block_state)
541+ emit_node_with_impl(t, r, level, indent, &join_pieces(pieces), show_block_state)
409542}
410543
411544fn push_task(task: Option<TaskState>, out: &mut String) {
@@ -532,19 +665,29 @@ fn place_sibling_embeds(vault: &mut Vault, owner: Option<&Id>, ids: &[Id]) -> st
532665 f.nodes
533666 .iter()
534667 .find(|nd| nd.embed.as_ref() == Some(oid))
535− .map(|nd| (fi, nd.span.end.min(f.text.len()), nd.indent))
668+ .map(|nd| {
669+ // the same form as the owner's embed (§4.7)
670+ let heading = (nd.kind == Kind::Section).then(|| nd.level.unwrap_or(1));
671+ (fi, nd.title_span.end.min(f.text.len()), nd.indent, heading)
672+ })
536673 })
537674 });
538675 let mut insert = String::new();
539676 match found {
540− Some((fi, end, indent)) => {
677+ Some((fi, end, indent, heading)) => {
541678 for id in ids {
542− insert.push_str(&format!("\n{}![[{}]]", " ".repeat(indent), id));
679+ match heading {
680+ Some(h) => insert.push_str(&format!(
681+ "\n\n{}{} ![[{}]]",
682+ " ".repeat(indent),
683+ "#".repeat(h),
684+ id
685+ )),
686+ None => insert.push_str(&format!("\n{}![[{}]]", " ".repeat(indent), id)),
687+ }
543688 }
544− // the embed line's span may or may not include its newline
545− let text = &vault.tree.files[fi].text;
546− let at = if end > 0 && text.as_bytes()[end - 1] == b'\n' { end - 1 } else { end };
547− vault.write_span(fi, crate::parse::Span { start: at, end: at }, &insert)
689+ // right after the embed's own line, before its blank separator
690+ vault.write_span(fi, crate::parse::Span { start: end, end }, &insert)
548691 }
549692 None => {
550693 let text = vault.tree.files[0].text.clone();
crates/fold-core/src/ops.rs+314−277
@@ -654,31 +654,28 @@ pub fn yank(vault: &Vault, r: NRef) -> String {
654654 render(&vault.tree, r, 1, true)
655655}
656656
657−/// Paste rendered text after/before a node as siblings (§10.3 `p`/`P`).
657+/// Paste rendered text after/before a node as siblings (§10.3 `p`/`P`),
658+/// clamped by the ordering rule (§3.1).
658659pub fn paste(vault: &mut Vault, at: NRef, text: &str, after: bool) -> std::io::Result<()> {
659− let n = vault.tree.node(at);
660− if n.kind == Kind::Root {
660+ if vault.tree.node(at).kind == Kind::Root {
661661 return Err(io_err("cannot paste beside the root"));
662662 }
663− let file = at.0;
664− let indent = vault.tree.indent(at);
665− let level_base = vault.tree.level(at);
666− // re-indent/level the pasted text: parse it standalone and re-emit at
667− // the target's position.
668− let shifted = shift_document(text, level_base, indent);
669− if after {
670− let pos = trimmed_end(&vault.tree.files[file].text, n.span);
671− insert_at(vault, file, pos, "\n\n", &shifted)
672− } else {
673− let pos = n.span.start;
674− vault.write_span(file, Span { start: pos, end: pos }, &format!("{}\n", shifted))
675− }
663+ // a block root stands in its parent file as its embed
664+ let at = stand_in(&vault.tree, at);
665+ let Some(parent) = vault.tree.node(at).parent.map(|p| (at.0, p)) else {
666+ return Err(io_err("cannot paste beside the root"));
667+ };
668+ let pos = vault.tree.raw_children(parent).iter().position(|&k| k == at).unwrap_or(0);
669+ let shifted = shift_document(text, vault.tree.level(parent), child_indent(&vault.tree, parent));
670+ place(vault, None, parent, pos + after as usize, &shifted).map(|_| ())
676671}
677672
678−/// Parse a standalone document and re-emit it at the given level/indent.
679−/// `level` is the display level the document's *root* should take; `indent`
680−/// its indent. Everything inside keeps its position relative to the root.
681−pub fn shift_document(text: &str, level: usize, indent: usize) -> String {
673+/// Parse a standalone document and re-emit it as children of a node whose
674+/// level is `parent_level` (the root's is 0), at `indent`: a top-level
675+/// section becomes a level `parent_level + 1` heading, a top-level item sits
676+/// in a section of level `parent_level`. Everything inside keeps its position
677+/// relative to its top-level node.
678+pub fn shift_document(text: &str, parent_level: usize, indent: usize) -> String {
682679 let block = crate::parse::Block {
683680 id: None,
684681 path: "clip.md".into(),
@@ -702,13 +699,15 @@ pub fn shift_document(text: &str, level: usize, indent: usize) -> String {
702699 let r = render(&tree, *k, 1, true);
703700 let first = r.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
704701 let t = first.trim_start();
705− let cur_level = if t.starts_with('#') {
706− t.chars().take_while(|&c| c == '#').count()
702+ // render(_, 1) writes a section root at level 1 and an item root in
703+ // a section of level 1 (its nested sections at 2)
704+ let ld = if t.starts_with('#') {
705+ let cur_level = t.chars().take_while(|&c| c == '#').count();
706+ parent_level as isize + 1 - cur_level as isize
707707 } else {
708− 1
708+ parent_level as isize - 1
709709 };
710710 let cur_indent = first.len() - t.len();
711− let ld = level as isize - cur_level as isize;
712711 let id = indent as isize - cur_indent as isize;
713712 out.push_str(&shift_lines(&r, ld, id));
714713 }
@@ -784,6 +783,7 @@ fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
784783
785784/// Refile: move a subtree under a new parent as its last child (§6.5).
786785pub fn refile(vault: &mut Vault, r: NRef, dest: NRef) -> std::io::Result<()> {
786+ let dest = vault.tree.resolved_child(dest);
787787 let n = vault.tree.node(r);
788788 // guard: cannot refile into own subtree — ancestry followed through
789789 // embeds, so a destination inside a nested block counts too
@@ -801,140 +801,164 @@ pub fn refile(vault: &mut Vault, r: NRef, dest: NRef) -> std::io::Result<()> {
801801 }
802802 anc = resolved_parent(&vault.tree, a);
803803 }
804− if n.is_block() || n.is_embed() {
805− // move only the embed line (§6.5)
806− let embed_ref = if n.is_embed() {
807− r
808− } else {
809− // find the embed node for this block
810− let id = n.block.as_ref().unwrap().id.clone().unwrap();
811− let mut embed_ref = None;
812− for (fi, f) in vault.tree.files.iter().enumerate() {
813− for (ni, nd) in f.nodes.iter().enumerate() {
814− if nd.embed.as_ref() == Some(&id) {
815− embed_ref = Some((fi, ni));
816− }
817− }
818− }
819− match embed_ref {
820− Some(e) => e,
821− None => return Err(io_err("block has no embed")),
822− }
823− };
824− // just the `![[id]]` line, without its old indent: insert_child_text
825− // indents it for the destination
826− let embed_text = {
827− let en = vault.tree.node(embed_ref);
828− en.title_span.text(&vault.tree.files[embed_ref.0].text).trim().to_string()
829− };
830− let dest_key = vault.key_of(dest);
831− let dest_path = vault.tree.path(dest);
832− // remove embed from source (no reload)
833− let src_file = embed_ref.0;
834− let span = vault.tree.node(embed_ref).span;
835− let src_text = vault.tree.files[src_file].text.clone();
836− remove_span_no_reload(vault, src_file, span)?;
837− let Some(dest) = vault
838− .find_by_key(&dest_key)
839− .or_else(|| vault.find_by_path(&dest_path))
840− else {
841− // put the source back rather than lose the embed
842− vault.write_file_text(src_file, &src_text)?;
843− return Err(io_err("destination lost"));
844− };
845− insert_child_text(vault, dest, &embed_text, true)
846− } else {
847− // plain node: same-file span surgery when possible (no reload, so
848− // sibling structure is preserved), cross-file otherwise.
849− let src_file = r.0;
850− // on-disk spelling: nested blocks travel as their embed lines
851− let rendered = render(&vault.tree, r, 1, false);
852− let span = vault.tree.node(r).span;
853− if dest.0 == src_file && !vault.tree.node(dest).is_embed() {
854− let text = vault.tree.files[src_file].text.clone();
855− let dest_span = vault.tree.node(dest).span;
856− let level = vault.tree.level(dest) + 1;
857− let indent = if vault.tree.node(dest).kind == Kind::Item {
858− vault.tree.indent(dest) + 2
859− } else {
860− vault.tree.indent(dest)
861− };
862− let shifted = shift_document(&rendered, level, indent);
863− let (start, end) = removal_range(&text, span);
864− let mut new_text = String::with_capacity(text.len());
865− new_text.push_str(&text[..start]);
866− new_text.push_str(&text[end..]);
867− // adjust insertion point for the removal
868− let mut ins = after_removal(dest_span.end.min(text.len()), start, end);
869− while ins > 0 && new_text.as_bytes()[ins - 1] == b'\n' {
870− ins -= 1;
804+ // a block moves by its embed line (§6.5); a plain node by its span, in
805+ // its on-disk spelling, so nested blocks travel as their embed lines
806+ let moving = stand_in(&vault.tree, r);
807+ if vault.tree.node(moving).is_block() {
808+ return Err(io_err("block has no embed"));
809+ }
810+ let rendered = render(&vault.tree, moving, 1, false);
811+ let shifted = shift_document(&rendered, vault.tree.level(dest), child_indent(&vault.tree, dest));
812+ place(vault, Some(moving), dest, usize::MAX, &shifted).map(|_| ())
813+}
814+
815+// ---------------------------------------------------------------- placement
816+
817+/// The node that stands for `r` among its parent's children in a file: a
818+/// block root's embed (§4.7), else `r` itself.
819+fn stand_in(tree: &crate::tree::Tree, r: NRef) -> NRef {
820+ if r.0 != tree.root.0 && tree.node(r).is_block() {
821+ if let Some(e) = resolved_parent(tree, r) {
822+ if tree.node(e).is_embed() {
823+ return e;
871824 }
872− let kids = vault.tree.resolved_children(dest);
873− let all_items = !kids.is_empty()
874− && kids.iter().all(|&k| vault.tree.node(k).kind == Kind::Item);
875− let first_is_item = shifted.trim_start().starts_with("- ");
876− let sep = if all_items && first_is_item { "\n" } else { "\n\n" };
877− new_text.insert_str(ins, &format!("{}{}", sep, shifted));
878− vault.write_file_text(src_file, &new_text)?;
879− vault.reload()
880− } else {
881− let dest_key = vault.key_of(dest);
882− let dest_path = vault.tree.path(dest);
883− let src_text = vault.tree.files[src_file].text.clone();
884− remove_span_with_separator(vault, src_file, span)?;
885− let Some(dest) = vault
886− .find_by_key(&dest_key)
887− .or_else(|| vault.find_by_path(&dest_path))
888− else {
889− // never lose the subtree: put the source back
890− vault.write_file_text(src_file, &src_text)?;
891− return Err(io_err("destination lost"));
892− };
893− insert_child_text(vault, dest, &rendered, false)
894825 }
895826 }
827+ r
828+}
829+
830+/// Indent of a child of `parent` (§3.1): items nest under items, everything
831+/// under a section stays at the section's own indent.
832+fn child_indent(tree: &crate::tree::Tree, parent: NRef) -> usize {
833+ match tree.node(parent).kind {
834+ Kind::Item => tree.indent(parent) + 2,
835+ Kind::Section => tree.indent(parent),
836+ Kind::Root => 0,
837+ }
838+}
839+
840+/// Spellings of a document's top-level nodes, in order. A heading embed is
841+/// a section, a bare embed an item (§4.7).
842+fn top_kinds(doc: &str) -> Vec<Kind> {
843+ let pf = crate::parse::parse_file("clip.md", doc, 0, None);
844+ pf.nodes[pf.root_node]
845+ .children
846+ .iter()
847+ .map(|&c| pf.nodes[c].kind)
848+ .collect()
849+}
850+
851+/// Clamp a wanted child index by the ordering rule `(text | item)*
852+/// section*` (§3.1): items no later than the first section child, sections
853+/// no earlier. `kids` are the parent's child nodes without the one moving.
854+fn clamp_index(tree: &crate::tree::Tree, kids: &[NRef], want: usize, kinds: &[Kind]) -> usize {
855+ let first_section = kids
856+ .iter()
857+ .position(|&k| tree.node(k).kind == Kind::Section)
858+ .unwrap_or(kids.len());
859+ let lo = if kinds.contains(&Kind::Section) { first_section } else { 0 };
860+ let hi = if kinds.contains(&Kind::Item) { first_section } else { kids.len() };
861+ want.min(kids.len()).clamp(lo, hi)
896862}
897863
898−/// Insert a rendered document as the last child of `parent`.
899−fn insert_child_text(
864+/// Write `doc` (already shifted to a child position of `parent`) as the
865+/// `want`-th child node of `parent`, clamped by the ordering rule (§3.1).
866+/// With `moving`, that node's own lines are removed in the same edit: text
867+/// children around it stay where they are. The insertion is written before
868+/// any removal in another file, so a failure never loses text. Returns the
869+/// index the document landed at among `parent`'s child nodes.
870+fn place(
900871 vault: &mut Vault,
872+ moving: Option<NRef>,
901873 parent: NRef,
902− text: &str,
903− is_embed: bool,
904−) -> std::io::Result<()> {
905− let node = vault.tree.node(parent);
874+ want: usize,
875+ doc: &str,
876+) -> std::io::Result<usize> {
877+ let tree = &vault.tree;
878+ let pnode = tree.node(parent);
879+ let kids: Vec<NRef> = tree
880+ .raw_children(parent)
881+ .into_iter()
882+ .filter(|&k| Some(k) != moving)
883+ .collect();
884+ let kinds = top_kinds(doc);
885+ if kinds.is_empty() {
886+ return Err(io_err("nothing to place"));
887+ }
888+ let idx = clamp_index(tree, &kids, want, &kinds);
889+ let first = kinds[0];
890+ let last = *kinds.last().unwrap();
906891 let file = parent.0;
907− // Items nest under items; everything under a section stays at the
908− // section's own indent (§3.1, §4.10).
909− let indent = if node.kind == Kind::Item {
910− vault.tree.indent(parent) + 2
911− } else {
912− vault.tree.indent(parent)
892+ let mut text = tree.files[file].text.clone();
893+ // the parent's content as it will be, without the moving node
894+ let content: Vec<crate::parse::Content> = pnode
895+ .content
896+ .iter()
897+ .copied()
898+ .filter(|c| !matches!(c, crate::parse::Content::Node(n) if Some((file, *n)) == moving))
899+ .collect();
900+ // positions in the original text, then adjusted for a same-file removal
901+ let next = kids.get(idx).copied();
902+ let mut pos = match next {
903+ Some(k) => tree.node(k).span.start,
904+ None => pnode.span.end.min(text.len()),
913905 };
914− let level = vault.tree.level(parent) + 1;
915− let shifted = if is_embed {
916− let mut s = String::new();
917− for line in text.trim_end_matches('\n').split('\n') {
918− s.push_str(&" ".repeat(indent));
919− s.push_str(line);
920− s.push('\n');
906+ // appending after the last child: is the list there tight?
907+ let tight_after = match content.last() {
908+ Some(crate::parse::Content::Node(k)) => {
909+ let kn = &tree.files[file].nodes[*k];
910+ kn.kind == Kind::Item && !kn.span.text(&text).ends_with("\n\n")
921911 }
922− s
923− } else {
924− shift_document(text, level, indent)
912+ Some(crate::parse::Content::Text(_)) => false,
913+ None => pnode.kind == Kind::Item,
914+ };
915+ let next_kind = next.map(|k| tree.node(k).kind);
916+ let removal = match moving {
917+ Some(m) if m.0 == file => Some(removal_range(&text, tree.node(m).span)),
918+ _ => None,
925919 };
926− // Separator: item children of an item-only list stay tight; sections and
927− // first children of a body-less node get a blank line, except in a block
928− // file whose spacing is the file's own (canonical: one blank).
929− let kids = vault.tree.resolved_children(parent);
930− let all_items = !kids.is_empty()
931− && kids.iter().all(|&k| vault.tree.node(k).kind == Kind::Item);
932− let first_line_is_item = shifted.trim_start().starts_with("- ");
933− let sep = if all_items && first_line_is_item { "\n" } else { "\n\n" };
934− let t = vault.tree.files[file].text.clone();
935− let pos = trimmed_end(&t, node.span);
936− insert_at(vault, file, pos, sep, &shifted)?;
937− vault.reload()
920+ if let Some((start, end)) = removal {
921+ text = format!("{}{}", &text[..start], &text[end..]);
922+ pos = after_removal(pos, start, end);
923+ }
924+ let body = doc.trim_end_matches('\n');
925+ let insertion = match next_kind {
926+ Some(nk) => {
927+ // before the next child node: keep a blank line before a
928+ // heading and after anything that is not a tight item run
929+ let lead = if first == Kind::Section && pos > 0 && !text[..pos].ends_with("\n\n") {
930+ "\n"
931+ } else {
932+ ""
933+ };
934+ let tight = last == Kind::Item && nk == Kind::Item && !text[..pos].ends_with("\n\n");
935+ format!("{}{}\n{}", lead, body, if tight { "" } else { "\n" })
936+ }
937+ None => {
938+ while pos > 0 && text.as_bytes()[pos - 1] == b'\n' {
939+ pos -= 1;
940+ }
941+ let sep = if pos == 0 {
942+ ""
943+ } else if first == Kind::Item && tight_after {
944+ "\n"
945+ } else {
946+ "\n\n"
947+ };
948+ let nl = if text[pos..].starts_with('\n') { "" } else { "\n" };
949+ format!("{}{}{}", sep, body, nl)
950+ }
951+ };
952+ text.insert_str(pos, &insertion);
953+ vault.write_file_text(file, &text)?;
954+ if let Some(m) = moving {
955+ if m.0 != file {
956+ // the other file's node refs are untouched by the write above
957+ remove_span_no_reload(vault, m.0, vault.tree.node(m).span)?;
958+ }
959+ }
960+ vault.reload()?;
961+ Ok(idx)
938962}
939963
940964/// Archive: refile under the top-level `Archive` section (§6.5).
@@ -1075,11 +1099,20 @@ pub fn make_block(vault: &mut Vault, r: NRef) -> std::io::Result<Id> {
10751099 let prefix = vault.unique_prefix(&id);
10761100 let fname = crate::ident::filename(&prefix, &slug(&n.title));
10771101 crate::vault::atomic_write(&vault.dir.join(&fname), &file_text)?;
1078− // replace the node's span with an embed (§6.1.3)
1102+ // replace the node's span with an embed in the node's form (§6.1.3,
1103+ // §4.7), keeping the blank lines that separated it from what follows
10791104 let file = r.0;
10801105 let span = n.span;
1081− let indent = vault.tree.indent(r);
1082− let embed = format!("{}![[{}]]\n", " ".repeat(indent), id);
1106+ let indent = " ".repeat(n.indent);
1107+ let mut embed = match n.kind {
1108+ Kind::Section => format!("{}{} ![[{}]]\n", indent, "#".repeat(vault.tree.level(r)), id),
1109+ _ => format!("{}![[{}]]\n", indent, id),
1110+ };
1111+ let old = span.text(&vault.tree.files[file].text);
1112+ let trailing = old.len() - old.trim_end_matches('\n').len();
1113+ for _ in 1..trailing {
1114+ embed.push('\n');
1115+ }
10831116 vault.write_span(file, span, &embed)?;
10841117 vault.reload()?;
10851118 Ok(id)
@@ -1109,13 +1142,10 @@ pub fn set_property(vault: &mut Vault, r: NRef, key: &str, value: &str) -> std::
11091142/// embed line, so blocks and plain nodes swap freely in the parent file.
11101143pub fn move_sibling(vault: &mut Vault, r: NRef, down: bool) -> std::io::Result<()> {
11111144 // a block root stands in its parent file as its embed
1112− let r = match vault.tree.node(r).block.as_ref().and_then(|b| b.id.clone()) {
1113− Some(_) if r.0 != vault.tree.root.0 => match resolved_parent(&vault.tree, r) {
1114− Some(e) if vault.tree.node(e).is_embed() => e,
1115− _ => return Ok(()),
1116− },
1117− _ => r,
1118− };
1145+ let r = stand_in(&vault.tree, r);
1146+ if vault.tree.node(r).is_block() {
1147+ return Ok(());
1148+ }
11191149 let parent = match vault.tree.node(r).parent {
11201150 Some(p) => (r.0, p),
11211151 None => return Ok(()),
@@ -1137,6 +1167,15 @@ pub fn move_sibling(vault: &mut Vault, r: NRef, down: bool) -> std::io::Result<(
11371167 return Ok(());
11381168 }
11391169 let other = siblings[swap_with];
1170+ // the ordering rule (§3.1): an item never goes below a section sibling
1171+ let (rk, ok) = (vault.tree.node(r).kind, vault.tree.node(other).kind);
1172+ if rk != ok {
1173+ return Err(io_err(if rk == Kind::Item {
1174+ "an item cannot move below a section"
1175+ } else {
1176+ "a section cannot move above an item"
1177+ }));
1178+ }
11401179 let file = r.0;
11411180 let text = vault.tree.files[file].text.clone();
11421181 // swap the nodes' own lines; trailing blank lines stay where they are,
@@ -1171,149 +1210,147 @@ pub fn move_sibling(vault: &mut Vault, r: NRef, down: bool) -> std::io::Result<(
11711210 vault.write_file_text(file, &new_text)
11721211}
11731212
1174−/// Toggle spelling section ↔ item (§10.3 `~`). The checkbox, if any, is kept.
1213+/// Toggle spelling section ↔ item (§10.3 `~`). The checkbox, if any, is kept,
1214+/// and the subtree is re-indented and re-levelled for the new spelling. The
1215+/// node moves to its parent's boundary if it has to (§3.1), so no sibling
1216+/// changes parent; a block's embed changes form and moves with it (§4.7).
11751217pub fn toggle_spelling(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
1218+ let r = vault.tree.resolved_child(r);
11761219 let n = vault.tree.node(r);
1177− if n.is_embed() || n.kind == Kind::Root {
1220+ if n.kind == Kind::Root {
11781221 return Ok(());
11791222 }
1223+ let to_section = n.kind == Kind::Item;
1224+ let stand = stand_in(&vault.tree, r);
11801225 let file = r.0;
1181− let ts = n.title_span;
1182− let line = ts.text(&vault.tree.files[file].text).to_string();
1183− let indent_str = &line[..line.len() - line.trim_start().len()];
1184− let trimmed = line.trim_start();
1185− // split off marker and checkbox
1186− let (after_marker, written_level) = if trimmed.starts_with('#') {
1187− let h = trimmed.chars().take_while(|&c| c == '#').count();
1188− (trimmed[h..].trim_start(), h)
1226+ let text = vault.tree.files[file].text.clone();
1227+ let span = Span { start: n.span.start, end: n.span.end.min(text.len()) };
1228+ if stand != r {
1229+ // a block: respell its file's root at level 1, then its embed
1230+ let respelled = respell(span.text(&text), to_section, 1);
1231+ let id = n.block.as_ref().and_then(|b| b.id.clone());
1232+ vault.write_span(file, span, &respelled)?;
1233+ let Some(e) = id.and_then(|id| vault.tree.embed_of(&id)) else {
1234+ return vault.reload();
1235+ };
1236+ return respell_embed(vault, e, to_section);
1237+ }
1238+ let Some(parent) = n.parent.map(|p| (file, p)) else { return Ok(()) };
1239+ let respelled = respell(span.text(&text), to_section, vault.tree.level(parent) + 1);
1240+ reposition(vault, r, parent, respelled, to_section)
1241+}
1242+
1243+/// Rewrite an embed in the other form (§4.7) — heading for a section-spelled
1244+/// block, bare for an item — moving it to its parent's boundary if needed.
1245+pub fn respell_embed(vault: &mut Vault, e: NRef, to_section: bool) -> std::io::Result<()> {
1246+ let en = vault.tree.node(e);
1247+ let Some(id) = en.embed.clone() else { return Ok(()) };
1248+ let Some(parent) = en.parent.map(|p| (e.0, p)) else { return Ok(()) };
1249+ let text = &vault.tree.files[e.0].text;
1250+ let old = Span { start: en.span.start, end: en.span.end.min(text.len()) }.text(text);
1251+ let rest = old.find('\n').map(|i| &old[i..]).unwrap_or("\n");
1252+ let indent = " ".repeat(en.indent);
1253+ let line = if to_section {
1254+ format!("{}{} ![[{}]]", indent, "#".repeat(vault.tree.level(parent) + 1), id)
11891255 } else {
1190− (trimmed.trim_start_matches("- "), 0)
1256+ format!("{}![[{}]]", indent, id)
11911257 };
1192− let (checkbox, title) = if let Some(rest) = after_marker.strip_prefix("[ ] ") {
1193− ("[ ] ", rest)
1194− } else if let Some(rest) = after_marker
1195− .strip_prefix("[x] ")
1196− .or_else(|| after_marker.strip_prefix("[X] "))
1197− .or_else(|| after_marker.strip_prefix("[-] "))
1198− {
1199− ("[x] ", rest)
1200− } else {
1201− ("", after_marker)
1258+ let new = format!("{}{}", line, rest);
1259+ reposition(vault, e, parent, new, to_section)
1260+}
1261+
1262+/// Replace `r`'s span with `new` (the same node, respelled), in place when
1263+/// the ordering rule allows it there, else at the parent's boundary.
1264+fn reposition(
1265+ vault: &mut Vault,
1266+ r: NRef,
1267+ parent: NRef,
1268+ new: String,
1269+ to_section: bool,
1270+) -> std::io::Result<()> {
1271+ let tree = &vault.tree;
1272+ let kids = tree.raw_children(parent);
1273+ let pos = kids.iter().position(|&k| k == r).unwrap_or(0);
1274+ let others: Vec<NRef> = kids.iter().copied().filter(|&k| k != r).collect();
1275+ let kind = if to_section { Kind::Section } else { Kind::Item };
1276+ if clamp_index(tree, &others, pos, &[kind]) == pos {
1277+ let span = tree.node(r).span;
1278+ vault.write_span(r.0, span, &new)?;
1279+ return vault.reload();
1280+ }
1281+ place(vault, Some(r), parent, pos, &new).map(|_| ())
1282+}
1283+
1284+
1285+/// A node's text respelled: its title line as a section at `level` or as an
1286+/// item, and everything under it shifted to match — an item's content sits
1287+/// two columns in, a section's at its own indent, and nested headings stay
1288+/// one below their new parent.
1289+fn respell(span_text: &str, to_section: bool, level: usize) -> String {
1290+ let (first, rest) = match span_text.find('\n') {
1291+ Some(i) => (&span_text[..i], &span_text[i..]),
1292+ None => (span_text, ""),
12021293 };
1203− let new_line = match n.kind {
1204− Kind::Section => format!("{}- {}{}", indent_str, checkbox, title),
1205− Kind::Item => {
1206− // item → section: deeper than the nearest section ancestor so
1207− // the parser keeps it a child (§3.1)
1208− let lvl = (written_level + 1).max(vault.tree.level(r) + 1).max(1);
1209− format!("{}{} {}{}", indent_str, "#".repeat(lvl), checkbox, title)
1294+ let indent_str = &first[..first.len() - first.trim_start().len()];
1295+ let (checkbox, title) = match checkbox_span(first) {
1296+ Some((s, e)) => {
1297+ let state = if &first[s..s + 3] == "[ ]" { "[ ] " } else { "[x] " };
1298+ (state, &first[e..])
12101299 }
1211− Kind::Root => unreachable!(),
1300+ // an empty title (`-`, `#`) has no marker end
1301+ None => ("", marker_end(first).map(|m| &first[m..]).unwrap_or("")),
1302+ };
1303+ let line = if to_section {
1304+ format!("{}{} {}{}", indent_str, "#".repeat(level.max(1)), checkbox, title)
1305+ } else {
1306+ format!("{}- {}{}", indent_str, checkbox, title)
12121307 };
1213− vault.write_span(file, ts, &new_line)
1308+ let (dl, di) = if to_section { (1, -2) } else { (-1, 2) };
1309+ // the rest starts with the newline that ends the title line
1310+ let shifted = shift_lines(&rest[1.min(rest.len())..], dl, di);
1311+ if rest.is_empty() {
1312+ line
1313+ } else {
1314+ format!("{}\n{}", line.trim_end(), shifted)
1315+ }
12141316}
12151317
1216−/// Demote: become the last child of the previous sibling (§10.3 `>`).
1217−/// Same-file span surgery, no reload.
1318+/// Demote: become the last child of the previous sibling node (§10.3 `>`),
1319+/// clamped by the ordering rule (§3.1).
12181320pub fn demote(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
1219− let parent = match vault.tree.node(r).parent {
1220− Some(p) => (r.0, p),
1221− None => return Ok(()),
1222− };
1223− let siblings = vault.tree.resolved_children(parent);
1224− let pos = match siblings.iter().position(|&s| s == r) {
1225− Some(p) => p,
1226− None => return Ok(()),
1227− };
1321+ let m = stand_in(&vault.tree, r);
1322+ let Some(parent) = vault.tree.node(m).parent.map(|p| (m.0, p)) else { return Ok(()) };
1323+ let kids = vault.tree.raw_children(parent);
1324+ let Some(pos) = kids.iter().position(|&k| k == m) else { return Ok(()) };
12281325 if pos == 0 {
12291326 return Ok(());
12301327 }
1231− let prev = siblings[pos - 1];
1232− if prev.0 != r.0 || vault.tree.node(r).is_embed() || vault.tree.node(prev).is_embed() {
1233− // cross-file or embed demotion falls back to refile
1234− return refile(vault, r, prev);
1235− }
1236− // Sections and items nest differently: delegate to the same logic refile
1237− // uses so the shifted text is right for the spelling.
1238− return refile(vault, r, prev);
1328+ let prev = vault.tree.resolved_child(kids[pos - 1]);
1329+ refile(vault, r, prev)
12391330}
12401331
1241−/// Promote: become the next sibling of the parent (§10.3 `<`). Same-file
1242−/// span surgery; out of a block's root, the node moves to the parent file
1243−/// right after the block's embed.
1332+/// Promote: become the next sibling of the parent (§10.3 `<`), clamped by the
1333+/// ordering rule (§3.1): an item leaving a section lands before the first
1334+/// section among the parent's siblings. Out of a block's root, the node moves
1335+/// to the parent file beside the block's embed.
12441336pub fn promote(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
1245− let n = vault.tree.node(r);
1246− let parent = match n.parent {
1247− Some(p) => (r.0, p),
1248− None => return Ok(()),
1249− };
1337+ let m = stand_in(&vault.tree, r);
1338+ let Some(parent) = vault.tree.node(m).parent.map(|p| (m.0, p)) else { return Ok(()) };
12501339 if vault.tree.node(parent).kind == Kind::Root {
12511340 return Ok(());
12521341 }
1253− let file = r.0;
1254− // on-disk spelling: nested blocks travel as their embed lines
1255− let rendered = render(&vault.tree, r, 1, false);
1256− let span = n.span;
1257− let text = vault.tree.files[file].text.clone();
1258− let (start, end) = removal_range(&text, span);
1259− let mut new_text = String::with_capacity(text.len());
1260− new_text.push_str(&text[..start]);
1261− new_text.push_str(&text[end..]);
1262− if vault.tree.node(parent).is_block() && file != vault.tree.root.0 {
1263− // the parent is a block root: its sibling position is beside the embed
1264− let Some(embed) = resolved_parent(&vault.tree, parent) else {
1265− return Err(io_err("block has no embed"));
1266− };
1267− // a section beside the embed sits one below the embed's enclosing
1268− // section (the embed line itself is spelled as an item)
1269− let in_section = vault
1270− .tree
1271− .ancestors(embed)
1272− .iter()
1273− .any(|&a| vault.tree.node(a).kind == Kind::Section);
1274− let level = if vault.tree.node(r).kind == Kind::Section && in_section {
1275− vault.tree.level(embed) + 1
1276− } else {
1277− vault.tree.level(embed).max(1)
1278− };
1279− let indent = vault.tree.indent(embed);
1280− let shifted = shift_document(&rendered, level, indent);
1281− let efile = embed.0;
1282− let espan = vault.tree.node(embed).title_span;
1283− let etext = vault.tree.files[efile].text.clone();
1284− let ins = (espan.end + 1).min(etext.len());
1285− let tight = vault.tree.node(r).kind == Kind::Item
1286− && vault.tree.node(parent).kind == Kind::Item;
1287− let mut out = String::with_capacity(etext.len() + shifted.len() + 2);
1288− out.push_str(&etext[..ins]);
1289− if !out.ends_with('\n') {
1290− out.push('\n');
1291− }
1292− if !tight {
1293− out.push('\n');
1294− }
1295− out.push_str(&shifted);
1296− let after = &etext[ins..];
1297− if !tight && !after.is_empty() && !after.starts_with('\n') {
1298− out.push('\n');
1299− }
1300− out.push_str(after);
1301− vault.write_file_text(file, &new_text)?;
1302− vault.write_file_text(efile, &out)?;
1303− return vault.reload();
1304− }
1305− let parent_span = vault.tree.node(parent).span;
1306− let level = vault.tree.level(parent);
1307− let indent = vault.tree.indent(parent);
1308− let shifted = shift_document(&rendered, level, indent);
1309− // the parent contains r, so its span end moves back by the removed bytes
1310− let mut ins = after_removal(parent_span.end.min(text.len()), start, end);
1311− while ins > 0 && new_text.as_bytes()[ins - 1] == b'\n' {
1312− ins -= 1;
1342+ // the parent's own position: its embed if it is a block root
1343+ let pstand = stand_in(&vault.tree, parent);
1344+ let Some(grand) = vault.tree.node(pstand).parent.map(|p| (pstand.0, p)) else {
1345+ return Ok(());
1346+ };
1347+ if vault.tree.node(grand).kind == Kind::Root && pstand.0 != vault.tree.root.0 {
1348+ return Err(io_err("block has no embed"));
13131349 }
1314− new_text.insert_str(ins, &format!("\n\n{}", shifted));
1315− vault.write_file_text(file, &new_text)?;
1316− vault.reload()
1350+ let pos = vault.tree.raw_children(grand).iter().position(|&k| k == pstand).unwrap_or(0);
1351+ let rendered = render(&vault.tree, m, 1, false);
1352+ let shifted = shift_document(&rendered, vault.tree.level(grand), child_indent(&vault.tree, grand));
1353+ place(vault, Some(m), grand, pos + 1, &shifted).map(|_| ())
13171354}
13181355
13191356/// Rename a node's title (used by the editor when it detects a title change,
crates/fold-core/src/parse.rs+87−62
@@ -51,6 +51,14 @@ impl Block {
5151 }
5252}
5353
54+/// One entry in a node's ordered content (§3.1): a run of text lines or a
55+/// child node. Text runs are never adjacent to each other.
56+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57+pub enum Content {
58+ Text(Span),
59+ Node(usize),
60+}
61+
5462#[derive(Debug, Clone)]
5563pub struct Node {
5664 pub kind: Kind,
@@ -59,13 +67,12 @@ pub struct Node {
5967 pub task: Option<TaskState>,
6068 /// Byte span of the title line (no trailing newline).
6169 pub title_span: Span,
62− /// Byte span of the body region (after the title line, before children).
63− pub body_span: Span,
64− /// Body lines that follow children (text after a list, between child
65− /// items): `(number of children before the segment, span)`, in order.
66− pub tail: Vec<(usize, Span)>,
70+ /// The node's children in document order: text runs and child nodes,
71+ /// interleaved (§3.1, §3.3).
72+ pub content: Vec<Content>,
6773 /// Byte span: title line through the whole subtree.
6874 pub span: Span,
75+ /// The node children alone, in order (derived from `content`).
6976 pub children: Vec<usize>,
7077 pub parent: Option<usize>,
7178 /// Which file's text this node's spans index into.
@@ -82,7 +89,28 @@ pub struct Node {
8289 pub noncanonical: Vec<String>,
8390}
8491
85−impl Node {}
92+impl Node {
93+ /// The leading text children: what precedes the first child node.
94+ pub fn body_span(&self) -> Option<Span> {
95+ match self.content.first() {
96+ Some(Content::Text(sp)) => Some(*sp),
97+ _ => None,
98+ }
99+ }
100+
101+ /// Every text child, with the number of child nodes before it.
102+ pub fn text_runs(&self) -> Vec<(usize, Span)> {
103+ let mut out = Vec::new();
104+ let mut before = 0;
105+ for c in &self.content {
106+ match c {
107+ Content::Text(sp) => out.push((before, *sp)),
108+ Content::Node(_) => before += 1,
109+ }
110+ }
111+ out
112+ }
113+}
86114
87115/// One parsed file: its text and the arena of nodes found in it.
88116#[derive(Debug, Clone)]
@@ -213,7 +241,9 @@ fn indent_cols(raw: &str, tabbed: &mut bool) -> usize {
213241enum TitleKind {
214242 Section { level: usize },
215243 Item,
216− Embed(Id),
244+ /// `![[id]]`, bare (item position) or as a heading (`## ![[id]]`,
245+ /// section position, with its level) (§4.7).
246+ Embed(Id, Option<usize>),
217247}
218248
219249struct TitleInfo {
@@ -243,7 +273,7 @@ fn classify_title(raw: &str) -> Option<(usize, TitleInfo)> {
243273 (
244274 indent,
245275 TitleInfo {
246− kind: TitleKind::Embed(id),
276+ kind: TitleKind::Embed(id, None),
247277 task: None,
248278 title: String::new(),
249279 noncanonical,
@@ -260,6 +290,24 @@ fn classify_title(raw: &str) -> Option<(usize, TitleInfo)> {
260290 if hashes > 6 {
261291 noncanonical.push("heading level beyond six".into());
262292 }
293+ // a heading embed: the heading holds nothing but `![[id]]`
294+ if let Some(inner) = after
295+ .trim()
296+ .strip_prefix("![[")
297+ .and_then(|s| s.strip_suffix("]]"))
298+ {
299+ if let Some(id) = Id::parse(inner.trim()) {
300+ return Some((
301+ indent,
302+ TitleInfo {
303+ kind: TitleKind::Embed(id, Some(hashes)),
304+ task: None,
305+ title: String::new(),
306+ noncanonical,
307+ },
308+ ));
309+ }
310+ }
263311 (
264312 TitleKind::Section { level: hashes },
265313 after.strip_prefix(' ').unwrap_or(after),
@@ -376,8 +424,7 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
376424 title: String::new(),
377425 task: None,
378426 title_span: Span::default(),
379− body_span: Span::default(),
380− tail: Vec::new(),
427+ content: Vec::new(),
381428 span: Span {
382429 start: 0,
383430 end: text.len(),
@@ -455,7 +502,7 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
455502 match classify_title(&raw) {
456503 Some((indent, info)) => {
457504 pending_setext = None;
458− let is_embed = matches!(info.kind, TitleKind::Embed(_));
505+ let is_embed = matches!(info.kind, TitleKind::Embed(..));
459506 // Pop until the top can contain this node (§3.1). A node's
460507 // parent is the nearest section above it, or the nearest item
461508 // whose child indent it reaches — items that can't contain it
@@ -465,7 +512,9 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
465512 let tnode = &nodes[top.node];
466513 let can = match tnode.kind {
467514 Kind::Root => true,
468− _ if top.is_embed => indent > top.indent, // diagnostic, but keep spans sane
515+ // lines under a bare embed are a diagnostic, but keep
516+ // spans sane; a heading embed nests like a section
517+ Kind::Item if top.is_embed => indent > top.indent,
469518 Kind::Item => {
470519 // An item contains only what reaches its child
471520 // indent; otherwise look through it.
@@ -487,7 +536,9 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
487536 // predecessor, so `## C` after `###### B` is a
488537 // sibling of B, not a child. Look through
489538 // intervening items.
490− if let TitleKind::Section { level } = &info.kind {
539+ if let TitleKind::Section { level } | TitleKind::Embed(_, Some(level)) =
540+ &info.kind
541+ {
491542 let mut nearest = None;
492543 for f in stack.iter().rev() {
493544 if nodes[f.node].kind == Kind::Section {
@@ -509,8 +560,8 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
509560 let parent = stack.last().unwrap().node;
510561 let mut node = Node {
511562 kind: match info.kind {
512− TitleKind::Section { .. } => Kind::Section,
513− TitleKind::Item | TitleKind::Embed(_) => Kind::Item,
563+ TitleKind::Section { .. } | TitleKind::Embed(_, Some(_)) => Kind::Section,
564+ TitleKind::Item | TitleKind::Embed(_, None) => Kind::Item,
514565 },
515566 title: info.title,
516567 task: info.task,
@@ -518,11 +569,7 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
518569 start: lstart,
519570 end: lend,
520571 },
521− body_span: Span {
522− start: lnext,
523− end: lnext,
524− },
525− tail: Vec::new(),
572+ content: Vec::new(),
526573 span: Span {
527574 start: lstart,
528575 end: lnext,
@@ -532,12 +579,14 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
532579 file: file_idx,
533580 block: None,
534581 embed: match &info.kind {
535− TitleKind::Embed(id) => Some(id.clone()),
582+ TitleKind::Embed(id, _) => Some(id.clone()),
536583 _ => None,
537584 },
538585 indent,
539586 level: match &info.kind {
540− TitleKind::Section { level } => Some(*level),
587+ TitleKind::Section { level } | TitleKind::Embed(_, Some(level)) => {
588+ Some(*level)
589+ }
541590 _ => None,
542591 },
543592 noncanonical: info.noncanonical,
@@ -570,6 +619,7 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
570619 }
571620 let idx = nodes.len();
572621 nodes[parent].children.push(idx);
622+ nodes[parent].content.push(Content::Node(idx));
573623 nodes.push(node);
574624 extend_spans(&mut nodes, idx, lnext);
575625 stack.push(Frame {
@@ -702,28 +752,14 @@ fn push_body(
702752 stack.pop();
703753 }
704754 let top = stack.last().unwrap().node;
705− let n = &mut nodes[top];
706− if !n.children.is_empty() {
707− // body text after children goes to a tail segment, so the body span
708− // never stretches over the children (§3.3)
709− let after = n.children.len();
710− match n.tail.last_mut() {
711− Some((k, sp)) if *k == after && sp.end == line.start => sp.end = line.next,
712− _ => n.tail.push((
713− after,
714− Span {
715− start: line.start,
716− end: line.next,
717− },
718− )),
719− }
720− } else {
721− // body_span starts empty (start == end, right after the title line);
722− // the first body line sets its start.
723− if n.body_span.start == n.body_span.end {
724− n.body_span.start = line.start;
725− }
726− n.body_span.end = line.next;
755+ // consecutive lines extend the node's last text child; a line after a
756+ // child node starts a new one, so text never spans a child (§3.3)
757+ match nodes[top].content.last_mut() {
758+ Some(Content::Text(sp)) if sp.end == line.start => sp.end = line.next,
759+ _ => nodes[top].content.push(Content::Text(Span {
760+ start: line.start,
761+ end: line.next,
762+ })),
727763 }
728764 extend_spans(nodes, top, line.next);
729765}
@@ -737,27 +773,19 @@ fn make_setext_section(
737773 underline_li: usize,
738774 level: usize,
739775) {
740− // The title line is the last body line pushed, so it ends the top
741− // frame's body or its last tail segment; take it back out.
776+ // The title line is the last text line pushed, so it ends the top
777+ // frame's last text child; take it back out.
742778 let title = lines[title_li].raw.trim().to_string();
743779 let underline = &lines[underline_li];
744780 let tl = &lines[title_li];
745781 {
746782 let owner = stack.last().unwrap().node;
747783 let nd = &mut nodes[owner];
748− match nd.tail.last_mut() {
749− Some((_, sp)) if sp.end == tl.next => {
784+ if let Some(Content::Text(sp)) = nd.content.last_mut() {
785+ if sp.end == tl.next {
750786 sp.end = tl.start;
751787 if sp.start >= sp.end {
752− nd.tail.pop();
753− }
754− }
755− _ => {
756− if nd.body_span.end == tl.next {
757− nd.body_span.end = tl.start;
758− if nd.body_span.start >= nd.body_span.end {
759− nd.body_span.start = nd.body_span.end;
760− }
788+ nd.content.pop();
761789 }
762790 }
763791 }
@@ -786,11 +814,7 @@ fn make_setext_section(
786814 start: lines[title_li].start,
787815 end: lines[title_li].end,
788816 },
789− body_span: Span {
790− start: underline.next,
791− end: underline.next,
792− },
793− tail: Vec::new(),
817+ content: Vec::new(),
794818 span: Span {
795819 start: lines[title_li].start,
796820 end: underline.next,
@@ -805,6 +829,7 @@ fn make_setext_section(
805829 noncanonical: vec!["setext heading".into()],
806830 });
807831 nodes[parent].children.push(idx);
832+ nodes[parent].content.push(Content::Node(idx));
808833 extend_spans(nodes, idx, underline.next);
809834 stack.push(Frame {
810835 node: idx,
crates/fold-core/src/render.rs+24−16
@@ -4,7 +4,7 @@
44//! editing buffer (§5.2) all consume it, so what the user reads, what they
55//! edit and what splice writes back are the same text.
66
7−use crate::parse::{Kind, Span, TaskState};
7+use crate::parse::{Content, Kind, Span, TaskState};
88use crate::tree::{NRef, Tree};
99
1010#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -118,10 +118,7 @@ impl Walk<'_> {
118118 };
119119 let title = match n.kind {
120120 Kind::Root => None,
121− _ if n.is_embed() => Some((
122− format!("{}![[{}]]", " ".repeat(dindent), n.embed.as_ref().unwrap()),
123− LineKind::Embed,
124− )),
121+ _ if n.is_embed() => Some((embed_line(n, dlevel, dindent), LineKind::Embed)),
125122 Kind::Section => {
126123 let mut s = " ".repeat(dindent);
127124 s.push_str(&"#".repeat(dlevel.max(1)));
@@ -147,13 +144,10 @@ impl Walk<'_> {
147144 if let Some((text, kind)) = title {
148145 self.push(text, kind, r, owner, outer, dlevel, dindent);
149146 }
150− self.span_lines(r, n.body_span, dlevel, dindent, owner, outer);
151− for (i, &c) in n.children.iter().enumerate() {
152− self.child(r, (r.0, c), dlevel, dindent, owner, outer);
153− for (k, sp) in &n.tail {
154− if *k == i + 1 {
155− self.span_lines(r, *sp, dlevel, dindent, owner, outer);
156− }
147+ for c in &n.content {
148+ match *c {
149+ Content::Text(sp) => self.span_lines(r, sp, dlevel, dindent, owner, outer),
150+ Content::Node(c) => self.child(r, (r.0, c), dlevel, dindent, owner, outer),
157151 }
158152 }
159153 self.seen.pop();
@@ -179,10 +173,14 @@ impl Walk<'_> {
179173 };
180174 self.node(t, tlevel, cindent, owner, outer);
181175 // the embed line's own blank separators (and any lines
182− // wrongly indented under it) stay in the parent
183− self.span_lines(c, cn.body_span, dlevel, cindent, owner, outer);
184− for &cc in &cn.children {
185− self.child(c, (c.0, cc), dlevel, cindent, owner, outer);
176+ // wrongly placed under it) stay in the parent
177+ for cc in &cn.content {
178+ match *cc {
179+ Content::Text(sp) => {
180+ self.span_lines(c, sp, dlevel, cindent, owner, outer)
181+ }
182+ Content::Node(k) => self.child(c, (c.0, k), dlevel, cindent, owner, outer),
183+ }
186184 }
187185 return;
188186 }
@@ -242,6 +240,16 @@ impl Walk<'_> {
242240 }
243241}
244242
243+/// An embed line at a display position: a heading embed (`## ![[id]]`) for
244+/// a section-position embed, a bare `![[id]]` for an item position (§4.7).
245+pub fn embed_line(n: &crate::parse::Node, dlevel: usize, dindent: usize) -> String {
246+ let id = n.embed.as_ref().expect("embed node");
247+ match n.kind {
248+ Kind::Section => format!("{}{} ![[{}]]", " ".repeat(dindent), "#".repeat(dlevel.max(1)), id),
249+ _ => format!("{}![[{}]]", " ".repeat(dindent), id),
250+ }
251+}
252+
245253fn push_checkbox(task: Option<TaskState>, out: &mut String) {
246254 match task {
247255 Some(TaskState::Open) => out.push_str("[ ] "),
crates/fold-core/src/tree.rs+37−15
@@ -126,6 +126,16 @@ impl Tree {
126126 r
127127 }
128128
129+ /// The embed node that references a block (§4.7), if any.
130+ pub fn embed_of(&self, id: &Id) -> Option<NRef> {
131+ self.files.iter().enumerate().find_map(|(fi, f)| {
132+ f.nodes
133+ .iter()
134+ .position(|nd| nd.embed.as_ref() == Some(id))
135+ .map(|ni| (fi, ni))
136+ })
137+ }
138+
129139 pub fn block_by_id(&self, id: &Id) -> Option<NRef> {
130140 self.blocks
131141 .iter()
@@ -195,22 +205,34 @@ impl Node {
195205 pub fn is_embed(&self) -> bool {
196206 self.embed.is_some()
197207 }
198− /// Body lines exactly as found in the file text.
208+ /// Body lines (the leading text children) exactly as found in the file.
199209 pub fn body_lines<'a>(&self, file_text: &'a str) -> Vec<&'a str> {
200− if self.body_span.start >= self.body_span.end {
201− return Vec::new();
210+ match self.body_span() {
211+ Some(sp) => content_lines(file_text, sp),
212+ None => Vec::new(),
202213 }
203− let mut lines: Vec<&'a str> = file_text[self.body_span.start..self.body_span.end]
204− .split_inclusive('\n')
205− .map(|l| l.strip_suffix('\n').unwrap_or(l))
206− .map(|l| l.strip_suffix('\r').unwrap_or(l))
207− .collect();
208− // A blank line produced only by the separator before a child node is
209− // not body content, but it is remembered: the renderer uses it to
210− // reproduce the file's spacing.
211− if lines.iter().all(|l| l.trim().is_empty()) {
212− lines.clear();
213− }
214− lines
215214 }
215+
216+ /// The lines of every text child, in order: the node's own prose,
217+ /// wherever it sits among its children (§3.3).
218+ pub fn text_lines<'a>(&self, file_text: &'a str) -> Vec<&'a str> {
219+ self.text_runs()
220+ .into_iter()
221+ .flat_map(|(_, sp)| content_lines(file_text, sp))
222+ .collect()
223+ }
224+}
225+
226+/// The lines of a text run. A run of blank lines alone is a separator, not
227+/// content: the renderer reproduces it, but it has no lines to show.
228+fn content_lines(file_text: &str, sp: crate::parse::Span) -> Vec<&str> {
229+ let mut lines: Vec<&str> = file_text[sp.start..sp.end]
230+ .split_inclusive('\n')
231+ .map(|l| l.strip_suffix('\n').unwrap_or(l))
232+ .map(|l| l.strip_suffix('\r').unwrap_or(l))
233+ .collect();
234+ if lines.iter().all(|l| l.trim().is_empty()) {
235+ lines.clear();
236+ }
237+ lines
216238}
crates/fold-core/tests/ordering.rsnew file+415
@@ -0,0 +1,415 @@
1+//! Ordered children (§3.1, §3.3): text and nodes interleave, children obey
2+//! `(text | item)* section*`, every verb keeps that rule and leaves text
3+//! children where they are; section blocks are embedded as headings (§4.7).
4+
5+use fold_core::check;
6+use fold_core::merge;
7+use fold_core::ops;
8+use fold_core::parse::{Content, Kind};
9+use fold_core::render::render;
10+use fold_core::tree::NRef;
11+use fold_core::vault::Vault;
12+
13+fn vault_with(root: &str) -> (tempfile::TempDir, Vault) {
14+ let dir = tempfile::tempdir().unwrap();
15+ std::fs::write(dir.path().join("root.md"), root).unwrap();
16+ let v = Vault::open(dir.path()).unwrap();
17+ (dir, v)
18+}
19+
20+fn at(v: &Vault, path: &str) -> NRef {
21+ let segs: Vec<String> = path.split('/').map(String::from).collect();
22+ v.find_by_path(&segs).unwrap_or_else(|| panic!("no node at {}", path))
23+}
24+
25+fn root_text(v: &Vault) -> String {
26+ v.tree.files[0].text.clone()
27+}
28+
29+/// Every node's title → its parent's title, over the resolved tree.
30+fn parents(v: &Vault) -> Vec<(String, String)> {
31+ let mut out = Vec::new();
32+ v.tree.walk(v.tree.root, &mut |t, r| {
33+ for c in t.resolved_children(r) {
34+ let p = if r == t.root { "<root>".to_string() } else { t.node(r).title.clone() };
35+ out.push((t.node(c).title.clone(), p));
36+ }
37+ });
38+ out.sort();
39+ out
40+}
41+
42+/// No item follows a section among any node's children (§3.1).
43+fn assert_ordered(v: &Vault) {
44+ v.tree.walk(v.tree.root, &mut |t, r| {
45+ let kinds: Vec<Kind> = t.raw_children(r).iter().map(|&c| t.node(c).kind).collect();
46+ if let Some(fs) = kinds.iter().position(|k| *k == Kind::Section) {
47+ assert!(
48+ kinds[fs..].iter().all(|k| *k == Kind::Section),
49+ "item after a section under {:?}",
50+ t.node(r).title
51+ );
52+ }
53+ });
54+}
55+
56+/// `parents` with one node's entry dropped: what a verb on it must not change.
57+fn others(p: &[(String, String)], moved: &str) -> Vec<(String, String)> {
58+ p.iter().filter(|(c, _)| c != moved).cloned().collect()
59+}
60+
61+// ------------------------------------------------------------- the model
62+
63+#[test]
64+fn text_and_nodes_interleave_in_order() {
65+ let src = "# Trip\n\nPlan below.\n\n- book flights\n- book hotel\n\nBudget is tight.\n";
66+ let (_d, v) = vault_with(src);
67+ let trip = at(&v, "Trip");
68+ let content = &v.tree.node(trip).content;
69+ let shape: Vec<&str> = content
70+ .iter()
71+ .map(|c| match c {
72+ Content::Text(_) => "text",
73+ Content::Node(_) => "node",
74+ })
75+ .collect();
76+ assert_eq!(shape, ["text", "node", "node", "text"]);
77+ let n = v.tree.node(trip);
78+ assert_eq!(n.body_lines(&v.tree.files[0].text), ["", "Plan below.", ""]);
79+ let all = n.text_lines(&v.tree.files[0].text);
80+ assert!(all.contains(&"Budget is tight."), "{:?}", all);
81+ assert_eq!(render(&v.tree, trip, 1, false), src);
82+}
83+
84+#[test]
85+fn unindented_text_after_a_list_belongs_to_the_parent() {
86+ let (_d, v) = vault_with("# Trip\n\n- book flights\nBudget is tight.\n");
87+ let flights = at(&v, "Trip/book flights");
88+ assert!(v.tree.node(flights).text_lines(&v.tree.files[0].text).is_empty());
89+ let trip = at(&v, "Trip");
90+ assert_eq!(v.tree.node(trip).text_lines(&v.tree.files[0].text), ["Budget is tight."]);
91+}
92+
93+// ------------------------------------------------------------- verbs
94+
95+#[test]
96+fn toggle_item_to_section_moves_to_boundary() {
97+ let (_d, mut v) = vault_with("# P\n\n- a\n- b\n");
98+ let before = parents(&v);
99+ let r = at(&v, "P/a");
100+ ops::toggle_spelling(&mut v, r).unwrap();
101+ assert_eq!(root_text(&v), "# P\n\n- b\n\n## a\n");
102+ assert_eq!(v.tree.node(at(&v, "P/a")).kind, Kind::Section);
103+ assert_eq!(others(&parents(&v), "a"), others(&before, "a"));
104+ assert_ordered(&v);
105+}
106+
107+#[test]
108+fn toggle_section_to_item_moves_to_boundary() {
109+ let (_d, mut v) = vault_with("# P\n\n- x\n\n## a\n\n## b\n");
110+ let r = at(&v, "P/b");
111+ ops::toggle_spelling(&mut v, r).unwrap();
112+ assert_eq!(v.tree.node(at(&v, "P/b")).kind, Kind::Item);
113+ let kids: Vec<String> = v
114+ .tree
115+ .resolved_children(at(&v, "P"))
116+ .iter()
117+ .map(|&c| v.tree.node(c).title.clone())
118+ .collect();
119+ assert_eq!(kids, ["x", "b", "a"]);
120+ assert_ordered(&v);
121+}
122+
123+#[test]
124+fn toggle_in_place_reshapes_the_subtree_and_back() {
125+ let src = "- a\n note\n - c\n";
126+ let (_d, mut v) = vault_with(src);
127+ let r = at(&v, "a");
128+ ops::toggle_spelling(&mut v, r).unwrap();
129+ assert_eq!(root_text(&v), "# a\nnote\n- c\n");
130+ assert_eq!(v.tree.node(at(&v, "a")).kind, Kind::Section);
131+ assert!(v.find_by_path(&["a".into(), "c".into()]).is_some());
132+ let r = at(&v, "a");
133+ ops::toggle_spelling(&mut v, r).unwrap();
134+ assert_eq!(root_text(&v), src);
135+}
136+
137+#[test]
138+fn toggle_keeps_a_done_checkbox() {
139+ let (_d, mut v) = vault_with("- [x] a\n");
140+ let r = at(&v, "a");
141+ ops::toggle_spelling(&mut v, r).unwrap();
142+ assert_eq!(root_text(&v), "# [x] a\n");
143+}
144+
145+#[test]
146+fn move_refuses_to_cross_the_boundary() {
147+ let src = "# P\n\n- a\n\n## S\n";
148+ let (_d, mut v) = vault_with(src);
149+ let r = at(&v, "P/a");
150+ assert!(ops::move_sibling(&mut v, r, true).is_err());
151+ let r = at(&v, "P/S");
152+ assert!(ops::move_sibling(&mut v, r, false).is_err());
153+ assert_eq!(root_text(&v), src);
154+}
155+
156+#[test]
157+fn move_leaves_text_children_in_place() {
158+ let (_d, mut v) = vault_with("# P\n\n- a\n\nmiddle\n\n- b\n");
159+ let r = at(&v, "P/a");
160+ ops::move_sibling(&mut v, r, true).unwrap();
161+ assert_eq!(root_text(&v), "# P\n\n- b\n\nmiddle\n\n- a\n");
162+}
163+
164+#[test]
165+fn delete_leaves_text_children_in_place() {
166+ let (_d, mut v) = vault_with("# P\n\n- a\n\nmiddle\n\n- b\n");
167+ let r = at(&v, "P/a");
168+ ops::delete_subtree(&mut v, r).unwrap();
169+ assert_eq!(root_text(&v), "# P\n\nmiddle\n\n- b\n");
170+}
171+
172+#[test]
173+fn promote_item_out_of_a_section_lands_before_the_sections() {
174+ let (_d, mut v) = vault_with("# G\n\n## P\n\n- x\n");
175+ let r = at(&v, "G/P/x");
176+ ops::promote(&mut v, r).unwrap();
177+ assert_eq!(root_text(&v), "# G\n\n- x\n\n## P\n");
178+ assert_ordered(&v);
179+}
180+
181+#[test]
182+fn demote_item_into_a_node_with_section_children() {
183+ let (_d, mut v) = vault_with("- a\n ## S\n s body\n- b\n");
184+ let r = at(&v, "b");
185+ ops::demote(&mut v, r).unwrap();
186+ let kids: Vec<String> = v
187+ .tree
188+ .resolved_children(at(&v, "a"))
189+ .iter()
190+ .map(|&c| v.tree.node(c).title.clone())
191+ .collect();
192+ assert_eq!(kids, ["b", "S"]);
193+ assert!(v.find_by_path(&["a".into(), "S".into()]).is_some());
194+ assert_ordered(&v);
195+}
196+
197+#[test]
198+fn refile_item_under_sections_is_clamped() {
199+ let (_d, mut v) = vault_with("# D\n\n## X\n\n# Src\n\n- it\n");
200+ let dest = at(&v, "D");
201+ let r = at(&v, "Src/it");
202+ ops::refile(&mut v, r, dest).unwrap();
203+ assert!(v.find_by_path(&["D".into(), "it".into()]).is_some(), "{}", root_text(&v));
204+ assert!(v.find_by_path(&["D".into(), "X".into(), "it".into()]).is_none());
205+ assert_ordered(&v);
206+}
207+
208+#[test]
209+fn refile_item_keeps_nested_section_levels() {
210+ let (_d, mut v) = vault_with("# D\n\n# Src\n\n- a\n ## S\n");
211+ let dest = at(&v, "D");
212+ let r = at(&v, "Src/a");
213+ ops::refile(&mut v, r, dest).unwrap();
214+ assert_eq!(root_text(&v), "# D\n\n- a\n ## S\n\n# Src\n");
215+}
216+
217+#[test]
218+fn paste_item_after_a_section_is_clamped() {
219+ let (_d, mut v) = vault_with("# P\n\n- a\n\n## S\n");
220+ let r = at(&v, "P/S");
221+ ops::paste(&mut v, r, "- new\n", true).unwrap();
222+ assert!(v.find_by_path(&["P".into(), "new".into()]).is_some(), "{}", root_text(&v));
223+ assert!(v.find_by_path(&["P".into(), "S".into(), "new".into()]).is_none());
224+ assert_ordered(&v);
225+}
226+
227+#[test]
228+fn paste_section_before_an_item_is_clamped() {
229+ let (_d, mut v) = vault_with("# P\n\n- a\n- b\n");
230+ let r = at(&v, "P/a");
231+ ops::paste(&mut v, r, "# T\n", false).unwrap();
232+ let kids: Vec<String> = v
233+ .tree
234+ .resolved_children(at(&v, "P"))
235+ .iter()
236+ .map(|&c| v.tree.node(c).title.clone())
237+ .collect();
238+ assert_eq!(kids, ["a", "b", "T"]);
239+ assert_ordered(&v);
240+}
241+
242+#[test]
243+fn capture_to_a_node_with_sections_lands_before_them() {
244+ let (_d, mut v) = vault_with("# P\n\n## S\n");
245+ let p = at(&v, "P");
246+ ops::capture_to(&mut v, "x", false, p).unwrap();
247+ assert!(v.find_by_path(&["P".into(), "x".into()]).is_some(), "{}", root_text(&v));
248+ assert_ordered(&v);
249+}
250+
251+// ------------------------------------------------------------- heading embeds
252+
253+#[test]
254+fn make_block_of_a_later_section_keeps_its_parent() {
255+ let (_d, mut v) = vault_with("# A\n\n## B\n\nb body\n\n## C\n\nc body\n");
256+ let before = parents(&v);
257+ let r = at(&v, "A/C");
258+ let id = ops::make_block(&mut v, r).unwrap();
259+ assert_eq!(
260+ root_text(&v),
261+ format!("# A\n\n## B\n\nb body\n\n## ![[{}]]\n", id)
262+ );
263+ assert_eq!(parents(&v), before);
264+ // and a section block before another section
265+ let r = at(&v, "A/B");
266+ let id2 = ops::make_block(&mut v, r).unwrap();
267+ assert_eq!(root_text(&v), format!("# A\n\n## ![[{}]]\n\n## ![[{}]]\n", id2, id));
268+ assert_eq!(parents(&v), before);
269+}
270+
271+#[test]
272+fn item_blocks_keep_bare_embeds() {
273+ let (_d, mut v) = vault_with("- a\n- b\n");
274+ let r = at(&v, "a");
275+ let id = ops::make_block(&mut v, r).unwrap();
276+ assert_eq!(root_text(&v), format!("![[{}]]\n- b\n", id));
277+}
278+
279+#[test]
280+fn toggling_a_block_rewrites_its_embed() {
281+ let (_d, mut v) = vault_with("# P\n\n- a\n- b\n");
282+ let r = at(&v, "P/a");
283+ let id = ops::make_block(&mut v, r).unwrap();
284+ let r = at(&v, "P/a");
285+ ops::toggle_spelling(&mut v, r).unwrap();
286+ assert_eq!(root_text(&v), format!("# P\n\n- b\n\n## ![[{}]]\n", id));
287+ let a = at(&v, "P/a");
288+ assert_eq!(v.tree.node(a).kind, Kind::Section);
289+ assert!(v.tree.files[a.0].text.ends_with("# a\n"), "{}", v.tree.files[a.0].text);
290+ assert!(check::check(&v).iter().all(|d| !d.message.contains("embed form")));
291+}
292+
293+#[test]
294+fn check_reports_and_fixes_embed_forms() {
295+ let dir = tempfile::tempdir().unwrap();
296+ std::fs::write(
297+ dir.path().join("root.md"),
298+ "# A\n\n## B\n\n- x\n![[racfer-hattes-mislup-nodrys]]\n",
299+ )
300+ .unwrap();
301+ std::fs::write(
302+ dir.path().join("racfer~s.md"),
303+ "---\nid: racfer-hattes-mislup-nodrys\n---\n\n# S\n",
304+ )
305+ .unwrap();
306+ let mut v = Vault::open(dir.path()).unwrap();
307+ assert!(check::check(&v).iter().any(|d| d.message.contains("embed form")));
308+ check::fix(&mut v).unwrap();
309+ assert!(check::check(&v).iter().all(|d| !d.message.contains("embed form")));
310+ assert!(root_text(&v).contains("### ![[racfer-hattes-mislup-nodrys]]"), "{}", root_text(&v));
311+ assert_ordered(&v);
312+}
313+
314+#[test]
315+fn fix_keeps_root_level_text_between_nodes() {
316+ let (_d, mut v) = vault_with("- a\ntext between\n- b\n");
317+ check::fix(&mut v).unwrap();
318+ assert!(root_text(&v).contains("text between"), "{}", root_text(&v));
319+ assert!(v.find_by_path(&["b".into()]).is_some());
320+}
321+
322+// ------------------------------------------------------------- merge
323+
324+#[test]
325+fn merge_keeps_text_after_children() {
326+ let o = "# P\n\n- a\n\nafter the list\n";
327+ let t = "# P\n\n- a\n\nafter the list\n\n# Q\n";
328+ let m = merge::merge_texts(o, t, "phone", "20260926-1200");
329+ assert_eq!(m.conflicts, 0);
330+ assert!(m.text.contains("after the list"), "{}", m.text);
331+ assert!(m.text.contains("# Q"), "{}", m.text);
332+}
333+
334+#[test]
335+fn merge_text_child_difference_conflicts() {
336+ let o = "# P\n\n- a\n\nafter\n";
337+ let t = "# P\n\n- a\n\nafter, edited\n";
338+ let m = merge::merge_texts(o, t, "phone", "20260926-1200");
339+ assert_eq!(m.conflicts, 1);
340+ assert!(m.conflict_blocks[0].1.contains("after, edited"));
341+}
342+
343+#[test]
344+fn merge_places_inserted_items_before_sections() {
345+ let o = "# P\n\n- a\n\n## S\n";
346+ let t = "# P\n\n- a\n- new\n\n## S\n";
347+ let m = merge::merge_texts(o, t, "phone", "20260926-1200");
348+ let (_d, v) = vault_with(&m.text);
349+ assert!(v.find_by_path(&["P".into(), "new".into()]).is_some(), "{}", m.text);
350+ assert_ordered(&v);
351+}
352+
353+#[test]
354+fn section_conflict_is_a_heading_embed_next_sibling() {
355+ let o = "# A\n\nbody o\n\n- x\n\n# B\n";
356+ let t = "# A\n\nbody t\n\n- x\n\n# B\n";
357+ let m = merge::merge_texts(o, t, "phone", "20260926-1200");
358+ assert_eq!(m.conflicts, 1);
359+ let dir = tempfile::tempdir().unwrap();
360+ std::fs::write(dir.path().join("root.md"), &m.text).unwrap();
361+ for (name, text) in &m.conflict_blocks {
362+ std::fs::write(dir.path().join(name), text).unwrap();
363+ }
364+ let v = Vault::open(dir.path()).unwrap();
365+ let pairs = merge::conflict_pairs(&v);
366+ assert_eq!(pairs.len(), 1, "{}", m.text);
367+ assert_eq!(v.tree.node(pairs[0].0).title, "A");
368+ // x is still A's child, and B is still top-level
369+ assert!(v.find_by_path(&["A".into(), "x".into()]).is_some());
370+ assert!(v.find_by_path(&["B".into()]).is_some());
371+ assert_ordered(&v);
372+}
373+
374+fn vault_with_block(root: &str, block: &str) -> (tempfile::TempDir, Vault) {
375+ let dir = tempfile::tempdir().unwrap();
376+ std::fs::write(dir.path().join("root.md"), root).unwrap();
377+ std::fs::write(
378+ dir.path().join("racfer~s.md"),
379+ format!("---\nid: racfer-hattes-mislup-nodrys\n---\n\n{}", block),
380+ )
381+ .unwrap();
382+ let v = Vault::open(dir.path()).unwrap();
383+ (dir, v)
384+}
385+
386+#[test]
387+fn refiling_a_section_block_relevels_its_heading_embed() {
388+ let (_d, mut v) = vault_with_block(
389+ "# A\n\n## ![[racfer-hattes-mislup-nodrys]]\n\n# B\n\n## C\n",
390+ "# S\n\nbody\n",
391+ );
392+ let dest = at(&v, "B/C");
393+ let r = at(&v, "A/S");
394+ ops::refile(&mut v, r, dest).unwrap();
395+ assert_eq!(root_text(&v), "# A\n\n# B\n\n## C\n\n### ![[racfer-hattes-mislup-nodrys]]\n");
396+ assert!(v.find_by_path(&["B".into(), "C".into(), "S".into()]).is_some());
397+}
398+
399+#[test]
400+fn promote_out_of_a_block_root_lands_beside_its_embed() {
401+ let (_d, mut v) = vault_with_block(
402+ "# A\n\n- x\n![[racfer-hattes-mislup-nodrys]]\n- y\n",
403+ "- s\n - kid\n",
404+ );
405+ let r = at(&v, "A/s/kid");
406+ ops::promote(&mut v, r).unwrap();
407+ let kids: Vec<String> = v
408+ .tree
409+ .resolved_children(at(&v, "A"))
410+ .iter()
411+ .map(|&c| v.tree.node(c).title.clone())
412+ .collect();
413+ assert_eq!(kids, ["x", "s", "kid", "y"], "{}", root_text(&v));
414+ assert!(v.tree.resolved_children(at(&v, "A/s")).is_empty());
415+}
crates/fold-tui/src/app.rs+1−1
@@ -2060,7 +2060,7 @@ impl App {
20602060 return;
20612061 }
20622062 // full-text over body
2063− let body = t.node(r).body_lines(t.text_of(r)).join("\n").to_lowercase();
2063+ let body = t.node(r).text_lines(t.text_of(r)).join("\n").to_lowercase();
20642064 if body.contains(&q) {
20652065 hits.push(r);
20662066 }
crates/fold-tui/tests/fixes.rs+11
@@ -180,3 +180,14 @@ fn tiny_terminal_filter_does_not_panic() {
180180 draw(&mut app, 40, h);
181181 }
182182}
183+
184+#[test]
185+fn respelling_moves_the_node_and_keeps_the_cursor_on_it() {
186+ let (d, mut app) = app_with("# P\n\n- a\n- b\n");
187+ press(&mut app, "j~");
188+ assert_eq!(root(&d), "# P\n\n- b\n\n## a\n");
189+ assert_eq!(current_title(&app), "a");
190+ // an item cannot move below a section: nothing changes
191+ press(&mut app, "kJ");
192+ assert_eq!(root(&d), "# P\n\n- b\n\n## a\n");
193+}