diff --git a/src/parser/org.rs b/src/parser/org.rs index 55981c3..7ab32ca 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/src/sentence/unicode.rs b/src/sentence/unicode.rs index 5a785f2..f730033 100644 --- a/src/sentence/unicode.rs +++ b/src/sentence/unicode.rs @@ -3233,14 +3233,22 @@ 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 { + 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. /// E.g., `He said "wow!"` + `and left.` should stay as one sentence when /// the next segment starts with a lowercase letter. @@ -3446,6 +3454,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/org_file_token_punct.rs b/tests/org_file_token_punct.rs index b132bf3..c6e7a8b 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",); diff --git a/tests/sentence_delim_props.rs b/tests/sentence_delim_props.rs index 6b472e2..2e09b2c 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);