Share host-aware completion across runtimes

This commit is contained in:
2026-08-13 18:55:03 +09:00
parent e862e53f3e
commit d28edcf041
25 changed files with 2836 additions and 578 deletions
+258 -9
View File
@@ -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),
}))