Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ edition = "2024"
anyhow = "1"
clap = { version = "4", features = ["derive"] }
regex = "1"
once_cell = "1"
html5ever = "0.27"
markup5ever_rcdom = "0.3"
unicode-width = ">=0.1, <0.2"
Expand Down
28 changes: 8 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,20 @@ making it safe to use on Markdown with mixed content.

Install via Cargo:

Bash

```bash
cargo install mdtablefix
```

Or clone the repository and build from source:

Bash

```bash
cargo install --path .
```

## Command-line usage

Bash

```bash
mdtablefix [--wrap] [--renumber] [--breaks] [--ellipsis] [--in-place] [FILE...]
mdtablefix [--wrap] [--renumber] [--breaks] [--ellipsis] [--footnotes] [--in-place] [FILE...]
```

- When one or more file paths are provided, the corrected tables are printed to
Expand All @@ -53,6 +47,9 @@ mdtablefix [--wrap] [--renumber] [--breaks] [--ellipsis] [--in-place] [FILE...]
character (`…`). Longer runs are processed left-to-right, so any leftover
dots are preserved.

- Use `--footnotes` to convert bare numeric references and the final numbered
list into GitHub-flavoured footnote links.

- Use `--in-place` to modify files in-place.

- If no files are specified, input is read from stdin and output is written to
Expand All @@ -62,8 +59,6 @@ mdtablefix [--wrap] [--renumber] [--breaks] [--ellipsis] [--in-place] [FILE...]

Before:

Markdown

```markdown
|Character|Catchphrase|Pizza count| |---|---|---| |Speedy Cerviche|Here
come the Samurai Pizza Cats!|lots| |Guido Anchovy|Slice and dice!|tons|
Expand All @@ -72,8 +67,6 @@ come the Samurai Pizza Cats!|lots| |Guido Anchovy|Slice and dice!|tons|

After running `mdtablefix`:

Markdown

```markdown
| Character | Catchphrase | Pizza count |
| --------------- | --------------------------------- | ----------- |
Expand All @@ -86,8 +79,6 @@ Markdown

Before:

Markdown

```markdown
1. The Big Cheese's evil plans.
4. Jerry Atric's schemes.
Expand All @@ -102,8 +93,6 @@ A brief intermission for pizza.

After running `mdtablefix --renumber`:

Markdown

```markdown
1. The Big Cheese's evil plans.
2. Jerry Atric's schemes.
Expand All @@ -121,8 +110,6 @@ A brief intermission for pizza.
The crate provides helper functions for embedding the table reflow logic in
your own Rust project:

Rust

```rust
use mdtablefix::{process_stream_opts, rewrite};
use std::path::Path;
Expand All @@ -133,16 +120,17 @@ fn main() -> std::io::Result<()> {
&lines,
/* wrap = */ true,
/* ellipsis = */ true,
/* footnotes = */ false,
);
println!("{}", fixed.join("\n"));
rewrite(Path::new("table.md"))?;
Ok(())
}
```

- `process_stream_opts(lines: &[String], wrap: bool, ellipsis: bool) ->
Vec<String>` rewrites tables in memory, with optional paragraph wrapping and
ellipsis substitution.
- `process_stream_opts(lines, wrap, ellipsis, footnotes) -> Vec<String>`
rewrites tables in memory, with optional paragraph wrapping, ellipsis
substitution, and footnote conversion.

