use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{channel, Receiver, Sender};
use std::sync::{Arc, Mutex};
use strop_core::worker::{CancelToken, WorkerId};
use strop_lsp::languages::LayerDiagnostic;
use strop_lsp::registry::{self, ServerSpec};
use strop_lsp::{Client, LspEvent, ServerId};
use strop_workspace::Filesystem;
pub(crate) struct LiveTransport {
pub client: Client,
pub rx: Receiver<LspEvent>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub(crate) struct AttachArgs {
pub ticket: WorkerId,
#[serde(with = "strop_core::path_serde")]
pub path: PathBuf,
pub language: String,
#[serde(default)]
pub target: Filesystem,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AttachRecord {
pub ticket: WorkerId,
pub server: Option<ServerId>,
pub language: String,
pub name: String,
#[serde(with = "strop_core::path_serde")]
pub root: PathBuf,
#[serde(default)]
pub target: Filesystem,
pub outcome: AttachDecision,
#[serde(default)]
pub layers: Vec<LayerDiagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum AttachDecision {
Attached,
NoServer,
Cancelled,
TrustRequired {
command: String,
},
TrustError {
error: String,
},
NotExecutable {
#[serde(default)]
command: String,
#[serde(default)]
reason: String,
hint: String,
},
SpawnFailed {
#[serde(default)]
reason: String,
},
RemoteIo {
#[serde(default)]
reason: String,
},
}
impl AttachDecision {
pub(crate) fn label(&self) -> &'static str {
match self {
Self::Attached => "attached",
Self::NoServer => "no_server",
Self::Cancelled => "cancelled",
Self::TrustRequired { .. } => "trust_required",
Self::TrustError { .. } => "trust_error",
Self::NotExecutable { .. } => "not_executable",
Self::SpawnFailed { .. } => "spawn_failed",
Self::RemoteIo { .. } => "remote_io",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct AttachKey {
pub target: Filesystem,
pub language: String,
pub path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Attachment {
pub language: String,
pub root: PathBuf,
pub server: ServerId,
pub target: Filesystem,
}
pub(crate) struct AttachState {
pub enabled: bool,
pub pending: HashMap<AttachKey, WorkerId>,
pub refused: HashMap<AttachKey, AttachDecision>,
pub trust_roots: HashMap<AttachKey, PathBuf>,
pub layer_diagnostics: Vec<LayerDiagnostic>,
pub attached: Vec<Attachment>,
pub transport: Arc<Mutex<HashMap<ServerId, LiveTransport>>>,
pub rx: Receiver<AttachRecord>,
tx: Sender<AttachRecord>,
}
impl AttachState {
pub fn new() -> Self {
let (tx, rx) = channel();
Self {
enabled: false,
pending: HashMap::new(),
refused: HashMap::new(),
trust_roots: HashMap::new(),
layer_diagnostics: Vec::new(),
attached: Vec::new(),
transport: Arc::new(Mutex::new(HashMap::new())),
rx,
tx,
}
}
pub fn take_rx(&mut self) -> Receiver<AttachRecord> {
let (_, empty) = channel();
std::mem::replace(&mut self.rx, empty)
}
pub fn attach_channel(&self) -> Sender<AttachRecord> {
self.tx.clone()
}
}
pub(crate) enum DiscoverPlace {
Local {
abs: PathBuf,
cwd: PathBuf,
git_workdir: Option<PathBuf>,
},
Remote {
file: strop_workspace::RemoteFile,
client: strop_remote::RemoteClient,
},
Container {
id: strop_workspace::ContainerId,
root: PathBuf,
},
}
pub(crate) struct DiscoverInput {
pub ticket: WorkerId,
pub place: DiscoverPlace,
pub ext: String,
pub language: &'static str,
pub state_dir: Option<PathBuf>,
pub xdg: Option<PathBuf>,
pub transport: Arc<Mutex<HashMap<ServerId, LiveTransport>>>,
}
pub(crate) fn discover(input: DiscoverInput, token: &CancelToken) -> Option<AttachRecord> {
match &input.place {
DiscoverPlace::Local {
abs,
cwd,
git_workdir,
} => Some(discover_local(&input, abs, cwd, git_workdir.as_deref())),
DiscoverPlace::Remote { file, client } => {
super::remote::discover(&input, file, client, token)
}
DiscoverPlace::Container { id, root } => Some(discover_container(&input, id, root)),
}
}
fn discover_local(
input: &DiscoverInput,
abs: &Path,
cwd: &Path,
git_workdir: Option<&Path>,
) -> AttachRecord {
let DiscoverInput {
ticket,
ext,
language,
state_dir,
xdg,
transport,
..
} = input;
let languages = strop_lsp::languages::Languages::load(
xdg.as_deref(),
strop_lsp::languages::project_path(abs).as_deref(),
);
let layers: Vec<LayerDiagnostic> = languages.layer_diagnostics().to_vec();
let refused = |outcome: AttachDecision, name: String, root: PathBuf| AttachRecord {
ticket: *ticket,
server: None,
language: language.to_string(),
name,
root,
target: Filesystem::Local,
outcome,
layers: layers.clone(),
};
let Some(spec) = registry::for_extension(ext, &languages) else {
return refused(
AttachDecision::NoServer,
language.to_string(),
cwd.to_owned(),
);
};
let name = spec.name.to_string();
let root = match languages.project_root.as_deref() {
Some(root) => root.to_path_buf(),
None => match git_workdir {
Some(workdir) => workdir.to_path_buf(),
None => registry::workspace_root(abs, cwd),
},
};
if let Some(outcome) = trust_refusal(&spec, state_dir.as_deref(), &root) {
return refused(outcome, name, root);
}
match registry::command_status(&spec, &root, std::env::var_os("PATH").as_deref()) {
registry::CommandStatus::Executable => {}
registry::CommandStatus::Unrunnable(reason) => {
let decision = AttachDecision::NotExecutable {
command: spec.command.to_string(),
reason: reason.to_string(),
hint: install_hint(&spec),
};
return refused(decision, name, root);
}
}
let (tx, rx) = channel();
match Client::spawn(
&spec,
strop_lsp::Workspace::Local { root: root.clone() },
tx,
) {
Ok(client) => {
let server = client.id();
if let Ok(mut table) = transport.lock() {
table.insert(server, LiveTransport { client, rx });
}
AttachRecord {
ticket: *ticket,
server: Some(server),
language: language.to_string(),
name,
root,
target: Filesystem::Local,
outcome: AttachDecision::Attached,
layers,
}
}
Err(error) => refused(
AttachDecision::SpawnFailed {
reason: error.to_string(),
},
name,
root,
),
}
}
fn discover_container(
input: &DiscoverInput,
id: &strop_workspace::ContainerId,
root: &Path,
) -> AttachRecord {
let languages = strop_lsp::languages::Languages::load(input.xdg.as_deref(), None);
let layers: Vec<LayerDiagnostic> = languages.layer_diagnostics().to_vec();
let target = Filesystem::Container(id.clone());
let refused = |outcome: AttachDecision, name: String| AttachRecord {
ticket: input.ticket,
server: None,
language: input.language.to_string(),
name,
root: root.to_path_buf(),
target: target.clone(),
outcome,
layers: layers.clone(),
};
let Some(spec) = registry::for_extension(&input.ext, &languages) else {
return refused(AttachDecision::NoServer, input.language.to_string());
};
let name = spec.name.to_string();
let (tx, rx) = channel();
match Client::spawn(
&spec,
strop_lsp::Workspace::Container {
container: id.clone(),
root: root.to_path_buf(),
},
tx,
) {
Ok(client) => {
let server = client.id();
if let Ok(mut table) = input.transport.lock() {
table.insert(server, LiveTransport { client, rx });
}
AttachRecord {
ticket: input.ticket,
server: Some(server),
language: input.language.to_string(),
name,
root: root.to_path_buf(),
target,
outcome: AttachDecision::Attached,
layers,
}
}
Err(error) => refused(
AttachDecision::SpawnFailed {
reason: error.to_string(),
},
name,
),
}
}
fn trust_refusal(
spec: &ServerSpec<'_>,
state_dir: Option<&std::path::Path>,
root: &std::path::Path,
) -> Option<AttachDecision> {
if !spec.project_executable {
return None;
}
match crate::session::is_trusted(state_dir, root) {
Ok(true) => None,
Ok(false) => Some(AttachDecision::TrustRequired {
command: spec.command.to_string(),
}),
Err(error) => Some(AttachDecision::TrustError {
error: error.to_string(),
}),
}
}
pub(super) fn install_hint(spec: &ServerSpec<'_>) -> String {
match spec.install_hint {
Some(hint) => hint.to_string(),
None => format!(
"install `{}` or fix the command in languages.toml",
spec.command
),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn input(place: DiscoverPlace, ext: &str) -> DiscoverInput {
DiscoverInput {
ticket: WorkerId::new(0),
place,
ext: ext.into(),
language: "nosuchlanguage",
state_dir: None,
xdg: None,
transport: Arc::new(Mutex::new(HashMap::new())),
}
}
#[test]
fn attach_keys_separate_local_from_remote_targets() {
let endpoint = strop_workspace::RemoteEndpoint::parse("ssh://builder.example").unwrap();
let local = AttachKey {
target: Filesystem::Local,
language: "rust".into(),
path: "/workspace/a.rs".into(),
};
let remote = AttachKey {
target: Filesystem::Remote(endpoint),
language: "rust".into(),
path: "/workspace/a.rs".into(),
};
assert_ne!(local, remote);
let mut pending = HashMap::new();
pending.insert(local, WorkerId::new(1));
assert!(!pending.contains_key(&remote));
}
#[test]
fn attach_args_replay_legacy_local_records_as_local() {
let legacy = r#"{"ticket":0,"path":"/w/a.rs","language":"rust"}"#;
let args: AttachArgs = serde_json::from_str(legacy).unwrap();
assert_eq!(args.target, Filesystem::Local);
}
#[test]
fn decision_labels_are_stable() {
assert_eq!(
AttachDecision::RemoteIo { reason: "x".into() }.label(),
"remote_io"
);
assert_eq!(AttachDecision::Attached.label(), "attached");
}
#[test]
fn local_discovery_refuses_without_a_server() {
let dir = std::path::Path::new("/w/definitely-not-here");
let abs = dir.join("a.nosuchlang");
let record = discover_local(
&input(
DiscoverPlace::Local {
abs: abs.clone(),
cwd: dir.to_path_buf(),
git_workdir: None,
},
".nosuchlang",
),
&abs,
dir,
None,
);
assert_eq!(record.outcome, AttachDecision::NoServer);
assert_eq!(record.target, Filesystem::Local);
assert_eq!(record.root, dir);
assert!(record.layers.is_empty());
}
}