use std::path::{Path, PathBuf};
use std::process::Command;
use super::error::{CorsaError, CorsaNotFoundError, CorsaResult};
use super::import_rewriter::ImportRewriter;
use super::type_checker::{
DeclarationEmitOptions, DeclarationEmitResult, DeclarationOutput, TypeCheckResult,
};
use super::virtual_project::VirtualProject;
use crate::{
corsa_client::CorsaProjectClient,
file_uri::path_to_file_uri,
lsp_client::paths::{corsa_search_roots, find_corsa_in_search_roots},
};
use oxc_span::SourceType;
use vize_carton::{String, cstr, profile};
mod cli;
mod diagnostics;
use cli::check_with_cli;
use diagnostics::map_batch_diagnostics;
pub struct CorsaExecutor {
corsa_path: PathBuf,
}
impl CorsaExecutor {
pub fn new(project_root: &Path) -> Result<Self, CorsaNotFoundError> {
Self::with_corsa_path(project_root, None)
}
pub fn with_corsa_path(
project_root: &Path,
corsa_path: Option<&Path>,
) -> Result<Self, CorsaNotFoundError> {
if let Some(path) = corsa_path {
let resolved_path = resolve_explicit_corsa_path(project_root, path);
if !resolved_path.exists() {
return Err(CorsaNotFoundError::new_explicit(
project_root,
&resolved_path,
));
}
let corsa_path = normalize_corsa_path(resolved_path.clone()).unwrap_or(resolved_path);
let corsa_path = corsa_path.canonicalize().unwrap_or(corsa_path);
return Ok(Self { corsa_path });
}
for env_name in ["CORSA_PATH", "TSGO_PATH"] {
if let Some(path) = std::env::var_os(env_name).map(PathBuf::from) {
let resolved_path = resolve_explicit_corsa_path(project_root, &path);
if !resolved_path.exists() {
return Err(CorsaNotFoundError::new_explicit(
project_root,
&resolved_path,
));
}
let corsa_path =
normalize_corsa_path(resolved_path.clone()).unwrap_or(resolved_path);
let corsa_path = corsa_path.canonicalize().unwrap_or(corsa_path);
return Ok(Self { corsa_path });
}
}
let search_roots = corsa_search_roots(Some(project_root));
if let Some(local_corsa) = find_corsa_in_search_roots(&search_roots)
&& let Some(corsa_path) = normalize_corsa_path(PathBuf::from(local_corsa.as_str()))
{
return Ok(Self { corsa_path });
}
for executable in ["corsa", "tsgo"] {
if let Ok(global_corsa) = which::which(executable)
&& let Some(corsa_path) = normalize_corsa_path(global_corsa)
{
return Ok(Self { corsa_path });
}
}
Err(CorsaNotFoundError::new(project_root))
}
pub fn corsa_path(&self) -> &Path {
&self.corsa_path
}
pub fn check(&self, project: &VirtualProject) -> CorsaResult<TypeCheckResult> {
profile!("canon.executor.materialize", project.materialize())?;
match profile!("canon.corsa.cli", check_with_cli(&self.corsa_path, project)) {
Ok(result) => return Ok(result),
Err(_cli_error) => {
}
}
self.check_with_project_session(project)
}
fn check_with_project_session(&self, project: &VirtualProject) -> CorsaResult<TypeCheckResult> {
let corsa_path = self.corsa_path.to_string_lossy();
let mut client = match profile!(
"canon.corsa.session",
CorsaProjectClient::new_for_workspace(
Some(corsa_path.as_ref()),
project.virtual_root()
)
) {
Ok(client) => client,
Err(error) if should_fallback_to_cli(&error) => {
return profile!(
"canon.corsa.cli_fallback",
check_with_cli(&self.corsa_path, project)
);
}
Err(error) => return Err(map_corsa_error(error)),
};
let uris = profile!(
"canon.corsa.collect_uris",
collect_virtual_file_uris(project.virtual_root())
)?;
let raw_diagnostics = profile!(
"canon.corsa.diagnostics",
client
.request_diagnostics_batch(&uris)
.map_err(map_corsa_error)
)?;
let diagnostics = profile!(
"canon.corsa.map_diagnostics",
map_batch_diagnostics(raw_diagnostics, project)
);
let success = diagnostics
.iter()
.all(|diagnostic| diagnostic.severity != 1);
Ok(TypeCheckResult {
exit_code: if success { 0 } else { 1 },
success,
diagnostics,
})
}
pub fn emit_declarations(
&self,
project: &VirtualProject,
options: &DeclarationEmitOptions,
) -> CorsaResult<DeclarationEmitResult> {
profile!("canon.executor.materialize_dts", project.materialize())?;
let config_path = profile!(
"canon.project.dts_tsconfig",
project.write_declaration_tsconfig(options.out_dir.as_path(), options.declaration_map)
)?;
let output = profile!(
"canon.corsa.emit_dts",
Command::new(&self.corsa_path)
.current_dir(project.virtual_root())
.arg("--pretty")
.arg("false")
.arg("--project")
.arg(&config_path)
.output()
)?;
if !output.status.success() {
let exit_code = output.status.code().unwrap_or(-1);
#[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 message = if stderr.trim().is_empty() {
stdout.trim().to_owned().into()
} else if stdout.trim().is_empty() {
stderr.trim().to_owned().into()
} else {
cstr!("{}\n{}", stderr.trim(), stdout.trim())
};
return Err(CorsaError::CorsaExecution { exit_code, message });
}
profile!(
"canon.dts.rewrite_outputs",
rewrite_declaration_outputs(options.out_dir.as_path())
)?;
Ok(DeclarationEmitResult {
files: profile!(
"canon.dts.collect_outputs",
collect_declaration_outputs(options.out_dir.as_path())
)?,
})
}
}
fn resolve_explicit_corsa_path(project_root: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
return path.to_path_buf();
}
let project_candidate = project_root.join(path);
if project_candidate.exists() {
return project_candidate;
}
if let Ok(cwd) = std::env::current_dir() {
let cwd_candidate = cwd.join(path);
if cwd_candidate.exists() {
return cwd_candidate;
}
}
project_candidate
}
fn normalize_corsa_path(path: PathBuf) -> Option<PathBuf> {
let Some(bin_dir) = path.parent() else {
return Some(path);
};
if bin_dir.file_name().and_then(|name| name.to_str()) != Some(".bin") {
return Some(path);
}
let Some(node_modules_dir) = bin_dir.parent() else {
return Some(path);
};
if node_modules_dir.file_name().and_then(|name| name.to_str()) != Some("node_modules") {
return Some(path);
}
let Some(project_root) = node_modules_dir.parent() else {
return Some(path);
};
find_corsa_in_search_roots(&corsa_search_roots(Some(project_root)))
.map(|resolved| PathBuf::from(resolved.as_str()))
.filter(|resolved| resolved != &path)
.or(Some(path))
}
fn collect_virtual_file_uris(virtual_root: &Path) -> CorsaResult<Vec<String>> {
let mut uris = Vec::new();
for entry in walkdir::WalkDir::new(virtual_root) {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
if let Some("ts" | "tsx") = path.extension().and_then(|extension| extension.to_str()) {
uris.push(path_to_file_uri(path));
}
}
uris.sort();
Ok(uris)
}
fn collect_declaration_outputs(out_dir: &Path) -> CorsaResult<Vec<DeclarationOutput>> {
let mut files = Vec::new();
let rewriter = ImportRewriter::new();
if !out_dir.exists() {
return Ok(files);
}
for entry in walkdir::WalkDir::new(out_dir) {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
if !path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(".d.ts"))
{
continue;
}
let content = std::fs::read_to_string(path)?;
files.push(DeclarationOutput {
path: path.to_path_buf(),
content: rewriter
.rewrite_declaration_specifiers(&content, SourceType::ts())
.code,
});
}
files.sort_by(|left, right| left.path.cmp(&right.path));
Ok(files)
}
fn rewrite_declaration_outputs(out_dir: &Path) -> CorsaResult<()> {
let rewriter = ImportRewriter::new();
if !out_dir.exists() {
return Ok(());
}
for entry in walkdir::WalkDir::new(out_dir) {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
if !path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(".d.ts"))
{
continue;
}
let content = std::fs::read_to_string(path)?;
let rewritten = rewriter
.rewrite_declaration_specifiers(&content, SourceType::ts())
.code;
if rewritten.as_str() != content {
std::fs::write(path, rewritten.as_str())?;
}
}
Ok(())
}
fn map_corsa_error(message: String) -> CorsaError {
CorsaError::CorsaExecution {
exit_code: -1,
message,
}
}
fn should_fallback_to_cli(error: &str) -> bool {
error.contains("expected tuple marker")
|| error.contains("expected uint8 marker")
|| error.contains("expected bin marker")
|| error.contains("process is closed: jsonrpc reader")
|| error.contains("Broken pipe")
|| error.contains("broken pipe")
}
#[cfg(test)]
mod tests {
use super::{
CorsaExecutor, collect_declaration_outputs, collect_virtual_file_uris, normalize_corsa_path,
};
use crate::file_uri::path_to_file_uri;
use std::{
fs,
path::PathBuf,
sync::atomic::{AtomicUsize, Ordering},
};
use vize_carton::cstr;
use tempfile::TempDir;
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("__agent_only")
.join("tests")
.join(&*cstr!(
"corsa-executor-{name}-{}-{case_id}",
std::process::id()
))
}
#[test]
fn collects_virtual_type_script_files_only() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
fs::write(root.join("index.ts"), "").unwrap();
fs::write(root.join("component.vue.ts"), "").unwrap();
fs::write(root.join("tsconfig.json"), "{}").unwrap();
fs::write(root.join("ignored.js"), "").unwrap();
let uris = collect_virtual_file_uris(root).unwrap();
assert_eq!(
uris,
vec![
path_to_file_uri(root.join("component.vue.ts").as_path()),
path_to_file_uri(root.join("index.ts").as_path()),
]
);
}
#[test]
fn encodes_reserved_characters_in_virtual_file_uris() {
let root = unique_case_dir("reserved-uri");
let _ = fs::remove_dir_all(&root);
let route_dir = root.join("pages").join("[[org]]").join("[packageName]");
fs::create_dir_all(&route_dir).unwrap();
fs::write(route_dir.join("[versionRange].vue.ts"), "").unwrap();
let uris = collect_virtual_file_uris(root.as_path()).unwrap();
let _ = fs::remove_dir_all(&root);
assert_eq!(
uris,
vec![path_to_file_uri(
route_dir.join("[versionRange].vue.ts").as_path()
)]
);
assert!(uris[0].contains("%5B%5Borg%5D%5D"));
assert!(uris[0].contains("%5BpackageName%5D"));
assert!(uris[0].contains("%5BversionRange%5D.vue.ts"));
}
#[test]
fn normalizes_node_modules_bin_wrapper_to_native_preview_binary() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let wrapper = root.join("node_modules/.bin/tsgo");
let native = root
.join("node_modules")
.join("@typescript")
.join("native-preview")
.join("lib")
.join("tsgo");
fs::create_dir_all(wrapper.parent().unwrap()).unwrap();
fs::create_dir_all(native.parent().unwrap()).unwrap();
fs::write(&wrapper, "").unwrap();
fs::write(&native, "").unwrap();
assert_eq!(normalize_corsa_path(wrapper), Some(native));
}
#[test]
fn falls_back_to_project_cache_when_wrapper_lacks_native_binary() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let wrapper = root.join("node_modules/.bin/tsgo");
let cache = root.join(".cache").join("tsgo");
fs::create_dir_all(wrapper.parent().unwrap()).unwrap();
fs::create_dir_all(cache.parent().unwrap()).unwrap();
fs::write(&wrapper, "").unwrap();
fs::write(&cache, "").unwrap();
assert_eq!(normalize_corsa_path(wrapper), Some(cache));
}
#[test]
fn uses_explicit_corsa_path() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path().join("project");
let explicit = temp_dir.path().join("bin").join("tsgo");
fs::create_dir_all(&project_root).unwrap();
fs::create_dir_all(explicit.parent().unwrap()).unwrap();
fs::write(&explicit, "").unwrap();
let executor = CorsaExecutor::with_corsa_path(&project_root, Some(&explicit)).unwrap();
assert_eq!(executor.corsa_path(), explicit.canonicalize().unwrap());
}
#[test]
fn resolves_relative_explicit_corsa_path_against_project_root() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path().join("project");
let explicit = project_root.join("bin").join("tsgo");
fs::create_dir_all(explicit.parent().unwrap()).unwrap();
fs::write(&explicit, "").unwrap();
let executor = CorsaExecutor::with_corsa_path(
&project_root,
Some(PathBuf::from("bin/tsgo").as_path()),
)
.unwrap();
assert_eq!(executor.corsa_path(), explicit.canonicalize().unwrap());
}
#[test]
fn collects_emitted_declaration_outputs() {
let temp_dir = TempDir::new().unwrap();
let out_dir = temp_dir.path().join("dist/types");
fs::create_dir_all(&out_dir).unwrap();
fs::write(out_dir.join("App.vue.d.ts"), "export {};\n").unwrap();
fs::write(out_dir.join("skip.js"), "").unwrap();
let files = collect_declaration_outputs(&out_dir).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, out_dir.join("App.vue.d.ts"));
assert_eq!(files[0].content, "export {};\n");
}
#[cfg(unix)]
#[test]
fn checks_with_cli_when_project_session_api_is_unavailable() {
use crate::batch::VirtualProject;
use std::os::unix::fs::PermissionsExt;
let case_dir = unique_case_dir("cli-fallback");
let _ = fs::remove_dir_all(&case_dir);
let cache_dir = case_dir.join(".cache");
let source = case_dir.join("src").join("main.ts");
fs::create_dir_all(&cache_dir).unwrap();
fs::create_dir_all(source.parent().unwrap()).unwrap();
fs::write(&source, "const value: number = 1;\n").unwrap();
let tsgo = cache_dir.join("tsgo");
fs::write(
&tsgo,
"#!/bin/sh\nif [ \"$1\" = \"--api\" ]; then printf 'api unavailable'; exit 0; fi\nexit 0\n",
)
.unwrap();
fs::set_permissions(&tsgo, fs::Permissions::from_mode(0o755)).unwrap();
let mut project = VirtualProject::new(&case_dir).unwrap();
project.register_path(&source).unwrap();
let executor = CorsaExecutor::new(&case_dir).unwrap();
let result = executor.check(&project).unwrap();
assert!(result.success);
assert!(result.diagnostics.is_empty());
let _ = fs::remove_dir_all(&case_dir);
}
}