Skip to content

Commit 8162816

Browse files
cscheidclaude
andauthored
fix(pampa): canonicalize em/en dashes and ellipsis on both read and write (#290)
Smart dashes were broken in two ways (repro: a doc mixing unspaced `---` and Unicode em dashes): 1. READ — `---`/`--` only converted to em/en dashes as a standalone, whitespace-delimited token; mid-word runs (`un---spaced`) stayed literal. 2. WRITE — a Unicode em/en dash was emitted verbatim instead of being canonicalized back to ASCII `---`/`--`, so source kept stray Unicode. Read side: new `apply_smart_typography` scans each prose-str node for dash runs (greedy 3=em while ≥3 remain, trailing 2=en, lone hyphen literal — Pandoc's default `dash` rule), dot runs (`...`→…), and apostrophes. Applied per node (the four sites that called `apply_smart_quotes`), NOT post-merge: escaped hyphens arrive as isolated `\-` nodes, so per-node conversion keeps `a\-\-b` literal. Dropped the whole-token `as_smart_str` branch and the now dead `apply_smart_quotes`. Reader escapes: `process_backslash_escapes` now strips a backslash before em dash / en dash / ellipsis, so the writer's `\—` round-trips. Write side: `escape_markdown` now canonicalizes Unicode smart chars to their ASCII spelling UNescaped (reader re-converts) while backslash-escaping genuinely-literal hyphen runs (≥2) and dot runs (≥3) so they survive the reader's smart pass. Operating on the original text is what distinguishes a canonicalized em dash (`—`→bare `---`) from literal hyphens (`--`→`\-\-`) — fixing a latent round-trip bug the read change exposed (`a\-\-b` → `a–b`). Thematic-break hazard: a line that would canonicalize to only dashes (≥3) re-parses as a HorizontalRule. `write_paragraph`/`write_plain` now split into line segments and, for an all-dash line (`line_is_dash_only_hazard`), escape each dash (`\—`, `\-\-\-`) so the leading backslash blocks thematic-break recognition while the AST round-trips. Tests: 22 unit tests (read + write helpers), a `smart-typography` native AST snapshot (mid-word convert, escaped-stays-literal, code untouched), and five `qmd-json-qmd/dashes_*.qmd` idempotency fixtures. Full workspace green (10065 passed); `cargo xtask verify --skip-hub-build` passes incl. the WASM rebuild. Snapshot changes (2 modified, 1 added): - native/012.snap, native/014.snap: intended mid-token conversion (`1--30`→`1–30`, `Hello---maybe---world...`→`Hello—maybe—world…`, `wait---really---did`→`wait—really—did`); regeneration also corrected a stale `source:` path in both. - native/smart-typography.snap: new fixture locking the read-side behavior. Strand: bd-k2h1x7bu Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d4073f8 commit 8162816

15 files changed

Lines changed: 818 additions & 73 deletions

File tree

claude-notes/plans/2026-06-15-em-en-dash-canonicalization.md

Lines changed: 381 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
source: crates/quarto-markdown-pandoc/tests/test.rs
2+
source: crates/pampa/tests/integration/test.rs
33
expression: output
44
---
5-
[ Para [Str "Pages", Space, Str "1--30.", Space, Str "Hello---maybe---world..."] ]
5+
[ Para [Str "Pages", Space, Str "130.", Space, Str "Hellomaybeworld"] ]
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
source: crates/quarto-markdown-pandoc/tests/test.rs
2+
source: crates/pampa/tests/integration/test.rs
33
expression: output
44
---
5-
[ Para [Str "i", Space, Str "think", Space, Str "e.g. this", Space, Str "is", Space, Str "good?", Space, Str "did", Space, Str "1--30", Space, Str "work?", Space, Str "wait---really---did", Space, Str "it?"] ]
5+
[ Para [Str "i", Space, Str "think", Space, Str "e.g. this", Space, Str "is", Space, Str "good?", Space, Str "did", Space, Str "130", Space, Str "work?", Space, Str "waitreallydid", Space, Str "it?"] ]
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
source: crates/pampa/tests/integration/test.rs
3+
expression: output
4+
---
5+
[ Para [Str "Mid-word", Space, Str "em-dashes—convert", Space, Str "and", Space, Str "en–dashes", Space, Str "too,", Space, Str "plus", Space, Str "ellipsis…"], Para [Str "Escaped", Space, Str "a--b", Space, Str "and", Space, Str "a---b", Space, Str "and", Space, Str "a...b", Space, Str "stay", Space, Str "literal."], Para [Str "Code", Space, Code ( "" , [] , [] ) "a---b", Space, Str "and", Space, Code ( "" , [] , [] ) "x...y", Space, Str "are", Space, Str "untouched."] ]

crates/pampa/src/pandoc/treesitter.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,7 @@ fn process_native_inline<T: Write>(
563563
})
564564
} else {
565565
Inline::Str(Str {
566-
text: apply_smart_quotes(text),
566+
text: apply_smart_typography(text),
567567
source_info: quarto_source_map::SourceInfo::from_range(
568568
context.current_file_id(),
569569
range,
@@ -737,7 +737,7 @@ fn native_visitor<T: Write>(
737737
if leading_ws == 0 {
738738
let text = process_backslash_escapes(raw_text.to_string());
739739
PandocNativeIntermediate::IntermediateInline(Inline::Str(Str {
740-
text: apply_smart_quotes(text),
740+
text: apply_smart_typography(text),
741741
source_info: node_source_info_with_context(node, context),
742742
}))
743743
} else {
@@ -771,7 +771,7 @@ fn native_visitor<T: Write>(
771771
end: node_range.end,
772772
};
773773
result.push(Inline::Str(Str {
774-
text: apply_smart_quotes(text),
774+
text: apply_smart_typography(text),
775775
source_info: quarto_source_map::SourceInfo::from_range(
776776
context.current_file_id(),
777777
str_range,

crates/pampa/src/pandoc/treesitter_utils/editorial_marks.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use regex::Regex;
1515

1616
use super::pandocnativeintermediate::PandocNativeIntermediate;
1717
use super::text_helpers::{
18-
apply_smart_quotes, extract_delimiter_space_info, wrap_inline_with_delimiter_spaces,
18+
apply_smart_typography, extract_delimiter_space_info, wrap_inline_with_delimiter_spaces,
1919
};
2020

2121
macro_rules! process_editorial_mark {
@@ -59,7 +59,7 @@ macro_rules! process_editorial_mark {
5959
}))
6060
} else {
6161
content.push(Inline::Str(Str {
62-
text: apply_smart_quotes(text),
62+
text: apply_smart_typography(text),
6363
source_info: quarto_source_map::SourceInfo::from_range(context.current_file_id(), range),
6464
}))
6565
}

crates/pampa/src/pandoc/treesitter_utils/postprocess.rs

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1676,20 +1676,12 @@ pub fn postprocess(doc: Pandoc, error_collector: &mut DiagnosticCollector) -> Re
16761676
if result.1 { Err(()) } else { Ok(result.0) }
16771677
}
16781678

1679-
/// Convert smart typography strings
1680-
fn as_smart_str(s: String) -> String {
1681-
if s == "..." {
1682-
"…".to_string()
1683-
} else if s == "--" {
1684-
"–".to_string()
1685-
} else if s == "---" {
1686-
"—".to_string()
1687-
} else {
1688-
s
1689-
}
1690-
}
1691-
1692-
/// Merge consecutive Str inlines and apply smart typography
1679+
/// Merge consecutive Str inlines.
1680+
///
1681+
/// Smart typography (dashes, ellipsis, apostrophes) is applied earlier, per
1682+
/// prose-str node, by `apply_smart_typography` — NOT here. Doing it per node is
1683+
/// what keeps escaped runs literal: `a\-\-b` arrives as separate single-hyphen
1684+
/// nodes, so merging them afterwards yields `a--b` rather than an en dash.
16931685
pub fn merge_strs(pandoc: Pandoc) -> Pandoc {
16941686
let mut ctx = FilterContext::new();
16951687
topdown_traverse(
@@ -1702,7 +1694,7 @@ pub fn merge_strs(pandoc: Pandoc) -> Pandoc {
17021694
for inline in inlines {
17031695
match inline {
17041696
Inline::Str(s) => {
1705-
let str_text = as_smart_str(s.text.clone());
1697+
let str_text = s.text.clone();
17061698
if let Some(ref mut current) = current_str {
17071699
current.push_str(&str_text);
17081700
if let Some(ref mut info) = current_source_info {

crates/pampa/src/pandoc/treesitter_utils/text_helpers.rs

Lines changed: 162 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,71 @@ pub fn create_specifier_base_text(
142142
PandocNativeIntermediate::IntermediateBaseText(id, node_location(node))
143143
}
144144

145-
/// Helper function to convert straight apostrophes to smart quotes
146-
/// Converts ASCII apostrophe (') to Unicode right single quotation mark (')
147-
pub fn apply_smart_quotes(text: String) -> String {
148-
text.replace('\'', "\u{2019}")
145+
/// Apply Pandoc "smart" typography to a prose text run: straight apostrophes
146+
/// become curly (`'` → `’`), runs of hyphens become en/em dashes, and runs of
147+
/// dots become ellipses.
148+
///
149+
/// Dash runs follow Pandoc's default `dash` parser
150+
/// (`Text/Pandoc/Parsing/Smart.hs`): consumed left-to-right, greedily taking
151+
/// three hyphens as an EM DASH (—) while at least three remain, then a trailing
152+
/// pair as an EN DASH (–), leaving a lone hyphen literal. Dot runs take three
153+
/// at a time as a HORIZONTAL ELLIPSIS (…), leaving a remainder of one or two
154+
/// dots literal.
155+
///
156+
/// **Must be applied per prose-str node, before merging adjacent strings.**
157+
/// Escaped punctuation (`\-`, `\.`) arrives from tree-sitter as its own
158+
/// single-character node, so a single node never contains an escaped hyphen or
159+
/// dot run; converting here (rather than after `merge_strs`) is what keeps
160+
/// `a\-\-b` literal instead of collapsing it to an en dash.
161+
pub fn apply_smart_typography(text: String) -> String {
162+
let mut out = String::with_capacity(text.len());
163+
let chars: Vec<char> = text.chars().collect();
164+
let mut i = 0;
165+
166+
while i < chars.len() {
167+
match chars[i] {
168+
'\'' => {
169+
out.push('\u{2019}');
170+
i += 1;
171+
}
172+
'-' => {
173+
let start = i;
174+
while i < chars.len() && chars[i] == '-' {
175+
i += 1;
176+
}
177+
let mut run = i - start;
178+
while run >= 3 {
179+
out.push('\u{2014}'); // — em dash
180+
run -= 3;
181+
}
182+
if run == 2 {
183+
out.push('\u{2013}'); // – en dash
184+
} else if run == 1 {
185+
out.push('-');
186+
}
187+
}
188+
'.' => {
189+
let start = i;
190+
while i < chars.len() && chars[i] == '.' {
191+
i += 1;
192+
}
193+
let mut run = i - start;
194+
while run >= 3 {
195+
out.push('\u{2026}'); // … ellipsis
196+
run -= 3;
197+
}
198+
for _ in 0..run {
199+
out.push('.');
200+
}
201+
}
202+
other => {
203+
out.push(other);
204+
i += 1;
205+
}
206+
}
207+
}
208+
209+
out
149210
}
150211

151212
/// Process backslash escapes in text according to Pandoc rules.
@@ -156,6 +217,13 @@ pub fn apply_smart_quotes(text: String) -> String {
156217
/// - A backslash followed by an ASCII space is Pandoc's non-breaking-space
157218
/// shorthand: the pair collapses to a single U+00A0 (NO-BREAK SPACE).
158219
/// See <https://pandoc.org/MANUAL.html#non-breaking-spaces>.
220+
/// - A backslash before one of the "smart typography" output characters —
221+
/// EM DASH (—, U+2014), EN DASH (–, U+2013), or HORIZONTAL ELLIPSIS
222+
/// (…, U+2026) — is also an escape: the backslash is dropped, leaving the
223+
/// character literal. This is how the QMD writer round-trips an em dash that
224+
/// would otherwise land on an all-dash line and be misread as a thematic
225+
/// break (it emits `\—`). A deliberate, narrow divergence from Pandoc, which
226+
/// keeps `\—` literal.
159227
/// - Any other `\X` is left as the literal two characters.
160228
pub fn process_backslash_escapes(text: String) -> String {
161229
let mut result = String::with_capacity(text.len());
@@ -166,9 +234,9 @@ pub fn process_backslash_escapes(text: String) -> String {
166234
// Check if next character is ASCII punctuation, an ASCII space,
167235
// or anything else.
168236
if let Some(&next_ch) = chars.peek() {
169-
if is_escapable_punctuation(next_ch) {
170-
// Backslash escape for a punctuation char: drop the
171-
// backslash, emit the punctuation.
237+
if is_escapable_punctuation(next_ch) || is_escapable_smart_char(next_ch) {
238+
// Backslash escape for a punctuation or smart-typography
239+
// char: drop the backslash, emit the character.
172240
chars.next();
173241
result.push(next_ch);
174242
} else if next_ch == ' ' {
@@ -230,6 +298,12 @@ fn is_escapable_punctuation(ch: char) -> bool {
230298
)
231299
}
232300

301+
/// Smart-typography output characters that a backslash may escape (D5): the em
302+
/// dash, en dash, and horizontal ellipsis. See `process_backslash_escapes`.
303+
fn is_escapable_smart_char(ch: char) -> bool {
304+
matches!(ch, '\u{2014}' | '\u{2013}' | '\u{2026}')
305+
}
306+
233307
/// Helper function to create simple line break inlines
234308
pub fn create_line_break_inline(
235309
node: &tree_sitter::Node,
@@ -503,3 +577,84 @@ mod tests {
503577
assert_eq!(extract_quoted_text(r#""abc\""#), "abc\\");
504578
}
505579
}
580+
581+
#[cfg(test)]
582+
mod smart_typography_tests {
583+
use super::{apply_smart_typography, process_backslash_escapes};
584+
585+
const EN: &str = "\u{2013}"; // –
586+
const EM: &str = "\u{2014}"; // —
587+
const ELL: &str = "\u{2026}"; // …
588+
const RSQUO: &str = "\u{2019}"; // ’
589+
590+
#[test]
591+
fn dash_runs_match_pandoc() {
592+
// Greedy: 3=em while >=3 remain, trailing 2=en, lone 1=hyphen.
593+
assert_eq!(apply_smart_typography("-".into()), "-");
594+
assert_eq!(apply_smart_typography("--".into()), EN);
595+
assert_eq!(apply_smart_typography("---".into()), EM);
596+
assert_eq!(apply_smart_typography("----".into()), format!("{EM}-"));
597+
assert_eq!(apply_smart_typography("-----".into()), format!("{EM}{EN}"));
598+
assert_eq!(apply_smart_typography("------".into()), format!("{EM}{EM}"));
599+
assert_eq!(
600+
apply_smart_typography("-------".into()),
601+
format!("{EM}{EM}-")
602+
);
603+
}
604+
605+
#[test]
606+
fn dash_mid_word_converts() {
607+
assert_eq!(
608+
apply_smart_typography("un---spaced".into()),
609+
format!("un{EM}spaced")
610+
);
611+
assert_eq!(
612+
apply_smart_typography("en--dash".into()),
613+
format!("en{EN}dash")
614+
);
615+
}
616+
617+
#[test]
618+
fn single_intraword_hyphen_preserved() {
619+
assert_eq!(apply_smart_typography("well-known".into()), "well-known");
620+
}
621+
622+
#[test]
623+
fn ellipsis_runs() {
624+
assert_eq!(apply_smart_typography("...".into()), ELL);
625+
assert_eq!(
626+
apply_smart_typography("Wait...".into()),
627+
format!("Wait{ELL}")
628+
);
629+
assert_eq!(apply_smart_typography("....".into()), format!("{ELL}."));
630+
assert_eq!(
631+
apply_smart_typography("......".into()),
632+
format!("{ELL}{ELL}")
633+
);
634+
// Only two dots: not an ellipsis, left literal.
635+
assert_eq!(apply_smart_typography("..".into()), "..");
636+
}
637+
638+
#[test]
639+
fn apostrophe_becomes_smart_quote() {
640+
assert_eq!(
641+
apply_smart_typography("don't".into()),
642+
format!("don{RSQUO}t")
643+
);
644+
}
645+
646+
#[test]
647+
fn backslash_escapes_smart_chars() {
648+
// D5: backslash strips before em/en-dash/ellipsis so `\—` → `—`.
649+
assert_eq!(process_backslash_escapes(format!("\\{EM}")), EM);
650+
assert_eq!(process_backslash_escapes(format!("\\{EN}")), EN);
651+
assert_eq!(process_backslash_escapes(format!("\\{ELL}")), ELL);
652+
}
653+
654+
#[test]
655+
fn backslash_escapes_ascii_punct_still_work() {
656+
assert_eq!(process_backslash_escapes("\\*".into()), "*");
657+
assert_eq!(process_backslash_escapes("a\\-b".into()), "a-b");
658+
assert_eq!(process_backslash_escapes("\\ ".into()), "\u{00A0}");
659+
}
660+
}

0 commit comments

Comments
 (0)