Skip to main content

pkl_lsp_core/
index.rs

1use pklr::error::Error as PklError;
2use pklr::lexer::{self, Token, TokenKind};
3use pklr::parser::{self, Annotation, Entry, Expr, Import, Module, Property, TypeExpr};
4use rkyv::{Archive, Deserialize, Serialize};
5use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
6
7use crate::Document;
8use crate::protocol;
9
10#[derive(Debug, Clone, PartialEq, Eq, Archive, Serialize, Deserialize)]
11#[rkyv(compare(PartialEq), derive(Debug))]
12pub enum DiagnosticSource {
13    Lex,
14    Parse,
15    Eval,
16    Io,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Archive, Serialize, Deserialize)]
20#[rkyv(compare(PartialEq), derive(Debug))]
21pub struct PklDiagnostic {
22    pub uri: String,
23    pub range: TextRange,
24    pub message: String,
25    pub source: DiagnosticSource,
26}
27
28#[derive(
29    Debug,
30    Clone,
31    Copy,
32    PartialEq,
33    Eq,
34    Archive,
35    Serialize,
36    Deserialize,
37    SerdeSerialize,
38    SerdeDeserialize,
39)]
40#[rkyv(compare(PartialEq), derive(Debug))]
41pub struct TextPosition {
42    pub line: u32,
43    pub character: u32,
44}
45
46#[derive(
47    Debug,
48    Clone,
49    Copy,
50    PartialEq,
51    Eq,
52    Archive,
53    Serialize,
54    Deserialize,
55    SerdeSerialize,
56    SerdeDeserialize,
57)]
58#[rkyv(compare(PartialEq), derive(Debug))]
59pub struct TextRange {
60    pub start: TextPosition,
61    pub end: TextPosition,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Archive, Serialize, Deserialize)]
65#[rkyv(compare(PartialEq), derive(Debug))]
66pub enum SymbolKind {
67    Module,
68    Import,
69    Property,
70    Class,
71    TypeAlias,
72    Annotation,
73}
74
75impl SymbolKind {
76    pub fn as_protocol(&self) -> u32 {
77        match self {
78            Self::Module => protocol::symbol_kind::MODULE,
79            Self::Import => protocol::symbol_kind::NAMESPACE,
80            Self::Property => protocol::symbol_kind::PROPERTY,
81            Self::Class => protocol::symbol_kind::CLASS,
82            Self::TypeAlias => protocol::symbol_kind::TYPE_PARAMETER,
83            Self::Annotation => protocol::symbol_kind::EVENT,
84        }
85    }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Archive, Serialize, Deserialize)]
89#[rkyv(compare(PartialEq), derive(Debug))]
90pub struct PklSymbol {
91    pub uri: String,
92    pub name: String,
93    pub kind: SymbolKind,
94    pub range: TextRange,
95    pub selection_range: TextRange,
96    pub detail: Option<String>,
97    pub container_name: Option<String>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Archive, Serialize, Deserialize)]
101#[rkyv(compare(PartialEq), derive(Debug))]
102pub enum ImportKind {
103    Import,
104    GlobImport,
105    Amends,
106    Extends,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Archive, Serialize, Deserialize)]
110#[rkyv(compare(PartialEq), derive(Debug))]
111pub struct ImportEdge {
112    pub uri: String,
113    pub target: String,
114    pub alias: Option<String>,
115    pub kind: ImportKind,
116    pub range: TextRange,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Archive, Serialize, Deserialize)]
120#[rkyv(compare(PartialEq), derive(Debug))]
121pub struct WorkspaceIndex {
122    pub diagnostics: Vec<PklDiagnostic>,
123    pub imports: Vec<ImportEdge>,
124    pub symbols: Vec<PklSymbol>,
125}
126
127impl WorkspaceIndex {
128    pub fn build<'a>(documents: impl IntoIterator<Item = &'a Document>) -> Self {
129        let mut index = Self {
130            diagnostics: Vec::new(),
131            imports: Vec::new(),
132            symbols: Vec::new(),
133        };
134
135        for document in documents {
136            index.index_document(document);
137        }
138
139        index
140    }
141
142    pub fn diagnostics_for_protocol(&self, uri: &str) -> Vec<protocol::Diagnostic> {
143        self.diagnostics
144            .iter()
145            .filter(|diagnostic| diagnostic.uri == uri)
146            .map(|diagnostic| protocol::Diagnostic {
147                range: diagnostic.range,
148                severity: Some(match diagnostic.source {
149                    DiagnosticSource::Lex
150                    | DiagnosticSource::Parse
151                    | DiagnosticSource::Eval
152                    | DiagnosticSource::Io => protocol::diagnostic_severity::ERROR,
153                }),
154                source: Some("pkl-lsp".to_string()),
155                message: diagnostic.message.clone(),
156            })
157            .collect()
158    }
159
160    pub fn document_symbols(&self, uri: &str) -> Vec<protocol::DocumentSymbol> {
161        self.symbols
162            .iter()
163            .filter(|symbol| symbol.uri == uri)
164            .map(|symbol| protocol::DocumentSymbol {
165                name: symbol.name.clone(),
166                detail: symbol.detail.clone(),
167                kind: symbol.kind.as_protocol(),
168                range: symbol.range,
169                selection_range: symbol.selection_range,
170                children: None,
171            })
172            .collect()
173    }
174
175    pub fn symbols_in_uri(&self, uri: &str) -> impl Iterator<Item = &PklSymbol> {
176        self.symbols.iter().filter(move |symbol| symbol.uri == uri)
177    }
178
179    fn index_document(&mut self, document: &Document) {
180        let module_symbol = PklSymbol {
181            uri: document.uri.clone(),
182            name: module_name(&document.uri),
183            kind: SymbolKind::Module,
184            range: entire_document_range(&document.text),
185            selection_range: position_range(0, 0, 0, 0),
186            detail: Some("Pkl module".to_string()),
187            container_name: None,
188        };
189        self.symbols.push(module_symbol);
190
191        let tokens = match lexer::lex_named(&document.text, &document.uri) {
192            Ok(tokens) => tokens,
193            Err(error) => {
194                self.diagnostics
195                    .push(error_to_diagnostic(&document.uri, &document.text, error));
196                return;
197            }
198        };
199
200        let module = match parser::parse_named(&tokens, &document.text, &document.uri) {
201            Ok(module) => module,
202            Err(error) => {
203                self.diagnostics
204                    .push(error_to_diagnostic(&document.uri, &document.text, error));
205                self.collect_fast_imports(document, &tokens);
206                return;
207            }
208        };
209
210        self.collect_module(document, &tokens, &module);
211    }
212
213    fn collect_fast_imports(&mut self, document: &Document, tokens: &[Token]) {
214        for uri in parser::collect_imports(tokens) {
215            if let Some(token) = find_string_token(tokens, &uri) {
216                self.imports.push(ImportEdge {
217                    uri: document.uri.clone(),
218                    target: uri,
219                    alias: None,
220                    kind: ImportKind::Import,
221                    range: token_range(token),
222                });
223            }
224        }
225    }
226
227    fn collect_module(&mut self, document: &Document, tokens: &[Token], module: &Module) {
228        if let Some(amends) = &module.amends {
229            self.push_module_edge(document, tokens, amends, ImportKind::Amends, None);
230        }
231        if let Some(extends) = &module.extends {
232            self.push_module_edge(document, tokens, extends, ImportKind::Extends, None);
233        }
234        for import in &module.imports {
235            self.push_import(document, tokens, import);
236        }
237        let mut cursor = TokenCursor::new(tokens);
238        self.collect_annotations(document, &module.annotations, &mut cursor, None);
239        self.collect_entries(document, &module.body, &mut cursor, None);
240    }
241
242    fn push_import(&mut self, document: &Document, tokens: &[Token], import: &Import) {
243        let kind = if import.is_glob {
244            ImportKind::GlobImport
245        } else {
246            ImportKind::Import
247        };
248        self.push_module_edge(document, tokens, &import.uri, kind, import.alias.clone());
249        let name = import
250            .alias
251            .clone()
252            .unwrap_or_else(|| import_name_from_uri(&import.uri));
253        if let Some(token) = find_ident_or_string_token(tokens, &name, &import.uri) {
254            self.symbols.push(PklSymbol {
255                uri: document.uri.clone(),
256                name,
257                kind: SymbolKind::Import,
258                range: token_range(token),
259                selection_range: token_range(token),
260                detail: Some(format!("import {}", import.uri)),
261                container_name: None,
262            });
263        }
264    }
265
266    fn push_module_edge(
267        &mut self,
268        document: &Document,
269        tokens: &[Token],
270        target: &str,
271        kind: ImportKind,
272        alias: Option<String>,
273    ) {
274        let range = find_string_token(tokens, target)
275            .map_or_else(|| entire_document_range(&document.text), token_range);
276        self.imports.push(ImportEdge {
277            uri: document.uri.clone(),
278            target: target.to_string(),
279            alias,
280            kind,
281            range,
282        });
283    }
284
285    fn collect_annotations(
286        &mut self,
287        document: &Document,
288        annotations: &[Annotation],
289        cursor: &mut TokenCursor<'_>,
290        container_name: Option<&str>,
291    ) {
292        for annotation in annotations {
293            if let Some(token) = cursor.next_ident(&annotation.name) {
294                self.symbols.push(PklSymbol {
295                    uri: document.uri.clone(),
296                    name: format!("@{}", annotation.name),
297                    kind: SymbolKind::Annotation,
298                    range: token_range(token),
299                    selection_range: token_range(token),
300                    detail: Some("annotation".to_string()),
301                    container_name: container_name.map(ToOwned::to_owned),
302                });
303            }
304            self.collect_entries(document, &annotation.body, cursor, Some(&annotation.name));
305        }
306    }
307
308    fn collect_entries(
309        &mut self,
310        document: &Document,
311        entries: &[Entry],
312        cursor: &mut TokenCursor<'_>,
313        container_name: Option<&str>,
314    ) {
315        for entry in entries {
316            match entry {
317                Entry::Property(property) => {
318                    self.collect_property(document, property, cursor, container_name)
319                }
320                Entry::ClassDef(name, _, parent, body) => {
321                    if let Some(token) = cursor.next_ident(name) {
322                        self.symbols.push(PklSymbol {
323                            uri: document.uri.clone(),
324                            name: name.clone(),
325                            kind: SymbolKind::Class,
326                            range: token_range(token),
327                            selection_range: token_range(token),
328                            detail: parent.as_ref().map(|parent| format!("extends {parent}")),
329                            container_name: container_name.map(ToOwned::to_owned),
330                        });
331                    }
332                    self.collect_entries(document, body, cursor, Some(name));
333                }
334                Entry::TypeAlias(name, ty) => {
335                    if let Some(token) = cursor.next_ident(name) {
336                        self.symbols.push(PklSymbol {
337                            uri: document.uri.clone(),
338                            name: name.clone(),
339                            kind: SymbolKind::TypeAlias,
340                            range: token_range(token),
341                            selection_range: token_range(token),
342                            detail: Some(type_expr_label(ty)),
343                            container_name: container_name.map(ToOwned::to_owned),
344                        });
345                    }
346                }
347                Entry::DynProperty(key, value) => {
348                    collect_expr(cursor, key);
349                    collect_expr(cursor, value);
350                }
351                Entry::ForGenerator(generator) => {
352                    self.collect_entries(document, &generator.body, cursor, container_name)
353                }
354                Entry::WhenGenerator(generator) => {
355                    self.collect_entries(document, &generator.body, cursor, container_name);
356                    if let Some(else_body) = &generator.else_body {
357                        self.collect_entries(document, else_body, cursor, container_name);
358                    }
359                }
360                Entry::Spread(expr) | Entry::Elem(expr) => collect_expr(cursor, expr),
361            }
362        }
363    }
364
365    fn collect_property(
366        &mut self,
367        document: &Document,
368        property: &Property,
369        cursor: &mut TokenCursor<'_>,
370        container_name: Option<&str>,
371    ) {
372        self.collect_annotations(
373            document,
374            &property.annotations,
375            cursor,
376            Some(&property.name),
377        );
378        if let Some(token) = cursor.next_ident(&property.name) {
379            let detail = property.type_ann.as_ref().map(type_expr_label);
380            self.symbols.push(PklSymbol {
381                uri: document.uri.clone(),
382                name: property.name.clone(),
383                kind: SymbolKind::Property,
384                range: token_range(token),
385                selection_range: token_range(token),
386                detail,
387                container_name: container_name.map(ToOwned::to_owned),
388            });
389        }
390        if let Some(value) = &property.value {
391            collect_expr(cursor, value);
392        }
393        if let Some(body) = &property.body {
394            self.collect_entries(document, body, cursor, Some(&property.name));
395        }
396    }
397}
398
399fn collect_expr(_cursor: &mut TokenCursor<'_>, _expr: &Expr) {}
400
401fn error_to_diagnostic(uri: &str, text: &str, error: PklError) -> PklDiagnostic {
402    match &error {
403        PklError::Lex { message, .. } => PklDiagnostic {
404            uri: uri.to_string(),
405            range: offset_range(text, error.source_offset().unwrap_or_default(), 1),
406            message: message.clone(),
407            source: DiagnosticSource::Lex,
408        },
409        PklError::Parse { message, .. } => PklDiagnostic {
410            uri: uri.to_string(),
411            range: offset_range(text, error.source_offset().unwrap_or_default(), 1),
412            message: message.clone(),
413            source: DiagnosticSource::Parse,
414        },
415        PklError::Io(path, error) => PklDiagnostic {
416            uri: uri.to_string(),
417            range: position_range(0, 0, 0, 1),
418            message: format!("IO error reading {}: {error}", path.display()),
419            source: DiagnosticSource::Io,
420        },
421        PklError::Eval(message)
422        | PklError::ImportNotFound(message)
423        | PklError::Unsupported(message) => PklDiagnostic {
424            uri: uri.to_string(),
425            range: position_range(0, 0, 0, 1),
426            message: message.clone(),
427            source: DiagnosticSource::Eval,
428        },
429    }
430}
431
432pub fn position_at_offset(text: &str, offset: usize) -> TextPosition {
433    let mut line = 0u32;
434    let mut line_start = 0usize;
435    for (idx, ch) in text.char_indices() {
436        if idx >= offset {
437            break;
438        }
439        if ch == '\n' {
440            line += 1;
441            line_start = idx + ch.len_utf8();
442        }
443    }
444    TextPosition {
445        line,
446        character: text[line_start..offset.min(text.len())].chars().count() as u32,
447    }
448}
449
450pub fn offset_at_position(text: &str, position: TextPosition) -> usize {
451    let mut line = 0u32;
452    let mut character = 0u32;
453    for (idx, ch) in text.char_indices() {
454        if line == position.line && character == position.character {
455            return idx;
456        }
457        if ch == '\n' {
458            line += 1;
459            character = 0;
460            if line > position.line {
461                return idx;
462            }
463        } else {
464            character += 1;
465        }
466    }
467    text.len()
468}
469
470pub fn word_at_position(text: &str, position: TextPosition) -> Option<String> {
471    let offset = offset_at_position(text, position);
472    let bytes = text.as_bytes();
473    let mut start = offset.min(bytes.len());
474    while start > 0 && is_ident_byte(bytes[start - 1]) {
475        start -= 1;
476    }
477    let mut end = offset.min(bytes.len());
478    while end < bytes.len() && is_ident_byte(bytes[end]) {
479        end += 1;
480    }
481    (start < end).then(|| text[start..end].to_string())
482}
483
484fn is_ident_byte(byte: u8) -> bool {
485    byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-'
486}
487
488fn offset_range(text: &str, offset: usize, len: usize) -> TextRange {
489    TextRange {
490        start: position_at_offset(text, offset),
491        end: position_at_offset(text, offset.saturating_add(len).min(text.len())),
492    }
493}
494
495fn token_range(token: &Token) -> TextRange {
496    let start_line = token.line.saturating_sub(1) as u32;
497    let start_col = token.col.saturating_sub(1) as u32;
498    let len = token_len(token) as u32;
499    position_range(
500        start_line,
501        start_col,
502        start_line,
503        start_col.saturating_add(len.max(1)),
504    )
505}
506
507fn token_len(token: &Token) -> usize {
508    match &token.kind {
509        TokenKind::Ident(value) | TokenKind::StringLit(value) => value.chars().count(),
510        TokenKind::IntLit(value) => value.to_string().len(),
511        TokenKind::FloatLit(value) => value.to_string().len(),
512        TokenKind::BoolLit(value) => value.to_string().len(),
513        TokenKind::Null => 4,
514        _ => 1,
515    }
516}
517
518pub fn position_range(start_line: u32, start_col: u32, end_line: u32, end_col: u32) -> TextRange {
519    TextRange {
520        start: TextPosition {
521            line: start_line,
522            character: start_col,
523        },
524        end: TextPosition {
525            line: end_line,
526            character: end_col,
527        },
528    }
529}
530
531fn entire_document_range(text: &str) -> TextRange {
532    let end = position_at_offset(text, text.len());
533    TextRange {
534        start: TextPosition {
535            line: 0,
536            character: 0,
537        },
538        end,
539    }
540}
541
542fn find_string_token<'a>(tokens: &'a [Token], value: &str) -> Option<&'a Token> {
543    tokens
544        .iter()
545        .find(|token| matches!(&token.kind, TokenKind::StringLit(text) if text == value))
546}
547
548fn find_ident_or_string_token<'a>(
549    tokens: &'a [Token],
550    ident: &str,
551    string: &str,
552) -> Option<&'a Token> {
553    tokens.iter().find(|token| match &token.kind {
554        TokenKind::Ident(text) => text == ident,
555        TokenKind::StringLit(text) => text == string,
556        _ => false,
557    })
558}
559
560fn module_name(uri: &str) -> String {
561    uri.rsplit('/')
562        .next()
563        .filter(|name| !name.is_empty())
564        .unwrap_or(uri)
565        .trim_end_matches(".pkl")
566        .to_string()
567}
568
569fn import_name_from_uri(uri: &str) -> String {
570    uri.rsplit('/')
571        .next()
572        .unwrap_or(uri)
573        .trim_end_matches(".pkl")
574        .replace('-', "_")
575}
576
577fn type_expr_label(ty: &TypeExpr) -> String {
578    match ty {
579        TypeExpr::Named(name) => name.clone(),
580        TypeExpr::Nullable(inner) => format!("{}?", type_expr_label(inner)),
581        TypeExpr::Union(items) => items
582            .iter()
583            .map(type_expr_label)
584            .collect::<Vec<_>>()
585            .join(" | "),
586        TypeExpr::Generic(name, args) => {
587            format!(
588                "{name}<{}>",
589                args.iter()
590                    .map(type_expr_label)
591                    .collect::<Vec<_>>()
592                    .join(", ")
593            )
594        }
595        TypeExpr::Constrained(name, _) => format!("{name}(...)"),
596    }
597}
598
599struct TokenCursor<'a> {
600    tokens: &'a [Token],
601    pos: usize,
602}
603
604impl<'a> TokenCursor<'a> {
605    fn new(tokens: &'a [Token]) -> Self {
606        Self { tokens, pos: 0 }
607    }
608
609    fn next_ident(&mut self, name: &str) -> Option<&'a Token> {
610        let found = self.tokens[self.pos..]
611            .iter()
612            .position(|token| matches!(&token.kind, TokenKind::Ident(text) if text == name))?;
613        self.pos += found + 1;
614        self.tokens.get(self.pos - 1)
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::{ImportKind, SymbolKind, WorkspaceIndex};
621    use crate::Document;
622
623    #[test]
624    fn extracts_symbols_and_imports() {
625        let document = Document {
626            uri: "file:///demo.pkl".to_string(),
627            version: 1,
628            text: r#"
629import "base.pkl" as base
630amends "parent.pkl"
631
632@Deprecated { message = "old" }
633class AppConfig {
634  name: String = "demo"
635}
636
637typealias Name = String
638"#
639            .to_string(),
640        };
641
642        let index = WorkspaceIndex::build([&document]);
643
644        assert!(index.diagnostics.is_empty(), "{:?}", index.diagnostics);
645        assert!(
646            index
647                .symbols
648                .iter()
649                .any(|symbol| symbol.name == "AppConfig" && symbol.kind == SymbolKind::Class)
650        );
651        assert!(
652            index
653                .symbols
654                .iter()
655                .any(|symbol| symbol.name == "Name" && symbol.kind == SymbolKind::TypeAlias)
656        );
657        assert!(
658            index
659                .imports
660                .iter()
661                .any(|edge| edge.target == "parent.pkl" && edge.kind == ImportKind::Amends)
662        );
663        assert!(
664            index
665                .imports
666                .iter()
667                .any(|edge| edge.alias.as_deref() == Some("base"))
668        );
669    }
670
671    #[test]
672    fn reports_parse_diagnostics_with_ranges() {
673        let document = Document {
674            uri: "file:///bad.pkl".to_string(),
675            version: 1,
676            text: "name = ".to_string(),
677        };
678
679        let index = WorkspaceIndex::build([&document]);
680        assert_eq!(index.diagnostics.len(), 1);
681        assert_eq!(index.diagnostics[0].range.start.line, 0);
682    }
683}