Skip to main content

perl_parser_core/engine/
parser_context.rs

1//! Parser context with error recovery support
2//!
3//! This module provides a parsing context that tracks errors, positions,
4//! and supports error recovery for IDE scenarios.
5
6use crate::{
7    error::{BudgetTracker, ParseBudget},
8    error_recovery::ParseError,
9    position::{Position, Range},
10    token_wrapper::TokenWithPosition,
11};
12use perl_ast_v2::NodeIdGenerator;
13use perl_lexer::TokenType;
14use perl_position_tracking::LineStartsCache;
15use std::collections::VecDeque;
16
17/// Parser context with error tracking and recovery
18pub struct ParserContext {
19    /// Token stream with positions
20    tokens: VecDeque<TokenWithPosition>,
21    /// Current token index
22    current: usize,
23    /// Node ID generator
24    pub id_generator: NodeIdGenerator,
25    /// Accumulated parse errors
26    errors: Vec<ParseError>,
27    /// Source text
28    source: String,
29    /// Position tracker for efficient position mapping
30    _position_tracker: PositionTracker,
31    /// Budget limits for this parse
32    budget: ParseBudget,
33    /// Budget consumption tracker
34    budget_tracker: BudgetTracker,
35}
36
37/// Efficient position tracking using line starts cache
38///
39/// This implementation leverages the existing LineStartsCache for O(log n) position lookups
40/// instead of O(n) character-by-character advancement. It provides UTF-16 aware position
41/// mapping for LSP compatibility while integrating with the existing position infrastructure.
42struct PositionTracker {
43    /// Cache for O(log n) position lookups
44    line_cache: LineStartsCache,
45    /// Source text reference
46    source: String,
47}
48
49impl PositionTracker {
50    fn new(source: String) -> Self {
51        let line_cache = LineStartsCache::new(&source);
52        PositionTracker { line_cache, source }
53    }
54
55    /// Convert byte offset to position with UTF-16 support
56    fn byte_to_position(&self, byte_offset: usize) -> Position {
57        let (line, character) = self.line_cache.offset_to_position(&self.source, byte_offset);
58        // LineStartsCache returns 0-based line/column, but Position expects 1-based.
59        let (line, column) = zero_based_to_one_based(line, character);
60        Position::new(byte_offset, line, column)
61    }
62}
63
64/// Convert a 0-based `(line, column)` pair into the 1-based form `Position` expects.
65///
66/// Uses [`u32::saturating_add`] so a `u32::MAX`-adjacent line or column saturates at
67/// `u32::MAX` instead of panicking in debug builds or wrapping to `0` in release builds.
68fn zero_based_to_one_based(line: u32, column: u32) -> (u32, u32) {
69    (line.saturating_add(1), column.saturating_add(1))
70}
71
72impl ParserContext {
73    /// Create a new parser context
74    pub fn new(source: String) -> Self {
75        let mut tokens = VecDeque::new();
76        let position_tracker = PositionTracker::new(source.clone());
77
78        // Build a file-local symbol table so the lexer can treat known subs as
79        // term-introducing, enabling correct bareword/regex disambiguation (#1353).
80        let symbol_table = perl_lexer::LocalSymbolTable::scan_subs(&source);
81        let lexer_config = perl_lexer::LexerConfig {
82            symbol_table: Some(symbol_table),
83            ..perl_lexer::LexerConfig::default()
84        };
85
86        // Tokenize the source using mode-aware lexer
87        let mut lexer = perl_lexer::PerlLexer::with_config(&source, lexer_config);
88        loop {
89            match lexer.next_token() {
90                Some(token) => {
91                    // Skip EOF tokens to avoid infinite loop
92                    if matches!(token.token_type, TokenType::EOF) {
93                        break;
94                    }
95
96                    let start = token.start;
97                    let end = token.end;
98
99                    // Use efficient position mapping with UTF-16 support
100                    let start_pos = position_tracker.byte_to_position(start);
101                    let end_pos = position_tracker.byte_to_position(end);
102
103                    tokens.push_back(TokenWithPosition::new(token, start_pos, end_pos));
104                }
105                None => break,
106            }
107        }
108
109        ParserContext {
110            tokens,
111            current: 0,
112            id_generator: NodeIdGenerator::new(),
113            errors: Vec::new(),
114            source,
115            _position_tracker: position_tracker,
116            budget: ParseBudget::default(),
117            budget_tracker: BudgetTracker::new(),
118        }
119    }
120
121    /// Create a new parser context with a custom budget.
122    pub fn with_budget(source: String, budget: ParseBudget) -> Self {
123        let mut ctx = Self::new(source);
124        ctx.budget = budget;
125        ctx
126    }
127
128    /// Get the current budget.
129    pub fn budget(&self) -> &ParseBudget {
130        &self.budget
131    }
132
133    /// Get the budget tracker.
134    pub fn budget_tracker(&self) -> &BudgetTracker {
135        &self.budget_tracker
136    }
137
138    /// Get mutable access to the budget tracker.
139    pub fn budget_tracker_mut(&mut self) -> &mut BudgetTracker {
140        &mut self.budget_tracker
141    }
142
143    /// Check if error budget is exhausted.
144    pub fn errors_exhausted(&self) -> bool {
145        self.budget_tracker.errors_exhausted(&self.budget)
146    }
147
148    /// Check if depth budget would be exceeded.
149    pub fn depth_would_exceed(&self) -> bool {
150        self.budget_tracker.depth_would_exceed(&self.budget)
151    }
152
153    /// Enter a nesting level, tracking depth.
154    pub fn enter_depth(&mut self) -> bool {
155        if self.depth_would_exceed() {
156            return false;
157        }
158        self.budget_tracker.enter_depth();
159        true
160    }
161
162    /// Exit a nesting level.
163    pub fn exit_depth(&mut self) {
164        self.budget_tracker.exit_depth();
165    }
166
167    /// Get current token
168    pub fn current_token(&self) -> Option<&TokenWithPosition> {
169        self.tokens.get(self.current)
170    }
171
172    /// Peek at next token
173    pub fn peek_token(&self, offset: usize) -> Option<&TokenWithPosition> {
174        self.tokens.get(self.current + offset)
175    }
176
177    /// Advance to next token
178    pub fn advance(&mut self) -> Option<&TokenWithPosition> {
179        if self.current < self.tokens.len() {
180            self.current += 1;
181        }
182        self.current_token()
183    }
184
185    /// Check if at end of tokens
186    pub fn is_eof(&self) -> bool {
187        self.current >= self.tokens.len()
188    }
189
190    /// Get current position
191    pub fn current_position(&self) -> Position {
192        if let Some(token) = self.current_token() {
193            token.range().start
194        } else if let Some(last_token) = self.tokens.back() {
195            // At EOF, use end of last token
196            last_token.range().end
197        } else {
198            Position::new(0, 1, 1)
199        }
200    }
201
202    /// Get current position range
203    pub fn current_position_range(&self) -> Range {
204        if let Some(token) = self.current_token() {
205            token.range()
206        } else {
207            let pos = self.current_position();
208            Range::new(pos, pos)
209        }
210    }
211
212    /// Add a parse error, tracking budget consumption.
213    ///
214    /// Returns `true` if the error was added, `false` if error budget exhausted.
215    pub fn add_error(&mut self, error: ParseError) -> bool {
216        if self.errors_exhausted() {
217            return false;
218        }
219        self.errors.push(error);
220        self.budget_tracker.record_error();
221        true
222    }
223
224    /// Add a parse error without checking budget (for critical errors).
225    pub fn add_error_unchecked(&mut self, error: ParseError) {
226        self.errors.push(error);
227        self.budget_tracker.record_error();
228    }
229
230    /// Get all accumulated errors
231    pub fn take_errors(&mut self) -> Vec<ParseError> {
232        std::mem::take(&mut self.errors)
233    }
234
235    /// Get current token index (for saving/restoring)
236    pub fn current_index(&self) -> usize {
237        self.current
238    }
239
240    /// Set current token index (for restoring)
241    pub fn set_index(&mut self, index: usize) {
242        self.current = index.min(self.tokens.len());
243    }
244
245    /// Match and consume a specific token type
246    pub fn expect(&mut self, expected: TokenType) -> Result<&TokenWithPosition, ParseError> {
247        match self.current_token() {
248            Some(token) if token.token.token_type == expected => {
249                self.advance();
250                Ok(&self.tokens[self.current - 1])
251            }
252            Some(token) => Err(ParseError::new(
253                format!("Expected {:?}, found {:?}", expected, token.token.token_type),
254                token.range(),
255            )
256            .with_expected(vec![format!("{:?}", expected)])
257            .with_found(format!("{:?}", token.token.token_type))),
258            None => Err(ParseError::new(
259                format!("Expected {:?}, found end of file", expected),
260                self.current_position_range(),
261            )
262            .with_expected(vec![format!("{:?}", expected)])
263            .with_found("EOF".to_string())),
264        }
265    }
266
267    /// Check if current token matches
268    pub fn check(&self, token_type: &TokenType) -> bool {
269        self.current_token().map(|t| &t.token.token_type == token_type).unwrap_or(false)
270    }
271
272    /// Consume token if it matches
273    pub fn consume(&mut self, token_type: &TokenType) -> bool {
274        if self.check(token_type) {
275            self.advance();
276            true
277        } else {
278            false
279        }
280    }
281
282    /// Get source slice for a range
283    pub fn source_slice(&self, range: &Range) -> &str {
284        &self.source[range.start.byte..range.end.byte]
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use perl_tdd_support::must_some;
292
293    #[test]
294    fn test_parser_context_creation() {
295        let source = "my $x = 42;".to_string();
296        let ctx = ParserContext::new(source);
297
298        assert!(!ctx.is_eof());
299        assert!(!ctx.tokens.is_empty());
300    }
301
302    #[test]
303    fn test_token_advancement() {
304        let source = "my $x".to_string();
305        let mut ctx = ParserContext::new(source);
306
307        // First token should be 'my'
308        assert!(matches!(
309            ctx.current_token().map(|t| &t.token.token_type),
310            Some(TokenType::Keyword(k)) if k.as_ref() == "my"
311        ));
312
313        // Advance to next token
314        ctx.advance();
315        assert!(ctx.current_token().is_some());
316    }
317
318    #[test]
319    fn test_error_accumulation() {
320        let mut ctx = ParserContext::new("test".to_string());
321
322        let error1 = ParseError::new("Error 1".to_string(), ctx.current_position_range());
323        let error2 = ParseError::new("Error 2".to_string(), ctx.current_position_range());
324
325        ctx.add_error(error1);
326        ctx.add_error(error2);
327
328        let errors = ctx.take_errors();
329        assert_eq!(errors.len(), 2);
330        assert_eq!(errors[0].message, "Error 1");
331        assert_eq!(errors[1].message, "Error 2");
332    }
333
334    #[test]
335    fn test_multiline_positions() {
336        let source = "my $x = 42;\nmy $y = 43;".to_string();
337        let ctx = ParserContext::new(source.clone());
338
339        let first_offset = must_some(source.find("my"));
340        let second_offset = must_some(source.rfind("my"));
341
342        let first = must_some(ctx.tokens.iter().find(|t| t.range().start.byte == first_offset));
343        assert_eq!(first.range().start.line, 1);
344        assert_eq!(first.range().start.column, 1);
345        assert_eq!(first.range().end.line, 1);
346        assert_eq!(first.range().end.column, 3);
347
348        let second = must_some(ctx.tokens.iter().find(|t| t.range().start.byte == second_offset));
349        assert_eq!(second.range().start.line, 2);
350        assert_eq!(second.range().start.column, 1);
351        assert_eq!(second.range().end.line, 2);
352        assert_eq!(second.range().end.column, 3);
353    }
354
355    #[test]
356    fn test_multiline_string_token_positions() {
357        let source = "my $s = \"a\nb\";".to_string();
358        let ctx = ParserContext::new(source.clone());
359
360        let string_offset = must_some(source.find('"'));
361        let token = must_some(ctx.tokens.iter().find(|t| t.range().start.byte == string_offset));
362
363        assert_eq!(token.range().start.line, 1);
364        assert_eq!(token.range().start.column, 9);
365        assert_eq!(token.range().end.line, 2);
366        assert_eq!(token.range().end.column, 3);
367    }
368
369    #[test]
370    fn test_utf16_position_mapping() {
371        // Test with emoji which takes 2 UTF-16 code units
372        let source = "my $emoji = 😀;".to_string();
373        let ctx = ParserContext::new(source.clone());
374
375        // Find the emoji token (if lexer produces it as separate token)
376        // For now, test that positions are computed correctly for the = token
377        let equals_offset = must_some(source.find('='));
378        let equals_token =
379            must_some(ctx.tokens.iter().find(|t| t.range().start.byte == equals_offset));
380
381        // Before emoji: "my $emoji "  = 10 characters but the emoji counts as 2 UTF-16 units
382        // So column should account for UTF-16 encoding
383        assert_eq!(equals_token.range().start.line, 1);
384        // The exact column depends on how the lexer tokenizes, but should be UTF-16 aware
385        assert!(equals_token.range().start.column > 0);
386    }
387
388    #[test]
389    fn test_crlf_line_endings() {
390        let source = "my $x = 42;\r\nmy $y = 43;".to_string();
391        let ctx = ParserContext::new(source.clone());
392
393        let first_offset = must_some(source.find("my"));
394        let second_offset = must_some(source.rfind("my"));
395
396        let first = must_some(ctx.tokens.iter().find(|t| t.range().start.byte == first_offset));
397        assert_eq!(first.range().start.line, 1);
398        assert_eq!(first.range().start.column, 1);
399
400        let second = must_some(ctx.tokens.iter().find(|t| t.range().start.byte == second_offset));
401        assert_eq!(second.range().start.line, 2);
402        assert_eq!(second.range().start.column, 1);
403    }
404
405    #[test]
406    fn test_empty_source() {
407        let source = "".to_string();
408        let ctx = ParserContext::new(source);
409
410        assert!(ctx.tokens.is_empty());
411        assert!(ctx.is_eof());
412    }
413
414    #[test]
415    fn test_zero_based_to_one_based_saturates_at_u32_max() {
416        // Regression for the unchecked `line + 1` / `character + 1` in
417        // PositionTracker::byte_to_position: a u32::MAX-adjacent line/column must
418        // saturate at u32::MAX rather than panic (debug) or wrap to 0 (release).
419        assert_eq!(zero_based_to_one_based(u32::MAX, u32::MAX), (u32::MAX, u32::MAX));
420        assert_eq!(zero_based_to_one_based(u32::MAX - 1, u32::MAX - 1), (u32::MAX, u32::MAX));
421        // A single increment short of the boundary still increments normally.
422        assert_eq!(
423            zero_based_to_one_based(u32::MAX - 2, u32::MAX - 2),
424            (u32::MAX - 1, u32::MAX - 1)
425        );
426
427        // The happy path remains a plain 1-based increment.
428        assert_eq!(zero_based_to_one_based(0, 0), (1, 1));
429        assert_eq!(zero_based_to_one_based(41, 7), (42, 8));
430    }
431
432    #[test]
433    fn test_single_token() {
434        let source = "42".to_string();
435        let ctx = ParserContext::new(source);
436
437        assert_eq!(ctx.tokens.len(), 1);
438
439        let token = &ctx.tokens[0];
440        assert_eq!(token.range().start.byte, 0);
441        assert_eq!(token.range().start.line, 1);
442        assert_eq!(token.range().start.column, 1);
443        assert_eq!(token.range().end.byte, 2);
444        assert_eq!(token.range().end.line, 1);
445        assert_eq!(token.range().end.column, 3);
446    }
447}