@@ -110,6 +110,159 @@ impl Inverse {
110110 }
111111}
112112
113+/// The session op log (§10.10): what undo would take back and redo would
114+/// do again, and the blocks an editor save left in transit (§5.2), each
115+/// by its file, with the length of `undo` once the entry of the save that
116+/// wrote its embed out was in. What the TUI and the app hold between
117+/// verbs.
118+#[derive(Debug, Default)]
119+pub struct OpLog {
120+ undo: Vec<Inverse>,
121+ redo: Vec<Inverse>,
122+ transit: Vec<(String, usize)>,
123+}
124+
125+impl OpLog {
126+ /// The entry undo would take back.
127+ pub fn last_undo(&self) -> Option<&Inverse> {
128+ self.undo.last()
129+ }
130+
131+ /// The entry redo would do again.
132+ pub fn last_redo(&self) -> Option<&Inverse> {
133+ self.redo.last()
134+ }
135+
136+ /// How many entries undo can take back.
137+ pub fn depth(&self) -> usize {
138+ self.undo.len()
139+ }
140+
141+ /// An operation's entry. `None`, an operation that changed nothing,
142+ /// leaves the log as it is, the redo stack too. True when recorded.
143+ pub fn record(&mut self, inv: Option<Inverse>) -> bool {
144+ let Some(inv) = inv else { return false };
145+ self.undo.push(inv);
146+ self.redo.clear();
147+ true
148+ }
149+
150+ /// `record` for a save of the editor, its *Revert*, or a reload that
151+ /// re-renders it (§11.2): a block an earlier save left in transit
152+ /// (§5.2) and this one deletes is deleted in the entry of that save,
153+ /// which wrote its embed out, so one undo puts back its file and its
154+ /// embed together, never the file embedded nowhere. One this save
155+ /// pastes back, writing its embed again, makes that save, this one and
156+ /// those between one entry, so no undo stops where its file is embedded
157+ /// nowhere either. `in_transit` are the files of the blocks the editor
158+ /// holds in transit now, remembered with this save's entry until their
159+ /// file is deleted or embedded again; `editing`, whether it is still
160+ /// open. True when there was an entry to record.
161+ pub fn record_edit(&mut self, inv: Option<Inverse>, vault: &Vault, editing: bool, in_transit: Vec<String>) -> bool {
162+ // the length of `undo` once this save's entry is in, if it has one
163+ let mut at = None;
164+ let recorded = inv.is_some();
165+ if let Some(mut inv) = inv {
166+ let (transit, undo) = (&self.transit, &mut self.undo);
167+ inv.changes.retain(|c| {
168+ let entry = transit.iter().find(|(p, _)| *p == c.path && c.after.is_none());
169+ // that entry, and none since, left the file as it was
170+ let Some(&(_, n)) = entry.filter(|&&(_, n)| n > 0 && n <= undo.len()) else { return true };
171+ if undo[n - 1..].iter().any(|e| e.changes.iter().any(|x| x.path == c.path)) {
172+ return true;
173+ }
174+ undo[n - 1].changes.push(c.clone());
175+ false
176+ });
177+ if !inv.changes.is_empty() {
178+ self.undo.push(inv);
179+ at = Some(self.undo.len());
180+ }
181+ self.redo.clear();
182+ }
183+ // pasted back: joined from the earliest save that wrote out the
184+ // embed of a block embedded again now
185+ let mut back: Vec<usize> =
186+ self.transit.iter().filter(|(p, _)| embedded(vault, p) == Some(true)).map(|&(_, n)| n).collect();
187+ back.sort_unstable();
188+ if let Some(n) = back.into_iter().find(|&n| n > 0 && self.join(n - 1)) {
189+ // the entries from `n - 1` on are the one at `n - 1` now, if any
190+ let joined = self.undo.len() == n;
191+ at = at.and(joined.then_some(n));
192+ self.transit.retain_mut(|(_, m)| {
193+ *m = (*m).min(n);
194+ *m < n || joined
195+ });
196+ }
197+ // still in transit, or pasted back with its embed not written yet
198+ self.transit.retain(|(p, _)| editing && embedded(vault, p) == Some(false));
199+ if let Some(n) = at {
200+ for p in in_transit {
201+ if !self.transit.iter().any(|(q, _)| *q == p) {
202+ self.transit.push((p, n));
203+ }
204+ }
205+ }
206+ recorded
207+ }
208+
209+ /// Make the entries from `from` on one: for each file, its text before
210+ /// the first and after the last; one that ends as it began is left
211+ /// out, and an entry left with none is dropped. False, with the entries
212+ /// as they were, where a file changed from outside between two of
213+ /// them: undoing them as one would drop that change.
214+ fn join(&mut self, from: usize) -> bool {
215+ let Some(first) = self.undo.get(from) else { return false };
216+ let description = first.description.clone();
217+ let mut changes: Vec<Change> = Vec::new();
218+ for c in self.undo[from..].iter().flat_map(|e| &e.changes) {
219+ match changes.iter_mut().find(|x| x.path == c.path) {
220+ Some(x) if x.after == c.before => x.after = c.after.clone(),
221+ Some(_) => return false,
222+ None => changes.push(c.clone()),
223+ }
224+ }
225+ changes.retain(|c| c.before != c.after);
226+ self.undo.truncate(from);
227+ if !changes.is_empty() {
228+ self.undo.push(Inverse { changes, description });
229+ }
230+ true
231+ }
232+
233+ /// Undo the last entry, or with `undo` false redo the last one undone
234+ /// (§10.10), and say which by its description. `None` when there is
235+ /// none. Refused — a file it touched changed since — it stays where
236+ /// it was. Either way the blocks in transit are let go: the entries
237+ /// they were remembered with may go.
238+ pub fn step(&mut self, vault: &mut Vault, undo: bool) -> Option<std::io::Result<String>> {
239+ self.transit.clear();
240+ let (from, to) = if undo { (&mut self.undo, &mut self.redo) } else { (&mut self.redo, &mut self.undo) };
241+ let inv = from.pop()?;
242+ let res = if undo { inv.undo(vault) } else { inv.redo(vault) };
243+ Some(match res {
244+ Ok(()) => {
245+ let done = inv.description.clone();
246+ to.push(inv);
247+ Ok(done)
248+ }
249+ Err(e) => {
250+ from.push(inv);
251+ Err(e)
252+ }
253+ })
254+ }
255+}
256+
257+/// Whether the block whose file is `path` is embedded now; `None` where
258+/// the file is gone.
259+fn embedded(vault: &Vault, path: &str) -> Option<bool> {
260+ let tree = &vault.tree;
261+ let f = vault.file_index(path)?;
262+ let (_, id) = tree.blocks.iter().find(|(r, _)| r.0 == f)?;
263+ Some(tree.embed_of(id).is_some())
264+}
265+
113266fn today() -> String {
114267 jiff::Zoned::now().date().to_string()
115268}
@@ -136,13 +289,6 @@ fn capture_inner(
136289 task: bool,
137290 target: Option<NRef>,
138291) -> std::io::Result<NRef> {
139− let dest = match target {
140− Some(t) => t,
141− None => {
142− let inbox = find_or_create_inbox(vault)?;
143− find_or_create_day(vault, inbox)?
144− }
145− };
146292 // The first line is the title; further lines are the item's body, so a
147293 // captured document (`capture < file.md`) nests under the item instead
148294 // of injecting structure beside it (§4.1): its headings are pushed below
@@ -154,14 +300,17 @@ fn capture_inner(
154300 } else {
155301 first.trim().to_string()
156302 };
157− let mut line = format!("- {}", title);
303+ // refused before the inbox or its day is created
304+ let mut line = item_line(&title)?;
305+ let dest = match target {
306+ Some(t) => write_target(&vault.tree, t)?,
307+ None => {
308+ let inbox = find_or_create_inbox(vault)?;
309+ find_or_create_day(vault, inbox)?
310+ }
311+ };
158312 if !rest.trim().is_empty() {
159− let dn = vault.tree.node(dest);
160− let item_indent = if dn.kind == Kind::Item {
161− vault.tree.indent(dest) + 2
162− } else {
163− vault.tree.indent(dest)
164− };
313+ let item_indent = child_indent(&vault.tree, dest);
165314 let level = vault.tree.level(dest) as isize;
166315 let body = shift_lines(rest.trim_end(), level, item_indent as isize + 2);
167316 line.push('\n');
@@ -188,9 +337,13 @@ fn find_or_create_day(vault: &mut Vault, inbox: NRef) -> std::io::Result<NRef> {
188337 return Ok(c);
189338 }
190339 }
191− // `## <today>` as the last child of Inbox (§7)
340+ // `## <today>` as the last child of Inbox (§7), at its child indent: an
341+ // Inbox respelled as an item is still the inbox (§3.1); no deeper than
342+ // a day before it as written, which would take it as its child
343+ let last = vault.tree.raw_children(inbox).last().copied();
192344 let level = vault.tree.level(inbox) + 1;
193− let indent = vault.tree.indent(inbox);
345+ let level = written_level(&vault.tree, last).map_or(level, |w| level.min(w));
346+ let indent = child_indent(&vault.tree, inbox);
194347 let heading = format!(
195348 "{}{} {}",
196349 " ".repeat(indent),
@@ -199,10 +352,9 @@ fn find_or_create_day(vault: &mut Vault, inbox: NRef) -> std::io::Result<NRef> {
199352 );
200353 let inbox_key = vault.key_of(inbox);
201354 append_structural_line(vault, inbox, &heading)?;
202− vault.reload()?;
203355 let inbox = vault
204356 .find_by_key(&inbox_key)
205− .ok_or_else(|| io_err("inbox lost after reload"))?;
357+ .ok_or_else(|| io_err("inbox lost after the write"))?;
206358 for c in vault.tree.resolved_children(inbox) {
207359 if vault.tree.node(c).title == day {
208360 return Ok(c);
@@ -282,20 +434,22 @@ fn append_child_line(
282434 line: &str,
283435 section_ok: bool,
284436) -> std::io::Result<NRef> {
437+ // under the node an embed stands for: an embed line has no children
438+ // in its file (§6.2)
439+ let parent = write_target(&vault.tree, parent)?;
285440 let parent_key = vault.key_of(parent);
286441 let node = vault.tree.node(parent);
287442 let file = parent.0;
288443 // Items under a section sit at the section's own indent; items under an
289444 // item nest one level deeper (§3.1, §4.10).
290− let indent = if node.kind == Kind::Item {
291− vault.tree.indent(parent) + 2
292− } else {
293− vault.tree.indent(parent)
294− };
445+ let indent = child_indent(&vault.tree, parent);
295446 let text = vault.tree.files[file].text.clone();
296447 let kids = vault.tree.raw_children(parent);
297448 let is_section = |r: NRef| vault.tree.node(r).kind == Kind::Section;
298449 let first_section = kids.iter().position(|&c| is_section(c));
450+ // §4.2 puts one blank line between a node's body and its first child:
451+ // an item with no body has its first child right under its title
452+ let bare_item = node.kind == Kind::Item && node.body_lines(&text).is_empty();
299453 let index;
300454 match (first_section, section_ok) {
301455 (Some(_), true) => {
@@ -307,10 +461,12 @@ fn append_child_line(
307461 *kids.iter().rev().find(|&&c| is_section(c)).unwrap()
308462 };
309463 let title = line.trim_start().strip_prefix("- ").unwrap_or(line.trim_start());
464+ // at the last section's written indent, which its parent
465+ // reaches, and written level, which keeps it beside it
310466 let heading = format!(
311467 "{}{} {}",
312− " ".repeat(vault.tree.indent(last)),
313− "#".repeat(vault.tree.level(last)),
468+ " ".repeat(vault.tree.node(last).indent),
469+ "#".repeat(written_level(&vault.tree, Some(last)).unwrap_or(1)),
314470 title
315471 );
316472 let pos = trimmed_end(&text, node.span);
@@ -325,7 +481,7 @@ fn append_child_line(
325481 pos -= 1;
326482 }
327483 let prev_is_item = fs > 0 && !is_section(kids[fs - 1]);
328− let sep = if prev_is_item { "" } else { "\n" };
484+ let sep = if prev_is_item || (fs == 0 && bare_item) { "" } else { "\n" };
329485 vault.write_span(
330486 file,
331487 Span { start: pos, end: pos },
@@ -337,17 +493,16 @@ fn append_child_line(
337493 let line_indented = format!("{}{}", " ".repeat(indent), line);
338494 let pos = trimmed_end(&text, node.span);
339495 // blank line before unless the previous sibling is also an item
340− // (tight list)
341− let prev_is_item = kids.last().map(|&c| !is_section(c)).unwrap_or(false);
496+ // (tight list), or there is none under a bare item
497+ let prev_is_item = kids.last().map(|&c| !is_section(c)).unwrap_or(bare_item);
342498 let sep = if prev_is_item { "\n" } else { "\n\n" };
343499 insert_at(vault, file, pos, sep, &line_indented)?;
344500 index = kids.len();
345501 }
346502 }
347− vault.reload()?;
348503 let parent = vault
349504 .find_by_key(&parent_key)
350− .ok_or_else(|| io_err("parent lost after reload"))?;
505+ .ok_or_else(|| io_err("parent lost after the write"))?;
351506 vault
352507 .tree
353508 .resolved_children(parent)
@@ -372,7 +527,7 @@ pub fn toggle_task(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
372527
373528/// Toggle task-ness itself (§10.3 `t`).
374529pub fn toggle_taskness(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
375− let r = vault.tree.resolved_child(r);
530+ let r = write_target(&vault.tree, r)?;
376531 let state = match vault.tree.node(r).task {
377532 Some(_) => None,
378533 None => Some(TaskState::Open),
@@ -387,8 +542,11 @@ pub fn set_task(vault: &mut Vault, r: NRef, state: Option<TaskState>) -> std::io
387542 let n = vault.tree.node(r);
388543 let is_block = n.is_block();
389544 let file = r.0;
390− let ts = n.title_span;
391− let line = ts.text(&vault.tree.files[file].text).to_string();
545+ // a setext title has no marker to put the checkbox after: it is
546+ // rewritten as an ATX heading, underline and all (§4.2)
547+ let (ts, line) = setext_as_atx(&vault.tree, r).unwrap_or_else(|| {
548+ (n.title_span, n.title_span.text(&vault.tree.files[file].text).to_string())
549+ });
392550 let mark = |st: TaskState| match st {
393551 TaskState::Open => "[ ]",
394552 TaskState::Done => "[x]", // also rewrites `[X]` / `[-]`
@@ -407,7 +565,9 @@ pub fn set_task(vault: &mut Vault, r: NRef, state: Option<TaskState>) -> std::io
407565 },
408566 (None, None) => line.clone(),
409567 };
410− if new_line != line {
568+ // `- [ ] ---` unchecked is `- ---`, a thematic break (§4.4)
569+ not_a_break(&new_line)?;
570+ if new_line != ts.text(&vault.tree.files[file].text) {
411571 vault.write_span(file, ts, &new_line)?;
412572 }
413573 if is_block {
@@ -440,6 +600,26 @@ fn marker_end(line: &str) -> Option<usize> {
440600 }
441601}
442602
603+/// A setext section's title (§4.2: read, converted on write) as an ATX
604+/// heading at its written level and indent, with the span it replaces: the
605+/// title line and its underline, the line after it. `None` for any other
606+/// node. The written level keeps what nests under the heading unchanged.
607+fn setext_as_atx(tree: &crate::tree::Tree, r: NRef) -> Option<(Span, String)> {
608+ let n = tree.node(r);
609+ let text = tree.text_of(r);
610+ let line = n.title_span.text(text);
611+ let t = line.trim_start();
612+ // every other section's title line is an ATX heading (or a heading embed)
613+ if n.kind != Kind::Section || atx_hashes(t).is_some() {
614+ return None;
615+ }
616+ let under = text[n.title_span.end..].find('\n').map_or(text.len(), |i| n.title_span.end + i + 1);
617+ let end = text[under..].find('\n').map_or(text.len(), |i| under + i);
618+ let indent = &line[..line.len() - t.len()];
619+ let atx = format!("{}{} {}", indent, "#".repeat(n.level.unwrap_or(1)), n.title);
620+ Some((Span { start: n.title_span.start, end }, atx))
621+}
622+
443623/// Byte range of the checkbox right after the marker (`[ ]`, `[x]`, `[X]`,
444624/// `[-]`) plus the space after it (§4.2). Checkbox-like text later in the
445625/// title is title text.
@@ -468,17 +648,13 @@ pub fn set_frontmatter_key(
468648 value: Option<&str>,
469649) -> std::io::Result<()> {
470650 let f = vault.tree.files[file].clone();
471− let root_node = f.nodes[f.root_node].children.get(0).copied();
472− let block = root_node.and_then(|rn| f.nodes[rn].block.clone());
651+ let block = file_block(&f).cloned();
473652 let (mut raw, fm_span) = match &block {
474653 Some(b) => (b.frontmatter_raw.clone(), b.frontmatter_span),
475654 None => (String::new(), None),
476655 };
477656 // rewrite the line for `key` inside raw
478657 let mut lines: Vec<String> = raw.split_inclusive('\n').map(|s| s.to_string()).collect();
479− if !lines.is_empty() && !lines.last().unwrap().ends_with('\n') {
480− // keep as is; we append a newline when needed
481− }
482658 let key_prefix = format!("{}:", key);
483659 let mut found = false;
484660 for l in lines.iter_mut() {
@@ -525,14 +701,18 @@ pub fn set_frontmatter_key(
525701 vault.write_file_text(file, &new_text)
526702}
527703
704+/// The block whose frontmatter a file holds: a block file's root, its
705+/// first node (§4.9); root.md's, the implicit Root, whose frontmatter holds
706+/// vault-level properties.
707+fn file_block(f: &crate::parse::ParsedFile) -> Option<&crate::parse::Block> {
708+ let root = &f.nodes[f.root_node];
709+ root.block.as_ref().or_else(|| f.nodes[*root.children.first()?].block.as_ref())
710+}
711+
528712/// All top-level keys of a block's frontmatter, for the property editor.
529713pub fn frontmatter_lines(vault: &Vault, file: usize) -> Vec<(String, String, bool)> {
530714 // (key, value, editable) — unknown-structure lines are read-only (§10.6)
531− let f = &vault.tree.files[file];
532− let Some(&rn) = f.nodes[f.root_node].children.first() else {
533− return Vec::new();
534− };
535− let Some(b) = &f.nodes[rn].block else {
715+ let Some(b) = file_block(&vault.tree.files[file]) else {
536716 return Vec::new();
537717 };
538718 let mut out = Vec::new();
@@ -555,31 +735,54 @@ pub fn frontmatter_lines(vault: &Vault, file: usize) -> Vec<(String, String, boo
555735
556736/// Delete a subtree: its span leaves its file; block files go to trash (§11.5),
557737/// including blocks nested anywhere under it, so none is left orphaned.
558−pub fn delete_subtree(vault: &mut Vault, r: NRef) -> std::io::Result<String> {
738+/// Returns how many nodes went, the node itself included, for the TUI to
739+/// say (§10.1).
740+pub fn delete_subtree(vault: &mut Vault, r: NRef) -> std::io::Result<usize> {
741+ // a node's conflict copies go with it, or the first would pair with
742+ // the node before it (§12.5); a copy goes alone, as keeping ours
743+ // sends it
744+ let copies: Vec<Id> = conflict_copies(&vault.tree, r)
745+ .into_iter()
746+ .filter_map(|c| vault.tree.node(c).block.as_ref()?.id.clone())
747+ .collect();
748+ let mut count = delete_one(vault, r)?;
749+ for id in copies {
750+ if let Some(c) = vault.tree.block_by_id(&id) {
751+ count += delete_one(vault, c)?;
752+ }
753+ }
754+ Ok(count)
755+}
756+
757+/// `delete_subtree` for `r` alone.
758+fn delete_one(vault: &mut Vault, r: NRef) -> std::io::Result<usize> {
559759 let n = vault.tree.node(r);
560760 if n.kind == Kind::Root {
561761 return Err(io_err("cannot delete the root"));
562762 }
763+ let mut count = 0;
764+ vault.tree.walk(vault.tree.resolved_child(r), &mut |_, _| count += 1);
563765 if n.is_block() || n.is_embed() {
564766 let target = if n.is_embed() { vault.tree.resolved_child(r) } else { r };
565767 if target == r && n.is_embed() {
566− // broken embed: just the line
567− let span = n.span;
568− remove_span_with_separator(vault, r.0, span)?;
569− return Ok("broken embed removed".into());
768+ // broken embed: just the line. So for a second embed of a
769+ // block, which renders as broken (§6.2): the block, and the
770+ // embed it is stitched in at, are not this line's
771+ let span = embed_line_span(&vault.tree, r);
772+ remove_span(vault, r.0, span)?;
773+ return Ok(1);
570774 }
775+ // the block itself first: it leaves by its own embed
571776 let ids = nested_block_ids(vault, target);
572− let path = vault.tree.node(target).block.as_ref().unwrap().path.clone();
573− for id in ids {
574− trash_block(vault, &id)?;
777+ if let Some((own, nested)) = ids.split_first() {
778+ trash_block(vault, own)?;
779+ trash_nested(vault, nested)?;
575780 }
576− return Ok(format!("block {:?} trashed", path));
781+ return Ok(count);
577782 }
578783 let ids = plain_remove(vault, r)?;
579− for id in ids {
580− trash_block(vault, &id)?;
581− }
582− Ok("subtree trashed".into())
784+ trash_nested(vault, &ids)?;
785+ Ok(count)
583786}
584787
585788/// Ids of every block in the resolved subtree of `r` (pre-order, `r` itself
@@ -602,22 +805,16 @@ fn plain_remove(vault: &mut Vault, r: NRef) -> std::io::Result<Vec<Id>> {
602805 let name = format!("{}.md", slug(&vault.tree.node(r).title));
603806 vault.trash_text(&name, &text)?;
604807 let span = vault.tree.node(r).span;
605− remove_span_with_separator(vault, r.0, span)?;
808+ remove_span(vault, r.0, span)?;
606809 Ok(ids)
607810}
608811
609−/// Remove a block's embed line (if it is still anywhere) and move its file to
610−/// the trash.
812+/// Remove the embed line a block is stitched in at (if it is still
813+/// anywhere) and move its file to the trash.
611814fn trash_block(vault: &mut Vault, id: &Id) -> std::io::Result<()> {
612− let embed = vault.tree.files.iter().enumerate().find_map(|(fi, f)| {
613− f.nodes
614− .iter()
615− .position(|nd| nd.embed.as_ref() == Some(id))
616− .map(|ni| (fi, ni))
617− });
618− if let Some(e) = embed {
619− let span = vault.tree.node(e).span;
620− remove_span_with_separator(vault, e.0, span)?;
815+ if let Some(e) = vault.tree.embed_of(id) {
816+ let span = embed_line_span(&vault.tree, e);
817+ remove_span(vault, e.0, span)?;
621818 }
622819 if let Some(b) = vault.tree.block_by_id(id) {
623820 vault.trash_file(b.0)?;
@@ -625,6 +822,36 @@ fn trash_block(vault: &mut Vault, id: &Id) -> std::io::Result<()> {
625822 Ok(())
626823}
627824
825+/// Move the files of blocks nested in a removed subtree to the trash. Their
826+/// embeds went with the lines and files that held them: an embed of one
827+/// still elsewhere is a second embed, a line of its own (§6.2), and stays.
828+fn trash_nested(vault: &mut Vault, ids: &[Id]) -> std::io::Result<()> {
829+ for id in ids {
830+ if let Some(b) = vault.tree.block_by_id(id) {
831+ vault.trash_file(b.0)?;
832+ }
833+ }
834+ Ok(())
835+}
836+
837+/// The part of an embed's span that is the embed itself: its line and the
838+/// blank lines after it. Lines nested under an embed are a diagnostic
839+/// (§4.7) that the reading pane shows in the parent, so they stay there
840+/// when the embed goes: nothing else holds a copy of them (§11.5).
841+fn embed_line_span(tree: &crate::tree::Tree, e: NRef) -> Span {
842+ let n = tree.node(e);
843+ let text = tree.text_of(e);
844+ let end = n.span.end.min(text.len());
845+ let mut at = text[n.title_span.end..end].find('\n').map_or(end, |i| n.title_span.end + i + 1);
846+ for l in text[at..end].split_inclusive('\n') {
847+ if !l.trim().is_empty() {
848+ break;
849+ }
850+ at += l.len();
851+ }
852+ Span { start: n.span.start, end: at }
853+}
854+
628855/// The byte range to cut when removing `span`: the node plus whatever blank
629856/// separator would otherwise be doubled or left dangling. A blank line that
630857/// separated the node from what follows stays when nothing blank precedes
@@ -658,17 +885,12 @@ fn after_removal(pos: usize, start: usize, end: usize) -> usize {
658885}
659886
660887/// Remove a span plus one adjacent blank separator line, then re-parse just
661−/// that file (no full reload, so other files' node refs stay valid).
662−fn remove_span_no_reload(vault: &mut Vault, file: usize, span: Span) -> std::io::Result<()> {
888+/// that file, so other files' node refs stay valid.
889+fn remove_span(vault: &mut Vault, file: usize, span: Span) -> std::io::Result<()> {
663890 let (start, end) = removal_range(&vault.tree.files[file].text, span);
664891 vault.write_span(file, Span { start, end }, "")
665892}
666893
667−/// Remove a span plus one adjacent blank separator line.
668−fn remove_span_with_separator(vault: &mut Vault, file: usize, span: Span) -> std::io::Result<()> {
669− remove_span_no_reload(vault, file, span)
670−}
671−
672894/// Yank: render the subtree resolved (the register's text) (§10.3).
673895pub fn yank(vault: &Vault, r: NRef) -> String {
674896 render(&vault.tree, r, 1, true)
@@ -676,7 +898,7 @@ pub fn yank(vault: &Vault, r: NRef) -> String {
676898
677899/// Paste rendered text after/before a node as siblings (§10.3 `p`/`P`),
678900/// clamped by the ordering rule (§3.1).
679−pub fn paste(vault: &mut Vault, at: NRef, text: &str, after: bool) -> std::io::Result<bool> {
901+pub fn paste(vault: &mut Vault, at: NRef, text: &str, after: bool) -> std::io::Result<Placed> {
680902 if vault.tree.node(at).kind == Kind::Root {
681903 return Err(io_err("cannot paste beside the root"));
682904 }
@@ -687,7 +909,8 @@ pub fn paste(vault: &mut Vault, at: NRef, text: &str, after: bool) -> std::io::R
687909 };
688910 let pos = vault.tree.raw_children(parent).iter().position(|&k| k == at).unwrap_or(0);
689911 let shifted = shift_document(text, vault.tree.level(parent), child_indent(&vault.tree, parent));
690− place(vault, None, parent, pos + after as usize, &shifted)
912+ let (nodes, clamped) = place(vault, &[], parent, pos + after as usize, &shifted)?;
913+ Placed::of(&nodes, 0, clamped)
691914}
692915
693916/// Parse a standalone document and re-emit it as children of a node whose
@@ -696,19 +919,9 @@ pub fn paste(vault: &mut Vault, at: NRef, text: &str, after: bool) -> std::io::R
696919/// in a section of level `parent_level`. Everything inside keeps its position
697920/// relative to its top-level node.
698921pub fn shift_document(text: &str, parent_level: usize, indent: usize) -> String {
699− let block = crate::parse::Block {
700− id: None,
701− path: "clip.md".into(),
702− props: Default::default(),
703− frontmatter_raw: String::new(),
704− frontmatter_span: None,
705− };
706− let pf = crate::parse::parse_file("clip.md", text, 0, Some(block));
707− let tree = crate::tree::Tree {
708− files: vec![pf],
709− root: (0, 0),
710− blocks: vec![],
711− };
922+ let block = crate::parse::Block::new("clip.md", None, None);
923+ let pf = crate::parse::parse_file("clip.md", text, Some(block));
924+ let tree = crate::tree::Tree::new(vec![pf]);
712925 let mut out = String::new();
713926 let kids = tree.resolved_children(tree.root);
714927 for (i, k) in kids.iter().enumerate() {
@@ -720,11 +933,9 @@ pub fn shift_document(text: &str, parent_level: usize, indent: usize) -> String
720933 let t = first.trim_start();
721934 // render(_, 1) writes a section root at level 1 and an item root in
722935 // a section of level 1 (its nested sections at 2)
723− let ld = if t.starts_with('#') {
724− let cur_level = t.chars().take_while(|&c| c == '#').count();
725− parent_level as isize + 1 - cur_level as isize
726− } else {
727− parent_level as isize - 1
936+ let ld = match atx_hashes(t) {
937+ Some(cur_level) => parent_level as isize + 1 - cur_level as isize,
938+ None => parent_level as isize - 1,
728939 };
729940 let cur_indent = first.len() - t.len();
730941 let id = indent as isize - cur_indent as isize;
@@ -740,26 +951,33 @@ pub fn shift_document(text: &str, parent_level: usize, indent: usize) -> String
740951}
741952
742953/// Shift every line of a rendered document by (level_delta, indent_delta).
954+/// Fenced code is shifted too, so it stays in its node's region (§3.3), but
955+/// never re-levelled.
743956fn shift_lines(raw: &str, level_delta: isize, indent_delta: isize) -> String {
744957 let mut out = String::new();
745958 let mut fence: Option<(char, usize)> = None;
746959 for line in raw.split_inclusive('\n') {
747960 let l = line.strip_suffix('\n').unwrap_or(line);
748961 let nl = if line.ends_with('\n') { "\n" } else { "" };
749− if fence_transition(l, &mut fence) || fence.is_some() {
750− out.push_str(l);
962+ let in_code = crate::parse::fence_transition(l, &mut fence) || fence.is_some();
963+ let trimmed = l.trim_start();
964+ if trimmed.is_empty() {
751965 out.push_str(nl);
752966 continue;
753967 }
754− let trimmed = l.trim_start();
755− if trimmed.is_empty() {
968+ if in_code {
969+ // only the leading spaces change: a tab, or any indentation
970+ // inside the code, stays as written
971+ let code = l.trim_start_matches(' ');
972+ let cur_indent = l.len() - code.len();
973+ out.push_str(&" ".repeat((cur_indent as isize + indent_delta).max(0) as usize));
974+ out.push_str(code);
756975 out.push_str(nl);
757976 continue;
758977 }
759978 let cur_indent = l.len() - trimmed.len();
760979 let new_indent = (cur_indent as isize + indent_delta).max(0) as usize;
761− if trimmed.starts_with('#') {
762− let hashes = trimmed.chars().take_while(|&c| c == '#').count();
980+ if let Some(hashes) = atx_hashes(trimmed) {
763981 let new_level = (hashes as isize + level_delta).max(1) as usize;
764982 out.push_str(&" ".repeat(new_indent));
765983 out.push_str(&"#".repeat(new_level));
@@ -774,35 +992,17 @@ fn shift_lines(raw: &str, level_delta: isize, indent_delta: isize) -> String {
774992 out
775993}
776994
777−fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
778− let t = raw.trim_start();
779− let first = match t.chars().next() {
780− Some(c) if c == '`' || c == '~' => c,
781− _ => return false,
782− };
783− let count = t.chars().take_while(|&c| c == first).count();
784− if count < 3 {
785− return false;
786− }
787− match open {
788− None => {
789− *open = Some((first, count));
790− true
791− }
792− Some((c, n)) => {
793− if *c == first && count >= *n {
794− *open = None;
795− true
796− } else {
797− false
798− }
799− }
800− }
995+/// The level of an ATX heading line, given without its indent: `#`+ then a
996+/// space or the end of the line (§4.2). `#tag` is text, as the parser reads it.
997+fn atx_hashes(trimmed: &str) -> Option<usize> {
998+ let hashes = trimmed.bytes().take_while(|&b| b == b'#').count();
999+ let after = &trimmed[hashes..];
1000+ (hashes > 0 && (after.is_empty() || after.starts_with(' '))).then_some(hashes)
8011001}
8021002
8031003/// Refile: move a subtree under a new parent as its last child (§6.5).
804−pub fn refile(vault: &mut Vault, r: NRef, dest: NRef) -> std::io::Result<bool> {
805− let dest = vault.tree.resolved_child(dest);
1004+pub fn refile(vault: &mut Vault, r: NRef, dest: NRef) -> std::io::Result<Placed> {
1005+ let dest = write_target(&vault.tree, dest)?;
8061006 let n = vault.tree.node(r);
8071007 // guard: cannot refile into own subtree — ancestry followed through
8081008 // embeds, so a destination inside a nested block counts too
@@ -816,7 +1016,7 @@ pub fn refile(vault: &mut Vault, r: NRef, dest: NRef) -> std::io::Result<bool> {
8161016 }
8171017 guard += 1;
8181018 if guard > 10_000 {
819− break; // embed cycle: a diagnostic elsewhere
1019+ break; // embed cycle: `check` reports it (§6.2)
8201020 }
8211021 anc = resolved_parent(&vault.tree, a);
8221022 }
@@ -826,9 +1026,61 @@ pub fn refile(vault: &mut Vault, r: NRef, dest: NRef) -> std::io::Result<bool> {
8261026 if vault.tree.node(moving).is_block() {
8271027 return Err(io_err("block has no embed"));
8281028 }
829− let rendered = render(&vault.tree, moving, 1, false);
830− let shifted = shift_document(&rendered, vault.tree.level(dest), child_indent(&vault.tree, dest));
831− place(vault, Some(moving), dest, usize::MAX, &shifted)
1029+ // with its conflict copies, or with the node a copy is of (§12.5)
1030+ let run = pair_run(&vault.tree, moving);
1031+ if run.iter().any(|&m| m != moving && within(&vault.tree, dest, m)) {
1032+ return Err(io_err(PAIR_INTO_ITSELF));
1033+ }
1034+ let doc = run_doc(&vault.tree, &run, dest);
1035+ let (nodes, clamped) = place(vault, &run, dest, usize::MAX, &doc)?;
1036+ Placed::of(&nodes, run.iter().position(|&m| m == moving).unwrap_or(0), clamped)
1037+}
1038+
1039+/// A node and its conflict copy move as one (§12.5): neither into the
1040+/// other.
1041+const PAIR_INTO_ITSELF: &str = "cannot move a node and its conflict copy into either of them";
1042+
1043+/// `run` (a node and its conflict copies, §12.5, as they stand in their
1044+/// parent's file) as a document of children of `parent`: each in its
1045+/// on-disk spelling, shifted there, a blank line before a heading or
1046+/// where there was one.
1047+fn run_doc(tree: &crate::tree::Tree, run: &[NRef], parent: NRef) -> String {
1048+ let (level, indent) = (tree.level(parent), child_indent(tree, parent));
1049+ let mut out = String::new();
1050+ for (i, &m) in run.iter().enumerate() {
1051+ if i > 0 {
1052+ let (sp, text) = (tree.node(run[i - 1]).span, tree.text_of(run[i - 1]));
1053+ if tree.node(m).kind == Kind::Section || text[sp.start..sp.end.min(text.len())].ends_with("\n\n") {
1054+ out.push('\n');
1055+ }
1056+ }
1057+ out.push_str(&shift_document(&render(tree, m, 1, false), level, indent));
1058+ }
1059+ out
1060+}
1061+
1062+/// Where a verb put the node it moved or made, by position — never found
1063+/// again by its title (§3.4): the node as the outline shows it (a block,
1064+/// not its embed), and whether the ordering rule (§3.1), not the verb,
1065+/// chose the place.
1066+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1067+pub struct Placed {
1068+ pub node: NRef,
1069+ pub clamped: bool,
1070+}
1071+
1072+impl Placed {
1073+ /// A verb that left `r` where it was.
1074+ fn stayed(tree: &crate::tree::Tree, r: NRef) -> Placed {
1075+ Placed { node: tree.resolved_child(r), clamped: false }
1076+ }
1077+
1078+ /// The `i`-th of the nodes `place` put (a run's, in order: the one a
1079+ /// verb moved among its conflict copies).
1080+ fn of(nodes: &[NRef], i: usize, clamped: bool) -> std::io::Result<Placed> {
1081+ let node = *nodes.get(i).ok_or_else(|| io_err("the placed node is not where it was written"))?;
1082+ Ok(Placed { node, clamped })
1083+ }
8321084}
8331085
8341086/// Where a dragged node is dropped (§10.3): before a node, as its sibling,
@@ -840,11 +1092,11 @@ pub enum Drop {
8401092}
8411093
8421094/// Move `r` relative to `target` (§10.3 drag and drop). Refuses a move into
843−/// `r`'s own subtree. Returns whether the ordering rule changed the place.
844−pub fn move_node(vault: &mut Vault, r: NRef, target: NRef, drop: Drop) -> std::io::Result<bool> {
1095+/// `r`'s own subtree.
1096+pub fn move_node(vault: &mut Vault, r: NRef, target: NRef, drop: Drop) -> std::io::Result<Placed> {
8451097 let target = vault.tree.resolved_child(target);
8461098 if target == vault.tree.resolved_child(r) {
847− return Ok(false);
1099+ return Ok(Placed::stayed(&vault.tree, r));
8481100 }
8491101 match drop {
8501102 Drop::Into => refile(vault, r, target),
@@ -858,11 +1110,27 @@ pub fn move_node(vault: &mut Vault, r: NRef, target: NRef, drop: Drop) -> std::i
8581110 return Err(io_err("cannot move a node into itself"));
8591111 }
8601112 let moving = stand_in(&vault.tree, r);
861− let idx = vault.tree.raw_children(parent).iter().position(|&k| k == t).unwrap_or(0);
862− let rendered = render(&vault.tree, moving, 1, false);
863− let shifted =
864− shift_document(&rendered, vault.tree.level(parent), child_indent(&vault.tree, parent));
865− place(vault, Some(moving), parent, idx, &shifted)
1113+ // with its conflict copies, or with the node a copy is of
1114+ // (§12.5); before either of them, it stays where it is
1115+ let run = pair_run(&vault.tree, moving);
1116+ if run.contains(&t) {
1117+ return Ok(Placed::stayed(&vault.tree, r));
1118+ }
1119+ if run.iter().any(|&m| m != moving && within(&vault.tree, parent, m)) {
1120+ return Err(io_err(PAIR_INTO_ITSELF));
1121+ }
1122+ // the target's index among the children `place` sees: without
1123+ // the moving nodes, which may come before it
1124+ let idx = vault
1125+ .tree
1126+ .raw_children(parent)
1127+ .into_iter()
1128+ .filter(|k| !run.contains(k))
1129+ .position(|k| k == t)
1130+ .unwrap_or(0);
1131+ let doc = run_doc(&vault.tree, &run, parent);
1132+ let (nodes, clamped) = place(vault, &run, parent, idx, &doc)?;
1133+ Placed::of(&nodes, run.iter().position(|&m| m == moving).unwrap_or(0), clamped)
8661134 }
8671135 }
8681136}
@@ -900,12 +1168,29 @@ fn stand_in(tree: &crate::tree::Tree, r: NRef) -> NRef {
9001168 r
9011169}
9021170
1171+/// The node a verb writes into at `r`: a block's own embed is the block
1172+/// (§4.7). An embed that resolves to nothing, broken or a second embed of
1173+/// a block (§6.2), is only a line of the file that holds it: it has no
1174+/// checkbox, properties or children of its own, so the verb is refused
1175+/// rather than writing them into that file.
1176+fn write_target(tree: &crate::tree::Tree, r: NRef) -> std::io::Result<NRef> {
1177+ let t = tree.resolved_child(r);
1178+ if tree.node(t).is_embed() {
1179+ return Err(io_err("a broken or duplicate embed has no node to write to"));
1180+ }
1181+ Ok(t)
1182+}
1183+
9031184/// Indent of a child of `parent` (§3.1): items nest under items, everything
904−/// under a section stays at the section's own indent.
1185+/// under a section stays at the section's own indent. Measured from the
1186+/// parent's written indent, not its derived one: nesting written with tabs
1187+/// or 4 spaces is read (§4.2), and a line at the derived child indent could
1188+/// fall short of such a parent and parse as its sibling.
9051189fn child_indent(tree: &crate::tree::Tree, parent: NRef) -> usize {
906− match tree.node(parent).kind {
907− Kind::Item => tree.indent(parent) + 2,
908− Kind::Section => tree.indent(parent),
1190+ let n = tree.node(parent);
1191+ match n.kind {
1192+ Kind::Item => n.indent + 2,
1193+ Kind::Section => n.indent,
9091194 Kind::Root => 0,
9101195 }
9111196}
@@ -913,7 +1198,7 @@ fn child_indent(tree: &crate::tree::Tree, parent: NRef) -> usize {
9131198/// Spellings of a document's top-level nodes, in order. A heading embed is
9141199/// a section, a bare embed an item (§4.7).
9151200fn top_kinds(doc: &str) -> Vec<Kind> {
916− let pf = crate::parse::parse_file("clip.md", doc, 0, None);
1201+ let pf = crate::parse::parse_file("clip.md", doc, None);
9171202 pf.nodes[pf.root_node]
9181203 .children
9191204 .iter()
@@ -921,6 +1206,12 @@ fn top_kinds(doc: &str) -> Vec<Kind> {
9211206 .collect()
9221207}
9231208
1209+/// The indent a document's first top-level node is written at.
1210+fn top_indent(doc: &str) -> Option<usize> {
1211+ let pf = crate::parse::parse_file("clip.md", doc, None);
1212+ pf.nodes[pf.root_node].children.first().map(|&c| pf.nodes[c].indent)
1213+}
1214+
9241215/// Clamp a wanted child index by the ordering rule `(text | item)*
9251216/// section*` (§3.1): items no later than the first section child, sections
9261217/// no earlier. `kids` are the parent's child nodes without the one moving.
@@ -934,41 +1225,98 @@ fn clamp_index(tree: &crate::tree::Tree, kids: &[NRef], want: usize, kinds: &[Ki
9341225 want.min(kids.len()).clamp(lo, hi)
9351226}
9361227
1228+/// The level of `k` as written, if it is a section. A written level is
1229+/// honoured (§4.7): a section written after it deeper than that is its
1230+/// child, and one written before it shallower than that takes it (and
1231+/// every section after it) as a child (§3.1). The level `k`'s position
1232+/// gives can be deeper: under an item, sections may be written shallower.
1233+fn written_level(tree: &crate::tree::Tree, k: Option<NRef>) -> Option<usize> {
1234+ k.map(|k| tree.node(k)).filter(|n| n.kind == Kind::Section).map(|n| n.level.unwrap_or(1))
1235+}
1236+
1237+/// `doc` (shifted to a child position) with its top-level sections, and
1238+/// everything under them, re-levelled to stay siblings of the sections
1239+/// around them (`written_level`): no deeper than `prev`, the sibling
1240+/// section they are written after, and no shallower than `next`, the one
1241+/// they are written before.
1242+fn level_among(tree: &crate::tree::Tree, prev: Option<NRef>, next: Option<NRef>, doc: &str) -> String {
1243+ let (most, least) = (written_level(tree, prev), written_level(tree, next));
1244+ if most.is_none() && least.is_none() {
1245+ return doc.to_string();
1246+ }
1247+ let pf = crate::parse::parse_file("clip.md", doc, None);
1248+ let tops = &pf.nodes[pf.root_node].children;
1249+ // a document's sections come after its items (§3.1)
1250+ let Some(first) = tops.iter().map(|&c| &pf.nodes[c]).find(|n| n.kind == Kind::Section) else {
1251+ return doc.to_string();
1252+ };
1253+ // indentation closes a section before its level does (§4.2): a sibling
1254+ // written deeper than the sections, before them, or shallower, after
1255+ // them, is no parent or child of theirs, whatever its level
1256+ let most = most.filter(|_| prev.is_some_and(|p| tree.node(p).indent <= first.indent));
1257+ let least = least.filter(|_| next.is_some_and(|k| tree.node(k).indent >= first.indent));
1258+ let have = tops.last().and_then(|&c| pf.nodes[c].level).unwrap_or(1);
1259+ let want = most.map_or(have, |m| have.min(m)).max(least.unwrap_or(1));
1260+ if want == have {
1261+ return doc.to_string();
1262+ }
1263+ let at = first.span.start;
1264+ format!("{}{}", &doc[..at], shift_lines(&doc[at..], want as isize - have as isize, 0))
1265+}
1266+
9371267/// Write `doc` (already shifted to a child position of `parent`) as the
9381268/// `want`-th child node of `parent`, clamped by the ordering rule (§3.1).
939−/// With `moving`, that node's own lines are removed in the same edit: text
940−/// children around it stay where they are. The insertion is written before
941−/// any removal in another file, so a failure never loses text. Returns
942−/// whether the ordering rule moved it from the wanted index.
1269+/// The `moving` nodes (siblings, in order: a node and its conflict copies,
1270+/// §12.5), have their own lines removed in the same edit: text children
1271+/// around them stay where they are. The insertion is written before any
1272+/// removal in another file, so a failure never loses text. Returns the
1273+/// document's top-level nodes where they are now, found by the position
1274+/// they were written at, and whether the ordering rule moved them from
1275+/// the wanted index.
9431276fn place(
9441277 vault: &mut Vault,
945− moving: Option<NRef>,
1278+ moving: &[NRef],
9461279 parent: NRef,
9471280 want: usize,
9481281 doc: &str,
949−) -> std::io::Result<bool> {
1282+) -> std::io::Result<(Vec<NRef>, bool)> {
9501283 let tree = &vault.tree;
9511284 let pnode = tree.node(parent);
9521285 let kids: Vec<NRef> = tree
9531286 .raw_children(parent)
9541287 .into_iter()
955− .filter(|&k| Some(k) != moving)
1288+ .filter(|k| !moving.contains(k))
9561289 .collect();
9571290 let kinds = top_kinds(doc);
9581291 if kinds.is_empty() {
9591292 return Err(io_err("nothing to place"));
9601293 }
961− let idx = clamp_index(tree, &kids, want, &kinds);
1294+ let clamped = clamp_index(tree, &kids, want, &kinds);
1295+ // nothing goes between a conflict copy and the node before it, which
1296+ // it would pair with (§12.5): past the copies, unless the node they
1297+ // follow is the one moving, back in its place
1298+ let mut idx = clamped;
1299+ let orig = tree.raw_children(parent);
1300+ let back = |k: NRef| {
1301+ let before = orig.iter().position(|&o| o == k).and_then(|i| i.checked_sub(1)).map(|i| orig[i]);
1302+ before.is_some_and(|b| moving.contains(&b))
1303+ };
1304+ if !kids.get(idx).is_some_and(|&k| back(k)) {
1305+ let copy_at = |i: usize| kids.get(i).is_some_and(|&k| is_copy(tree, k));
1306+ while copy_at(idx) && clamp_index(tree, &kids, idx + 1, &kinds) == idx + 1 {
1307+ idx += 1;
1308+ }
1309+ }
9621310 let first = kinds[0];
9631311 let last = *kinds.last().unwrap();
9641312 let file = parent.0;
9651313 let mut text = tree.files[file].text.clone();
966− // the parent's content as it will be, without the moving node
1314+ // the parent's content as it will be, without the moving nodes
9671315 let content: Vec<crate::parse::Content> = pnode
9681316 .content
9691317 .iter()
9701318 .copied()
971− .filter(|c| !matches!(c, crate::parse::Content::Node(n) if Some((file, *n)) == moving))
1319+ .filter(|c| !matches!(c, crate::parse::Content::Node(n) if moving.contains(&(file, *n))))
9721320 .collect();
9731321 // positions in the original text, then adjusted for a same-file removal
9741322 let next = kids.get(idx).copied();
@@ -996,17 +1344,52 @@ fn place(
9961344 Some(crate::parse::Content::Text(_)) => false,
9971345 None => pnode.kind == Kind::Item,
9981346 };
999− let next_kind = next.map(|k| tree.node(k).kind);
1000− let removal = match moving {
1001− Some(m) if m.0 == file => Some(removal_range(&text, tree.node(m).span)),
1002− _ => None,
1347+ // inserting before an item: after another item, the list is as tight as
1348+ // the text there says (read below); starting the list, it is read in
1349+ // the list as found, the moving nodes still in it: between `next` and
1350+ // the item after it, or else between the last moving node and `next`
1351+ // right after it. A list of one item is tight.
1352+ let is_item = |c: Option<&crate::parse::Content>| {
1353+ matches!(c, Some(crate::parse::Content::Node(k)) if tree.files[file].nodes[*k].kind == Kind::Item)
1354+ };
1355+ let next_at = next.and_then(|k| content.iter().position(|c| *c == crate::parse::Content::Node(k.1)));
1356+ let starts_run = next_at.is_some_and(|i| i == 0 || !is_item(content.get(i - 1)));
1357+ let found = &pnode.content;
1358+ let found_at = next.and_then(|k| found.iter().position(|c| *c == crate::parse::Content::Node(k.1)));
1359+ let no_blank_after = |k: NRef| {
1360+ let sp = tree.node(k).span;
1361+ !text[sp.start..sp.end.min(text.len())].ends_with("\n\n")
1362+ };
1363+ let run_tight = match (next, found_at) {
1364+ (Some(k), Some(i)) if is_item(found.get(i + 1)) => no_blank_after(k),
1365+ (Some(_), Some(i)) => match moving.last() {
1366+ Some(&m) if i > 0 && m.0 == file && found[i - 1] == crate::parse::Content::Node(m.1) => {
1367+ no_blank_after(m)
1368+ }
1369+ _ => true,
1370+ },
1371+ _ => true,
10031372 };
1004− if let Some((start, end)) = removal {
1373+ let next_kind = next.map(|k| tree.node(k).kind);
1374+ // the last first, so the spans before it stay where they are
1375+ for &m in moving.iter().rev().filter(|m| m.0 == file) {
1376+ let (start, end) = removal_range(&text, tree.node(m).span);
10051377 text = format!("{}{}", &text[..start], &text[end..]);
10061378 pos = after_removal(pos, start, end);
10071379 }
1380+ // before a sibling written deeper than the node (tab or 4-space
1381+ // nesting is read, §4.2), the node goes at that sibling's indent: any
1382+ // shallower, the sibling would parse as its child
1383+ let doc = match (next.map(|k| tree.node(k).indent), top_indent(doc)) {
1384+ (Some(want), Some(have)) if want > have => shift_lines(doc, 0, (want - have) as isize),
1385+ _ => doc.to_string(),
1386+ };
1387+ let prev = idx.checked_sub(1).map(|i| kids[i]);
1388+ let doc = level_among(tree, prev, next, &doc);
10081389 let body = doc.trim_end_matches('\n');
1009− let insertion = match next_kind {
1390+ let tops = top_starts(body);
1391+ // the insertion, and where `body` starts in it
1392+ let (insertion, at) = match next_kind {
10101393 Some(nk) => {
10111394 // before the next child node: keep a blank line before a
10121395 // heading and after anything that is not a tight item run
@@ -1015,8 +1398,10 @@ fn place(
10151398 } else {
10161399 ""
10171400 };
1018− let tight = last == Kind::Item && nk == Kind::Item && !text[..pos].ends_with("\n\n");
1019− format!("{}{}\n{}", lead, body, if tight { "" } else { "\n" })
1401+ let tight = last == Kind::Item
1402+ && nk == Kind::Item
1403+ && if starts_run { run_tight } else { !text[..pos].ends_with("\n\n") };
1404+ (format!("{}{}\n{}", lead, body, if tight { "" } else { "\n" }), lead.len())
10201405 }
10211406 None => {
10221407 while pos > 0 && text.as_bytes()[pos - 1] == b'\n' {
@@ -1030,23 +1415,37 @@ fn place(
10301415 "\n\n"
10311416 };
10321417 let nl = if text[pos..].starts_with('\n') { "" } else { "\n" };
1033− format!("{}{}{}", sep, body, nl)
1418+ (format!("{}{}{}", sep, body, nl), sep.len())
10341419 }
10351420 };
1421+ let start = pos + at;
10361422 text.insert_str(pos, &insertion);
10371423 vault.write_file_text(file, &text)?;
1038− if let Some(m) = moving {
1039− if m.0 != file {
1040− // the other file's node refs are untouched by the write above
1041− remove_span_no_reload(vault, m.0, vault.tree.node(m).span)?;
1042− }
1424+ // the other file's node refs are untouched by the write above; each
1425+ // removal re-parses that file, so the spans are read first
1426+ let away: Vec<(usize, Span)> =
1427+ moving.iter().filter(|m| m.0 != file).map(|&m| (m.0, vault.tree.node(m).span)).collect();
1428+ for (f, span) in away.into_iter().rev() {
1429+ remove_span(vault, f, span)?;
10431430 }
1044− vault.reload()?;
1045− Ok(idx != want.min(kids.len()))
1431+ // `file` is as written: each node starts where its line went in
1432+ let f = &vault.tree.files[file];
1433+ let placed = tops
1434+ .iter()
1435+ .filter_map(|&t| f.nodes.iter().position(|n| n.kind != Kind::Root && n.title_span.start == start + t))
1436+ .map(|i| vault.tree.resolved_child((file, i)))
1437+ .collect();
1438+ Ok((placed, clamped != want.min(kids.len())))
1439+}
1440+
1441+/// Where each of a document's top-level nodes starts in it.
1442+fn top_starts(doc: &str) -> Vec<usize> {
1443+ let pf = crate::parse::parse_file("clip.md", doc, None);
1444+ pf.nodes[pf.root_node].children.iter().map(|&c| pf.nodes[c].title_span.start).collect()
10461445}
10471446
10481447/// Archive: refile under the top-level `Archive` section (§6.5).
1049−pub fn archive(vault: &mut Vault, r: NRef) -> std::io::Result<bool> {
1448+pub fn archive(vault: &mut Vault, r: NRef) -> std::io::Result<Placed> {
10501449 let root = vault.tree.root;
10511450 let mut dest = None;
10521451 for c in vault.tree.resolved_children(root) {
@@ -1072,12 +1471,22 @@ pub fn clear_done(vault: &mut Vault, target: NRef) -> std::io::Result<usize> {
10721471 vault.tree.walk(target, &mut |t, r| {
10731472 let n = t.node(r);
10741473 if n.task == Some(TaskState::Done) {
1075− let (open, _total) = t.task_counts(r);
1076− if open == 0 {
1474+ // any open task below keeps it, one in a conflict copy too,
1475+ // which task counts leave out (§3.5)
1476+ let mut open = false;
1477+ t.walk(r, &mut |t, c| open |= t.node(c).task == Some(TaskState::Open));
1478+ if !open {
10771479 to_clear.push(r);
10781480 }
10791481 }
10801482 });
1483+ // a node's conflict copies go with it (§12.5): one whose copies are
1484+ // not all cleared too stays, or they would pair with the node before
1485+ let to_clear: Vec<NRef> = to_clear
1486+ .iter()
1487+ .copied()
1488+ .filter(|&r| conflict_copies(&vault.tree, r).iter().all(|c| to_clear.contains(c)))
1489+ .collect();
10811490 // skip nodes under another node being cleared
10821491 let tops: Vec<NRef> = to_clear
10831492 .iter()
@@ -1097,10 +1506,17 @@ pub fn clear_done(vault: &mut Vault, target: NRef) -> std::io::Result<usize> {
10971506 .collect();
10981507 let mut blocks: Vec<Id> = Vec::new();
10991508 let mut plain: Vec<NRef> = Vec::new();
1509+ let mut nested: Vec<Id> = Vec::new();
11001510 for &r in &tops {
11011511 let n = vault.tree.node(r);
11021512 match n.block.as_ref().and_then(|b| b.id.clone()) {
1103− Some(id) => blocks.push(id),
1513+ Some(id) => {
1514+ // the block leaves by its own embed; blocks embedded in its
1515+ // file go to the trash with it, as delete_subtree sends them
1516+ let ids = nested_block_ids(vault, r);
1517+ nested.extend(ids.into_iter().filter(|i| *i != id));
1518+ blocks.push(id);
1519+ }
11041520 None => plain.push(r),
11051521 }
11061522 }
@@ -1109,13 +1525,14 @@ pub fn clear_done(vault: &mut Vault, target: NRef) -> std::io::Result<usize> {
11091525 let mut done = 0;
11101526 for r in plain {
11111527 let ids = plain_remove(vault, r)?;
1112− blocks.extend(ids);
1528+ nested.extend(ids);
11131529 done += 1;
11141530 }
11151531 let n_block_tops = tops.len() - done;
11161532 for id in blocks {
11171533 trash_block(vault, &id)?;
11181534 }
1535+ trash_nested(vault, &nested)?;
11191536 Ok(done + n_block_tops)
11201537}
11211538
@@ -1125,13 +1542,7 @@ fn resolved_parent(tree: &crate::tree::Tree, r: NRef) -> Option<NRef> {
11251542 let p = tree.node(r).parent?;
11261543 let pr = (r.0, p);
11271544 if tree.node(pr).kind == Kind::Root && r.0 != tree.root.0 {
1128− let id = tree.node(r).block.as_ref()?.id.clone()?;
1129− tree.files.iter().enumerate().find_map(|(fi, f)| {
1130− f.nodes
1131− .iter()
1132− .position(|nd| nd.embed.as_ref() == Some(&id))
1133− .map(|ni| (fi, ni))
1134− })
1545+ tree.embed_of(tree.node(r).block.as_ref()?.id.as_ref()?)
11351546 } else if tree.node(pr).kind == Kind::Root {
11361547 None
11371548 } else {
@@ -1155,14 +1566,28 @@ pub fn make_block(vault: &mut Vault, r: NRef) -> std::io::Result<Id> {
11551566 file_text.push_str(&body);
11561567 let prefix = vault.unique_prefix(&id);
11571568 let fname = crate::ident::filename(&prefix, &slug(&n.title));
1158− crate::vault::atomic_write(&vault.dir.join(&fname), &file_text)?;
1569+ // the embed cannot be written into a file changed on disk (§11.2), and
1570+ // a block file without it would be embedded nowhere: check first
1571+ let file = r.0;
1572+ vault.check_unchanged(file)?;
1573+ let full = vault.dir.join(&fname);
1574+ crate::vault::atomic_write(&full, &file_text)?;
11591575 // replace the node's span with an embed in the node's form (§6.1.3,
11601576 // §4.7), keeping the blank lines that separated it from what follows
1161− let file = r.0;
11621577 let span = n.span;
11631578 let indent = " ".repeat(n.indent);
1579+ // at its level, but kept a sibling of the sections around it as they
1580+ // are written: no deeper than the one before it, which would take the
1581+ // embed as its child, nor shallower than the one after it
1582+ let kids = n.parent.map(|p| vault.tree.raw_children((file, p))).unwrap_or_default();
1583+ let at = kids.iter().position(|&c| c == r);
1584+ let prev = at.and_then(|i| i.checked_sub(1)).map(|i| kids[i]);
1585+ let next = at.and_then(|i| kids.get(i + 1).copied());
11641586 let mut embed = match n.kind {
1165− Kind::Section => format!("{}{} ![[{}]]\n", indent, "#".repeat(vault.tree.level(r)), id),
1587+ Kind::Section => {
1588+ let line = format!("{}{} ![[{}]]\n", indent, "#".repeat(vault.tree.level(r)), id);
1589+ level_among(&vault.tree, prev, next, &line)
1590+ }
11661591 _ => format!("{}![[{}]]\n", indent, id),
11671592 };
11681593 let old = span.text(&vault.tree.files[file].text);
@@ -1170,8 +1595,13 @@ pub fn make_block(vault: &mut Vault, r: NRef) -> std::io::Result<Id> {
11701595 for _ in 1..trailing {
11711596 embed.push('\n');
11721597 }
1173− vault.write_span(file, span, &embed)?;
1174− vault.reload()?;
1598+ if let Err(e) = vault.write_span(file, span, &embed) {
1599+ // changed in between after all: nothing refers to the new file yet,
1600+ // and the node is still in its parent
1601+ let _ = std::fs::remove_file(&full);
1602+ return Err(e);
1603+ }
1604+ vault.add_file(&fname, &file_text);
11751605 Ok(id)
11761606}
11771607
@@ -1181,7 +1611,7 @@ pub fn set_property(vault: &mut Vault, r: NRef, key: &str, value: &str) -> std::
11811611 let target = if vault.tree.node(r).is_block() {
11821612 r
11831613 } else if vault.tree.node(r).is_embed() {
1184− vault.tree.resolved_child(r)
1614+ write_target(&vault.tree, r)?
11851615 } else {
11861616 // make_block reloads, so `r` is stale: find the new block by its id
11871617 let id = make_block(vault, r)?;
@@ -1199,6 +1629,9 @@ pub fn set_property(vault: &mut Vault, r: NRef, key: &str, value: &str) -> std::
11991629
12001630/// Move a node among its siblings (§10.3 `J`/`K`). A block moves by its
12011631/// embed line, so blocks and plain nodes swap freely in the parent file.
1632+/// A node and the conflict copies after it are one (§12.5): they move
1633+/// together, and a node moves past them together, so no copy is left
1634+/// after another node, which it would pair with.
12021635pub fn move_sibling(vault: &mut Vault, r: NRef, down: bool) -> std::io::Result<()> {
12031636 // a block root stands in its parent file as its embed
12041637 let r = stand_in(&vault.tree, r);
@@ -1214,20 +1647,21 @@ pub fn move_sibling(vault: &mut Vault, r: NRef, down: bool) -> std::io::Result<(
12141647 Some(p) => p,
12151648 None => return Ok(()),
12161649 };
1217− let swap_with = if down {
1218− pos + 1
1650+ let run = |i: usize| paired_run(&vault.tree, &siblings, i);
1651+ let (a, b) = run(pos);
1652+ let other = if down {
1653+ if b + 1 >= siblings.len() {
1654+ return Ok(());
1655+ }
1656+ run(b + 1)
12191657 } else {
1220− match pos.checked_sub(1) {
1221− Some(p) => p,
1658+ match a.checked_sub(1) {
1659+ Some(p) => run(p),
12221660 None => return Ok(()),
12231661 }
12241662 };
1225− if swap_with >= siblings.len() {
1226− return Ok(());
1227− }
1228− let other = siblings[swap_with];
12291663 // the ordering rule (§3.1): an item never goes below a section sibling
1230− let (rk, ok) = (vault.tree.node(r).kind, vault.tree.node(other).kind);
1664+ let (rk, ok) = (vault.tree.node(siblings[a]).kind, vault.tree.node(siblings[other.0]).kind);
12311665 if rk != ok {
12321666 return Err(io_err(if rk == Kind::Item {
12331667 "an item cannot move below a section"
@@ -1237,7 +1671,7 @@ pub fn move_sibling(vault: &mut Vault, r: NRef, down: bool) -> std::io::Result<(
12371671 }
12381672 let file = r.0;
12391673 let text = vault.tree.files[file].text.clone();
1240− // swap the nodes' own lines; trailing blank lines stay where they are,
1674+ // swap the runs' own lines; trailing blank lines stay where they are,
12411675 // so the list's spacing is unchanged
12421676 let core = |sp: Span| {
12431677 let end = sp.end.min(text.len());
@@ -1247,9 +1681,12 @@ pub fn move_sibling(vault: &mut Vault, r: NRef, down: bool) -> std::io::Result<(
12471681 }
12481682 Span { start: sp.start, end: e }
12491683 };
1250− let a = core(vault.tree.node(r).span);
1251− let b = core(vault.tree.node(other).span);
1252− let (first, second) = if a.start < b.start { (a, b) } else { (b, a) };
1684+ let (fr, sr) = if down { ((a, b), other) } else { (other, (a, b)) };
1685+ let lines = |(s, e): (usize, usize)| Span {
1686+ start: vault.tree.node(siblings[s]).span.start,
1687+ end: core(vault.tree.node(siblings[e]).span).end,
1688+ };
1689+ let (first, second) = (lines(fr), lines(sr));
12531690 let with_nl = |t: &str| {
12541691 if t.ends_with('\n') {
12551692 t.to_string()
@@ -1257,18 +1694,95 @@ pub fn move_sibling(vault: &mut Vault, r: NRef, down: bool) -> std::io::Result<(
12571694 format!("{}\n", t)
12581695 }
12591696 };
1697+ // The written level and indent decide the parent: a sibling written
1698+ // deeper than the one after it (a skipped heading level, §4.7, or a
1699+ // wider indent) would nest under that one once below it, and J/K keep
1700+ // every other node's parent (§15.6). So the nodes moving down are
1701+ // written at the level and indent of the one moving up, their subtrees
1702+ // re-levelled with them, as a moved node is (§4.2).
1703+ let (fnode, snode) = (vault.tree.node(siblings[fr.0]), vault.tree.node(siblings[sr.0]));
1704+ let lower = if (fnode.level, fnode.indent) == (snode.level, snode.indent) {
1705+ with_nl(first.text(&text))
1706+ } else {
1707+ let base = snode.level.unwrap_or_else(|| vault.tree.level(siblings[fr.0]));
1708+ let pad = " ".repeat(snode.indent);
1709+ let mut out = String::new();
1710+ for i in fr.0..=fr.1 {
1711+ let k = siblings[i];
1712+ out.extend(
1713+ render(&vault.tree, k, base, false)
1714+ .lines()
1715+ .map(|l| if l.is_empty() { "\n".to_string() } else { format!("{}{}\n", pad, l) }),
1716+ );
1717+ // the blank lines between a node and its copy stay
1718+ if i < fr.1 {
1719+ let next = vault.tree.node(siblings[i + 1]).span.start;
1720+ out.push_str(&text[core(vault.tree.node(k).span).end..next]);
1721+ }
1722+ }
1723+ out
1724+ };
12601725 let mut new_text = format!(
12611726 "{}{}{}{}",
12621727 &text[..first.start],
12631728 with_nl(second.text(&text)),
12641729 &text[first.end..second.start],
1265− with_nl(first.text(&text)),
1730+ lower,
12661731 );
12671732 let rest = &text[second.end..];
12681733 new_text.push_str(rest);
12691734 vault.write_file_text(file, &new_text)
12701735}
12711736
1737+/// Whether the child `k` is a conflict copy, which pairs with the node
1738+/// before it (§12.4).
1739+fn is_copy(tree: &crate::tree::Tree, k: NRef) -> bool {
1740+ tree.node(tree.resolved_child(k)).conflict().is_some()
1741+}
1742+
1743+/// The run of `kids` that the `i`-th is in: a node and the conflict copies
1744+/// right after it, which pair with it and each other (§12.4), as indices.
1745+fn paired_run(tree: &crate::tree::Tree, kids: &[NRef], i: usize) -> (usize, usize) {
1746+ let mut a = i;
1747+ while a > 0 && is_copy(tree, kids[a]) {
1748+ a -= 1;
1749+ }
1750+ let mut b = i;
1751+ while b + 1 < kids.len() && is_copy(tree, kids[b + 1]) {
1752+ b += 1;
1753+ }
1754+ (a, b)
1755+}
1756+
1757+/// The run `m` (a node as it stands in its parent's file, §4.7) is in
1758+/// there: a node and its conflict copies, which a verb that moves or
1759+/// deletes the one takes with it, or a copy left behind would pair with
1760+/// the node before it (§12.5). `m` alone when it is in no pair.
1761+fn pair_run(tree: &crate::tree::Tree, m: NRef) -> Vec<NRef> {
1762+ let Some(p) = tree.node(m).parent else { return vec![m] };
1763+ let kids = tree.raw_children((m.0, p));
1764+ let Some(i) = kids.iter().position(|&k| k == m) else { return vec![m] };
1765+ let (a, b) = paired_run(tree, &kids, i);
1766+ kids[a..=b].to_vec()
1767+}
1768+
1769+/// The conflict pair `r` is in (§12.4): the node, then its copies, as the
1770+/// outline shows them; `r` alone when it is in none.
1771+pub fn conflict_pair(tree: &crate::tree::Tree, r: NRef) -> Vec<NRef> {
1772+ let m = stand_in(tree, tree.resolved_child(r));
1773+ pair_run(tree, m).into_iter().map(|k| tree.resolved_child(k)).collect()
1774+}
1775+
1776+/// The conflict copies of `r` (§12.4), as the outline shows them: none
1777+/// when it is a copy itself.
1778+pub fn conflict_copies(tree: &crate::tree::Tree, r: NRef) -> Vec<NRef> {
1779+ let r = tree.resolved_child(r);
1780+ match conflict_pair(tree, r).split_first() {
1781+ Some((&o, copies)) if o == r && tree.node(r).conflict().is_none() => copies.to_vec(),
1782+ _ => Vec::new(),
1783+ }
1784+}
1785+
12721786/// Toggle spelling section ↔ item (§10.3 `~`). The checkbox, if any, is kept,
12731787/// and the subtree is re-indented and re-levelled for the new spelling. The
12741788/// node moves to its parent's boundary if it has to (§3.1), so no sibling
@@ -1280,26 +1794,57 @@ pub fn toggle_spelling(vault: &mut Vault, r: NRef) -> std::io::Result<bool> {
12801794 return Ok(false);
12811795 }
12821796 let to_section = n.kind == Kind::Item;
1797+ // a node in a conflict pair keeps its spelling: moved to its boundary
1798+ // it would leave its copy, or a copy its node, to pair with the node
1799+ // before (§12.5), and keeping theirs would write the other one back
1800+ if conflict_pair(&vault.tree, r).len() > 1 {
1801+ return Err(io_err(PAIR_SPELLING));
1802+ }
1803+ // an embed still here after resolving is broken: there is no block to
1804+ // respell, so only the embed's form changes and its id stays (§4.7)
1805+ if n.is_embed() {
1806+ return respell_embed(vault, r, to_section);
1807+ }
1808+ // `## ---` spelled as an item is `- ---`, a thematic break (§4.4)
1809+ if !to_section && n.task.is_none() {
1810+ item_line(&n.title)?;
1811+ }
12831812 let stand = stand_in(&vault.tree, r);
12841813 let file = r.0;
12851814 let text = vault.tree.files[file].text.clone();
12861815 let span = Span { start: n.span.start, end: n.span.end.min(text.len()) };
1816+ // respelled from the subtree as a move writes it (§4.2): every heading
1817+ // in it ATX, a setext one converted, at the level its position gives,
1818+ // so re-levelling keeps each under the node it was under; at the node's
1819+ // indent, with the blank lines that end its span
1820+ let blanks = span.text(&text).lines().rev().take_while(|l| l.trim().is_empty()).count();
1821+ let rendered: String = crate::render::render_lines(&vault.tree, r, vault.tree.level(r), false)
1822+ .into_iter()
1823+ .map(|l| l.text + "\n")
1824+ .collect();
1825+ let src = shift_lines(&rendered, 0, n.indent as isize) + &"\n".repeat(blanks);
12871826 if stand != r {
1288− // a block: respell its file's root at level 1, then its embed
1289− let respelled = respell(span.text(&text), to_section, 1);
1827+ // a block: respell its file's root at level 1, then its embed. The
1828+ // embed cannot be written into a file changed on disk (§11.2), and
1829+ // the block would be left under an embed of the other form (§4.7):
1830+ // check first
1831+ vault.check_unchanged(stand.0)?;
1832+ let respelled = respell(&src, to_section, 1);
12901833 let id = n.block.as_ref().and_then(|b| b.id.clone());
12911834 vault.write_span(file, span, &respelled)?;
12921835 let Some(e) = id.and_then(|id| vault.tree.embed_of(&id)) else {
1293− vault.reload()?;
12941836 return Ok(false);
12951837 };
12961838 return respell_embed(vault, e, to_section);
12971839 }
12981840 let Some(parent) = n.parent.map(|p| (file, p)) else { return Ok(false) };
1299− let respelled = respell(span.text(&text), to_section, vault.tree.level(parent) + 1);
1841+ let respelled = respell(&src, to_section, vault.tree.level(parent) + 1);
13001842 reposition(vault, r, parent, respelled, to_section)
13011843}
13021844
1845+/// Why `~` is refused on either side of a conflict pair (§12.5).
1846+pub const PAIR_SPELLING: &str = "resolve its conflict";
1847+
13031848/// Rewrite an embed in the other form (§4.7) — heading for a section-spelled
13041849/// block, bare for an item — moving it to its parent's boundary if needed.
13051850pub fn respell_embed(vault: &mut Vault, e: NRef, to_section: bool) -> std::io::Result<bool> {
@@ -1335,11 +1880,12 @@ fn reposition(
13351880 let kind = if to_section { Kind::Section } else { Kind::Item };
13361881 if clamp_index(tree, &others, pos, &[kind]) == pos {
13371882 let span = tree.node(r).span;
1883+ let prev = pos.checked_sub(1).map(|i| others[i]);
1884+ let new = level_among(tree, prev, others.get(pos).copied(), &new);
13381885 vault.write_span(r.0, span, &new)?;
1339− vault.reload()?;
13401886 return Ok(false);
13411887 }
1342− place(vault, Some(r), parent, pos, &new).map(|_| true)
1888+ place(vault, &[r], parent, pos, &new).map(|_| true)
13431889}
13441890
13451891/// A node's text respelled: its title line as a section at `level` or as an
@@ -1377,15 +1923,18 @@ fn respell(span_text: &str, to_section: bool, level: usize) -> String {
13771923
13781924/// Demote: become the last child of the previous sibling node (§10.3 `>`),
13791925/// clamped by the ordering rule (§3.1).
1380−pub fn demote(vault: &mut Vault, r: NRef) -> std::io::Result<bool> {
1926+pub fn demote(vault: &mut Vault, r: NRef) -> std::io::Result<Placed> {
1927+ let stayed = Placed::stayed(&vault.tree, r);
13811928 let m = stand_in(&vault.tree, r);
1382− let Some(parent) = vault.tree.node(m).parent.map(|p| (m.0, p)) else { return Ok(false) };
1929+ let Some(parent) = vault.tree.node(m).parent.map(|p| (m.0, p)) else { return Ok(stayed) };
13831930 let kids = vault.tree.raw_children(parent);
1384− let Some(pos) = kids.iter().position(|&k| k == m) else { return Ok(false) };
1385− if pos == 0 {
1386− return Ok(false);
1931+ let Some(pos) = kids.iter().position(|&k| k == m) else { return Ok(stayed) };
1932+ // under the node before its conflict pair, which moves as one (§12.5)
1933+ let (a, _) = paired_run(&vault.tree, &kids, pos);
1934+ if a == 0 {
1935+ return Ok(stayed);
13871936 }
1388− let prev = vault.tree.resolved_child(kids[pos - 1]);
1937+ let prev = vault.tree.resolved_child(kids[a - 1]);
13891938 refile(vault, r, prev)
13901939}
13911940
@@ -1393,24 +1942,27 @@ pub fn demote(vault: &mut Vault, r: NRef) -> std::io::Result<bool> {
13931942/// ordering rule (§3.1): an item leaving a section lands before the first
13941943/// section among the parent's siblings. Out of a block's root, the node moves
13951944/// to the parent file beside the block's embed.
1396−pub fn promote(vault: &mut Vault, r: NRef) -> std::io::Result<bool> {
1945+pub fn promote(vault: &mut Vault, r: NRef) -> std::io::Result<Placed> {
1946+ let stayed = Placed::stayed(&vault.tree, r);
13971947 let m = stand_in(&vault.tree, r);
1398− let Some(parent) = vault.tree.node(m).parent.map(|p| (m.0, p)) else { return Ok(false) };
1948+ let Some(parent) = vault.tree.node(m).parent.map(|p| (m.0, p)) else { return Ok(stayed) };
13991949 if vault.tree.node(parent).kind == Kind::Root {
1400− return Ok(false);
1950+ return Ok(stayed);
14011951 }
14021952 // the parent's own position: its embed if it is a block root
14031953 let pstand = stand_in(&vault.tree, parent);
14041954 let Some(grand) = vault.tree.node(pstand).parent.map(|p| (pstand.0, p)) else {
1405− return Ok(false);
1955+ return Ok(stayed);
14061956 };
14071957 if vault.tree.node(grand).kind == Kind::Root && pstand.0 != vault.tree.root.0 {
14081958 return Err(io_err("block has no embed"));
14091959 }
14101960 let pos = vault.tree.raw_children(grand).iter().position(|&k| k == pstand).unwrap_or(0);
1411− let rendered = render(&vault.tree, m, 1, false);
1412− let shifted = shift_document(&rendered, vault.tree.level(grand), child_indent(&vault.tree, grand));
1413− place(vault, Some(m), grand, pos + 1, &shifted)
1961+ // with its conflict pair, which moves as one (§12.5)
1962+ let run = pair_run(&vault.tree, m);
1963+ let doc = run_doc(&vault.tree, &run, grand);
1964+ let (nodes, clamped) = place(vault, &run, grand, pos + 1, &doc)?;
1965+ Placed::of(&nodes, run.iter().position(|&k| k == m).unwrap_or(0), clamped)
14141966}
14151967
14161968/// Append a plain item child titled `title` under `parent`; returns the new
@@ -1420,7 +1972,25 @@ pub fn append_child_public(
14201972 parent: NRef,
14211973 title: &str,
14221974) -> std::io::Result<NRef> {
1423− append_child_line(vault, parent, &format!("- {}", title), true)
1975+ append_child_line(vault, parent, &item_line(title)?, true)
1976+}
1977+
1978+/// The item line titled `title`, refused when it would not read back as
1979+/// one: a title of dashes (`---`, `- -`) makes it a thematic break, which
1980+/// CommonMark reads before a bullet, and body text (§4.4). Checked before
1981+/// anything is written, as the TUI won't create an invalid title (§4.3).
1982+fn item_line(title: &str) -> std::io::Result<String> {
1983+ let line = format!("- {}", title);
1984+ not_a_break(&line)?;
1985+ Ok(line)
1986+}
1987+
1988+/// Refuse an item's title line that reads as a thematic break (§4.4).
1989+fn not_a_break(line: &str) -> std::io::Result<()> {
1990+ if crate::parse::is_thematic_break(line.trim_start_matches([' ', '\t'])) {
1991+ return Err(io_err("a title of dashes reads as a thematic break, not an item"));
1992+ }
1993+ Ok(())
14241994}
14251995
14261996fn io_err(msg: &str) -> std::io::Error {