Skip to main content

strop_engine/editor/lsp/
attach.rs

1//! Asynchronous attach discovery (R6): config layers, workspace root,
2//! trust and the executability check run on a worker thread — never on
3//! the dispatch path. Completion is a pure serializable `AttachRecord`
4//! (R11); the live transport is handed over through a side table keyed
5//! by server identity, so replay injects records without spawning or
6//! faking a client. Remote discovery (0036 RW8) lives in
7//! [`super::remote`]; this module owns the shared record/key types and
8//! the local path.
9use 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
20/// A live connection produced by discovery: the client handle plus the
21/// event stream every server owns.
22pub(crate) struct LiveTransport {
23    pub client: Client,
24    pub rx: Receiver<LspEvent>,
25}
26
27/// What discovery was asked to do — the tape records this before any
28/// native config/trust/executability work runs. `target` distinguishes
29/// the local workspace from a remote endpoint: the same language on
30/// two filesystems is two different attempts.
31#[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/// The serializable outcome of one attach attempt. Refusals carry
42/// `server: None`; a spawned (or replayed) server keeps its identity.
43#[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    /// Malformed layer diagnostics met while loading the config layers
55    /// for this attempt (0033 §2) — reported even when a valid
56    /// fallback server attached.
57    #[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    /// Remote discovery infrastructure failed (owned connection lost,
84    /// remote probe unreachable). Non-sticky: the next attach attempt
85    /// re-discovers rather than caching a transient failure.
86    RemoteIo {
87        #[serde(default)]
88        reason: String,
89    },
90}
91
92impl AttachDecision {
93    /// Stable trace label for attach completions.
94    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/// One attach attempt's identity: filesystem target + language. A
109/// remote workspace and a local one sharing a language never share a
110/// pending attempt, refusal or placement (0036 RW8).
111#[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/// One live/replayed server placement: a language inside a root on one
119/// filesystem.
120#[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    /// Services start explicitly (the startup action), never as a
130    /// constructor side effect — live and replayed runs perform the
131    /// same transition. Default false: pure test editors never spawn.
132    pub enabled: bool,
133    /// Attach discovery in flight: key → owning ticket. Later attempts
134    /// replace the ticket, so stale completions are refused and the
135    /// superseded worker is cancelled.
136    pub pending: HashMap<AttachKey, WorkerId>,
137    /// Terminal refusals per key. Trust and remote-io refusals
138    /// re-check on every attach attempt (`:trust` must work;
139    /// connections recover); the rest are reported once.
140    pub refused: HashMap<AttachKey, AttachDecision>,
141    pub trust_roots: HashMap<AttachKey, PathBuf>,
142    /// Malformed layer diagnostics ever reported by discovery, deduped
143    /// — a healthy Ready must not erase them (0033 §2).
144    pub layer_diagnostics: Vec<LayerDiagnostic>,
145    /// Servers placed for (target, language, root) — live or replayed.
146    pub attached: Vec<Attachment>,
147    /// Live transports published by discovery workers, keyed by server.
148    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    /// Hand the completion channel to the event forwarder (TUI); the
170    /// headless drains read `rx` directly.
171    pub fn take_rx(&mut self) -> Receiver<AttachRecord> {
172        let (_, empty) = channel();
173        std::mem::replace(&mut self.rx, empty)
174    }
175
176    /// A sender for one more discovery worker.
177    pub fn attach_channel(&self) -> Sender<AttachRecord> {
178        self.tx.clone()
179    }
180}
181
182/// Where discovery runs: the local workspace, or one remote endpoint
183/// whose canonical trigger file is `file` and whose owned connection is
184/// reachable through `client`.
185pub(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    /// A running container (0037 DC1b): the engine is local, the id is
196    /// the canonical inspect id, root names container paths.
197    Container {
198        id: strop_workspace::ContainerId,
199        root: PathBuf,
200    },
201}
202
203/// Everything the discovery worker owns for one attempt.
204pub(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    /// The resolved XDG layer path (`languages::xdg_path()`), injected
211    /// so tests never read a real HOME. Trusted local user
212    /// configuration — the only local layer a remote attach reads.
213    pub xdg: Option<PathBuf>,
214    pub transport: Arc<Mutex<HashMap<ServerId, LiveTransport>>>,
215}
216
217/// Native discovery, dispatched by target: local layers → spec →
218/// workspace root → trust → executability → spawn, or the remote
219/// equivalent in [`super::remote`]. Runs entirely on the discovery
220/// worker; the only editor contact is the completion record (and, on
221/// success, the side-table transport). `None` means the attempt was
222/// cancelled — nothing is reported.
223pub(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
237/// Local discovery: config layers → spec → workspace root → trust →
238/// executability → spawn.
239fn 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    // Malformed layers ride along with every outcome (0033 §2) — even
259    // a healthy fallback attach must keep diagnosing them.
260    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    // The executability check is pure metadata (0033 §3): no process is
290    // spawned, so no orphan probe exists and an untrusted project
291    // command is never executed merely to test it.
292    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
335/// Container discovery (0037 DC1b): XDG/embedded layers only — an
336/// in-container project languages.toml is deliberately not read yet (it
337/// joins the trust gate when it is). The server binary's absence is
338/// classified by the spawn, honestly, from the engine's own error.
339fn 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        // Pending and refusal maps keyed this way never conflate a
455        // local attach with a remote one for the same language.
456        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        // Records from before remote targets existed carry no target
464        // field; they must deserialize as local attempts.
465        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        // An extension no layer or registry entry covers: an honest
482        // NoServer refusal, target local, no layer diagnostics.
483        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}