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}