- `rewrite(path: &Path) -> std::io::Result<()>` modifies a Markdown file on
disk in-place.
Expand Down
9 changes: 7 additions & 2 deletions docs/module-relationships.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ classDiagram
<<module>>
+replace_ellipsis()
}
class footnotes {
<<module>>
+convert_footnotes()
}
class process {
<<module>>
+process_stream()
Expand Down Expand Up @@ -63,11 +67,12 @@ classDiagram
process ..> table : uses reflow_table
process ..> wrap : uses wrap_text, is_fence
process ..> ellipsis : uses replace_ellipsis
process ..> footnotes : uses convert_footnotes
io ..> process : uses process_stream, process_stream_no_wrap
```

The `lib` module re-exports the public API from the other modules. The
`ellipsis` module performs text normalisation. The `process` module provides
streaming helpers that combine the lower-level functions, including ellipsis
replacement. The `io` module handles filesystem operations, delegating the text
processing to `process`.
replacement and footnote conversion. The `io` module handles filesystem
operations, delegating the text processing to `process`.
109 changes: 109 additions & 0 deletions src/footnotes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
//! Footnote normalisation utilities.
//!
//! Converts bare numeric references in text to GitHub-flavoured Markdown
//! footnote links and rewrites the trailing numeric list into a footnote
//! block. Only the final contiguous list of footnotes is processed.

use regex::{Captures, Regex};

static INLINE_FN_RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r"(?P<pre>^|[^0-9])(?P<punc>[.!?);:])(?P<style>[*_]*)(?P<num>\d+)(?P<boundary>\s|$)")
.unwrap()
});

static FOOTNOTE_LINE_RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r"^(?P<indent>\s*)(?P<num>\d+)\.\s+(?P<rest>.*)$").unwrap()
});

use crate::wrap::{Token, tokenize_markdown};

fn convert_inline(text: &str) -> String {
Comment thread
leynos marked this conversation as resolved.
INLINE_FN_RE
.replace_all(text, |caps: &Captures| {
format!(
"{}{}{}[^{}]{}",
&caps["pre"], &caps["punc"], &caps["style"], &caps["num"], &caps["boundary"]
)
})
.into_owned()
}

fn convert_block(lines: &mut [String]) {
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()))
.map_or(0, |i| i + 1);

if start >= end || lines[start].trim_start().starts_with("[^") {
return;
}

for line in &mut lines[start..end] {
*line = FOOTNOTE_LINE_RE
.replace(line, "${indent}[^${num}] ${rest}")
.to_string();
}
}

/// Convert bare numeric footnote references to Markdown footnote syntax.
#[must_use]
pub fn convert_footnotes(lines: &[String]) -> Vec<String> {
if lines.is_empty() {
return Vec::new();
}
let joined = lines.join("\n");
let mut out = String::new();
for token in tokenize_markdown(&joined) {
match token {
Token::Text(t) => out.push_str(&convert_inline(t)),
Token::Code(c) => {
out.push('`');
out.push_str(c);
out.push('`');
}
Token::Fence(f) => out.push_str(f),
Token::Newline => out.push('\n'),
}
}
let mut lines: Vec<String> = out.split('\n').map(str::to_string).collect();
convert_block(&mut lines);
lines
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn converts_inline_numbers() {
let input = vec!["See the docs.2".to_string()];
let expected = vec!["See the docs.[^2]".to_string()];
assert_eq!(convert_footnotes(&input), expected);
}

#[test]
fn converts_final_list() {
let input = vec![
"Text.".to_string(),
String::new(),
" 1. First".to_string(),
" 2. Second".to_string(),
];
let expected = vec![
"Text.".to_string(),
String::new(),
" [^1] First".to_string(),
" [^2] Second".to_string(),
];
assert_eq!(convert_footnotes(&input), expected);
}

#[test]
fn idempotent_on_existing_block() {
let input = vec![" [^1] First".to_string()];
assert_eq!(convert_footnotes(&input), input);
}
}
Comment on lines +76 to +109
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Unit tests provide good basic coverage.

The unit tests cover key scenarios including inline conversion, block conversion, and idempotency. However, expand test coverage to match the integration tests' thoroughness.

Add these additional unit test cases:

