Skip to main content

pkl_lsp_core/
features.rs

1use crate::document::DocumentStore;
2use crate::index::{
3    PklSymbol, SymbolKind, TextPosition, TextRange, WorkspaceIndex, offset_at_position,
4    position_at_offset, word_at_position,
5};
6use crate::protocol::{
7    self, CodeAction, CompletionItem, CompletionList, DocumentHighlight, FoldingRange, Hover,
8    InlayHint, Location, PrepareRename, SelectionRange, SemanticToken, SemanticTokens,
9    SignatureHelp, SignatureInformation, TextEdit, WorkspaceEdit, WorkspaceSymbol,
10};
11use pklr::lexer::{self, Token, TokenKind};
12
13const KEYWORDS: &[&str] = &[
14    "abstract",
15    "amends",
16    "as",
17    "class",
18    "const",
19    "else",
20    "extends",
21    "external",
22    "false",
23    "fixed",
24    "for",
25    "function",
26    "hidden",
27    "if",
28    "import",
29    "import*",
30    "in",
31    "is",
32    "let",
33    "local",
34    "module",
35    "new",
36    "null",
37    "open",
38    "out",
39    "read",
40    "read?",
41    "super",
42    "this",
43    "throw",
44    "trace",
45    "true",
46    "typealias",
47    "when",
48];
49
50const BUILTIN_TYPES: &[&str] = &[
51    "Any",
52    "Null",
53    "Boolean",
54    "String",
55    "Number",
56    "Int",
57    "Float",
58    "Duration",
59    "DataSize",
60    "Dynamic",
61    "Typed",
62    "Listing",
63    "Mapping",
64    "Collection",
65    "List",
66    "Set",
67    "Map",
68    "Pair",
69    "Regex",
70    "Bytes",
71    "IntSeq",
72    "Class",
73    "Function0",
74    "Function1",
75    "Function2",
76    "Function3",
77    "Function4",
78    "Function5",
79    "unknown",
80    "nothing",
81];
82
83const BUILTIN_CONSTANTS: &[&str] = &["NaN", "Infinity", "true", "false", "null"];
84
85const BUILTIN_FUNCTIONS: &[(&str, &str)] = &[
86    ("read", "read(uri: String): Any"),
87    ("read?", "read?(uri: String): Any?"),
88    ("trace", "trace(value: Any): Any"),
89    ("throw", "throw(message: String): nothing"),
90];
91
92#[derive(Debug, Clone, Copy, Default)]
93pub struct CompletionOptions {
94    pub include_keywords: bool,
95}
96
97#[derive(Debug, Clone)]
98pub struct FeatureEngine {
99    documents: DocumentStore,
100    index: WorkspaceIndex,
101}
102
103impl FeatureEngine {
104    pub fn new(documents: DocumentStore) -> Self {
105        let index = WorkspaceIndex::build(documents.iter());
106        Self { documents, index }
107    }
108
109    pub fn rebuild(&mut self) {
110        self.index = WorkspaceIndex::build(self.documents.iter());
111    }
112
113    pub fn documents(&self) -> &DocumentStore {
114        &self.documents
115    }
116
117    pub fn documents_mut(&mut self) -> &mut DocumentStore {
118        &mut self.documents
119    }
120
121    pub fn index(&self) -> &WorkspaceIndex {
122        &self.index
123    }
124
125    pub fn publish_diagnostics(&self, uri: &str) -> Vec<protocol::Diagnostic> {
126        self.index.diagnostics_for_protocol(uri)
127    }
128
129    pub fn document_symbols(&self, uri: &str) -> Vec<protocol::DocumentSymbol> {
130        self.index.document_symbols(uri)
131    }
132
133    pub fn workspace_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
134        let query = query.trim().to_ascii_lowercase();
135        self.index
136            .symbols
137            .iter()
138            .filter(|symbol| {
139                query.is_empty()
140                    || symbol.name.to_ascii_lowercase().contains(&query)
141                    || symbol
142                        .container_name
143                        .as_ref()
144                        .is_some_and(|container| container.to_ascii_lowercase().contains(&query))
145            })
146            .map(|symbol| WorkspaceSymbol {
147                name: symbol.name.clone(),
148                kind: symbol.kind.as_protocol(),
149                location: Location {
150                    uri: symbol.uri.clone(),
151                    range: symbol.selection_range,
152                },
153                container_name: symbol.container_name.clone(),
154            })
155            .collect()
156    }
157
158    pub fn hover(&self, uri: &str, position: TextPosition) -> Option<Hover> {
159        let symbol = self.symbol_at(uri, position)?;
160        let mut value = format!("**{}**", symbol.name);
161        if let Some(detail) = &symbol.detail {
162            value.push_str(&format!("\n\n`{detail}`"));
163        }
164        if let Some(container) = &symbol.container_name {
165            value.push_str(&format!("\n\nContainer: `{container}`"));
166        }
167        Some(Hover {
168            contents: value,
169            range: Some(symbol.selection_range),
170        })
171    }
172
173    pub fn completion(
174        &self,
175        uri: &str,
176        position: TextPosition,
177        options: CompletionOptions,
178    ) -> CompletionList {
179        let mut items = Vec::new();
180        let context = self.completion_context(uri, position);
181        if options.include_keywords {
182            items.extend(KEYWORDS.iter().map(|keyword| {
183                completion_item(
184                    keyword,
185                    protocol::completion_kind::KEYWORD,
186                    Some("Pkl keyword"),
187                    None,
188                )
189            }));
190        }
191        items.extend(BUILTIN_TYPES.iter().map(|name| {
192            completion_item(
193                name,
194                protocol::completion_kind::CLASS,
195                Some("Pkl standard library type"),
196                Some("Built-in type from the Pkl standard library"),
197            )
198        }));
199        items.extend(BUILTIN_CONSTANTS.iter().map(|name| {
200            completion_item(
201                name,
202                protocol::completion_kind::VALUE,
203                Some("Pkl constant"),
204                None,
205            )
206        }));
207        items.extend(BUILTIN_FUNCTIONS.iter().map(|(name, detail)| {
208            completion_item(
209                name,
210                protocol::completion_kind::FUNCTION,
211                Some(detail),
212                Some("Built-in Pkl expression"),
213            )
214        }));
215
216        match context {
217            CompletionContext::Member { receiver } => {
218                items.extend(self.member_completion_items(uri, receiver.as_deref()));
219            }
220            CompletionContext::Annotation => {
221                items.extend(
222                    self.index
223                        .symbols
224                        .iter()
225                        .filter(|symbol| symbol.kind == SymbolKind::Annotation)
226                        .map(symbol_completion_item),
227                );
228            }
229            CompletionContext::ImportString => {
230                items.extend(self.index.imports.iter().map(|edge| {
231                    completion_item(
232                        &edge.target,
233                        protocol::completion_kind::MODULE,
234                        Some("Imported module"),
235                        None,
236                    )
237                }));
238            }
239            CompletionContext::Default => {
240                items.extend(self.index.symbols.iter().map(symbol_completion_item));
241            }
242        }
243
244        dedupe_completion_items(&mut items);
245        CompletionList { items }
246    }
247
248    pub fn definition(&self, uri: &str, position: TextPosition) -> Option<Location> {
249        let document = self.documents.get(uri)?;
250        let word = word_at_position(&document.text, position)?;
251        self.index
252            .symbols
253            .iter()
254            .find(|symbol| symbol.name.trim_start_matches('@') == word)
255            .map(|symbol| Location {
256                uri: symbol.uri.clone(),
257                range: symbol.selection_range,
258            })
259    }
260
261    pub fn references(&self, uri: &str, position: TextPosition) -> Vec<Location> {
262        let Some(document) = self.documents.get(uri) else {
263            return Vec::new();
264        };
265        let Some(word) = word_at_position(&document.text, position) else {
266            return Vec::new();
267        };
268        self.documents
269            .iter()
270            .flat_map(|document| find_word_ranges(&document.uri, &document.text, &word))
271            .collect()
272    }
273
274    pub fn document_highlights(&self, uri: &str, position: TextPosition) -> Vec<DocumentHighlight> {
275        self.references(uri, position)
276            .into_iter()
277            .filter(|location| location.uri == uri)
278            .map(|location| DocumentHighlight {
279                range: location.range,
280                kind: Some(protocol::document_highlight_kind::TEXT),
281            })
282            .collect()
283    }
284
285    pub fn semantic_tokens(&self, uri: &str, range: Option<TextRange>) -> SemanticTokens {
286        let Some(document) = self.documents.get(uri) else {
287            return SemanticTokens { data: Vec::new() };
288        };
289        let Ok(tokens) = lexer::lex_named(&document.text, uri) else {
290            return SemanticTokens { data: Vec::new() };
291        };
292        let mut data = Vec::new();
293        for token in tokens {
294            if matches!(token.kind, TokenKind::Eof) {
295                continue;
296            }
297            let Some((token_type, modifiers)) = semantic_class_for_token(&token, &self.index)
298            else {
299                continue;
300            };
301            let token_range = token_text_range(&document.text, &token);
302            if range.is_some_and(|range| !ranges_intersect(range, token_range)) {
303                continue;
304            }
305            data.push(SemanticToken {
306                line: token_range.start.line,
307                start: token_range.start.character,
308                length: token_range
309                    .end
310                    .character
311                    .saturating_sub(token_range.start.character)
312                    .max(1),
313                token_type,
314                modifiers,
315            });
316        }
317        SemanticTokens { data }
318    }
319
320    pub fn folding_ranges(&self, uri: &str) -> Vec<FoldingRange> {
321        let Some(document) = self.documents.get(uri) else {
322            return Vec::new();
323        };
324        let mut stack: Vec<(char, TextPosition)> = Vec::new();
325        let mut ranges = Vec::new();
326        for (offset, ch) in document.text.char_indices() {
327            match ch {
328                '{' | '[' | '(' => stack.push((ch, position_at_offset(&document.text, offset))),
329                '}' | ']' | ')' => {
330                    let close = position_at_offset(&document.text, offset);
331                    if let Some((open, start)) = stack.pop()
332                        && matching_delimiter(open, ch)
333                        && close.line > start.line
334                    {
335                        ranges.push(FoldingRange {
336                            start_line: start.line,
337                            start_character: Some(start.character),
338                            end_line: close.line,
339                            end_character: Some(close.character),
340                            kind: Some("region".to_string()),
341                        });
342                    }
343                }
344                _ => {}
345            }
346        }
347        ranges
348    }
349
350    pub fn selection_ranges(&self, uri: &str, positions: &[TextPosition]) -> Vec<SelectionRange> {
351        let Some(document) = self.documents.get(uri) else {
352            return Vec::new();
353        };
354        positions
355            .iter()
356            .map(|position| {
357                let word = word_range_at_position(&document.text, *position).unwrap_or(TextRange {
358                    start: *position,
359                    end: *position,
360                });
361                SelectionRange {
362                    range: word,
363                    parent: Some(Box::new(SelectionRange {
364                        range: entire_document_range_public(&document.text),
365                        parent: None,
366                    })),
367                }
368            })
369            .collect()
370    }
371
372    pub fn prepare_rename(&self, uri: &str, position: TextPosition) -> Option<PrepareRename> {
373        let document = self.documents.get(uri)?;
374        let range = word_range_at_position(&document.text, position)?;
375        let placeholder = word_at_position(&document.text, position)?;
376        Some(PrepareRename { range, placeholder })
377    }
378
379    pub fn rename(
380        &self,
381        uri: &str,
382        position: TextPosition,
383        new_name: &str,
384    ) -> Option<WorkspaceEdit> {
385        if !is_valid_identifier(new_name) {
386            return None;
387        }
388        let document = self.documents.get(uri)?;
389        let word = word_at_position(&document.text, position)?;
390        let mut changes = Vec::new();
391        for document in self.documents.iter() {
392            let edits = find_word_ranges(&document.uri, &document.text, &word)
393                .into_iter()
394                .map(|location| TextEdit {
395                    range: location.range,
396                    new_text: new_name.to_string(),
397                })
398                .collect::<Vec<_>>();
399            if !edits.is_empty() {
400                changes.push(protocol::DocumentEdit {
401                    uri: document.uri.clone(),
402                    edits,
403                });
404            }
405        }
406        Some(WorkspaceEdit { changes })
407    }
408
409    pub fn code_actions(&self, _uri: &str, _range: TextRange) -> Vec<CodeAction> {
410        Vec::new()
411    }
412
413    pub fn signature_help(&self, uri: &str, position: TextPosition) -> Option<SignatureHelp> {
414        let document = self.documents.get(uri)?;
415        let offset = offset_at_position(&document.text, position);
416        let prefix = &document.text[..offset.min(document.text.len())];
417        let function = BUILTIN_FUNCTIONS
418            .iter()
419            .find(|(name, _)| prefix.ends_with(&format!("{name}(")))?;
420        Some(SignatureHelp {
421            signatures: vec![SignatureInformation {
422                label: function.1.to_string(),
423                documentation: Some("Pkl built-in function".to_string()),
424                parameters: Vec::new(),
425            }],
426            active_signature: Some(0),
427            active_parameter: Some(0),
428        })
429    }
430
431    pub fn inlay_hints(&self, _uri: &str, _range: TextRange) -> Vec<InlayHint> {
432        Vec::new()
433    }
434
435    pub fn type_definition(&self, uri: &str, position: TextPosition) -> Option<Location> {
436        let document = self.documents.get(uri)?;
437        let word = word_at_position(&document.text, position)?;
438        self.index
439            .symbols
440            .iter()
441            .find(|symbol| {
442                symbol.name == word
443                    && matches!(symbol.kind, SymbolKind::Class | SymbolKind::TypeAlias)
444            })
445            .map(|symbol| Location {
446                uri: symbol.uri.clone(),
447                range: symbol.selection_range,
448            })
449    }
450
451    pub fn implementation(&self, uri: &str, position: TextPosition) -> Vec<Location> {
452        self.type_definition(uri, position).into_iter().collect()
453    }
454
455    fn symbol_at(&self, uri: &str, position: TextPosition) -> Option<&PklSymbol> {
456        let offset = self
457            .documents
458            .get(uri)
459            .map(|document| offset_at_position(&document.text, position))?;
460        self.index
461            .symbols_in_uri(uri)
462            .filter(|symbol| {
463                range_contains_offset(
464                    self.documents.get(uri).unwrap().text.as_str(),
465                    symbol.selection_range,
466                    offset,
467                )
468            })
469            .min_by_key(|symbol| {
470                let range = symbol.selection_range;
471                (
472                    range.end.line - range.start.line,
473                    range.end.character - range.start.character,
474                )
475            })
476    }
477
478    fn member_completion_items(&self, uri: &str, receiver: Option<&str>) -> Vec<CompletionItem> {
479        self.index
480            .symbols
481            .iter()
482            .filter(move |symbol| {
483                let include = match receiver {
484                    Some(receiver) => {
485                        symbol.container_name.as_deref() == Some(receiver)
486                            || symbol.uri.ends_with(&format!("/{receiver}.pkl"))
487                            || symbol.uri == uri && symbol.name != receiver
488                    }
489                    None => symbol.uri == uri,
490                };
491                include
492                    && matches!(
493                        symbol.kind,
494                        SymbolKind::Property | SymbolKind::Class | SymbolKind::TypeAlias
495                    )
496            })
497            .map(symbol_completion_item)
498            .collect()
499    }
500
501    fn completion_context(&self, uri: &str, position: TextPosition) -> CompletionContext {
502        let Some(document) = self.documents.get(uri) else {
503            return CompletionContext::Default;
504        };
505        let offset = offset_at_position(&document.text, position);
506        let prefix = &document.text[..offset.min(document.text.len())];
507        let trimmed = prefix.trim_end();
508        if trimmed.ends_with('@') {
509            return CompletionContext::Annotation;
510        }
511        if is_inside_import_string(prefix) {
512            return CompletionContext::ImportString;
513        }
514        if trimmed.ends_with('.') || trimmed.ends_with("?.") {
515            let receiver = receiver_before_dot(trimmed);
516            return CompletionContext::Member { receiver };
517        }
518        CompletionContext::Default
519    }
520}
521
522enum CompletionContext {
523    Default,
524    Member { receiver: Option<String> },
525    Annotation,
526    ImportString,
527}
528
529fn range_contains_offset(text: &str, range: TextRange, offset: usize) -> bool {
530    let start = offset_at_position(text, range.start);
531    let end = offset_at_position(text, range.end);
532    start <= offset && offset <= end
533}
534
535fn completion_item(
536    label: &str,
537    kind: u32,
538    detail: Option<&str>,
539    documentation: Option<&str>,
540) -> CompletionItem {
541    CompletionItem {
542        label: label.to_string(),
543        kind: Some(kind),
544        detail: detail.map(ToOwned::to_owned),
545        documentation: documentation.map(ToOwned::to_owned),
546        sort_text: Some(format!("{kind:02}_{label}")),
547        filter_text: Some(label.to_string()),
548        insert_text: None,
549        insert_text_format: None,
550        text_edit: None,
551    }
552}
553
554fn symbol_completion_item(symbol: &PklSymbol) -> CompletionItem {
555    CompletionItem {
556        label: symbol.name.clone(),
557        kind: Some(match symbol.kind {
558            SymbolKind::Module => protocol::completion_kind::MODULE,
559            SymbolKind::Import => protocol::completion_kind::MODULE,
560            SymbolKind::Property => protocol::completion_kind::PROPERTY,
561            SymbolKind::Class => protocol::completion_kind::CLASS,
562            SymbolKind::TypeAlias => protocol::completion_kind::TYPE_PARAMETER,
563            SymbolKind::Annotation => protocol::completion_kind::REFERENCE,
564        }),
565        detail: symbol.detail.clone(),
566        documentation: symbol
567            .container_name
568            .as_ref()
569            .map(|container| format!("Container: {container}")),
570        sort_text: Some(format!("20_{}", symbol.name)),
571        filter_text: Some(symbol.name.clone()),
572        insert_text: None,
573        insert_text_format: None,
574        text_edit: None,
575    }
576}
577
578fn dedupe_completion_items(items: &mut Vec<CompletionItem>) {
579    items.sort_by(|a, b| {
580        a.sort_text
581            .cmp(&b.sort_text)
582            .then_with(|| a.label.cmp(&b.label))
583    });
584    items.dedup_by(|left, right| left.label == right.label);
585}
586
587fn semantic_class_for_token(token: &Token, index: &WorkspaceIndex) -> Option<(u32, u32)> {
588    match &token.kind {
589        TokenKind::StringLit(_) | TokenKind::InterpolatedString(_) => {
590            Some((protocol::semantic_token_type::STRING, 0))
591        }
592        TokenKind::IntLit(_) | TokenKind::FloatLit(_) => {
593            Some((protocol::semantic_token_type::NUMBER, 0))
594        }
595        TokenKind::BoolLit(_) | TokenKind::Null => Some((
596            protocol::semantic_token_type::KEYWORD,
597            protocol::semantic_token_modifier::READONLY,
598        )),
599        TokenKind::Ident(name) if BUILTIN_TYPES.contains(&name.as_str()) => {
600            Some((protocol::semantic_token_type::TYPE, 0))
601        }
602        TokenKind::Ident(name) if BUILTIN_CONSTANTS.contains(&name.as_str()) => Some((
603            protocol::semantic_token_type::VARIABLE,
604            protocol::semantic_token_modifier::READONLY,
605        )),
606        TokenKind::Ident(name) => index
607            .symbols
608            .iter()
609            .find(|symbol| symbol.name.trim_start_matches('@') == name)
610            .map(|symbol| {
611                let token_type = match symbol.kind {
612                    SymbolKind::Module | SymbolKind::Import => {
613                        protocol::semantic_token_type::NAMESPACE
614                    }
615                    SymbolKind::Property => protocol::semantic_token_type::PROPERTY,
616                    SymbolKind::Class => protocol::semantic_token_type::CLASS,
617                    SymbolKind::TypeAlias => protocol::semantic_token_type::TYPE,
618                    SymbolKind::Annotation => protocol::semantic_token_type::MACRO,
619                };
620                (token_type, protocol::semantic_token_modifier::DEFINITION)
621            })
622            .or(Some((protocol::semantic_token_type::VARIABLE, 0))),
623        kind if is_keyword_token(kind) => Some((protocol::semantic_token_type::KEYWORD, 0)),
624        kind if is_operator_token(kind) => Some((protocol::semantic_token_type::OPERATOR, 0)),
625        _ => None,
626    }
627}
628
629fn is_keyword_token(kind: &TokenKind) -> bool {
630    matches!(
631        kind,
632        TokenKind::KwAmends
633            | TokenKind::KwImport
634            | TokenKind::KwAs
635            | TokenKind::KwLocal
636            | TokenKind::KwConst
637            | TokenKind::KwFixed
638            | TokenKind::KwHidden
639            | TokenKind::KwNew
640            | TokenKind::KwExtends
641            | TokenKind::KwAbstract
642            | TokenKind::KwOpen
643            | TokenKind::KwExternal
644            | TokenKind::KwClass
645            | TokenKind::KwTypeAlias
646            | TokenKind::KwFunction
647            | TokenKind::KwThis
648            | TokenKind::KwSuper
649            | TokenKind::KwModule
650            | TokenKind::KwImportStar
651            | TokenKind::KwIf
652            | TokenKind::KwElse
653            | TokenKind::KwWhen
654            | TokenKind::KwIs
655            | TokenKind::KwLet
656            | TokenKind::KwThrow
657            | TokenKind::KwTrace
658            | TokenKind::KwRead
659            | TokenKind::KwReadOrNull
660            | TokenKind::KwFor
661            | TokenKind::KwIn
662    )
663}
664
665fn is_operator_token(kind: &TokenKind) -> bool {
666    matches!(
667        kind,
668        TokenKind::Plus
669            | TokenKind::Minus
670            | TokenKind::Star
671            | TokenKind::Slash
672            | TokenKind::Percent
673            | TokenKind::EqEq
674            | TokenKind::BangEq
675            | TokenKind::Lt
676            | TokenKind::Gt
677            | TokenKind::LtEq
678            | TokenKind::GtEq
679            | TokenKind::TildeSlash
680            | TokenKind::StarStar
681            | TokenKind::AmpAmp
682            | TokenKind::PipePipe
683            | TokenKind::Arrow
684            | TokenKind::ThinArrow
685            | TokenKind::QuestionQuestion
686            | TokenKind::PipeGt
687            | TokenKind::Bang
688            | TokenKind::BangBang
689    )
690}
691
692fn token_text_range(text: &str, token: &Token) -> TextRange {
693    let start = TextPosition {
694        line: token.line.saturating_sub(1) as u32,
695        character: token.col.saturating_sub(1) as u32,
696    };
697    let end = position_at_offset(text, token.offset.saturating_add(token_display_len(token)));
698    TextRange { start, end }
699}
700
701fn token_display_len(token: &Token) -> usize {
702    match &token.kind {
703        TokenKind::Ident(value) | TokenKind::StringLit(value) => value.len(),
704        TokenKind::IntLit(value) => value.to_string().len(),
705        TokenKind::FloatLit(value) if value.is_nan() => 3,
706        TokenKind::FloatLit(value) if value.is_infinite() => "Infinity".len(),
707        TokenKind::FloatLit(value) => value.to_string().len(),
708        TokenKind::BoolLit(true) => 4,
709        TokenKind::BoolLit(false) => 5,
710        TokenKind::Null => 4,
711        TokenKind::KwImportStar => "import*".len(),
712        TokenKind::KwReadOrNull => "read?".len(),
713        TokenKind::DotDotDot => 3,
714        TokenKind::QuestionQuestion
715        | TokenKind::QuestionDot
716        | TokenKind::BangBang
717        | TokenKind::PipeGt
718        | TokenKind::EqEq
719        | TokenKind::BangEq
720        | TokenKind::LtEq
721        | TokenKind::GtEq
722        | TokenKind::TildeSlash
723        | TokenKind::StarStar
724        | TokenKind::AmpAmp
725        | TokenKind::PipePipe
726        | TokenKind::Arrow
727        | TokenKind::ThinArrow => 2,
728        _ => 1,
729    }
730}
731
732fn ranges_intersect(left: TextRange, right: TextRange) -> bool {
733    position_le(left.start, right.end) && position_le(right.start, left.end)
734}
735
736fn position_le(left: TextPosition, right: TextPosition) -> bool {
737    left.line < right.line || (left.line == right.line && left.character <= right.character)
738}
739
740fn word_range_at_position(text: &str, position: TextPosition) -> Option<TextRange> {
741    let offset = offset_at_position(text, position);
742    let bytes = text.as_bytes();
743    let mut start = offset.min(bytes.len());
744    while start > 0 && is_ident_byte(bytes[start - 1]) {
745        start -= 1;
746    }
747    let mut end = offset.min(bytes.len());
748    while end < bytes.len() && is_ident_byte(bytes[end]) {
749        end += 1;
750    }
751    (start < end).then(|| TextRange {
752        start: position_at_offset(text, start),
753        end: position_at_offset(text, end),
754    })
755}
756
757fn entire_document_range_public(text: &str) -> TextRange {
758    TextRange {
759        start: TextPosition {
760            line: 0,
761            character: 0,
762        },
763        end: position_at_offset(text, text.len()),
764    }
765}
766
767fn matching_delimiter(open: char, close: char) -> bool {
768    matches!((open, close), ('{', '}') | ('[', ']') | ('(', ')'))
769}
770
771fn is_valid_identifier(value: &str) -> bool {
772    let mut chars = value.chars();
773    chars
774        .next()
775        .is_some_and(|ch| ch.is_alphabetic() || ch == '_')
776        && chars.all(|ch| ch.is_alphanumeric() || ch == '_' || ch == '-')
777}
778
779fn is_inside_import_string(prefix: &str) -> bool {
780    let trimmed = prefix.trim_end();
781    let Some(last_quote) = trimmed.rfind('"') else {
782        return false;
783    };
784    let before = trimmed[..last_quote].trim_end();
785    before.ends_with("import") || before.ends_with("import*") || before.ends_with("amends")
786}
787
788fn receiver_before_dot(trimmed: &str) -> Option<String> {
789    let without_dot = trimmed.trim_end_matches('.').trim_end_matches('?');
790    let end = without_dot.len();
791    let bytes = without_dot.as_bytes();
792    let mut start = end;
793    while start > 0 && is_ident_byte(bytes[start - 1]) {
794        start -= 1;
795    }
796    (start < end).then(|| without_dot[start..end].to_string())
797}
798
799fn find_word_ranges(uri: &str, text: &str, word: &str) -> Vec<Location> {
800    let mut result = Vec::new();
801    let mut start = 0;
802    while let Some(idx) = text[start..].find(word) {
803        let absolute = start + idx;
804        let before = absolute
805            .checked_sub(1)
806            .and_then(|pos| text.as_bytes().get(pos))
807            .copied();
808        let after = text.as_bytes().get(absolute + word.len()).copied();
809        if !before.is_some_and(is_ident_byte) && !after.is_some_and(is_ident_byte) {
810            let start_pos = crate::index::position_at_offset(text, absolute);
811            let end_pos = crate::index::position_at_offset(text, absolute + word.len());
812            result.push(Location {
813                uri: uri.to_string(),
814                range: TextRange {
815                    start: start_pos,
816                    end: end_pos,
817                },
818            });
819        }
820        start = absolute + word.len();
821    }
822    result
823}
824
825fn is_ident_byte(byte: u8) -> bool {
826    byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-'
827}
828
829#[cfg(test)]
830mod tests {
831    use crate::{CompletionOptions, DocumentStore, FeatureEngine, TextPosition};
832
833    #[test]
834    fn hover_completion_definition_and_references() {
835        let mut docs = DocumentStore::default();
836        docs.open(
837            "file:///demo.pkl",
838            "class App {\n  name: String\n}\nname: String = \"demo\"\nother = name\n",
839            1,
840        );
841        let engine = FeatureEngine::new(docs);
842
843        assert!(
844            engine
845                .hover(
846                    "file:///demo.pkl",
847                    TextPosition {
848                        line: 0,
849                        character: 7,
850                    },
851                )
852                .is_some()
853        );
854        let completion = engine.completion(
855            "file:///demo.pkl",
856            TextPosition {
857                line: 0,
858                character: 0,
859            },
860            CompletionOptions {
861                include_keywords: true,
862            },
863        );
864        assert!(!completion.items.is_empty());
865        assert!(completion.items.iter().any(|item| item.label == "String"));
866        assert!(completion.items.iter().any(|item| item.label == "abstract"));
867        assert!(
868            engine
869                .definition(
870                    "file:///demo.pkl",
871                    TextPosition {
872                        line: 4,
873                        character: 9,
874                    },
875                )
876                .is_some()
877        );
878        assert_eq!(
879            engine
880                .references(
881                    "file:///demo.pkl",
882                    TextPosition {
883                        line: 4,
884                        character: 9,
885                    },
886                )
887                .len(),
888            3
889        );
890        assert!(
891            !engine
892                .semantic_tokens("file:///demo.pkl", None)
893                .data
894                .is_empty()
895        );
896        assert!(!engine.folding_ranges("file:///demo.pkl").is_empty());
897        assert!(
898            engine
899                .prepare_rename(
900                    "file:///demo.pkl",
901                    TextPosition {
902                        line: 4,
903                        character: 9,
904                    },
905                )
906                .is_some()
907        );
908        let edit = engine
909            .rename(
910                "file:///demo.pkl",
911                TextPosition {
912                    line: 4,
913                    character: 9,
914                },
915                "newName",
916            )
917            .unwrap();
918        assert_eq!(edit.changes[0].edits.len(), 3);
919        assert!(
920            engine
921                .workspace_symbols("App")
922                .iter()
923                .any(|symbol| symbol.name == "App")
924        );
925    }
926}