Share host-aware completion across runtimes
This commit is contained in:
@@ -6,19 +6,23 @@ use std::{
|
||||
};
|
||||
|
||||
use decodal::{
|
||||
Diagnostic as DecodalDiagnostic, DiagnosticKind, HostEnvironment, ImportLoader, LoadedImport,
|
||||
LoadedSource, SourceId, Span,
|
||||
Diagnostic as DecodalDiagnostic, DiagnosticKind, HostEnvironment, ImportCandidate,
|
||||
ImportLoader, LoadedImport, LoadedSource, SourceId, Span,
|
||||
};
|
||||
use decodal_language_service::{
|
||||
CompletionKind as ServiceCompletionKind, CompletionResult, LanguageService, SemanticAnalysis,
|
||||
};
|
||||
use decodal_language_service::{LanguageService, SemanticAnalysis};
|
||||
use decodal_language_tools::format_source;
|
||||
use lsp_server::{Connection, ErrorCode, Message, Notification, Request, Response};
|
||||
pub use lsp_types::InitializeParams;
|
||||
use lsp_types::{
|
||||
Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, DidChangeTextDocumentParams,
|
||||
DidCloseTextDocumentParams, DidOpenTextDocumentParams, DidSaveTextDocumentParams,
|
||||
DocumentFormattingParams, InitializeResult, Location, NumberOrString, OneOf, Position,
|
||||
PositionEncodingKind, PublishDiagnosticsParams, Range, SaveOptions, ServerCapabilities,
|
||||
ServerInfo, TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions,
|
||||
CompletionItem, CompletionItemKind, CompletionOptions, CompletionParams, CompletionResponse,
|
||||
CompletionTextEdit, CompletionTriggerKind, Diagnostic, DiagnosticRelatedInformation,
|
||||
DiagnosticSeverity, DidChangeTextDocumentParams, DidCloseTextDocumentParams,
|
||||
DidOpenTextDocumentParams, DidSaveTextDocumentParams, DocumentFormattingParams,
|
||||
InitializeResult, Location, NumberOrString, OneOf, Position, PositionEncodingKind,
|
||||
PublishDiagnosticsParams, Range, SaveOptions, ServerCapabilities, ServerInfo,
|
||||
TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions,
|
||||
TextDocumentSyncSaveOptions, TextEdit, Uri,
|
||||
};
|
||||
|
||||
@@ -124,6 +128,11 @@ fn server_capabilities() -> ServerCapabilities {
|
||||
},
|
||||
)),
|
||||
document_formatting_provider: Some(OneOf::Left(true)),
|
||||
completion_provider: Some(CompletionOptions {
|
||||
resolve_provider: Some(false),
|
||||
trigger_characters: Some(vec![String::from("."), String::from("/")]),
|
||||
..CompletionOptions::default()
|
||||
}),
|
||||
..ServerCapabilities::default()
|
||||
}
|
||||
}
|
||||
@@ -166,11 +175,66 @@ impl<E: LspEnvironment> Server<E> {
|
||||
|
||||
fn handle_request(&self, request: Request) -> ServerResult {
|
||||
match request.method.as_str() {
|
||||
"textDocument/completion" => self.complete_document(request),
|
||||
"textDocument/formatting" => self.format_document(request),
|
||||
_ => self.reject_unknown_request(request),
|
||||
}
|
||||
}
|
||||
|
||||
fn complete_document(&self, request: Request) -> ServerResult {
|
||||
let id = request.id;
|
||||
let params: CompletionParams = match serde_json::from_value(request.params) {
|
||||
Ok(params) => params,
|
||||
Err(error) => {
|
||||
self.connection
|
||||
.sender
|
||||
.send(Message::Response(Response::new_err(
|
||||
id,
|
||||
ErrorCode::InvalidParams as i32,
|
||||
error.to_string(),
|
||||
)))?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let document_id = params.text_document_position.text_document.uri.as_str();
|
||||
let Some(document) = self.documents.get(document_id) else {
|
||||
self.connection
|
||||
.sender
|
||||
.send(Message::Response(Response::new_ok(
|
||||
id,
|
||||
Option::<CompletionResponse>::None,
|
||||
)))?;
|
||||
return Ok(());
|
||||
};
|
||||
let position = params.text_document_position.position;
|
||||
let offset = position_to_byte_offset(&document.source, position);
|
||||
let explicit = params
|
||||
.context
|
||||
.is_some_and(|context| context.trigger_kind == CompletionTriggerKind::INVOKED);
|
||||
let completion =
|
||||
match self
|
||||
.service
|
||||
.complete(&document.key, &document.source, offset, explicit)
|
||||
{
|
||||
Ok(completion) => completion
|
||||
.map(|completion| completion_to_lsp(completion, &document.source, offset)),
|
||||
Err(error) => {
|
||||
self.connection
|
||||
.sender
|
||||
.send(Message::Response(Response::new_err(
|
||||
id,
|
||||
ErrorCode::InternalError as i32,
|
||||
error.message,
|
||||
)))?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
self.connection
|
||||
.sender
|
||||
.send(Message::Response(Response::new_ok(id, completion)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_document(&self, request: Request) -> ServerResult {
|
||||
let id = request.id;
|
||||
let params: DocumentFormattingParams = match serde_json::from_value(request.params) {
|
||||
@@ -413,6 +477,42 @@ fn span_to_range(source: &str, span: Span) -> Range {
|
||||
}
|
||||
}
|
||||
|
||||
fn completion_to_lsp(
|
||||
completion: CompletionResult,
|
||||
source: &str,
|
||||
offset: usize,
|
||||
) -> CompletionResponse {
|
||||
let range = Range::new(
|
||||
byte_offset_to_position(source, completion.from),
|
||||
byte_offset_to_position(source, offset),
|
||||
);
|
||||
CompletionResponse::Array(
|
||||
completion
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| CompletionItem {
|
||||
sort_text: Some(format!("{:04}-{}", 9999 - item.priority, item.label)),
|
||||
text_edit: Some(CompletionTextEdit::Edit(TextEdit::new(
|
||||
range,
|
||||
item.label.clone(),
|
||||
))),
|
||||
label: item.label,
|
||||
kind: Some(match item.kind {
|
||||
ServiceCompletionKind::Keyword => CompletionItemKind::KEYWORD,
|
||||
ServiceCompletionKind::Constant => CompletionItemKind::CONSTANT,
|
||||
ServiceCompletionKind::Type => CompletionItemKind::TYPE_PARAMETER,
|
||||
ServiceCompletionKind::Variable => CompletionItemKind::VARIABLE,
|
||||
ServiceCompletionKind::Namespace => CompletionItemKind::MODULE,
|
||||
ServiceCompletionKind::Property => CompletionItemKind::PROPERTY,
|
||||
ServiceCompletionKind::File => CompletionItemKind::FILE,
|
||||
}),
|
||||
detail: item.detail,
|
||||
..CompletionItem::default()
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn byte_offset_to_position(source: &str, offset: usize) -> Position {
|
||||
let mut offset = offset.min(source.len());
|
||||
while !source.is_char_boundary(offset) {
|
||||
@@ -431,6 +531,36 @@ fn byte_offset_to_position(source: &str, offset: usize) -> Position {
|
||||
Position { line, character }
|
||||
}
|
||||
|
||||
fn position_to_byte_offset(source: &str, position: Position) -> usize {
|
||||
let mut line = 0u32;
|
||||
let mut line_start = 0usize;
|
||||
for (offset, ch) in source.char_indices() {
|
||||
if line == position.line {
|
||||
line_start = offset;
|
||||
break;
|
||||
}
|
||||
if ch == '\n' {
|
||||
line += 1;
|
||||
line_start = offset + ch.len_utf8();
|
||||
}
|
||||
}
|
||||
if line < position.line {
|
||||
return source.len();
|
||||
}
|
||||
let mut utf16 = 0u32;
|
||||
for (relative, ch) in source[line_start..].char_indices() {
|
||||
if ch == '\n' || utf16 >= position.character {
|
||||
return line_start + relative;
|
||||
}
|
||||
let next = utf16 + ch.len_utf16() as u32;
|
||||
if next > position.character {
|
||||
return line_start + relative;
|
||||
}
|
||||
utf16 = next;
|
||||
}
|
||||
source.len()
|
||||
}
|
||||
|
||||
fn document_key(uri: &Uri) -> String {
|
||||
if uri
|
||||
.scheme()
|
||||
@@ -543,6 +673,64 @@ impl ImportLoader for FileSystemLoader {
|
||||
source,
|
||||
}))
|
||||
}
|
||||
|
||||
fn complete_import(
|
||||
&mut self,
|
||||
current_key: Option<&str>,
|
||||
prefix: &str,
|
||||
) -> decodal::Result<Vec<ImportCandidate>> {
|
||||
let current_dir = current_key
|
||||
.and_then(|key| Path::new(key).parent())
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
let prefix_path = Path::new(prefix);
|
||||
let resolved = if prefix_path.is_absolute() {
|
||||
prefix_path.to_path_buf()
|
||||
} else {
|
||||
current_dir.join(prefix_path)
|
||||
};
|
||||
let ends_with_separator = prefix.ends_with('/') || prefix.ends_with('\\');
|
||||
let directory = if ends_with_separator {
|
||||
resolved.as_path()
|
||||
} else {
|
||||
resolved.parent().unwrap_or_else(|| Path::new("."))
|
||||
};
|
||||
let fragment = if ends_with_separator {
|
||||
""
|
||||
} else {
|
||||
resolved
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("")
|
||||
};
|
||||
let label_base = if ends_with_separator {
|
||||
prefix.to_owned()
|
||||
} else {
|
||||
prefix
|
||||
.rfind(['/', '\\'])
|
||||
.map_or_else(String::new, |index| prefix[..=index].to_owned())
|
||||
};
|
||||
let Ok(entries) = fs::read_dir(directory) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut candidates = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if !name.starts_with(fragment) {
|
||||
continue;
|
||||
}
|
||||
let is_directory = entry.file_type().is_ok_and(|kind| kind.is_dir());
|
||||
let mut specifier = format!("{label_base}{name}");
|
||||
if is_directory {
|
||||
specifier.push('/');
|
||||
}
|
||||
candidates.push(
|
||||
ImportCandidate::new(specifier)
|
||||
.with_detail(entry.path().to_string_lossy().into_owned()),
|
||||
);
|
||||
}
|
||||
candidates.sort_by(|left, right| left.specifier.cmp(&right.specifier));
|
||||
Ok(candidates)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -642,6 +830,14 @@ mod tests {
|
||||
byte_offset_to_position(source, source.len()),
|
||||
Position::new(1, 1)
|
||||
);
|
||||
assert_eq!(position_to_byte_offset(source, Position::new(0, 0)), 0);
|
||||
assert_eq!(position_to_byte_offset(source, Position::new(0, 1)), 1);
|
||||
assert_eq!(position_to_byte_offset(source, Position::new(0, 3)), 5);
|
||||
assert_eq!(position_to_byte_offset(source, Position::new(1, 0)), 6);
|
||||
assert_eq!(
|
||||
position_to_byte_offset(source, Position::new(1, 1)),
|
||||
source.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -651,7 +847,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishes_host_environment_diagnostics_over_lsp() {
|
||||
fn serves_host_environment_diagnostics_and_completion_over_lsp() {
|
||||
let (server_connection, client_connection) = Connection::memory();
|
||||
let initialized_workspace = Arc::new(Mutex::new(None));
|
||||
let server_workspace = Arc::clone(&initialized_workspace);
|
||||
@@ -689,6 +885,9 @@ mod tests {
|
||||
panic!("expected initialize response")
|
||||
};
|
||||
assert!(initialized.response_result.is_ok());
|
||||
let initialize_result: InitializeResult =
|
||||
serde_json::from_value(initialized.response_result.as_ref().unwrap().clone()).unwrap();
|
||||
assert!(initialize_result.capabilities.completion_provider.is_some());
|
||||
assert_eq!(
|
||||
initialized_workspace.lock().unwrap().as_deref(),
|
||||
Some("file:///tmp")
|
||||
@@ -779,10 +978,60 @@ mod tests {
|
||||
assert_eq!(edits.len(), 1);
|
||||
assert!(edits[0].new_text.ends_with('\n'));
|
||||
|
||||
client_connection
|
||||
.sender
|
||||
.send(Message::Notification(Notification::new(
|
||||
String::from("textDocument/didChange"),
|
||||
json!({
|
||||
"textDocument": { "uri": uri, "version": 2 },
|
||||
"contentChanges": [{ "text": "Post.dr" }]
|
||||
}),
|
||||
)))
|
||||
.unwrap();
|
||||
let published = client_connection
|
||||
.receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.unwrap();
|
||||
assert!(matches!(published, Message::Notification(_)));
|
||||
|
||||
client_connection
|
||||
.sender
|
||||
.send(Message::Request(Request {
|
||||
id: RequestId::from(3),
|
||||
method: String::from("textDocument/completion"),
|
||||
params: json!({
|
||||
"textDocument": { "uri": uri },
|
||||
"position": { "line": 0, "character": 7 },
|
||||
"context": { "triggerKind": 1 }
|
||||
}),
|
||||
}))
|
||||
.unwrap();
|
||||
let completed = client_connection
|
||||
.receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.unwrap();
|
||||
let Message::Response(completed) = completed else {
|
||||
panic!("expected completion response")
|
||||
};
|
||||
let completion: CompletionResponse =
|
||||
serde_json::from_value(completed.response_result.unwrap()).unwrap();
|
||||
let CompletionResponse::Array(items) = completion else {
|
||||
panic!("expected completion item array")
|
||||
};
|
||||
let draft = items.iter().find(|item| item.label == "draft").unwrap();
|
||||
assert_eq!(draft.kind, Some(CompletionItemKind::PROPERTY));
|
||||
let Some(CompletionTextEdit::Edit(edit)) = &draft.text_edit else {
|
||||
panic!("expected completion text edit")
|
||||
};
|
||||
assert_eq!(
|
||||
edit.range,
|
||||
Range::new(Position::new(0, 5), Position::new(0, 7))
|
||||
);
|
||||
|
||||
client_connection
|
||||
.sender
|
||||
.send(Message::Request(Request {
|
||||
id: RequestId::from(4),
|
||||
method: String::from("shutdown"),
|
||||
params: json!(null),
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user