use std::path::{Path, PathBuf};
use std::sync::mpsc::channel;
use strop_core::worker::CancelToken;
use strop_remote::{
ReadFailureKind, ReadLimit, ReadSelection, RemoteClient, RemoteCommand, RemoteCommandError,
RemoteOffset,
};
use strop_workspace::{RemoteEndpoint, RemoteFile, RemoteLocation};
use super::attach::{AttachDecision, AttachRecord, DiscoverInput, LiveTransport};
use strop_lsp::languages::{Languages, LayerDiagnostic, RemoteLayer};
use strop_lsp::registry::{self, ServerSpec};
use strop_lsp::{Client, ServerId, Workspace};
const CONFIG_READ_LIMIT_BYTES: u64 = 256 * 1024;
enum Fetch {
Found(Vec<u8>),
Missing,
Cancelled,
Failed(String),
Transport(String),
}
fn fetch_small(
client: &RemoteClient,
seed: &RemoteFile,
path: &Path,
token: &CancelToken,
) -> Fetch {
let file = match seed.with_path(path.to_path_buf()) {
Ok(file) => file,
Err(error) => return Fetch::Failed(format!("not a canonical remote path: {error}")),
};
let selection = match ReadLimit::new(CONFIG_READ_LIMIT_BYTES) {
Ok(length) => ReadSelection::Range {
start: RemoteOffset::new(0),
length,
},
Err(error) => return Fetch::Failed(error.to_string()),
};
match client.read(&RemoteLocation::from(file), selection, token) {
Ok(snapshot) if snapshot.window.is_complete() => {
Fetch::Found(snapshot.buffer.text().to_string().into_bytes())
}
Ok(_) => {
Fetch::Failed("remote language configuration exceeds its bounded read limit".into())
}
Err(error) => match error.kind() {
ReadFailureKind::NotFound => Fetch::Missing,
ReadFailureKind::Cancelled => Fetch::Cancelled,
ReadFailureKind::Auth
| ReadFailureKind::Trust
| ReadFailureKind::Network
| ReadFailureKind::Connect
| ReadFailureKind::Deadline
| ReadFailureKind::Spawn
| ReadFailureKind::Subsystem => Fetch::Transport(error.to_string()),
_ => Fetch::Failed(error.to_string()),
},
}
}
enum LayerSearch {
Found(PathBuf, Vec<u8>),
Malformed(LayerDiagnostic),
None,
Cancelled,
Io(String),
}
fn find_project_layer(
client: &RemoteClient,
seed: &RemoteFile,
abs: &Path,
token: &CancelToken,
) -> LayerSearch {
let Some(mut dir) = abs.parent().map(Path::to_path_buf) else {
return LayerSearch::None;
};
let endpoint = seed.endpoint();
loop {
let candidate = dir.join(".strop").join("languages.toml");
match fetch_small(client, seed, &candidate, token) {
Fetch::Found(bytes) => return LayerSearch::Found(candidate, bytes),
Fetch::Missing => {}
Fetch::Cancelled => return LayerSearch::Cancelled,
Fetch::Transport(reason) => return LayerSearch::Io(reason),
Fetch::Failed(reason) => {
return LayerSearch::Malformed(LayerDiagnostic::remote_layer(
endpoint,
&candidate,
format!("{reason} — layer ignored"),
));
}
}
if !dir.pop() {
return LayerSearch::None;
}
}
}
fn workspace_root(endpoint: &RemoteEndpoint, parent: &Path, token: &CancelToken) -> PathBuf {
let Ok(command) = RemoteCommand::new(
"git",
vec!["rev-parse".into(), "--show-toplevel".into()],
parent,
) else {
return parent.to_path_buf();
};
match strop_remote::run(endpoint, &command, token) {
Ok(output) if output.status.success() => {
let text = String::from_utf8_lossy(&output.stdout);
let first = text
.lines()
.next()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(PathBuf::from)
.filter(|path| path.is_absolute());
first.unwrap_or_else(|| parent.to_path_buf())
}
_ => parent.to_path_buf(),
}
}
enum Executability {
Executable,
Refused { reason: String },
Cancelled,
Failed(String),
}
fn remote_executable(
endpoint: &RemoteEndpoint,
spec: &ServerSpec<'_>,
root: &Path,
token: &CancelToken,
) -> Executability {
let probe = if spec.command.contains('/') {
"test -x \"$1\""
} else {
"command -v -- \"$1\""
};
let Ok(command) = RemoteCommand::new(
"sh",
vec!["-c".into(), probe.into(), "sh".into(), spec.command.into()],
root,
) else {
return Executability::Refused {
reason: "empty command".into(),
};
};
match strop_remote::run(endpoint, &command, token) {
Ok(output) if output.status.success() => Executability::Executable,
Ok(output) => {
let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
let reason = if detail.is_empty() {
"not found on the remote host".to_string()
} else {
detail
};
Executability::Refused { reason }
}
Err(RemoteCommandError::Cancelled { .. }) => Executability::Cancelled,
Err(error) => Executability::Failed(error.to_string()),
}
}
pub(super) fn discover(
input: &DiscoverInput,
file: &RemoteFile,
client: &RemoteClient,
token: &CancelToken,
) -> Option<AttachRecord> {
let DiscoverInput {
ticket,
ext,
language,
state_dir,
xdg,
transport,
..
} = input;
let endpoint = file.endpoint().clone();
let abs = file.path().to_path_buf();
let target = strop_workspace::Filesystem::Remote(endpoint.clone());
let parent = abs.parent().map(Path::to_path_buf).unwrap_or_default();
let record = |server: Option<ServerId>,
name: String,
root: PathBuf,
outcome: AttachDecision,
layers: Vec<LayerDiagnostic>| AttachRecord {
ticket: *ticket,
server,
language: language.to_string(),
name,
root,
target: target.clone(),
outcome,
layers,
};
let mut layers: Vec<LayerDiagnostic> = Vec::new();
let project = match find_project_layer(client, file, &abs, token) {
LayerSearch::Found(path, bytes) => Some((path, bytes)),
LayerSearch::Malformed(diagnostic) => {
layers.push(diagnostic);
None
}
LayerSearch::None => None,
LayerSearch::Cancelled => return None,
LayerSearch::Io(reason) => {
return Some(record(
None,
language.to_string(),
parent,
AttachDecision::RemoteIo { reason },
layers,
));
}
};
let languages = Languages::load_remote(
xdg.as_deref(),
project.as_ref().map(|(path, bytes)| RemoteLayer {
endpoint: &endpoint,
path,
bytes,
}),
);
layers.extend(languages.layer_diagnostics().iter().cloned());
let root = match project.as_ref().map(|(path, _)| path) {
Some(config) => config
.parent()
.and_then(Path::parent)
.map(Path::to_path_buf)
.unwrap_or_else(|| parent.clone()),
None => workspace_root(&endpoint, &parent, token),
};
let Some(spec) = registry::for_extension(ext, &languages) else {
return Some(record(
None,
language.to_string(),
root,
AttachDecision::NoServer,
layers,
));
};
let name = spec.name.to_string();
if spec.project_executable {
match crate::session::is_trusted_remote(state_dir.as_deref(), &endpoint, &root) {
Ok(true) => {}
Ok(false) => {
return Some(record(
None,
name,
root,
AttachDecision::TrustRequired {
command: spec.command.to_string(),
},
layers,
))
}
Err(error) => {
return Some(record(
None,
name,
root,
AttachDecision::TrustError {
error: error.to_string(),
},
layers,
))
}
}
}
match remote_executable(&endpoint, &spec, &root, token) {
Executability::Executable => {}
Executability::Cancelled => return None,
Executability::Failed(reason) => {
return Some(record(
None,
name,
root,
AttachDecision::RemoteIo { reason },
layers,
))
}
Executability::Refused { reason } => {
return Some(record(
None,
name,
root,
AttachDecision::NotExecutable {
command: spec.command.to_string(),
reason,
hint: super::attach::install_hint(&spec),
},
layers,
))
}
}
let (tx, rx) = channel();
match Client::spawn(
&spec,
Workspace::Remote {
endpoint: endpoint.clone(),
root: root.clone(),
},
tx,
) {
Ok(client) => {
let server = client.id();
if let Ok(mut table) = transport.lock() {
table.insert(server, LiveTransport { client, rx });
}
Some(record(
Some(server),
name,
root,
AttachDecision::Attached,
layers,
))
}
Err(error) => Some(record(
None,
name,
root,
AttachDecision::SpawnFailed {
reason: error.to_string(),
},
layers,
)),
}
}