decomment 0.2.0

Remove comments from source text in 13 languages without touching strings, templates, or regex literals.
Documentation
//! The statements that must hold for every language, every mode, and every
//! flag combination, checked over a fixed vector list.

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

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];

const VECTORS: &[&str] = &[
    "",
    " ",
    "/",
    "/*",
    "/*/",
    "/**/",
    "//",
    "*/",
    "-->",
    "<!--",
    "#!/usr/bin/env sh\n",
    "a // b\r\nc",
    "a /* b */ c",
    "/* a /* b */ c */",
    "/*/*/*/* x */*/*/*/",
    "var re = /a//b/; var x = 1;",
    "tag`raw // text`; // real",
    "a\"raw // text\"; // real",
    "`a ${ `b ${ c /* x */ }` } d`",
    "s = r#\"a // b\"#; // c",
    "select 'a''-- not' -- yes",
    "R\"d(// not)d\" // yes",
    "@\"a\"\"b\" // yes",
    "'''# not\n''' # yes",
    "\"\"\"\na // b\n\"\"\" // yes",
    "let s = \"héllo → wörld\"; // ünicode\n",
    "a\0b // c\0d\ne\0",
    "x\u{2028}// y\u{2029}z",
    "\"unterminated\n// gone\n",
    "`unterminated ${ // x\n",
    "#\"swift raw // no\"# // yes",
    "\\\\ // trailing escape \\",
    "a/**/b/**/c",
    "//! banner\n/*! banner */\n// drop\n",
];

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 every_setting(mut check: impl FnMut(&str, Options, Stripped)) {
    for &source in VECTORS {
        for language in LANGUAGES {
            for mode in MODES {
                for preserve in [false, true] {
                    for bang in [false, true] {
                        let mut options = Options::new(language);
                        options.mode = mode;
                        options.preserve_newlines = preserve;
                        options.keep_bang_comments = bang;
                        check(source, options, strip(source, options));
                    }
                }
            }
        }
    }
}

#[test]
fn the_output_is_always_a_byte_subsequence_of_the_input() {
    every_setting(|source, options, result| {
        assert!(
            is_subsequence(&result.output, source),
            "{options:?} on {source:?} gave {:?}",
            result.output
        );
    });
}

#[test]
fn the_output_is_never_longer_than_the_input() {
    every_setting(|source, options, result| {
        assert!(
            result.output.len() <= source.len(),
            "{options:?} on {source:?}"
        );
    });
}

#[test]
fn preserve_newlines_keeps_the_line_feed_and_carriage_return_counts() {
    every_setting(|source, options, result| {
        if !options.preserve_newlines {
            return;
        }
        assert_eq!(
            count(&result.output, b'\n'),
            count(source, b'\n'),
            "{options:?} on {source:?}"
        );
        assert_eq!(
            count(&result.output, b'\r'),
            count(source, b'\r'),
            "{options:?} on {source:?}"
        );
    });
}

#[test]
fn a_reported_offset_is_always_inside_the_input() {
    every_setting(|source, options, result| {
        if let Some(open) = result.unterminated {
            assert!(open.offset < source.len(), "{options:?} on {source:?}");
        }
    });
}

#[test]
fn the_result_depends_only_on_the_input_and_the_options() {
    every_setting(|source, options, result| {
        assert_eq!(strip(source, options), result);
    });
}

#[test]
fn typescript_and_javascript_agree_on_every_vector() {
    for &source in VECTORS {
        for mode in MODES {
            for preserve in [false, true] {
                for bang in [false, true] {
                    let mut js = Options::new(Language::JavaScript);
                    js.mode = mode;
                    js.preserve_newlines = preserve;
                    js.keep_bang_comments = bang;
                    let mut ts = js;
                    ts.language = Language::TypeScript;
                    assert_eq!(strip(source, js), strip(source, ts), "{source:?}");
                }
            }
        }
    }
}

#[test]
fn mode_first_changes_at_most_one_contiguous_region() {
    for &source in VECTORS {
        for language in LANGUAGES {
            let mut options = Options::new(language);
            options.mode = Mode::First;
            let output = strip(source, options).output;
            if output == source {
                continue;
            }
            let prefix = source
                .bytes()
                .zip(output.bytes())
                .take_while(|(a, b)| a == b)
                .count();
            let suffix = source
                .bytes()
                .rev()
                .zip(output.bytes().rev())
                .take_while(|(a, b)| a == b)
                .count();
            // One removed region means the kept prefix and suffix cover the
            // whole output.
            assert!(
                prefix + suffix >= output.len(),
                "{language:?} {source:?} gave {output:?}"
            );
        }
    }
}

