decomment 0.2.0

Remove comments from source text in 13 languages without touching strings, templates, or regex literals.
Documentation
//! Randomized checks driven by a fixed seed, so every failure reproduces.
//!
//! The generator is a linear congruential sequence written here rather than
//! pulled in as a dependency, because the crate ships none and a seeded
//! sequence is eight lines.

use decomment::{strip, Language, Mode, Options};

const SEED: u64 = 0x00c0_ffee_c0ff_ee00;

struct Rng(u64);

impl Rng {
    fn next(&mut self) -> u64 {
        self.0 = self
            .0
            .wrapping_mul(6_364_136_223_846_793_005)
            .wrapping_add(1_442_695_040_888_963_407);
        self.0 >> 17
    }

    fn below(&mut self, bound: usize) -> usize {
        (self.next() % bound as u64) as usize
    }

    /// Returns the element by value rather than by reference. Every item picked
    /// here is `Copy`, and a reference return makes the element type depend on
    /// the caller's expected type, which older compilers resolve the other way.
    fn pick<T: Copy>(&mut self, items: &[T]) -> T {
        items[self.below(items.len())]
    }

    fn flip(&mut self) -> bool {
        self.next() % 2 == 0
    }
}

const LANGUAGES: [Language; 13] = [
    Language::C,
    Language::Cpp,
    Language::CSharp,
    Language::Css,
    Language::Go,
    Language::Html,
    Language::Java,
    Language::JavaScript,
    Language::Python,
    Language::Rust,
    Language::Sql,
    Language::Swift,
    Language::TypeScript,
];

const MODES: [Mode; 4] = [Mode::All, Mode::Line, Mode::Block, Mode::First];

/// Marker fragments, quote forms, escapes, and terminators. Joining them at
/// random puts every marker next to every other marker.
const FRAGMENTS: &[&str] = &[
    "/*",
    "*/",
    "//",
    "/**/",
    "/*!",
    "//!",
    "#",
    "#!",
    "--",
    "--!",
    "<!--",
    "-->",
    "\"",
    "'",
    "`",
    "${",
    "}",
    "{",
    "\\",
    "\\\\",
    "\\\"",
    "\\'",
    "r#\"",
    "\"#",
    "R\"d(",
    ")d\"",
    "@\"",
    "\"\"",
    "\"\"\"",
    "'''",
    "b\"",
    "br#\"",
    "##\"",
    "\"##",
    "\n",
    "\r\n",
    "\r",
    "\t",
    " ",
    "\0",
    "\u{2028}",
    "\u{2029}",
    "é",
    "→",
    "a",
    "x = ",
    "1",
    ";",
    "/",
    "*",
    "[",
    "]",
    "(",
    ")",
    "return ",
    "typeof ",
    "http://u/x",
    "a-b",
    "0x1f",
];

fn is_subsequence(needle: &str, haystack: &str) -> bool {
    let mut bytes = haystack.bytes();
    needle.bytes().all(|byte| bytes.any(|other| other == byte))
}

fn count(text: &str, byte: u8) -> usize {
    text.bytes().filter(|b| *b == byte).count()
}

fn random_source(rng: &mut Rng) -> String {
    let parts = rng.below(14);
    let mut source = String::new();
    for _ in 0..parts {
        source.push_str(rng.pick(FRAGMENTS));
    }
    source
}

fn random_options(rng: &mut Rng) -> Options {
    let mut options = Options::new(rng.pick(&LANGUAGES));
    options.mode = rng.pick(&MODES);
    options.preserve_newlines = rng.flip();
    options.keep_bang_comments = rng.flip();
    options
}

#[test]
fn every_invariant_holds_over_random_input() {
    let mut rng = Rng(SEED);
    for case in 0..60_000 {
        let source = random_source(&mut rng);
        let options = random_options(&mut rng);
        let result = strip(&source, options);
        let context = format!("case {case} {options:?} on {source:?}");

        assert!(is_subsequence(&result.output, &source), "{context}");
        assert!(result.output.len() <= source.len(), "{context}");
        if options.preserve_newlines {
            assert_eq!(
                count(&result.output, b'\n'),
                count(&source, b'\n'),
                "{context}"
            );
            assert_eq!(
                count(&result.output, b'\r'),
                count(&source, b'\r'),
                "{context}"
            );
        }
        if let Some(open) = result.unterminated {
            assert!(open.offset < source.len(), "{context}");
        }
        assert_eq!(strip(&source, options), result, "{context}");
    }
}

#[test]
fn typescript_matches_javascript_over_random_input() {
    let mut rng = Rng(SEED ^ 0x5555_5555);
    for _ in 0..20_000 {
        let source = random_source(&mut rng);
        let mut js = Options::new(Language::JavaScript);
        js.mode = rng.pick(&MODES);
        js.preserve_newlines = rng.flip();
        js.keep_bang_comments = rng.flip();
        let mut ts = js;
        ts.language = Language::TypeScript;
        assert_eq!(strip(&source, js), strip(&source, ts), "{source:?}");
    }
}

