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