pub fn is_note_line(line: &str) -> bool {
line.trim_start().starts_with("..")
}
#[allow(dead_code)]
pub fn strip_markers(line: &str) -> String {
let chars: Vec<char> = line.chars().collect();
let spans = crate::markdown::scan_line(line);
let mut out = String::with_capacity(line.len());
for (i, &c) in chars.iter().enumerate() {
let is_marker = spans
.iter()
.any(|&(s, e, kind)| i >= s && i < e && kind == crate::markdown::MdKind::Marker);
if !is_marker {
out.push(c);
}
}
out
}
pub fn opens_quote(prev: Option<char>) -> bool {
match prev {
None => true,
Some(p) => p.is_whitespace() || matches!(p, '(' | '[' | '{' | '\u{2014}' | '\u{2013}'),
}
}
pub struct Sub {
pub ch: char,
pub consumed: usize,
}
pub fn smart_char(chars: &[char], i: usize, prev: Option<char>) -> Option<Sub> {
match chars[i] {
'-' if run_len(chars, i, '-') >= 2 => Some(Sub {
ch: '\u{2014}',
consumed: run_len(chars, i, '-'),
}),
'.' if run_len(chars, i, '.') >= 3 => {
Some(Sub {
ch: '\u{2026}',
consumed: 3,
})
}
'"' => Some(Sub {
ch: if opens_quote(prev) {
'\u{201C}'
} else {
'\u{201D}'
},
consumed: 1,
}),
'\'' => Some(Sub {
ch: if opens_quote(prev) {
'\u{2018}'
} else {
'\u{2019}'
},
consumed: 1,
}),
_ => None,
}
}
#[allow(dead_code)]
pub fn smart_typography(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let mut out = String::with_capacity(text.len());
let mut i = 0;
while i < chars.len() {
let prev = out.chars().last();
if let Some(sub) = smart_char(&chars, i, prev) {
out.push(sub.ch);
i += sub.consumed;
} else {
out.push(chars[i]);
i += 1;
}
}
out
}
fn run_len(chars: &[char], i: usize, target: char) -> usize {
let mut n = 0;
while i + n < chars.len() && chars[i + n] == target {
n += 1;
}
n
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn note_line_detection() {
assert!(is_note_line(".. fix later"));
assert!(is_note_line(" .. indented note"));
assert!(is_note_line("..no space"));
assert!(!is_note_line("Real prose."));
assert!(!is_note_line("A sentence ending in ..."));
assert!(!is_note_line(". single dot"));
}
#[test]
fn strip_markers_removes_emphasis_punctuation() {
assert_eq!(strip_markers("a **b** and *c*"), "a b and c");
assert_eq!(strip_markers("run `x` now"), "run x now");
assert_eq!(strip_markers("## Heading"), "Heading");
}
#[test]
fn strip_markers_leaves_plain_text_untouched() {
assert_eq!(strip_markers("2 * 3 = 6"), "2 * 3 = 6");
assert_eq!(strip_markers("plain line"), "plain line");
}
#[test]
fn em_dash_and_ellipsis() {
assert_eq!(smart_typography("wait--stop"), "wait\u{2014}stop");
assert_eq!(smart_typography("er...um"), "er\u{2026}um");
assert_eq!(smart_typography("done...."), "done\u{2026}.");
}
#[test]
fn curly_quotes_open_and_close() {
assert_eq!(smart_typography("\"hi\""), "\u{201C}hi\u{201D}");
assert_eq!(smart_typography("it's"), "it\u{2019}s");
}
#[test]
fn quote_after_dash_opens() {
let out = smart_typography("--\"yes\"");
assert!(out.starts_with("\u{2014}\u{201C}yes"), "got {out}");
}
#[test]
fn single_dash_and_double_dot_are_literal() {
assert_eq!(smart_typography("a-b"), "a-b");
assert_eq!(smart_typography("a..b"), "a..b");
}
}