1use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::sync::mpsc::{channel, Receiver, Sender};
12use std::sync::{Arc, Mutex};
13
14use strop_core::worker::{CancelToken, WorkerId};
15use strop_lsp::languages::LayerDiagnostic;
16use strop_lsp::registry::{self, ServerSpec};
17use strop_lsp::{Client, LspEvent, ServerId};
18use strop_workspace::Filesystem;
19
20pub(crate) struct LiveTransport {
23 pub client: Client,
24 pub rx: Receiver<LspEvent>,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
32pub(crate) struct AttachArgs {
33 pub ticket: WorkerId,
34 #[serde(with = "strop_core::path_serde")]
35 pub path: PathBuf,
36 pub language: String,
37 #[serde(default)]
38 pub target: Filesystem,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
44pub struct AttachRecord {
45 pub ticket: WorkerId,
46 pub server: Option<ServerId>,
47 pub language: String,
48 pub name: String,
49 #[serde(with = "strop_core::path_serde")]
50 pub root: PathBuf,
51 #[serde(default)]
52 pub target: Filesystem,
53 pub outcome: AttachDecision,
54 #[serde(default)]
58 pub layers: Vec<LayerDiagnostic>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
62pub enum AttachDecision {
63 Attached,
64 NoServer,
65 Cancelled,
66 TrustRequired {
67 command: String,
68 },
69 TrustError {
70 error: String,
71 },
72 NotExecutable {
73 #[serde(default)]
74 command: String,
75 #[serde(default)]
76 reason: String,
77 hint: String,
78 },
79 SpawnFailed {
80 #[serde(default)]
81 reason: String,
82 },
83 RemoteIo {
87 #[serde(default)]
88 reason: String,
89 },
90}
91
92impl AttachDecision {
93 pub(crate) fn label(&self) -> &'static str {
95 match self {
96 Self::Attached => "attached",
97 Self::NoServer => "no_server",
98 Self::Cancelled => "cancelled",
99 Self::TrustRequired { .. } => "trust_required",
100 Self::TrustError { .. } => "trust_error",
101 Self::NotExecutable { .. } => "not_executable",
102 Self::SpawnFailed { .. } => "spawn_failed",
103 Self::RemoteIo { .. } => "remote_io",
104 }
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Hash)]
112pub(crate) struct AttachKey {
113 pub target: Filesystem,
114 pub language: String,
115 pub path: PathBuf,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
121pub(crate) struct Attachment {
122 pub language: String,
123 pub root: PathBuf,
124 pub server: ServerId,
125 pub target: Filesystem,
126}
127
128pub(crate) struct AttachState {
129 pub enabled: bool,
133 pub pending: HashMap<AttachKey, WorkerId>,
137 pub refused: HashMap<AttachKey, AttachDecision>,
141 pub trust_roots: HashMap<AttachKey, PathBuf>,
142 pub layer_diagnostics: Vec<LayerDiagnostic>,
145 pub attached: Vec<Attachment>,
147 pub transport: Arc<Mutex<HashMap<ServerId, LiveTransport>>>,
149 pub rx: Receiver<AttachRecord>,
150 tx: Sender<AttachRecord>,
151}
152
153impl AttachState {
154 pub fn new() -> Self {
155 let (tx, rx) = channel();
156 Self {
157 enabled: false,
158 pending: HashMap::new(),
159 refused: HashMap::new(),
160 trust_roots: HashMap::new(),
161 layer_diagnostics: Vec::new(),
162 attached: Vec::new(),
163 transport: Arc::new(Mutex::new(HashMap::new())),
164 rx,
165 tx,
166 }
167 }
168
169 pub fn take_rx(&mut self) -> Receiver<AttachRecord> {
172 let (_, empty) = channel();
173 std::mem::replace(&mut self.rx, empty)
174 }
175
176 pub fn attach_channel(&self) -> Sender<AttachRecord> {
178 self.tx.clone()
179 }
180}
181
182pub(crate) enum DiscoverPlace {
186 Local {
187 abs: PathBuf,
188 cwd: PathBuf,
189 git_workdir: Option<PathBuf>,
190 },
191 Remote {
192 file: strop_workspace::RemoteFile,
193 client: strop_remote::RemoteClient,
194 },
195 Container {
198 id: strop_workspace::ContainerId,
199 root: PathBuf,
200 },
201}
202
203pub(crate) struct DiscoverInput {
205 pub ticket: WorkerId,
206 pub place: DiscoverPlace,
207 pub ext: String,
208 pub language: &'static str,
209 pub state_dir: Option<PathBuf>,
210 pub xdg: Option<PathBuf>,
214 pub transport: Arc<Mutex<HashMap<ServerId, LiveTransport>>>,
215}
216
217pub(crate) fn discover(input: DiscoverInput, token: &CancelToken) -> Option<AttachRecord> {
224 match &input.place {
225 DiscoverPlace::Local {
226 abs,
227 cwd,
228 git_workdir,
229 } => Some(discover_local(&input, abs, cwd, git_workdir.as_deref())),
230 DiscoverPlace::Remote { file, client } => {
231 super::remote::discover(&input, file, client, token)
232 }
233 DiscoverPlace::Container { id, root } => Some(discover_container(&input, id, root)),
234 }
235}
236
237fn discover_local(
240 input: &DiscoverInput,
241 abs: &Path,
242 cwd: &Path,
243 git_workdir: Option<&Path>,
244) -> AttachRecord {
245 let DiscoverInput {
246 ticket,
247 ext,
248 language,
249 state_dir,
250 xdg,
251 transport,
252 ..
253 } = input;
254 let languages = strop_lsp::languages::Languages::load(
255 xdg.as_deref(),
256 strop_lsp::languages::project_path(abs).as_deref(),
257 );
258 let layers: Vec<LayerDiagnostic> = languages.layer_diagnostics().to_vec();
261 let refused = |outcome: AttachDecision, name: String, root: PathBuf| AttachRecord {
262 ticket: *ticket,
263 server: None,
264 language: language.to_string(),
265 name,
266 root,
267 target: Filesystem::Local,
268 outcome,
269 layers: layers.clone(),
270 };
271 let Some(spec) = registry::for_extension(ext, &languages) else {
272 return refused(
273 AttachDecision::NoServer,
274 language.to_string(),
275 cwd.to_owned(),
276 );
277 };
278 let name = spec.name.to_string();
279 let root = match languages.project_root.as_deref() {
280 Some(root) => root.to_path_buf(),
281 None => match git_workdir {
282 Some(workdir) => workdir.to_path_buf(),
283 None => registry::workspace_root(abs, cwd),
284 },
285 };
286 if let Some(outcome) = trust_refusal(&spec, state_dir.as_deref(), &root) {
287 return refused(outcome, name, root);
288 }
289 match registry::command_status(&spec, &root, std::env::var_os("PATH").as_deref()) {
293 registry::CommandStatus::Executable => {}
294 registry::CommandStatus::Unrunnable(reason) => {
295 let decision = AttachDecision::NotExecutable {
296 command: spec.command.to_string(),
297 reason: reason.to_string(),
298 hint: install_hint(&spec),
299 };
300 return refused(decision, name, root);
301 }
302 }
303 let (tx, rx) = channel();
304 match Client::spawn(
305 &spec,
306 strop_lsp::Workspace::Local { root: root.clone() },
307 tx,
308 ) {
309 Ok(client) => {
310 let server = client.id();
311 if let Ok(mut table) = transport.lock() {
312 table.insert(server, LiveTransport { client, rx });
313 }
314 AttachRecord {
315 ticket: *ticket,
316 server: Some(server),
317 language: language.to_string(),
318 name,
319 root,
320 target: Filesystem::Local,
321 outcome: AttachDecision::Attached,
322 layers,
323 }
324 }
325 Err(error) => refused(
326 AttachDecision::SpawnFailed {
327 reason: error.to_string(),
328 },
329 name,
330 root,
331 ),
332 }
333}
334
335fn discover_container(
340 input: &DiscoverInput,
341 id: &strop_workspace::ContainerId,
342 root: &Path,
343) -> AttachRecord {
344 let languages = strop_lsp::languages::Languages::load(input.xdg.as_deref(), None);
345 let layers: Vec<LayerDiagnostic> = languages.layer_diagnostics().to_vec();
346 let target = Filesystem::Container(id.clone());
347 let refused = |outcome: AttachDecision, name: String| AttachRecord {
348 ticket: input.ticket,
349 server: None,
350 language: input.language.to_string(),
351 name,
352 root: root.to_path_buf(),
353 target: target.clone(),
354 outcome,
355 layers: layers.clone(),
356 };
357 let Some(spec) = registry::for_extension(&input.ext, &languages) else {
358 return refused(AttachDecision::NoServer, input.language.to_string());
359 };
360 let name = spec.name.to_string();
361 let (tx, rx) = channel();
362 match Client::spawn(
363 &spec,
364 strop_lsp::Workspace::Container {
365 container: id.clone(),
366 root: root.to_path_buf(),
367 },
368 tx,
369 ) {
370 Ok(client) => {
371 let server = client.id();
372 if let Ok(mut table) = input.transport.lock() {
373 table.insert(server, LiveTransport { client, rx });
374 }
375 AttachRecord {
376 ticket: input.ticket,
377 server: Some(server),
378 language: input.language.to_string(),
379 name,
380 root: root.to_path_buf(),
381 target,
382 outcome: AttachDecision::Attached,
383 layers,
384 }
385 }
386 Err(error) => refused(
387 AttachDecision::SpawnFailed {
388 reason: error.to_string(),
389 },
390 name,
391 ),
392 }
393}
394
395fn trust_refusal(
396 spec: &ServerSpec<'_>,
397 state_dir: Option<&std::path::Path>,
398 root: &std::path::Path,
399) -> Option<AttachDecision> {
400 if !spec.project_executable {
401 return None;
402 }
403 match crate::session::is_trusted(state_dir, root) {
404 Ok(true) => None,
405 Ok(false) => Some(AttachDecision::TrustRequired {
406 command: spec.command.to_string(),
407 }),
408 Err(error) => Some(AttachDecision::TrustError {
409 error: error.to_string(),
410 }),
411 }
412}
413
414pub(super) fn install_hint(spec: &ServerSpec<'_>) -> String {
415 match spec.install_hint {
416 Some(hint) => hint.to_string(),
417 None => format!(
418 "install `{}` or fix the command in languages.toml",
419 spec.command
420 ),
421 }
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 fn input(place: DiscoverPlace, ext: &str) -> DiscoverInput {
429 DiscoverInput {
430 ticket: WorkerId::new(0),
431 place,
432 ext: ext.into(),
433 language: "nosuchlanguage",
434 state_dir: None,
435 xdg: None,
436 transport: Arc::new(Mutex::new(HashMap::new())),
437 }
438 }
439
440 #[test]
441 fn attach_keys_separate_local_from_remote_targets() {
442 let endpoint = strop_workspace::RemoteEndpoint::parse("ssh://builder.example").unwrap();
443 let local = AttachKey {
444 target: Filesystem::Local,
445 language: "rust".into(),
446 path: "/workspace/a.rs".into(),
447 };
448 let remote = AttachKey {
449 target: Filesystem::Remote(endpoint),
450 language: "rust".into(),
451 path: "/workspace/a.rs".into(),
452 };
453 assert_ne!(local, remote);
454 let mut pending = HashMap::new();
457 pending.insert(local, WorkerId::new(1));
458 assert!(!pending.contains_key(&remote));
459 }
460
461 #[test]
462 fn attach_args_replay_legacy_local_records_as_local() {
463 let legacy = r#"{"ticket":0,"path":"/w/a.rs","language":"rust"}"#;
466 let args: AttachArgs = serde_json::from_str(legacy).unwrap();
467 assert_eq!(args.target, Filesystem::Local);
468 }
469
470 #[test]
471 fn decision_labels_are_stable() {
472 assert_eq!(
473 AttachDecision::RemoteIo { reason: "x".into() }.label(),
474 "remote_io"
475 );
476 assert_eq!(AttachDecision::Attached.label(), "attached");
477 }
478
479 #[test]
480 fn local_discovery_refuses_without_a_server() {
481 let dir = std::path::Path::new("/w/definitely-not-here");
484 let abs = dir.join("a.nosuchlang");
485 let record = discover_local(
486 &input(
487 DiscoverPlace::Local {
488 abs: abs.clone(),
489 cwd: dir.to_path_buf(),
490 git_workdir: None,
491 },
492 ".nosuchlang",
493 ),
494 &abs,
495 dir,
496 None,
497 );
498 assert_eq!(record.outcome, AttachDecision::NoServer);
499 assert_eq!(record.target, Filesystem::Local);
500 assert_eq!(record.root, dir);
501 assert!(record.layers.is_empty());
502 }
503}