rumdl 0.2.78

A fast Markdown linter and formatter written in Rust
Documentation
//! A Rust file is markdown only inside its doc comments, on every path.
//!
//! The lint pass has always read `.rs` files that way, through
//! `check_doc_comment_blocks`. The fix pass did not: it handed the whole source
//! to the markdown fix coordinator, which reads `#[derive(Debug)]` as an MD018
//! heading and `"* {} *"` as MD037 spaces inside emphasis. `rumdl fmt lib.rs`
//! wrote `# [derive(Debug)]` to disk and the file stopped being Rust, with every
//! one of those edits a byte no `check` of the same file ever reported.
//!
//! `.rs` files are a supported target (`include = ["**/*.rs"]`), so this reached
//! whole trees, and the stdin path took the same route under
//! `--stdin-filename lib.rs`.

use std::fs;
use std::io::Write;
use std::path::Path;
use std::process::{Command, Output, Stdio};

/// Rust that a markdown fixer has plenty to say about: an attribute that reads
/// as an unspaced ATX heading, emphasis-looking spacing inside a string literal,
/// and a doc comment holding the one finding that is genuinely there.
const RUST_SOURCE: &str = r#"//! Crate docs.
//!
//!#Overview

use std::fmt;

#[derive(Debug)]
pub struct Widget {
    pub name: String,
}

impl fmt::Display for Widget {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "* {} *", self.name)
    }
}
"#;

/// The same source with the only markdown in it corrected: the doc comment's
/// heading. Every other byte is Rust and belongs to the compiler.
const RUST_SOURCE_FIXED: &str = r#"//! Crate docs.
//!
//!# Overview

use std::fmt;

#[derive(Debug)]
pub struct Widget {
    pub name: String,
}

impl fmt::Display for Widget {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "* {} *", self.name)
    }
}
"#;

fn rumdl(dir: &Path, args: &[&str]) -> Output {
    Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .current_dir(dir)
        .args(args)
        .output()
        .expect("failed to execute rumdl")
}

/// Pipe `content` into rumdl running in `dir`, returning (stdout, stderr).
fn rumdl_with_stdin(dir: &Path, content: &str, args: &[&str]) -> (String, String) {
    let mut child = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .current_dir(dir)
        .args(args)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("failed to execute rumdl");
    child
        .stdin
        .as_mut()
        .unwrap()
        .write_all(content.as_bytes())
        .expect("failed to write stdin");
    let output = child.wait_with_output().expect("failed to collect rumdl output");
    (
        String::from_utf8_lossy(&output.stdout).replace("\r\n", "\n"),
        String::from_utf8_lossy(&output.stderr).replace("\r\n", "\n"),
    )
}

#[test]
fn fmt_rewrites_a_rust_files_doc_comment_and_leaves_the_rust_alone() {
    let dir = tempfile::tempdir().unwrap();
    fs::write(dir.path().join("widget.rs"), RUST_SOURCE).unwrap();

    let output = rumdl(
        dir.path(),
        &["fmt", "--color", "never", "--no-cache", "--no-config", "widget.rs"],
    );
    let stdout = String::from_utf8_lossy(&output.stdout);

    assert_eq!(
        fs::read_to_string(dir.path().join("widget.rs")).unwrap(),
        RUST_SOURCE_FIXED,
        "only the doc comment is markdown.\nstdout:\n{stdout}"
    );
    let diagnostics: Vec<&str> = stdout.lines().filter(|line| line.contains("widget.rs:")).collect();
    assert_eq!(
        diagnostics,
        ["widget.rs:3:5: [MD018] No space after # in heading [fixed]"],
        "the doc-comment heading is the only finding, and the fix pass resolved it.\nstdout:\n{stdout}"
    );
    assert!(stdout.contains("Fixed: 1/1 issue in 1 file"), "stdout:\n{stdout}");
}

