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, DiagnosticTag, 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    // Undefined symbols (analyzer-specific codes; compile ERRORS in Godot itself, so they default
136    // to ERROR here). Only emitted when the loader declared the workspace COMPLETE
137    // (`SourceRoot::complete`) — proving a name is defined nowhere requires seeing everywhere.
138    /// A bare-name call that resolves to nothing anywhere (a typo like `usseState(0)`).
139    UndefinedFunction,
140    /// A bare identifier that resolves to nothing anywhere.
141    UndefinedIdentifier,
142    /// A method call on a BUILT-IN receiver (`Callable`, `String`, `Vector2`, …) whose method does
143    /// not exist. Builtin member tables are closed and bundled, so — unlike `Object` receivers,
144    /// where a script can attach methods at runtime — this needs NO workspace-completeness claim:
145    /// Godot itself reports it as a compile error. 4.7 prints BOTH `Cannot find member "casll" in
146    /// base "Callable".` and `Function "casll()" not found in base Callable.` for one miss (its
147    /// member- and call-checks each fire); the analyzer emits the latter, one diagnostic per site.
148    UndefinedMethod,
149    /// A property/constant access on a BUILT-IN receiver that does not exist (`v.zzz` on a
150    /// `Vector2`). Same closed-table reasoning as [`Self::UndefinedMethod`].
151    UndefinedProperty,
152    /// A call passing fewer arguments than the callee's required (default-less) parameters —
153    /// a compile error in Godot (`Too few arguments for "f()" call. Expected at least N but
154    /// received M.`). Only fires on a statically-resolved own/engine/utility/builtin signature;
155    /// `Callable` values and cross-file seams stay silent.
156    TooFewArguments,
157    /// A call passing more arguments than the callee accepts — a compile error in Godot
158    /// (`Too many arguments for "f()" call. Expected at most N but received M.`). A variadic
159    /// callee (`print`, vararg engine methods) never fires it.
160    TooManyArguments,
161}
162
163/// Godot's `WarnLevel` (`gdscript_warning.h`): the resolved severity of a code.
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum WarnLevel {
166    /// The code is silenced.
167    Ignore,
168    /// The code is reported as a warning.
169    Warn,
170    /// The code is reported as an error.
171    Error,
172}
173
174impl WarnLevel {
175    /// The level for a `project.godot` `0|1|2` value (Ignore/Warn/Error), or `None` if out of range.
176    #[must_use]
177    pub fn from_int(n: u32) -> Option<Self> {
178        match n {
179            0 => Some(Self::Ignore),
180            1 => Some(Self::Warn),
181            2 => Some(Self::Error),
182            _ => None,
183        }
184    }
185}
186
187/// The lowest Godot minor a code exists in. `Master` means "newer than any stable we bundle as
188/// the default model" — gated against the project's declared engine version.
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum Since {
191    /// Present since Godot 4.3 (the earliest we model).
192    V4_3,
193    /// Only on Godot's master / a release newer than the bundled model.
194    Master,
195}
196
197impl Since {
198    /// The `(major, minor)` a code is first available in.
199    #[must_use]
200    pub fn min_version(self) -> (u32, u32) {
201        match self {
202            Self::V4_3 => (4, 3),
203            Self::Master => bundled_version(),
204        }
205    }
206}
207
208impl WarningCode {
209    /// Every code, for reverse lookup ([`from_setting_name`](WarningCode::from_setting_name)) and
210    /// the W5 docgen. Must list every variant.
211    pub const ALL: &'static [WarningCode] = &[
212        Self::UnassignedVariable,
213        Self::UnassignedVariableOpAssign,
214        Self::UnusedVariable,
215        Self::UnusedLocalConstant,
216        Self::UnusedPrivateClassVariable,
217        Self::UnusedParameter,
218        Self::UnusedSignal,
219        Self::ShadowedVariable,
220        Self::ShadowedVariableBaseClass,
221        Self::ShadowedGlobalIdentifier,
222        Self::UnreachableCode,
223        Self::UnreachablePattern,
224        Self::StandaloneExpression,
225        Self::StandaloneTernary,
226        Self::IncompatibleTernary,
227        Self::UnsafeVoidReturn,
228        Self::StaticCalledOnInstance,
229        Self::MissingTool,
230        Self::RedundantStaticUnload,
231        Self::RedundantAwait,
232        Self::AssertAlwaysTrue,
233        Self::AssertAlwaysFalse,
234        Self::IntegerDivision,
235        Self::NarrowingConversion,
236        Self::IntAsEnumWithoutCast,
237        Self::IntAsEnumWithoutMatch,
238        Self::EnumVariableWithoutDefault,
239        Self::EmptyFile,
240        Self::DeprecatedKeyword,
241        Self::ConfusableIdentifier,
242        Self::ConfusableLocalDeclaration,
243        Self::ConfusableLocalUsage,
244        Self::ConfusableCaptureReassignment,
245        Self::ConfusableTemporaryModification,
246        Self::PropertyUsedAsFunction,
247        Self::ConstantUsedAsFunction,
248        Self::FunctionUsedAsProperty,
249        Self::UntypedDeclaration,
250        Self::InferredDeclaration,
251        Self::UnsafePropertyAccess,
252        Self::UnsafeMethodAccess,
253        Self::UnsafeCast,
254        Self::UnsafeCallArgument,
255        Self::ReturnValueDiscarded,
256        Self::MissingAwait,
257        Self::InferenceOnVariant,
258        Self::NativeMethodOverride,
259        Self::GetNodeDefaultWithoutOnready,
260        Self::OnreadyWithExport,
261        Self::UndefinedFunction,
262        Self::UndefinedIdentifier,
263        Self::UndefinedMethod,
264        Self::UndefinedProperty,
265        Self::TooFewArguments,
266        Self::TooManyArguments,
267    ];
268
269    /// The stable serialized identity — what `Diagnostic.code` carries (e.g. `INTEGER_DIVISION`).
270    /// These strings are the frozen consumer-facing identifiers (Workstream 6).
271    #[must_use]
272    pub fn as_str(self) -> &'static str {
273        match self {
274            Self::UnassignedVariable => "UNASSIGNED_VARIABLE",
275            Self::UnassignedVariableOpAssign => "UNASSIGNED_VARIABLE_OP_ASSIGN",
276            Self::UnusedVariable => "UNUSED_VARIABLE",
277            Self::UnusedLocalConstant => "UNUSED_LOCAL_CONSTANT",
278            Self::UnusedPrivateClassVariable => "UNUSED_PRIVATE_CLASS_VARIABLE",
279            Self::UnusedParameter => "UNUSED_PARAMETER",
280            Self::UnusedSignal => "UNUSED_SIGNAL",
281            Self::ShadowedVariable => "SHADOWED_VARIABLE",
282            Self::ShadowedVariableBaseClass => "SHADOWED_VARIABLE_BASE_CLASS",
283            Self::ShadowedGlobalIdentifier => "SHADOWED_GLOBAL_IDENTIFIER",
284            Self::UnreachableCode => "UNREACHABLE_CODE",
285            Self::UnreachablePattern => "UNREACHABLE_PATTERN",
286            Self::StandaloneExpression => "STANDALONE_EXPRESSION",
287            Self::StandaloneTernary => "STANDALONE_TERNARY",
288            Self::IncompatibleTernary => "INCOMPATIBLE_TERNARY",
289            Self::UnsafeVoidReturn => "UNSAFE_VOID_RETURN",
290            Self::StaticCalledOnInstance => "STATIC_CALLED_ON_INSTANCE",
291            Self::MissingTool => "MISSING_TOOL",
292            Self::RedundantStaticUnload => "REDUNDANT_STATIC_UNLOAD",
293            Self::RedundantAwait => "REDUNDANT_AWAIT",
294            Self::AssertAlwaysTrue => "ASSERT_ALWAYS_TRUE",
295            Self::AssertAlwaysFalse => "ASSERT_ALWAYS_FALSE",
296            Self::IntegerDivision => "INTEGER_DIVISION",
297            Self::NarrowingConversion => "NARROWING_CONVERSION",
298            Self::IntAsEnumWithoutCast => "INT_AS_ENUM_WITHOUT_CAST",
299            Self::IntAsEnumWithoutMatch => "INT_AS_ENUM_WITHOUT_MATCH",
300            Self::EnumVariableWithoutDefault => "ENUM_VARIABLE_WITHOUT_DEFAULT",
301            Self::EmptyFile => "EMPTY_FILE",
302            Self::DeprecatedKeyword => "DEPRECATED_KEYWORD",
303            Self::ConfusableIdentifier => "CONFUSABLE_IDENTIFIER",
304            Self::ConfusableLocalDeclaration => "CONFUSABLE_LOCAL_DECLARATION",
305            Self::ConfusableLocalUsage => "CONFUSABLE_LOCAL_USAGE",
306            Self::ConfusableCaptureReassignment => "CONFUSABLE_CAPTURE_REASSIGNMENT",
307            Self::ConfusableTemporaryModification => "CONFUSABLE_TEMPORARY_MODIFICATION",
308            Self::PropertyUsedAsFunction => "PROPERTY_USED_AS_FUNCTION",
309            Self::ConstantUsedAsFunction => "CONSTANT_USED_AS_FUNCTION",
310            Self::FunctionUsedAsProperty => "FUNCTION_USED_AS_PROPERTY",
311            Self::UntypedDeclaration => "UNTYPED_DECLARATION",
312            Self::InferredDeclaration => "INFERRED_DECLARATION",
313            Self::UnsafePropertyAccess => "UNSAFE_PROPERTY_ACCESS",
314            Self::UnsafeMethodAccess => "UNSAFE_METHOD_ACCESS",
315            Self::UnsafeCast => "UNSAFE_CAST",
316            Self::UnsafeCallArgument => "UNSAFE_CALL_ARGUMENT",
317            Self::ReturnValueDiscarded => "RETURN_VALUE_DISCARDED",
318            Self::MissingAwait => "MISSING_AWAIT",
319            Self::InferenceOnVariant => "INFERENCE_ON_VARIANT",
320            Self::NativeMethodOverride => "NATIVE_METHOD_OVERRIDE",
321            Self::GetNodeDefaultWithoutOnready => "GET_NODE_DEFAULT_WITHOUT_ONREADY",
322            Self::OnreadyWithExport => "ONREADY_WITH_EXPORT",
323            Self::UndefinedFunction => "UNDEFINED_FUNCTION",
324            Self::UndefinedIdentifier => "UNDEFINED_IDENTIFIER",
325            Self::UndefinedMethod => "UNDEFINED_METHOD",
326            Self::UndefinedProperty => "UNDEFINED_PROPERTY",
327            Self::TooFewArguments => "TOO_FEW_ARGUMENTS",
328            Self::TooManyArguments => "TOO_MANY_ARGUMENTS",
329        }
330    }
331
332    /// The `project.godot` `debug/gdscript/warnings/<tail>` key tail — the lowercased [`as_str`].
333    #[must_use]
334    pub fn setting_name(self) -> String {
335        self.as_str().to_ascii_lowercase()
336    }
337
338    /// The LSP rendering tags this code's diagnostics carry: `Unnecessary` (editors dim the
339    /// range) for the unused/unreachable family, matching VS Code's built-in convention.
340    /// No catalog code is `Deprecated`-tagged today; the arm exists for when one is.
341    #[must_use]
342    pub fn tags(self) -> &'static [DiagnosticTag] {
343        match self {
344            Self::UnusedVariable
345            | Self::UnusedLocalConstant
346            | Self::UnusedPrivateClassVariable
347            | Self::UnusedParameter
348            | Self::UnusedSignal
349            | Self::UnreachableCode
350            | Self::UnreachablePattern => &[DiagnosticTag::Unnecessary],
351            _ => &[],
352        }
353    }
354
355    /// A one-line human description — the source of truth for the generated Warning Reference
356    /// (Workstream 5). Kept terse and stable; an exhaustive `match` so a new code must add one.
357    #[must_use]
358    #[allow(
359        clippy::too_many_lines,
360        reason = "one arm per catalog code by design — the exhaustive match IS the reference table"
361    )]
362    pub fn description(self) -> &'static str {
363        match self {
364            Self::UnassignedVariable => {
365                "An untyped or enum-typed local is read before it is assigned a value (a typed local is zero-initialized)."
366            }
367            Self::UnassignedVariableOpAssign => {
368                "A compound assignment (`+=`, …) is applied to a still-unassigned local."
369            }
370            Self::UnusedVariable => "A local variable is declared but never read.",
371            Self::UnusedLocalConstant => "A local constant is declared but never read.",
372            Self::UnusedPrivateClassVariable => {
373                "A `_`-prefixed class member is never read within the class."
374            }
375            Self::UnusedParameter => "A function parameter is never used (prefix it with `_`).",
376            Self::UnusedSignal => "A signal is never emitted or connected in the file.",
377            Self::ShadowedVariable => "A local shadows an outer local or parameter.",
378            Self::ShadowedVariableBaseClass => "A member shadows a member of a base class.",
379            Self::ShadowedGlobalIdentifier => {
380                "A `class_name`, member, or local shadows a global identifier."
381            }
382            Self::UnreachableCode => {
383                "A statement follows an unconditional `return`/`break`/`continue` (or an exhaustive `match`)."
384            }
385            Self::UnreachablePattern => {
386                "A `match` pattern can never match (it follows a wildcard)."
387            }
388            Self::StandaloneExpression => "An expression statement has no effect.",
389            Self::StandaloneTernary => {
390                "A ternary conditional is used as a statement; its value is discarded."
391            }
392            Self::IncompatibleTernary => {
393                "The two values of a ternary conditional have no common type."
394            }
395            Self::UnsafeVoidReturn => "A `Variant` value is returned from a `-> void` function.",
396            Self::StaticCalledOnInstance => "A static method is called through an instance.",
397            Self::MissingTool => "A class extends a `@tool` class but is not itself `@tool`.",
398            Self::RedundantStaticUnload => {
399                "`@static_unload` is used on a class with no static variables."
400            }
401            Self::RedundantAwait => "`await` is applied to a non-coroutine, non-signal value.",
402            Self::AssertAlwaysTrue => "An `assert(...)` condition is always true.",
403            Self::AssertAlwaysFalse => "An `assert(...)` condition is always false.",
404            Self::IntegerDivision => "Integer division discards the fractional part.",
405            Self::NarrowingConversion => "A `float` is stored into an `int`, losing precision.",
406            Self::IntAsEnumWithoutCast => "An integer is assigned to an enum value without a cast.",
407            Self::IntAsEnumWithoutMatch => "An integer is compared to an enum value in a `match`.",
408            Self::EnumVariableWithoutDefault => {
409                "An enum-typed variable has no explicit default value."
410            }
411            Self::EmptyFile => "The script file has no members, `class_name`, or `extends`.",
412            Self::DeprecatedKeyword => "A deprecated keyword (e.g. `yield`) is used.",
413            Self::ConfusableIdentifier => {
414                "An identifier mixes scripts / uses confusable characters."
415            }
416            Self::ConfusableLocalDeclaration => "A local is declared after a same-name outer use.",
417            Self::ConfusableLocalUsage => {
418                "A local shadowing a member is used before its declaration."
419            }
420            Self::ConfusableCaptureReassignment => {
421                "A captured variable is reassigned inside a lambda."
422            }
423            Self::ConfusableTemporaryModification => "A temporary value is modified in place.",
424            Self::PropertyUsedAsFunction => "A property is called as if it were a function.",
425            Self::ConstantUsedAsFunction => "A constant is called as if it were a function.",
426            Self::FunctionUsedAsProperty => "A function is accessed as if it were a property.",
427            Self::UntypedDeclaration => "A declaration has no type annotation.",
428            Self::InferredDeclaration => "A declaration uses an inferred type (`:=`).",
429            Self::UnsafePropertyAccess => {
430                "A property is not present on the inferred type (but may be on a subtype)."
431            }
432            Self::UnsafeMethodAccess => {
433                "A method is not present on the inferred type (but may be on a subtype)."
434            }
435            Self::UnsafeCast => "A value is cast through `Variant`, which is unsafe.",
436            Self::UnsafeCallArgument => {
437                "An argument needs an unsafe implicit cast into the parameter type."
438            }
439            Self::ReturnValueDiscarded => "A non-`void` call's return value is discarded.",
440            Self::MissingAwait => "An awaitable call's result is not awaited.",
441            Self::InferenceOnVariant => "A type is inferred from a statically-`Variant` value.",
442            Self::NativeMethodOverride => {
443                "A native virtual method is overridden with an incompatible signature."
444            }
445            Self::GetNodeDefaultWithoutOnready => {
446                "A `get_node(...)` default initializer should be `@onready`."
447            }
448            Self::OnreadyWithExport => "`@onready` and `@export` are used together on one member.",
449            Self::UndefinedFunction => {
450                "A called function is not defined anywhere in the loaded project (a compile error in Godot). \
451                 Analyzer-specific code; fires only when the loader declared the workspace complete."
452            }
453            Self::UndefinedIdentifier => {
454                "An identifier is not declared anywhere in the loaded project (a compile error in Godot). \
455                 Analyzer-specific code; fires only when the loader declared the workspace complete."
456            }
457            Self::UndefinedMethod => {
458                "A method called on a built-in type does not exist on it (a compile error in Godot; the bundled built-in tables are closed, so no completeness claim is needed)."
459            }
460            Self::UndefinedProperty => {
461                "A property accessed on a built-in type does not exist on it (a compile error in Godot; the bundled built-in tables are closed, so no completeness claim is needed)."
462            }
463            Self::TooFewArguments => {
464                "A call passes fewer arguments than the callee's required parameters (a compile error in Godot). Only statically-resolved signatures are checked."
465            }
466            Self::TooManyArguments => {
467                "A call passes more arguments than the callee accepts (a compile error in Godot). Variadic callees are never flagged."
468            }
469        }
470    }
471
472    /// Godot's `default_warning_levels[]` entry for this code.
473    #[must_use]
474    pub fn default_level(self) -> WarnLevel {
475        match self {
476            // The opt-in "type-strictness" group: IGNORE by default.
477            Self::UntypedDeclaration
478            | Self::InferredDeclaration
479            | Self::UnsafePropertyAccess
480            | Self::UnsafeMethodAccess
481            | Self::UnsafeCast
482            | Self::UnsafeCallArgument
483            | Self::ReturnValueDiscarded
484            | Self::MissingAwait => WarnLevel::Ignore,
485            // The hard-fail group: ERROR by default. The `UNDEFINED_*` pair are compile errors in
486            // Godot itself (not warnings), so they land here; being gateable codes still leaves a
487            // per-code project setting / `@warning_ignore` as the escape hatch.
488            Self::InferenceOnVariant
489            | Self::NativeMethodOverride
490            | Self::GetNodeDefaultWithoutOnready
491            | Self::OnreadyWithExport
492            | Self::UndefinedFunction
493            | Self::UndefinedIdentifier
494            | Self::UndefinedMethod
495            | Self::UndefinedProperty
496            | Self::TooFewArguments
497            | Self::TooManyArguments => WarnLevel::Error,
498            // Everything else defaults to WARN.
499            _ => WarnLevel::Warn,
500        }
501    }
502
503    /// Whether this code is in the opt-in type-strictness group (the IGNORE-default set).
504    #[must_use]
505    pub fn is_opt_in(self) -> bool {
506        self.default_level() == WarnLevel::Ignore
507    }
508
509    /// Whether a strict / standalone run (`strict_opt_in`) auto-promotes this IGNORE-default code to
510    /// WARN. True for the `UNSAFE_*` safety group; **false** for the stylistic `UNTYPED_DECLARATION`
511    /// / `INFERRED_DECLARATION` — those fire on essentially *every* untyped / inferred declaration, so
512    /// they require an explicit per-code project setting to enable (never the blanket strict umbrella),
513    /// matching Godot's "most users never enable them" reality. Keeps the standalone default clean.
514    #[must_use]
515    pub fn promoted_by_strict(self) -> bool {
516        self.is_opt_in() && !matches!(self, Self::UntypedDeclaration | Self::InferredDeclaration)
517    }
518
519    /// The lowest engine version this code exists in (for version-gating master-only codes).
520    #[must_use]
521    pub fn since(self) -> Since {
522        match self {
523            Self::ConfusableTemporaryModification | Self::MissingAwait => Since::Master,
524            _ => Since::V4_3,
525        }
526    }
527
528    /// The code whose [`setting_name`](WarningCode::setting_name) (case-insensitively) is `name`,
529    /// for parsing `project.godot` keys and `@warning_ignore("name")` arguments.
530    #[must_use]
531    pub fn from_setting_name(name: &str) -> Option<WarningCode> {
532        Self::ALL
533            .iter()
534            .copied()
535            .find(|c| c.as_str().eq_ignore_ascii_case(name))
536    }
537}
538
539/// An emitted-but-ungraded warning: the inference layer records these (no severity); [`gate`]
540/// resolves each into a final [`Diagnostic`] or drops it.
541#[derive(Debug, Clone, PartialEq, Eq)]
542pub struct RawWarning {
543    /// The byte range the warning applies to.
544    pub range: TextRange,
545    /// The code (the source of truth for severity + identity).
546    pub code: WarningCode,
547    /// The human-readable message.
548    pub message: String,
549}
550
551/// The resolved warning configuration for a project (or the standalone analyzer default). Parsed
552/// from `project.godot`'s `debug/gdscript/warnings/*`; passed to [`gate`].
553// A settings/config struct — each bool is an independent Godot project setting, so the
554// state-machine refactor `struct_excessive_bools` suggests would only obscure it.
555#[allow(clippy::struct_excessive_bools)]
556#[derive(Debug, Clone, PartialEq, Eq)]
557pub struct WarningSettings {
558    /// `debug/gdscript/warnings/enable` — the master switch (default `true`).
559    pub enabled: bool,
560    /// `debug/gdscript/warnings/treat_warnings_as_errors` — escalate every WARN to ERROR.
561    pub treat_as_errors: bool,
562    /// Explicit per-code level overrides from `project.godot`.
563    pub per_code: FxHashMap<WarningCode, WarnLevel>,
564    /// `debug/gdscript/warnings/exclude_addons` — suppress warnings under `res://addons/**`.
565    pub exclude_addons: bool,
566    /// The project's declared engine `(major, minor)`, for version-gating master-only codes.
567    pub engine: (u32, u32),
568    /// When `true` (a standalone run / CLI `--strict`), the IGNORE-default opt-in group is
569    /// promoted to WARN. A real `project.godot` clears this (its explicit settings win).
570    pub strict_opt_in: bool,
571}
572
573impl WarningSettings {
574    /// The standalone default (no `project.godot`): everything on, the opt-in strictness group
575    /// promoted to WARN, addons not excluded. Matches the analyzer's pre-gating behavior.
576    #[must_use]
577    pub fn analyzer_default() -> Self {
578        Self {
579            enabled: true,
580            treat_as_errors: false,
581            per_code: FxHashMap::default(),
582            exclude_addons: false,
583            engine: bundled_version(),
584            strict_opt_in: true,
585        }
586    }
587
588    /// Force the `strict_opt_in` flag on an already-resolved settings value, leaving every other
589    /// field (the project's explicit `per_code`, `treat_as_errors`, `exclude_addons`, `engine`)
590    /// intact — so a CLI `--strict`/`--engine-defaults` override only flips the opt-in-group
591    /// promotion and still honors the project's explicit per-code levels (`gate()` gives `per_code`
592    /// priority over the promotion). The pure transform behind the host `WarningOverride`.
593    #[must_use]
594    pub fn with_strict_opt_in(mut self, on: bool) -> Self {
595        self.strict_opt_in = on;
596        self
597    }
598
599    /// The engine-matching default for a project of declared version `engine`: Godot's own
600    /// `default_warning_levels[]` (the opt-in group stays IGNORE), addons excluded.
601    #[must_use]
602    pub fn engine_default(engine: (u32, u32)) -> Self {
603        Self {
604            enabled: true,
605            treat_as_errors: false,
606            per_code: FxHashMap::default(),
607            exclude_addons: true,
608            engine,
609            strict_opt_in: false,
610        }
611    }
612}
613
614/// The `@warning_ignore[_start|_restore]` suppression spans for one file. A warning is suppressed
615/// when its range falls inside a span listing its code. (M0 ships the empty map; the CST walk that
616/// populates it lands in W1 M2.)
617#[derive(Debug, Clone, Default, PartialEq, Eq)]
618pub struct SuppressionMap {
619    spans: Vec<(TextRange, Vec<WarningCode>)>,
620}
621
622impl SuppressionMap {
623    /// Whether `code` at `at` is suppressed by some span.
624    #[must_use]
625    pub fn is_suppressed(&self, code: WarningCode, at: TextRange) -> bool {
626        self.spans.iter().any(|(span, codes)| {
627            span.start <= at.start && at.end <= span.end && codes.contains(&code)
628        })
629    }
630
631    /// Add a suppression span over `range` for `codes` (used by the W1 M2 CST builder + tests).
632    pub fn push(&mut self, range: TextRange, codes: Vec<WarningCode>) {
633        self.spans.push((range, codes));
634    }
635}
636
637/// Build the per-file suppression map from the parsed CST (Workstream 1 M2): each
638/// `@warning_ignore("code", …)` suppresses the listed codes over the **single following
639/// statement/declaration**, and a `@warning_ignore_start("code")` … `@warning_ignore_restore("code")`
640/// pair suppresses a region (EOF-terminated if unrestored). Unknown code names are skipped (the
641/// unknown-name meta-diagnostic is deferred — see `TECH_DEBT.md`).
642#[must_use]
643pub fn build_suppression_map(root: &GdNode, source: &str) -> SuppressionMap {
644    let mut map = SuppressionMap::default();
645    // Annotations in source order.
646    let mut anns: Vec<GdNode> = gdscript_syntax::ast::descendants(root)
647        .into_iter()
648        .filter(|n| n.kind() == SyntaxKind::Annotation)
649        .collect();
650    anns.sort_by_key(|n| u32::from(n.text_range().start()));
651
652    // Open region starts for `_start`/`_restore`, keyed by code. Godot's
653    // `warning_ignore_start_lines` is a map keyed by warning code, so a repeated
654    // `@warning_ignore_start("x")` OVERWRITES the prior start for `x` (it is not a stack) — a
655    // single `@warning_ignore_restore("x")` ends the region at that latest start, and any earlier
656    // start does not leak past it.
657    let mut open: FxHashMap<WarningCode, u32> = FxHashMap::default();
658    let eof = u32::from(root.text_range().end());
659
660    for ann in &anns {
661        let Some(name) = annotation_name(ann) else {
662            continue;
663        };
664        let codes = annotation_warning_codes(ann);
665        if codes.is_empty() {
666            continue; // not a `@warning_ignore*` with a recognized code
667        }
668        match name.as_str() {
669            "warning_ignore" => {
670                if let Some(target) = next_decorated_sibling(ann) {
671                    let r = target.text_range();
672                    let start = u32::from(r.start());
673                    // Cover the whole physical line of the decorated statement — Godot tracks
674                    // `@warning_ignore` by line, so `;`-joined statements sharing that line are all
675                    // suppressed. Scan from the statement's END (its range START may include the
676                    // preceding newline as leading trivia); the next `\n` at-or-after the code ends
677                    // the line and always covers the full statement (incl. a multi-line one).
678                    let end = line_end_from(source, u32::from(r.end()));
679                    map.push(TextRange::new(start, end), codes);
680                }
681            }
682            "warning_ignore_start" => {
683                let start = u32::from(ann.text_range().end());
684                for c in codes {
685                    open.insert(c, start); // overwrite any prior open start for this code
686                }
687            }
688            "warning_ignore_restore" => {
689                let end = u32::from(ann.text_range().start());
690                for c in &codes {
691                    if let Some(start) = open.remove(c) {
692                        map.push(TextRange::new(start, end), vec![*c]);
693                    }
694                }
695            }
696            _ => {}
697        }
698    }
699    // Unrestored regions run to end of file. Sort for a deterministic span order (the map feeds a
700    // salsa query whose value is compared by equality).
701    let mut leftover: Vec<(WarningCode, u32)> = open.into_iter().collect();
702    leftover.sort_by_key(|&(_, start)| start);
703    for (c, start) in leftover {
704        map.push(TextRange::new(start, eof), vec![c]);
705    }
706    map
707}
708
709/// The annotation's name token (the identifier after `@`).
710fn annotation_name(ann: &GdNode) -> Option<String> {
711    ann.children_with_tokens()
712        .filter_map(NodeOrToken::into_token)
713        .find(|t| t.kind() == SyntaxKind::Ident)
714        .map(|t| t.text().to_owned())
715}
716
717/// The recognized warning codes named by a `@warning_ignore*` annotation's string arguments.
718fn annotation_warning_codes(ann: &GdNode) -> Vec<WarningCode> {
719    let Some(arglist) = ann.children().find(|c| c.kind() == SyntaxKind::ArgList) else {
720        return Vec::new();
721    };
722    let mut codes = Vec::new();
723    for lit in arglist
724        .children()
725        .filter(|c| c.kind() == SyntaxKind::Literal)
726    {
727        for tok in lit
728            .children_with_tokens()
729            .filter_map(NodeOrToken::into_token)
730        {
731            if tok.kind() == SyntaxKind::String
732                && let Some(c) =
733                    WarningCode::from_setting_name(tok.text().trim_matches(['"', '\'']))
734            {
735                codes.push(c);
736            }
737        }
738    }
739    codes
740}
741
742/// The byte offset of the end of the physical line containing `start` (the next `\n`, or EOF). Used
743/// to widen a one-shot `@warning_ignore` to cover every `;`-joined statement on the decorated line.
744fn line_end_from(source: &str, start: u32) -> u32 {
745    let s = start as usize;
746    match source.get(s..).and_then(|rest| rest.find('\n')) {
747        Some(i) => u32::try_from(s + i).unwrap_or(u32::MAX),
748        None => u32::try_from(source.len()).unwrap_or(u32::MAX),
749    }
750}
751
752/// The single statement/declaration a `@warning_ignore` decorates — the next sibling node that is
753/// not itself an annotation (annotations stack: `@onready @warning_ignore("…") var x`).
754fn next_decorated_sibling(ann: &GdNode) -> Option<GdNode> {
755    let parent = ann.parent()?;
756    let after = ann.text_range().start();
757    parent
758        .children()
759        .filter(|c| c.text_range().start() > after && c.kind() != SyntaxKind::Annotation)
760        .min_by_key(|c| u32::from(c.text_range().start()))
761        .cloned()
762}
763
764/// Resolve one [`RawWarning`] into a final [`Diagnostic`], or drop it. The **only** place
765/// settings/version/suppression touch a warning — pure, so it is trivially cacheable and testable.
766/// Precedence (research/04 §2.3): enable → per-code level → treat-as-errors → scope → suppression.
767#[must_use]
768pub fn gate(
769    raw: &RawWarning,
770    settings: &WarningSettings,
771    ignores: &SuppressionMap,
772    path: Option<&str>,
773) -> Option<Diagnostic> {
774    if !settings.enabled {
775        return None;
776    }
777    // Version-gate: a code the project's engine predates never fires.
778    if raw.code.since().min_version() > settings.engine {
779        return None;
780    }
781    // Base level: an explicit override wins; else the engine default, with the opt-in group
782    // promoted to WARN under `strict_opt_in`.
783    let mut level = settings
784        .per_code
785        .get(&raw.code)
786        .copied()
787        .unwrap_or_else(|| {
788            let d = raw.code.default_level();
789            if settings.strict_opt_in && raw.code.promoted_by_strict() {
790                WarnLevel::Warn
791            } else {
792                d
793            }
794        });
795    if level == WarnLevel::Ignore {
796        return None;
797    }
798    if settings.treat_as_errors && level == WarnLevel::Warn {
799        level = WarnLevel::Error;
800    }
801    if settings.exclude_addons && path.is_some_and(is_addon_path) {
802        return None;
803    }
804    if ignores.is_suppressed(raw.code, raw.range) {
805        return None;
806    }
807    Some(Diagnostic {
808        range: raw.range,
809        severity: match level {
810            WarnLevel::Error => Severity::Error,
811            // `Ignore` was returned above; only `Warn` reaches here besides `Error`.
812            _ => Severity::Warning,
813        },
814        code: raw.code.as_str().to_owned(),
815        message: raw.message.clone(),
816        source: DiagnosticSource::Type,
817        fixes: Vec::new(),
818        tags: raw.code.tags().to_vec(),
819    })
820}
821
822/// Render the Markdown **Warning Reference** page from the [`WarningCode`] catalog (Workstream 5
823/// docgen). The single source of truth — a test asserts the committed page matches this output, so
824/// the docs can never drift from the code (regenerate with `GDSCRIPT_UPDATE_DOCS=1`).
825#[must_use]
826pub fn render_warning_reference() -> String {
827    use std::fmt::Write as _;
828    let mut codes: Vec<WarningCode> = WarningCode::ALL.to_vec();
829    codes.sort_by_key(|c| c.as_str());
830
831    let mut s = String::new();
832    s.push_str("<!-- @generated by `gdscript-hir` (warnings::render_warning_reference); do not edit by hand. -->\n");
833    s.push_str("<!-- Regenerate: `GDSCRIPT_UPDATE_DOCS=1 cargo test -p gdscript-hir warning_reference_doc_is_current` -->\n\n");
834    s.push_str("# Warning Reference\n\n");
835    s.push_str(
836        "Every gateable GDScript warning the analyzer can emit, with its `project.godot` setting key, \
837         engine-default level, and the earliest Godot version it applies to. Configure these under \
838         `[debug]` as `gdscript/warnings/<key>` (`0` = ignore, `1` = warn, `2` = error), or suppress \
839         inline with `@warning_ignore(\"<key>\")`. See [Configuration](./configuration.md).\n\n",
840    );
841    s.push_str("| Code | Setting key | Default | Since | Description |\n");
842    s.push_str("|---|---|---|---|---|\n");
843    for c in codes {
844        let default = match c.default_level() {
845            WarnLevel::Ignore => "Ignore",
846            WarnLevel::Warn => "Warn",
847            WarnLevel::Error => "Error",
848        };
849        let since = match c.since() {
850            Since::V4_3 => "4.3",
851            Since::Master => "master",
852        };
853        let _ = writeln!(
854            s,
855            "| `{}` | `{}` | {default} | {since} | {} |",
856            c.as_str(),
857            c.setting_name(),
858            c.description(),
859        );
860    }
861    s
862}
863
864/// Whether `path` is under the project-root `res://addons/**` directory (the `exclude_addons`
865/// scope). Matches Godot exactly — `script_path.begins_with("res://addons/")` — so a *nested*
866/// user directory named `addons` (e.g. `res://game/addons/x.gd`) is **not** excluded (an earlier
867/// `contains("/addons/")` over-match silently dropped genuine warnings there).
868fn is_addon_path(path: &str) -> bool {
869    path.starts_with("res://addons/")
870}
871
872/// The bundled engine `(major, minor)` — the default project version and the `Since::Master`
873/// threshold. Parsed from [`gdscript_api::godot_version`] (so it tracks the bundled model, not a
874/// hardcoded literal).
875#[must_use]
876pub fn bundled_version() -> (u32, u32) {
877    parse_major_minor(gdscript_api::godot_version()).unwrap_or((4, 5))
878}
879
880/// Parse a leading `<major>.<minor>` (ignoring any `.patch`/`-suffix`) from `s`.
881fn parse_major_minor(s: &str) -> Option<(u32, u32)> {
882    let mut parts = s.split('.');
883    let major = parts.next()?.parse().ok()?;
884    let minor: u32 = parts
885        .next()?
886        .chars()
887        .take_while(char::is_ascii_digit)
888        .collect::<String>()
889        .parse()
890        .ok()?;
891    Some((major, minor))
892}
893
894#[cfg(test)]
895mod tests {
896    use super::*;
897    use gdscript_syntax::parse;
898    use std::collections::HashSet;
899
900    fn off(src: &str, needle: &str) -> u32 {
901        u32::try_from(src.find(needle).unwrap()).unwrap()
902    }
903
904    #[test]
905    fn warning_reference_doc_is_current() {
906        // The committed Warning Reference is generated from the catalog — keep them in lockstep.
907        let path = concat!(
908            env!("CARGO_MANIFEST_DIR"),
909            "/../../docs/src/reference/warnings.md"
910        );
911        let generated = render_warning_reference();
912        if std::env::var("GDSCRIPT_UPDATE_DOCS").is_ok() {
913            if let Some(parent) = std::path::Path::new(path).parent() {
914                std::fs::create_dir_all(parent).unwrap();
915            }
916            std::fs::write(path, &generated).unwrap();
917            return;
918        }
919        let on_disk = std::fs::read_to_string(path).unwrap_or_default();
920        assert_eq!(
921            on_disk, generated,
922            "docs/src/reference/warnings.md is stale — regenerate with \
923             `GDSCRIPT_UPDATE_DOCS=1 cargo test -p gdscript-hir warning_reference_doc_is_current`",
924        );
925    }
926
927    #[test]
928    fn warning_ignore_suppresses_the_next_statement() {
929        let src = "func f():\n\t@warning_ignore(\"integer_division\")\n\tvar x = 5 / 2\n";
930        let map = build_suppression_map(&parse(src).syntax_node(), src);
931        let at = off(src, "5 / 2");
932        assert!(map.is_suppressed(WarningCode::IntegerDivision, TextRange::new(at, at + 5)));
933        // A different code at the same place is not suppressed.
934        assert!(!map.is_suppressed(WarningCode::NarrowingConversion, TextRange::new(at, at + 5)));
935    }
936
937    #[test]
938    fn warning_ignore_covers_semicolon_joined_statements_on_the_line() {
939        // Godot tracks `@warning_ignore` by line, so a one-shot ignore must cover BOTH `;`-joined
940        // statements on the decorated line — not just the first.
941        let src = "func f():\n\t@warning_ignore(\"unused_variable\")\n\tvar a = 1; var b = 2\n\tvar c = 3\n";
942        let map = build_suppression_map(&parse(src).syntax_node(), src);
943        let a = off(src, "var a");
944        let b = off(src, "var b");
945        let c = off(src, "var c");
946        assert!(map.is_suppressed(WarningCode::UnusedVariable, TextRange::new(a, a + 1)));
947        assert!(
948            map.is_suppressed(WarningCode::UnusedVariable, TextRange::new(b, b + 1)),
949            "the second `;`-joined statement on the line must be covered"
950        );
951        // The next line is NOT covered (the ignore is one line only).
952        assert!(!map.is_suppressed(WarningCode::UnusedVariable, TextRange::new(c, c + 1)));
953    }
954
955    #[test]
956    fn warning_ignore_start_restore_suppresses_a_region() {
957        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";
958        let map = build_suppression_map(&parse(src).syntax_node(), src);
959        let a = off(src, "var a");
960        let b = off(src, "var b");
961        assert!(map.is_suppressed(WarningCode::UnusedVariable, TextRange::new(a, a + 1)));
962        // After the restore, the same code is no longer suppressed.
963        assert!(!map.is_suppressed(WarningCode::UnusedVariable, TextRange::new(b, b + 1)));
964    }
965
966    #[test]
967    fn repeated_start_for_one_code_overwrites_and_does_not_leak_past_restore() {
968        // Godot keys `warning_ignore_start` by code, so a 2nd start OVERWRITES the 1st. Only the
969        // region [latest_start .. restore] is suppressed; code BEFORE the 2nd start and AFTER the
970        // restore is still checked. The old Vec-stack leaked start#1 to EOF, over-suppressing both.
971        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";
972        let map = build_suppression_map(&parse(src).syntax_node(), src);
973        let before = off(src, "before");
974        let inside = off(src, "inside");
975        let after = off(src, "after");
976        assert!(
977            map.is_suppressed(
978                WarningCode::UnusedVariable,
979                TextRange::new(inside, inside + 1)
980            ),
981            "the active [start2 .. restore] region must be suppressed"
982        );
983        assert!(
984            !map.is_suppressed(
985                WarningCode::UnusedVariable,
986                TextRange::new(after, after + 1)
987            ),
988            "code after the restore must NOT be suppressed (no leak to EOF)"
989        );
990        assert!(
991            !map.is_suppressed(
992                WarningCode::UnusedVariable,
993                TextRange::new(before, before + 1)
994            ),
995            "code before the overwriting start must NOT be suppressed"
996        );
997    }
998
999    #[test]
1000    fn exclude_addons_only_matches_the_root_addons_dir() {
1001        let none = SuppressionMap::default();
1002        let mut s = WarningSettings::engine_default((4, 5));
1003        s.per_code
1004            .insert(WarningCode::IntegerDivision, WarnLevel::Warn);
1005        // A *nested* dir merely named `addons` is NOT an addon path (Godot: begins_with res://addons/).
1006        assert!(
1007            gate(
1008                &raw(WarningCode::IntegerDivision),
1009                &s,
1010                &none,
1011                Some("res://game/addons/spawner.gd")
1012            )
1013            .is_some(),
1014            "a nested addons/ dir must still be checked"
1015        );
1016        // The real root addons dir is excluded.
1017        assert!(
1018            gate(
1019                &raw(WarningCode::IntegerDivision),
1020                &s,
1021                &none,
1022                Some("res://addons/plugin/x.gd")
1023            )
1024            .is_none()
1025        );
1026    }
1027
1028    fn raw(code: WarningCode) -> RawWarning {
1029        RawWarning {
1030            range: TextRange::new(10, 20),
1031            code,
1032            message: "msg".to_owned(),
1033        }
1034    }
1035
1036    #[test]
1037    fn unused_and_unreachable_diagnostics_carry_the_unnecessary_tag() {
1038        let none = SuppressionMap::default();
1039        let s = WarningSettings::analyzer_default();
1040        for code in [WarningCode::UnusedVariable, WarningCode::UnreachableCode] {
1041            let d = gate(&raw(code), &s, &none, None).unwrap();
1042            assert_eq!(d.tags, vec![gdscript_base::DiagnosticTag::Unnecessary]);
1043        }
1044        // A non-rendering-hint code carries none. (The wire shape — `tags: [1]` present /
1045        // omitted when empty — is pinned at the session layer, where JSON crossing happens.)
1046        let plain = gate(&raw(WarningCode::IntegerDivision), &s, &none, None).unwrap();
1047        assert!(plain.tags.is_empty());
1048    }
1049
1050    #[test]
1051    fn every_code_has_a_unique_uppercase_string_that_round_trips() {
1052        let mut seen = HashSet::new();
1053        for &c in WarningCode::ALL {
1054            assert!(seen.insert(c.as_str()), "duplicate as_str: {}", c.as_str());
1055            assert_eq!(c.as_str(), c.as_str().to_ascii_uppercase());
1056            assert_eq!(WarningCode::from_setting_name(&c.setting_name()), Some(c));
1057        }
1058        // The set is the catalog; a missed `ALL` entry shows up as a short count.
1059        assert_eq!(seen.len(), 55);
1060    }
1061
1062    #[test]
1063    fn disabled_drops_everything() {
1064        let mut s = WarningSettings::analyzer_default();
1065        s.enabled = false;
1066        assert!(
1067            gate(
1068                &raw(WarningCode::IntegerDivision),
1069                &s,
1070                &SuppressionMap::default(),
1071                None
1072            )
1073            .is_none()
1074        );
1075    }
1076
1077    #[test]
1078    fn opt_in_group_is_silent_under_engine_default_but_warns_under_strict() {
1079        let none = SuppressionMap::default();
1080        let engine = WarningSettings::engine_default((4, 5));
1081        assert!(gate(&raw(WarningCode::UnsafeMethodAccess), &engine, &none, None).is_none());
1082        let strict = WarningSettings::analyzer_default(); // strict_opt_in = true
1083        let d = gate(&raw(WarningCode::UnsafeMethodAccess), &strict, &none, None).unwrap();
1084        assert_eq!(d.severity, Severity::Warning);
1085        assert_eq!(d.code, "UNSAFE_METHOD_ACCESS");
1086    }
1087
1088    #[test]
1089    fn untyped_inferred_are_not_promoted_by_strict_but_explicit_setting_warns() {
1090        let none = SuppressionMap::default();
1091        // The standalone / strict default does NOT auto-promote the stylistic declaration codes
1092        // (they are too noisy — every untyped/inferred local would warn).
1093        let strict = WarningSettings::analyzer_default(); // strict_opt_in = true
1094        assert!(gate(&raw(WarningCode::UntypedDeclaration), &strict, &none, None).is_none());
1095        assert!(gate(&raw(WarningCode::InferredDeclaration), &strict, &none, None).is_none());
1096        // But the UNSAFE_* group still is promoted (the regression guard).
1097        assert!(gate(&raw(WarningCode::UnsafeMethodAccess), &strict, &none, None).is_some());
1098        // An explicit per-code project setting enables them regardless.
1099        let mut s = WarningSettings::engine_default((4, 5));
1100        s.per_code
1101            .insert(WarningCode::UntypedDeclaration, WarnLevel::Warn);
1102        let d = gate(&raw(WarningCode::UntypedDeclaration), &s, &none, None).unwrap();
1103        assert_eq!(d.severity, Severity::Warning);
1104    }
1105
1106    #[test]
1107    fn error_default_stays_error() {
1108        let d = gate(
1109            &raw(WarningCode::InferenceOnVariant),
1110            &WarningSettings::analyzer_default(),
1111            &SuppressionMap::default(),
1112            None,
1113        )
1114        .unwrap();
1115        assert_eq!(d.severity, Severity::Error);
1116    }
1117
1118    #[test]
1119    fn treat_as_errors_escalates_warn_only() {
1120        let none = SuppressionMap::default();
1121        let mut s = WarningSettings::analyzer_default();
1122        s.treat_as_errors = true;
1123        // A WARN-default code escalates to ERROR.
1124        let d = gate(&raw(WarningCode::IntegerDivision), &s, &none, None).unwrap();
1125        assert_eq!(d.severity, Severity::Error);
1126        // An explicitly-Ignored code is never resurrected by treat-as-errors.
1127        s.per_code
1128            .insert(WarningCode::IntegerDivision, WarnLevel::Ignore);
1129        assert!(gate(&raw(WarningCode::IntegerDivision), &s, &none, None).is_none());
1130    }
1131
1132    #[test]
1133    fn per_code_override_sets_level() {
1134        let none = SuppressionMap::default();
1135        let mut s = WarningSettings::engine_default((4, 5));
1136        s.per_code
1137            .insert(WarningCode::UnsafeMethodAccess, WarnLevel::Error);
1138        let d = gate(&raw(WarningCode::UnsafeMethodAccess), &s, &none, None).unwrap();
1139        assert_eq!(d.severity, Severity::Error);
1140    }
1141
1142    #[test]
1143    fn exclude_addons_suppresses_by_path() {
1144        let mut s = WarningSettings::analyzer_default();
1145        s.exclude_addons = true;
1146        assert!(
1147            gate(
1148                &raw(WarningCode::IntegerDivision),
1149                &s,
1150                &SuppressionMap::default(),
1151                Some("res://addons/x/y.gd")
1152            )
1153            .is_none()
1154        );
1155        assert!(
1156            gate(
1157                &raw(WarningCode::IntegerDivision),
1158                &s,
1159                &SuppressionMap::default(),
1160                Some("res://game/y.gd")
1161            )
1162            .is_some()
1163        );
1164    }
1165
1166    #[test]
1167    fn suppression_map_drops_covered_range() {
1168        let mut map = SuppressionMap::default();
1169        map.push(TextRange::new(0, 100), vec![WarningCode::IntegerDivision]);
1170        assert!(
1171            gate(
1172                &raw(WarningCode::IntegerDivision),
1173                &WarningSettings::analyzer_default(),
1174                &map,
1175                None
1176            )
1177            .is_none()
1178        );
1179        // A different code in the same span is unaffected.
1180        assert!(
1181            gate(
1182                &raw(WarningCode::NarrowingConversion),
1183                &WarningSettings::analyzer_default(),
1184                &map,
1185                None
1186            )
1187            .is_some()
1188        );
1189    }
1190
1191    #[test]
1192    fn master_only_codes_gate_on_engine_version() {
1193        let none = SuppressionMap::default();
1194        // ConfusableTemporaryModification is WARN-default but master-only.
1195        let mut old = WarningSettings::engine_default((4, 3));
1196        old.strict_opt_in = false;
1197        assert!(
1198            gate(
1199                &raw(WarningCode::ConfusableTemporaryModification),
1200                &old,
1201                &none,
1202                None
1203            )
1204            .is_none()
1205        );
1206        // A project on the bundled model's own version (`Since::Master` resolves to it) fires —
1207        // derived, not hardcoded, so bumping the vendored engine model doesn't break this test.
1208        let new = WarningSettings::engine_default(bundled_version());
1209        assert!(
1210            gate(
1211                &raw(WarningCode::ConfusableTemporaryModification),
1212                &new,
1213                &none,
1214                None
1215            )
1216            .is_some()
1217        );
1218    }
1219}