From e72dd4b449a75711b54188e037a34322f4b8870b Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 15 Sep 2026 13:21:25 -0500 Subject: [PATCH 1/3] fix(org): leftover file+emacs and file+sys plain links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit org-element file+emacs: and file+sys: must be matched before file: or the +… is eaten as the path. Leftover after the path hangs Prose and still splits. --- src/parser/org.rs | 15 ++++++++----- tests/org_file_token_punct.rs | 41 +++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/parser/org.rs b/src/parser/org.rs index 55981c33..7ab32ca5 100644 --- a/src/parser/org.rs +++ b/src/parser/org.rs @@ -166,14 +166,19 @@ struct OpenGreater { /// org-element-drawer-re NAME: `(any ?- ?_ word)` — hyphen, underscore, /// or Unicode word characters (letters and digits). `:END:` is the closer. -/// org-element plain link at column 0: `file:` / `http://` / `https://` -/// / `mailto:` / `news:` / `doi:` / `ftp://` / `attachment:` / `id:` -/// plus the path. -/// Leftover after the path is hung Prose. +/// org-element plain link at column 0: `file+emacs:` / `file+sys:` / +/// `file:` / `http://` / `https://` / `mailto:` / `news:` / `doi:` / +/// `ftp://` / `attachment:` / `id:` plus the path. +/// Leftover after the path is hung Prose. `file+emacs:` / `file+sys:` +/// must be matched before `file:` or the `+…` is eaten as the path. pub(crate) fn org_plain_link_marker_len(line: &str) -> Option { let indent = line.len() - line.trim_start().len(); let t = &line[indent..]; - let prefix = if t.starts_with("file:") { + let prefix = if t.starts_with("file+emacs:") { + "file+emacs:" + } else if t.starts_with("file+sys:") { + "file+sys:" + } else if t.starts_with("file:") { "file:" } else if t.starts_with("https://") { "https://" diff --git a/tests/org_file_token_punct.rs b/tests/org_file_token_punct.rs index b132bf31..c6e7a8b6 100644 --- a/tests/org_file_token_punct.rs +++ b/tests/org_file_token_punct.rs @@ -59,6 +59,47 @@ fn leftover_id_plain_link_after_path_hangs_and_splits() { assert_eq!(format_text(&out, &org_cfg()).unwrap(), out); } +#[test] +fn leftover_file_emacs_plain_link_after_path_hangs_and_splits() { + let input = concat!( + "file+emacs:/tmp/plot.png leftover. Next.\n", + "After. Next.\n", + ); + let regions = OrgParser.parse(input); + assert!( + regions.iter().any(|r| matches!( + r, + Region::Structure(s) if s.contains("file+emacs:/tmp/plot.png") + )), + "file+emacs path must stay Structure, got {regions:?}" + ); + let out = format_text(input, &org_cfg()).unwrap(); + assert!( + !out.contains("file+emacs:/tmp/plot.png leftover. Next."), + "leftover after file+emacs path must still split, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "following prose must still split, got:\n{out}" + ); + assert_eq!(format_text(&out, &org_cfg()).unwrap(), out); +} + +#[test] +fn leftover_file_sys_plain_link_after_path_hangs_and_splits() { + let input = concat!("file+sys:/tmp/plot.png leftover. Next.\n", "After. Next.\n",); + let out = format_text(input, &org_cfg()).unwrap(); + assert!( + !out.contains("file+sys:/tmp/plot.png leftover. Next."), + "leftover after file+sys path must still split, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "following prose must still split, got:\n{out}" + ); + assert_eq!(format_text(&out, &org_cfg()).unwrap(), out); +} + #[test] fn leftover_attachment_plain_link_after_path_hangs_and_splits() { let input = concat!("attachment:plot.png leftover. Next.\n", "After. Next.\n",); From 71dd2ed7c3ca88fac326597d0284d5054e526c68 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 15 Sep 2026 13:31:29 -0500 Subject: [PATCH 2/3] fix: leftover .`A plaintext SemBr cycle Do not glue a leftover-backtick capital onto a period even when a paren is still open. First pass of =(a=.`Aa must emit the newline that the second pass keeps. Still skip inventing space for `.`` ` latex quotes. --- src/sentence/unicode.rs | 20 ++++++++++++++++++-- tests/sentence_delim_props.rs | 13 +++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/sentence/unicode.rs b/src/sentence/unicode.rs index 5a785f24..f629970a 100644 --- a/src/sentence/unicode.rs +++ b/src/sentence/unicode.rs @@ -3233,14 +3233,23 @@ fn push_segment_preserving_space(dest: &mut String, piece: &str) { && !dest.ends_with("!!") // After protect_inline_tokens, `.`` ` is `.` + leftover backtick // (`dest="…."`, `piece="`\\0PHn\\0"`). Inventing `. `` ` then - // wrapping back is a SemBr cycle (ubuntu CI seed). - && !(dest.ends_with(['.', '!', '?']) && piece.starts_with('`')); + // wrapping back is a SemBr cycle. Still invent a space when the + // leftover ticks start a capital (`=(a=.`Aa` vs `=(a=.\n`Aa`). + && !(dest.ends_with(['.', '!', '?']) + && piece.starts_with('`') + && !piece_starts_sentence_after_ticks(piece)); if need_space { dest.push(' '); } dest.push_str(piece); } +fn piece_starts_sentence_after_ticks(piece: &str) -> bool { + piece + .trim_start_matches('`') + .starts_with(|c: char| c.is_uppercase()) +} + /// Merge false splits caused by sentence punctuation inside quotes or parens. /// E.g., `He said "wow!"` + `and left.` should stay as one sentence when /// the next segment starts with a lowercase letter. @@ -3446,6 +3455,13 @@ fn merge_splits_inside_delimiters(segments: Vec) -> Vec { } continue; } + if result.last().is_some_and(|last| { + last.ends_with(['.', '!', '?']) && piece_starts_sentence_after_ticks(&segment) + }) { + result.push(segment.clone()); + state.feed(&segment); + continue; + } if let Some(last) = result.last_mut() { push_segment_preserving_space(last, &segment); } else { diff --git a/tests/sentence_delim_props.rs b/tests/sentence_delim_props.rs index 6b472e23..2e09b2c8 100644 --- a/tests/sentence_delim_props.rs +++ b/tests/sentence_delim_props.rs @@ -76,6 +76,18 @@ fn plaintext_latex_quoted_bang_letter_is_span_safe() { } #[test] +fn plaintext_period_backtick_capital_is_idempotent() { + // ubuntu CI seed: ` `!=(a=.`Aa + // first pass splits after `.`; gluing without a space loses the newline. + let input = "` `!=(a=.`Aa"; + let out = format_plain(input); + assert_eq!( + format_plain(&out), + out, + "idempotence\n in={input:?}\n out={out:?}" + ); +} + #[test] fn plaintext_period_then_latex_quotes_is_idempotent() { // ubuntu CI seed: first pass keeps `.`` `; second pass invents `. `` `. @@ -88,6 +100,7 @@ fn plaintext_period_then_latex_quotes_is_idempotent() { ); } +#[test] fn plaintext_quoted_bang_letter_is_span_safe() { let input = "\"!!a\""; let out = format_plain(input); From c7e543a25eecd118493d356e12b2353ba9ab2b84 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 15 Sep 2026 13:39:57 -0500 Subject: [PATCH 3/3] fix: leftover-tick sentence glue requires a backtick piece_starts_sentence_after_ticks treated any capital as after ticks, so '!! plus A'A and dialogue quotes stopped merging. Require at least one leading backtick. --- src/sentence/unicode.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/sentence/unicode.rs b/src/sentence/unicode.rs index f629970a..f730033d 100644 --- a/src/sentence/unicode.rs +++ b/src/sentence/unicode.rs @@ -3245,9 +3245,8 @@ fn push_segment_preserving_space(dest: &mut String, piece: &str) { } fn piece_starts_sentence_after_ticks(piece: &str) -> bool { - piece - .trim_start_matches('`') - .starts_with(|c: char| c.is_uppercase()) + let rest = piece.trim_start_matches('`'); + rest.len() < piece.len() && rest.starts_with(|c: char| c.is_uppercase()) } /// Merge false splits caused by sentence punctuation inside quotes or parens.