use serde_json::{json, Value};
use std::io::{self, Read};
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::mpsc::{self, Receiver};
use std::thread;
use std::time::{Duration, Instant};
const PROBE_TIMEOUT: Duration = Duration::from_millis(1_500);
const PROBE_OUTPUT_CAP_BYTES: usize = 8 * 1024;
const PROBE_POLL_INTERVAL: Duration = Duration::from_millis(20);
const PROBE_KILL_GRACE: Duration = Duration::from_millis(200);
const PROBE_READER_GRACE: Duration = Duration::from_millis(100);
struct ProbeCapture {
text: String,
bytes: usize,
truncated: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn probe_command_times_out_and_reports_unavailable() {
let started = Instant::now();
let payload = probe_command("sh", &["-c", "sleep 5"]);
assert_eq!(payload["available"], false);
assert_eq!(payload["timed_out"], true);
assert_eq!(payload["exit_status"], Value::Null);
assert!(started.elapsed() < Duration::from_secs(4));
}
#[cfg(unix)]
#[test]
fn probe_command_caps_stdout_without_unbounded_capture() {
let payload = probe_command(
"sh",
&[
"-c",
"i=0; while [ \"$i\" -lt 9000 ]; do printf x; i=$((i + 1)); done",
],
);
assert_eq!(payload["available"], true);
assert_eq!(payload["timed_out"], false);
assert_eq!(payload["stdout_truncated"], true);
assert!(payload["stdout"].as_str().expect("stdout").len() <= PROBE_OUTPUT_CAP_BYTES);
assert!(payload["stdout_bytes"].as_u64().expect("stdout byte count") >= 9000);
}
}
fn probe_timeout_ms() -> u64 {
PROBE_TIMEOUT.as_millis() as u64
}
fn language_server_command<'a>(
config: &'a Value,
language_key: &str,
default_command: &'a str,
) -> &'a str {
config
.pointer(&format!("/lsp/language_servers/{language_key}/command"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(default_command)
}
fn language_server_package_hint(config: &Value, language_key: &str, default_hint: &str) -> String {
config
.pointer(&format!(
"/lsp/language_servers/{language_key}/package_hint"
))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(default_hint)
.to_string()
}
fn annotate_language_server_probe(
mut probe: Value,
language_key: &str,
package_hint: &str,
) -> Value {
if let Some(object) = probe.as_object_mut() {
object.insert(
"language".to_string(),
Value::String(language_key.to_string()),
);
object.insert(
"package_hint".to_string(),
Value::String(package_hint.to_string()),
);
}
probe
}
fn read_capped_output<R: Read>(mut reader: R) -> ProbeCapture {
let mut captured = Vec::new();
let mut bytes = 0_usize;
let mut truncated = false;
let mut buffer = [0_u8; 1024];
loop {
let read = match reader.read(&mut buffer) {
Ok(0) => break,
Ok(read) => read,
Err(_) => break,
};
bytes = bytes.saturating_add(read);
let remaining = PROBE_OUTPUT_CAP_BYTES.saturating_sub(captured.len());
if remaining == 0 {
truncated = true;
continue;
}
let take = read.min(remaining);
captured.extend_from_slice(&buffer[..take]);
if take < read {
truncated = true;
}
}
ProbeCapture {
text: String::from_utf8_lossy(&captured).trim().to_string(),
bytes,
truncated,
}
}
fn spawn_output_reader<R: Read + Send + 'static>(reader: R) -> Receiver<ProbeCapture> {
let (sender, receiver) = mpsc::channel();
thread::spawn(move || {
let _ = sender.send(read_capped_output(reader));
});
receiver
}
fn receive_capture(receiver: Receiver<ProbeCapture>) -> ProbeCapture {
receiver
.recv_timeout(PROBE_READER_GRACE)
.unwrap_or_else(|_| ProbeCapture {
text: String::new(),
bytes: PROBE_OUTPUT_CAP_BYTES,
truncated: true,
})
}
fn probe_error(command: &str, args: &[&str], error: io::Error) -> Value {
json!({
"command": command,
"args": args,
"available": false,
"exit_status": Value::Null,
"stdout": "",
"stderr": error.to_string(),
"stdout_bytes": 0,
"stderr_bytes": error.to_string().len(),
"stdout_truncated": false,
"stderr_truncated": false,
"timed_out": false,
"timeout_ms": probe_timeout_ms(),
"output_cap_bytes": PROBE_OUTPUT_CAP_BYTES,
})
}
fn skipped_probe(command: &str, args: &[&str], reason: &str) -> Value {
json!({
"command": command,
"args": args,
"available": false,
"exit_status": Value::Null,
"stdout": "",
"stderr": "",
"stdout_bytes": 0,
"stderr_bytes": 0,
"stdout_truncated": false,
"stderr_truncated": false,
"timed_out": false,
"timeout_ms": probe_timeout_ms(),
"output_cap_bytes": PROBE_OUTPUT_CAP_BYTES,
"skipped": true,
"skip_reason": reason,
})
}
fn probe_command(command: &str, args: &[&str]) -> Value {
let mut child = match Command::new(command)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(child) => child,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return json!({
"command": command,
"args": args,
"available": false,
"exit_status": Value::Null,
"stdout": "",
"stderr": error.to_string(),
"stdout_bytes": 0,
"stderr_bytes": error.to_string().len(),
"stdout_truncated": false,
"stderr_truncated": false,
"timed_out": false,
"timeout_ms": probe_timeout_ms(),
"output_cap_bytes": PROBE_OUTPUT_CAP_BYTES,
})
}
Err(error) => return probe_error(command, args, error),
};
let stdout = child.stdout.take().map(spawn_output_reader);
let stderr = child.stderr.take().map(spawn_output_reader);
let deadline = Instant::now() + PROBE_TIMEOUT;
let (exit_status, timed_out) = loop {
match child.try_wait() {
Ok(Some(status)) => break (status.code(), false),
Ok(None) if Instant::now() >= deadline => {
let _ = child.kill();
let kill_deadline = Instant::now() + PROBE_KILL_GRACE;
let killed_status = loop {
match child.try_wait() {
Ok(Some(status)) => break status.code(),
Ok(None) if Instant::now() >= kill_deadline => break None,
Ok(None) => thread::sleep(PROBE_POLL_INTERVAL),
Err(_) => break None,
}
};
break (killed_status, true);
}
Ok(None) => thread::sleep(PROBE_POLL_INTERVAL),
Err(error) => return probe_error(command, args, error),
}
};
let stdout = stdout.map(receive_capture).unwrap_or_else(|| ProbeCapture {
text: String::new(),
bytes: 0,
truncated: false,
});
let stderr = stderr.map(receive_capture).unwrap_or_else(|| ProbeCapture {
text: String::new(),
bytes: 0,
truncated: false,
});
json!({
"command": command,
"args": args,
"available": !timed_out && exit_status == Some(0),
"exit_status": exit_status,
"stdout": stdout.text,
"stderr": stderr.text,
"stdout_bytes": stdout.bytes,
"stderr_bytes": stderr.bytes,
"stdout_truncated": stdout.truncated,
"stderr_truncated": stderr.truncated,
"timed_out": timed_out,
"timeout_ms": probe_timeout_ms(),
"output_cap_bytes": PROBE_OUTPUT_CAP_BYTES,
})
}
fn should_probe_lsp(enabled: bool, runtime_execution: &str) -> bool {
enabled && runtime_execution == "bounded_readiness"
}
fn readiness_state(
enabled: bool,
runtime_execution: &str,
rust_available: bool,
ts_available: bool,
) -> (&'static str, &'static str) {
if !enabled {
("disabled", "lsp_disabled")
} else if runtime_execution == "deferred" {
("deferred", "runtime_execution_deferred")
} else if runtime_execution != "bounded_readiness" {
("unavailable", "unsupported_runtime_execution")
} else if rust_available || ts_available {
("available", "server_probe_available")
} else {
("unavailable", "server_probe_unavailable")
}
}
pub(crate) fn create_lsp_runtime_readiness_payload(config: &Value, cwd: &Path) -> Value {
let enabled = config
.pointer("/lsp/enabled")
.and_then(Value::as_bool)
.unwrap_or(true);
let runtime_execution = config
.pointer("/lsp/runtime_execution")
.and_then(Value::as_str)
.unwrap_or("bounded_readiness");
let rust_command = language_server_command(config, "rust", "rust-analyzer");
let ts_command = language_server_command(
config,
"typescript_javascript",
"typescript-language-server",
);
let rust_package_hint =
language_server_package_hint(config, "rust", "rustup component add rust-analyzer");
let ts_package_hint = language_server_package_hint(
config,
"typescript_javascript",
"npm install -g typescript typescript-language-server",
);
let probe_enabled = should_probe_lsp(enabled, runtime_execution);
let skip_reason = if !enabled {
"lsp_disabled"
} else if runtime_execution == "deferred" {
"runtime_execution_deferred"
} else {
"unsupported_runtime_execution"
};
let rust = if probe_enabled {
probe_command(rust_command, &["--version"])
} else {
skipped_probe(rust_command, &["--version"], skip_reason)
};
let rust = annotate_language_server_probe(rust, "rust", &rust_package_hint);
let typescript = if probe_enabled {
probe_command(ts_command, &["--version"])
} else {
skipped_probe(ts_command, &["--version"], skip_reason)
};
let typescript =
annotate_language_server_probe(typescript, "typescript_javascript", &ts_package_hint);
let rust_available = rust
.get("available")
.and_then(Value::as_bool)
.unwrap_or(false);
let ts_available = typescript
.get("available")
.and_then(Value::as_bool)
.unwrap_or(false);
let (readiness, reason) =
readiness_state(enabled, runtime_execution, rust_available, ts_available);
let surface_usable = readiness == "available";
json!({
"schema": "ccc.lsp_runtime.v1",
"enabled": enabled,
"runtime_execution": runtime_execution,
"probe_enabled": probe_enabled,
"readiness": readiness,
"reason": reason,
"surface_usable": surface_usable,
"completion_status": "readiness_only_complete",
"inventory_evidence": {
"status": "complete",
"scope": "readiness_only",
"surface_usable": surface_usable,
"diagnostics_runtime": "readiness_only_noop",
"bounded_probe": probe_enabled,
"safe_fallback": "no_lsp_runtime_requests_when_unavailable_or_deferred"
},
"blocking": false,
"cwd": cwd.to_string_lossy(),
"safe_fallback": "no_lsp_runtime_requests_when_unavailable_or_deferred",
"requests": {
"diagnostics": {
"implemented": false,
"available": false,
"mode": "readiness_only_noop",
"execution": "no_diagnostics_produced",
"safe_fallback": "no_diagnostics_when_server_unavailable"
},
"readiness": {
"implemented": true,
"available": true,
"mode": if probe_enabled { "bounded_probe" } else { "safe_noop" },
"probe_enabled": probe_enabled,
"timeout_ms": probe_timeout_ms(),
"output_cap_bytes": PROBE_OUTPUT_CAP_BYTES,
"rust": "rust-analyzer --version",
"typescript_javascript": "typescript-language-server --version"
}
},
"language_servers": {
"rust": rust,
"typescript_javascript": typescript
},
"server_availability": {
"rust": {
"available": rust_available,
"command": rust_command,
"package_hint": rust_package_hint
},
"typescript_javascript": {
"available": ts_available,
"command": ts_command,
"package_hint": ts_package_hint
}
},
"summary": match readiness {
"available" => "At least one configured LSP server is available for bounded CCC readiness requests.",
"deferred" => "LSP runtime execution is deferred by config; CCC will not spawn probes and will use safe no-op fallback.",
"disabled" => "LSP runtime is disabled by config; CCC will use safe no-op fallback.",
_ => "No configured LSP server is available; CCC will use safe no-op fallback.",
}
})
}
pub(crate) fn create_lsp_runtime_payload(arguments: &Value, config: &Value) -> io::Result<Value> {
let cwd = arguments
.get("cwd")
.and_then(Value::as_str)
.map(std::path::PathBuf::from)
.unwrap_or(std::env::current_dir()?);
let request = arguments
.get("request")
.and_then(Value::as_str)
.unwrap_or("readiness");
let mut payload = create_lsp_runtime_readiness_payload(config, &cwd);
let probe_enabled = payload
.get("probe_enabled")
.and_then(Value::as_bool)
.unwrap_or(false);
if let Some(object) = payload.as_object_mut() {
object.insert("request".to_string(), Value::String(request.to_string()));
object.insert(
"execution_status".to_string(),
Value::String(
if request == "diagnostics" {
"noop_diagnostics_readiness_only"
} else if request == "readiness" && probe_enabled {
"readiness_probe_completed"
} else if request == "readiness" {
"noop_readiness_probe_skipped"
} else {
"noop_unsupported_request"
}
.to_string(),
),
);
if request == "diagnostics" {
object.insert("diagnostics_produced".to_string(), Value::Bool(false));
}
}
Ok(payload)
}
pub(crate) fn create_lsp_runtime_text(payload: &Value) -> String {
format!(
"LSP runtime: readiness={} execution={} usable={} rust={} typescript={} blocking=false",
payload
.get("readiness")
.and_then(Value::as_str)
.unwrap_or("unknown"),
payload
.get("runtime_execution")
.and_then(Value::as_str)
.unwrap_or("unknown"),
payload
.get("surface_usable")
.and_then(Value::as_bool)
.unwrap_or(false),
payload
.pointer("/language_servers/rust/available")
.and_then(Value::as_bool)
.unwrap_or(false),
payload
.pointer("/language_servers/typescript_javascript/available")
.and_then(Value::as_bool)
.unwrap_or(false),
)
}