#![allow(clippy::disallowed_types)]
use super::{CorsaProjectClient, utils::remap_serialized_uris};
use crate::file_uri::{file_uri_to_path, path_to_file_uri};
use corsa::{
CorsaError,
api::{
ApiMode, ApiSpawnConfig, CapabilitiesResponse, DocumentIdentifier, FileChangeSummary,
FileChanges, OverlayChanges, OverlayUpdate, ProjectSession,
},
fast::CompactString,
runtime::block_on,
};
use lsp_types::Diagnostic;
use serde_json::Value;
use std::{
path::{Component, Path, PathBuf},
sync::Arc,
};
use vize_carton::{String, cstr};
pub(super) fn spawn_project_session(
executable: &str,
cwd: &Path,
project_root: &Path,
) -> Result<(ProjectSession, Arc<CapabilitiesResponse>), String> {
let config_path = project_root.join("tsconfig.json");
let config_path_wire = config_path.to_string_lossy();
let mode = api_mode_for_executable(executable);
let session = match block_on(spawn_project_session_with_mode(
executable,
cwd,
config_path_wire.as_ref(),
mode,
)) {
Ok(session) => session,
Err(error) if should_retry_json_rpc(mode, &error) => block_on(
spawn_project_session_with_mode(
executable,
cwd,
config_path_wire.as_ref(),
ApiMode::AsyncJsonRpcStdio,
),
)
.map_err(|fallback| {
cstr!("Failed to start Corsa API session: {fallback} (after msgpack error: {error})")
})?,
Err(error) => {
return Err(cstr!("Failed to start Corsa API session: {error}"));
}
};
let capabilities = block_on(session.describe_capabilities())
.unwrap_or_else(|_| Arc::new(CapabilitiesResponse::default()));
Ok((session, capabilities))
}
async fn spawn_project_session_with_mode(
executable: &str,
cwd: &Path,
config_path: &str,
mode: ApiMode,
) -> Result<ProjectSession, CorsaError> {
ProjectSession::spawn(
ApiSpawnConfig::new(executable)
.with_mode(mode)
.with_cwd(cwd),
config_path,
None,
)
.await
}
fn should_retry_json_rpc(mode: ApiMode, error: &CorsaError) -> bool {
if mode != ApiMode::SyncMsgpackStdio {
return false;
}
let CorsaError::Protocol(message) = error else {
return false;
};
let message = message.as_str();
message.contains("expected tuple marker")
|| message.contains("expected uint8 marker")
|| message.contains("expected bin marker")
}
fn api_mode_for_executable(executable: &str) -> ApiMode {
if is_node_wrapper_executable(Path::new(executable)) {
ApiMode::AsyncJsonRpcStdio
} else {
ApiMode::SyncMsgpackStdio
}
}
fn is_node_wrapper_executable(path: &Path) -> bool {
if path.extension().and_then(|extension| extension.to_str()) == Some("js") {
return true;
}
if path
.parent()
.and_then(|parent| parent.file_name())
.and_then(|name| name.to_str())
== Some(".bin")
{
return true;
}
let Some(parent) = path.parent() else {
return false;
};
let Some(grandparent) = parent.parent() else {
return false;
};
parent.file_name().and_then(|name| name.to_str()) == Some("bin")
&& grandparent.file_name().and_then(|name| name.to_str()) == Some("native-preview")
}
pub(super) fn uri_document_identifier(uri: &str) -> DocumentIdentifier {
if let Some(path) = file_uri_to_path(uri) {
let path = path.to_string_lossy();
return DocumentIdentifier::FileName(CompactString::from(path.as_ref()));
}
DocumentIdentifier::Uri {
uri: CompactString::from(uri),
}
}
impl CorsaProjectClient {
fn trusts_capabilities(&self) -> bool {
self.capabilities.runtime.capability_endpoint
}
pub(super) fn supports_overlay_api(&self) -> bool {
!self.trusts_capabilities() || self.capabilities.overlay.update_snapshot_overlay_changes
}
fn overlay_api_confirmed(&self) -> bool {
self.trusts_capabilities() && self.capabilities.overlay.update_snapshot_overlay_changes
}
pub(super) fn supports_project_diagnostics_api(&self) -> bool {
!self.trusts_capabilities() || self.capabilities.diagnostics.project
}
pub(super) fn supports_file_diagnostics_api(&self) -> bool {
!self.trusts_capabilities() || self.capabilities.diagnostics.file
}
pub(super) fn supports_hover_api(&self) -> bool {
!self.trusts_capabilities() || self.capabilities.editor.hover
}
pub(super) fn supports_definition_api(&self) -> bool {
!self.trusts_capabilities() || self.capabilities.editor.definition
}
pub(super) fn supports_references_api(&self) -> bool {
!self.trusts_capabilities() || self.capabilities.editor.references
}
pub(super) fn supports_rename_api(&self) -> bool {
!self.trusts_capabilities() || self.capabilities.editor.rename
}
pub(super) fn supports_completion_api(&self) -> bool {
!self.trusts_capabilities() || self.capabilities.editor.completion
}
pub(super) fn can_use_api_for_uri(&self, uri: &str) -> bool {
!self.document_texts.contains_key(uri) || self.supports_overlay_api()
}
pub(super) fn sync_overlay_document(&mut self, uri: &str, content: &str) -> Result<(), String> {
let previous = self.document_texts.insert(uri.into(), content.into());
let document_uri = self.session_document_uri(uri);
if previous.as_deref() == Some(content) {
return Ok(());
}
let file_changes = materialize_session_document(uri, document_uri.as_str(), content);
if document_uri != uri {
return block_on(self.session.refresh(file_changes))
.map_err(|error| cstr!("Failed to refresh Corsa snapshot: {error}"));
}
if !self.supports_overlay_api() {
return self.sync_materialized_overlay_document(uri, content);
}
let version = next_overlay_version(&mut self.overlay_versions, uri);
match block_on(self.session.refresh_with_overlay_changes(
file_changes,
Some(OverlayChanges {
upsert: vec![OverlayUpdate {
document: uri_document_identifier(document_uri.as_str()),
text: content.into(),
version: Some(version),
language_id: Some("typescript".into()),
}],
delete: Vec::new(),
}),
)) {
Ok(()) => Ok(()),
Err(error) if overlay_changes_error_is_unsupported(&error) => {
self.sync_materialized_overlay_document(uri, content)
}
Err(error) => Err(cstr!("Failed to sync Corsa overlay: {error}")),
}
}
fn sync_materialized_overlay_document(
&mut self,
uri: &str,
content: &str,
) -> Result<(), String> {
let document_uri = self
.materialized_session_document_uri(uri)
.ok_or_else(|| cstr!("Failed to derive materialized Corsa overlay path for {uri}"))?;
let file_changes = materialize_session_document(uri, document_uri.as_str(), content);
block_on(self.session.refresh(file_changes))
.map_err(|error| cstr!("Failed to refresh Corsa snapshot: {error}"))
}
pub(super) fn delete_overlay_document(&mut self, uri: &str) -> Result<(), String> {
self.document_texts.remove(uri);
self.overlay_versions.remove(uri);
let document_uri = self
.session_document_uris
.remove(uri)
.unwrap_or_else(|| self.session_document_uri(uri));
self.external_document_uris.remove(document_uri.as_str());
let file_changes = remove_session_document(uri, document_uri.as_str());
if document_uri != uri {
return block_on(self.session.refresh(file_changes))
.map_err(|error| cstr!("Failed to refresh Corsa snapshot: {error}"));
}
if !self.supports_overlay_api() {
return Ok(());
}
block_on(self.session.refresh_with_overlay_changes(
file_changes,
Some(OverlayChanges {
upsert: Vec::new(),
delete: vec![uri_document_identifier(document_uri.as_str())],
}),
))
.map_err(|error| cstr!("Failed to remove Corsa overlay: {error}"))
}
pub(super) fn utf16_offset_for(&self, uri: &str, line: u32, character: u32) -> Option<u32> {
self.document_texts
.get(uri)
.map(|content| line_character_to_utf16_offset(content.as_str(), line, character))
.or_else(|| {
load_file_text(uri)
.as_deref()
.map(|content| line_character_to_utf16_offset(content, line, character))
})
}
pub(super) fn session_document_uri(&mut self, uri: &str) -> String {
if let Some(mapped) = self.session_document_uris.get(uri) {
return mapped.clone();
}
let mapped =
build_session_document_uri(uri, &self.project_root, self.overlay_api_confirmed());
self.remember_session_document_uri(uri, mapped)
}
fn materialized_session_document_uri(&mut self, uri: &str) -> Option<String> {
let mapped = build_materialized_session_document_uri(uri, &self.project_root)?;
Some(self.remember_session_document_uri(uri, mapped))
}
fn remember_session_document_uri(&mut self, uri: &str, mapped: String) -> String {
if let Some(previous) = self
.session_document_uris
.insert(uri.into(), mapped.clone())
{
self.external_document_uris.remove(previous.as_str());
}
self.external_document_uris
.insert(mapped.clone(), uri.into());
mapped
}
pub(super) fn remap_diagnostics(&self, diagnostics: Vec<Diagnostic>) -> Vec<Diagnostic> {
remap_serialized_uris(diagnostics.clone(), &self.external_document_uris)
.unwrap_or(diagnostics)
}
pub(super) fn remap_result_uris(&self, value: &mut Value) {
super::utils::remap_json_uris(value, &self.external_document_uris);
}
}
fn next_overlay_version(versions: &mut vize_carton::FxHashMap<String, i32>, uri: &str) -> i32 {
let next = versions.get(uri).copied().unwrap_or(0).saturating_add(1);
versions.insert(uri.into(), next);
next
}
fn load_file_text(uri: &str) -> Option<String> {
let path = file_uri_to_path(uri)?;
std::fs::read_to_string(path).ok().map(Into::into)
}
pub(super) fn build_session_document_uri(
uri: &str,
project_root: &Path,
overlay_confirmed: bool,
) -> String {
let Some(external_path) = external_document_path(uri) else {
return uri.into();
};
if external_path.starts_with(project_root)
&& (external_path.exists()
|| (overlay_confirmed && virtual_overlay_target_exists(&external_path)))
{
return path_to_file_uri(&external_path);
}
path_to_file_uri(&materialized_session_path(&external_path, project_root))
}
fn build_materialized_session_document_uri(uri: &str, project_root: &Path) -> Option<String> {
let external_path = external_document_path(uri)?;
Some(path_to_file_uri(&materialized_session_path(
&external_path,
project_root,
)))
}
fn materialized_session_path(external_path: &Path, project_root: &Path) -> PathBuf {
let mut session_path = overlay_root_for_project(project_root);
for component in external_path.components() {
match component {
Component::Prefix(prefix) => session_path.push(prefix.as_os_str()),
Component::RootDir | Component::CurDir => {}
Component::ParentDir => session_path.push("__parent__"),
Component::Normal(part) => session_path.push(part),
}
}
session_path
}
fn virtual_overlay_target_exists(external_path: &Path) -> bool {
let Some(name) = external_path.file_name().and_then(|name| name.to_str()) else {
return false;
};
let Some(real_name) = name.strip_suffix(".ts") else {
return false;
};
if !real_name.ends_with(".vue") && !real_name.ends_with(".html") {
return false;
}
external_path.with_file_name(real_name).is_file()
}
fn overlay_root_for_project(project_root: &Path) -> PathBuf {
if is_under_node_modules_vize(project_root) {
return project_root.join("overlays");
}
project_root
.join("node_modules")
.join(".vize")
.join("corsa-overlay")
}
fn is_under_node_modules_vize(path: &Path) -> bool {
let mut previous = None;
for component in path.components() {
let Some(name) = component.as_os_str().to_str() else {
previous = None;
continue;
};
if previous == Some("node_modules") && name == ".vize" {
return true;
}
previous = Some(name);
}
false
}
fn external_document_path(uri: &str) -> Option<PathBuf> {
if let Some(path) = file_uri_to_path(uri) {
return Some(path);
}
let (scheme, path) = uri.split_once("://")?;
let mut session_path = PathBuf::from("__scheme__");
session_path.push(scheme);
session_path.push(path.trim_start_matches('/'));
Some(session_path)
}
fn overlay_changes_error_is_unsupported(error: &impl std::fmt::Display) -> bool {
let message = cstr!("{error}");
message.contains("overlayChanges")
&& (message.contains("unsupported") || message.contains("not supported"))
}
pub(super) fn materialize_session_document(
external_uri: &str,
document_uri: &str,
content: &str,
) -> Option<FileChanges> {
if document_uri == external_uri {
return None;
}
let path = file_uri_to_path(document_uri)?;
let path = path.as_path();
let parent = path.parent()?;
let existed = path.exists();
let _ = std::fs::create_dir_all(parent);
let _ = std::fs::write(path, content);
Some(FileChanges::Summary(FileChangeSummary {
changed: if existed {
vec![uri_document_identifier(document_uri)]
} else {
Vec::new()
},
created: if existed {
Vec::new()
} else {
vec![uri_document_identifier(document_uri)]
},
deleted: Vec::new(),
}))
}
fn remove_session_document(external_uri: &str, document_uri: &str) -> Option<FileChanges> {
if document_uri == external_uri {
return None;
}
let path = file_uri_to_path(document_uri)?;
let path = path.as_path();
if !path.exists() {
return None;
}
let _ = std::fs::remove_file(path);
Some(FileChanges::Summary(FileChangeSummary {
changed: Vec::new(),
created: Vec::new(),
deleted: vec![uri_document_identifier(document_uri)],
}))
}
pub(super) fn line_character_to_utf16_offset(text: &str, line: u32, character: u32) -> u32 {
let mut offset = 0u32;
let mut lines = text.split_inclusive('\n');
for _ in 0..line {
let Some(segment) = lines.next() else {
return text.encode_utf16().count() as u32;
};
offset += segment.encode_utf16().count() as u32;
}
let Some(segment) = lines.next() else {
return text.encode_utf16().count() as u32;
};
let line_without_break = segment.strip_suffix('\n').unwrap_or(segment);
let line_len = line_without_break.encode_utf16().count() as u32;
offset + character.min(line_len)
}
#[cfg(test)]
mod tests {
use super::{
api_mode_for_executable, build_session_document_uri, line_character_to_utf16_offset,
path_to_file_uri, should_retry_json_rpc, uri_document_identifier,
};
use corsa::CorsaError;
use corsa::api::{ApiMode, DocumentIdentifier};
#[test]
fn keeps_vue_virtual_overlay_at_real_path_inside_project() {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let project = std::env::temp_dir().join(format!(
"vize-canon-session-uri-{}-{nonce}",
std::process::id()
));
let components = project.join("src/components");
std::fs::create_dir_all(&components).unwrap();
let real = components.join("Button.vue");
std::fs::write(&real, "<template><div /></template>").unwrap();
let virtual_path = components.join("Button.vue.ts");
let uri = path_to_file_uri(&virtual_path);
let mapped = build_session_document_uri(&uri, &project, true);
assert_eq!(mapped, uri, "in-project .vue.ts overlay must keep its path");
let mapped_no_overlay = build_session_document_uri(&uri, &project, false);
assert_ne!(mapped_no_overlay, uri);
let outside = std::env::temp_dir().join(format!("vize-outside-{nonce}/Other.vue.ts"));
let outside_uri = path_to_file_uri(&outside);
let mapped_outside = build_session_document_uri(&outside_uri, &project, true);
assert_ne!(mapped_outside, outside_uri);
let _ = std::fs::remove_dir_all(project);
}
#[test]
fn uses_async_json_rpc_for_node_modules_bin_wrappers() {
assert_eq!(
api_mode_for_executable("/tmp/project/node_modules/.bin/tsgo"),
ApiMode::AsyncJsonRpcStdio
);
}
#[test]
fn uses_async_json_rpc_for_native_preview_js_entrypoints() {
assert_eq!(
api_mode_for_executable(
"/tmp/project/node_modules/@typescript/native-preview/bin/tsgo.js"
),
ApiMode::AsyncJsonRpcStdio
);
}
#[test]
fn keeps_native_binaries_on_sync_msgpack() {
assert_eq!(
api_mode_for_executable("/tmp/project/corsa-bind/.cache/tsgo"),
ApiMode::SyncMsgpackStdio
);
}
#[test]
fn retries_json_rpc_after_msgpack_shape_mismatch() {
let error = CorsaError::Protocol("expected tuple marker, got 61".into());
assert!(should_retry_json_rpc(ApiMode::SyncMsgpackStdio, &error));
assert!(!should_retry_json_rpc(ApiMode::AsyncJsonRpcStdio, &error));
}
#[test]
fn utf16_offsets_clamp_to_line_boundaries() {
assert_eq!(line_character_to_utf16_offset("alpha\nbeta", 0, 99), 5);
assert_eq!(line_character_to_utf16_offset("a😀b", 0, 3), 3);
assert_eq!(line_character_to_utf16_offset("a\nb", 9, 0), 3);
}
#[test]
fn api_queries_use_uri_document_identifiers() {
assert!(matches!(
uri_document_identifier("file:///workspace/App.vue.ts"),
DocumentIdentifier::FileName(_)
));
assert!(matches!(
uri_document_identifier("corsa://overlay/App.vue.ts"),
DocumentIdentifier::Uri { .. }
));
}
}