Skip to main content

eure_ls/
queries.rs

1//! LSP-specific queries that convert to LSP types.
2
3use eure::query::{
4    CompletionItem, CompletionKind, DiagnosticMessage, DiagnosticSeverity, GetFileDiagnostics,
5    GetSemanticTokens, SemanticToken, TextFile, get_completions, get_hover,
6};
7use lsp_types::{
8    CompletionItem as LspCompletionItem, CompletionItemKind, CompletionTextEdit, Diagnostic,
9    DiagnosticSeverity as LspSeverity, Documentation, Hover, HoverContents, MarkupContent,
10    MarkupKind, NumberOrString, Position, Range, SemanticToken as LspSemanticToken, SemanticTokens,
11    TextEdit,
12};
13use query_flow::{Db, QueryError, query};
14
15/// LSP-formatted completion.
16///
17/// Wraps `get_completions` and converts to LSP `CompletionItem`s. `offset` is
18/// the cursor position as a byte offset (see [`position_to_offset`]).
19///
20/// Like `get_completions`, this is a plain function rather than a query so
21/// that per-cursor results are not memoized.
22pub fn lsp_completion(
23    db: &impl Db,
24    file: &TextFile,
25    offset: u32,
26) -> Result<Vec<LspCompletionItem>, QueryError> {
27    let items = get_completions(db, file, offset)?;
28    let source: std::sync::Arc<eure::query::TextFileContent> = db.asset(file.clone())?;
29    let line_offsets = compute_line_offsets(source.get());
30    Ok(items
31        .iter()
32        .map(|item| convert_completion_item(item, source.get(), &line_offsets))
33        .collect())
34}
35
36fn convert_completion_item(
37    item: &CompletionItem,
38    source: &str,
39    line_offsets: &[usize],
40) -> LspCompletionItem {
41    let range = Range {
42        start: offset_to_lsp_position(item.replace.start as usize, source, line_offsets),
43        end: offset_to_lsp_position(item.replace.end as usize, source, line_offsets),
44    };
45    LspCompletionItem {
46        label: item.label.clone(),
47        kind: Some(convert_completion_kind(item.kind)),
48        detail: item.detail.clone(),
49        documentation: item.documentation.as_ref().map(|value| {
50            Documentation::MarkupContent(MarkupContent {
51                kind: MarkupKind::Markdown,
52                value: value.clone(),
53            })
54        }),
55        deprecated: item.deprecated.then_some(true),
56        filter_text: Some(item.label.clone()),
57        text_edit: Some(CompletionTextEdit::Edit(TextEdit {
58            range,
59            new_text: item.label.clone(),
60        })),
61        ..Default::default()
62    }
63}
64
65fn convert_completion_kind(kind: CompletionKind) -> CompletionItemKind {
66    match kind {
67        CompletionKind::Field => CompletionItemKind::FIELD,
68        CompletionKind::Extension => CompletionItemKind::PROPERTY,
69        CompletionKind::Variant => CompletionItemKind::ENUM_MEMBER,
70        CompletionKind::Value => CompletionItemKind::VALUE,
71    }
72}
73
74/// LSP-formatted hover.
75///
76/// Wraps `get_hover` and converts to an LSP `Hover` with markdown contents
77/// and the range of the hovered key or value. `offset` is the cursor
78/// position as a byte offset (see [`position_to_offset`]).
79///
80/// Like `get_hover`, this is a plain function rather than a query so that
81/// per-cursor results are not memoized.
82pub fn lsp_hover(db: &impl Db, file: &TextFile, offset: u32) -> Result<Option<Hover>, QueryError> {
83    let Some(hover) = get_hover(db, file, offset)? else {
84        return Ok(None);
85    };
86    let source: std::sync::Arc<eure::query::TextFileContent> = db.asset(file.clone())?;
87    let line_offsets = compute_line_offsets(source.get());
88    Ok(Some(Hover {
89        contents: HoverContents::Markup(MarkupContent {
90            kind: MarkupKind::Markdown,
91            value: hover.contents,
92        }),
93        range: Some(Range {
94            start: offset_to_lsp_position(hover.span.start as usize, source.get(), &line_offsets),
95            end: offset_to_lsp_position(hover.span.end as usize, source.get(), &line_offsets),
96        }),
97    }))
98}
99
100/// Definition ranges use the target file's own UTF-16 coordinates.
101pub fn lsp_definition(
102    db: &impl Db,
103    file: &TextFile,
104    offset: u32,
105) -> Result<Vec<lsp_types::LocationLink>, QueryError> {
106    let definitions = eure::query::get_definition(db, file, offset)?;
107    let source = db.asset(file.clone())?;
108    let offsets = compute_line_offsets(source.get());
109    definitions
110        .into_iter()
111        .map(|definition| {
112            let target = db.asset(definition.file.clone())?;
113            let target_offsets = compute_line_offsets(target.get());
114            Ok(lsp_types::LocationLink {
115                origin_selection_range: Some(Range {
116                    start: offset_to_lsp_position(
117                        definition.origin.start as usize,
118                        source.get(),
119                        &offsets,
120                    ),
121                    end: offset_to_lsp_position(
122                        definition.origin.end as usize,
123                        source.get(),
124                        &offsets,
125                    ),
126                }),
127                target_uri: crate::uri_utils::text_file_to_uri(&definition.file).parse()?,
128                target_range: Range {
129                    start: offset_to_lsp_position(
130                        definition.range.start as usize,
131                        target.get(),
132                        &target_offsets,
133                    ),
134                    end: offset_to_lsp_position(
135                        definition.range.end as usize,
136                        target.get(),
137                        &target_offsets,
138                    ),
139                },
140                target_selection_range: Range {
141                    start: offset_to_lsp_position(
142                        definition.selection.start as usize,
143                        target.get(),
144                        &target_offsets,
145                    ),
146                    end: offset_to_lsp_position(
147                        definition.selection.end as usize,
148                        target.get(),
149                        &target_offsets,
150                    ),
151                },
152            })
153        })
154        .collect()
155}
156
157/// Convert an LSP position (UTF-16 based) to a byte offset in `source`.
158///
159/// Positions past the end of a line clamp to the line end; positions past
160/// the last line clamp to the end of the source.
161pub fn position_to_offset(source: &str, position: Position) -> usize {
162    let line_offsets = compute_line_offsets(source);
163    let Some(&line_start) = line_offsets.get(position.line as usize) else {
164        return source.len();
165    };
166    let line_end = line_offsets
167        .get(position.line as usize + 1)
168        .map(|&next| next - 1)
169        .unwrap_or(source.len());
170    let line = &source[line_start..line_end];
171    let mut utf16_units = 0u32;
172    for (byte_index, c) in line.char_indices() {
173        if utf16_units >= position.character {
174            return line_start + byte_index;
175        }
176        utf16_units += c.len_utf16() as u32;
177    }
178    line_end
179}
180
181/// LSP-formatted semantic tokens query.
182///
183/// Wraps `GetSemanticTokens` and converts to LSP `SemanticTokens` format.
184#[query]
185pub fn lsp_semantic_tokens(
186    db: &impl Db,
187    file: TextFile,
188    source: String,
189) -> Result<SemanticTokens, QueryError> {
190    let tokens = db.query(GetSemanticTokens::new(file.clone()))?;
191    Ok(convert_tokens(&tokens, &source))
192}
193
194/// LSP-formatted diagnostics query, grouped by file.
195///
196/// Wraps `GetFileDiagnostics` and converts to LSP `Diagnostic` format.
197/// Returns diagnostics grouped by file, so that each file can receive
198/// its own publishDiagnostics notification.
199#[query]
200pub fn lsp_diagnostics(
201    db: &impl Db,
202    file: TextFile,
203) -> Result<Vec<(TextFile, Vec<Diagnostic>)>, QueryError> {
204    let diagnostics = db.query(GetFileDiagnostics::new(file.clone()))?;
205
206    // Group diagnostics by file
207    let mut by_file: std::collections::HashMap<TextFile, Vec<DiagnosticMessage>> =
208        std::collections::HashMap::new();
209
210    // Always include the target file so diagnostics are cleared when errors are fixed
211    by_file.insert(file, vec![]);
212
213    for d in diagnostics.iter() {
214        by_file.entry(d.file.clone()).or_default().push(d.clone());
215    }
216
217    // Convert each group to LSP diagnostics using the correct source
218    let mut result = Vec::new();
219    for (diag_file, file_diagnostics) in by_file {
220        let source: std::sync::Arc<eure::query::TextFileContent> = db.asset(diag_file.clone())?;
221        let line_offsets = compute_line_offsets(source.get());
222        let lsp_diagnostics: Vec<Diagnostic> = file_diagnostics
223            .iter()
224            .map(|d| convert_diagnostic(d, source.get(), &line_offsets))
225            .collect();
226        result.push((diag_file, lsp_diagnostics));
227    }
228
229    Ok(result)
230}
231
232/// LSP-formatted diagnostics for a single file.
233///
234/// Wraps `GetFileDiagnostics` and converts to LSP `Diagnostic` format.
235/// Returns diagnostics only for the specified file.
236#[query]
237pub fn lsp_file_diagnostics(db: &impl Db, file: TextFile) -> Result<Vec<Diagnostic>, QueryError> {
238    let diagnostics = db.query(GetFileDiagnostics::new(file.clone()))?;
239
240    // Get source for position conversion
241    let source: std::sync::Arc<eure::query::TextFileContent> = db.asset(file.clone())?;
242    let line_offsets = compute_line_offsets(source.get());
243
244    // Convert to LSP diagnostics
245    let lsp_diagnostics: Vec<Diagnostic> = diagnostics
246        .iter()
247        .filter(|d| d.file == file) // Only include diagnostics for this file
248        .map(|d| convert_diagnostic(d, source.get(), &line_offsets))
249        .collect();
250
251    Ok(lsp_diagnostics)
252}
253
254/// Convert internal semantic tokens to LSP format.
255///
256/// LSP semantic tokens use a delta encoding:
257/// - Each token is encoded as (deltaLine, deltaStartChar, length, tokenType, tokenModifiers)
258/// - deltaLine is relative to the previous token's line
259/// - deltaStartChar is relative to the previous token's start (or line start if on new line)
260/// - All character positions and lengths are in UTF-16 code units
261fn convert_tokens(tokens: &[SemanticToken], source: &str) -> SemanticTokens {
262    let line_offsets = compute_line_offsets(source);
263
264    let mut data = Vec::new();
265    let mut prev_line = 0u32;
266    let mut prev_start = 0u32;
267
268    for token in tokens {
269        let start = token.start as usize;
270        let end = start + token.length as usize;
271        let (line, char) = offset_to_position(start, source, &line_offsets);
272        let length = byte_len_to_utf16_len(source, start, end);
273
274        let delta_line = line - prev_line;
275        let delta_start = if delta_line == 0 {
276            char - prev_start
277        } else {
278            char
279        };
280
281        data.push(LspSemanticToken {
282            delta_line,
283            delta_start,
284            length,
285            token_type: token.token_type as u32,
286            token_modifiers_bitset: token.modifiers,
287        });
288
289        prev_line = line;
290        prev_start = char;
291    }
292
293    SemanticTokens {
294        result_id: None,
295        data,
296    }
297}
298
299/// Convert internal diagnostic to LSP format.
300fn convert_diagnostic(msg: &DiagnosticMessage, source: &str, line_offsets: &[usize]) -> Diagnostic {
301    let start = offset_to_lsp_position(msg.start, source, line_offsets);
302    let end = offset_to_lsp_position(msg.end, source, line_offsets);
303
304    Diagnostic {
305        range: Range { start, end },
306        severity: Some(convert_severity(msg.severity)),
307        code: msg.code.clone().map(NumberOrString::String),
308        code_description: None,
309        source: Some("eure".to_string()),
310        message: msg.message.clone(),
311        related_information: None,
312        tags: None,
313        data: None,
314    }
315}
316
317/// Convert internal severity to LSP severity.
318fn convert_severity(severity: DiagnosticSeverity) -> LspSeverity {
319    match severity {
320        DiagnosticSeverity::Error => LspSeverity::ERROR,
321        DiagnosticSeverity::Warning => LspSeverity::WARNING,
322        DiagnosticSeverity::Info => LspSeverity::INFORMATION,
323        DiagnosticSeverity::Hint => LspSeverity::HINT,
324    }
325}
326
327/// Compute line offsets for a source string.
328///
329/// Returns a vector where `line_offsets[i]` is the byte offset of line `i`.
330fn compute_line_offsets(source: &str) -> Vec<usize> {
331    let mut offsets = vec![0];
332    for (i, c) in source.char_indices() {
333        if c == '\n' {
334            offsets.push(i + 1);
335        }
336    }
337    offsets
338}
339
340/// Convert a byte offset to (line, character) position.
341///
342/// Line is 0-indexed. Character is in UTF-16 code units (as required by LSP).
343fn offset_to_position(offset: usize, source: &str, line_offsets: &[usize]) -> (u32, u32) {
344    let line = line_offsets.iter().rposition(|&o| o <= offset).unwrap_or(0);
345    let line_start = line_offsets[line];
346    // Count UTF-16 code units from line start to offset
347    let end = offset.min(source.len());
348    let line_content = &source[line_start..end];
349    let utf16_offset: usize = line_content.chars().map(|c| c.len_utf16()).sum();
350    (line as u32, utf16_offset as u32)
351}
352
353/// Convert a byte offset to LSP Position with UTF-16 character position.
354fn offset_to_lsp_position(offset: usize, source: &str, line_offsets: &[usize]) -> Position {
355    let (line, character) = offset_to_position(offset, source, line_offsets);
356    Position { line, character }
357}
358
359/// Convert a byte length to UTF-16 code unit length.
360fn byte_len_to_utf16_len(source: &str, start: usize, end: usize) -> u32 {
361    let end = end.min(source.len());
362    let start = start.min(end);
363    source[start..end]
364        .chars()
365        .map(|c| c.len_utf16())
366        .sum::<usize>() as u32
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn test_compute_line_offsets() {
375        let source = "hello\nworld\n";
376        let offsets = compute_line_offsets(source);
377        assert_eq!(offsets, vec![0, 6, 12]);
378    }
379
380    #[test]
381    fn test_offset_to_position_ascii() {
382        let source = "hello\nworld\n";
383        let offsets = compute_line_offsets(source);
384        assert_eq!(offset_to_position(0, source, &offsets), (0, 0));
385        assert_eq!(offset_to_position(5, source, &offsets), (0, 5));
386        assert_eq!(offset_to_position(6, source, &offsets), (1, 0));
387        assert_eq!(offset_to_position(11, source, &offsets), (1, 5));
388    }
389
390    #[test]
391    fn test_offset_to_position_utf8() {
392        // "日本語" is 9 bytes (3 chars × 3 bytes each), but 3 UTF-16 code units
393        let source = "日本語\ntest";
394        let offsets = compute_line_offsets(source);
395        // Byte offset 0 -> (line 0, char 0)
396        assert_eq!(offset_to_position(0, source, &offsets), (0, 0));
397        // Byte offset 3 (after 日) -> (line 0, char 1)
398        assert_eq!(offset_to_position(3, source, &offsets), (0, 1));
399        // Byte offset 6 (after 日本) -> (line 0, char 2)
400        assert_eq!(offset_to_position(6, source, &offsets), (0, 2));
401        // Byte offset 9 (after 日本語) -> (line 0, char 3)
402        assert_eq!(offset_to_position(9, source, &offsets), (0, 3));
403        // Byte offset 10 (after \n) -> (line 1, char 0)
404        assert_eq!(offset_to_position(10, source, &offsets), (1, 0));
405    }
406
407    #[test]
408    fn test_offset_to_position_emoji() {
409        // "😀" is 4 bytes in UTF-8, but 2 UTF-16 code units (surrogate pair)
410        let source = "😀a";
411        let offsets = compute_line_offsets(source);
412        // Byte offset 0 -> (line 0, char 0)
413        assert_eq!(offset_to_position(0, source, &offsets), (0, 0));
414        // Byte offset 4 (after 😀) -> (line 0, char 2) because emoji is 2 UTF-16 units
415        assert_eq!(offset_to_position(4, source, &offsets), (0, 2));
416        // Byte offset 5 (after 😀a) -> (line 0, char 3)
417        assert_eq!(offset_to_position(5, source, &offsets), (0, 3));
418    }
419
420    #[test]
421    fn test_position_to_offset() {
422        let source = "日本語\ntest";
423        assert_eq!(position_to_offset(source, Position::new(0, 0)), 0);
424        assert_eq!(position_to_offset(source, Position::new(0, 2)), 6);
425        assert_eq!(position_to_offset(source, Position::new(0, 3)), 9);
426        // Past the end of the line clamps to the line end (before the newline)
427        assert_eq!(position_to_offset(source, Position::new(0, 10)), 9);
428        assert_eq!(position_to_offset(source, Position::new(1, 4)), 14);
429        // Past the last line clamps to the end of the source
430        assert_eq!(position_to_offset(source, Position::new(5, 0)), 14);
431        // Surrogate pair counts as two UTF-16 units
432        assert_eq!(position_to_offset("😀a", Position::new(0, 2)), 4);
433    }
434
435    #[test]
436    fn test_byte_len_to_utf16_len() {
437        // ASCII: 1 byte = 1 UTF-16 unit
438        assert_eq!(byte_len_to_utf16_len("hello", 0, 5), 5);
439        // Japanese: 3 bytes per char, 1 UTF-16 unit per char
440        assert_eq!(byte_len_to_utf16_len("日本語", 0, 9), 3);
441        // Emoji: 4 bytes, 2 UTF-16 units
442        assert_eq!(byte_len_to_utf16_len("😀", 0, 4), 2);
443    }
444}