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}