+    #[test]
+    fn handles_empty_input() {
+        assert_eq!(convert_footnotes(&[]), Vec::<String>::new());
+    }
+
+    #[test]
+    fn preserves_code_blocks() {
+        let input = vec!["Text `code.1` more.2".to_string()];
+        let expected = vec!["Text `code.1` more.[^2]".to_string()];
+        assert_eq!(convert_footnotes(&input), expected);
+    }
+
+    #[test]
+    fn handles_emphasis_markers() {
+        let input = vec!["Text.**1".to_string()];
+        let expected = vec!["Text.**[^1]".to_string()];
+        assert_eq!(convert_footnotes(&input), expected);
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn converts_inline_numbers() {
let input = vec!["See the docs.2".to_string()];
let expected = vec!["See the docs.[^2]".to_string()];
assert_eq!(convert_footnotes(&input), expected);
}
#[test]
fn converts_final_list() {
let input = vec![
"Text.".to_string(),
String::new(),
" 1. First".to_string(),
" 2. Second".to_string(),
];
let expected = vec![
"Text.".to_string(),
String::new(),
" [^1] First".to_string(),
" [^2] Second".to_string(),
];
assert_eq!(convert_footnotes(&input), expected);
}
#[test]
fn idempotent_on_existing_block() {
let input = vec![" [^1] First".to_string()];
assert_eq!(convert_footnotes(&input), input);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn converts_inline_numbers() {
let input = vec!["See the docs.2".to_string()];
let expected = vec!["See the docs.[^2]".to_string()];
assert_eq!(convert_footnotes(&input), expected);
}
#[test]
fn converts_final_list() {
let input = vec![
"Text.".to_string(),
String::new(),
" 1. First".to_string(),
" 2. Second".to_string(),
];
let expected = vec![
"Text.".to_string(),
String::new(),
" [^1] First".to_string(),
" [^2] Second".to_string(),
];
assert_eq!(convert_footnotes(&input), expected);
}
#[test]
fn idempotent_on_existing_block() {
let input = vec![" [^1] First".to_string()];
assert_eq!(convert_footnotes(&input), input);
}
+
+ #[test]
+ fn handles_empty_input() {
+ assert_eq!(convert_footnotes(&[]), Vec::<String>::new());
+ }
+
+ #[test]
+ fn preserves_code_blocks() {
+ let input = vec!["Text `code.1` more.2".to_string()];
+ let expected = vec!["Text `code.1` more.[^2]".to_string()];
+ assert_eq!(convert_footnotes(&input), expected);
+ }
+
+ #[test]
+ fn handles_emphasis_markers() {
+ let input = vec!["Text.**1".to_string()];
+ let expected = vec!["Text.**[^1]".to_string()];
+ assert_eq!(convert_footnotes(&input), expected);
+ }
}
🤖 Prompt for AI Agents
In src/footnotes.rs around lines 108 to 141, the current unit tests cover basic
scenarios but lack the thoroughness of the integration tests. Add more unit
tests to cover edge cases such as multiple inline footnotes in one line,
footnotes with non-numeric identifiers, empty input, and mixed content lines.
This will ensure the convert_footnotes function is robust and behaves correctly
in all expected scenarios.

3 changes: 3 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@
//! - `lists` for renumbering ordered lists.
//! - `breaks` for thematic break formatting.
//! - `ellipsis` for normalising textual ellipses.
//! - `footnotes` for converting bare footnote links.
//! - `process` for stream processing.
//! - `io` for file helpers.

pub mod breaks;
pub mod ellipsis;
pub mod footnotes;
mod html;
pub mod io;
pub mod lists;
Expand All @@ -28,6 +30,7 @@ pub fn html_table_to_markdown(lines: &[String]) -> Vec<String> {

pub use breaks::{THEMATIC_BREAK_LEN, format_breaks};
pub use ellipsis::replace_ellipsis;
pub use footnotes::convert_footnotes;
pub use html::convert_html_tables;
pub use io::{rewrite, rewrite_no_wrap};
pub use lists::renumber_lists;
Expand Down
8 changes: 6 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ struct Cli {
#[derive(clap::Args, Clone, Copy)]
#[expect(
clippy::struct_excessive_bools,
reason = "CLI exposes four independent flags"
reason = "CLI exposes five independent flags"
)]
struct FormatOpts {
/// Wrap paragraphs and list items to 80 columns
Expand All @@ -38,10 +38,14 @@ struct FormatOpts {
/// Replace "..." with the ellipsis character
#[arg(long = "ellipsis")]
ellipsis: bool,
/// Convert bare numeric references and the final numbered list to
/// Markdown footnote links
#[arg(long = "footnotes")]
footnotes: bool,
}

fn process_lines(lines: &[String], opts: FormatOpts) -> Vec<String> {
let mut out = process_stream_opts(lines, opts.wrap, opts.ellipsis);
let mut out = process_stream_opts(lines, opts.wrap, opts.ellipsis, opts.footnotes);
if opts.renumber {
out = renumber_lists(&out);
}
Expand Down
26 changes: 21 additions & 5 deletions src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@

use crate::{
ellipsis::replace_ellipsis,
footnotes::convert_footnotes,
html::convert_html_tables,
table::reflow_table,
wrap::{self, wrap_text},
};

#[must_use]
pub fn process_stream_inner(lines: &[String], wrap: bool, ellipsis: bool) -> Vec<String> {
pub fn process_stream_inner(
lines: &[String],
wrap: bool,
ellipsis: bool,
footnotes: bool,
) -> Vec<String> {
let pre = convert_html_tables(lines);

let mut out = Vec::new();
Expand Down Expand Up @@ -74,20 +80,30 @@ pub fn process_stream_inner(lines: &[String], wrap: bool, ellipsis: bool) -> Vec
if ellipsis {
out = replace_ellipsis(&out);
}
if footnotes {
out = convert_footnotes(&out);
}
out
}

#[must_use]
pub fn process_stream(lines: &[String]) -> Vec<String> { process_stream_inner(lines, true, false) }
pub fn process_stream(lines: &[String]) -> Vec<String> {
process_stream_inner(lines, true, false, false)
}

#[must_use]
pub fn process_stream_no_wrap(lines: &[String]) -> Vec<String> {
process_stream_inner(lines, false, false)
process_stream_inner(lines, false, false, false)
}

#[must_use]
pub fn process_stream_opts(lines: &[String], wrap: bool, ellipsis: bool) -> Vec<String> {
process_stream_inner(lines, wrap, ellipsis)
pub fn process_stream_opts(
lines: &[String],
wrap: bool,
ellipsis: bool,
footnotes: bool,
) -> Vec<String> {
process_stream_inner(lines, wrap, ellipsis, footnotes)
}
Comment on lines +90 to 107
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

❓ Verification inconclusive

Wrapper function updates maintain backward compatibility whilst extending functionality.

The implementation correctly preserves existing behaviour by defaulting footnotes to false and properly forwards the new parameter through process_stream_opts.

Verify that the API changes are reflected in the documentation as required by the coding guidelines:


🏁 Script executed:

#!/bin/bash
# Description: Check if documentation has been updated to reflect the new footnotes parameter in process_stream_opts
# Expected: Documentation should mention the new footnotes parameter

# Check for mentions of footnotes parameter in documentation
rg -i "footnotes.*parameter|process_stream_opts.*footnotes" docs/

Length of output: 67


Update documentation to include the new footnotes parameter

The search returned no references to footnotes or process_stream_opts in the docs. Add or update the relevant documentation to describe the new footnotes flag:

  • docs/… (e.g. usage guide or API reference): document the footnotes boolean parameter in process_stream_opts along with its default behaviour.
  • README.md or examples: show how to call process_stream_opts with footnotes = true and explain its effect.
🤖 Prompt for AI Agents
In src/process.rs around lines 90 to 107, the new boolean parameter `footnotes`
in the function `process_stream_opts` is not documented. Update the relevant
documentation files such as the usage guide or API reference to describe the
`footnotes` parameter, including its purpose and default behavior. Additionally,
update README.md or example files to demonstrate how to call
`process_stream_opts` with `footnotes` set to true and explain the effect this
flag has on the function's output.


#[cfg(test)]
Expand Down
18 changes: 18 additions & 0 deletions tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,21 @@ fn test_cli_ellipsis_multiple_sequences() {
"First… then second… done.\n"
);
}

/// Tests the CLI `--footnotes` option to convert bare footnote links.
#[test]
fn test_cli_footnotes_option() {
let input = include_str!("data/footnotes_input.txt");
let expected = include_str!("data/footnotes_expected.txt");
let output = Command::cargo_bin("mdtablefix")
.expect("Failed to create cargo command for mdtablefix")
.arg("--footnotes")
.write_stdin(input)
.output()
.expect("Failed to execute mdtablefix command");
assert!(output.status.success());
assert_eq!(
output.stdout,
format!("{}\n", expected.trim_end()).as_bytes()
);
}
Loading