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