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, relative_module_resolves_on_disk, should_skip_diagnostic,
should_skip_original_diagnostic,
};
use vize_carton::{FxHashMap, 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");
run_cli_for_config(corsa_path, project, &config_path, Some(available_threads()))
}
fn available_threads() -> usize {
std::thread::available_parallelism()
.map(std::num::NonZero::get)
.unwrap_or(1)
}
pub(super) fn check_with_cli_sharded(
corsa_path: &Path,
project: &VirtualProject,
servers: usize,
) -> CorsaResult<TypeCheckResult> {
let plan = partition_virtual_files(project, servers);
if plan.shards.len() <= 1 {
return check_with_cli(corsa_path, project);
}
let mut config_paths = Vec::with_capacity(plan.shards.len());
for (index, shard) in plan.shards.iter().enumerate() {
config_paths.push(profile!(
"canon.corsa.cli.write_shard_tsconfig",
project.write_shard_tsconfig(index, shard)
)?);
}
let owners = &plan.owners;
let checkers = (available_threads() / config_paths.len()).max(4);
let results = profile!("canon.corsa.cli.sharded", {
std::thread::scope(|scope| {
let handles: Vec<_> = config_paths
.iter()
.map(|config_path| {
scope.spawn(move || {
run_cli_for_config(corsa_path, project, config_path, Some(checkers))
})
})
.collect();
handles
.into_iter()
.map(|handle| {
handle.join().unwrap_or_else(|_| {
Err(CorsaError::CorsaExecution {
exit_code: -1,
message: "sharded corsa CLI worker panicked".into(),
})
})
})
.collect::<Vec<_>>()
})
});
let mut merged = TypeCheckResult {
exit_code: 0,
success: true,
diagnostics: Vec::new(),
};
for (index, result) in results.into_iter().enumerate() {
let result = result?;
merged.exit_code = merged.exit_code.max(result.exit_code);
merged.success = merged.success && result.success;
merged.diagnostics.extend(
result
.diagnostics
.into_iter()
.filter(|diagnostic| owners.get(&diagnostic.file).copied().unwrap_or(0) == index),
);
}
Ok(merged)
}
pub(super) fn auto_server_count(project: &VirtualProject) -> usize {
let vue_files = project
.virtual_files_sorted()
.iter()
.filter(|file| is_vue_original(&file.original_path))
.count();
let threads = std::thread::available_parallelism()
.map(std::num::NonZero::get)
.unwrap_or(1);
(threads / 4).min(vue_files / 64).clamp(1, 8)
}
struct ShardPlan<'a> {
shards: Vec<Vec<&'a Path>>,
owners: FxHashMap<PathBuf, usize>,
}
fn partition_virtual_files(project: &VirtualProject, servers: usize) -> ShardPlan<'_> {
let files = project.virtual_files_sorted();
let mut partitioned: Vec<&super::super::VirtualFile> = Vec::new();
let mut shared: Vec<&Path> = Vec::new();
for file in files {
let program_wide = project
.original_content_for_virtual(&file.virtual_path)
.is_some_and(declares_program_wide_types);
if program_wide || is_ambient_declaration(&file.original_path) {
shared.push(file.virtual_path.as_path());
} else {
partitioned.push(file);
}
}
let servers = servers.clamp(1, partitioned.len().max(1));
let no_sharding = ShardPlan {
shards: Vec::new(),
owners: FxHashMap::default(),
};
if servers <= 1 {
return no_sharding;
}
let index_by_virtual: FxHashMap<&Path, usize> = partitioned
.iter()
.enumerate()
.map(|(index, file)| (file.virtual_path.as_path(), index))
.collect();
let alias_prefixes = project.path_alias_prefixes();
let mut components = UnionFind::new(partitioned.len());
let mut coupling_keys: FxHashMap<String, usize> = FxHashMap::default();
let mut workspace_packages: FxHashMap<String, bool> = FxHashMap::default();
for (index, file) in partitioned.iter().enumerate() {
for specifier in import_specifiers(&file.content) {
if specifier.starts_with("./") || specifier.starts_with("../") {
let Some(base) = file.virtual_path.parent() else {
continue;
};
let target = normalize_join(base, specifier);
if let Some(target_index) = resolve_virtual_import(&target, &index_by_virtual) {
components.union(index, target_index);
} else {
let key = String::from(target.to_string_lossy());
match coupling_keys.get(key.as_str()) {
Some(&first) => components.union(index, first),
None => {
coupling_keys.insert(key, index);
}
}
}
} else if let Some(alias) = alias_prefixes
.iter()
.find(|alias| specifier.starts_with(alias.as_str()))
{
let key = cstr!("alias:{alias}");
match coupling_keys.get(key.as_str()) {
Some(&first) => components.union(index, first),
None => {
coupling_keys.insert(key, index);
}
}
} else if let Some(package) =
workspace_source_package(project.project_root(), specifier, &mut workspace_packages)
{
let key = cstr!("workspace:{package}");
match coupling_keys.get(key.as_str()) {
Some(&first) => components.union(index, first),
None => {
coupling_keys.insert(key, index);
}
}
}
}
}
let mut component_files: FxHashMap<usize, Vec<usize>> = FxHashMap::default();
for index in 0..partitioned.len() {
component_files
.entry(components.find(index))
.or_default()
.push(index);
}
let weight = |file_indices: &[usize]| -> usize {
file_indices
.iter()
.map(|&index| partitioned[index].content.len())
.sum()
};
let mut component_groups: Vec<Vec<usize>> = component_files.into_values().collect();
if component_groups.len() < 2 {
return no_sharding;
}
let total_weight: usize = component_groups.iter().map(|group| weight(group)).sum();
component_groups.sort_by(|left, right| {
weight(right)
.cmp(&weight(left))
.then_with(|| left.first().cmp(&right.first()))
});
let servers = servers.min(component_groups.len());
let mut bins: Vec<(usize, Vec<usize>)> = vec![(0, Vec::new()); servers];
for group in component_groups {
let bin = bins
.iter_mut()
.min_by_key(|(bin_weight, _)| *bin_weight)
.expect("at least one shard bin");
bin.0 += weight(&group);
bin.1.extend(group);
}
let largest = bins.iter().map(|(bin_weight, _)| *bin_weight).max();
if largest.unwrap_or(0) * 4 >= total_weight * 3 {
return no_sharding;
}
let mut shards: Vec<Vec<&Path>> = Vec::with_capacity(bins.len());
let mut owners = FxHashMap::default();
for (shard_index, (_, file_indices)) in bins.into_iter().enumerate() {
let mut include = shared.clone();
for file_index in file_indices {
let file = partitioned[file_index];
include.push(file.virtual_path.as_path());
owners.insert(file.original_path.clone(), shard_index);
}
shards.push(include);
}
ShardPlan { shards, owners }
}
fn import_specifiers(content: &str) -> Vec<&str> {
let mut specifiers = Vec::new();
for token in ["from ", "import(", "import ", "require("] {
for (at, _) in content.match_indices(token) {
let rest = content[at + token.len()..].trim_start();
let Some(quote) = rest.chars().next().filter(|ch| matches!(ch, '\'' | '"')) else {
continue;
};
let rest = &rest[1..];
let Some(end) = rest.find(quote) else {
continue;
};
specifiers.push(&rest[..end]);
}
}
specifiers
}
fn workspace_source_package<'spec>(
project_root: &Path,
specifier: &'spec str,
cache: &mut FxHashMap<String, bool>,
) -> Option<&'spec str> {
let mut segments = specifier.splitn(3, '/');
let first = segments.next()?;
let package_end = if first.starts_with('@') {
first.len() + 1 + segments.next()?.len()
} else {
first.len()
};
let package = &specifier[..package_end];
if let Some(&is_workspace) = cache.get(package) {
return is_workspace.then_some(package);
}
let mut is_workspace = false;
let mut dir = Some(project_root);
while let Some(current) = dir {
let candidate = current.join("node_modules").join(package);
if let Ok(metadata) = std::fs::symlink_metadata(&candidate) {
if metadata.file_type().is_symlink()
&& let Ok(target) = std::fs::canonicalize(&candidate)
{
is_workspace = !target
.components()
.any(|component| component.as_os_str() == "node_modules");
}
break;
}
dir = current.parent();
}
cache.insert(String::from(package), is_workspace);
is_workspace.then_some(package)
}
fn normalize_join(base: &Path, specifier: &str) -> PathBuf {
let mut normalized = base.to_path_buf();
for component in Path::new(specifier).components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
normalized.pop();
}
other => normalized.push(other.as_os_str()),
}
}
normalized
}
struct UnionFind {
parent: Vec<usize>,
}
impl UnionFind {
fn new(size: usize) -> Self {
Self {
parent: (0..size).collect(),
}
}
fn find(&mut self, node: usize) -> usize {
let mut root = node;
while self.parent[root] != root {
root = self.parent[root];
}
let mut current = node;
while self.parent[current] != root {
let next = self.parent[current];
self.parent[current] = root;
current = next;
}
root
}
fn union(&mut self, left: usize, right: usize) {
let left_root = self.find(left);
let right_root = self.find(right);
if left_root != right_root {
self.parent[right_root] = left_root;
}
}
}
fn is_vue_original(path: &Path) -> bool {
path.extension().is_some_and(|extension| extension == "vue")
}
fn is_ambient_declaration(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(".d.ts"))
}
fn declares_program_wide_types(content: &str) -> bool {
content.contains("declare module") || content.contains("declare global")
}
fn resolve_virtual_import(
target: &Path,
index_by_virtual: &FxHashMap<&Path, usize>,
) -> Option<usize> {
if let Some(&index) = index_by_virtual.get(target) {
return Some(index);
}
let target_str = target.to_string_lossy();
for suffix in [".ts", ".tsx", ".d.ts", "/index.ts", "/index.tsx"] {
let candidate = PathBuf::from(cstr!("{target_str}{suffix}").as_str());
if let Some(&index) = index_by_virtual.get(candidate.as_path()) {
return Some(index);
}
}
None
}
fn run_cli_for_config(
corsa_path: &Path,
project: &VirtualProject,
config_path: &Path,
checkers: Option<usize>,
) -> CorsaResult<TypeCheckResult> {
let output = profile!("canon.corsa.cli.command", {
let mut command = Command::new(corsa_path);
command.current_dir(project.virtual_root());
if let Some(checkers) = checkers {
command.arg("--checkers").arg(cstr!("{checkers}").as_str());
}
command
.arg("--pretty")
.arg("false")
.arg("--project")
.arg(config_path)
.output()
})?;
let diagnostics = profile!(
"canon.corsa.cli.parse",
parse_output_diagnostics(&output, project)
);
if checkers.is_some()
&& !output.status.success()
&& diagnostics.iter().any(|diagnostic| {
diagnostic.code == Some(5023) && diagnostic.message.contains("checkers")
})
{
return run_cli_for_config(corsa_path, project, config_path, None);
}
let success = output.status.success()
&& diagnostics
.iter()
.all(|diagnostic| diagnostic.severity != 1);
if !output.status.success()
&& diagnostics.is_empty()
&& !output_contains_diagnostic_lines(&output)
{
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 let Some(diagnostic) = parse_global_diagnostic_line(line, project) {
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_global_diagnostic_line(line: &str, project: &VirtualProject) -> Option<Diagnostic> {
let (severity, rest) = line.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")?.parse::<u32>().ok()?;
if should_skip_diagnostic(Some(code), message) {
return None;
}
Some(Diagnostic {
file: project.project_diagnostics_anchor(),
line: 0,
column: 0,
message: message.into(),
code: Some(code),
severity,
block_type: None,
})
}
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, message) {
return None;
}
if code == Some(6133) && !mapper.preserves_unused_diagnostics() {
return None;
}
let virtual_path = normalize_cli_path(path, project.virtual_root());
let original = mapper.map_to_original(&virtual_path, line, column)?;
if should_skip_original_diagnostic(code, &original) {
return None;
}
if code == Some(2307) && relative_module_resolves_on_disk(message, &original.path) {
return None;
}
Some(Diagnostic {
file: original.path,
line: original.line,
column: original.column,
message: message.into(),
code,
severity,
block_type: original.block_type,
})
}
fn output_contains_diagnostic_lines(output: &Output) -> bool {
[&output.stdout, &output.stderr].into_iter().any(|stream| {
#[allow(clippy::disallowed_types)]
let text = std::string::String::from_utf8_lossy(stream);
text.lines()
.any(|line| is_cli_diagnostic_line(line) || is_global_diagnostic_line(line))
})
}
fn is_global_diagnostic_line(line: &str) -> bool {
let Some(rest) = line
.strip_prefix("error ")
.or_else(|| line.strip_prefix("warning "))
.or_else(|| line.strip_prefix("info "))
else {
return false;
};
let Some(code) = rest.strip_prefix("TS") else {
return false;
};
let digits = code.bytes().take_while(u8::is_ascii_digit).count();
digits > 0 && code[digits..].starts_with(':')
}
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::{is_cli_diagnostic_line, is_global_diagnostic_line, 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 partitions_vue_files_and_shares_program_wide_sources() {
use super::partition_virtual_files;
let case_dir = unique_case_dir("shard-partition");
let _ = fs::remove_dir_all(&case_dir);
let src = case_dir.join("src");
fs::create_dir_all(&src).unwrap();
for index in 0..4 {
fs::write(
src.join(cstr!("Comp{index}.vue").as_str()),
"<script setup lang=\"ts\">const n = 1</script><template>{{ n }}</template>",
)
.unwrap();
}
fs::write(
src.join("Augment.vue"),
"<script lang=\"ts\">declare global { interface Window { __x?: number } }\nexport default {}</script>",
)
.unwrap();
fs::write(src.join("util.ts"), "export const util = 1;\n").unwrap();
let mut project = crate::batch::VirtualProject::new(&case_dir).unwrap();
let paths: Vec<_> = fs::read_dir(&src)
.unwrap()
.map(|entry| entry.unwrap().path())
.collect();
project.register_paths(&paths).unwrap();
let plan = partition_virtual_files(&project, 2);
assert_eq!(plan.shards.len(), 2);
assert_eq!(plan.owners.len(), 5);
assert!(plan.owners.values().any(|&shard| shard == 0));
assert!(plan.owners.values().any(|&shard| shard == 1));
let util_owner = plan.owners.get(&src.join("util.ts")).copied();
assert!(util_owner.is_some(), "plain sources are partitioned too");
for shard in &plan.shards {
assert!(
shard
.iter()
.any(|path| path.to_string_lossy().ends_with("Augment.vue.ts")),
"program-wide augmentations must be visible to every shard"
);
}
let _ = fs::remove_dir_all(&case_dir);
}
#[test]
fn shards_along_import_graph_components() {
use super::partition_virtual_files;
let case_dir = unique_case_dir("shard-components");
let _ = fs::remove_dir_all(&case_dir);
let src = case_dir.join("src");
fs::create_dir_all(&src).unwrap();
fs::write(
src.join("A.vue"),
"<script setup lang=\"ts\">import B from './B.vue'\nvoid B</script><template><B /></template>",
)
.unwrap();
for name in ["B", "C", "D"] {
fs::write(
src.join(cstr!("{name}.vue").as_str()),
"<script setup lang=\"ts\">const n = 1</script><template>{{ n }}</template>",
)
.unwrap();
}
let mut project = crate::batch::VirtualProject::new(&case_dir).unwrap();
let paths: Vec<_> = fs::read_dir(&src)
.unwrap()
.map(|entry| entry.unwrap().path())
.collect();
project.register_paths(&paths).unwrap();
let plan = partition_virtual_files(&project, 2);
assert_eq!(plan.shards.len(), 2);
let owner_a = plan.owners.get(&src.join("A.vue")).copied();
let owner_b = plan.owners.get(&src.join("B.vue")).copied();
assert!(owner_a.is_some());
assert_eq!(owner_a, owner_b);
fs::write(
src.join("C.vue"),
"<script setup lang=\"ts\">import A from './A.vue'\nvoid A</script><template><A /></template>",
)
.unwrap();
let mut project = crate::batch::VirtualProject::new(&case_dir).unwrap();
let paths: Vec<_> = fs::read_dir(&src)
.unwrap()
.map(|entry| entry.unwrap().path())
.collect();
project.register_paths(&paths).unwrap();
let plan = partition_virtual_files(&project, 2);
assert!(
plan.shards.is_empty(),
"a dominant component must collapse to a single program"
);
let _ = fs::remove_dir_all(&case_dir);
}
#[test]
fn recognizes_global_and_positioned_diagnostic_lines() {
assert!(is_global_diagnostic_line(
"error TS2688: Cannot find type definition file for 'vite/client'."
));
assert!(is_global_diagnostic_line("warning TS1: w"));
assert!(!is_global_diagnostic_line(
" The file is in the program because:"
));
assert!(!is_global_diagnostic_line("error: missing argument"));
assert!(!is_global_diagnostic_line("error TSX: nope"));
assert!(is_cli_diagnostic_line(
"src/App.vue.ts(3,7): error TS2322: Type 'string' is not assignable to type 'number'."
));
assert!(!is_cli_diagnostic_line(
"error TS2688: Cannot find type definition file for 'vite/client'."
));
}
#[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);
}
}