Skip to main content

gdscript_hir/
warnings.rs

1//! The Godot warning catalog + the emit-then-gate seam (Phase-6 Workstream 1).
2//!
3//! Severity is a *resolved* property, not a baked-in one. Inference records a [`RawWarning`]
4//! (a code + range + message, **no severity**); the pure [`gate`] function resolves it against
5//! the project's [`WarningSettings`] and the per-file [`SuppressionMap`] into a final
6//! [`Diagnostic`] (or drops it). Because `gate` runs *downstream* of the cached `analyze_file`
7//! query (in `gdscript-ide`'s `type_diagnostics`), editing a warning level never invalidates
8//! inference — the salsa-cacheability invariant (Playbook §6).
9//!
10//! [`WarningCode`] is the single source of truth for the gateable Godot codes. The public
11//! `Diagnostic.code` stays a stable `String` (via [`WarningCode::as_str`]) so the wire contract
12//! is unchanged — the enum is internal to `gdscript-hir`.
13
14use cstree::util::NodeOrToken;
15use gdscript_base::{Diagnostic, DiagnosticSource, Severity, TextRange};
16use gdscript_syntax::{GdNode, SyntaxKind};
17use rustc_hash::FxHashMap;
18
19/// A gateable Godot GDScript warning code (research/04 §2.2). Internal to `gdscript-hir`; the
20/// public `Diagnostic.code` carries its [`as_str`](WarningCode::as_str) form, so the serialized
21/// identity stays a stable string. Adding a variant is a compile error until every table below
22/// (`as_str`, `default_level`, and `ALL`) covers it.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum WarningCode {
25    // Unassigned / unused.
26    /// A typed local read before it is assigned.
27    UnassignedVariable,
28    /// A compound-assign (`x += …`) on a still-unassigned local.
29    UnassignedVariableOpAssign,
30    /// A local `var` that is never read.
31    UnusedVariable,
32    /// A local `const` that is never read.
33    UnusedLocalConstant,
34    /// A `_`-prefixed class member that is never read in-class.
35    UnusedPrivateClassVariable,
36    /// A parameter that is never read (excluding `_`-prefixed).
37    UnusedParameter,
38    /// A `signal` that is never emitted or connected in-file.
39    UnusedSignal,
40    // Shadowing.
41    /// A local that shadows an outer local / parameter.
42    ShadowedVariable,
43    /// A member that shadows a base-class member.
44    ShadowedVariableBaseClass,
45    /// A `class_name` / member / local that shadows a global identifier.
46    ShadowedGlobalIdentifier,
47    // Control-flow (the two `UNREACHABLE_*` need the W2 CFG).
48    /// Statements after an unconditional `return`/`break`/`continue` / an exhaustive `match`.
49    UnreachableCode,
50    /// A `match` arm after a wildcard/bind arm.
51    UnreachablePattern,
52    /// An expression statement whose value is unused and side-effect-free.
53    StandaloneExpression,
54    /// A ternary used as a statement.
55    StandaloneTernary,
56    /// A ternary whose two arms have incompatible types.
57    IncompatibleTernary,
58    // Type-safety.
59    /// `return f()` where `f` is `Variant` into a `-> void`.
60    UnsafeVoidReturn,
61    /// A static method called through an instance.
62    StaticCalledOnInstance,
63    // Tool / static / await.
64    /// A base `@tool` class without a local `@tool`.
65    MissingTool,
66    /// `@static_unload` on a class with no static variables.
67    RedundantStaticUnload,
68    /// `await` on a non-coroutine / non-signal value.
69    RedundantAwait,
70    // Assertions.
71    /// `assert(true)` / an always-true constant condition.
72    AssertAlwaysTrue,
73    /// `assert(false)` / an always-false constant condition.
74    AssertAlwaysFalse,
75    // Numeric / enum.
76    /// `int / int` (the decimal part is discarded).
77    IntegerDivision,
78    /// A `float` stored into an `int` slot.
79    NarrowingConversion,
80    /// An `int` assigned to an enum without a cast.
81    IntAsEnumWithoutCast,
82    /// An `int` compared to an enum in a `match`.
83    IntAsEnumWithoutMatch,
84    /// `var e: SomeEnum` with no initializer.
85    EnumVariableWithoutDefault,
86    // File / keyword.
87    /// A file with no members.
88    EmptyFile,
89    /// A deprecated keyword (`yield`).
90    DeprecatedKeyword,
91    // Confusables.
92    /// A mixed-script / homoglyph identifier.
93    ConfusableIdentifier,
94    /// A local declared after a same-name outer use.
95    ConfusableLocalDeclaration,
96    /// A use-before-declaration of a local shadowing a member.
97    ConfusableLocalUsage,
98    /// Reassigning a lambda capture.
99    ConfusableCaptureReassignment,
100    /// Modifying a temporary (master-only).
101    ConfusableTemporaryModification,
102    // Deprecated misuse.
103    /// `obj.prop()` where `prop` is a property.
104    PropertyUsedAsFunction,
105    /// `obj.CONST()` where `CONST` is a constant.
106    ConstantUsedAsFunction,
107    /// `obj.method` used as a property.
108    FunctionUsedAsProperty,
109    // Type-strictness (default IGNORE — the opt-in group).
110    /// `var x = …` without a `: T` annotation.
111    UntypedDeclaration,
112    /// A `:=` inferred declaration.
113    InferredDeclaration,
114    /// A property missing on a statically-known base.
115    UnsafePropertyAccess,
116    /// A method missing on a statically-known base.
117    UnsafeMethodAccess,
118    /// An `as T` through a `Variant`.
119    UnsafeCast,
120    /// An argument needing an unsafe implicit cast into the parameter type.
121    UnsafeCallArgument,
122    /// A non-void call result dropped.
123    ReturnValueDiscarded,
124    /// A `await`-able call whose result is not awaited (master-only).
125    MissingAwait,
126    // Hard-fail (default ERROR).
127    /// A `:=` / inferred binding from a statically-`Variant` value.
128    InferenceOnVariant,
129    /// Overriding a native virtual with an incompatible signature.
130    NativeMethodOverride,
131    /// A `get_node(...)` default-value init that should be `@onready`.
132    GetNodeDefaultWithoutOnready,
133    /// `@onready` together with `@export` on one member.
134    OnreadyWithExport,
135}
136
137/// Godot's `WarnLevel` (`gdscript_warning.h`): the resolved severity of a code.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum WarnLevel {
140    /// The code is silenced.
141    Ignore,
142    /// The code is reported as a warning.
143    Warn,
144    /// The code is reported as an error.
145    Error,
146}
147
148impl WarnLevel {
149    /// The level for a `project.godot` `0|1|2` value (Ignore/Warn/Error), or `None` if out of range.
150    #[must_use]
151    pub fn from_int(n: u32) -> Option<Self> {
152        match n {
153            0 => Some(Self::Ignore),
154            1 => Some(Self::Warn),
155            2 => Some(Self::Error),
156            _ => None,
157        }
158    }
159}
160
161/// The lowest Godot minor a code exists in. `Master` means "newer than any stable we bundle as
162/// the default model" — gated against the project's declared engine version.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum Since {
165    /// Present since Godot 4.3 (the earliest we model).
166    V4_3,
167    /// Only on Godot's master / a release newer than the bundled model.
168    Master,
169}
170
171impl Since {
172    /// The `(major, minor)` a code is first available in.
173    #[must_use]
174    pub fn min_version(self) -> (u32, u32) {
175        match self {
176            Self::V4_3 => (4, 3),
177            Self::Master => bundled_version(),
178        }
179    }
180}
181
182impl WarningCode {
183    /// Every code, for reverse lookup ([`from_setting_name`](WarningCode::from_setting_name)) and
184    /// the W5 docgen. Must list every variant.
185    pub const ALL: &'static [WarningCode] = &[
186        Self::UnassignedVariable,
187        Self::UnassignedVariableOpAssign,
188        Self::UnusedVariable,
189        Self::UnusedLocalConstant,
190        Self::UnusedPrivateClassVariable,
191        Self::UnusedParameter,
192        Self::UnusedSignal,
193        Self::ShadowedVariable,
194        Self::ShadowedVariableBaseClass,
195        Self::ShadowedGlobalIdentifier,
196        Self::UnreachableCode,
197        Self::UnreachablePattern,
198        Self::StandaloneExpression,
199        Self::StandaloneTernary,
200        Self::IncompatibleTernary,
201        Self::UnsafeVoidReturn,
202        Self::StaticCalledOnInstance,
203        Self::MissingTool,
204        Self::RedundantStaticUnload,
205        Self::RedundantAwait,
206        Self::AssertAlwaysTrue,
207        Self::AssertAlwaysFalse,
208        Self::IntegerDivision,
209        Self::NarrowingConversion,
210        Self::IntAsEnumWithoutCast,
211        Self::IntAsEnumWithoutMatch,
212        Self::EnumVariableWithoutDefault,
213        Self::EmptyFile,
214        Self::DeprecatedKeyword,
215        Self::ConfusableIdentifier,
216        Self::ConfusableLocalDeclaration,
217        Self::ConfusableLocalUsage,
218        Self::ConfusableCaptureReassignment,
219        Self::ConfusableTemporaryModification,
220        Self::PropertyUsedAsFunction,
221        Self::ConstantUsedAsFunction,
222        Self::FunctionUsedAsProperty,
223        Self::UntypedDeclaration,
224        Self::InferredDeclaration,
225        Self::UnsafePropertyAccess,
226        Self::UnsafeMethodAccess,
227        Self::UnsafeCast,
228        Self::UnsafeCallArgument,
229        Self::ReturnValueDiscarded,
230        Self::MissingAwait,
231        Self::InferenceOnVariant,
232        Self::NativeMethodOverride,
233        Self::GetNodeDefaultWithoutOnready,
234        Self::OnreadyWithExport,
235    ];
236
237    /// The stable serialized identity — what `Diagnostic.code` carries (e.g. `INTEGER_DIVISION`).
238    /// These strings are the frozen consumer-facing identifiers (Workstream 6).
239    #[must_use]
240    pub fn as_str(self) -> &'static str {
241        match self {
242            Self::UnassignedVariable => "UNASSIGNED_VARIABLE",
243            Self::UnassignedVariableOpAssign => "UNASSIGNED_VARIABLE_OP_ASSIGN",
244            Self::UnusedVariable => "UNUSED_VARIABLE",
245            Self::UnusedLocalConstant => "UNUSED_LOCAL_CONSTANT",
246            Self::UnusedPrivateClassVariable => "UNUSED_PRIVATE_CLASS_VARIABLE",
247            Self::UnusedParameter => "UNUSED_PARAMETER",
248            Self::UnusedSignal => "UNUSED_SIGNAL",
249            Self::ShadowedVariable => "SHADOWED_VARIABLE",
250            Self::ShadowedVariableBaseClass => "SHADOWED_VARIABLE_BASE_CLASS",
251            Self::ShadowedGlobalIdentifier => "SHADOWED_GLOBAL_IDENTIFIER",
252            Self::UnreachableCode => "UNREACHABLE_CODE",
253            Self::UnreachablePattern => "UNREACHABLE_PATTERN",
254            Self::StandaloneExpression => "STANDALONE_EXPRESSION",
255            Self::StandaloneTernary => "STANDALONE_TERNARY",
256            Self::IncompatibleTernary => "INCOMPATIBLE_TERNARY",
257            Self::UnsafeVoidReturn => "UNSAFE_VOID_RETURN",
258            Self::StaticCalledOnInstance => "STATIC_CALLED_ON_INSTANCE",
259            Self::MissingTool => "MISSING_TOOL",
260            Self::RedundantStaticUnload => "REDUNDANT_STATIC_UNLOAD",
261            Self::RedundantAwait => "REDUNDANT_AWAIT",
262            Self::AssertAlwaysTrue => "ASSERT_ALWAYS_TRUE",
263            Self::AssertAlwaysFalse => "ASSERT_ALWAYS_FALSE",
264            Self::IntegerDivision => "INTEGER_DIVISION",
265            Self::NarrowingConversion => "NARROWING_CONVERSION",
266            Self::IntAsEnumWithoutCast => "INT_AS_ENUM_WITHOUT_CAST",
267            Self::IntAsEnumWithoutMatch => "INT_AS_ENUM_WITHOUT_MATCH",
268            Self::EnumVariableWithoutDefault => "ENUM_VARIABLE_WITHOUT_DEFAULT",
269            Self::EmptyFile => "EMPTY_FILE",
270            Self::DeprecatedKeyword => "DEPRECATED_KEYWORD",
271            Self::ConfusableIdentifier => "CONFUSABLE_IDENTIFIER",
272            Self::ConfusableLocalDeclaration => "CONFUSABLE_LOCAL_DECLARATION",
273            Self::ConfusableLocalUsage => "CONFUSABLE_LOCAL_USAGE",
274            Self::ConfusableCaptureReassignment => "CONFUSABLE_CAPTURE_REASSIGNMENT",
275            Self::ConfusableTemporaryModification => "CONFUSABLE_TEMPORARY_MODIFICATION",
276            Self::PropertyUsedAsFunction => "PROPERTY_USED_AS_FUNCTION",
277            Self::ConstantUsedAsFunction => "CONSTANT_USED_AS_FUNCTION",
278            Self::FunctionUsedAsProperty => "FUNCTION_USED_AS_PROPERTY",
279            Self::UntypedDeclaration => "UNTYPED_DECLARATION",
280            Self::InferredDeclaration => "INFERRED_DECLARATION",
281            Self::UnsafePropertyAccess => "UNSAFE_PROPERTY_ACCESS",
282            Self::UnsafeMethodAccess => "UNSAFE_METHOD_ACCESS",
283            Self::UnsafeCast => "UNSAFE_CAST",
284            Self::UnsafeCallArgument => "UNSAFE_CALL_ARGUMENT",
285            Self::ReturnValueDiscarded => "RETURN_VALUE_DISCARDED",
286            Self::MissingAwait => "MISSING_AWAIT",
287            Self::InferenceOnVariant => "INFERENCE_ON_VARIANT",
288            Self::NativeMethodOverride => "NATIVE_METHOD_OVERRIDE",
289            Self::GetNodeDefaultWithoutOnready => "GET_NODE_DEFAULT_WITHOUT_ONREADY",
290            Self::OnreadyWithExport => "ONREADY_WITH_EXPORT",
291        }
292    }
293
294    /// The `project.godot` `debug/gdscript/warnings/<tail>` key tail — the lowercased [`as_str`].
295    #[must_use]
296    pub fn setting_name(self) -> String {
297        self.as_str().to_ascii_lowercase()
298    }
299
300    /// A one-line human description — the source of truth for the generated Warning Reference
301    /// (Workstream 5). Kept terse and stable; an exhaustive `match` so a new code must add one.
302    #[must_use]
303    pub fn description(self) -> &'static str {
304        match self {
305            Self::UnassignedVariable => "A typed local is read before it is assigned a value.",
306            Self::UnassignedVariableOpAssign => {
307                "A compound assignment (`+=`, …) is applied to a still-unassigned local."
308            }
309            Self::UnusedVariable => "A local variable is declared but never read.",
310            Self::UnusedLocalConstant => "A local constant is declared but never read.",
311            Self::UnusedPrivateClassVariable => {
312                "A `_`-prefixed class member is never read within the class."
313            }
314            Self::UnusedParameter => "A function parameter is never used (prefix it with `_`).",
315            Self::UnusedSignal => "A signal is never emitted or connected in the file.",
316            Self::ShadowedVariable => "A local shadows an outer local or parameter.",
317            Self::ShadowedVariableBaseClass => "A member shadows a member of a base class.",
318            Self::ShadowedGlobalIdentifier => {
319                "A `class_name`, member, or local shadows a global identifier."
320            }
321            Self::UnreachableCode => {
322                "A statement follows an unconditional `return`/`break`/`continue` (or an exhaustive `match`)."
323            }
324            Self::UnreachablePattern => {
325                "A `match` pattern can never match (it follows a wildcard)."
326            }
327            Self::StandaloneExpression => "An expression statement has no effect.",
328            Self::StandaloneTernary => {
329                "A ternary conditional is used as a statement; its value is discarded."
330            }
331            Self::IncompatibleTernary => {
332                "The two values of a ternary conditional have no common type."
333            }
334            Self::UnsafeVoidReturn => "A `Variant` value is returned from a `-> void` function.",
335            Self::StaticCalledOnInstance => "A static method is called through an instance.",
336            Self::MissingTool => "A class extends a `@tool` class but is not itself `@tool`.",
337            Self::RedundantStaticUnload => {
338                "`@static_unload` is used on a class with no static variables."
339            }
340            Self::RedundantAwait => "`await` is applied to a non-coroutine, non-signal value.",
341            Self::AssertAlwaysTrue => "An `assert(...)` condition is always true.",
342            Self::AssertAlwaysFalse => "An `assert(...)` condition is always false.",
343            Self::IntegerDivision => "Integer division discards the fractional part.",
344            Self::NarrowingConversion => "A `float` is stored into an `int`, losing precision.",
345            Self::IntAsEnumWithoutCast => "An integer is assigned to an enum value without a cast.",
346            Self::IntAsEnumWithoutMatch => "An integer is compared to an enum value in a `match`.",
347            Self::EnumVariableWithoutDefault => {
348                "An enum-typed variable has no explicit default value."
349            }
350            Self::EmptyFile => "The script file has no members, `class_name`, or `extends`.",
351            Self::DeprecatedKeyword => "A deprecated keyword (e.g. `yield`) is used.",
352            Self::ConfusableIdentifier => {
353                "An identifier mixes scripts / uses confusable characters."
354            }
355            Self::ConfusableLocalDeclaration => "A local is declared after a same-name outer use.",
356            Self::ConfusableLocalUsage => {
357                "A local shadowing a member is used before its declaration."
358            }
359            Self::ConfusableCaptureReassignment => {
360                "A captured variable is reassigned inside a lambda."
361            }
362            Self::ConfusableTemporaryModification => "A temporary value is modified in place.",
363            Self::PropertyUsedAsFunction => "A property is called as if it were a function.",
364            Self::ConstantUsedAsFunction => "A constant is called as if it were a function.",
365            Self::FunctionUsedAsProperty => "A function is accessed as if it were a property.",
366            Self::UntypedDeclaration => "A declaration has no type annotation.",
367            Self::InferredDeclaration => "A declaration uses an inferred type (`:=`).",
368            Self::UnsafePropertyAccess => {
369                "A property is not present on the inferred type (but may be on a subtype)."
370            }
371            Self::UnsafeMethodAccess => {
372                "A method is not present on the inferred type (but may be on a subtype)."
373            }
374            Self::UnsafeCast => "A value is cast through `Variant`, which is unsafe.",
375            Self::UnsafeCallArgument => {
376                "An argument needs an unsafe implicit cast into the parameter type."
377            }
378            Self::ReturnValueDiscarded => "A non-`void` call's return value is discarded.",
379            Self::MissingAwait => "An awaitable call's result is not awaited.",
380            Self::InferenceOnVariant => "A type is inferred from a statically-`Variant` value.",
381            Self::NativeMethodOverride => {
382                "A native virtual method is overridden with an incompatible signature."
383            }
384            Self::GetNodeDefaultWithoutOnready => {
385                "A `get_node(...)` default initializer should be `@onready`."
386            }
387            Self::OnreadyWithExport => "`@onready` and `@export` are used together on one member.",
388        }
389    }
390
391    /// Godot's `default_warning_levels[]` entry for this code.
392    #[must_use]
393    pub fn default_level(self) -> WarnLevel {
394        match self {
395            // The opt-in "type-strictness" group: IGNORE by default.
396            Self::UntypedDeclaration
397            | Self::InferredDeclaration
398            | Self::UnsafePropertyAccess
399            | Self::UnsafeMethodAccess
400            | Self::UnsafeCast
401            | Self::UnsafeCallArgument
402            | Self::ReturnValueDiscarded
403            | Self::MissingAwait => WarnLevel::Ignore,
404            // The hard-fail group: ERROR by default.
405            Self::InferenceOnVariant
406            | Self::NativeMethodOverride
407            | Self::GetNodeDefaultWithoutOnready
408            | Self::OnreadyWithExport => WarnLevel::Error,
409            // Everything else defaults to WARN.
410            _ => WarnLevel::Warn,
411        }
412    }
413
414    /// Whether this code is in the opt-in type-strictness group (the codes a standalone/`--strict`
415    /// run promotes from IGNORE to WARN). Currently exactly the IGNORE-default set.
416    #[must_use]
417    pub fn is_opt_in(self) -> bool {
418        self.default_level() == WarnLevel::Ignore
419    }
420
421    /// The lowest engine version this code exists in (for version-gating master-only codes).
422    #[must_use]
423    pub fn since(self) -> Since {
424        match self {
425            Self::ConfusableTemporaryModification | Self::MissingAwait => Since::Master,
426            _ => Since::V4_3,
427        }
428    }
429
430    /// The code whose [`setting_name`](WarningCode::setting_name) (case-insensitively) is `name`,
431    /// for parsing `project.godot` keys and `@warning_ignore("name")` arguments.
432    #[must_use]
433    pub fn from_setting_name(name: &str) -> Option<WarningCode> {
434        Self::ALL
435            .iter()
436            .copied()
437            .find(|c| c.as_str().eq_ignore_ascii_case(name))
438    }
439}
440
441/// An emitted-but-ungraded warning: the inference layer records these (no severity); [`gate`]
442/// resolves each into a final [`Diagnostic`] or drops it.
443#[derive(Debug, Clone, PartialEq, Eq)]
444pub struct RawWarning {
445    /// The byte range the warning applies to.
446    pub range: TextRange,
447    /// The code (the source of truth for severity + identity).
448    pub code: WarningCode,
449    /// The human-readable message.
450    pub message: String,
451}
452
453/// The resolved warning configuration for a project (or the standalone analyzer default). Parsed
454/// from `project.godot`'s `debug/gdscript/warnings/*`; passed to [`gate`].
455// A settings/config struct — each bool is an independent Godot project setting, so the
456// state-machine refactor `struct_excessive_bools` suggests would only obscure it.
457#[allow(clippy::struct_excessive_bools)]
458#[derive(Debug, Clone, PartialEq, Eq)]
459pub struct WarningSettings {
460    /// `debug/gdscript/warnings/enable` — the master switch (default `true`).
461    pub enabled: bool,
462    /// `debug/gdscript/warnings/treat_warnings_as_errors` — escalate every WARN to ERROR.
463    pub treat_as_errors: bool,
464    /// Explicit per-code level overrides from `project.godot`.
465    pub per_code: FxHashMap<WarningCode, WarnLevel>,
466    /// `debug/gdscript/warnings/exclude_addons` — suppress warnings under `res://addons/**`.
467    pub exclude_addons: bool,
468    /// The project's declared engine `(major, minor)`, for version-gating master-only codes.
469    pub engine: (u32, u32),
470    /// When `true` (a standalone run / CLI `--strict`), the IGNORE-default opt-in group is
471    /// promoted to WARN. A real `project.godot` clears this (its explicit settings win).
472    pub strict_opt_in: bool,
473}
474
475impl WarningSettings {
476    /// The standalone default (no `project.godot`): everything on, the opt-in strictness group
477    /// promoted to WARN, addons not excluded. Matches the analyzer's pre-gating behavior.
478    #[must_use]
479    pub fn analyzer_default() -> Self {
480        Self {
481            enabled: true,
482            treat_as_errors: false,
483            per_code: FxHashMap::default(),
484            exclude_addons: false,
485            engine: bundled_version(),
486            strict_opt_in: true,
487        }
488    }
489
490    /// The engine-matching default for a project of declared version `engine`: Godot's own
491    /// `default_warning_levels[]` (the opt-in group stays IGNORE), addons excluded.
492    #[must_use]
493    pub fn engine_default(engine: (u32, u32)) -> Self {
494        Self {
495            enabled: true,
496            treat_as_errors: false,
497            per_code: FxHashMap::default(),
498            exclude_addons: true,
499            engine,
500            strict_opt_in: false,
501        }
502    }
503}
504
505/// The `@warning_ignore[_start|_restore]` suppression spans for one file. A warning is suppressed
506/// when its range falls inside a span listing its code. (M0 ships the empty map; the CST walk that
507/// populates it lands in W1 M2.)
508#[derive(Debug, Clone, Default, PartialEq, Eq)]
509pub struct SuppressionMap {
510    spans: Vec<(TextRange, Vec<WarningCode>)>,
511}
512
513impl SuppressionMap {
514    /// Whether `code` at `at` is suppressed by some span.
515    #[must_use]
516    pub fn is_suppressed(&self, code: WarningCode, at: TextRange) -> bool {
517        self.spans.iter().any(|(span, codes)| {
518            span.start <= at.start && at.end <= span.end && codes.contains(&code)
519        })
520    }
521
522    /// Add a suppression span over `range` for `codes` (used by the W1 M2 CST builder + tests).
523    pub fn push(&mut self, range: TextRange, codes: Vec<WarningCode>) {
524        self.spans.push((range, codes));
525    }
526}
527
528/// Build the per-file suppression map from the parsed CST (Workstream 1 M2): each
529/// `@warning_ignore("code", …)` suppresses the listed codes over the **single following
530/// statement/declaration**, and a `@warning_ignore_start("code")` … `@warning_ignore_restore("code")`
531/// pair suppresses a region (EOF-terminated if unrestored). Unknown code names are skipped (the
532/// unknown-name meta-diagnostic is deferred — see `TECH_DEBT.md`).
533#[must_use]
534pub fn build_suppression_map(root: &GdNode, source: &str) -> SuppressionMap {
535    let mut map = SuppressionMap::default();
536    // Annotations in source order.
537    let mut anns: Vec<GdNode> = gdscript_syntax::ast::descendants(root)
538        .into_iter()
539        .filter(|n| n.kind() == SyntaxKind::Annotation)
540        .collect();
541    anns.sort_by_key(|n| u32::from(n.text_range().start()));
542
543    // Open region starts for `_start`/`_restore`, keyed by code. Godot's
544    // `warning_ignore_start_lines` is a map keyed by warning code, so a repeated
545    // `@warning_ignore_start("x")` OVERWRITES the prior start for `x` (it is not a stack) — a
546    // single `@warning_ignore_restore("x")` ends the region at that latest start, and any earlier
547    // start does not leak past it.
548    let mut open: FxHashMap<WarningCode, u32> = FxHashMap::default();
549    let eof = u32::from(root.text_range().end());
550
551    for ann in &anns {
552        let Some(name) = annotation_name(ann) else {
553            continue;
554        };
555        let codes = annotation_warning_codes(ann);
556        if codes.is_empty() {
557            continue; // not a `@warning_ignore*` with a recognized code
558        }
559        match name.as_str() {
560            "warning_ignore" => {
561                if let Some(target) = next_decorated_sibling(ann) {
562                    let r = target.text_range();
563                    let start = u32::from(r.start());
564                    // Cover the whole physical line of the decorated statement — Godot tracks
565                    // `@warning_ignore` by line, so `;`-joined statements sharing that line are all
566                    // suppressed. Scan from the statement's END (its range START may include the
567                    // preceding newline as leading trivia); the next `\n` at-or-after the code ends
568                    // the line and always covers the full statement (incl. a multi-line one).
569                    let end = line_end_from(source, u32::from(r.end()));
570                    map.push(TextRange::new(start, end), codes);
571                }
572            }
573            "warning_ignore_start" => {
574                let start = u32::from(ann.text_range().end());
575                for c in codes {
576                    open.insert(c, start); // overwrite any prior open start for this code
577                }
578            }
579            "warning_ignore_restore" => {
580                let end = u32::from(ann.text_range().start());
581                for c in &codes {
582                    if let Some(start) = open.remove(c) {
583                        map.push(TextRange::new(start, end), vec![*c]);
584                    }
585                }
586            }
587            _ => {}
588        }
589    }
590    // Unrestored regions run to end of file. Sort for a deterministic span order (the map feeds a
591    // salsa query whose value is compared by equality).
592    let mut leftover: Vec<(WarningCode, u32)> = open.into_iter().collect();
593    leftover.sort_by_key(|&(_, start)| start);
594    for (c, start) in leftover {
595        map.push(TextRange::new(start, eof), vec![c]);
596    }
597    map
598}
599
600/// The annotation's name token (the identifier after `@`).
601fn annotation_name(ann: &GdNode) -> Option<String> {
602    ann.children_with_tokens()
603        .filter_map(NodeOrToken::into_token)
604        .find(|t| t.kind() == SyntaxKind::Ident)
605        .map(|t| t.text().to_owned())
606}
607
608/// The recognized warning codes named by a `@warning_ignore*` annotation's string arguments.
609fn annotation_warning_codes(ann: &GdNode) -> Vec<WarningCode> {
610    let Some(arglist) = ann.children().find(|c| c.kind() == SyntaxKind::ArgList) else {
611        return Vec::new();
612    };
613    let mut codes = Vec::new();
614    for lit in arglist
615        .children()
616        .filter(|c| c.kind() == SyntaxKind::Literal)
617    {
618        for tok in lit
619            .children_with_tokens()
620            .filter_map(NodeOrToken::into_token)
621        {
622            if tok.kind() == SyntaxKind::String
623                && let Some(c) =
624                    WarningCode::from_setting_name(tok.text().trim_matches(['"', '\'']))
625            {
626                codes.push(c);
627            }
628        }
629    }
630    codes
631}
632
633/// The byte offset of the end of the physical line containing `start` (the next `\n`, or EOF). Used
634/// to widen a one-shot `@warning_ignore` to cover every `;`-joined statement on the decorated line.
635fn line_end_from(source: &str, start: u32) -> u32 {
636    let s = start as usize;
637    match source.get(s..).and_then(|rest| rest.find('\n')) {
638        Some(i) => u32::try_from(s + i).unwrap_or(u32::MAX),
639        None => u32::try_from(source.len()).unwrap_or(u32::MAX),
640    }
641}
642
643/// The single statement/declaration a `@warning_ignore` decorates — the next sibling node that is
644/// not itself an annotation (annotations stack: `@onready @warning_ignore("…") var x`).
645fn next_decorated_sibling(ann: &GdNode) -> Option<GdNode> {
646    let parent = ann.parent()?;
647    let after = ann.text_range().start();
648    parent
649        .children()
650        .filter(|c| c.text_range().start() > after && c.kind() != SyntaxKind::Annotation)
651        .min_by_key(|c| u32::from(c.text_range().start()))
652        .cloned()
653}
654
655/// Resolve one [`RawWarning`] into a final [`Diagnostic`], or drop it. The **only** place
656/// settings/version/suppression touch a warning — pure, so it is trivially cacheable and testable.
657/// Precedence (research/04 §2.3): enable → per-code level → treat-as-errors → scope → suppression.
658#[must_use]
659pub fn gate(
660    raw: &RawWarning,
661    settings: &WarningSettings,
662    ignores: &SuppressionMap,
663    path: Option<&str>,
664) -> Option<Diagnostic> {
665    if !settings.enabled {
666        return None;
667    }
668    // Version-gate: a code the project's engine predates never fires.
669    if raw.code.since().min_version() > settings.engine {
670        return None;
671    }
672    // Base level: an explicit override wins; else the engine default, with the opt-in group
673    // promoted to WARN under `strict_opt_in`.
674    let mut level = settings
675        .per_code
676        .get(&raw.code)
677        .copied()
678        .unwrap_or_else(|| {
679            let d = raw.code.default_level();
680            if settings.strict_opt_in && d == WarnLevel::Ignore {
681                WarnLevel::Warn
682            } else {
683                d
684            }
685        });
686    if level == WarnLevel::Ignore {
687        return None;
688    }
689    if settings.treat_as_errors && level == WarnLevel::Warn {
690        level = WarnLevel::Error;
691    }
692    if settings.exclude_addons && path.is_some_and(is_addon_path) {
693        return None;
694    }
695    if ignores.is_suppressed(raw.code, raw.range) {
696        return None;
697    }
698    Some(Diagnostic {
699        range: raw.range,
700        severity: match level {
701            WarnLevel::Error => Severity::Error,
702            // `Ignore` was returned above; only `Warn` reaches here besides `Error`.
703            _ => Severity::Warning,
704        },
705        code: raw.code.as_str().to_owned(),
706        message: raw.message.clone(),
707        source: DiagnosticSource::Type,
708        fixes: Vec::new(),
709    })
710}
711
712/// Render the Markdown **Warning Reference** page from the [`WarningCode`] catalog (Workstream 5
713/// docgen). The single source of truth — a test asserts the committed page matches this output, so
714/// the docs can never drift from the code (regenerate with `GDSCRIPT_UPDATE_DOCS=1`).
715#[must_use]
716pub fn render_warning_reference() -> String {
717    use std::fmt::Write as _;
718    let mut codes: Vec<WarningCode> = WarningCode::ALL.to_vec();
719    codes.sort_by_key(|c| c.as_str());
720
721    let mut s = String::new();
722    s.push_str("<!-- @generated by `gdscript-hir` (warnings::render_warning_reference); do not edit by hand. -->\n");
723    s.push_str("<!-- Regenerate: `GDSCRIPT_UPDATE_DOCS=1 cargo test -p gdscript-hir warning_reference_doc_is_current` -->\n\n");
724    s.push_str("# Warning Reference\n\n");
725    s.push_str(
726        "Every gateable GDScript warning the analyzer can emit, with its `project.godot` setting key, \
727         engine-default level, and the earliest Godot version it applies to. Configure these under \
728         `[debug]` as `gdscript/warnings/<key>` (`0` = ignore, `1` = warn, `2` = error), or suppress \
729         inline with `@warning_ignore(\"<key>\")`. See [Configuration](./configuration.md).\n\n",
730    );
731    s.push_str("| Code | Setting key | Default | Since | Description |\n");
732    s.push_str("|---|---|---|---|---|\n");
733    for c in codes {
734        let default = match c.default_level() {
735            WarnLevel::Ignore => "Ignore",
736            WarnLevel::Warn => "Warn",
737            WarnLevel::Error => "Error",
738        };
739        let since = match c.since() {
740            Since::V4_3 => "4.3",
741            Since::Master => "master",
742        };
743        let _ = writeln!(
744            s,
745            "| `{}` | `{}` | {default} | {since} | {} |",
746            c.as_str(),
747            c.setting_name(),
748            c.description(),
749        );
750    }
751    s
752}
753
754/// Whether `path` is under the project-root `res://addons/**` directory (the `exclude_addons`
755/// scope). Matches Godot exactly — `script_path.begins_with("res://addons/")` — so a *nested*
756/// user directory named `addons` (e.g. `res://game/addons/x.gd`) is **not** excluded (an earlier
757/// `contains("/addons/")` over-match silently dropped genuine warnings there).
758fn is_addon_path(path: &str) -> bool {
759    path.starts_with("res://addons/")
760}
761
762/// The bundled engine `(major, minor)` — the default project version and the `Since::Master`
763/// threshold. Parsed from [`gdscript_api::godot_version`] (so it tracks the bundled model, not a
764/// hardcoded literal).
765#[must_use]
766pub fn bundled_version() -> (u32, u32) {
767    parse_major_minor(gdscript_api::godot_version()).unwrap_or((4, 5))
768}
769
770/// Parse a leading `<major>.<minor>` (ignoring any `.patch`/`-suffix`) from `s`.
771fn parse_major_minor(s: &str) -> Option<(u32, u32)> {
772    let mut parts = s.split('.');
773    let major = parts.next()?.parse().ok()?;
774    let minor: u32 = parts
775        .next()?
776        .chars()
777        .take_while(char::is_ascii_digit)
778        .collect::<String>()
779        .parse()
780        .ok()?;
781    Some((major, minor))
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787    use gdscript_syntax::parse;
788    use std::collections::HashSet;
789
790    fn off(src: &str, needle: &str) -> u32 {
791        u32::try_from(src.find(needle).unwrap()).unwrap()
792    }
793
794    #[test]
795    fn warning_reference_doc_is_current() {
796        // The committed Warning Reference is generated from the catalog — keep them in lockstep.
797        let path = concat!(
798            env!("CARGO_MANIFEST_DIR"),
799            "/../../docs/src/reference/warnings.md"
800        );
801        let generated = render_warning_reference();
802        if std::env::var("GDSCRIPT_UPDATE_DOCS").is_ok() {
803            if let Some(parent) = std::path::Path::new(path).parent() {
804                std::fs::create_dir_all(parent).unwrap();
805            }
806            std::fs::write(path, &generated).unwrap();
807            return;
808        }
809        let on_disk = std::fs::read_to_string(path).unwrap_or_default();
810        assert_eq!(
811            on_disk, generated,
812            "docs/src/reference/warnings.md is stale — regenerate with \
813             `GDSCRIPT_UPDATE_DOCS=1 cargo test -p gdscript-hir warning_reference_doc_is_current`",
814        );
815    }
816
817    #[test]
818    fn warning_ignore_suppresses_the_next_statement() {
819        let src = "func f():\n\t@warning_ignore(\"integer_division\")\n\tvar x = 5 / 2\n";
820        let map = build_suppression_map(&parse(src).syntax_node(), src);
821        let at = off(src, "5 / 2");
822        assert!(map.is_suppressed(WarningCode::IntegerDivision, TextRange::new(at, at + 5)));
823        // A different code at the same place is not suppressed.
824        assert!(!map.is_suppressed(WarningCode::NarrowingConversion, TextRange::new(at, at + 5)));
825    }
826
827    #[test]
828    fn warning_ignore_covers_semicolon_joined_statements_on_the_line() {
829        // Godot tracks `@warning_ignore` by line, so a one-shot ignore must cover BOTH `;`-joined
830        // statements on the decorated line — not just the first.
831        let src = "func f():\n\t@warning_ignore(\"unused_variable\")\n\tvar a = 1; var b = 2\n\tvar c = 3\n";
832        let map = build_suppression_map(&parse(src).syntax_node(), src);
833        let a = off(src, "var a");
834        let b = off(src, "var b");
835        let c = off(src, "var c");
836        assert!(map.is_suppressed(WarningCode::UnusedVariable, TextRange::new(a, a + 1)));
837        assert!(
838            map.is_suppressed(WarningCode::UnusedVariable, TextRange::new(b, b + 1)),
839            "the second `;`-joined statement on the line must be covered"
840        );
841        // The next line is NOT covered (the ignore is one line only).
842        assert!(!map.is_suppressed(WarningCode::UnusedVariable, TextRange::new(c, c + 1)));
843    }
844
845    #[test]
846    fn warning_ignore_start_restore_suppresses_a_region() {
847        let src = "@warning_ignore_start(\"unused_variable\")\nfunc f():\n\tvar a = 1\n@warning_ignore_restore(\"unused_variable\")\nfunc g():\n\tvar b = 2\n";
848        let map = build_suppression_map(&parse(src).syntax_node(), src);
849        let a = off(src, "var a");
850        let b = off(src, "var b");
851        assert!(map.is_suppressed(WarningCode::UnusedVariable, TextRange::new(a, a + 1)));
852        // After the restore, the same code is no longer suppressed.
853        assert!(!map.is_suppressed(WarningCode::UnusedVariable, TextRange::new(b, b + 1)));
854    }
855
856    #[test]
857    fn repeated_start_for_one_code_overwrites_and_does_not_leak_past_restore() {
858        // Godot keys `warning_ignore_start` by code, so a 2nd start OVERWRITES the 1st. Only the
859        // region [latest_start .. restore] is suppressed; code BEFORE the 2nd start and AFTER the
860        // restore is still checked. The old Vec-stack leaked start#1 to EOF, over-suppressing both.
861        let src = "@warning_ignore_start(\"unused_variable\")\nvar before = 1\n@warning_ignore_start(\"unused_variable\")\nvar inside = 2\n@warning_ignore_restore(\"unused_variable\")\nvar after = 3\n";
862        let map = build_suppression_map(&parse(src).syntax_node(), src);
863        let before = off(src, "before");
864        let inside = off(src, "inside");
865        let after = off(src, "after");
866        assert!(
867            map.is_suppressed(
868                WarningCode::UnusedVariable,
869                TextRange::new(inside, inside + 1)
870            ),
871            "the active [start2 .. restore] region must be suppressed"
872        );
873        assert!(
874            !map.is_suppressed(
875                WarningCode::UnusedVariable,
876                TextRange::new(after, after + 1)
877            ),
878            "code after the restore must NOT be suppressed (no leak to EOF)"
879        );
880        assert!(
881            !map.is_suppressed(
882                WarningCode::UnusedVariable,
883                TextRange::new(before, before + 1)
884            ),
885            "code before the overwriting start must NOT be suppressed"
886        );
887    }
888
889    #[test]
890    fn exclude_addons_only_matches_the_root_addons_dir() {
891        let none = SuppressionMap::default();
892        let mut s = WarningSettings::engine_default((4, 5));
893        s.per_code
894            .insert(WarningCode::IntegerDivision, WarnLevel::Warn);
895        // A *nested* dir merely named `addons` is NOT an addon path (Godot: begins_with res://addons/).
896        assert!(
897            gate(
898                &raw(WarningCode::IntegerDivision),
899                &s,
900                &none,
901                Some("res://game/addons/spawner.gd")
902            )
903            .is_some(),
904            "a nested addons/ dir must still be checked"
905        );
906        // The real root addons dir is excluded.
907        assert!(
908            gate(
909                &raw(WarningCode::IntegerDivision),
910                &s,
911                &none,
912                Some("res://addons/plugin/x.gd")
913            )
914            .is_none()
915        );
916    }
917
918    fn raw(code: WarningCode) -> RawWarning {
919        RawWarning {
920            range: TextRange::new(10, 20),
921            code,
922            message: "msg".to_owned(),
923        }
924    }
925
926    #[test]
927    fn every_code_has_a_unique_uppercase_string_that_round_trips() {
928        let mut seen = HashSet::new();
929        for &c in WarningCode::ALL {
930            assert!(seen.insert(c.as_str()), "duplicate as_str: {}", c.as_str());
931            assert_eq!(c.as_str(), c.as_str().to_ascii_uppercase());
932            assert_eq!(WarningCode::from_setting_name(&c.setting_name()), Some(c));
933        }
934        // The set is the catalog; a missed `ALL` entry shows up as a short count.
935        assert_eq!(seen.len(), 49);
936    }
937
938    #[test]
939    fn disabled_drops_everything() {
940        let mut s = WarningSettings::analyzer_default();
941        s.enabled = false;
942        assert!(
943            gate(
944                &raw(WarningCode::IntegerDivision),
945                &s,
946                &SuppressionMap::default(),
947                None
948            )
949            .is_none()
950        );
951    }
952
953    #[test]
954    fn opt_in_group_is_silent_under_engine_default_but_warns_under_strict() {
955        let none = SuppressionMap::default();
956        let engine = WarningSettings::engine_default((4, 5));
957        assert!(gate(&raw(WarningCode::UnsafeMethodAccess), &engine, &none, None).is_none());
958        let strict = WarningSettings::analyzer_default(); // strict_opt_in = true
959        let d = gate(&raw(WarningCode::UnsafeMethodAccess), &strict, &none, None).unwrap();
960        assert_eq!(d.severity, Severity::Warning);
961        assert_eq!(d.code, "UNSAFE_METHOD_ACCESS");
962    }
963
964    #[test]
965    fn error_default_stays_error() {
966        let d = gate(
967            &raw(WarningCode::InferenceOnVariant),
968            &WarningSettings::analyzer_default(),
969            &SuppressionMap::default(),
970            None,
971        )
972        .unwrap();
973        assert_eq!(d.severity, Severity::Error);
974    }
975
976    #[test]
977    fn treat_as_errors_escalates_warn_only() {
978        let none = SuppressionMap::default();
979        let mut s = WarningSettings::analyzer_default();
980        s.treat_as_errors = true;
981        // A WARN-default code escalates to ERROR.
982        let d = gate(&raw(WarningCode::IntegerDivision), &s, &none, None).unwrap();
983        assert_eq!(d.severity, Severity::Error);
984        // An explicitly-Ignored code is never resurrected by treat-as-errors.
985        s.per_code
986            .insert(WarningCode::IntegerDivision, WarnLevel::Ignore);
987        assert!(gate(&raw(WarningCode::IntegerDivision), &s, &none, None).is_none());
988    }
989
990    #[test]
991    fn per_code_override_sets_level() {
992        let none = SuppressionMap::default();
993        let mut s = WarningSettings::engine_default((4, 5));
994        s.per_code
995            .insert(WarningCode::UnsafeMethodAccess, WarnLevel::Error);
996        let d = gate(&raw(WarningCode::UnsafeMethodAccess), &s, &none, None).unwrap();
997        assert_eq!(d.severity, Severity::Error);
998    }
999
1000    #[test]
1001    fn exclude_addons_suppresses_by_path() {
1002        let mut s = WarningSettings::analyzer_default();
1003        s.exclude_addons = true;
1004        assert!(
1005            gate(
1006                &raw(WarningCode::IntegerDivision),
1007                &s,
1008                &SuppressionMap::default(),
1009                Some("res://addons/x/y.gd")
1010            )
1011            .is_none()
1012        );
1013        assert!(
1014            gate(
1015                &raw(WarningCode::IntegerDivision),
1016                &s,
1017                &SuppressionMap::default(),
1018                Some("res://game/y.gd")
1019            )
1020            .is_some()
1021        );
1022    }
1023
1024    #[test]
1025    fn suppression_map_drops_covered_range() {
1026        let mut map = SuppressionMap::default();
1027        map.push(TextRange::new(0, 100), vec![WarningCode::IntegerDivision]);
1028        assert!(
1029            gate(
1030                &raw(WarningCode::IntegerDivision),
1031                &WarningSettings::analyzer_default(),
1032                &map,
1033                None
1034            )
1035            .is_none()
1036        );
1037        // A different code in the same span is unaffected.
1038        assert!(
1039            gate(
1040                &raw(WarningCode::NarrowingConversion),
1041                &WarningSettings::analyzer_default(),
1042                &map,
1043                None
1044            )
1045            .is_some()
1046        );
1047    }
1048
1049    #[test]
1050    fn master_only_codes_gate_on_engine_version() {
1051        let none = SuppressionMap::default();
1052        // ConfusableTemporaryModification is WARN-default but master-only.
1053        let mut old = WarningSettings::engine_default((4, 3));
1054        old.strict_opt_in = false;
1055        assert!(
1056            gate(
1057                &raw(WarningCode::ConfusableTemporaryModification),
1058                &old,
1059                &none,
1060                None
1061            )
1062            .is_none()
1063        );
1064        let new = WarningSettings::engine_default((4, 5));
1065        assert!(
1066            gate(
1067                &raw(WarningCode::ConfusableTemporaryModification),
1068                &new,
1069                &none,
1070                None
1071            )
1072            .is_some()
1073        );
1074    }
1075}