1use cstree::util::NodeOrToken;
15use gdscript_base::{Diagnostic, DiagnosticSource, 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}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum WarnLevel {
140 Ignore,
142 Warn,
144 Error,
146}
147
148impl WarnLevel {
149 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum Since {
165 V4_3,
167 Master,
169}
170
171impl Since {
172 #[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 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 #[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 #[must_use]
296 pub fn setting_name(self) -> String {
297 self.as_str().to_ascii_lowercase()
298 }
299
300 #[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 #[must_use]
393 pub fn default_level(self) -> WarnLevel {
394 match self {
395 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 Self::InferenceOnVariant
406 | Self::NativeMethodOverride
407 | Self::GetNodeDefaultWithoutOnready
408 | Self::OnreadyWithExport => WarnLevel::Error,
409 _ => WarnLevel::Warn,
411 }
412 }
413
414 #[must_use]
416 pub fn is_opt_in(self) -> bool {
417 self.default_level() == WarnLevel::Ignore
418 }
419
420 #[must_use]
426 pub fn promoted_by_strict(self) -> bool {
427 self.is_opt_in() && !matches!(self, Self::UntypedDeclaration | Self::InferredDeclaration)
428 }
429
430 #[must_use]
432 pub fn since(self) -> Since {
433 match self {
434 Self::ConfusableTemporaryModification | Self::MissingAwait => Since::Master,
435 _ => Since::V4_3,
436 }
437 }
438
439 #[must_use]
442 pub fn from_setting_name(name: &str) -> Option<WarningCode> {
443 Self::ALL
444 .iter()
445 .copied()
446 .find(|c| c.as_str().eq_ignore_ascii_case(name))
447 }
448}
449
450#[derive(Debug, Clone, PartialEq, Eq)]
453pub struct RawWarning {
454 pub range: TextRange,
456 pub code: WarningCode,
458 pub message: String,
460}
461
462#[allow(clippy::struct_excessive_bools)]
467#[derive(Debug, Clone, PartialEq, Eq)]
468pub struct WarningSettings {
469 pub enabled: bool,
471 pub treat_as_errors: bool,
473 pub per_code: FxHashMap<WarningCode, WarnLevel>,
475 pub exclude_addons: bool,
477 pub engine: (u32, u32),
479 pub strict_opt_in: bool,
482}
483
484impl WarningSettings {
485 #[must_use]
488 pub fn analyzer_default() -> Self {
489 Self {
490 enabled: true,
491 treat_as_errors: false,
492 per_code: FxHashMap::default(),
493 exclude_addons: false,
494 engine: bundled_version(),
495 strict_opt_in: true,
496 }
497 }
498
499 #[must_use]
505 pub fn with_strict_opt_in(mut self, on: bool) -> Self {
506 self.strict_opt_in = on;
507 self
508 }
509
510 #[must_use]
513 pub fn engine_default(engine: (u32, u32)) -> Self {
514 Self {
515 enabled: true,
516 treat_as_errors: false,
517 per_code: FxHashMap::default(),
518 exclude_addons: true,
519 engine,
520 strict_opt_in: false,
521 }
522 }
523}
524
525#[derive(Debug, Clone, Default, PartialEq, Eq)]
529pub struct SuppressionMap {
530 spans: Vec<(TextRange, Vec<WarningCode>)>,
531}
532
533impl SuppressionMap {
534 #[must_use]
536 pub fn is_suppressed(&self, code: WarningCode, at: TextRange) -> bool {
537 self.spans.iter().any(|(span, codes)| {
538 span.start <= at.start && at.end <= span.end && codes.contains(&code)
539 })
540 }
541
542 pub fn push(&mut self, range: TextRange, codes: Vec<WarningCode>) {
544 self.spans.push((range, codes));
545 }
546}
547
548#[must_use]
554pub fn build_suppression_map(root: &GdNode, source: &str) -> SuppressionMap {
555 let mut map = SuppressionMap::default();
556 let mut anns: Vec<GdNode> = gdscript_syntax::ast::descendants(root)
558 .into_iter()
559 .filter(|n| n.kind() == SyntaxKind::Annotation)
560 .collect();
561 anns.sort_by_key(|n| u32::from(n.text_range().start()));
562
563 let mut open: FxHashMap<WarningCode, u32> = FxHashMap::default();
569 let eof = u32::from(root.text_range().end());
570
571 for ann in &anns {
572 let Some(name) = annotation_name(ann) else {
573 continue;
574 };
575 let codes = annotation_warning_codes(ann);
576 if codes.is_empty() {
577 continue; }
579 match name.as_str() {
580 "warning_ignore" => {
581 if let Some(target) = next_decorated_sibling(ann) {
582 let r = target.text_range();
583 let start = u32::from(r.start());
584 let end = line_end_from(source, u32::from(r.end()));
590 map.push(TextRange::new(start, end), codes);
591 }
592 }
593 "warning_ignore_start" => {
594 let start = u32::from(ann.text_range().end());
595 for c in codes {
596 open.insert(c, start); }
598 }
599 "warning_ignore_restore" => {
600 let end = u32::from(ann.text_range().start());
601 for c in &codes {
602 if let Some(start) = open.remove(c) {
603 map.push(TextRange::new(start, end), vec![*c]);
604 }
605 }
606 }
607 _ => {}
608 }
609 }
610 let mut leftover: Vec<(WarningCode, u32)> = open.into_iter().collect();
613 leftover.sort_by_key(|&(_, start)| start);
614 for (c, start) in leftover {
615 map.push(TextRange::new(start, eof), vec![c]);
616 }
617 map
618}
619
620fn annotation_name(ann: &GdNode) -> Option<String> {
622 ann.children_with_tokens()
623 .filter_map(NodeOrToken::into_token)
624 .find(|t| t.kind() == SyntaxKind::Ident)
625 .map(|t| t.text().to_owned())
626}
627
628fn annotation_warning_codes(ann: &GdNode) -> Vec<WarningCode> {
630 let Some(arglist) = ann.children().find(|c| c.kind() == SyntaxKind::ArgList) else {
631 return Vec::new();
632 };
633 let mut codes = Vec::new();
634 for lit in arglist
635 .children()
636 .filter(|c| c.kind() == SyntaxKind::Literal)
637 {
638 for tok in lit
639 .children_with_tokens()
640 .filter_map(NodeOrToken::into_token)
641 {
642 if tok.kind() == SyntaxKind::String
643 && let Some(c) =
644 WarningCode::from_setting_name(tok.text().trim_matches(['"', '\'']))
645 {
646 codes.push(c);
647 }
648 }
649 }
650 codes
651}
652
653fn line_end_from(source: &str, start: u32) -> u32 {
656 let s = start as usize;
657 match source.get(s..).and_then(|rest| rest.find('\n')) {
658 Some(i) => u32::try_from(s + i).unwrap_or(u32::MAX),
659 None => u32::try_from(source.len()).unwrap_or(u32::MAX),
660 }
661}
662
663fn next_decorated_sibling(ann: &GdNode) -> Option<GdNode> {
666 let parent = ann.parent()?;
667 let after = ann.text_range().start();
668 parent
669 .children()
670 .filter(|c| c.text_range().start() > after && c.kind() != SyntaxKind::Annotation)
671 .min_by_key(|c| u32::from(c.text_range().start()))
672 .cloned()
673}
674
675#[must_use]
679pub fn gate(
680 raw: &RawWarning,
681 settings: &WarningSettings,
682 ignores: &SuppressionMap,
683 path: Option<&str>,
684) -> Option<Diagnostic> {
685 if !settings.enabled {
686 return None;
687 }
688 if raw.code.since().min_version() > settings.engine {
690 return None;
691 }
692 let mut level = settings
695 .per_code
696 .get(&raw.code)
697 .copied()
698 .unwrap_or_else(|| {
699 let d = raw.code.default_level();
700 if settings.strict_opt_in && raw.code.promoted_by_strict() {
701 WarnLevel::Warn
702 } else {
703 d
704 }
705 });
706 if level == WarnLevel::Ignore {
707 return None;
708 }
709 if settings.treat_as_errors && level == WarnLevel::Warn {
710 level = WarnLevel::Error;
711 }
712 if settings.exclude_addons && path.is_some_and(is_addon_path) {
713 return None;
714 }
715 if ignores.is_suppressed(raw.code, raw.range) {
716 return None;
717 }
718 Some(Diagnostic {
719 range: raw.range,
720 severity: match level {
721 WarnLevel::Error => Severity::Error,
722 _ => Severity::Warning,
724 },
725 code: raw.code.as_str().to_owned(),
726 message: raw.message.clone(),
727 source: DiagnosticSource::Type,
728 fixes: Vec::new(),
729 })
730}
731
732#[must_use]
736pub fn render_warning_reference() -> String {
737 use std::fmt::Write as _;
738 let mut codes: Vec<WarningCode> = WarningCode::ALL.to_vec();
739 codes.sort_by_key(|c| c.as_str());
740
741 let mut s = String::new();
742 s.push_str("<!-- @generated by `gdscript-hir` (warnings::render_warning_reference); do not edit by hand. -->\n");
743 s.push_str("<!-- Regenerate: `GDSCRIPT_UPDATE_DOCS=1 cargo test -p gdscript-hir warning_reference_doc_is_current` -->\n\n");
744 s.push_str("# Warning Reference\n\n");
745 s.push_str(
746 "Every gateable GDScript warning the analyzer can emit, with its `project.godot` setting key, \
747 engine-default level, and the earliest Godot version it applies to. Configure these under \
748 `[debug]` as `gdscript/warnings/<key>` (`0` = ignore, `1` = warn, `2` = error), or suppress \
749 inline with `@warning_ignore(\"<key>\")`. See [Configuration](./configuration.md).\n\n",
750 );
751 s.push_str("| Code | Setting key | Default | Since | Description |\n");
752 s.push_str("|---|---|---|---|---|\n");
753 for c in codes {
754 let default = match c.default_level() {
755 WarnLevel::Ignore => "Ignore",
756 WarnLevel::Warn => "Warn",
757 WarnLevel::Error => "Error",
758 };
759 let since = match c.since() {
760 Since::V4_3 => "4.3",
761 Since::Master => "master",
762 };
763 let _ = writeln!(
764 s,
765 "| `{}` | `{}` | {default} | {since} | {} |",
766 c.as_str(),
767 c.setting_name(),
768 c.description(),
769 );
770 }
771 s
772}
773
774fn is_addon_path(path: &str) -> bool {
779 path.starts_with("res://addons/")
780}
781
782#[must_use]
786pub fn bundled_version() -> (u32, u32) {
787 parse_major_minor(gdscript_api::godot_version()).unwrap_or((4, 5))
788}
789
790fn parse_major_minor(s: &str) -> Option<(u32, u32)> {
792 let mut parts = s.split('.');
793 let major = parts.next()?.parse().ok()?;
794 let minor: u32 = parts
795 .next()?
796 .chars()
797 .take_while(char::is_ascii_digit)
798 .collect::<String>()
799 .parse()
800 .ok()?;
801 Some((major, minor))
802}
803
804#[cfg(test)]
805mod tests {
806 use super::*;
807 use gdscript_syntax::parse;
808 use std::collections::HashSet;
809
810 fn off(src: &str, needle: &str) -> u32 {
811 u32::try_from(src.find(needle).unwrap()).unwrap()
812 }
813
814 #[test]
815 fn warning_reference_doc_is_current() {
816 let path = concat!(
818 env!("CARGO_MANIFEST_DIR"),
819 "/../../docs/src/reference/warnings.md"
820 );
821 let generated = render_warning_reference();
822 if std::env::var("GDSCRIPT_UPDATE_DOCS").is_ok() {
823 if let Some(parent) = std::path::Path::new(path).parent() {
824 std::fs::create_dir_all(parent).unwrap();
825 }
826 std::fs::write(path, &generated).unwrap();
827 return;
828 }
829 let on_disk = std::fs::read_to_string(path).unwrap_or_default();
830 assert_eq!(
831 on_disk, generated,
832 "docs/src/reference/warnings.md is stale — regenerate with \
833 `GDSCRIPT_UPDATE_DOCS=1 cargo test -p gdscript-hir warning_reference_doc_is_current`",
834 );
835 }
836
837 #[test]
838 fn warning_ignore_suppresses_the_next_statement() {
839 let src = "func f():\n\t@warning_ignore(\"integer_division\")\n\tvar x = 5 / 2\n";
840 let map = build_suppression_map(&parse(src).syntax_node(), src);
841 let at = off(src, "5 / 2");
842 assert!(map.is_suppressed(WarningCode::IntegerDivision, TextRange::new(at, at + 5)));
843 assert!(!map.is_suppressed(WarningCode::NarrowingConversion, TextRange::new(at, at + 5)));
845 }
846
847 #[test]
848 fn warning_ignore_covers_semicolon_joined_statements_on_the_line() {
849 let src = "func f():\n\t@warning_ignore(\"unused_variable\")\n\tvar a = 1; var b = 2\n\tvar c = 3\n";
852 let map = build_suppression_map(&parse(src).syntax_node(), src);
853 let a = off(src, "var a");
854 let b = off(src, "var b");
855 let c = off(src, "var c");
856 assert!(map.is_suppressed(WarningCode::UnusedVariable, TextRange::new(a, a + 1)));
857 assert!(
858 map.is_suppressed(WarningCode::UnusedVariable, TextRange::new(b, b + 1)),
859 "the second `;`-joined statement on the line must be covered"
860 );
861 assert!(!map.is_suppressed(WarningCode::UnusedVariable, TextRange::new(c, c + 1)));
863 }
864
865 #[test]
866 fn warning_ignore_start_restore_suppresses_a_region() {
867 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";
868 let map = build_suppression_map(&parse(src).syntax_node(), src);
869 let a = off(src, "var a");
870 let b = off(src, "var b");
871 assert!(map.is_suppressed(WarningCode::UnusedVariable, TextRange::new(a, a + 1)));
872 assert!(!map.is_suppressed(WarningCode::UnusedVariable, TextRange::new(b, b + 1)));
874 }
875
876 #[test]
877 fn repeated_start_for_one_code_overwrites_and_does_not_leak_past_restore() {
878 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";
882 let map = build_suppression_map(&parse(src).syntax_node(), src);
883 let before = off(src, "before");
884 let inside = off(src, "inside");
885 let after = off(src, "after");
886 assert!(
887 map.is_suppressed(
888 WarningCode::UnusedVariable,
889 TextRange::new(inside, inside + 1)
890 ),
891 "the active [start2 .. restore] region must be suppressed"
892 );
893 assert!(
894 !map.is_suppressed(
895 WarningCode::UnusedVariable,
896 TextRange::new(after, after + 1)
897 ),
898 "code after the restore must NOT be suppressed (no leak to EOF)"
899 );
900 assert!(
901 !map.is_suppressed(
902 WarningCode::UnusedVariable,
903 TextRange::new(before, before + 1)
904 ),
905 "code before the overwriting start must NOT be suppressed"
906 );
907 }
908
909 #[test]
910 fn exclude_addons_only_matches_the_root_addons_dir() {
911 let none = SuppressionMap::default();
912 let mut s = WarningSettings::engine_default((4, 5));
913 s.per_code
914 .insert(WarningCode::IntegerDivision, WarnLevel::Warn);
915 assert!(
917 gate(
918 &raw(WarningCode::IntegerDivision),
919 &s,
920 &none,
921 Some("res://game/addons/spawner.gd")
922 )
923 .is_some(),
924 "a nested addons/ dir must still be checked"
925 );
926 assert!(
928 gate(
929 &raw(WarningCode::IntegerDivision),
930 &s,
931 &none,
932 Some("res://addons/plugin/x.gd")
933 )
934 .is_none()
935 );
936 }
937
938 fn raw(code: WarningCode) -> RawWarning {
939 RawWarning {
940 range: TextRange::new(10, 20),
941 code,
942 message: "msg".to_owned(),
943 }
944 }
945
946 #[test]
947 fn every_code_has_a_unique_uppercase_string_that_round_trips() {
948 let mut seen = HashSet::new();
949 for &c in WarningCode::ALL {
950 assert!(seen.insert(c.as_str()), "duplicate as_str: {}", c.as_str());
951 assert_eq!(c.as_str(), c.as_str().to_ascii_uppercase());
952 assert_eq!(WarningCode::from_setting_name(&c.setting_name()), Some(c));
953 }
954 assert_eq!(seen.len(), 49);
956 }
957
958 #[test]
959 fn disabled_drops_everything() {
960 let mut s = WarningSettings::analyzer_default();
961 s.enabled = false;
962 assert!(
963 gate(
964 &raw(WarningCode::IntegerDivision),
965 &s,
966 &SuppressionMap::default(),
967 None
968 )
969 .is_none()
970 );
971 }
972
973 #[test]
974 fn opt_in_group_is_silent_under_engine_default_but_warns_under_strict() {
975 let none = SuppressionMap::default();
976 let engine = WarningSettings::engine_default((4, 5));
977 assert!(gate(&raw(WarningCode::UnsafeMethodAccess), &engine, &none, None).is_none());
978 let strict = WarningSettings::analyzer_default(); let d = gate(&raw(WarningCode::UnsafeMethodAccess), &strict, &none, None).unwrap();
980 assert_eq!(d.severity, Severity::Warning);
981 assert_eq!(d.code, "UNSAFE_METHOD_ACCESS");
982 }
983
984 #[test]
985 fn untyped_inferred_are_not_promoted_by_strict_but_explicit_setting_warns() {
986 let none = SuppressionMap::default();
987 let strict = WarningSettings::analyzer_default(); assert!(gate(&raw(WarningCode::UntypedDeclaration), &strict, &none, None).is_none());
991 assert!(gate(&raw(WarningCode::InferredDeclaration), &strict, &none, None).is_none());
992 assert!(gate(&raw(WarningCode::UnsafeMethodAccess), &strict, &none, None).is_some());
994 let mut s = WarningSettings::engine_default((4, 5));
996 s.per_code
997 .insert(WarningCode::UntypedDeclaration, WarnLevel::Warn);
998 let d = gate(&raw(WarningCode::UntypedDeclaration), &s, &none, None).unwrap();
999 assert_eq!(d.severity, Severity::Warning);
1000 }
1001
1002 #[test]
1003 fn error_default_stays_error() {
1004 let d = gate(
1005 &raw(WarningCode::InferenceOnVariant),
1006 &WarningSettings::analyzer_default(),
1007 &SuppressionMap::default(),
1008 None,
1009 )
1010 .unwrap();
1011 assert_eq!(d.severity, Severity::Error);
1012 }
1013
1014 #[test]
1015 fn treat_as_errors_escalates_warn_only() {
1016 let none = SuppressionMap::default();
1017 let mut s = WarningSettings::analyzer_default();
1018 s.treat_as_errors = true;
1019 let d = gate(&raw(WarningCode::IntegerDivision), &s, &none, None).unwrap();
1021 assert_eq!(d.severity, Severity::Error);
1022 s.per_code
1024 .insert(WarningCode::IntegerDivision, WarnLevel::Ignore);
1025 assert!(gate(&raw(WarningCode::IntegerDivision), &s, &none, None).is_none());
1026 }
1027
1028 #[test]
1029 fn per_code_override_sets_level() {
1030 let none = SuppressionMap::default();
1031 let mut s = WarningSettings::engine_default((4, 5));
1032 s.per_code
1033 .insert(WarningCode::UnsafeMethodAccess, WarnLevel::Error);
1034 let d = gate(&raw(WarningCode::UnsafeMethodAccess), &s, &none, None).unwrap();
1035 assert_eq!(d.severity, Severity::Error);
1036 }
1037
1038 #[test]
1039 fn exclude_addons_suppresses_by_path() {
1040 let mut s = WarningSettings::analyzer_default();
1041 s.exclude_addons = true;
1042 assert!(
1043 gate(
1044 &raw(WarningCode::IntegerDivision),
1045 &s,
1046 &SuppressionMap::default(),
1047 Some("res://addons/x/y.gd")
1048 )
1049 .is_none()
1050 );
1051 assert!(
1052 gate(
1053 &raw(WarningCode::IntegerDivision),
1054 &s,
1055 &SuppressionMap::default(),
1056 Some("res://game/y.gd")
1057 )
1058 .is_some()
1059 );
1060 }
1061
1062 #[test]
1063 fn suppression_map_drops_covered_range() {
1064 let mut map = SuppressionMap::default();
1065 map.push(TextRange::new(0, 100), vec![WarningCode::IntegerDivision]);
1066 assert!(
1067 gate(
1068 &raw(WarningCode::IntegerDivision),
1069 &WarningSettings::analyzer_default(),
1070 &map,
1071 None
1072 )
1073 .is_none()
1074 );
1075 assert!(
1077 gate(
1078 &raw(WarningCode::NarrowingConversion),
1079 &WarningSettings::analyzer_default(),
1080 &map,
1081 None
1082 )
1083 .is_some()
1084 );
1085 }
1086
1087 #[test]
1088 fn master_only_codes_gate_on_engine_version() {
1089 let none = SuppressionMap::default();
1090 let mut old = WarningSettings::engine_default((4, 3));
1092 old.strict_opt_in = false;
1093 assert!(
1094 gate(
1095 &raw(WarningCode::ConfusableTemporaryModification),
1096 &old,
1097 &none,
1098 None
1099 )
1100 .is_none()
1101 );
1102 let new = WarningSettings::engine_default((4, 5));
1103 assert!(
1104 gate(
1105 &raw(WarningCode::ConfusableTemporaryModification),
1106 &new,
1107 &none,
1108 None
1109 )
1110 .is_some()
1111 );
1112 }
1113}