tsify 0.5.8

Tsify is a library for generating TypeScript definitions from rust code.
Documentation
use indoc::{formatdoc, indoc};
use std::{
    fs,
    path::{Path, PathBuf},
    process::Command,
    time::{SystemTime, UNIX_EPOCH},
};

struct TempCrate(PathBuf);

struct CompileFailCase {
    module: &'static str,
    source: &'static str,
    line: usize,
    message: &'static str,
}

impl TempCrate {
    fn new() -> Self {
        let nonce = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system time should be after the Unix epoch")
            .as_nanos();
        let path = std::env::temp_dir().join(format!(
            "tsify-rename-compile-fail-{}-{nonce}",
            std::process::id()
        ));
        fs::create_dir_all(path.join("src")).expect("failed to create temporary test crate");
        Self(path)
    }

    fn path(&self) -> &Path {
        &self.0
    }
}

impl Drop for TempCrate {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.0);
    }
}

fn assert_diagnostic(stderr: &str, case: &CompileFailCase) {
    let error = format!("error: {}", case.message);
    let location = format!("--> src/{}.rs:{}:", case.module, case.line);
    let mut search_start = 0;

    while let Some(relative_start) = stderr[search_start..].find(&error) {
        let error_start = search_start + relative_start;
        let diagnostic = &stderr[error_start..];
        let diagnostic_end = diagnostic[1..]
            .find("\nerror:")
            .map_or(diagnostic.len(), |index| index + 1);
        let diagnostic = &diagnostic[..diagnostic_end];

        if diagnostic.contains(&location) {
            return;
        }
        search_start = error_start + error.len();
    }

    panic!(
        "missing diagnostic for {} at line {}: {}\n{stderr}",
        case.module, case.line, case.message
    );
}

#[test]
fn invalid_rename_attributes_fail_to_compile() {
    let temp_crate = TempCrate::new();
    let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
    let dependency_path = manifest_dir.to_string_lossy().replace('\\', "\\\\");
    let manifest = formatdoc! {r#"
        [package]
        name = "tsify-rename-compile-fail"
        version = "0.0.0"
        edition = "2021"

        [dependencies]
        tsify = {{ path = "{dependency_path}" }}

        [workspace]
    "#};
    fs::write(temp_crate.path().join("Cargo.toml"), manifest)
        .expect("failed to write temporary Cargo.toml");
    let cases = [
        CompileFailCase {
            module: "duplicate_same_attribute",
            source: indoc! {r#"
                use tsify::Tsify;

                #[derive(Tsify)]
                #[tsify(rename = "First", rename = "Second")]
                struct DuplicateSameAttribute;
            "#},
            line: 4,
            message: "duplicate attribute",
        },
        CompileFailCase {
            module: "duplicate_separate_attributes",
            source: indoc! {r#"
                use tsify::Tsify;

                #[derive(Tsify)]
                #[tsify(rename = "First")]
                #[tsify(rename = "Second")]
                struct DuplicateSeparateAttributes;
            "#},
            line: 5,
            message: "duplicate attribute",
        },
        CompileFailCase {
            module: "empty",
            source: indoc! {r#"
                use tsify::Tsify;

                #[derive(Tsify)]
                #[tsify(rename = "")]
                struct Empty;
            "#},
            line: 4,
            message: "`rename` must be a valid TypeScript identifier",
        },
        CompileFailCase {
            module: "leading_digit",
            source: indoc! {r#"
                use tsify::Tsify;

                #[derive(Tsify)]
                #[tsify(rename = "1Name")]
                struct LeadingDigit;
            "#},
            line: 4,
            message: "`rename` must be a valid TypeScript identifier",
        },
        CompileFailCase {
            module: "invalid_character",
            source: indoc! {r#"
                use tsify::Tsify;

                #[derive(Tsify)]
                #[tsify(rename = "not-valid")]
                struct InvalidCharacter;
            "#},
            line: 4,
            message: "`rename` must be a valid TypeScript identifier",
        },
        CompileFailCase {
            module: "non_string",
            source: indoc! {r#"
                use tsify::Tsify;

                #[derive(Tsify)]
                #[tsify(rename = 123)]
                struct NonString;
            "#},
            line: 4,
            message: "expected string literal",
        },
        CompileFailCase {
            module: "value_missing",
            source: indoc! {r#"
                use tsify::Tsify;

                #[derive(Tsify)]
                #[tsify(rename)]
                struct ValueMissing;
            "#},
            line: 4,
            message: "expected `=`",
        },
        CompileFailCase {
            module: "rename_then_prefix",
            source: indoc! {r#"
                use tsify::Tsify;

                #[derive(Tsify)]
                #[tsify(rename = "Renamed", type_prefix = "Prefix")]
                struct RenameThenPrefix;
            "#},
            line: 4,
            message: "`rename` cannot be combined with `type_prefix` or `type_suffix`",
        },
        CompileFailCase {
            module: "prefix_then_rename",
            source: indoc! {r#"
                use tsify::Tsify;

                #[derive(Tsify)]
                #[tsify(type_prefix = "Prefix", rename = "Renamed")]
                struct PrefixThenRename;
            "#},
            line: 4,
            message: "`rename` cannot be combined with `type_prefix` or `type_suffix`",
        },
        CompileFailCase {
            module: "rename_then_suffix",
            source: indoc! {r#"
                use tsify::Tsify;

                #[derive(Tsify)]
                #[tsify(rename = "Renamed", type_suffix = "Suffix")]
                struct RenameThenSuffix;
            "#},
            line: 4,
            message: "`rename` cannot be combined with `type_prefix` or `type_suffix`",
        },
        CompileFailCase {
            module: "suffix_then_rename",
            source: indoc! {r#"
                use tsify::Tsify;

                #[derive(Tsify)]
                #[tsify(type_suffix = "Suffix", rename = "Renamed")]
                struct SuffixThenRename;
            "#},
            line: 4,
            message: "`rename` cannot be combined with `type_prefix` or `type_suffix`",
        },
    ];
    let modules = cases
        .iter()
        .map(|case| format!("mod {};", case.module))
        .collect::<Vec<_>>()
        .join("\n");
    fs::write(temp_crate.path().join("src/lib.rs"), modules)
        .expect("failed to write temporary lib.rs");
    for case in &cases {
        fs::write(
            temp_crate
                .path()
                .join("src")
                .join(format!("{}.rs", case.module)),
            case.source,
        )
        .unwrap_or_else(|error| panic!("failed to write {} fixture: {error}", case.module));
    }

    let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
    let output = Command::new(cargo)
        .args(["check", "--offline", "--quiet"])
        .current_dir(temp_crate.path())
        .env(
            "CARGO_TARGET_DIR",
            manifest_dir.join("target/tests/compile-fail"),
        )
        .output()
        .expect("failed to execute cargo check for compile-fail cases");
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(!output.status.success(), "fixture unexpectedly compiled");
    for case in &cases {
        assert_diagnostic(&stderr, case);
    }
}