vize_canon 0.142.0

Canon - The standard of correctness for Vize type checking
Documentation
use std::{
    path::{Path, PathBuf},
    process::{Command, Output},
};

use super::super::{Diagnostic, TypeCheckResult, VirtualProject};
use crate::batch::error::{CorsaError, CorsaResult};
use crate::batch::executor::diagnostics::{DiagnosticMapper, should_skip_diagnostic};
use vize_carton::profile;
use vize_carton::{String, cstr};

pub(super) fn check_with_cli(
    corsa_path: &Path,
    project: &VirtualProject,
) -> CorsaResult<TypeCheckResult> {
    let config_path = project.virtual_root().join("tsconfig.json");
    let output = profile!(
        "canon.corsa.cli.command",
        Command::new(corsa_path)
            .current_dir(project.virtual_root())
            .arg("--pretty")
            .arg("false")
            .arg("--project")
            .arg(&config_path)
            .output()
    )?;
    let diagnostics = profile!(
        "canon.corsa.cli.parse",
        parse_output_diagnostics(&output, project)
    );
    let success = output.status.success()
        && diagnostics
            .iter()
            .all(|diagnostic| diagnostic.severity != 1);

    if !output.status.success() && diagnostics.is_empty() {
        return Err(CorsaError::CorsaExecution {
            exit_code: output.status.code().unwrap_or(-1),
            message: output_message(&output),
        });
    }

    Ok(TypeCheckResult {
        exit_code: output.status.code().unwrap_or(if success { 0 } else { 1 }),
        success,
        diagnostics,
    })
}

fn parse_output_diagnostics(output: &Output, project: &VirtualProject) -> Vec<Diagnostic> {
    let mut diagnostics = Vec::new();
    let mut mapper = DiagnosticMapper::new(project);
    #[allow(clippy::disallowed_types)]
    let stdout = std::string::String::from_utf8_lossy(&output.stdout);
    parse_cli_diagnostics(stdout.as_ref(), project, &mut mapper, &mut diagnostics);
    #[allow(clippy::disallowed_types)]
    let stderr = std::string::String::from_utf8_lossy(&output.stderr);
    parse_cli_diagnostics(stderr.as_ref(), project, &mut mapper, &mut diagnostics);
    diagnostics
}

fn parse_cli_diagnostics(
    output: &str,
    project: &VirtualProject,
    mapper: &mut DiagnosticMapper<'_>,
    diagnostics: &mut Vec<Diagnostic>,
) {
    for line in output.lines() {
        if let Some(diagnostic) = parse_cli_diagnostic_line(line, project, mapper) {
            diagnostics.push(diagnostic);
            continue;
        }
        if is_cli_diagnostic_line(line) {
            continue;
        }

        let Some(last) = diagnostics.last_mut() else {
            continue;
        };
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        last.message.push('\n');
        last.message.push_str(line);
    }
}

fn parse_cli_diagnostic_line(
    line: &str,
    project: &VirtualProject,
    mapper: &mut DiagnosticMapper<'_>,
) -> Option<Diagnostic> {
    let (prefix, suffix) = line.split_once("): ")?;
    let open = prefix.rfind('(')?;
    let path = &prefix[..open];
    let position = &prefix[open + 1..];
    let (line, column) = position.split_once(',')?;
    let line = line.parse::<u32>().ok()?.saturating_sub(1);
    let column = column.parse::<u32>().ok()?.saturating_sub(1);

    let (severity, rest) = suffix.split_once(' ')?;
    let severity = match severity {
        "error" => 1,
        "warning" => 2,
        "info" => 3,
        _ => return None,
    };
    let (code, message) = rest.split_once(": ")?;
    let code = code
        .strip_prefix("TS")
        .and_then(|code| code.parse::<u32>().ok());
    if should_skip_diagnostic(code) {
        return None;
    }

    let virtual_path = normalize_cli_path(path, project.virtual_root());
    let original = mapper.map_to_original(&virtual_path, line, column)?;
    Some(Diagnostic {
        file: original.path,
        line: original.line,
        column: original.column,
        message: message.into(),
        code,
        severity,
        block_type: original.block_type,
    })
}

fn is_cli_diagnostic_line(line: &str) -> bool {
    let Some((prefix, suffix)) = line.split_once("): ") else {
        return false;
    };
    let Some(open) = prefix.rfind('(') else {
        return false;
    };
    let position = &prefix[open + 1..];
    let Some((line, column)) = position.split_once(',') else {
        return false;
    };
    if line.parse::<u32>().is_err() || column.parse::<u32>().is_err() {
        return false;
    }

    matches!(
        suffix.split_once(' ').map(|(severity, _)| severity),
        Some("error" | "warning" | "info")
    )
}

fn normalize_cli_path(path: &str, virtual_root: &Path) -> PathBuf {
    let path = PathBuf::from(path);
    if path.is_absolute() {
        path
    } else {
        virtual_root.join(path)
    }
}

fn output_message(output: &Output) -> String {
    #[allow(clippy::disallowed_types)]
    let stderr = std::string::String::from_utf8_lossy(&output.stderr);
    #[allow(clippy::disallowed_types)]
    let stdout = std::string::String::from_utf8_lossy(&output.stdout);
    let stderr = stderr.trim();
    let stdout = stdout.trim();
    if stderr.is_empty() {
        return stdout.to_owned().into();
    }
    if stdout.is_empty() {
        return stderr.to_owned().into();
    }
    cstr!("{}\n{}", stderr, stdout)
}

#[cfg(test)]
mod tests {
    use super::parse_cli_diagnostics;
    use crate::batch::VirtualProject;
    use crate::batch::executor::diagnostics::DiagnosticMapper;
    use std::{
        fs,
        path::PathBuf,
        sync::atomic::{AtomicUsize, Ordering},
    };
    use vize_carton::cstr;

    fn unique_case_dir(name: &str) -> PathBuf {
        static NEXT_CASE_ID: AtomicUsize = AtomicUsize::new(0);

        let case_id = NEXT_CASE_ID.fetch_add(1, Ordering::Relaxed);
        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("target")
            .join("vize-tests")
            .join("tests")
            .join(&*cstr!(
                "cli-fallback-{name}-{}-{case_id}",
                std::process::id()
            ))
    }

    #[test]
    fn parses_cli_diagnostics_back_to_original_files() {
        let case_dir = unique_case_dir("diagnostics");
        let _ = fs::remove_dir_all(&case_dir);
        let source = case_dir.join("src").join("main.ts");
        fs::create_dir_all(source.parent().unwrap()).unwrap();
        fs::write(&source, "const value: number = 'x';\n").unwrap();

        let mut project = VirtualProject::new(&case_dir).unwrap();
        project.register_path(&source).unwrap();
        project.materialize().unwrap();

        let output = cstr!(
            "{}(1,7): error TS2322: Type 'string' is not assignable to type 'number'.",
            project.virtual_root().join("src").join("main.ts").display()
        );
        let mut diagnostics = Vec::new();
        let mut mapper = DiagnosticMapper::new(&project);
        parse_cli_diagnostics(output.as_str(), &project, &mut mapper, &mut diagnostics);

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].file, source);
        assert_eq!(diagnostics[0].line, 0);
        assert_eq!(diagnostics[0].column, 6);
        assert_eq!(diagnostics[0].code, Some(2322));

        let _ = fs::remove_dir_all(&case_dir);
    }
}