/// Every opener that stays open across a line terminator, with the kind it
/// reports. A missing report is as much a defect as a wrong one, so this list
/// is checked in the positive direction.
const OPENERS: &[(Language, &str, UnterminatedKind)] = &[
    (Language::C, "/*", UnterminatedKind::BlockComment),
    (Language::Cpp, "/*", UnterminatedKind::BlockComment),
    (Language::Cpp, "R\"d(", UnterminatedKind::StringLiteral),
    (Language::CSharp, "/*", UnterminatedKind::BlockComment),
    (Language::CSharp, "@\"", UnterminatedKind::StringLiteral),
    (Language::Css, "/*", UnterminatedKind::BlockComment),
    (Language::Go, "/*", UnterminatedKind::BlockComment),
    (Language::Go, "`", UnterminatedKind::StringLiteral),
    (Language::Html, "<!--", UnterminatedKind::BlockComment),
    (Language::Java, "/*", UnterminatedKind::BlockComment),
    (Language::Java, "\"\"\"", UnterminatedKind::StringLiteral),
    (Language::JavaScript, "/*", UnterminatedKind::BlockComment),
    (Language::JavaScript, "`", UnterminatedKind::StringLiteral),
    (Language::Python, "'''", UnterminatedKind::StringLiteral),
    (Language::Python, "\"\"\"", UnterminatedKind::StringLiteral),
    (Language::Rust, "/*", UnterminatedKind::BlockComment),
    (Language::Rust, "\"", UnterminatedKind::StringLiteral),
    (Language::Rust, "r#\"", UnterminatedKind::StringLiteral),
    (Language::Sql, "/*", UnterminatedKind::BlockComment),
    (Language::Sql, "'", UnterminatedKind::StringLiteral),
    (Language::Sql, "\"", UnterminatedKind::StringLiteral),
    (Language::Swift, "/*", UnterminatedKind::BlockComment),
    (Language::Swift, "#\"", UnterminatedKind::StringLiteral),
    (Language::Swift, "\"\"\"", UnterminatedKind::StringLiteral),
    (Language::TypeScript, "/*", UnterminatedKind::BlockComment),
    (Language::TypeScript, "`", UnterminatedKind::StringLiteral),
];

#[test]
fn a_construct_left_open_at_end_of_input_is_always_reported() {
    for &(language, opener, kind) in OPENERS {
        for body in ["", "x", "x\ny"] {
            let source = format!("v = {opener}{body}");
            let result = strip(&source, Options::new(language));
            let context = format!("{language:?} on {source:?}");
            let open = result.unterminated.unwrap_or_else(|| panic!("{context}"));
            assert_eq!(open.kind, kind, "{context}");
            assert_eq!(open.offset, 4, "{context}");
        }
    }
}

/// Both diagnostic types are published as errors, so a caller can wrap either
/// one. The bound is checked by the compiler here, because a missing impl is
/// invisible to a test that only reads fields.
#[test]
fn both_diagnostic_types_implement_error() {
    fn message<T: core::error::Error>(value: T) -> String {
        value.to_string()
    }
    assert_eq!(message(UnterminatedKind::BlockComment), "block comment");
    assert_eq!(message(UnterminatedKind::StringLiteral), "string literal");
    // `strip` is the only thing that builds an `Unterminated`, so the value
    // under test comes from a scan that leaves a block comment open at byte 11.
    let result = strip("var x = 1; /* open", Options::new(Language::JavaScript));
    let open = result.unterminated.unwrap();
    assert_eq!(open.kind, UnterminatedKind::BlockComment);
    assert_eq!(open.offset, 11);
    assert_eq!(
        message(open),
        "unterminated block comment opened at byte 11"
    );
}

#[test]
fn input_with_no_comment_marker_comes_back_unchanged() {
    let clean = "function f(a, b) { return a + b; }\nlet s = \"text\";\n";
    for language in LANGUAGES {
        let result = strip(clean, Options::new(language));
        assert_eq!(result.output, clean, "{language:?}");
        assert_eq!(result.unterminated, None, "{language:?}");
    }
}