/// The control for the test above: the identical bytes under a name that makes
/// them markdown. Everything the `.rs` file kept is rewritten here, so a run that
/// left `widget.rs` alone did so because of what it is, not because the fixer had
/// nothing to say about the content.
#[test]
fn the_same_bytes_as_markdown_are_rewritten_throughout() {
    let dir = tempfile::tempdir().unwrap();
    fs::write(dir.path().join("widget.md"), RUST_SOURCE).unwrap();

    let output = rumdl(
        dir.path(),
        &["fmt", "--color", "never", "--no-cache", "--no-config", "widget.md"],
    );
    let fixed = fs::read_to_string(dir.path().join("widget.md")).unwrap();
    let stdout = String::from_utf8_lossy(&output.stdout);

    assert!(
        fixed.contains("# [derive(Debug)]"),
        "the attribute reads as a heading in markdown.\nfixed:\n{fixed}\nstdout:\n{stdout}"
    );
    assert!(
        fixed.contains(r#"write!(f, "*{}*", self.name)"#),
        "the string literal reads as emphasis in markdown.\nfixed:\n{fixed}\nstdout:\n{stdout}"
    );
}

/// A diff is what the fix pass would write, so it answers to the same rule.
/// Offering to rewrite the Rust would be the same defect one keystroke away.
#[test]
fn diff_mode_offers_no_change_to_a_rust_files_source() {
    let dir = tempfile::tempdir().unwrap();
    fs::write(dir.path().join("widget.rs"), RUST_SOURCE).unwrap();

    let output = rumdl(
        dir.path(),
        &[
            "check",
            "--diff",
            "--color",
            "never",
            "--no-cache",
            "--no-config",
            "widget.rs",
        ],
    );
    let stdout = String::from_utf8_lossy(&output.stdout);

    assert!(
        stdout.contains("-//!#Overview") && stdout.contains("+//!# Overview"),
        "the doc-comment fix is still offered.\nstdout:\n{stdout}"
    );
    assert!(
        !stdout.contains("derive") && !stdout.contains("write!"),
        "no line of Rust is offered for rewriting.\nstdout:\n{stdout}"
    );
    assert_eq!(
        fs::read_to_string(dir.path().join("widget.rs")).unwrap(),
        RUST_SOURCE,
        "a diff writes nothing"
    );
}

/// `--stdin-filename lib.rs` says the piped text is that file, so the stdin path
/// answers for it the way a run over the file does. Editors and pre-commit hooks
/// drive rumdl this way, which is where a format-on-save would have destroyed the
/// buffer.
#[test]
fn stdin_named_as_a_rust_file_fixes_only_its_doc_comments() {
    let dir = tempfile::tempdir().unwrap();

    let (stdout, stderr) = rumdl_with_stdin(
        dir.path(),
        RUST_SOURCE,
        &[
            "fmt",
            "--stdin",
            "--stdin-filename",
            "widget.rs",
            "--color",
            "never",
            "--no-config",
        ],
    );

    assert_eq!(stdout, RUST_SOURCE_FIXED, "stderr:\n{stderr}");
    assert!(
        stderr.contains("widget.rs:3:5: [MD018] No space after # in heading [fixed]"),
        "the doc-comment fix is reported.\nstderr:\n{stderr}"
    );
    assert!(
        stderr.contains("1 issue fixed, 0 issues remaining"),
        "stderr:\n{stderr}"
    );
}

/// The lint side of the same routing: a piped Rust file reports what
/// `rumdl check widget.rs` reports, so no finding names a line of Rust.
#[test]
fn stdin_named_as_a_rust_file_reports_only_its_doc_comments() {
    let dir = tempfile::tempdir().unwrap();

    let (stdout, stderr) = rumdl_with_stdin(
        dir.path(),
        RUST_SOURCE,
        &[
            "check",
            "--stdin",
            "--stdin-filename",
            "widget.rs",
            "--color",
            "never",
            "--no-config",
        ],
    );

    let diagnostics: Vec<&str> = stdout.lines().filter(|line| line.contains("widget.rs:")).collect();
    assert_eq!(
        diagnostics,
        ["widget.rs:3:5: [MD018] No space after # in heading"],
        "the doc-comment heading is the only finding.\nstdout:\n{stdout}\nstderr:\n{stderr}"
    );
}

/// Run `rumdl <command>` over `source` written as `lib.rs`, returning the file
/// as it is afterwards and the run's stdout.
fn run_on_rust_file(command: &str, source: &str) -> (String, String) {
    let dir = tempfile::tempdir().unwrap();
    fs::write(dir.path().join("lib.rs"), source).unwrap();
    let output = rumdl(
        dir.path(),
        &[command, "--color", "never", "--no-cache", "--no-config", "lib.rs"],
    );
    (
        fs::read_to_string(dir.path().join("lib.rs")).unwrap(),
        String::from_utf8_lossy(&output.stdout).into_owned(),
    )
}

fn diagnostics(stdout: &str) -> Vec<&str> {
    stdout.lines().filter(|line| line.starts_with("lib.rs:")).collect()
}

/// A fix that removes doc-comment lines leaves every remaining line with its own
/// prefix. Collapsing the blank lines must not hand `more` the bare `//!` of the
/// blank line whose position it now holds, which writes `//!more`.
#[test]
fn fmt_removing_blank_doc_lines_keeps_each_remaining_lines_prefix() {
    let (fixed, stdout) = run_on_rust_file("fmt", "//! text\n//!\n//!\n//!\n//! more\n\npub fn f() {}\n");

    assert_eq!(fixed, "//! text\n//!\n//! more\n\npub fn f() {}\n", "stdout:\n{stdout}");
    assert_eq!(
        diagnostics(&stdout),
        [
            "lib.rs:3:4: [MD012] Multiple consecutive blank lines between content [fixed]",
            "lib.rs:4:4: [MD012] Multiple consecutive blank lines between content [fixed]",
        ],
        "stdout:\n{stdout}"
    );
}

#[test]
fn fmt_removing_blank_lines_in_an_indented_doc_comment_keeps_each_lines_prefix() {
    let source = "pub struct S;\n\nimpl S {\n    /// text\n    ///\n    ///\n    /// more\n    pub fn f() {}\n}\n";
    let (fixed, stdout) = run_on_rust_file("fmt", source);

    assert_eq!(
        fixed, "pub struct S;\n\nimpl S {\n    /// text\n    ///\n    /// more\n    pub fn f() {}\n}\n",
        "stdout:\n{stdout}"
    );
}

/// A fix that inserts a line leaves every later line with its own separator. A
/// tab, or no separator at all, is as valid to rustdoc as a space, and the lines
/// the fix did not touch are not the fix's to rewrite.
#[test]
fn fmt_inserting_a_doc_line_keeps_each_later_lines_separator() {
    let (fixed, stdout) = run_on_rust_file("fmt", "/// # Heading\n///\tTabbed\n///no-space\npub fn f() {}\n");

    assert_eq!(
        fixed, "/// # Heading\n///\n///\tTabbed\n///no-space\npub fn f() {}\n",
        "stdout:\n{stdout}"
    );
    assert_eq!(
        diagnostics(&stdout),
        ["lib.rs:1:5: [MD022] Expected 1 blank line below heading [fixed]"],
        "stdout:\n{stdout}"
    );
}

/// rustdoc reads a tab after `///` as the separator: `///\t# Heading` renders as
/// a heading and `///\ttext` as a paragraph. rumdl reads the lines the same way,
/// so the heading is linted as a heading, and its column counts the tab once.
/// A fix that edits a line in place leaves it with its own prefix and
/// indentation even when its fixed text equals the next line's.
#[test]
fn fmt_trimming_a_doc_line_to_equal_its_neighbor_keeps_each_lines_prefix() {
    let (fixed, stdout) = run_on_rust_file("fmt", "///foo   \n  /// foo\npub fn f() {}\n");

    assert_eq!(fixed, "///foo\n  /// foo\npub fn f() {}\n", "stdout:\n{stdout}");
    assert_eq!(diagnostics(&stdout).len(), 1, "stdout:\n{stdout}");
}

#[test]
fn a_tab_after_the_doc_prefix_separates_it_from_the_markdown() {
    let source = "///\t# Heading\n///\ttext\npub fn f() {}\n";

    let (unchanged, stdout) = run_on_rust_file("check", source);
    assert_eq!(unchanged, source);
    assert_eq!(
        diagnostics(&stdout),
        ["lib.rs:1:5: [MD022] Expected 1 blank line below heading"],
        "stdout:\n{stdout}"
    );

    let (fixed, stdout) = run_on_rust_file("fmt", source);
    assert_eq!(
        fixed, "///\t# Heading\n///\n///\ttext\npub fn f() {}\n",
        "stdout:\n{stdout}"
    );
}

/// The separator is a single character. A tab after `/// ` is inside the markdown,
/// so it is a leading tab there, reported at its own column, and the positive
/// control for the tab-separated line beside it reporting nothing.
#[test]
fn a_tab_separator_is_not_a_leading_tab_but_a_tab_after_the_separator_is() {
    let (_, stdout) = run_on_rust_file("check", "/// Some text\n///\tmore\n/// \tcode\npub fn f() {}\n");

    let md010: Vec<&str> = diagnostics(&stdout)
        .into_iter()
        .filter(|line| line.contains("[MD010]"))
        .collect();
    assert_eq!(
        md010,
        ["lib.rs:3:5: [MD010] Found leading tab, use 4 spaces instead"],
        "stdout:\n{stdout}"
    );
}

#[test]
fn stdin_named_as_a_rust_file_keeps_each_doc_lines_prefix_when_a_fix_removes_lines() {
    let dir = tempfile::tempdir().unwrap();

    let (stdout, stderr) = rumdl_with_stdin(
        dir.path(),
        "//! text\n//!\n//!\n//!\n//! more\n\npub fn f() {}\n",
        &[
            "fmt",
            "--stdin",
            "--stdin-filename",
            "lib.rs",
            "--color",
            "never",
            "--no-config",
        ],
    );

    assert_eq!(
        stdout, "//! text\n//!\n//! more\n\npub fn f() {}\n",
        "stderr:\n{stderr}"
    );
}