1use cstree::util::NodeOrToken;
15use gdscript_base::{Diagnostic, DiagnosticSource, DiagnosticTag, Severity, TextRange};
16use gdscript_syntax::{GdNode, SyntaxKind};
17use rustc_hash::FxHashMap;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum WarningCode {
25 UnassignedVariable,
28 UnassignedVariableOpAssign,
30 UnusedVariable,
32 UnusedLocalConstant,
34 UnusedPrivateClassVariable,
36 UnusedParameter,
38 UnusedSignal,
40 ShadowedVariable,
43 ShadowedVariableBaseClass,
45 ShadowedGlobalIdentifier,
47 UnreachableCode,
50 UnreachablePattern,
52 StandaloneExpression,
54 StandaloneTernary,
56 IncompatibleTernary,
58 UnsafeVoidReturn,
61 StaticCalledOnInstance,
63 MissingTool,
66 RedundantStaticUnload,
68 RedundantAwait,
70 AssertAlwaysTrue,
73 AssertAlwaysFalse,
75 IntegerDivision,
78 NarrowingConversion,
80 IntAsEnumWithoutCast,
82 IntAsEnumWithoutMatch,
84 EnumVariableWithoutDefault,
86 EmptyFile,
89 DeprecatedKeyword,
91 ConfusableIdentifier,
94 ConfusableLocalDeclaration,
96 ConfusableLocalUsage,
98 ConfusableCaptureReassignment,
100 ConfusableTemporaryModification,
102 PropertyUsedAsFunction,
105 ConstantUsedAsFunction,
107 FunctionUsedAsProperty,
109 UntypedDeclaration,
112 InferredDeclaration,
114 UnsafePropertyAccess,
116 UnsafeMethodAccess,
118 UnsafeCast,
120 UnsafeCallArgument,
122 ReturnValueDiscarded,
124 MissingAwait,
126 InferenceOnVariant,
129 NativeMethodOverride,
131 GetNodeDefaultWithoutOnready,
133 OnreadyWithExport,
135 UndefinedFunction,
140 UndefinedIdentifier,
142 UndefinedMethod,
149 UndefinedProperty,
152 TooFewArguments,
157 TooManyArguments,
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum WarnLevel {
166 Ignore,
168 Warn,
170 Error,
172}
173
174impl WarnLevel {
175 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum Since {
191 V4_3,
193 Master,
195}
196
197impl Since {
198 #[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 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 #[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 #[must_use]
334 pub fn setting_name(self) -> String {
335 self.as_str().to_ascii_lowercase()
336 }
337
338 #[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 #[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 #[must_use]
474 pub fn default_level(self) -> WarnLevel {
475 match self {
476 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 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 _ => WarnLevel::Warn,
500 }
501 }
502
503 #[must_use]
505 pub fn is_opt_in(self) -> bool {
506 self.default_level() == WarnLevel::Ignore
507 }
508
509 #[must_use]
515 pub fn promoted_by_strict(self) -> bool {
516 self.is_opt_in() && !matches!(self, Self::UntypedDeclaration | Self::InferredDeclaration)
517 }
518
519 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
542pub struct RawWarning {
543 pub range: TextRange,
545 pub code: WarningCode,
547 pub message: String,
549}
550
551#[allow(clippy::struct_excessive_bools)]
556#[derive(Debug, Clone, PartialEq, Eq)]
557pub struct WarningSettings {
558 pub enabled: bool,
560 pub treat_as_errors: bool,
562 pub per_code: FxHashMap<WarningCode, WarnLevel>,
564 pub exclude_addons: bool,
566 pub engine: (u32, u32),
568 pub strict_opt_in: bool,
571}
572
573impl WarningSettings {
574 #[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 #[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 #[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
618pub struct SuppressionMap {
619 spans: Vec<(TextRange, Vec<WarningCode>)>,
620}
621
622impl SuppressionMap {
623 #[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 pub fn push(&mut self, range: TextRange, codes: Vec<WarningCode>) {
633 self.spans.push((range, codes));
634 }
635}
636
637#[must_use]
643pub fn build_suppression_map(root: &GdNode, source: &str) -> SuppressionMap {
644 let mut map = SuppressionMap::default();
645 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 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; }
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 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); }
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 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
709fn 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
717fn 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
742fn 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
752fn 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#[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 if raw.code.since().min_version() > settings.engine {
779 return None;
780 }
781 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 _ => 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#[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
864fn is_addon_path(path: &str) -> bool {
869 path.starts_with("res://addons/")
870}
871
872#[must_use]
876pub fn bundled_version() -> (u32, u32) {
877 parse_major_minor(gdscript_api::godot_version()).unwrap_or((4, 5))
878}
879
880fn 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 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 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 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 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 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 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 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 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 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 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(); 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 let strict = WarningSettings::analyzer_default(); assert!(gate(&raw(WarningCode::UntypedDeclaration), &strict, &none, None).is_none());
1095 assert!(gate(&raw(WarningCode::InferredDeclaration), &strict, &none, None).is_none());
1096 assert!(gate(&raw(WarningCode::UnsafeMethodAccess), &strict, &none, None).is_some());
1098 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 let d = gate(&raw(WarningCode::IntegerDivision), &s, &none, None).unwrap();
1125 assert_eq!(d.severity, Severity::Error);
1126 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 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 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 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}