use lsp_types::CompletionTriggerKind;
use super::Translator;
use super::dto::{
Completion, CompletionTrigger, CompletionsResult, InlayHintEntry, InlayHintsResult, Position,
PositionRange, SignatureHelpResult, SignatureInfo, SignatureParameter, lsp_kind_to_u32,
};
use super::navigation::ItemBudget;
use super::positioned::Positioned;
use super::routing::{Capability, IndexingGate};
use crate::bridge::encoding::{LabelOffsets, PositionEncoding};
use crate::bridge::{ClientPath, Indexed};
use crate::error::Result;
fn extract_documentation(doc: lsp_types::Documentation) -> String {
match doc {
lsp_types::Documentation::String(s) => s,
lsp_types::Documentation::MarkupContent(m) => m.value,
}
}
const MAX_SIGNATURE_PARAMETERS: usize = 256;
const RESOLVED_LABEL_BUDGET_MULTIPLE: usize = 4;
fn signature_parameters(
params: Vec<lsp_types::ParameterInformation>,
signature_label: &str,
encoding: PositionEncoding,
) -> Vec<SignatureParameter> {
if params.len() > MAX_SIGNATURE_PARAMETERS {
tracing::warn!(
reported = params.len(),
cap = MAX_SIGNATURE_PARAMETERS,
"signature parameter count exceeds the cap; truncating"
);
}
let offsets = LabelOffsets::new(
signature_label,
params
.iter()
.take(MAX_SIGNATURE_PARAMETERS)
.filter_map(|p| match p.label {
lsp_types::ParameterInformationLabel::Tuple((start, end)) => {
Some(<[u32; 2]>::from((start, end)))
}
lsp_types::ParameterInformationLabel::String(_) => None,
})
.flatten(),
encoding,
);
let mut resolved_budget = signature_label
.len()
.saturating_mul(RESOLVED_LABEL_BUDGET_MULTIPLE);
params
.into_iter()
.take(MAX_SIGNATURE_PARAMETERS)
.map(|param| {
let label = match param.label {
lsp_types::ParameterInformationLabel::String(s) => Some(s),
lsp_types::ParameterInformationLabel::Tuple((start, end)) => {
let resolved = offsets
.substring(start, end)
.filter(|text| text.len() <= resolved_budget);
if let Some(text) = resolved {
resolved_budget = resolved_budget.saturating_sub(text.len());
} else {
tracing::warn!(
start,
end,
encoding = encoding.to_lsp(),
"signature parameter label offsets do not resolve within the \
signature label; omitting the label"
);
}
resolved.map(str::to_string)
}
};
SignatureParameter {
label,
documentation: param.documentation.map(extract_documentation),
}
})
.collect()
}
impl Translator {
pub async fn handle_completions(
&self,
file_path: ClientPath,
position: Position,
trigger: Option<CompletionTrigger>,
) -> Result<CompletionsResult> {
let context = trigger.map(|trigger_char| lsp_types::CompletionContext {
trigger_kind: CompletionTriggerKind::TriggerCharacter,
trigger_character: Some(trigger_char.into_string()),
});
let Positioned {
result: response,
ctx,
doc: _doc,
} = self
.position_request::<lsp_types::CompletionRequest>(
&file_path,
position,
IndexingGate::Required(Capability::Completions),
context,
)
.await?;
let items = match response {
Some(lsp_types::CompletionResponse::CompletionItemList(items)) => items,
Some(lsp_types::CompletionResponse::CompletionList(list)) => list.items,
None => vec![],
};
let result = CompletionsResult {
items: items
.into_iter()
.map(|item| Completion {
label: item.label,
kind: item.kind.map(lsp_kind_to_u32),
detail: item.detail,
documentation: item.documentation.map(extract_documentation),
})
.collect(),
positions_degraded: ctx.positions_degraded(),
};
Ok(result)
}
pub async fn handle_signature_help(
&self,
file_path: ClientPath,
position: Position,
) -> Result<Indexed<SignatureHelpResult>> {
let Positioned {
result:
Indexed {
result: response,
indexing,
},
ctx,
doc: _doc,
} = self
.disclosed_position_request::<lsp_types::SignatureHelpRequest>(&file_path, position, ())
.await?;
let result = match response {
Some(sig_help) => SignatureHelpResult {
signatures: sig_help
.signatures
.into_iter()
.map(|sig| SignatureInfo {
parameters: signature_parameters(
sig.parameters.unwrap_or_default(),
&sig.label,
ctx.encoding,
),
documentation: sig.documentation.map(extract_documentation),
label: sig.label,
})
.collect(),
active_signature: sig_help.active_signature,
active_parameter: sig_help.active_parameter.and_then(|ap| match ap {
lsp_types::ActiveParameter::Int(n) => Some(n),
lsp_types::ActiveParameter::Null => None,
}),
positions_degraded: ctx.positions_degraded(),
},
None => SignatureHelpResult {
signatures: vec![],
active_signature: None,
active_parameter: None,
positions_degraded: ctx.positions_degraded(),
},
};
Ok(Indexed::new(result, indexing))
}
pub async fn handle_inlay_hints(
&self,
file_path: ClientPath,
range: PositionRange,
) -> Result<Indexed<InlayHintsResult>> {
let Positioned {
result:
Indexed {
result: response,
indexing,
},
ctx,
doc,
} = self
.disclosed_range_request::<lsp_types::InlayHintRequest>(&file_path, range, ())
.await?;
let uri = doc.uri();
let mut budget = ItemBudget::new();
let lsp_hints = budget.admit(response.unwrap_or_default());
let mut hints = Vec::with_capacity(lsp_hints.len());
for hint in lsp_hints {
let position = ctx.to_mcp(uri, hint.position).await;
let label = match hint.label {
lsp_types::Label::String(s) => s,
lsp_types::Label::InlayHintLabelPartList(parts) => {
parts.into_iter().map(|p| p.value).collect::<String>()
}
};
let tooltip = hint.tooltip.map(|t| match t {
lsp_types::Tooltip::String(s) => s,
lsp_types::Tooltip::MarkupContent(m) => m.value,
});
hints.push(InlayHintEntry {
position,
label,
kind: hint.kind.map(lsp_kind_to_u32),
padding_left: hint.padding_left,
padding_right: hint.padding_right,
tooltip,
});
}
Ok(Indexed::new(
InlayHintsResult {
hints,
truncated: budget.truncated(),
positions_degraded: ctx.positions_degraded(),
},
indexing,
))
}
}
#[cfg(test)]
mod tests {
use std::{assert_matches, fs};
use super::*;
use crate::bridge::IndexingSignal;
use crate::bridge::translator::testing::*;
use crate::config::ServerId;
use crate::error::Error;
use crate::test_lsp::client_path;
#[tokio::test(start_paused = true)]
async fn test_handle_completions_returns_workspace_indexing_error_when_loading() {
use std::sync::Arc;
use tempfile::TempDir;
use tokio::sync::Mutex;
use crate::bridge::NotificationCache;
use crate::config::ServerId;
let dir = TempDir::new().unwrap();
let server_id = ServerId::from_static("rust");
let caps = lsp_types::ServerCapabilities {
completion_provider: Some(lsp_types::CompletionOptions::default()),
..Default::default()
};
let (translator, _server) = translator_with_capabilities(&dir, &server_id, caps);
let cache = Arc::new(Mutex::new(NotificationCache::new()));
cache.lock().await.observe_indexing_signal(
&server_id,
"experimental/serverStatus",
Some(&serde_json::json!({"quiescent": false})),
);
let translator = translator.with_notification_cache(cache);
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}").unwrap();
let err = translator
.handle_completions(
client_path(path.to_string_lossy().into_owned()),
Position::at(1, 1),
None,
)
.await
.unwrap_err();
assert_matches!(
err,
Error::WorkspaceIndexing { server_id: id, .. } if id == server_id
);
}
#[tokio::test]
async fn test_handle_completions_dispatches_when_indexing_ready() {
use std::sync::Arc;
use tempfile::TempDir;
use tokio::io::BufReader;
use tokio::sync::Mutex;
use crate::bridge::NotificationCache;
use crate::config::ServerId;
let dir = TempDir::new().unwrap();
let server_id = ServerId::from_static("rust");
let caps = lsp_types::ServerCapabilities {
completion_provider: Some(lsp_types::CompletionOptions::default()),
..Default::default()
};
let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
let cache = Arc::new(Mutex::new(NotificationCache::new()));
cache.lock().await.observe_indexing_signal(
&server_id,
"experimental/serverStatus",
Some(&serde_json::json!({"quiescent": true})),
);
let translator = Arc::new(translator.with_notification_cache(cache));
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}").unwrap();
let handle = {
let translator = Arc::clone(&translator);
let path = path.to_string_lossy().to_string();
tokio::spawn(async move {
translator
.handle_completions(client_path(path), Position::at(1, 1), None)
.await
})
};
let mut wire = BufReader::new(&mut server.write_stdout);
let opened = read_framed_message(&mut wire).await;
assert_eq!(opened["method"], "textDocument/didOpen");
let request = read_framed_message(&mut wire).await;
assert_eq!(request["method"], "textDocument/completion");
write_response(
&mut server.read_half_stdin,
&request["id"],
serde_json::json!([]),
)
.await;
let result = handle.await.unwrap().unwrap();
assert!(result.items.is_empty());
}
#[tokio::test]
async fn test_handle_inlay_hints_preserves_custom_kind_above_u8_range() {
use std::sync::Arc;
use tempfile::TempDir;
use tokio::io::BufReader;
use crate::bridge::translator::testing::pos;
use crate::config::ServerId;
let dir = TempDir::new().unwrap();
let server_id = ServerId::from_static("rust");
let caps = lsp_types::ServerCapabilities {
inlay_hint_provider: Some(lsp_types::InlayHintProvider::Bool(true)),
..Default::default()
};
let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}\n").unwrap();
let translator = Arc::new(translator);
let handle = {
let translator = Arc::clone(&translator);
let path = path.to_string_lossy().to_string();
tokio::spawn(async move {
translator
.handle_inlay_hints(client_path(path), span(pos(1, 1), pos(1, 13)))
.await
})
};
let mut wire = BufReader::new(&mut server.write_stdout);
let opened = read_framed_message(&mut wire).await;
assert_eq!(opened["method"], "textDocument/didOpen");
let request = read_framed_message(&mut wire).await;
assert_eq!(request["method"], "textDocument/inlayHint");
write_response(
&mut server.read_half_stdin,
&request["id"],
serde_json::json!([{
"position": {"line": 0, "character": 5},
"label": "custom",
"kind": 300,
}]),
)
.await;
let result = handle.await.unwrap().unwrap().result;
assert_eq!(result.hints.len(), 1);
assert_eq!(
result.hints[0].kind,
Some(300u32),
"a custom InlayHintKind above u8::MAX must not be truncated or dropped"
);
}
async fn inlay_hints_with_response(count: usize) -> InlayHintsResult {
use std::sync::Arc;
use tempfile::TempDir;
use tokio::io::BufReader;
use crate::bridge::translator::testing::pos;
use crate::config::ServerId;
let dir = TempDir::new().unwrap();
let server_id = ServerId::from_static("rust");
let caps = lsp_types::ServerCapabilities {
inlay_hint_provider: Some(lsp_types::InlayHintProvider::Bool(true)),
..Default::default()
};
let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}\n").unwrap();
let translator = Arc::new(translator);
let handle = {
let translator = Arc::clone(&translator);
let path = path.to_string_lossy().to_string();
tokio::spawn(async move {
translator
.handle_inlay_hints(client_path(path), span(pos(1, 1), pos(1, 13)))
.await
})
};
let mut wire = BufReader::new(&mut server.write_stdout);
let opened = read_framed_message(&mut wire).await;
assert_eq!(opened["method"], "textDocument/didOpen");
let request = read_framed_message(&mut wire).await;
assert_eq!(request["method"], "textDocument/inlayHint");
let hints: Vec<_> = (0..count)
.map(|_| serde_json::json!({"position": {"line": 0, "character": 5}, "label": "h"}))
.collect();
write_response(
&mut server.read_half_stdin,
&request["id"],
serde_json::Value::Array(hints),
)
.await;
handle.await.unwrap().unwrap().result
}
#[tokio::test]
async fn test_handle_inlay_hints_caps_item_count_and_reports_truncation() {
use crate::bridge::translator::navigation::MAX_NORMALIZED_LOCATIONS;
let result = inlay_hints_with_response(MAX_NORMALIZED_LOCATIONS + 500).await;
assert_eq!(result.hints.len(), MAX_NORMALIZED_LOCATIONS);
assert!(result.truncated);
}
#[tokio::test]
async fn test_handle_inlay_hints_at_cap_is_not_truncated() {
use crate::bridge::translator::navigation::MAX_NORMALIZED_LOCATIONS;
let result = inlay_hints_with_response(MAX_NORMALIZED_LOCATIONS).await;
assert_eq!(result.hints.len(), MAX_NORMALIZED_LOCATIONS);
assert!(!result.truncated);
}
async fn utf8_degradation(
content: &str,
line: u32,
character: u32,
method: &str,
) -> serde_json::Value {
use std::sync::Arc;
use tempfile::TempDir;
use tokio::io::BufReader;
use crate::config::ServerId;
let dir = TempDir::new().unwrap();
let server_id = ServerId::from_static("rust");
let caps = lsp_types::ServerCapabilities {
completion_provider: Some(lsp_types::CompletionOptions::default()),
signature_help_provider: Some(lsp_types::SignatureHelpOptions::default()),
..Default::default()
};
let (translator, mut server) = translator_with_capabilities_and_encoding(
&dir,
&server_id,
caps,
lsp_types::PositionEncodingKind::UTF8,
);
let path = dir.path().join("main.rs");
fs::write(&path, content).unwrap();
let translator = Arc::new(translator);
let handle = {
let translator = Arc::clone(&translator);
let path = path.to_string_lossy().to_string();
let method = method.to_string();
tokio::spawn(async move {
if method == "textDocument/completion" {
translator
.handle_completions(client_path(path), pos(line, character), None)
.await
.map(|r| serde_json::to_value(r).unwrap())
} else {
translator
.handle_signature_help(client_path(path), pos(line, character))
.await
.map(|r| serde_json::to_value(r).unwrap())
}
})
};
let mut wire = BufReader::new(&mut server.write_stdout);
let opened = read_framed_message(&mut wire).await;
assert_eq!(opened["method"], "textDocument/didOpen");
let request = read_framed_message(&mut wire).await;
assert_eq!(request["method"], method);
let response = if method == "textDocument/completion" {
serde_json::json!([])
} else {
serde_json::Value::Null
};
write_response(&mut server.read_half_stdin, &request["id"], response).await;
handle.await.unwrap().unwrap()
}
#[tokio::test]
async fn test_handle_completions_trailing_empty_line_is_not_degraded() {
let wire = utf8_degradation("fn main() {}\n", 2, 1, "textDocument/completion").await;
assert!(wire.get("positions_degraded").is_none());
}
#[tokio::test]
async fn test_handle_completions_and_signature_help_line_past_eof_are_rejected() {
use tempfile::TempDir;
use crate::config::ServerId;
let dir = TempDir::new().unwrap();
let caps = lsp_types::ServerCapabilities {
completion_provider: Some(lsp_types::CompletionOptions::default()),
signature_help_provider: Some(lsp_types::SignatureHelpOptions::default()),
..Default::default()
};
let (translator, _server) = translator_with_capabilities_and_encoding(
&dir,
&ServerId::from_static("rust"),
caps,
lsp_types::PositionEncodingKind::UTF8,
);
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}").unwrap();
let file = || client_path(path.to_string_lossy().into_owned());
let completions = translator.handle_completions(file(), pos(5, 3), None).await;
assert_matches!(completions, Err(Error::PositionBeyondDocument { .. }));
let signature = translator.handle_signature_help(file(), pos(5, 3)).await;
assert_matches!(signature, Err(Error::PositionBeyondDocument { .. }));
}
#[tokio::test]
async fn test_handle_signature_help_in_range_position_is_not_degraded() {
let wire = utf8_degradation("fn main() {}", 1, 4, "textDocument/signatureHelp").await;
assert!(wire.get("positions_degraded").is_none());
}
fn tuple_param(start: u32, end: u32) -> lsp_types::ParameterInformation {
lsp_types::ParameterInformation {
label: lsp_types::ParameterInformationLabel::Tuple((start, end)),
documentation: None,
}
}
fn one_param(
param: lsp_types::ParameterInformation,
label: &str,
encoding: PositionEncoding,
) -> SignatureParameter {
signature_parameters(vec![param], label, encoding)
.pop()
.unwrap()
}
#[test]
fn test_signature_parameter_tuple_label_resolves_per_encoding() {
let label = "é𝄞(a: u8)";
for (encoding, start, end) in [
(PositionEncoding::Utf8, 7, 12),
(PositionEncoding::Utf16, 4, 9),
(PositionEncoding::Utf32, 3, 8),
] {
let param = one_param(tuple_param(start, end), label, encoding);
assert_eq!(param.label.as_deref(), Some("a: u8"), "{encoding:?}");
}
}
#[test]
fn test_signature_parameter_invalid_tuple_offsets_yield_no_label() {
let label = "é𝄞(a: u8)";
for (encoding, start, end) in [
(PositionEncoding::Utf16, 0, 99),
(PositionEncoding::Utf16, 9, 4),
(PositionEncoding::Utf16, 2, 4),
(PositionEncoding::Utf8, 1, 3),
(PositionEncoding::Utf32, 3, 99),
] {
let param = one_param(tuple_param(start, end), label, encoding);
assert_eq!(param.label, None, "{encoding:?} {start}..{end}");
}
}
#[test]
fn test_signature_parameter_string_label_passes_through() {
let param = one_param(
lsp_types::ParameterInformation {
label: lsp_types::ParameterInformationLabel::String("a: u8".to_string()),
documentation: None,
},
"f(a: u8)",
PositionEncoding::Utf16,
);
assert_eq!(param.label.as_deref(), Some("a: u8"));
}
#[test]
fn test_signature_parameter_unresolved_label_is_omitted_when_serialized() {
let param = one_param(tuple_param(5, 1), "abcdef", PositionEncoding::Utf16);
let wire = serde_json::to_value(param).unwrap();
assert!(wire.get("label").is_none());
}
#[test]
fn test_signature_parameters_whole_label_pairs_are_bounded() {
let label = "x".repeat(1000);
let params = vec![tuple_param(0, 1000); MAX_SIGNATURE_PARAMETERS * 4];
let out = signature_parameters(params, &label, PositionEncoding::Utf16);
assert_eq!(out.len(), MAX_SIGNATURE_PARAMETERS);
let resolved: usize = out
.iter()
.filter_map(|p| p.label.as_ref())
.map(String::len)
.sum();
assert!(
resolved <= label.len() * RESOLVED_LABEL_BUDGET_MULTIPLE,
"resolved {resolved} bytes"
);
assert_eq!(out[0].label.as_deref(), Some(label.as_str()));
assert!(out[RESOLVED_LABEL_BUDGET_MULTIPLE].label.is_none());
}
#[test]
fn test_signature_parameters_overlapping_ranges_resolve() {
let label = "f(a, b, ...rest)";
let params = vec![
tuple_param(2, 3),
tuple_param(2, 3),
tuple_param(2, 15),
tuple_param(8, 15),
];
let out = signature_parameters(params, label, PositionEncoding::Utf16);
let labels: Vec<_> = out.iter().map(|p| p.label.as_deref()).collect();
assert_eq!(
labels,
[Some("a"), Some("a"), Some("a, b, ...rest"), Some("...rest")]
);
}
async fn indexing_signal_of<T, Fut>(
caps: lsp_types::ServerCapabilities,
method: &str,
quiescent: Option<bool>,
call: impl FnOnce(std::sync::Arc<Translator>, String) -> Fut + Send + 'static,
) -> IndexingSignal
where
T: Send + 'static,
Fut: Future<Output = Result<Indexed<T>>> + Send + 'static,
{
use std::sync::Arc;
use tempfile::TempDir;
use tokio::io::BufReader;
use tokio::sync::Mutex;
use crate::bridge::NotificationCache;
let dir = TempDir::new().unwrap();
let server_id = ServerId::from_static("rust");
let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
let cache = Arc::new(Mutex::new(NotificationCache::new()));
if let Some(quiescent) = quiescent {
cache.lock().await.observe_indexing_signal(
&server_id,
"experimental/serverStatus",
Some(&serde_json::json!({"quiescent": quiescent})),
);
}
let translator = Arc::new(translator.with_notification_cache(cache));
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}\n").unwrap();
let path = path.to_string_lossy().into_owned();
let handle = tokio::spawn(call(Arc::clone(&translator), path));
let mut wire = BufReader::new(&mut server.write_stdout);
let opened = read_framed_message(&mut wire).await;
assert_eq!(opened["method"], "textDocument/didOpen");
let request = read_framed_message(&mut wire).await;
assert_eq!(request["method"], method);
write_response(
&mut server.read_half_stdin,
&request["id"],
serde_json::Value::Null,
)
.await;
handle.await.unwrap().unwrap().indexing
}
#[tokio::test]
async fn test_ungated_tools_report_indexing_only_while_loading() {
use crate::bridge::translator::testing::{pos, span};
let loading = IndexingSignal {
indexing_in_progress: true,
};
for (quiescent, expected) in [
(Some(false), loading),
(Some(true), IndexingSignal::default()),
(None, IndexingSignal::default()),
] {
let call_hierarchy = lsp_types::ServerCapabilities {
call_hierarchy_provider: Some(lsp_types::CallHierarchyProvider::Bool(true)),
..Default::default()
};
assert_eq!(
indexing_signal_of(
call_hierarchy,
"textDocument/prepareCallHierarchy",
quiescent,
|t, path| async move {
t.handle_call_hierarchy_prepare(client_path(path), pos(1, 1))
.await
},
)
.await,
expected,
"prepare_call_hierarchy {quiescent:?}"
);
let type_hierarchy = lsp_types::ServerCapabilities {
type_hierarchy_provider: Some(lsp_types::TypeHierarchyProvider::Bool(true)),
..Default::default()
};
assert_eq!(
indexing_signal_of(
type_hierarchy,
"textDocument/prepareTypeHierarchy",
quiescent,
|t, path| async move {
t.handle_type_hierarchy_prepare(client_path(path), pos(1, 1))
.await
},
)
.await,
expected,
"prepare_type_hierarchy {quiescent:?}"
);
let signature_help = lsp_types::ServerCapabilities {
signature_help_provider: Some(lsp_types::SignatureHelpOptions::default()),
..Default::default()
};
assert_eq!(
indexing_signal_of(
signature_help,
"textDocument/signatureHelp",
quiescent,
|t, path| async move {
t.handle_signature_help(client_path(path), pos(1, 1)).await
},
)
.await,
expected,
"get_signature_help {quiescent:?}"
);
let inlay_hints = lsp_types::ServerCapabilities {
inlay_hint_provider: Some(lsp_types::InlayHintProvider::Bool(true)),
..Default::default()
};
assert_eq!(
indexing_signal_of(
inlay_hints,
"textDocument/inlayHint",
quiescent,
|t, path| async move {
t.handle_inlay_hints(client_path(path), span(pos(1, 1), pos(1, 13)))
.await
},
)
.await,
expected,
"get_inlay_hints {quiescent:?}"
);
}
}
}