Skip to main content

perl_pragma/
lib.rs

1//! Pragma tracker for Perl code analysis
2//!
3//! Tracks `use` and `no` pragmas throughout the codebase to determine
4//! effective pragma state at any point in the code.
5
6use perl_ast::ast::{Node, NodeKind};
7use std::ops::Range;
8
9const MAX_DISABLED_WARNING_CATEGORIES: usize = 256;
10
11/// Parsed Perl version from a lexical `use v...;` or `use 5.xxx;` pragma.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
13pub struct PerlVersion {
14    /// Major Perl version component.
15    pub major: u32,
16    /// Minor Perl version component.
17    pub minor: u32,
18}
19
20impl PerlVersion {
21    /// Create a new Perl version value.
22    pub const fn new(major: u32, minor: u32) -> Self {
23        Self { major, minor }
24    }
25}
26
27/// Pragma state at a given point in the code
28#[derive(Debug, Clone, Default, PartialEq)]
29pub struct PragmaState {
30    /// Whether strict vars is enabled
31    pub strict_vars: bool,
32    /// Whether strict subs is enabled
33    pub strict_subs: bool,
34    /// Whether strict refs is enabled
35    pub strict_refs: bool,
36    /// Whether warnings are enabled (globally)
37    pub warnings: bool,
38    /// Whether `use utf8` is enabled.
39    pub utf8: bool,
40    /// Active source encoding from `use encoding`.
41    pub encoding: Option<String>,
42    /// Whether `use feature 'unicode_strings'` or a matching feature bundle is enabled.
43    pub unicode_strings: bool,
44    /// Whether locale-sensitive behavior is enabled.
45    pub locale: bool,
46    /// Locale scope from `use locale`, if any.
47    pub locale_scope: Option<String>,
48    /// Warning categories explicitly disabled via `no warnings 'CATEGORY'`.
49    ///
50    /// When `no warnings` is used with specific category arguments (e.g.
51    /// `no warnings 'uninitialized'`), the global `warnings` flag stays `true`
52    /// and the disabled categories are recorded here.  Only bare `no warnings`
53    /// (no arguments) clears the global `warnings` flag.
54    pub disabled_warning_categories: Vec<String>,
55    /// Whether explicit `use feature 'signatures'` currently implies strictness.
56    ///
57    /// This is tracked separately from the raw strict flags so `no feature
58    /// 'signatures'` can unwind the feature-driven implication without
59    /// clobbering explicit `use strict` or version-implied strict state.
60    pub signatures_strict: bool,
61    /// Effective language features enabled in the current lexical scope.
62    ///
63    /// This starts with any features implied by `use vX.Y` declarations and is
64    /// then updated by explicit `use feature` / `no feature` pragmas.
65    pub features: Vec<&'static str>,
66    /// Lexically imported builtin short names from `use builtin`.
67    pub builtin_imports: Vec<String>,
68}
69
70/// Immutable compile-time snapshot of pragma state.
71///
72/// This is the stable value object returned by position queries, and the same
73/// type used by lexical save/restore operations while building an environment.
74#[derive(Debug, Clone, Default, PartialEq)]
75pub struct PragmaSnapshot {
76    state: PragmaState,
77}
78
79impl PragmaSnapshot {
80    /// Create a snapshot from a concrete state value.
81    #[must_use]
82    pub fn from_state(state: PragmaState) -> Self {
83        Self { state }
84    }
85
86    /// Borrow the underlying state.
87    #[must_use]
88    pub fn state(&self) -> &PragmaState {
89        &self.state
90    }
91
92    /// Whether all strict categories are active in this snapshot.
93    #[must_use]
94    pub fn strict_enabled(&self) -> bool {
95        self.state.strict_vars && self.state.strict_subs && self.state.strict_refs
96    }
97
98    /// Whether warnings are globally active in this snapshot.
99    #[must_use]
100    pub fn warnings_enabled(&self) -> bool {
101        self.state.warnings
102    }
103
104    /// Whether a feature is enabled in this snapshot.
105    #[must_use]
106    pub fn has_feature(&self, feature: &str) -> bool {
107        self.state.has_feature(feature)
108    }
109
110    /// Returns true if warnings are active for the given category.
111    #[must_use]
112    pub fn is_warning_active(&self, category: &str) -> bool {
113        self.state.is_warning_active(category)
114    }
115}
116
117impl From<PragmaState> for PragmaSnapshot {
118    fn from(state: PragmaState) -> Self {
119        Self::from_state(state)
120    }
121}
122
123impl From<PragmaSnapshot> for PragmaState {
124    fn from(snapshot: PragmaSnapshot) -> Self {
125        snapshot.state
126    }
127}
128
129/// Query object describing compile-time pragma state at a byte offset.
130#[derive(Debug, Clone, PartialEq)]
131pub struct PragmaStateQuery {
132    offset: usize,
133    snapshot: PragmaSnapshot,
134}
135
136impl PragmaStateQuery {
137    /// Byte offset this query was created for.
138    #[must_use]
139    pub fn offset(&self) -> usize {
140        self.offset
141    }
142
143    /// Immutable snapshot at this query position.
144    #[must_use]
145    pub fn snapshot(&self) -> &PragmaSnapshot {
146        &self.snapshot
147    }
148}
149
150/// Explicit compile-time pragma environment that can answer file-position
151/// queries and expose immutable snapshots.
152#[derive(Debug, Clone, Default, PartialEq)]
153pub struct CompileTimePragmaEnvironment {
154    map: Vec<(Range<usize>, PragmaSnapshot)>,
155}
156
157impl CompileTimePragmaEnvironment {
158    /// Build a queryable environment from an AST.
159    #[must_use]
160    pub fn build(ast: &Node) -> Self {
161        let mut ranges = Vec::new();
162        let mut current_state = PragmaState::default();
163        PragmaTracker::build_ranges(ast, &mut current_state, &mut ranges);
164        ranges.sort_by_key(|(range, _)| range.start);
165
166        let map =
167            ranges.into_iter().map(|(range, state)| (range, PragmaSnapshot::from(state))).collect();
168
169        Self { map }
170    }
171
172    /// Return a position query object with immutable state snapshot.
173    #[must_use]
174    pub fn query_at(&self, offset: usize) -> PragmaStateQuery {
175        PragmaStateQuery { offset, snapshot: self.snapshot_at(offset) }
176    }
177
178    /// Return the immutable snapshot active at the given byte offset.
179    #[must_use]
180    pub fn snapshot_at(&self, offset: usize) -> PragmaSnapshot {
181        let idx = self.map.partition_point(|(range, _)| range.start <= offset);
182        let mut snapshot =
183            if idx > 0 { self.map[idx - 1].1.clone() } else { PragmaSnapshot::default() };
184
185        if snapshot.state.signatures_strict {
186            snapshot.state.strict_vars = true;
187            snapshot.state.strict_subs = true;
188            snapshot.state.strict_refs = true;
189        }
190
191        snapshot
192    }
193
194    /// Access the underlying range map for advanced consumers.
195    #[must_use]
196    pub fn as_map(&self) -> &[(Range<usize>, PragmaSnapshot)] {
197        &self.map
198    }
199}
200
201impl PragmaState {
202    /// Create a new pragma state with all strict modes enabled
203    pub fn all_strict() -> Self {
204        Self {
205            strict_vars: true,
206            strict_subs: true,
207            strict_refs: true,
208            warnings: false,
209            utf8: false,
210            encoding: None,
211            unicode_strings: false,
212            locale: false,
213            locale_scope: None,
214            disabled_warning_categories: Vec::new(),
215            signatures_strict: false,
216            features: Vec::new(),
217            builtin_imports: Vec::new(),
218        }
219    }
220
221    /// Returns `true` if warnings are active for the given category.
222    ///
223    /// Warnings for a category are active when:
224    /// - The global `warnings` flag is `true`, **and**
225    /// - The category is not listed in `disabled_warning_categories`.
226    ///
227    /// If the global `warnings` flag is `false` (i.e. `no warnings` with no
228    /// arguments was used), all categories are considered inactive regardless of
229    /// the `disabled_warning_categories` list.
230    #[must_use]
231    pub fn is_warning_active(&self, category: &str) -> bool {
232        self.warnings && !self.disabled_warning_categories.iter().any(|c| c == category)
233    }
234
235    /// Returns `true` if the given feature name is currently enabled.
236    #[must_use]
237    pub fn has_feature(&self, feature: &str) -> bool {
238        self.features.contains(&feature)
239    }
240
241    /// Returns `true` when a builtin short name was lexically imported in scope.
242    #[must_use]
243    pub fn has_builtin_import(&self, name: &str) -> bool {
244        self.builtin_imports.iter().any(|import| import == name)
245    }
246}
247
248/// Parse a Perl version string into a major/minor pair.
249///
250/// Handles lexical version pragmas such as:
251/// - `v5.36`
252/// - `v5.36.0`
253/// - `5.036`
254/// - `5.10`
255/// - developer releases like `5.012_001`
256pub fn parse_perl_version(module: &str) -> Option<PerlVersion> {
257    let s = module.strip_prefix('v').unwrap_or(module);
258    let mut parts = s.splitn(3, '.');
259
260    let major = parse_version_component(parts.next()?)?;
261    let minor = match parts.next() {
262        Some(part) => parse_version_component(part)?,
263        None => 0,
264    };
265
266    Some(PerlVersion::new(major, minor))
267}
268
269fn parse_version_component(component: &str) -> Option<u32> {
270    let component = component.split_once('_').map_or(component, |(head, _)| head);
271    component.parse().ok()
272}
273
274/// Whether `use VERSION` implies `strict` for this version.
275#[must_use]
276pub fn version_implies_strict(version: PerlVersion) -> bool {
277    version >= PerlVersion::new(5, 12)
278}
279
280/// Whether `use VERSION` implies `warnings` for this version.
281#[must_use]
282pub fn version_implies_warnings(version: PerlVersion) -> bool {
283    version >= PerlVersion::new(5, 35)
284}
285
286/// Returns the language features implicitly enabled by declaring `use VERSION`.
287///
288/// Mirrors the Perl `feature` pragma bundle semantics: each `use vX.Y`
289/// declaration implicitly enables the same features as `use feature ':X.Y'`.
290/// Features that were removed from a bundle (e.g. `switch` removed in v5.38)
291/// are **not** included for that version and above.
292///
293/// Reference: <https://perldoc.perl.org/feature#FEATURE-BUNDLES>
294#[must_use]
295pub fn features_enabled_by_version(version: PerlVersion) -> Vec<&'static str> {
296    let mut features = Vec::new();
297
298    // v5.10 bundle: say, state, switch (given/when)
299    if version >= PerlVersion::new(5, 10) {
300        features.extend_from_slice(&["say", "state", "switch"]);
301    }
302
303    // v5.12 bundle adds: unicode_strings
304    if version >= PerlVersion::new(5, 12) {
305        features.push("unicode_strings");
306    }
307
308    // v5.16 bundle adds: unicode_eval, evalbytes, current_sub, fc
309    if version >= PerlVersion::new(5, 16) {
310        features.extend_from_slice(&["unicode_eval", "evalbytes", "current_sub", "fc"]);
311    }
312
313    // v5.20 bundle adds: postfix_deref (experimental; stable-bundled at v5.26)
314    // We track it from v5.20 so explicit `use feature 'postfix_deref'` on v5.20 works.
315    if version >= PerlVersion::new(5, 20) {
316        features.push("postfix_deref");
317    }
318
319    // v5.34 bundle adds: try (experimental)
320    if version >= PerlVersion::new(5, 34) {
321        features.push("try");
322    }
323
324    // v5.36 bundle adds: signatures (stable), defer, isa
325    if version >= PerlVersion::new(5, 36) {
326        features.extend_from_slice(&["signatures", "defer", "isa"]);
327    }
328
329    // v5.38 bundle adds: class, field, method; removes: switch (given/when deprecated)
330    if version >= PerlVersion::new(5, 38) {
331        features.extend_from_slice(&["class", "field", "method"]);
332        features.retain(|&f| f != "switch");
333    }
334
335    // v5.40 bundle adds: builtin
336    if version >= PerlVersion::new(5, 40) {
337        features.push("builtin");
338    }
339
340    features
341}
342
343fn enable_effective_version_semantics(state: &mut PragmaState, version: PerlVersion) {
344    if version_implies_strict(version) {
345        state.strict_vars = true;
346        state.strict_subs = true;
347        state.strict_refs = true;
348    }
349    if version_implies_warnings(version) {
350        state.warnings = true;
351    }
352    // Populate the version-implied feature set.
353    // Replace (not merge) so the highest `use vX.Y` wins if multiple appear.
354    state.features = features_enabled_by_version(version);
355    state.unicode_strings = state.has_feature("unicode_strings");
356    state.signatures_strict = false;
357}
358
359fn feature_items(arg: &str) -> Vec<String> {
360    pragma_arg_items(arg)
361}
362
363fn known_feature_name(name: &str) -> Option<&'static str> {
364    match name {
365        "say" => Some("say"),
366        "state" => Some("state"),
367        "switch" => Some("switch"),
368        "unicode_strings" => Some("unicode_strings"),
369        "unicode_eval" => Some("unicode_eval"),
370        "evalbytes" => Some("evalbytes"),
371        "current_sub" => Some("current_sub"),
372        "fc" => Some("fc"),
373        "postfix_deref" => Some("postfix_deref"),
374        "try" => Some("try"),
375        "signatures" => Some("signatures"),
376        "defer" => Some("defer"),
377        "isa" => Some("isa"),
378        "class" => Some("class"),
379        "field" => Some("field"),
380        "method" => Some("method"),
381        "builtin" => Some("builtin"),
382        _ => None,
383    }
384}
385
386const ALL_KNOWN_FEATURES: &[&str] = &[
387    "say",
388    "state",
389    "switch",
390    "unicode_strings",
391    "unicode_eval",
392    "evalbytes",
393    "current_sub",
394    "fc",
395    "postfix_deref",
396    "try",
397    "signatures",
398    "defer",
399    "isa",
400    "class",
401    "field",
402    "method",
403    "builtin",
404];
405
406fn enable_feature_name(state: &mut PragmaState, name: &str) -> bool {
407    if name == "signatures" {
408        state.signatures_strict = true;
409    }
410    if name == "unicode_strings" {
411        state.unicode_strings = true;
412    }
413
414    if let Some(feature) = known_feature_name(name) {
415        if state.features.iter().all(|existing| existing != &feature) {
416            state.features.push(feature);
417        }
418        true
419    } else {
420        false
421    }
422}
423
424fn disable_feature_name(state: &mut PragmaState, name: &str) -> bool {
425    if name == "signatures" {
426        state.signatures_strict = false;
427    }
428    if name == "unicode_strings" {
429        state.unicode_strings = false;
430    }
431
432    if let Some(feature) = known_feature_name(name) {
433        let before = state.features.len();
434        state.features.retain(|existing| *existing != feature);
435        before != state.features.len()
436    } else {
437        false
438    }
439}
440
441fn apply_feature_state(state: &mut PragmaState, args: &[String], enabled: bool) -> bool {
442    if !enabled && args.is_empty() {
443        let changed =
444            !state.features.is_empty() || state.unicode_strings || state.signatures_strict;
445        state.features.clear();
446        state.unicode_strings = false;
447        state.signatures_strict = false;
448        return changed;
449    }
450
451    let mut changed = false;
452
453    for arg in args {
454        for item in feature_items(arg) {
455            if enabled && item == ":all" {
456                for feature in ALL_KNOWN_FEATURES {
457                    changed |= enable_feature_name(state, feature);
458                }
459                continue;
460            }
461
462            if !enabled && item == ":all" {
463                let had_features =
464                    !state.features.is_empty() || state.unicode_strings || state.signatures_strict;
465                state.features.clear();
466                state.unicode_strings = false;
467                state.signatures_strict = false;
468                changed |= had_features;
469                continue;
470            }
471
472            if let Some(version) = item.strip_prefix(':').and_then(parse_perl_version) {
473                for feature in features_enabled_by_version(version) {
474                    changed |= if enabled {
475                        enable_feature_name(state, feature)
476                    } else {
477                        disable_feature_name(state, feature)
478                    };
479                }
480                continue;
481            }
482
483            changed |= if enabled {
484                enable_feature_name(state, &item)
485            } else {
486                disable_feature_name(state, &item)
487            };
488        }
489    }
490
491    changed
492}
493
494fn builtin_import_names(arg: &str) -> Vec<String> {
495    let trimmed = arg.trim();
496
497    if let Some(inner) = trimmed.strip_prefix("qw(").and_then(|s| s.strip_suffix(')')) {
498        return inner
499            .split_whitespace()
500            .filter(|name| !name.is_empty())
501            .map(|name| name.trim_matches('\'').trim_matches('"').to_string())
502            .collect();
503    }
504
505    let name = trimmed.trim_matches('\'').trim_matches('"');
506    if name.is_empty() { Vec::new() } else { vec![name.to_string()] }
507}
508
509fn apply_builtin_imports(state: &mut PragmaState, args: &[String]) {
510    for arg in args {
511        for name in builtin_import_names(arg) {
512            if !state.builtin_imports.iter().any(|import| import == &name) {
513                state.builtin_imports.push(name);
514            }
515        }
516    }
517}
518
519/// Insert `category` into `state.disabled_warning_categories` if not already present and
520/// within the hard cap of [`MAX_DISABLED_WARNING_CATEGORIES`].
521///
522/// Categories beyond the cap are silently dropped. In valid Perl code this is never reached
523/// (Perl's own warning hierarchy has ~30 leaf categories); the cap is a safety guard against
524/// pathological or adversarial AST input that would otherwise cause O(n²) clone cost.
525fn add_disabled_warning_category(state: &mut PragmaState, category: &str) {
526    if category.is_empty() {
527        return;
528    }
529
530    if state.disabled_warning_categories.iter().any(|c| c == category) {
531        return;
532    }
533
534    if state.disabled_warning_categories.len() >= MAX_DISABLED_WARNING_CATEGORIES {
535        return;
536    }
537
538    state.disabled_warning_categories.push(category.to_string());
539}
540
541fn remove_builtin_imports(state: &mut PragmaState, args: &[String]) {
542    if args.is_empty() {
543        state.builtin_imports.clear();
544        return;
545    }
546
547    let names_to_remove: Vec<String> =
548        args.iter().flat_map(|arg| builtin_import_names(arg)).collect();
549    state.builtin_imports.retain(|import| !names_to_remove.iter().any(|name| name == import));
550}
551
552fn pragma_arg_items(arg: &str) -> Vec<String> {
553    let trimmed = arg.trim().trim_matches('\'').trim_matches('"');
554
555    if let Some(inner) = trimmed.strip_prefix("qw(").and_then(|s| s.strip_suffix(')')) {
556        return inner.split_whitespace().map(|item| item.to_string()).collect();
557    }
558
559    if trimmed.contains(char::is_whitespace) {
560        return trimmed.split_whitespace().map(|item| item.to_string()).collect();
561    }
562
563    vec![trimmed.to_string()]
564}
565
566fn normalized_pragma_token(arg: &str) -> &str {
567    arg.trim().trim_matches('\'').trim_matches('"')
568}
569
570fn is_tracked_pragma_module(module: &str) -> bool {
571    matches!(module, "strict" | "warnings" | "utf8" | "encoding" | "locale" | "feature" | "builtin")
572}
573
574fn valid_strict_args(args: &[String]) -> bool {
575    args.iter()
576        .flat_map(|arg| pragma_arg_items(arg))
577        .all(|item| matches!(item.as_str(), "vars" | "subs" | "refs"))
578}
579
580fn conditional_target_tail_is_valid(module: &str, tail: &[String]) -> bool {
581    if parse_perl_version(module).is_some() {
582        return tail.is_empty();
583    }
584
585    match module {
586        "strict" => tail.is_empty() || valid_strict_args(tail),
587        "warnings" => true,
588        "utf8" => tail.is_empty(),
589        "encoding" => tail.len() == 1 && !normalized_pragma_token(&tail[0]).is_empty(),
590        "locale" => {
591            tail.is_empty() || (tail.len() == 1 && !normalized_pragma_token(&tail[0]).is_empty())
592        }
593        "feature" => !tail.is_empty(),
594        "builtin" => tail.iter().any(|arg| !builtin_import_names(arg).is_empty()),
595        _ => false,
596    }
597}
598
599fn conditional_pragma_target(args: &[String]) -> Option<(&str, &[String])> {
600    args.iter().enumerate().find_map(|(idx, arg)| {
601        let module = normalized_pragma_token(arg);
602        let tail = &args[idx + 1..];
603        if (is_tracked_pragma_module(module) || parse_perl_version(module).is_some())
604            && conditional_target_tail_is_valid(module, tail)
605        {
606            Some((module, tail))
607        } else {
608            None
609        }
610    })
611}
612
613/// Tracks pragma state throughout a Perl file
614pub struct PragmaTracker;
615
616/// Monotonic query cursor for repeated pragma lookups.
617///
618/// Reuse a single cursor when querying offsets in non-decreasing order to
619/// avoid repeated binary searches over the pragma map.
620#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
621pub struct PragmaQueryCursor {
622    index: usize,
623}
624
625impl PragmaQueryCursor {
626    /// Create a new cursor positioned before the start of the map.
627    #[must_use]
628    pub fn new() -> Self {
629        Self::default()
630    }
631
632    /// Query state at `offset` assuming lookups are mostly non-decreasing.
633    ///
634    /// If the caller queries an older offset, this falls back to a binary
635    /// search and repositions the cursor.
636    pub fn state_for_offset(
637        &mut self,
638        pragma_map: &[(Range<usize>, PragmaState)],
639        offset: usize,
640    ) -> PragmaState {
641        if pragma_map.is_empty() {
642            return PragmaState::default();
643        }
644
645        if self.index >= pragma_map.len() {
646            self.index = pragma_map.len() - 1;
647        }
648
649        if pragma_map[self.index].0.start > offset {
650            self.index = pragma_map.partition_point(|(range, _)| range.start <= offset);
651            if self.index > 0 {
652                self.index -= 1;
653            }
654        } else {
655            while self.index + 1 < pragma_map.len() && pragma_map[self.index + 1].0.start <= offset
656            {
657                self.index += 1;
658            }
659        }
660
661        let mut state = if pragma_map[self.index].0.start <= offset {
662            pragma_map[self.index].1.clone()
663        } else {
664            PragmaState::default()
665        };
666
667        if state.signatures_strict {
668            state.strict_vars = true;
669            state.strict_subs = true;
670            state.strict_refs = true;
671        }
672
673        state
674    }
675}
676
677impl PragmaTracker {
678    /// Build a range-indexed pragma map from an AST
679    pub fn build(ast: &Node) -> Vec<(Range<usize>, PragmaState)> {
680        CompileTimePragmaEnvironment::build(ast)
681            .as_map()
682            .iter()
683            .map(|(range, snapshot)| (range.clone(), snapshot.clone().into()))
684            .collect()
685    }
686
687    /// Get the pragma state at a specific byte offset
688    pub fn state_for_offset(
689        pragma_map: &[(Range<usize>, PragmaState)],
690        offset: usize,
691    ) -> PragmaState {
692        let map = pragma_map
693            .iter()
694            .map(|(range, state)| (range.clone(), PragmaSnapshot::from(state.clone())))
695            .collect();
696        let environment = CompileTimePragmaEnvironment { map };
697        environment.snapshot_at(offset).into()
698    }
699
700    /// Get the final top-level pragma state after all lexical scopes close.
701    #[must_use]
702    pub fn final_state(pragma_map: &[(Range<usize>, PragmaState)]) -> PragmaState {
703        let mut state = pragma_map.last().map_or_else(PragmaState::default, |(_, s)| s.clone());
704
705        if state.signatures_strict {
706            state.strict_vars = true;
707            state.strict_subs = true;
708            state.strict_refs = true;
709        }
710
711        state
712    }
713
714    /// Process a lexically scoped body and then restore the caller state.
715    ///
716    /// This applies to ordinary blocks and phase blocks alike. `BEGIN`/`END`/
717    /// `INIT`/`CHECK`/`UNITCHECK` execute at special times, but their pragma
718    /// effects are still lexical to the block body rather than file-wide.
719    fn build_scoped_body(
720        body: &Node,
721        current_state: &mut PragmaState,
722        ranges: &mut Vec<(Range<usize>, PragmaState)>,
723    ) {
724        let saved_state = current_state.clone();
725        Self::build_ranges(body, current_state, ranges);
726        *current_state = saved_state;
727        ranges.push((body.location.end..body.location.end, current_state.clone()));
728    }
729
730    fn build_ranges(
731        node: &Node,
732        current_state: &mut PragmaState,
733        ranges: &mut Vec<(Range<usize>, PragmaState)>,
734    ) {
735        match &node.kind {
736            NodeKind::Use { module, args, .. } => {
737                if (module == "if" || module == "unless")
738                    && let Some((conditional_module, conditional_args)) =
739                        conditional_pragma_target(args)
740                {
741                    match conditional_module {
742                        "strict" => {
743                            if conditional_args.is_empty() {
744                                current_state.strict_vars = true;
745                                current_state.strict_subs = true;
746                                current_state.strict_refs = true;
747                            } else {
748                                for arg in conditional_args {
749                                    for item in pragma_arg_items(arg) {
750                                        match item.as_str() {
751                                            "vars" => current_state.strict_vars = true,
752                                            "subs" => current_state.strict_subs = true,
753                                            "refs" => current_state.strict_refs = true,
754                                            _ => {}
755                                        }
756                                    }
757                                }
758                            }
759                            ranges.push((
760                                node.location.start..node.location.end,
761                                current_state.clone(),
762                            ));
763                            return;
764                        }
765                        "warnings" => {
766                            current_state.warnings = true;
767                            current_state.disabled_warning_categories.clear();
768                            ranges.push((
769                                node.location.start..node.location.end,
770                                current_state.clone(),
771                            ));
772                            return;
773                        }
774                        "utf8" => {
775                            current_state.utf8 = true;
776                            ranges.push((
777                                node.location.start..node.location.end,
778                                current_state.clone(),
779                            ));
780                            return;
781                        }
782                        "encoding" => {
783                            current_state.encoding = conditional_args
784                                .first()
785                                .map(|arg| normalized_pragma_token(arg).to_string());
786                            ranges.push((
787                                node.location.start..node.location.end,
788                                current_state.clone(),
789                            ));
790                            return;
791                        }
792                        "locale" => {
793                            current_state.locale = true;
794                            current_state.locale_scope = conditional_args
795                                .first()
796                                .map(|arg| normalized_pragma_token(arg).to_string());
797                            ranges.push((
798                                node.location.start..node.location.end,
799                                current_state.clone(),
800                            ));
801                            return;
802                        }
803                        "feature" => {
804                            if apply_feature_state(current_state, conditional_args, true) {
805                                ranges.push((
806                                    node.location.start..node.location.end,
807                                    current_state.clone(),
808                                ));
809                            }
810                            return;
811                        }
812                        "builtin" => {
813                            apply_builtin_imports(current_state, conditional_args);
814                            ranges.push((
815                                node.location.start..node.location.end,
816                                current_state.clone(),
817                            ));
818                            return;
819                        }
820                        _ => {
821                            if let Some(version) = parse_perl_version(conditional_module) {
822                                enable_effective_version_semantics(current_state, version);
823                                ranges.push((
824                                    node.location.start..node.location.end,
825                                    current_state.clone(),
826                                ));
827                            }
828                            return;
829                        }
830                    }
831                }
832
833                // Handle use statements
834                match module.as_str() {
835                    "strict" => {
836                        if args.is_empty() {
837                            // use strict; enables all categories
838                            current_state.strict_vars = true;
839                            current_state.strict_subs = true;
840                            current_state.strict_refs = true;
841                        } else {
842                            // Parse specific categories
843                            for arg in args {
844                                for item in pragma_arg_items(arg) {
845                                    match item.as_str() {
846                                        "vars" => {
847                                            current_state.strict_vars = true;
848                                        }
849                                        "subs" => {
850                                            current_state.strict_subs = true;
851                                        }
852                                        "refs" => {
853                                            current_state.strict_refs = true;
854                                        }
855                                        _ => {}
856                                    }
857                                }
858                            }
859                        }
860
861                        // Record the state change at this location
862                        ranges
863                            .push((node.location.start..node.location.end, current_state.clone()));
864                    }
865                    "warnings" => {
866                        current_state.warnings = true;
867                        // `use warnings` re-enables all warnings; clear any previously
868                        // disabled categories so they are active again.
869                        current_state.disabled_warning_categories.clear();
870                        ranges
871                            .push((node.location.start..node.location.end, current_state.clone()));
872                    }
873                    "utf8" => {
874                        current_state.utf8 = true;
875                        ranges
876                            .push((node.location.start..node.location.end, current_state.clone()));
877                    }
878                    "encoding" => {
879                        current_state.encoding = args
880                            .first()
881                            .map(|arg| arg.trim().trim_matches('\'').trim_matches('"').to_string());
882                        ranges
883                            .push((node.location.start..node.location.end, current_state.clone()));
884                    }
885                    "locale" => {
886                        current_state.locale = true;
887                        current_state.locale_scope = args
888                            .first()
889                            .map(|arg| arg.trim().trim_matches('\'').trim_matches('"').to_string());
890                        ranges
891                            .push((node.location.start..node.location.end, current_state.clone()));
892                    }
893                    "feature" => {
894                        if apply_feature_state(current_state, args, true) {
895                            ranges.push((
896                                node.location.start..node.location.end,
897                                current_state.clone(),
898                            ));
899                        }
900                    }
901                    "builtin" => {
902                        apply_builtin_imports(current_state, args);
903                        ranges
904                            .push((node.location.start..node.location.end, current_state.clone()));
905                    }
906                    _ => {
907                        if let Some(version) = parse_perl_version(module) {
908                            enable_effective_version_semantics(current_state, version);
909                            ranges.push((
910                                node.location.start..node.location.end,
911                                current_state.clone(),
912                            ));
913                        }
914                    }
915                }
916            }
917            NodeKind::No { module, args, .. } => {
918                if (module == "if" || module == "unless")
919                    && let Some((conditional_module, conditional_args)) =
920                        conditional_pragma_target(args)
921                {
922                    match conditional_module {
923                        "strict" => {
924                            if conditional_args.is_empty() {
925                                current_state.strict_vars = false;
926                                current_state.strict_subs = false;
927                                current_state.strict_refs = false;
928                            } else {
929                                for arg in conditional_args {
930                                    for item in pragma_arg_items(arg) {
931                                        match item.as_str() {
932                                            "vars" => current_state.strict_vars = false,
933                                            "subs" => current_state.strict_subs = false,
934                                            "refs" => current_state.strict_refs = false,
935                                            _ => {}
936                                        }
937                                    }
938                                }
939                            }
940                            ranges.push((
941                                node.location.start..node.location.end,
942                                current_state.clone(),
943                            ));
944                            return;
945                        }
946                        "warnings" => {
947                            if conditional_args.is_empty() {
948                                current_state.warnings = false;
949                                current_state.disabled_warning_categories.clear();
950                            } else {
951                                for arg in conditional_args {
952                                    let category = normalized_pragma_token(arg);
953                                    add_disabled_warning_category(current_state, category);
954                                }
955                            }
956                            ranges.push((
957                                node.location.start..node.location.end,
958                                current_state.clone(),
959                            ));
960                            return;
961                        }
962                        "utf8" => {
963                            current_state.utf8 = false;
964                            ranges.push((
965                                node.location.start..node.location.end,
966                                current_state.clone(),
967                            ));
968                            return;
969                        }
970                        "encoding" => {
971                            current_state.encoding = None;
972                            ranges.push((
973                                node.location.start..node.location.end,
974                                current_state.clone(),
975                            ));
976                            return;
977                        }
978                        "locale" => {
979                            current_state.locale = false;
980                            current_state.locale_scope = None;
981                            ranges.push((
982                                node.location.start..node.location.end,
983                                current_state.clone(),
984                            ));
985                            return;
986                        }
987                        "feature" => {
988                            if apply_feature_state(current_state, conditional_args, false) {
989                                ranges.push((
990                                    node.location.start..node.location.end,
991                                    current_state.clone(),
992                                ));
993                            }
994                            return;
995                        }
996                        "builtin" => {
997                            remove_builtin_imports(current_state, conditional_args);
998                            ranges.push((
999                                node.location.start..node.location.end,
1000                                current_state.clone(),
1001                            ));
1002                            return;
1003                        }
1004                        _ => return,
1005                    }
1006                }
1007
1008                // Handle no statements
1009                match module.as_str() {
1010                    "strict" => {
1011                        if args.is_empty() {
1012                            // no strict; disables all categories
1013                            current_state.strict_vars = false;
1014                            current_state.strict_subs = false;
1015                            current_state.strict_refs = false;
1016                        } else {
1017                            // Parse specific categories
1018                            for arg in args {
1019                                for item in pragma_arg_items(arg) {
1020                                    match item.as_str() {
1021                                        "vars" => {
1022                                            current_state.strict_vars = false;
1023                                        }
1024                                        "subs" => {
1025                                            current_state.strict_subs = false;
1026                                        }
1027                                        "refs" => {
1028                                            current_state.strict_refs = false;
1029                                        }
1030                                        _ => {}
1031                                    }
1032                                }
1033                            }
1034                        }
1035
1036                        // Record the state change at this location
1037                        ranges
1038                            .push((node.location.start..node.location.end, current_state.clone()));
1039                    }
1040                    "warnings" => {
1041                        let warnings_before = current_state.warnings;
1042                        let had_disabled_before =
1043                            !current_state.disabled_warning_categories.is_empty();
1044                        let before = current_state.disabled_warning_categories.len();
1045                        if args.is_empty() {
1046                            // `no warnings;` — disable all warnings globally
1047                            current_state.warnings = false;
1048                            current_state.disabled_warning_categories.clear();
1049                        } else {
1050                            // `no warnings 'CATEGORY'` — disable only the named
1051                            // categories; the global flag stays true so that other
1052                            // categories remain active.
1053                            for arg in args {
1054                                // Strip any surrounding single or double quotes that
1055                                // the parser may have left on the argument.
1056                                let category = arg.trim_matches('\'').trim_matches('"');
1057                                add_disabled_warning_category(current_state, category);
1058                            }
1059                        }
1060                        let changed = if args.is_empty() {
1061                            warnings_before || had_disabled_before
1062                        } else {
1063                            current_state.disabled_warning_categories.len() != before
1064                        };
1065                        if changed {
1066                            ranges.push((
1067                                node.location.start..node.location.end,
1068                                current_state.clone(),
1069                            ));
1070                        }
1071                    }
1072                    "utf8" => {
1073                        current_state.utf8 = false;
1074                        ranges
1075                            .push((node.location.start..node.location.end, current_state.clone()));
1076                    }
1077                    "encoding" => {
1078                        current_state.encoding = None;
1079                        ranges
1080                            .push((node.location.start..node.location.end, current_state.clone()));
1081                    }
1082                    "locale" => {
1083                        current_state.locale = false;
1084                        current_state.locale_scope = None;
1085                        ranges
1086                            .push((node.location.start..node.location.end, current_state.clone()));
1087                    }
1088                    "feature" => {
1089                        if apply_feature_state(current_state, args, false) {
1090                            ranges.push((
1091                                node.location.start..node.location.end,
1092                                current_state.clone(),
1093                            ));
1094                        }
1095                    }
1096                    "builtin" => {
1097                        remove_builtin_imports(current_state, args);
1098                        ranges
1099                            .push((node.location.start..node.location.end, current_state.clone()));
1100                    }
1101                    _ => {}
1102                }
1103            }
1104            NodeKind::Block { statements } => {
1105                // Save current state
1106                let saved_state = current_state.clone();
1107
1108                // Process statements in the block
1109                for stmt in statements {
1110                    Self::build_ranges(stmt, current_state, ranges);
1111                }
1112
1113                // Restore state after block
1114                *current_state = saved_state;
1115                ranges.push((node.location.end..node.location.end, current_state.clone()));
1116            }
1117            NodeKind::Program { statements } => {
1118                // Process all top-level statements
1119                for stmt in statements {
1120                    Self::build_ranges(stmt, current_state, ranges);
1121                }
1122            }
1123            // For subroutines and other container nodes, recurse into their bodies
1124            NodeKind::Subroutine { body, .. } => {
1125                Self::build_scoped_body(body, current_state, ranges);
1126            }
1127            NodeKind::Method { body, .. } => {
1128                Self::build_scoped_body(body, current_state, ranges);
1129            }
1130            NodeKind::Class { body, .. } => {
1131                Self::build_scoped_body(body, current_state, ranges);
1132            }
1133            NodeKind::If { then_branch, elsif_branches, else_branch, .. } => {
1134                Self::build_scoped_body(then_branch, current_state, ranges);
1135                for (_, elsif_body) in elsif_branches {
1136                    Self::build_scoped_body(elsif_body, current_state, ranges);
1137                }
1138                if let Some(else_b) = else_branch {
1139                    Self::build_scoped_body(else_b, current_state, ranges);
1140                }
1141            }
1142            NodeKind::While { body, continue_block, .. }
1143            | NodeKind::For { body, continue_block, .. }
1144            | NodeKind::Foreach { body, continue_block, .. } => {
1145                Self::build_scoped_body(body, current_state, ranges);
1146                if let Some(continue_block) = continue_block {
1147                    Self::build_scoped_body(continue_block, current_state, ranges);
1148                }
1149            }
1150            NodeKind::Eval { block } => {
1151                if matches!(block.kind, NodeKind::Block { .. }) {
1152                    Self::build_scoped_body(block, current_state, ranges);
1153                }
1154            }
1155            NodeKind::Do { block } | NodeKind::Defer { block } => {
1156                Self::build_scoped_body(block, current_state, ranges);
1157            }
1158            NodeKind::PhaseBlock { block, .. } => {
1159                Self::build_scoped_body(block, current_state, ranges);
1160            }
1161            NodeKind::Given { body, .. }
1162            | NodeKind::When { body, .. }
1163            | NodeKind::Default { body } => {
1164                Self::build_scoped_body(body, current_state, ranges);
1165            }
1166            NodeKind::Try { body, catch_blocks, finally_block } => {
1167                Self::build_scoped_body(body, current_state, ranges);
1168                for (_, catch_body) in catch_blocks {
1169                    Self::build_scoped_body(catch_body, current_state, ranges);
1170                }
1171                if let Some(finally_block) = finally_block {
1172                    Self::build_scoped_body(finally_block, current_state, ranges);
1173                }
1174            }
1175            NodeKind::LabeledStatement { statement, .. } => {
1176                Self::build_ranges(statement, current_state, ranges);
1177            }
1178            NodeKind::StatementModifier { statement, condition, .. } => {
1179                Self::build_ranges(statement, current_state, ranges);
1180                Self::build_ranges(condition, current_state, ranges);
1181            }
1182            // `package Foo { ... }` — the block form is lexically scoped.
1183            // Save/restore state around the block so pragmas declared inside
1184            // don't leak out, just like a regular braced block.
1185            //
1186            // `package Foo;` (no block) has no inner scope to walk — its
1187            // siblings in `Program` already accumulate state normally.
1188            NodeKind::Package { block: Some(pkg_block), .. } => {
1189                Self::build_scoped_body(pkg_block, current_state, ranges);
1190            }
1191            // Other node types don't contain use/no statements
1192            _ => {}
1193        }
1194    }
1195}