mod handlers;
mod requests;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{
io::BufReader,
process::{Child, ChildStdin, ChildStdout, Command, Stdio},
sync::atomic::{AtomicI64, Ordering},
thread,
time::Duration,
};
#[cfg(unix)]
use std::os::unix::io::AsRawFd;
use vize_carton::cstr;
use vize_carton::FxHashMap;
use vize_carton::String;
use vize_carton::ToCompactString;
pub struct TsgoLspClient {
process: Child,
pub(crate) stdin: ChildStdin,
pub(crate) stdout: BufReader<ChildStdout>,
pub(crate) request_id: AtomicI64,
pub(crate) diagnostics: FxHashMap<String, Vec<LspDiagnostic>>,
temp_dir: Option<std::path::PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LspDiagnostic {
pub range: LspRange,
pub severity: Option<i32>,
pub code: Option<Value>,
pub source: Option<String>,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LspRange {
pub start: LspPosition,
pub end: LspPosition,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LspPosition {
pub line: u32,
pub character: u32,
}
impl TsgoLspClient {
pub fn new(tsgo_path: Option<&str>, working_dir: Option<&str>) -> Result<Self, String> {
let tsgo: String = tsgo_path
.map(String::from)
.or_else(|| std::env::var("TSGO_PATH").ok().map(String::from))
.or_else(|| Self::find_tsgo_in_local_node_modules(working_dir))
.or_else(Self::find_tsgo_in_common_locations)
.unwrap_or_else(|| "tsgo".into());
eprintln!("\x1b[90m[tsgo] Using: {tsgo}\x1b[0m");
let _project_root = working_dir
.map(std::path::PathBuf::from)
.or_else(|| std::env::current_dir().ok())
.and_then(|p| p.canonicalize().ok());
let temp_dir_path = std::env::temp_dir().join(&*cstr!("vize-tsgo-{}", std::process::id()));
std::fs::create_dir_all(&temp_dir_path)
.map_err(|e| cstr!("Failed to create temp directory: {e}"))?;
let tsconfig_content = serde_json::json!({
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noEmit": true,
"skipLibCheck": true
}
});
std::fs::write(
temp_dir_path.join("tsconfig.json"),
tsconfig_content.to_compact_string(),
)
.map_err(|e| cstr!("Failed to write temp tsconfig.json: {e}"))?;
let mut cmd = Command::new(tsgo.as_str());
cmd.arg("--lsp")
.arg("--stdio")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
cmd.current_dir(&temp_dir_path);
let mut process = cmd
.spawn()
.map_err(|e| cstr!("Failed to start tsgo lsp: {e}"))?;
let stdin = process
.stdin
.take()
.ok_or("Failed to get stdin of tsgo lsp")?;
let stdout = process
.stdout
.take()
.ok_or("Failed to get stdout of tsgo lsp")?;
#[cfg(unix)]
{
use libc::{fcntl, F_GETFL, F_SETFL, O_NONBLOCK};
let fd = stdout.as_raw_fd();
unsafe {
let flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
}
let temp_root = temp_dir_path.canonicalize().ok();
let mut client = Self {
process,
stdin,
stdout: BufReader::new(stdout),
request_id: AtomicI64::new(1),
diagnostics: FxHashMap::default(),
temp_dir: Some(temp_dir_path),
};
client.initialize(temp_root.as_ref())?;
Ok(client)
}
fn initialize(&mut self, project_root: Option<&std::path::PathBuf>) -> Result<(), String> {
let root_uri = project_root.map(|p| cstr!("file://{}", p.display()));
let workspace_folders = root_uri.as_ref().map(|uri| {
serde_json::json!([{
"uri": uri,
"name": "workspace"
}])
});
let params = serde_json::json!({
"processId": std::process::id(),
"capabilities": {
"textDocument": {
"publishDiagnostics": {
"relatedInformation": true
},
"diagnostic": {
"dynamicRegistration": false
}
},
"workspace": {
"workspaceFolders": true,
"configuration": true
}
},
"rootUri": root_uri,
"workspaceFolders": workspace_folders
});
let _response = self.send_request("initialize", params)?;
self.send_notification("initialized", serde_json::json!({}))?;
Ok(())
}
pub fn shutdown(&mut self) -> Result<(), String> {
let shutdown_req = serde_json::json!({
"jsonrpc": "2.0",
"id": self.request_id.fetch_add(1, Ordering::SeqCst),
"method": "shutdown",
"params": Value::Null
});
let _ = self.send_message(&shutdown_req);
let _ = self.send_notification("exit", Value::Null);
thread::sleep(Duration::from_millis(10));
let _ = self.process.kill();
let _ = self.process.wait();
Ok(())
}
fn find_tsgo_in_local_node_modules(working_dir: Option<&str>) -> Option<String> {
let base_dir = working_dir
.map(std::path::PathBuf::from)
.or_else(|| std::env::current_dir().ok())?;
let platform_suffix = if cfg!(target_os = "macos") {
if cfg!(target_arch = "aarch64") {
"darwin-arm64"
} else {
"darwin-x64"
}
} else if cfg!(target_os = "linux") {
if cfg!(target_arch = "aarch64") {
"linux-arm64"
} else {
"linux-x64"
}
} else if cfg!(target_os = "windows") {
"win32-x64"
} else {
""
};
let search_in_dir = |dir: &std::path::Path| -> Option<String> {
let pnpm_pattern = dir.join("node_modules/.pnpm");
if pnpm_pattern.exists() {
if let Ok(entries) = std::fs::read_dir(&pnpm_pattern) {
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with("@typescript+native-preview-")
&& name_str.contains(platform_suffix)
{
let native_path = entry.path().join(&*cstr!(
"node_modules/@typescript/native-preview-{}/lib/tsgo",
platform_suffix
));
if native_path.exists() {
return Some(native_path.to_string_lossy().into());
}
}
}
}
}
let native_candidates = [
dir.join(&*cstr!(
"node_modules/@typescript/native-preview-{}/lib/tsgo",
platform_suffix
)),
dir.join("node_modules/@typescript/native-preview/lib/tsgo"),
];
for candidate in &native_candidates {
if candidate.exists() {
return Some(candidate.to_string_lossy().into());
}
}
let candidates = [
dir.join("node_modules/.bin/tsgo"),
dir.join("node_modules/@typescript/native-preview/bin/tsgo"),
];
for candidate in &candidates {
if candidate.exists() {
return Some(candidate.to_string_lossy().into());
}
}
None
};
if let Some(path) = search_in_dir(&base_dir) {
return Some(path);
}
let mut current = base_dir.as_path();
while let Some(parent) = current.parent() {
if let Some(path) = search_in_dir(parent) {
return Some(path);
}
current = parent;
}
None
}
fn find_tsgo_in_common_locations() -> Option<String> {
let home = std::env::var("HOME").ok()?;
let candidates: [String; 10] = [
cstr!("{home}/.npm-global/bin/tsgo"),
cstr!("{home}/.npm/bin/tsgo"),
cstr!("{home}/.local/share/pnpm/tsgo"),
cstr!("{home}/.volta/bin/tsgo"),
cstr!("{home}/.local/share/mise/shims/tsgo"),
cstr!("{home}/.asdf/shims/tsgo"),
cstr!("{home}/.local/share/fnm/node-versions/current/bin/tsgo"),
cstr!("{home}/.nvm/versions/node/current/bin/tsgo"),
"/opt/homebrew/bin/tsgo".into(),
"/usr/local/bin/tsgo".into(),
];
for path in candidates {
if std::path::Path::new(path.as_str()).exists() {
return Some(path);
}
}
if let Ok(output) = std::process::Command::new("npm")
.args(["root", "-g"])
.output()
{
if output.status.success() {
#[allow(clippy::disallowed_types)]
let npm_root = std::string::String::from_utf8_lossy(&output.stdout);
let npm_root = npm_root.trim();
if let Some(lib_parent) = std::path::Path::new(npm_root).parent() {
let tsgo_path = lib_parent.join("bin/tsgo");
if tsgo_path.exists() {
return Some(tsgo_path.to_string_lossy().into());
}
}
}
}
None
}
}
impl Drop for TsgoLspClient {
fn drop(&mut self) {
let _ = self.shutdown();
if let Some(ref dir) = self.temp_dir {
let _ = std::fs::remove_dir_all(dir);
}
}
}