diff --git a/docs/footnote-conversion.md b/docs/footnote-conversion.md new file mode 100644 index 00000000..6b79729d --- /dev/null +++ b/docs/footnote-conversion.md @@ -0,0 +1,59 @@ +# Footnote Conversion + +`mdtablefix` can optionally convert bare numeric references into +GitHub-flavoured Markdown footnotes. The `convert_footnotes` function performs +this operation and is exposed via the higher-level `process_stream_opts` helper. + +Inline references that appear after punctuation are rewritten as footnote links. + +Before: + +```markdown +A useful tip.1 +``` + +After: + +```markdown +A useful tip.[^1] +``` + +Numbers inside inline code or parentheses are ignored. + +Before: + +```markdown +Look at `code 1` for details. +Refer to equation (1) for context. +``` + +After: + +```markdown +Look at `code 1` for details. +Refer to equation (1) for context. +``` + +When the final lines of a document form a numbered list they are replaced with +footnote definitions. + +Before: + +```markdown +Text. + + 1. First note + 2. Second note +``` + +After: + +```markdown +Text. + + [^1] First note +[^2] Second note +``` + +`convert_footnotes` only processes the final contiguous list of numeric +references. diff --git a/src/footnotes.rs b/src/footnotes.rs index 790ea833..bc0deb2b 100644 --- a/src/footnotes.rs +++ b/src/footnotes.rs @@ -28,14 +28,40 @@ fn convert_inline(text: &str) -> String { .into_owned() } -fn convert_block(lines: &mut [String]) { +/// Find the trailing block of lines that satisfy a predicate. +/// +/// The slice is scanned from the end and trailing blank lines are ignored. +/// The returned `(start, end)` indices delimit the contiguous region of lines +/// whose trimmed contents cause `predicate` to return `true`. Use +/// `lines[start..end]` for slicing. +/// +/// # Examples +/// +/// ```ignore +/// let lines = vec![ +/// "A".to_string(), +/// "1. note".to_string(), +/// "2. more".to_string(), +/// ]; +/// let (start, end) = trimmed_range(&lines, |l| l.starts_with('1') || l.starts_with('2')); +/// assert_eq!((start, end), (1, 3)); +/// ``` +fn trimmed_range(lines: &[String], predicate: F) -> (usize, usize) +where + F: Fn(&str) -> bool, +{ let end = lines .iter() .rposition(|l| !l.trim().is_empty()) .map_or(0, |i| i + 1); let start = (0..end) - .rfind(|&i| !FOOTNOTE_LINE_RE.is_match(lines[i].trim_end())) + .rfind(|&i| !predicate(lines[i].trim_end())) .map_or(0, |i| i + 1); + (start, end) +} + +fn convert_block(lines: &mut [String]) { + let (start, end) = trimmed_range(lines, |l| FOOTNOTE_LINE_RE.is_match(l)); if start >= end || lines[start].trim_start().starts_with("[^") { return; diff --git a/tests/footnotes.rs b/tests/footnotes.rs index 4dc4e1db..46075512 100644 --- a/tests/footnotes.rs +++ b/tests/footnotes.rs @@ -26,6 +26,41 @@ fn test_avoids_false_positives() { assert_eq!(convert_footnotes(&input), input); } +#[test] +fn test_ignores_numbers_in_inline_code() { + let input = lines_vec!("Look at `code 1` for details."); + assert_eq!(convert_footnotes(&input), input); +} + +#[test] +fn test_ignores_numbers_in_parentheses() { + let input = lines_vec!("Refer to equation (1) for context."); + assert_eq!(convert_footnotes(&input), input); +} + +#[test] +fn test_ignores_numbers_in_fenced_code_block() { + let input = lines_vec!( + "Here is a code block:", + "```", + "let x = 42; // note 1", + "```", + "Done." + ); + assert_eq!(convert_footnotes(&input), input); +} + +#[test] +fn test_ignores_numbers_in_indented_code_block() { + let input = lines_vec!( + " let a = 1;", + " let b = 2; // number 2", + "", + "Outside." + ); + assert_eq!(convert_footnotes(&input), input); +} + #[test] fn test_handles_punctuation_inside_bold() { let input = lines_vec!("It was **scary.**7");