#[test]
fn arbitrary_bytes_never_panic() {
    let mut rng = Rng(SEED ^ 0xaaaa_aaaa);
    for _ in 0..20_000 {
        let len = rng.below(64);
        let bytes: Vec<u8> = (0..len).map(|_| (rng.next() % 256) as u8).collect();
        let source = String::from_utf8_lossy(&bytes).into_owned();
        let options = random_options(&mut rng);
        let result = strip(&source, options);
        assert!(is_subsequence(&result.output, &source), "{source:?}");
    }
}

/// Comment, literal, and code fragment templates for one language. Each holds
/// one `{}` that a unique sentinel fills.
///
/// Every language with an escape rule carries a literal whose sentinel sits
/// behind an escaped quote and a comment marker. Read the escape wrong and the
/// literal closes early, the marker opens a comment, and the sentinel goes.
fn templates(
    language: Language,
) -> (
    &'static [&'static str],
    &'static [&'static str],
    &'static str,
) {
    match language {
        Language::C => (
            &["// {}", "/* {} */"],
            &["\"{}\"", "'{}'", "\"a\\\"// {}\""],
            "int v{} = 1;",
        ),
        Language::Cpp => (
            &["// {}", "/* {} */"],
            &["\"{}\"", "'{}'", "R\"d({})d\"", "\"a\\\"// {}\""],
            "int v{} = 1;",
        ),
        Language::CSharp => (
            &["// {}", "/* {} */"],
            &["\"{}\"", "'{}'", "@\"{}\"", "@\"a\"\"// {}\""],
            "int v{} = 1;",
        ),
        Language::Css => (
            &["/* {} */"],
            &["\"{}\"", "'{}'", "\"a\\\"/* {} */\""],
            "a_{}: 1",
        ),
        Language::Go => (
            &["// {}", "/* {} */"],
            &["\"{}\"", "`{}`", "\"a\\\"// {}\""],
            "v{} := 1",
        ),
        Language::Html => (&["<!-- {} -->"], &[], "<p>{}</p>"),
        Language::Java => (
            &["// {}", "/* {} */"],
            &["\"{}\"", "\"\"\"\n{}\n\"\"\"", "\"a\\\"// {}\""],
            "int v{} = 1;",
        ),
        Language::JavaScript | Language::TypeScript => (
            &["// {}", "/* {} */"],
            &["\"{}\"", "'{}'", "`{}`", "x = /{}[/]/", "\"a\\\"// {}\""],
            "var v{} = 1;",
        ),
        Language::Python => (
            &["# {}"],
            &["\"{}\"", "'{}'", "'''{}'''", "r\"{}\"", "r'a\\'# {}'"],
            "v{} = 1",
        ),
        Language::Rust => (
            &["// {}", "/* {} */"],
            &["\"{}\"", "r#\"{}\"#", "b\"{}\"", "\"a\\\"// {}\""],
            "let v{} = 1;",
        ),
        Language::Sql => (
            &["-- {}", "/* {} */"],
            &["'{}'", "\"{}\"", "'a''-- {}'"],
            "select v{}",
        ),
        Language::Swift => (
            &["// {}", "/* {} */"],
            &[
                "\"{}\"",
                "#\"{}\"#",
                "\"\"\"\n{}\n\"\"\"",
                "#\"a\\#\"// {}\"#",
            ],
            "let v{} = 1",
        ),
        // `Language` is non-exhaustive, so a new variant lands here and fails
        // loudly rather than being scanned with the wrong templates.
        other => panic!("no sentinel templates for {other:?}"),
    }
}

#[test]
fn planted_sentinels_survive_outside_comments_and_vanish_inside_them() {
    let mut rng = Rng(SEED ^ 0x1234_5678);
    let mut serial = 0u32;
    for language in LANGUAGES {
        let (comments, literals, code) = templates(language);
        for _ in 0..2_000 {
            let mut source = String::new();
            let mut gone = Vec::new();
            let mut kept = Vec::new();
            for _ in 0..1 + rng.below(6) {
                serial += 1;
                let sentinel = format!("S{serial:07}");
                let (template, target) = match rng.below(3) {
                    0 => (rng.pick(comments), &mut gone),
                    1 if !literals.is_empty() => (rng.pick(literals), &mut kept),
                    _ => (code, &mut kept),
                };
                source.push_str(&template.replace("{}", &sentinel));
                // A line comment ends at its terminator, so every fragment gets
                // one and no fragment can swallow the next.
                source.push('\n');
                target.push(sentinel);
            }
            let output = strip(&source, Options::new(language)).output;
            for sentinel in &gone {
                assert!(!output.contains(sentinel), "{language:?} {source:?}");
            }
            for sentinel in &kept {
                assert!(output.contains(sentinel), "{language:?} {source:?}");
            }
        }
    }
}