use std::{
collections::BTreeMap,
ops::Range,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use cosh_sdk::lsp::{
DiagnosticsEngine, LanguageServer, LspError, Manager, PositionEncoding,
lsp_types::{
DocumentChangeOperation, DocumentChanges, OneOf, Position, ServerCapabilities, TextEdit,
WorkspaceEdit,
},
offset_to_position, position_to_offset, uri_to_path,
};
pub struct Deps<'a> {
pub manager: &'a Manager,
pub diagnostics: &'a DiagnosticsEngine,
pub request_timeout: Duration,
pub settle_cap: Duration,
}
pub async fn prepare(deps: &Deps<'_>, path: &Path) -> Result<Vec<Arc<LanguageServer>>, String> {
let handles = deps
.manager
.ensure_for_file(path)
.await
.map_err(|err| format!("no language server for `{}`: {err:#}", path.display()))?;
if handles.is_empty() {
return Err(format!(
"no language server for `{}` (file type not supported or binary missing)",
path.display()
));
}
for handle in &handles {
if let Err(err) = handle.touch_file(path).await {
return Err(format!(
"failed to open `{}` on {}: {err}",
path.display(),
handle.name()
));
}
}
deps.diagnostics.wait_for_settle(deps.settle_cap).await;
Ok(handles)
}
pub async fn resolve_target(
deps: &Deps<'_>,
path: &Path,
position: &Option<super::types::Position1>,
symbol: &Option<String>,
) -> Result<(Position, Vec<Arc<LanguageServer>>), String> {
let clients = prepare(deps, path).await?;
if clients.is_empty() {
return Err(format!("no language server handles `{}`", path.display()));
}
let encoding = clients[0].position_encoding();
let text = std::fs::read_to_string(path)
.map_err(|err| format!("cannot read `{}`: {err}", path.display()))?;
let target = match position {
Some(given) => {
let raw = Position {
line: given.line.saturating_sub(1),
character: given.character.saturating_sub(1),
};
let offset = position_to_offset(&text, raw, encoding);
offset_to_position(&text, offset, encoding).unwrap_or(raw)
}
None => {
let symbol = symbol
.as_deref()
.ok_or("provide either `position` or `symbol`")?;
let offset = locate_symbol(&text, symbol)?;
offset_to_position(&text, offset, encoding)
.ok_or_else(|| format!("symbol `{symbol}` landed mid-character"))?
}
};
Ok((target, clients))
}
fn locate_symbol(text: &str, symbol: &str) -> Result<usize, String> {
if symbol.is_empty() {
return Err("empty symbol".into());
}
let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
let mut from = 0usize;
while let Some(rel) = text[from..].find(symbol) {
let start = from + rel;
let end = start + symbol.len();
let before_ok = text[..start]
.chars()
.next_back()
.is_none_or(|c| !is_word_char(c));
let after_ok = text[end..].chars().next().is_none_or(|c| !is_word_char(c));
if before_ok && after_ok {
return Ok(start);
}
from = end.max(from + 1);
}
Err(format!("symbol `{symbol}` not found"))
}
pub fn uri(path: &Path) -> Result<cosh_sdk::lsp::lsp_types::Uri, String> {
cosh_sdk::lsp::uri_from_path(path).map_err(|err| err.to_string())
}
pub fn provider_enabled<T>(provider: &Option<OneOf<bool, T>>) -> bool {
match provider {
Some(OneOf::Left(enabled)) => *enabled,
Some(OneOf::Right(_)) => true,
None => false,
}
}
pub async fn first_answer<T, P, F>(
clients: &[Arc<LanguageServer>],
timeout: Duration,
supported: F,
query: P,
) -> Result<Option<T>, String>
where
T: serde::de::DeserializeOwned,
P: Fn(&LanguageServer) -> (String, serde_json::Value),
F: Fn(&ServerCapabilities) -> bool,
{
let mut last_error: Option<String> = None;
let mut saw_capable = false;
for client in clients {
let capable = client.capabilities().is_some_and(|caps| supported(&caps));
if !capable {
continue;
}
saw_capable = true;
let (method, params) = query(client);
match client.request_raw(&method, Some(params), timeout).await {
Ok(value) if value.is_null() => continue,
Ok(value) => {
let parsed: T = serde_json::from_value(value).map_err(|err| {
format!("server `{}` returned malformed data: {err}", client.name())
})?;
return Ok(Some(parsed));
}
Err(LspError::Rpc { code: -32601, .. }) => continue,
Err(LspError::Timeout { .. }) => {
last_error = Some(format!("server `{}` timed out", client.name()));
}
Err(err) => {
log::debug!("server `{}` rejected query: {err:#}", client.name());
last_error = Some(format!("{err:#}"));
}
}
}
if !saw_capable {
return Err("no queried server advertises this capability".into());
}
match last_error {
Some(err) => Err(err),
None => Ok(None),
}
}
pub(crate) fn symbol_kind_name(kind: cosh_sdk::lsp::lsp_types::SymbolKind) -> String {
use cosh_sdk::lsp::lsp_types::SymbolKind as K;
match kind {
K::FILE => "file".into(),
K::MODULE | K::NAMESPACE | K::PACKAGE => "module".into(),
K::CLASS => "class".into(),
K::METHOD => "method".into(),
K::PROPERTY => "property".into(),
K::FIELD => "field".into(),
K::CONSTRUCTOR => "constructor".into(),
K::ENUM => "enum".into(),
K::INTERFACE => "interface".into(),
K::FUNCTION => "function".into(),
K::VARIABLE => "variable".into(),
K::CONSTANT => "constant".into(),
K::STRING | K::NUMBER | K::BOOLEAN | K::ARRAY | K::OBJECT | K::KEY | K::NULL => {
"literal".into()
}
K::ENUM_MEMBER => "enum-member".into(),
K::STRUCT => "struct".into(),
K::EVENT => "event".into(),
K::OPERATOR => "operator".into(),
K::TYPE_PARAMETER => "type-parameter".into(),
_ => "symbol".into(),
}
}
pub fn flatten_workspace_edit(
edit: &WorkspaceEdit,
encoding: PositionEncoding,
) -> Result<BTreeMap<String, Vec<PlannedEdit>>, String> {
let mut out: BTreeMap<String, Vec<PlannedEdit>> = BTreeMap::new();
if let Some(document_changes) = &edit.document_changes {
match document_changes {
DocumentChanges::Edits(edits) => {
for doc_edit in edits {
for edit in doc_edit.edits.iter() {
let text_edit = match edit {
OneOf::Left(edit) => edit,
OneOf::Right(annotated) => &annotated.text_edit,
};
push_edit(
uri_to_path(&doc_edit.text_document.uri),
text_edit,
encoding,
&mut out,
);
}
}
}
DocumentChanges::Operations(ops) => {
for op in ops {
match op {
DocumentChangeOperation::Op(resource_op) => {
log::warn!(
"edit plan carries an unhandled resource op: {resource_op:?}"
);
}
DocumentChangeOperation::Edit(doc_edit) => {
for edit in doc_edit.edits.iter() {
let text_edit = match edit {
OneOf::Left(edit) => edit,
OneOf::Right(annotated) => &annotated.text_edit,
};
push_edit(
uri_to_path(&doc_edit.text_document.uri),
text_edit,
encoding,
&mut out,
);
}
}
}
}
}
}
}
if let Some(changes) = &edit.changes {
for (uri, edits) in changes {
for edit in edits {
push_edit(uri_to_path(uri), edit, encoding, &mut out);
}
}
}
Ok(out)
}
fn push_edit(
target: Option<PathBuf>,
edit: &TextEdit,
encoding: PositionEncoding,
out: &mut BTreeMap<String, Vec<PlannedEdit>>,
) {
let Some(path) = target else {
log::warn!("skipping non-file edit target");
return;
};
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(err) => {
log::warn!(
"skipping unreadable edit target `{}`: {err}",
path.display()
);
return;
}
};
let span = Range {
start: position_to_offset(&text, edit.range.start, encoding),
end: position_to_offset(&text, edit.range.end, encoding),
};
out.entry(path.display().to_string())
.or_default()
.push(PlannedEdit {
span,
new_text: edit.new_text.clone(),
line: usize::try_from(edit.range.start.line).unwrap_or(0) + 1,
});
}
pub struct PlannedEdit {
pub span: Range<usize>,
pub new_text: String,
pub line: usize,
}
pub fn apply_edits(path: &Path, planned: &[PlannedEdit]) -> Result<(), String> {
let mut sorted: Vec<&PlannedEdit> = planned.iter().collect();
sorted.sort_by_key(|edit| std::cmp::Reverse(edit.span.start));
for pair in sorted.windows(2) {
let later = pair[0];
let earlier = pair[1];
if earlier.span.end > later.span.start {
return Err(format!(
"overlapping edits in {} — aborting without writing",
path.display()
));
}
}
let mut content = std::fs::read_to_string(path)
.map_err(|err| format!("cannot read `{}`: {err}", path.display()))?;
for edit in &sorted {
if !content.is_char_boundary(edit.span.start) || !content.is_char_boundary(edit.span.end) {
return Err(format!("edits no longer fit `{}`", path.display()));
}
}
for edit in &sorted {
content.replace_range(edit.span.clone(), &edit.new_text);
}
std::fs::write(path, content).map_err(|err| format!("cannot write `{}`: {err}", path.display()))
}