Skip to main content

gdscript_base/
lib.rs

1//! `gdscript-base` — foundational POD types shared across the gdscript-analyzer.
2//!
3//! The lowest layer of the crate stack (`plans/01-ARCHITECTURE.md` §1). It holds the
4//! engine-/protocol-neutral, `serde`-serializable result structs every client maps to
5//! its own protocol, plus byte-offset position types and a [`LineIndex`] for the
6//! byte↔(line, column) and byte↔UTF-16 conversions LSP clients need.
7//!
8//! All offsets are **byte** offsets into a file's UTF-8 source. No logic beyond the
9//! conversions lives here. The crate is `wasm32`-safe (no `std::fs`, clocks, threads).
10#![cfg_attr(docsrs, feature(doc_cfg))]
11
12use serde::{Deserialize, Serialize};
13
14/// An opaque file handle. The host owns the `FileId` → text mapping; the library never
15/// reads paths.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
17pub struct FileId(pub u32);
18
19/// A half-open byte range `[start, end)` into a file's UTF-8 source.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21pub struct TextRange {
22    /// Inclusive start byte offset.
23    pub start: u32,
24    /// Exclusive end byte offset.
25    pub end: u32,
26}
27
28impl TextRange {
29    /// A new range from `start` to `end` (bytes).
30    #[must_use]
31    pub const fn new(start: u32, end: u32) -> Self {
32        Self { start, end }
33    }
34}
35
36/// A `(file, byte offset)` cursor position.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38pub struct FilePosition {
39    /// The file.
40    pub file: FileId,
41    /// The byte offset within the file.
42    pub offset: u32,
43}
44
45/// Diagnostic severity.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "lowercase")]
48pub enum Severity {
49    /// A hard error.
50    Error,
51    /// A warning.
52    Warning,
53    /// Informational.
54    Info,
55    /// A hint.
56    Hint,
57}
58
59/// What analysis layer produced a diagnostic — lets clients group/filter parse vs. type
60/// diagnostics without parsing the `code`.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
62#[serde(rename_all = "lowercase")]
63pub enum DiagnosticSource {
64    /// A lexer / parser / indentation diagnostic (Phase 1).
65    #[default]
66    Syntax,
67    /// A type / semantic diagnostic from inference (Phase 2).
68    Type,
69}
70
71/// A diagnostic with a byte range, a stable machine code, a severity, and a message.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct Diagnostic {
74    /// The byte range the diagnostic applies to.
75    pub range: TextRange,
76    /// Severity.
77    pub severity: Severity,
78    /// A stable code, e.g. `GDSCRIPT_SYNTAX` or `INTEGER_DIVISION`.
79    pub code: String,
80    /// Human-readable message.
81    pub message: String,
82    /// Which analysis layer produced it. Defaults to [`DiagnosticSource::Syntax`] so older
83    /// serialized diagnostics and Phase-1 call sites round-trip unchanged.
84    #[serde(default)]
85    pub source: DiagnosticSource,
86    /// Quick-fixes offered for this diagnostic (e.g. "add type annotation"). Empty when none.
87    #[serde(default)]
88    pub fixes: Vec<CodeAction>,
89}
90
91/// The kind of a document symbol (a subset of LSP `SymbolKind`, named for GDScript).
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "snake_case")]
94pub enum SymbolKind {
95    /// A `class_name` / inner `class`.
96    Class,
97    /// A `func`.
98    Function,
99    /// A `func` that is a class member (currently same as `Function`).
100    Method,
101    /// A `var`.
102    Variable,
103    /// A `const`.
104    Constant,
105    /// An `enum`.
106    Enum,
107    /// An enum variant.
108    EnumMember,
109    /// A `signal`.
110    Signal,
111}
112
113/// A (possibly nested) symbol in a document's outline.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct DocumentSymbol {
116    /// The symbol name.
117    pub name: String,
118    /// Optional detail (e.g. a signature).
119    pub detail: Option<String>,
120    /// The symbol kind.
121    pub kind: SymbolKind,
122    /// The full range of the symbol (its whole declaration).
123    pub range: TextRange,
124    /// The range of the name/selection within `range`.
125    pub selection_range: TextRange,
126    /// Nested symbols (members of a class, variants of an enum).
127    pub children: Vec<DocumentSymbol>,
128}
129
130/// What a fold range corresponds to.
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(rename_all = "lowercase")]
133pub enum FoldKind {
134    /// An indented block body.
135    Block,
136    /// A `#region`…`#endregion` pair.
137    Region,
138    /// A multi-line bracketed span.
139    Brackets,
140}
141
142/// A foldable range.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144pub struct FoldRange {
145    /// The foldable byte range.
146    pub range: TextRange,
147    /// What kind of fold it is.
148    pub kind: FoldKind,
149}
150
151/// The kind of a completion item.
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(rename_all = "snake_case")]
154pub enum CompletionKind {
155    /// A language keyword.
156    Keyword,
157    /// An annotation (`@export`, …).
158    Annotation,
159    /// A function/method name.
160    Function,
161    /// A variable / parameter / local.
162    Variable,
163    /// A constant.
164    Constant,
165    /// A class / type name.
166    Class,
167    /// An enum.
168    Enum,
169    /// A signal.
170    Signal,
171}
172
173/// A by-name completion suggestion.
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175pub struct CompletionItem {
176    /// The label shown / inserted.
177    pub label: String,
178    /// The kind of suggestion.
179    pub kind: CompletionKind,
180    /// Optional text to insert (defaults to `label`).
181    pub insert_text: Option<String>,
182    /// Optional secondary text shown after the label — a type or signature, e.g. `: int`
183    /// or `(node: Node) -> void`. Phase 2 fills this for typed members; `None` keeps the
184    /// Phase-1 by-name items unchanged.
185    #[serde(default)]
186    pub detail: Option<String>,
187}
188
189// ---------------------------------------------------------------------------
190// Phase 2 PODs — hover, signature help, inlay hints, code actions, navigation.
191// Each is an engine-/protocol-neutral result struct (byte offsets, serde). A feature
192// returning one of these maps it to its own protocol at the client edge. See
193// `plans/PHASE-2-IMPLEMENTATION-PLAYBOOK.md` §1.1.
194// ---------------------------------------------------------------------------
195
196/// Documentation rendered as Markdown (engine `BBCode` already converted at codegen time).
197pub type Markdown = String;
198
199/// The result of a hover query: an inferred type / signature label plus engine docs.
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct HoverResult {
202    /// The inferred type / signature rendered for display, e.g. `Node` or
203    /// `add_child(node: Node) -> void`. `None` when the type is `Unknown` (elided — the
204    /// Phase-3 cross-file seam) so we never show a placeholder type.
205    pub ty_label: Option<String>,
206    /// Engine documentation as Markdown. Empty when no doc XML is available.
207    pub doc: Markdown,
208    /// The source range the hover applies to (the hovered token / expression).
209    pub range: TextRange,
210}
211
212/// One parameter within a [`SignatureInfo`].
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214pub struct ParamInfo {
215    /// The parameter label, e.g. `node: Node` or `force_readable_name: bool = false`.
216    pub label: String,
217    /// Optional documentation (Markdown).
218    pub doc: Markdown,
219}
220
221/// One signature shown in signature help (GDScript has no overloads, so usually one).
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223pub struct SignatureInfo {
224    /// The full signature label, e.g.
225    /// `add_child(node: Node, force_readable_name: bool = false) -> void`.
226    pub label: String,
227    /// Optional documentation (Markdown).
228    pub doc: Markdown,
229    /// The parameters, in order.
230    pub params: Vec<ParamInfo>,
231}
232
233/// The result of a signature-help query at a call site.
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235pub struct SignatureHelp {
236    /// The candidate signatures.
237    pub signatures: Vec<SignatureInfo>,
238    /// Index into `signatures` of the active one.
239    pub active_signature: u32,
240    /// Index of the active parameter within the active signature. A vararg call keeps the
241    /// last parameter active once the fixed parameters are exhausted.
242    pub active_parameter: u32,
243}
244
245/// What an [`InlayHint`] represents.
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
247#[serde(rename_all = "lowercase")]
248pub enum InlayHintKind {
249    /// An inferred type, e.g. `: int` after a `:=` declaration or an unannotated parameter.
250    Type,
251    /// An inferred parameter name shown at a call site.
252    Parameter,
253}
254
255/// An inline hint rendered at a byte offset (e.g. the `: int` the engine LSP omits).
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
257pub struct InlayHint {
258    /// The byte offset at which to render the hint.
259    pub offset: u32,
260    /// The hint text, e.g. `: int`.
261    pub label: String,
262    /// What kind of hint it is.
263    pub kind: InlayHintKind,
264}
265
266/// The semantic role of a [`SemanticToken`] — a GDScript-named subset of the LSP standard token
267/// types. Richer than a TextMate grammar: it distinguishes a type from a variable, a parameter from
268/// a local, a member from a global, a declaration from a use.
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
270#[serde(rename_all = "camelCase")]
271pub enum SemanticTokenType {
272    /// A free function / a function call.
273    Function,
274    /// A method (a function that is a class member).
275    Method,
276    /// A local variable / `var`.
277    Variable,
278    /// A function parameter.
279    Parameter,
280    /// A member field accessed via `.`.
281    Property,
282    /// A `class` / `class_name`.
283    Class,
284    /// An `enum`.
285    Enum,
286    /// An enum variant.
287    EnumMember,
288    /// A type name (in a `: T`, `as T`, `is T`, `extends T`, `-> T` position).
289    Type,
290    /// An annotation, e.g. `@export`.
291    Decorator,
292    /// A numeric literal.
293    Number,
294    /// A string literal (incl. `StringName` / `NodePath`).
295    String,
296    /// A comment.
297    Comment,
298    /// A `signal`.
299    Signal,
300    /// A `const`.
301    Constant,
302}
303
304/// Bit flags for [`SemanticToken::modifiers`] (the LSP standard modifier subset we emit).
305pub mod semantic_token_modifier {
306    /// The token is the *declaration* of the symbol (vs. a use).
307    pub const DECLARATION: u32 = 1 << 0;
308    /// A read-only binding (`const`).
309    pub const READONLY: u32 = 1 << 1;
310    /// A `static` member.
311    pub const STATIC: u32 = 1 << 2;
312    /// An engine / built-in symbol (not user code).
313    pub const DEFAULT_LIBRARY: u32 = 1 << 3;
314}
315
316/// A semantic-highlighting token: a source range classified by its contextual/resolved role. Drives
317/// `textDocument/semanticTokens` — intelligence a grammar can't produce. Modifiers are a bitset of
318/// [`semantic_token_modifier`] flags.
319#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
320pub struct SemanticToken {
321    /// The token's byte range.
322    pub range: TextRange,
323    /// What the token denotes.
324    pub token_type: SemanticTokenType,
325    /// A bitset of [`semantic_token_modifier`] flags.
326    pub modifiers: u32,
327}
328
329/// A single text edit: replace `range` with `new_text`.
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
331pub struct TextEdit {
332    /// The byte range to replace.
333    pub range: TextRange,
334    /// The replacement text.
335    pub new_text: String,
336}
337
338/// The edits to apply to one file (non-overlapping; the client sorts and applies them).
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
340pub struct FileEdit {
341    /// The file the edits apply to.
342    pub file: FileId,
343    /// The edits within that file.
344    pub edits: Vec<TextEdit>,
345}
346
347/// A set of edits across one or more files (a cross-file rename, a quick-fix). Phase 3's rename
348/// spans files, so this is multi-file; a single-file change is just one [`FileEdit`].
349#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
350pub struct SourceChange {
351    /// The per-file edits.
352    pub edits: Vec<FileEdit>,
353}
354
355impl SourceChange {
356    /// A change that touches a single file.
357    #[must_use]
358    pub fn single(file: FileId, edits: Vec<TextEdit>) -> Self {
359        Self {
360            edits: vec![FileEdit { file, edits }],
361        }
362    }
363}
364
365/// A (file, range) pair — the atom of cross-file navigation results.
366#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
367pub struct FileRange {
368    /// The file the range lives in.
369    pub file: FileId,
370    /// The byte range.
371    pub range: TextRange,
372}
373
374/// Why a token is a reference (rust-analyzer's `ReferenceCategory`, trimmed).
375#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
376#[serde(rename_all = "lowercase")]
377pub enum ReferenceKind {
378    /// The symbol's declaration site.
379    Declaration,
380    /// A read (the default — any non-write use).
381    Read,
382    /// A write (the symbol on the left of an assignment).
383    Write,
384}
385
386/// One reference to a symbol (find-references result), including its declaration.
387#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
388pub struct Reference {
389    /// The file the reference is in.
390    pub file: FileId,
391    /// The identifier-token range.
392    pub range: TextRange,
393    /// What kind of reference it is.
394    pub kind: ReferenceKind,
395}
396
397/// Why a [rename](crate) was refused — the "correct or it refuses" contract. Never a partial edit.
398#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
399#[serde(tag = "kind", rename_all = "snake_case")]
400pub enum RenameError {
401    /// The new name is not a single valid GDScript identifier (or is a keyword).
402    InvalidIdentifier {
403        /// The rejected name.
404        new_name: String,
405    },
406    /// The target is an engine/builtin symbol, or could not be resolved — not ours to rename.
407    NotRenamable {
408        /// Why.
409        reason: String,
410    },
411    /// The new name already exists in an affected scope.
412    WouldCollide {
413        /// Where the colliding symbol is.
414        at: FileRange,
415        /// The colliding name.
416        with: String,
417    },
418    /// The symbol is also reachable via a surface this analyzer cannot safely rewrite (a `.tscn`
419    /// `[connection]`/string call for a method/signal, the `project.godot` `[autoload]` key). We
420    /// refuse rather than leave a stale reference behind.
421    CrossesUnsupportedBoundary {
422        /// What boundary.
423        what: String,
424    },
425}
426
427/// A code action / quick-fix: a titled, optionally-kinded [`SourceChange`].
428#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
429pub struct CodeAction {
430    /// Human-readable title, e.g. `Add type annotation`.
431    pub title: String,
432    /// An LSP-style kind such as `quickfix` or `refactor.rewrite`; `None` if unspecified.
433    pub kind: Option<String>,
434    /// The edit this action performs.
435    pub edit: SourceChange,
436}
437
438/// A navigation target (goto-definition / -declaration). Phase 2 only ever points within
439/// the same file; cross-file targets arrive in Phase 3.
440#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
441pub struct NavTarget {
442    /// The file the target lives in.
443    pub file: FileId,
444    /// The full range of the target's declaration.
445    pub full_range: TextRange,
446    /// The name / selection range to focus within `full_range`.
447    pub focus_range: TextRange,
448    /// The target symbol's name.
449    pub name: String,
450    /// The target symbol's kind.
451    pub kind: SymbolKind,
452}
453
454/// A read query was cancelled by a concurrent change. (Phase 1 never actually cancels,
455/// but the type is on the API surface so the Phase 3 salsa swap is source-compatible.)
456#[derive(Debug, Clone, Copy, PartialEq, Eq)]
457pub struct Cancelled;
458
459/// The result of a cancellable read query.
460pub type Cancellable<T> = Result<T, Cancelled>;
461
462/// Maps byte offsets to/from `(line, column)` and UTF-16 columns.
463///
464/// Lines and columns are 0-based. The core emits byte offsets; LSP/JS clients convert
465/// to UTF-16 via this (the documented position-encoding footgun —
466/// `plans/01-ARCHITECTURE.md` §4).
467#[derive(Debug, Clone)]
468pub struct LineIndex {
469    /// Byte offset of the start of each line (line 0 starts at 0).
470    line_starts: Vec<u32>,
471    /// Total source length in bytes.
472    len: u32,
473}
474
475/// A 0-based `(line, column)` position. `col` is a byte offset within the line.
476#[derive(Debug, Clone, Copy, PartialEq, Eq)]
477pub struct LineCol {
478    /// 0-based line.
479    pub line: u32,
480    /// 0-based byte column within the line.
481    pub col: u32,
482}
483
484impl LineIndex {
485    /// Build a line index for `text`.
486    #[must_use]
487    pub fn new(text: &str) -> Self {
488        let mut line_starts = vec![0u32];
489        for (i, b) in text.bytes().enumerate() {
490            if b == b'\n' {
491                // `i` fits in u32 for any file we accept (< 4 GiB).
492                #[allow(clippy::cast_possible_truncation)]
493                line_starts.push(i as u32 + 1);
494            }
495        }
496        #[allow(clippy::cast_possible_truncation)]
497        let len = text.len() as u32;
498        Self { line_starts, len }
499    }
500
501    /// The `(line, byte-column)` of a byte offset (clamped to the end of input).
502    #[must_use]
503    pub fn line_col(&self, offset: u32) -> LineCol {
504        let offset = offset.min(self.len);
505        // The line is the last line-start <= offset.
506        let line = match self.line_starts.binary_search(&offset) {
507            Ok(line) => line,
508            Err(next) => next - 1,
509        };
510        #[allow(clippy::cast_possible_truncation)]
511        let line = line as u32;
512        LineCol {
513            line,
514            col: offset - self.line_starts[line as usize],
515        }
516    }
517
518    /// The UTF-16 column of a byte offset on its line (LSP's default encoding).
519    #[must_use]
520    pub fn utf16_col(&self, text: &str, offset: u32) -> u32 {
521        let lc = self.line_col(offset);
522        let line_start = self.line_starts[lc.line as usize] as usize;
523        let col_end = (line_start + lc.col as usize).min(text.len());
524        let units: usize = text[line_start..col_end].chars().map(char::len_utf16).sum();
525        u32::try_from(units).unwrap_or(u32::MAX)
526    }
527
528    /// The number of lines.
529    #[must_use]
530    pub fn line_count(&self) -> u32 {
531        #[allow(clippy::cast_possible_truncation)]
532        {
533            self.line_starts.len() as u32
534        }
535    }
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    #[test]
543    fn line_index_basics() {
544        let src = "ab\ncde\n\nx";
545        let idx = LineIndex::new(src);
546        assert_eq!(idx.line_count(), 4);
547        assert_eq!(idx.line_col(0), LineCol { line: 0, col: 0 });
548        assert_eq!(idx.line_col(1), LineCol { line: 0, col: 1 });
549        assert_eq!(idx.line_col(3), LineCol { line: 1, col: 0 }); // 'c'
550        assert_eq!(idx.line_col(7), LineCol { line: 2, col: 0 }); // blank line
551        assert_eq!(idx.line_col(8), LineCol { line: 3, col: 0 }); // 'x'
552    }
553
554    #[test]
555    fn utf16_columns_account_for_astral_chars() {
556        // "a😀b": 'a' is 1 UTF-8 byte / 1 UTF-16 unit; '😀' is 4 bytes / 2 units.
557        let src = "a😀b";
558        let idx = LineIndex::new(src);
559        assert_eq!(idx.utf16_col(src, 0), 0); // before 'a'
560        assert_eq!(idx.utf16_col(src, 1), 1); // before '😀'
561        assert_eq!(idx.utf16_col(src, 5), 3); // before 'b' (1 + 2 units)
562    }
563}