use std::collections::BTreeSet;
use std::io::{BufRead, BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender, sync_channel};
use std::sync::{Mutex, PoisonError};
use lanekeep_core::{AnalysisBudget, FileAccess, FilePath, TypesConfig, analysis_overrun_fallback};
use crate::provider::{BeginRunError, Query, TypeProvider};
use crate::types::{Primitive, Symbol, Type};
const DRIVER: &str = include_str!("driver.mjs");
const STDERR_KEPT: usize = 8 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ProviderError {
#[error(
"cannot start the type provider: {0}\n \
`types.provider` is `tsc`, which runs the project's own toolchain, so it needs \
`types.command` on PATH"
)]
Unavailable(String),
#[error(
"the type provider started but could not use the project's `typescript`: {0}\n \
point `types.typescript` at the package to load"
)]
Unloadable(String),
#[error(
"cannot write the type provider's driver to {0}\n \
`types.provider` is `tsc`, which writes its sidecar into the project's `.lanekeep/`, \
so that directory has to be writable"
)]
Unwritable(String),
#[error("the type provider refused: {0}")]
Refused(String),
#[error(
"the type provider did not answer within the remaining `timeouts.analysis` budget\n \
the sidecar has been killed; raise `timeouts.analysis` or set `types.provider` to \
`builtin`"
)]
Timeout,
}
#[derive(Clone, Copy)]
enum Sidecar {
Gone,
Live,
}
enum Failure {
Timeout,
Refused(String, Sidecar),
}
struct Session {
child: Child,
stdin: ChildStdin,
lines: Receiver<std::io::Result<String>>,
next_id: u64,
}
pub struct TscProvider {
session: Mutex<Session>,
root: PathBuf,
budget: AnalysisBudget,
run_budget: Mutex<Option<AnalysisBudget>>,
stderr: std::sync::Arc<Mutex<Vec<u8>>>,
stderr_drain: Mutex<Option<std::thread::JoinHandle<()>>>,
failure: Mutex<Option<ProviderError>>,
typescript_version: String,
identity: Vec<u8>,
programs_hash: Mutex<[u8; 32]>,
adhoc: Mutex<usize>,
dependency_paths: Mutex<BTreeSet<FilePath>>,
}
impl std::fmt::Debug for TscProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TscProvider")
.field("typescript", &self.typescript_version)
.finish_non_exhaustive()
}
}
impl Drop for TscProvider {
fn drop(&mut self) {
if let Ok(mut session) = self.session.lock() {
let _ = session.child.kill();
let _ = session.child.wait();
}
}
}
impl TscProvider {
pub fn spawn(
root: &Path,
config: &TypesConfig,
budget: AnalysisBudget,
) -> Result<Self, ProviderError> {
Self::spawn_with_env(root, config, budget, &[])
}
pub fn spawn_with_env(
root: &Path,
config: &TypesConfig,
budget: AnalysisBudget,
env: &[(&str, &str)],
) -> Result<Self, ProviderError> {
let (program, arguments) = config
.command
.split_first()
.ok_or_else(|| ProviderError::Unavailable("`types.command` is empty".to_owned()))?;
let root = std::path::absolute(root).map_err(|e| {
ProviderError::Unavailable(format!("cannot resolve the project root: {e}"))
})?;
let driver = write_driver(&root)?;
let mut command = Command::new(program);
command
.args(arguments)
.arg(&driver)
.arg(&root)
.arg(&config.typescript)
.current_dir(&root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env_remove("NODE_OPTIONS")
.env_remove("NODE_PATH")
.env_remove("TS_NODE_PROJECT")
.env_remove("LANEKEEP_TSC_DRIVER_DELAY_MS");
for (key, value) in env {
command.env(key, value);
}
let mut child = command
.spawn()
.map_err(|e| ProviderError::Unavailable(e.to_string()))?;
let stdin = child
.stdin
.take()
.ok_or_else(|| ProviderError::Unavailable("no stdin on the sidecar".to_owned()))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| ProviderError::Unavailable("no stdout on the sidecar".to_owned()))?;
let child_stderr = child
.stderr
.take()
.ok_or_else(|| ProviderError::Unavailable("no stderr on the sidecar".to_owned()))?;
let lines = read_lines(stdout)?;
let (stderr, stderr_drain) = drain_stderr(child_stderr)?;
let mut provider = Self {
session: Mutex::new(Session {
child,
stdin,
lines,
next_id: 0,
}),
root,
budget,
run_budget: Mutex::new(None),
stderr,
stderr_drain: Mutex::new(Some(stderr_drain)),
failure: Mutex::new(None),
typescript_version: String::new(),
identity: Vec::new(),
programs_hash: Mutex::new([0; 32]),
adhoc: Mutex::new(0),
dependency_paths: Mutex::new(BTreeSet::new()),
};
let hello = provider.request("hello", &serde_json::json!({}))?;
if let Some(error) = hello.get("error").and_then(serde_json::Value::as_str) {
return Err(ProviderError::Unloadable(format!(
"{error}; a pnpm workspace has no root `node_modules/typescript` — point \
`types.typescript` at a workspace package's copy"
)));
}
let version = hello
.get("typescript")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| provider.refused("`hello` carried no version", Sidecar::Live))?
.to_owned();
if let Some(missing) = hello
.get("unsupported")
.and_then(serde_json::Value::as_array)
{
let missing: Vec<&str> = missing
.iter()
.filter_map(serde_json::Value::as_str)
.collect();
return Err(ProviderError::Unloadable(format!(
"typescript {version} at `{}` does not provide the compiler API the driver \
needs ({}); the tsc provider is written against the TypeScript 5.x compiler \
API and tested through 6.0.3",
config.typescript,
missing.join(", "),
)));
}
provider.identity = fold_identity(&version, DRIVER, config);
provider.typescript_version = version;
Ok(provider)
}
#[must_use]
pub fn typescript_version(&self) -> &str {
&self.typescript_version
}
pub fn programs(&self, files: &[FilePath]) -> Result<(), ProviderError> {
let listed: Vec<String> = files
.iter()
.filter(|file| typed_extension(file.as_str()))
.map(|file| self.absolute(file))
.collect();
let answer = self.request("programs", &serde_json::json!({ "files": listed }))?;
let hash = fold_programs(&answer).inspect_err(|e| self.remember(e))?;
if let Ok(mut slot) = self.programs_hash.lock() {
*slot = hash;
}
let adhoc = adhoc_count(&answer).inspect_err(|e| self.remember(e))?;
if let Ok(mut slot) = self.adhoc.lock() {
*slot = adhoc;
}
let paths = programs_paths(&answer).inspect_err(|e| self.remember(e))?;
if let Ok(mut slot) = self.dependency_paths.lock() {
*slot = paths;
}
Ok(())
}
#[must_use]
pub fn failure(&self) -> Option<ProviderError> {
self.failure
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
#[cfg(test)]
fn write_raw(&self, line: &str) {
if let Ok(mut session) = self.session.lock() {
let _ = session.stdin.write_all(line.as_bytes());
let _ = session.stdin.flush();
}
}
#[cfg(test)]
fn kill_sidecar(&self) {
if let Ok(mut session) = self.session.lock() {
let _ = stop(&mut session);
}
}
fn remember(&self, error: &ProviderError) {
self.failure
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get_or_insert_with(|| error.clone());
}
#[must_use]
pub fn programs_hash(&self) -> [u8; 32] {
self.programs_hash.lock().map_or([0; 32], |slot| *slot)
}
fn current_budget(&self) -> AnalysisBudget {
self.run_budget
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone()
.unwrap_or_else(|| self.budget.clone())
}
fn stderr_tail(&self) -> String {
let Ok(kept) = self.stderr.lock() else {
return String::new();
};
let text = String::from_utf8_lossy(&kept);
if text.trim().is_empty() {
return String::new();
}
format!("\n the sidecar wrote on stderr:\n{}", text.trim_end())
}
fn join_stderr(&self) {
let handle = self
.stderr_drain
.lock()
.ok()
.and_then(|mut slot| slot.take());
if let Some(handle) = handle {
let _ = handle.join();
}
}
fn absolute(&self, file: &FilePath) -> String {
self.root
.join(file.as_str())
.to_string_lossy()
.replace('\\', "/")
}
fn locate(&self, q: &Query<'_>) -> serde_json::Value {
serde_json::json!({
"file": self.absolute(q.file),
"start": q.node.start_byte(),
"end": q.node.end_byte(),
})
}
fn ask_about(&self, op: &str, q: &Query<'_>) -> Result<serde_json::Value, ProviderError> {
self.ask_recording(op, &self.locate(q), q)
}
fn request(
&self,
op: &str,
body: &serde_json::Value,
) -> Result<serde_json::Value, ProviderError> {
Ok(self.request_reporting(op, body)?.0)
}
fn request_reporting(
&self,
op: &str,
body: &serde_json::Value,
) -> Result<(serde_json::Value, Vec<String>), ProviderError> {
let error = match self.exchange(op, body) {
Ok(answer) => return Ok(answer),
Err(Failure::Timeout) => ProviderError::Timeout,
Err(Failure::Refused(detail, sidecar)) => self.refused(&detail, sidecar),
};
self.remember(&error);
Err(error)
}
fn ask_recording(
&self,
op: &str,
body: &serde_json::Value,
q: &Query<'_>,
) -> Result<serde_json::Value, ProviderError> {
let (value, read) = self.request_reporting(op, body)?;
for path in read {
let _ = q.files.hash_of(&path);
}
Ok(value)
}
fn exchange(
&self,
op: &str,
body: &serde_json::Value,
) -> Result<(serde_json::Value, Vec<String>), Failure> {
let budget = self.current_budget();
let mut body = body.clone();
let Ok(mut session) = self.session.lock() else {
return Err(Failure::Refused(
"the sidecar's mutex is poisoned".to_owned(),
Sidecar::Live,
));
};
let Some(remaining) = budget.remaining() else {
return Err(Failure::Timeout);
};
let _charge = budget.charge();
session.next_id += 1;
let id = session.next_id;
if let Some(object) = body.as_object_mut() {
object.insert("id".to_owned(), serde_json::json!(id));
object.insert("op".to_owned(), serde_json::json!(op));
}
let mut line = match serde_json::to_string(&body) {
Ok(line) => line,
Err(e) => return Err(Failure::Refused(e.to_string(), Sidecar::Live)),
};
line.push('\n');
if let Err(e) = session
.stdin
.write_all(line.as_bytes())
.and_then(|()| session.stdin.flush())
{
return Err(Failure::Refused(e.to_string(), stop(&mut session)));
}
let answer = match session.lines.recv_timeout(remaining) {
Ok(Ok(line)) => line,
Ok(Err(e)) => return Err(Failure::Refused(e.to_string(), stop(&mut session))),
Err(RecvTimeoutError::Timeout) => {
let _ = stop(&mut session);
return Err(Failure::Timeout);
}
Err(RecvTimeoutError::Disconnected) => {
return Err(Failure::Refused(
"the sidecar exited without answering".to_owned(),
stop(&mut session),
));
}
};
let parsed: serde_json::Value = match serde_json::from_str(&answer) {
Ok(parsed) => parsed,
Err(e) => {
return Err(Failure::Refused(
format!("{e}: {answer}"),
stop(&mut session),
));
}
};
let echoed = parsed.get("id").and_then(serde_json::Value::as_u64);
if echoed != Some(id) {
let echoed = echoed.map_or_else(|| "none".to_owned(), |value| value.to_string());
return Err(Failure::Refused(
format!(
"the sidecar answered id {echoed} for request id {id}; the protocol is out \
of step and every later answer would be attributed to the wrong question"
),
stop(&mut session),
));
}
if parsed.get("ok").and_then(serde_json::Value::as_bool) != Some(true) {
let detail = parsed
.get("error")
.and_then(serde_json::Value::as_str)
.unwrap_or("no reason given");
return Err(Failure::Refused(detail.to_owned(), Sidecar::Live));
}
let read = parsed
.get("reads")
.and_then(serde_json::Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default();
Ok((
parsed
.get("value")
.cloned()
.unwrap_or(serde_json::Value::Null),
read,
))
}
fn refused(&self, detail: &str, sidecar: Sidecar) -> ProviderError {
if matches!(sidecar, Sidecar::Gone) {
self.join_stderr();
}
ProviderError::Refused(format!("{detail}{}", self.stderr_tail()))
}
}
fn stop(session: &mut Session) -> Sidecar {
let _ = session.child.kill();
let _ = session.child.wait();
Sidecar::Gone
}
impl TypeProvider for TscProvider {
fn notices(&self) -> Vec<String> {
let count = self.adhoc.lock().map_or(0, |slot| *slot);
if count == 0 {
return Vec::new();
}
vec![format!(
"{count} file(s) typed without a `tsconfig.json` under the project root — there is \
none, or the nearest one is above it, so the project's own compiler options \
(`strict` among them) did not apply to them"
)]
}
fn spends_analysis_budget(&self) -> bool {
true
}
fn type_of(&self, q: Query<'_>) -> Option<Type> {
decode_type(&self.ask_about("typeOf", &q).ok()?)
}
fn symbol_of(&self, q: Query<'_>) -> Option<Symbol> {
decode_symbol(&self.ask_about("symbolOf", &q).ok()?)
}
fn return_type_of(&self, q: Query<'_>) -> Option<Type> {
decode_type(&self.ask_about("returnTypeOf", &q).ok()?)
}
fn is_assignable_to(&self, q: Query<'_>, module: &str, name: &str) -> Option<bool> {
let mut body = self.locate(&q);
let object = body.as_object_mut()?;
object.insert("module".to_owned(), serde_json::json!(module));
object.insert("name".to_owned(), serde_json::json!(name));
self.ask_recording("isAssignableTo", &body, &q)
.ok()?
.as_bool()
}
fn complete(&self, q: Query<'_>) -> bool {
self.ask_recording(
"complete",
&serde_json::json!({ "file": self.absolute(q.file) }),
&q,
)
.ok()
.and_then(|value| value.as_bool())
.unwrap_or(false)
}
fn identity(&self) -> Vec<u8> {
self.identity.clone()
}
fn needs_rebuild(&self) -> bool {
let mut session = self.session.lock().unwrap_or_else(PoisonError::into_inner);
!matches!(session.child.try_wait(), Ok(None))
}
fn dependency_paths(&self) -> BTreeSet<FilePath> {
self.dependency_paths
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
fn revalidate(&self, _files: &FileAccess) {
}
fn begin_run(
&self,
files: &dyn Fn() -> Vec<FilePath>,
budget: AnalysisBudget,
) -> Result<Vec<u8>, BeginRunError> {
*self
.run_budget
.lock()
.unwrap_or_else(PoisonError::into_inner) = Some(budget.clone());
*self.failure.lock().unwrap_or_else(PoisonError::into_inner) = None;
self.programs(&files()).map_err(|e| match e {
ProviderError::Timeout => BeginRunError::Timeout(
budget
.overrun()
.unwrap_or_else(|| analysis_overrun_fallback(budget.budget())),
),
other => BeginRunError::Failed(other.to_string()),
})?;
Ok(self.programs_hash().to_vec())
}
fn failure(&self) -> Option<BeginRunError> {
let budget = self.current_budget();
Some(match TscProvider::failure(self)? {
ProviderError::Timeout => BeginRunError::Timeout(
budget
.overrun()
.unwrap_or_else(|| analysis_overrun_fallback(budget.budget())),
),
other => BeginRunError::Failed(other.to_string()),
})
}
}
fn read_lines(
stdout: std::process::ChildStdout,
) -> Result<Receiver<std::io::Result<String>>, ProviderError> {
let (sender, lines): (SyncSender<std::io::Result<String>>, _) = sync_channel(16);
std::thread::Builder::new()
.name("lanekeep-tsc-reader".to_owned())
.spawn(move || {
let mut reader = BufReader::new(stdout);
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => break,
Ok(_) => {
if sender.send(Ok(line)).is_err() {
break;
}
}
Err(e) => {
let _ = sender.send(Err(e));
break;
}
}
}
})
.map_err(|e| ProviderError::Unavailable(e.to_string()))?;
Ok(lines)
}
type Drain = (std::sync::Arc<Mutex<Vec<u8>>>, std::thread::JoinHandle<()>);
fn drain_stderr(stderr: std::process::ChildStderr) -> Result<Drain, ProviderError> {
let kept = std::sync::Arc::new(Mutex::new(Vec::new()));
let sink = std::sync::Arc::clone(&kept);
let handle = std::thread::Builder::new()
.name("lanekeep-tsc-stderr".to_owned())
.spawn(move || {
let mut reader = BufReader::new(stderr);
let mut chunk = [0_u8; 4096];
loop {
match reader.read(&mut chunk) {
Ok(0) | Err(_) => break,
Ok(read) => {
let Ok(mut buffer) = sink.lock() else { break };
if buffer.len() < STDERR_KEPT {
buffer.extend_from_slice(&chunk[..read]);
buffer.truncate(STDERR_KEPT);
}
}
}
}
})
.map_err(|e| ProviderError::Unavailable(e.to_string()))?;
Ok((kept, handle))
}
fn write_driver(root: &Path) -> Result<PathBuf, ProviderError> {
let digest = blake3::hash(DRIVER.as_bytes()).to_hex();
let dir = root.join(".lanekeep");
let unwritable =
|e: &std::io::Error| ProviderError::Unwritable(format!("{}: {e}", dir.display()));
std::fs::create_dir_all(&dir).map_err(|e| unwritable(&e))?;
let final_path = dir.join(format!("types-driver-{}.mjs", &digest[..16]));
if final_path.is_file() {
return Ok(final_path);
}
let temporary = dir.join(format!(
"types-driver-{}.{}.tmp",
&digest[..16],
std::process::id()
));
std::fs::write(&temporary, DRIVER).map_err(|e| unwritable(&e))?;
std::fs::rename(&temporary, &final_path).map_err(|e| unwritable(&e))?;
Ok(final_path)
}
fn fold_identity(version: &str, driver: &str, config: &TypesConfig) -> Vec<u8> {
let mut hasher = blake3::Hasher::new();
hasher.update(b"lanekeep-tsc-provider-v1");
for field in [
version.as_bytes(),
driver.as_bytes(),
config.canonical_bytes().as_slice(),
] {
hasher.update(&u64::try_from(field.len()).unwrap_or(u64::MAX).to_le_bytes());
hasher.update(field);
}
hasher.finalize().as_bytes().to_vec()
}
fn fold_programs(answer: &serde_json::Value) -> Result<[u8; 32], ProviderError> {
let listing = answer.get("listing").unwrap_or(answer);
let rows = listing.as_array().ok_or_else(|| {
ProviderError::Refused(format!(
"`programs` answered {answer}, whose `listing` is not a list of `[path, hash]` pairs"
))
})?;
let mut hasher = blake3::Hasher::new();
hasher.update(b"lanekeep-tsc-programs-v1");
hasher.update(&u64::try_from(rows.len()).unwrap_or(u64::MAX).to_le_bytes());
for row in rows {
let pair = row.as_array().filter(|pair| pair.len() == 2);
let pair = pair.ok_or_else(|| {
ProviderError::Refused(format!(
"`programs` answered a row {row} that is not a `[path, hash]` pair"
))
})?;
for field in pair {
let text = field.as_str().ok_or_else(|| {
ProviderError::Refused(format!(
"`programs` answered a row {row} whose fields are not both strings"
))
})?;
hasher.update(&u64::try_from(text.len()).unwrap_or(u64::MAX).to_le_bytes());
hasher.update(text.as_bytes());
}
}
hasher.update(b"lanekeep-tsc-adhoc-v1");
let adhoc = adhoc_paths(answer)?;
hasher.update(&u64::try_from(adhoc.len()).unwrap_or(u64::MAX).to_le_bytes());
for path in adhoc {
hasher.update(&u64::try_from(path.len()).unwrap_or(u64::MAX).to_le_bytes());
hasher.update(path.as_bytes());
}
Ok(*hasher.finalize().as_bytes())
}
fn programs_paths(answer: &serde_json::Value) -> Result<BTreeSet<FilePath>, ProviderError> {
let listing = answer.get("listing").unwrap_or(answer);
let rows = listing.as_array().ok_or_else(|| {
ProviderError::Refused(format!(
"`programs` answered {answer}, whose `listing` is not a list of `[path, hash]` pairs"
))
})?;
let mut paths = BTreeSet::new();
for row in rows {
let pair = row
.as_array()
.filter(|pair| pair.len() == 2)
.ok_or_else(|| {
ProviderError::Refused(format!(
"`programs` answered a row {row} that is not a `[path, hash]` pair"
))
})?;
let path = pair[0].as_str().ok_or_else(|| {
ProviderError::Refused(format!(
"`programs` answered a row {row} whose fields are not both strings"
))
})?;
paths.insert(FilePath::new(path));
}
for path in adhoc_paths(answer)? {
paths.insert(FilePath::new(path));
}
Ok(paths)
}
fn adhoc_paths(answer: &serde_json::Value) -> Result<Vec<String>, ProviderError> {
let Some(rows) = answer.get("adhoc") else {
return Ok(Vec::new());
};
let rows = rows.as_array().ok_or_else(|| {
ProviderError::Refused(format!(
"`programs` answered an `adhoc` of {rows}, which is not a list of paths"
))
})?;
let mut paths: Vec<String> = Vec::with_capacity(rows.len());
for row in rows {
let path = row.as_str().ok_or_else(|| {
ProviderError::Refused(format!(
"`programs` answered an `adhoc` entry {row} that is not a path"
))
})?;
paths.push(path.to_owned());
}
paths.sort();
paths.dedup();
Ok(paths)
}
fn adhoc_count(answer: &serde_json::Value) -> Result<usize, ProviderError> {
adhoc_paths(answer).map(|paths| paths.len())
}
fn typed_extension(path: &str) -> bool {
const TYPED: &[&str] = &[
".ts", ".tsx", ".mts", ".cts", ".d.ts", ".js", ".jsx", ".mjs", ".cjs",
];
let lowered = path.to_ascii_lowercase();
TYPED.iter().any(|suffix| lowered.ends_with(suffix))
}
fn decode_primitive(name: &str) -> Option<Primitive> {
[
Primitive::Number,
Primitive::String,
Primitive::Boolean,
Primitive::BigInt,
Primitive::Symbol,
Primitive::Null,
Primitive::Undefined,
]
.into_iter()
.find(|candidate| candidate.as_str() == name)
}
fn decode_type(answer: &serde_json::Value) -> Option<Type> {
if let Some(name) = answer.get("primitive").and_then(serde_json::Value::as_str) {
return decode_primitive(name).map(Type::Primitive);
}
if let Some(members) = answer.get("union").and_then(serde_json::Value::as_array) {
let decoded: Vec<Type> = members.iter().filter_map(decode_type).collect();
if decoded.len() != members.len() {
return None;
}
return Type::union(decoded);
}
let name = answer.get("text").and_then(serde_json::Value::as_str)?;
Some(Type::Nominal {
name: name.to_owned(),
symbol: answer.get("symbol").and_then(decode_symbol),
})
}
fn decode_symbol(answer: &serde_json::Value) -> Option<Symbol> {
let name = answer.get("name").and_then(serde_json::Value::as_str)?;
Some(Symbol {
name: name.to_owned(),
exported: answer
.get("exported")
.and_then(serde_json::Value::as_str)
.map(str::to_owned),
module: answer
.get("module")
.and_then(serde_json::Value::as_str)
.map(str::to_owned),
})
}
#[cfg(test)]
#[expect(
clippy::print_stderr,
reason = "a test that finds `typescript` absent has to say so on the terminal: the \
alternative is a suite that reports six passes for six tests it did not run"
)]
mod tests;