Skip to main content

syntect/parsing/
parser.rs

1// Suppression of a false positive clippy lint. Upstream issue:
2//
3//   mutable_key_type false positive for raw pointers
4//   https://github.com/rust-lang/rust-clippy/issues/6745
5//
6// We use `*const MatchPattern` as key in our `SearchCache` hash map.
7// Clippy thinks this is a problem since `MatchPattern` has interior mutability
8// via `MatchPattern::regex::regex` which is an `AtomicLazyCell`.
9// But raw pointers are hashed via the pointer itself, not what is pointed to.
10// See https://github.com/rust-lang/rust/blob/1.54.0/library/core/src/hash/mod.rs#L717-L725
11#![allow(clippy::mutable_key_type)]
12
13use super::regex::{Regex, Region};
14use super::scope::*;
15use super::syntax_definition::*;
16use crate::parsing::syntax_definition::ContextId;
17use crate::parsing::syntax_set::{SyntaxReference, SyntaxSet};
18use fnv::FnvHasher;
19use regex_syntax::escape;
20use std::collections::HashMap;
21use std::hash::BuildHasherDefault;
22
23/// Errors that can occur while parsing.
24#[derive(Debug, thiserror::Error)]
25#[non_exhaustive]
26pub enum ParsingError {
27    #[error("Somehow main context was popped from the stack")]
28    MissingMainContext,
29    /// A context is missing. Usually caused by a syntax referencing a another
30    /// syntax that is not known to syntect. See e.g. <https://github.com/trishume/syntect/issues/421>
31    #[error("Missing context with ID '{0:?}'")]
32    MissingContext(ContextId),
33    #[error("Bad index to match_at: {0}")]
34    BadMatchIndex(usize),
35    #[error("Tried to use a ContextReference that has not bee resolved yet: {0:?}")]
36    UnresolvedContextReference(ContextReference),
37}
38
39/// Keeps the current parser state (the internal syntax interpreter stack) between lines of parsing.
40///
41/// If you are parsing an entire file you create one of these at the start and use it
42/// all the way to the end.
43///
44/// # Caching
45///
46/// One reason this is exposed is that since it implements `Clone` you can actually cache
47/// these (probably along with a [`HighlightState`]) and only re-start parsing from the point of a change.
48/// See the docs for [`HighlightState`] for more in-depth discussion of caching.
49///
50/// This state doesn't keep track of the current scope stack and parsing only returns changes to this stack
51/// so if you want to construct scope stacks you'll need to keep track of that as well.
52/// Note that [`HighlightState`] contains exactly this as a public field that you can use.
53///
54/// **Note:** Caching is for advanced users who have tons of time to maximize performance or want to do so eventually.
55/// It is not recommended that you try caching the first time you implement highlighting.
56///
57/// [`HighlightState`]: ../highlighting/struct.HighlightState.html
58/// Output of [`ParseState::parse_line`].
59///
60/// `ops` contains the scope-stack operations for the current line, as before.
61/// `replayed` is non-empty only after a cross-line `fail` fires: it contains
62/// the corrected ops for each buffered line (in chronological order) so that
63/// callers who want full cross-line accuracy can re-apply them.
64///
65/// Callers that do not need cross-line accuracy can use `.ops` directly,
66/// which behaves identically to the old `Vec<(usize, ScopeStackOp)>` return.
67#[derive(Debug, Clone, Default)]
68pub struct ParseLineOutput {
69    /// Ops for the current line.
70    pub ops: Vec<(usize, ScopeStackOp)>,
71    /// Ops for previously buffered lines that have now been corrected, in order.
72    /// Non-empty only when a cross-line `fail` just resolved.
73    pub replayed: Vec<Vec<(usize, ScopeStackOp)>>,
74    /// Warnings collected during parsing (e.g. branch point expiry).
75    pub warnings: Vec<String>,
76}
77
78#[derive(Debug, Clone, Eq, PartialEq)]
79pub struct ParseState {
80    stack: Vec<StateLevel>,
81    first_line: bool,
82    // See issue #101. Contains indices of frames pushed by `with_prototype`s.
83    // Doesn't look at `with_prototype`s below top of stack.
84    proto_starts: Vec<usize>,
85    /// Active branch points for backtracking support.
86    branch_points: Vec<BranchPoint>,
87    /// Line counter for 128-line branch point expiry.
88    line_number: usize,
89    /// Line strings buffered while branch points are active, for potential
90    /// cross-line `fail` replay. Only the strings are stored; the ops are
91    /// returned to callers immediately (same as before).
92    pending_lines: Vec<String>,
93    /// Corrected ops produced by a cross-line `fail` replay, to be returned
94    /// as `ParseLineOutput::replayed` at the end of `parse_line`.
95    flushed_ops: Vec<Vec<(usize, ScopeStackOp)>>,
96    /// Warnings accumulated during parsing, drained into `ParseLineOutput`.
97    warnings: Vec<String>,
98    /// Active escape patterns from embed operations. The escape regex takes
99    /// strict precedence over normal patterns — it is checked first and can
100    /// truncate the search region.
101    escape_stack: Vec<EscapeEntry>,
102}
103
104/// A resolved escape pattern from an `embed` operation, stored on the escape stack.
105#[derive(Debug, Clone, Eq, PartialEq)]
106struct EscapeEntry {
107    /// The resolved escape regex (backrefs substituted at push time).
108    regex: Regex,
109    /// Capture mapping for escape_captures scopes.
110    captures: Option<CaptureMapping>,
111    /// Stack depth at the time of the embed push — when escape fires,
112    /// pop down to this depth.
113    stack_depth: usize,
114}
115
116/// Snapshot of parser state at a branch point, used for backtracking.
117#[derive(Debug, Clone, Eq, PartialEq)]
118struct BranchPoint {
119    name: String,
120    /// Index of the next alternative to try (0 = first alt already tried).
121    next_alternative: usize,
122    alternatives: Vec<ContextReference>,
123    stack_snapshot: Vec<StateLevel>,
124    proto_starts_snapshot: Vec<usize>,
125    /// Character position to rewind to. Despite the name, this is the
126    /// branch match's *end* position — where the parser resumes from.
127    match_start: usize,
128    /// Real start of the branch_point match text. Together with
129    /// `match_start` (above, which is the match end) this bounds the
130    /// span on which `pat_scope` applies. Used to re-emit the keyword
131    /// scope — e.g. `keyword.operator.comparison.sql` on `LIKE` —
132    /// after a `fail` rewind, since the original Push/Pop pair was
133    /// truncated off `ops` along with `alt[0]`'s subsequent work.
134    trigger_match_start: usize,
135    /// Scopes declared on the branch_point match itself (re-emitted on
136    /// fail-retry over the [`trigger_match_start`, `match_start`) span).
137    pat_scope: Vec<Scope>,
138    /// Line number when the branch was created (for 128-line limit).
139    line_number: usize,
140    /// Length of ops vec at snapshot time — truncation point on fail.
141    ops_snapshot_len: usize,
142    /// Stack depth at creation — if stack shrinks below this, branch is invalid.
143    stack_depth: usize,
144    non_consuming_push_at_snapshot: (usize, usize),
145    first_line_snapshot: bool,
146    with_prototype: Option<ContextReference>,
147    /// `pending_lines.len()` at snapshot time, for cross-line replay truncation.
148    pending_lines_snapshot_len: usize,
149    escape_stack_snapshot: Vec<EscapeEntry>,
150    /// Number of contexts to pop before pushing the alternative (for pop + branch).
151    pop_count: usize,
152    /// Ops emitted on the branch-creation line before the branch match.
153    /// Used by cross-line fail replay to reconstruct the first buffered
154    /// line without re-parsing its pre-branch prefix under the new
155    /// alternative (which would misattribute pre-branch content to
156    /// rules of the new alternative — e.g. in multi-line SQL `LIKE …
157    /// ESCAPE …`, every non-whitespace before the `LIKE` fires
158    /// `else-pop` in the escape-alternative, derailing the stack).
159    prefix_ops: Vec<(usize, ScopeStackOp)>,
160    /// Capture Push/Pop ops emitted alongside the branch_point match's
161    /// `pat_scope`. Re-emitted on fail-retry between the pat_scope
162    /// Push and Pop so captures like `keyword.declaration.data.haskell`
163    /// on the first capture group of `(data)(?:\s+(family|instance))?`
164    /// survive a branch swap — without this, a `data CtxCls ctx => …`
165    /// (where `alt[0]` `data-signature` fails into `alt[1]` `data-context`)
166    /// drops the keyword scope from the `data` token.
167    capture_ops: Vec<(usize, ScopeStackOp)>,
168}
169
170#[derive(Debug, Clone, Eq, PartialEq)]
171struct StateLevel {
172    context: ContextId,
173    prototypes: Vec<ContextId>,
174    captures: Option<(Region, String)>,
175}
176
177#[derive(Debug)]
178struct RegexMatch<'a> {
179    regions: Region,
180    context: &'a Context,
181    pat_index: usize,
182    from_with_prototype: bool,
183    would_loop: bool,
184    /// For escape matches (pat_index == usize::MAX): index into escape_stack.
185    escape_index: usize,
186}
187
188/// Maps the pattern to the start index, which is -1 if not found.
189type SearchCache = HashMap<*const MatchPattern, Option<Region>, BuildHasherDefault<FnvHasher>>;
190
191/// Build the ordered Push/Pop ops for a match's `captures:` mapping over
192/// its regex `regions`. Captures can appear in arbitrary source order
193/// (e.g. `((bob)|(hi))*` matching `hibob` — the outer group must Push
194/// before any inner group). Empty captures are skipped because they'd
195/// otherwise sort a Pop before its Push. The returned ops are already
196/// position-ordered and safe to append to a parser ops vec.
197fn build_capture_ops(capture_map: &CaptureMapping, regions: &Region) -> Vec<(usize, ScopeStackOp)> {
198    let mut map: Vec<((usize, i32), ScopeStackOp)> = Vec::new();
199    for &(cap_index, ref scopes) in capture_map.iter() {
200        if let Some((cap_start, cap_end)) = regions.pos(cap_index) {
201            if cap_start == cap_end {
202                continue;
203            }
204            for scope in scopes.iter() {
205                map.push((
206                    (cap_start, -((cap_end - cap_start) as i32)),
207                    ScopeStackOp::Push(*scope),
208                ));
209            }
210            map.push(((cap_end, i32::MIN), ScopeStackOp::Pop(scopes.len())));
211        }
212    }
213    map.sort_by(|a, b| a.0.cmp(&b.0));
214    map.into_iter().map(|((i, _), op)| (i, op)).collect()
215}
216
217// To understand the implementation of this, here's an introduction to how
218// Sublime Text syntax definitions work.
219//
220// Let's say we have the following made-up syntax definition:
221//
222//     contexts:
223//       main:
224//         - match: A
225//           scope: scope.a.first
226//           push: context-a
227//         - match: b
228//           scope: scope.b
229//         - match: \w+
230//           scope: scope.other
231//       context-a:
232//         - match: a+
233//           scope: scope.a.rest
234//         - match: (?=.)
235//           pop: true
236//
237// There are two contexts, `main` and `context-a`. Each context contains a list
238// of match rules with instructions for how to proceed.
239//
240// Let's say we have the input string " Aaaabxxx". We start at position 0 in
241// the string. We keep a stack of contexts, which at the beginning is just main.
242//
243// So we start by looking at the top of the context stack (main), and look at
244// the rules in order. The rule that wins is the first one that matches
245// "earliest" in the input string. In our example:
246//
247// 1. The first one matches "A". Note that matches are not anchored, so this
248//    matches at position 1.
249// 2. The second one matches "b", so position 5. The first rule is winning.
250// 3. The third one matches "\w+", so also position 1. But because the first
251//    rule comes first, it wins.
252//
253// So now we execute the winning rule. Whenever we matched some text, we assign
254// the scope (if there is one) to the matched text and advance our position to
255// after the matched text. The scope is "scope.a.first" and our new position is
256// after the "A", so 2. The "push" means that we should change our stack by
257// pushing `context-a` on top of it.
258//
259// In the next step, we repeat the above, but now with the rules in `context-a`.
260// The result is that we match "a+" and assign "scope.a.rest" to "aaa", and our
261// new position is now after the "aaa". Note that there was no instruction for
262// changing the stack, so we stay in that context.
263//
264// In the next step, the first rule doesn't match anymore, so we go to the next
265// rule where "(?=.)" matches. The instruction is to "pop", which means we
266// pop the top of our context stack, which means we're now back in main.
267//
268// This time in main, we match "b", and in the next step we match the rest with
269// "\w+", and we're done.
270//
271//
272// ## Preventing loops
273//
274// These are the basics of how matching works. Now, you saw that you can write
275// patterns that result in an empty match and don't change the position. These
276// are called non-consuming matches. The problem with them is that they could
277// result in infinite loops. Let's look at a syntax where that is the case:
278//
279//     contexts:
280//       main:
281//         - match: (?=.)
282//           push: test
283//       test:
284//         - match: \w+
285//           scope: word
286//         - match: (?=.)
287//           pop: true
288//
289// This is a bit silly, but it's a minimal example for explaining how matching
290// works in that case.
291//
292// Let's say we have the input string " hello". In `main`, our rule matches and
293// we go into `test` and stay at position 0. Now, the best match is the rule
294// with "pop". But if we used that rule, we'd pop back to `main` and would still
295// be at the same position we started at! So this would be an infinite loop,
296// which we don't want.
297//
298// So what Sublime Text does in case a looping rule "won":
299//
300// * If there's another rule that matches at the same position and does not
301//   result in a loop, use that instead.
302// * Otherwise, go to the next position and go through all the rules in the
303//   current context again. Note that it means that the "pop" could again be the
304//   winning rule, but that's ok as it wouldn't result in a loop anymore.
305//
306// So in our input string, we'd skip one character and try to match the rules
307// again. This time, the "\w+" wins because it comes first.
308
309impl ParseState {
310    /// Creates a state from a syntax definition, keeping its own reference-counted point to the
311    /// main context of the syntax
312    pub fn new(syntax: &SyntaxReference) -> ParseState {
313        let start_state = StateLevel {
314            context: syntax.context_ids()["__start"],
315            prototypes: Vec::new(),
316            captures: None,
317        };
318        ParseState {
319            stack: vec![start_state],
320            first_line: true,
321            proto_starts: Vec::new(),
322            branch_points: Vec::new(),
323            line_number: 0,
324            pending_lines: Vec::new(),
325            flushed_ops: Vec::new(),
326            warnings: Vec::new(),
327            escape_stack: Vec::new(),
328        }
329    }
330
331    /// Parses a single line of the file. Because of the way regex engines work you unfortunately
332    /// have to pass in a single line contiguous in memory. This can be bad for really long lines.
333    /// Sublime Text avoids this by just not highlighting lines that are too long (thousands of characters).
334    ///
335    /// For efficiency reasons this returns only the changes to the current scope at each point in the line.
336    /// You can use [`ScopeStack::apply`] on each operation in succession to get the stack for a given point.
337    /// Look at the code in `highlighter.rs` for an example of doing this for highlighting purposes.
338    ///
339    /// The returned vector is in order both by index to apply at (the `usize`) and also by order to apply them at a
340    /// given index (e.g popping old scopes before pushing new scopes).
341    ///
342    /// The [`SyntaxSet`] has to be the one that contained the syntax that was used to construct
343    /// this [`ParseState`], or an extended version of it. Otherwise the parsing would return the
344    /// wrong result or even panic. The reason for this is that contexts within the [`SyntaxSet`]
345    /// are referenced via indexes.
346    ///
347    /// [`ScopeStack::apply`]: struct.ScopeStack.html#method.apply
348    /// [`SyntaxSet`]: struct.SyntaxSet.html
349    /// [`ParseState`]: struct.ParseState.html
350    pub fn parse_line(
351        &mut self,
352        line: &str,
353        syntax_set: &SyntaxSet,
354    ) -> Result<ParseLineOutput, ParsingError> {
355        if self.stack.is_empty() {
356            return Err(ParsingError::MissingMainContext);
357        }
358
359        // Prune branch points older than 128 lines
360        let cur_line = self.line_number;
361        let warnings = &mut self.warnings;
362        self.branch_points.retain(|bp| {
363            let alive = cur_line.saturating_sub(bp.line_number) <= 128;
364            if !alive {
365                warnings.push(format!(
366                    "branch point '{}' expired (exceeded 128-line rewind limit)",
367                    bp.name
368                ));
369            }
370            alive
371        });
372        self.line_number += 1;
373
374        let ops = self.parse_line_inner(line, syntax_set)?;
375
376        // Collect any corrected ops produced by a cross-line `fail` during the
377        // parse above.  These are stored by `handle_fail` in `self.flushed_ops`.
378        let replayed = std::mem::take(&mut self.flushed_ops);
379
380        // Keep the line string for potential future cross-line replay.
381        if !self.branch_points.is_empty() {
382            self.pending_lines.push(line.to_string());
383        } else {
384            // No active branch points: any buffered strings are stale.
385            self.pending_lines.clear();
386        }
387
388        let warnings = std::mem::take(&mut self.warnings);
389
390        Ok(ParseLineOutput {
391            ops,
392            replayed,
393            warnings,
394        })
395    }
396
397    /// Returns `true` when the parser is inside a `branch_point` and the
398    /// result of `parse_line` may be revised by a future `fail` action.
399    /// Once the branch resolves (or if no branch was entered), this returns
400    /// `false` and all ops emitted so far are final.
401    pub fn is_speculative(&self) -> bool {
402        !self.branch_points.is_empty()
403    }
404
405    /// Inner parsing loop: processes `line` with the current parser state and
406    /// returns the scope-stack operations.  Does **not** touch `pending_lines`
407    /// or `flushed_ops`, so it is safe to call recursively from `handle_fail`
408    /// for cross-line replay without re-entrancy issues.
409    fn parse_line_inner(
410        &mut self,
411        line: &str,
412        syntax_set: &SyntaxSet,
413    ) -> Result<Vec<(usize, ScopeStackOp)>, ParsingError> {
414        self.parse_line_inner_from(line, syntax_set, 0)
415    }
416
417    /// Parse `line` starting at `start_at` rather than column 0. Used by
418    /// cross-line `fail` replay: the first buffered line's pre-branch
419    /// prefix was correctly parsed under the pre-branch state, so the
420    /// replay resumes *after* the branch match under the new alternative.
421    /// When `start_at > 0` the `first_line` bookkeeping is skipped — the
422    /// caller has already emitted (or preserved) the initial
423    /// meta_content_scope push.
424    fn parse_line_inner_from(
425        &mut self,
426        line: &str,
427        syntax_set: &SyntaxSet,
428        start_at: usize,
429    ) -> Result<Vec<(usize, ScopeStackOp)>, ParsingError> {
430        let mut match_start = start_at;
431        let mut res = Vec::new();
432
433        if start_at == 0 && self.first_line {
434            let cur_level = &self.stack[self.stack.len() - 1];
435            let context = syntax_set.get_context(&cur_level.context)?;
436            if !context.meta_content_scope.is_empty() {
437                res.push((0, ScopeStackOp::Push(context.meta_content_scope[0])));
438            }
439            self.first_line = false;
440        }
441
442        let mut regions = Region::new();
443        let fnv = BuildHasherDefault::<FnvHasher>::default();
444        let mut search_cache: SearchCache = HashMap::with_capacity_and_hasher(128, fnv);
445        // Used for detecting loops with push/pop, see long comment above.
446        let mut non_consuming_push_at = (0, 0);
447
448        while self.parse_next_token(
449            line,
450            syntax_set,
451            &mut match_start,
452            &mut search_cache,
453            &mut regions,
454            &mut non_consuming_push_at,
455            &mut res,
456        )? {}
457
458        Ok(res)
459    }
460
461    #[allow(clippy::too_many_arguments)]
462    fn parse_next_token(
463        &mut self,
464        line: &str,
465        syntax_set: &SyntaxSet,
466        start: &mut usize,
467        search_cache: &mut SearchCache,
468        regions: &mut Region,
469        non_consuming_push_at: &mut (usize, usize),
470        ops: &mut Vec<(usize, ScopeStackOp)>,
471    ) -> Result<bool, ParsingError> {
472        let check_pop_loop = {
473            let (pos, stack_depth) = *non_consuming_push_at;
474            pos == *start && stack_depth == self.stack.len()
475        };
476
477        // Trim proto_starts that are no longer valid
478        while self
479            .proto_starts
480            .last()
481            .map(|start| *start >= self.stack.len())
482            .unwrap_or(false)
483        {
484            self.proto_starts.pop();
485        }
486
487        let best_match = self.find_best_match(
488            line,
489            *start,
490            syntax_set,
491            search_cache,
492            regions,
493            check_pop_loop,
494        )?;
495
496        if let Some(reg_match) = best_match {
497            // Check if this is an escape match (sentinel pat_index)
498            if reg_match.pat_index == usize::MAX {
499                let (match_start, match_end) = reg_match.regions.pos(0).unwrap();
500                *start = match_end;
501                self.exec_escape(
502                    reg_match.escape_index,
503                    match_start,
504                    match_end,
505                    &reg_match.regions,
506                    syntax_set,
507                    ops,
508                )?;
509                search_cache.clear();
510                return Ok(true);
511            }
512
513            if reg_match.would_loop {
514                // A push that doesn't consume anything (a regex that resulted
515                // in an empty match at the current position) can not be
516                // followed by a non-consuming pop. Otherwise we're back where
517                // we started and would try the same sequence of matches again,
518                // resulting in an infinite loop. In this case, Sublime Text
519                // advances one character and tries again, thus preventing the
520                // loop.
521
522                // println!("pop_would_loop for match {:?}, start {}", reg_match, *start);
523
524                // nth(1) gets the next character if there is one. Need to do
525                // this instead of just += 1 because we have byte indices and
526                // unicode characters can be more than 1 byte.
527                if let Some((i, _)) = line[*start..].char_indices().nth(1) {
528                    *start += i;
529                    return Ok(true);
530                } else {
531                    // End of line, no character to advance and no point trying
532                    // any more patterns.
533                    return Ok(false);
534                }
535            }
536
537            let match_end = reg_match.regions.pos(0).unwrap().1;
538
539            // Check if this is a Fail operation — handle before advancing start
540            let context = reg_match.context;
541            let match_pattern = context.match_at(reg_match.pat_index)?;
542            if let MatchOperation::Fail(_) = match_pattern.operation {
543                let level_context = {
544                    let id = &self.stack[self.stack.len() - 1].context;
545                    syntax_set.get_context(id)?
546                };
547                return self.exec_pattern(
548                    line,
549                    &reg_match,
550                    level_context,
551                    syntax_set,
552                    start,
553                    non_consuming_push_at,
554                    ops,
555                    search_cache,
556                );
557            }
558
559            let consuming = match_end > *start;
560            if !consuming {
561                // The match doesn't consume any characters. If this is a
562                // "push", remember the position and stack size so that we can
563                // check the next "pop" for loops. Otherwise leave the state,
564                // e.g. non-consuming "set" could also result in a loop.
565                if matches!(
566                    match_pattern.operation,
567                    MatchOperation::Push(_)
568                        | MatchOperation::Branch { .. }
569                        | MatchOperation::Embed { .. }
570                ) {
571                    *non_consuming_push_at = (match_end, self.stack.len() + 1);
572                }
573            }
574
575            *start = match_end;
576
577            // ignore `with_prototype`s below this if a context is pushed
578            if reg_match.from_with_prototype {
579                // use current height, since we're before the actual push
580                self.proto_starts.push(self.stack.len());
581            }
582
583            let level_context = {
584                let id = &self.stack[self.stack.len() - 1].context;
585                syntax_set.get_context(id)?
586            };
587            self.exec_pattern(
588                line,
589                &reg_match,
590                level_context,
591                syntax_set,
592                start,
593                non_consuming_push_at,
594                ops,
595                search_cache,
596            )?;
597
598            Ok(true)
599        } else {
600            Ok(false)
601        }
602    }
603
604    fn find_best_match<'a>(
605        &self,
606        line: &str,
607        start: usize,
608        syntax_set: &'a SyntaxSet,
609        search_cache: &mut SearchCache,
610        regions: &mut Region,
611        check_pop_loop: bool,
612    ) -> Result<Option<RegexMatch<'a>>, ParsingError> {
613        let cur_level = &self.stack[self.stack.len() - 1];
614        let context = syntax_set.get_context(&cur_level.context)?;
615        let prototype = if let Some(ref p) = context.prototype {
616            Some(p)
617        } else {
618            None
619        };
620
621        // Build an iterator for the contexts we want to visit in order
622        let context_chain = {
623            let proto_start = self.proto_starts.last().cloned().unwrap_or(0);
624            // Sublime applies with_prototypes from bottom to top
625            let with_prototypes = self.stack[proto_start..].iter().flat_map(|lvl| {
626                lvl.prototypes
627                    .iter()
628                    .map(move |ctx| (true, ctx, lvl.captures.as_ref()))
629            });
630            let cur_prototype = prototype.into_iter().map(|ctx| (false, ctx, None));
631            let cur_context =
632                Some((false, &cur_level.context, cur_level.captures.as_ref())).into_iter();
633            with_prototypes.chain(cur_prototype).chain(cur_context)
634        };
635
636        // println!("{:#?}", cur_level);
637        // println!("token at {} on {}", start, line.trim_right());
638
639        // Check escape patterns first — they take strict precedence.
640        // If an escape matches at `start`, return it immediately as a synthetic match.
641        // If it matches later, truncate the search region for normal patterns.
642        let mut search_end = line.len();
643        let mut escape_match: Option<(usize, Region)> = None; // (escape_stack_index, region)
644
645        for (ei, entry) in self.escape_stack.iter().enumerate() {
646            let mut esc_regions = Region::new();
647            if entry
648                .regex
649                .search(line, start, line.len(), Some(&mut esc_regions), true)
650            {
651                let (esc_start, _esc_end) = esc_regions.pos(0).unwrap();
652                if esc_start < search_end {
653                    search_end = esc_start;
654                    escape_match = Some((ei, esc_regions));
655                }
656            }
657        }
658
659        // If escape matches right at `start`, it wins immediately — no need to
660        // search normal patterns.
661        if let Some((ei, ref esc_region)) = escape_match {
662            let esc_start = esc_region.pos(0).unwrap().0;
663            if esc_start == start {
664                return Ok(Some(RegexMatch {
665                    regions: esc_region.clone(),
666                    context: syntax_set.get_context(&cur_level.context)?,
667                    pat_index: usize::MAX, // sentinel for escape match
668                    from_with_prototype: false,
669                    would_loop: false,
670                    escape_index: ei,
671                }));
672            }
673        }
674
675        let mut min_start = usize::MAX;
676        let mut best_match: Option<RegexMatch<'_>> = None;
677        let mut pop_would_loop = false;
678
679        for (from_with_proto, ctx, captures) in context_chain {
680            for (pat_context, pat_index) in context_iter(syntax_set, syntax_set.get_context(ctx)?) {
681                let match_pat = pat_context.match_at(pat_index)?;
682
683                if let Some(match_region) = self.search_with_end(
684                    line,
685                    start,
686                    search_end,
687                    match_pat,
688                    captures,
689                    search_cache,
690                    regions,
691                ) {
692                    let (match_start, match_end) = match_region.pos(0).unwrap();
693
694                    // println!("matched pattern {:?} at start {} end {} (pop would loop: {}, min start: {}, initial start: {}, check_pop_loop: {}, stack_len: {})", match_pat, match_start, match_end, pop_would_loop, min_start, start, check_pop_loop, self.stack.len());
695
696                    if match_start < min_start || (match_start == min_start && pop_would_loop) {
697                        // New match is earlier in text than old match,
698                        // or old match was a looping pop at the same
699                        // position.
700
701                        // println!("setting as current match");
702
703                        min_start = match_start;
704
705                        let consuming = match_end > start;
706                        // A non-consuming `pop: N` after a non-consuming push
707                        // only loops when N == 1 — that restores the exact
708                        // pre-push stack, so the push rule fires again. With
709                        // N >= 2 the stack drops strictly below the pre-push
710                        // depth, so the outer context no longer has the same
711                        // trigger in scope (e.g. Haskell's `immediately-pop2`
712                        // as the fallback branch alternative for
713                        // `declaration-type-end`).
714                        pop_would_loop = check_pop_loop
715                            && !consuming
716                            && matches!(match_pat.operation, MatchOperation::Pop(1));
717
718                        let push_too_deep = matches!(
719                            match_pat.operation,
720                            MatchOperation::Push(_)
721                                | MatchOperation::Branch { .. }
722                                | MatchOperation::Embed { .. }
723                        ) && self.stack.len() >= 100;
724
725                        if push_too_deep {
726                            return Ok(None);
727                        }
728
729                        best_match = Some(RegexMatch {
730                            regions: match_region,
731                            context: pat_context,
732                            pat_index,
733                            from_with_prototype: from_with_proto,
734                            would_loop: pop_would_loop,
735                            escape_index: 0, // not an escape match
736                        });
737
738                        if match_start == start && !pop_would_loop {
739                            // We're not gonna find a better match after this,
740                            // so as an optimization we can stop matching now.
741                            return Ok(best_match);
742                        }
743                    }
744                }
745            }
746        }
747
748        // If no normal match was found before the escape position, or escape
749        // position is earlier, use the escape match.
750        if let Some((ei, esc_region)) = escape_match {
751            let esc_start = esc_region.pos(0).unwrap().0;
752            if esc_start < min_start || (esc_start == min_start && pop_would_loop) {
753                return Ok(Some(RegexMatch {
754                    regions: esc_region,
755                    context: syntax_set.get_context(&cur_level.context)?,
756                    pat_index: usize::MAX, // sentinel for escape match
757                    from_with_prototype: false,
758                    would_loop: false,
759                    escape_index: ei,
760                }));
761            }
762        }
763
764        Ok(best_match)
765    }
766
767    fn search_with_end(
768        &self,
769        line: &str,
770        start: usize,
771        search_end: usize,
772        match_pat: &MatchPattern,
773        captures: Option<&(Region, String)>,
774        search_cache: &mut SearchCache,
775        regions: &mut Region,
776    ) -> Option<Region> {
777        // println!("{} - {:?} - {:?}", match_pat.regex_str, match_pat.has_captures, cur_level.captures.is_some());
778        let match_ptr = match_pat as *const MatchPattern;
779
780        if let Some(maybe_region) = search_cache.get(&match_ptr) {
781            if let Some(ref region) = *maybe_region {
782                let (cached_start, cached_end) = region.pos(0).unwrap();
783                if cached_start >= start && cached_end <= search_end {
784                    // Cached match is valid within the truncated region.
785                    return Some(region.clone());
786                } else if cached_start >= start && cached_start < search_end {
787                    // Match starts within range but extends past search_end.
788                    // Can't use cache — need to re-search. Fall through below.
789                } else if cached_start >= search_end {
790                    // Cached match is beyond our search end — treat as no match
791                    return None;
792                }
793                // cached_start < start: cache miss, re-search below
794            } else {
795                // Didn't find a match earlier, so no point trying to match it again
796                return None;
797            }
798        }
799
800        let (regex, can_cache) = match (match_pat.has_captures, captures) {
801            (true, Some(captures)) => {
802                let (region, s) = captures;
803                (&match_pat.regex_with_refs(region, s), false)
804            }
805            _ => (match_pat.regex(), true),
806        };
807        // Only `MatchOperation::None` patterns must avoid zero-length matches; every other
808        // operation legitimately needs them (lookaheads with branch/fail, empty patterns with
809        // pop/set, etc.). The regex engine handles this via its `FIND_NOT_EMPTY` option.
810        let allow_empty = !matches!(match_pat.operation, MatchOperation::None);
811        // print!("  executing regex: {:?} at pos {} on line {}", regex.regex_str(), start, line);
812        let matched = regex.search(line, start, search_end, Some(regions), allow_empty);
813
814        if matched {
815            let (match_start, match_end) = regions.pos(0).unwrap();
816            // this is necessary to avoid infinite looping on dumb patterns
817            let does_something = match match_pat.operation {
818                MatchOperation::None => match_start != match_end,
819                MatchOperation::Push(_)
820                | MatchOperation::Branch { .. }
821                | MatchOperation::Embed { .. } => self.stack.len() < 100,
822                _ => true,
823            };
824            if can_cache && does_something && search_end == line.len() {
825                // Only cache when searching the full line — truncated searches
826                // could give different results for later positions.
827                search_cache.insert(match_pat, Some(regions.clone()));
828            }
829            if does_something {
830                // print!("catch {} at {} on {}", match_pat.regex_str, match_start, line);
831                return Some(regions.clone());
832            }
833        } else if can_cache && search_end == line.len() {
834            search_cache.insert(match_pat, None);
835        }
836        None
837    }
838
839    /// Returns true if the stack was changed.
840    /// For `Fail` operations, returns `Ok(true)` if backtracking was performed
841    /// (caller should continue parsing from the rewound position).
842    fn exec_pattern<'a>(
843        &mut self,
844        line: &str,
845        reg_match: &RegexMatch<'a>,
846        level_context: &'a Context,
847        syntax_set: &'a SyntaxSet,
848        start: &mut usize,
849        non_consuming_push_at: &mut (usize, usize),
850        ops: &mut Vec<(usize, ScopeStackOp)>,
851        search_cache: &mut SearchCache,
852    ) -> Result<bool, ParsingError> {
853        let (match_start, match_end) = reg_match.regions.pos(0).unwrap();
854        let context = reg_match.context;
855        let pat = context.match_at(reg_match.pat_index)?;
856
857        // Handle Fail: attempt backtracking
858        if let MatchOperation::Fail(ref name) = pat.operation {
859            return self.handle_fail(
860                name,
861                line,
862                start,
863                non_consuming_push_at,
864                ops,
865                search_cache,
866                syntax_set,
867            );
868        }
869
870        // For Branch, we need to snapshot state before executing, then synthesize a Push.
871        let is_branch = matches!(pat.operation, MatchOperation::Branch { .. });
872        let synthetic_op;
873
874        if is_branch {
875            if let MatchOperation::Branch {
876                ref name,
877                ref alternatives,
878                pop_count,
879            } = pat.operation
880            {
881                // Snapshot current state.
882                //
883                // NOTE on field naming: `match_start` here stores the
884                // position the parser should *resume* from on fail —
885                // which is the branch match's end position (since the
886                // parser has already consumed the match). `match_end`
887                // and `pat_scope` carry the *real* match span plus the
888                // keyword's own scopes so a same-line fail rewind can
889                // re-emit them (they were truncated off `ops` along
890                // with the alt[0]'s subsequent work).
891                let bp = BranchPoint {
892                    name: name.clone(),
893                    next_alternative: 1, // 0 is about to be pushed
894                    alternatives: alternatives.clone(),
895                    stack_snapshot: self.stack.clone(),
896                    proto_starts_snapshot: self.proto_starts.clone(),
897                    match_start: *start, // position before this match's advance
898                    trigger_match_start: match_start,
899                    pat_scope: pat.scope.clone(),
900                    line_number: self.line_number.saturating_sub(1), // current line (already incremented)
901                    ops_snapshot_len: ops.len(),
902                    stack_depth: self.stack.len(),
903                    non_consuming_push_at_snapshot: *non_consuming_push_at,
904                    first_line_snapshot: self.first_line,
905                    with_prototype: pat.with_prototype.clone(),
906                    pending_lines_snapshot_len: self.pending_lines.len(),
907                    escape_stack_snapshot: self.escape_stack.clone(),
908                    pop_count,
909                    prefix_ops: ops.clone(),
910                    capture_ops: pat
911                        .captures
912                        .as_ref()
913                        .map(|m| build_capture_ops(m, &reg_match.regions))
914                        .unwrap_or_default(),
915                };
916                self.branch_points.push(bp);
917                // When pop_count > 0 (pop + branch), use Set semantics to
918                // pop the current context before pushing the first alternative.
919                synthetic_op = if pop_count > 0 {
920                    MatchOperation::Set {
921                        ctx_refs: vec![alternatives[0].clone()],
922                        pop_count,
923                    }
924                } else {
925                    MatchOperation::Push(vec![alternatives[0].clone()])
926                };
927            } else {
928                unreachable!()
929            }
930        } else {
931            synthetic_op = pat.operation.clone();
932        }
933
934        let op_to_use = if is_branch {
935            &synthetic_op
936        } else {
937            &pat.operation
938        };
939
940        self.push_meta_ops(true, match_start, level_context, op_to_use, syntax_set, ops)?;
941        for s in &pat.scope {
942            ops.push((match_start, ScopeStackOp::Push(*s)));
943        }
944        let capture_ops = pat
945            .captures
946            .as_ref()
947            .map(|m| build_capture_ops(m, &reg_match.regions))
948            .unwrap_or_default();
949        ops.extend(capture_ops.iter().cloned());
950        if !pat.scope.is_empty() {
951            ops.push((match_end, ScopeStackOp::Pop(pat.scope.len())));
952        }
953        self.push_meta_ops(false, match_end, level_context, op_to_use, syntax_set, ops)?;
954
955        if is_branch {
956            // Execute the synthetic Push through perform_op
957            let synthetic_pat = MatchPattern::new(
958                pat.has_captures,
959                pat.regex.regex_str().to_string(),
960                pat.scope.clone(),
961                pat.captures.clone(),
962                synthetic_op,
963                pat.with_prototype.clone(),
964            );
965            self.perform_op(line, &reg_match.regions, &synthetic_pat, syntax_set)
966        } else {
967            self.perform_op(line, &reg_match.regions, pat, syntax_set)
968        }
969    }
970
971    /// Handle a `fail` operation by rewinding to the named branch point.
972    /// Returns Ok(true) if backtracking happened (caller should continue from rewound position).
973    /// Returns Ok(false) if the fail had no effect.
974    fn handle_fail(
975        &mut self,
976        name: &str,
977        line: &str,
978        start: &mut usize,
979        non_consuming_push_at: &mut (usize, usize),
980        ops: &mut Vec<(usize, ScopeStackOp)>,
981        search_cache: &mut SearchCache,
982        syntax_set: &SyntaxSet,
983    ) -> Result<bool, ParsingError> {
984        // Find the branch point by name (most recent first), skipping
985        // records whose alternative's pushed frame is no longer on
986        // the stack. The alternative lives at
987        // `bp.stack_depth - bp.pop_count + 1`, so `stack.len() >
988        // bp.stack_depth - bp.pop_count` means the frame is still
989        // present. Without this skip, a nested `branch_point` with
990        // the same name whose inner alternative popped cleanly would
991        // shadow an enclosing record via `rposition`, rewinding to
992        // the inner branch position instead of the outer one
993        // (Haskell's raw-string QQ `[r|[a-zA-Z]|]`).
994        let stack_len = self.stack.len();
995        let bp_index = self.branch_points.iter().rposition(|bp| {
996            bp.name == name && stack_len > bp.stack_depth.saturating_sub(bp.pop_count)
997        });
998        let bp_index = match bp_index {
999            Some(i) => i,
1000            None => return Ok(false), // No such branch point, fail is no-op
1001        };
1002
1003        let cur_line = self.line_number.saturating_sub(1);
1004        let bp = &self.branch_points[bp_index];
1005
1006        // Check validity: not >128 lines old
1007        if cur_line.saturating_sub(bp.line_number) > 128 {
1008            let bp = self.branch_points.remove(bp_index);
1009            self.warnings.push(format!(
1010                "branch point '{}' expired (exceeded 128-line rewind limit)",
1011                bp.name
1012            ));
1013            return Ok(false);
1014        }
1015
1016        // Check validity: stack depth still >= branch's stack_depth
1017        if self.stack.len() < bp.stack_depth {
1018            self.branch_points.remove(bp_index);
1019            return Ok(false);
1020        }
1021
1022        // Check if there are more alternatives
1023        if bp.next_alternative >= bp.alternatives.len() {
1024            // All alternatives exhausted: restore parser state to the
1025            // pre-branch snapshot so the stuck alternative's pushed
1026            // contexts and emitted ops are discarded, then advance
1027            // past the branch_point match position by one character so
1028            // we don't immediately re-enter the same branch_point and
1029            // loop. Before this, the branch_point was silently removed
1030            // while its last alternative's contexts remained on the
1031            // stack — the cause of the "scope stack stays in
1032            // `meta.interpolation.brace.shell`" cascade in Zsh when
1033            // both `brace-interpolation-sequence` and
1034            // `brace-interpolation-series` failed and there was no
1035            // fallback alternative (Zsh explicitly excludes
1036            // `brace-interpolation-fallback`).
1037            //
1038            // Cross-line exhaustion takes the same shape, plus a replay
1039            // of the buffered lines under the pre-branch state so
1040            // callers see corrected ops for lines they've already been
1041            // handed. Without this, the unterminated TypeScript type
1042            // expression at `sublimehq/Packages#3598`
1043            // (`type x = { bar: (cb: (\n};`) left the inner
1044            // `ts-type-function-parameter-list-body` on the stack
1045            // forever, contaminating every subsequent line's scope
1046            // stack with `meta.type.js, meta.group.js` — 274 cascading
1047            // assertion failures in `syntax_test_typescript.ts`.
1048            let is_cross_line = bp.line_number < cur_line;
1049            let stack_snapshot = bp.stack_snapshot.clone();
1050            let proto_starts_snapshot = bp.proto_starts_snapshot.clone();
1051            let escape_stack_snapshot = bp.escape_stack_snapshot.clone();
1052            let first_line_snapshot = bp.first_line_snapshot;
1053            let non_consuming_push_at_snapshot = bp.non_consuming_push_at_snapshot;
1054            let ops_snapshot_len = bp.ops_snapshot_len;
1055            let match_start_pos = bp.match_start;
1056            let pending_lines_snapshot_len = bp.pending_lines_snapshot_len;
1057            let prefix_ops = bp.prefix_ops.clone();
1058            self.branch_points.remove(bp_index);
1059
1060            self.stack = stack_snapshot;
1061            self.proto_starts = proto_starts_snapshot;
1062            self.escape_stack = escape_stack_snapshot;
1063            self.first_line = first_line_snapshot;
1064            *non_consuming_push_at = non_consuming_push_at_snapshot;
1065            ops.truncate(ops_snapshot_len.min(ops.len()));
1066
1067            if is_cross_line {
1068                // Re-parse each buffered line under the restored (pre-branch)
1069                // state so `parse_line` can surface the corrected ops via
1070                // `ParseLineOutput::replayed`. The first buffered line is the
1071                // branch-creation line: emit its saved `prefix_ops` (the ops
1072                // emitted before the branch match) verbatim, then advance past
1073                // the branch match by one character before resuming — otherwise
1074                // the same branch_point would fire again at the original match
1075                // position and we'd loop.
1076                //
1077                // Keep `pending_lines` intact (don't drain): if an outer
1078                // branch_point on this same line also fails after this
1079                // exhaustion replay, its own replay needs access to the same
1080                // buffered lines.
1081                let truncated_lines: Vec<String> =
1082                    self.pending_lines[pending_lines_snapshot_len..].to_vec();
1083                let mut replayed_ops: Vec<Vec<(usize, ScopeStackOp)>> =
1084                    Vec::with_capacity(truncated_lines.len());
1085                for (i, replay_line) in truncated_lines.iter().enumerate() {
1086                    let line_ops = if i == 0 {
1087                        let mut first_line_ops = prefix_ops.clone();
1088                        let resume_at = if let Some((j, _)) =
1089                            replay_line[match_start_pos..].char_indices().nth(1)
1090                        {
1091                            match_start_pos + j
1092                        } else {
1093                            replay_line.len()
1094                        };
1095                        let tail_ops =
1096                            self.parse_line_inner_from(replay_line, syntax_set, resume_at)?;
1097                        first_line_ops.extend(tail_ops);
1098                        first_line_ops
1099                    } else {
1100                        self.parse_line_inner(replay_line, syntax_set)?
1101                    };
1102                    replayed_ops.push(line_ops);
1103                }
1104                self.flushed_ops.extend(replayed_ops);
1105
1106                // Restart the current line from the beginning under the
1107                // restored state.
1108                ops.clear();
1109                *start = 0;
1110                *non_consuming_push_at = (0, 0);
1111                search_cache.clear();
1112                return Ok(true);
1113            }
1114
1115            // Same-line exhaustion: advance one char past the branch_point match
1116            // to avoid immediately re-matching the same `(?=...)` lookahead.
1117            if let Some((i, _)) = line[match_start_pos..].char_indices().nth(1) {
1118                *start = match_start_pos + i;
1119            } else {
1120                // End of line — no character to advance past.
1121                *start = line.len();
1122            }
1123            search_cache.clear();
1124            return Ok(true);
1125        }
1126
1127        // Determine if this is a cross-line fail (branch was created on a previous line).
1128        let is_cross_line = bp.line_number < cur_line;
1129
1130        // Extract everything we need from bp before mutating self.
1131        let next_alt_index = bp.next_alternative;
1132        let next_alt = bp.alternatives[next_alt_index].clone();
1133        let match_start_pos = bp.match_start;
1134        let trigger_match_start = bp.trigger_match_start;
1135        let trigger_pat_scope = bp.pat_scope.clone();
1136        let trigger_capture_ops = bp.capture_ops.clone();
1137        let stack_snapshot = bp.stack_snapshot.clone();
1138        let proto_starts_snapshot = bp.proto_starts_snapshot.clone();
1139        let first_line_snapshot = bp.first_line_snapshot;
1140        let non_consuming_push_at_snapshot = bp.non_consuming_push_at_snapshot;
1141        let ops_snapshot_len = bp.ops_snapshot_len;
1142        let pending_lines_snapshot_len = bp.pending_lines_snapshot_len;
1143        let escape_stack_snapshot = bp.escape_stack_snapshot.clone();
1144        let prefix_ops = bp.prefix_ops.clone();
1145        // bp borrow ends here.
1146
1147        let pop_count = self.branch_points[bp_index].pop_count;
1148
1149        // Restore parser state to the snapshot.
1150        self.stack = stack_snapshot;
1151        self.proto_starts = proto_starts_snapshot;
1152        self.escape_stack = escape_stack_snapshot;
1153        self.first_line = first_line_snapshot;
1154        *non_consuming_push_at = non_consuming_push_at_snapshot;
1155
1156        // Update the branch point record before popping/pushing
1157        // (must happen before the pop which may invalidate indices).
1158        self.branch_points[bp_index].next_alternative = next_alt_index + 1;
1159
1160        // For pop + branch: re-pop the contexts (snapshot was taken pre-pop).
1161        if pop_count > 0 {
1162            for _ in 0..pop_count {
1163                self.stack.pop();
1164            }
1165        }
1166
1167        // Push the next alternative onto the stack.
1168        let with_prototype = self.branch_points[bp_index].with_prototype.clone();
1169        let context_id = next_alt.id()?;
1170        let context = syntax_set.get_context(&context_id)?;
1171        let captures = None; // no captures available at rewind time
1172
1173        let proto_ids = match with_prototype {
1174            Some(ref p) => vec![p.id()?],
1175            None => Vec::new(),
1176        };
1177
1178        self.stack.push(StateLevel {
1179            context: context_id,
1180            prototypes: proto_ids,
1181            captures,
1182        });
1183
1184        if is_cross_line {
1185            // Cross-line fail: the ops for lines since the branch was created
1186            // have already been returned to callers.  Re-parse those lines under
1187            // the new alternative and store the corrected ops in `flushed_ops`
1188            // so that `parse_line` can surface them via `ParseLineOutput::replayed`.
1189            //
1190            // The first buffered line is the branch-creation line. Its
1191            // pre-branch prefix (cols 0..trigger_match_start) was correctly
1192            // parsed under the *pre-branch* state — not the new alternative.
1193            // Re-parsing it from column 0 with the new alternative on the
1194            // stack would misattribute that prefix to the new alternative's
1195            // rules (observed on multi-line SQL `LIKE … ESCAPE …`: every
1196            // non-whitespace before `LIKE` fires `else-pop` in the
1197            // escape-alternative, derailing the stack). Instead, reuse the
1198            // prefix_ops saved at branch-creation time, manually emit the
1199            // branch trigger's pat.scope and the new alternative's meta
1200            // scope ops, then resume parsing from match_end with the new
1201            // alternative on the stack via `parse_line_inner_from`.
1202            // Keep `pending_lines` intact (don't drain): if a second branch_point
1203            // on the current line also fails after this retry, its own replay
1204            // needs access to the same buffered lines. Nested branches from the
1205            // same earlier line share the buffer.
1206            let truncated_lines: Vec<String> =
1207                self.pending_lines[pending_lines_snapshot_len..].to_vec();
1208
1209            let mut replayed_ops: Vec<Vec<(usize, ScopeStackOp)>> =
1210                Vec::with_capacity(truncated_lines.len());
1211            for (i, replay_line) in truncated_lines.iter().enumerate() {
1212                let line_ops = if i == 0 {
1213                    // First buffered line: compose prefix + branch ops + resume.
1214                    let mut first_line_ops = prefix_ops.clone();
1215                    // Re-emit the trigger's pat.scope and the new
1216                    // alternative's meta scope ops in the same order
1217                    // the non-fail push path uses: clear_scopes and
1218                    // meta_scope at `trigger_match_start` (so the
1219                    // matched text sees them), then pat.scope at the
1220                    // same position, popped at `match_start_pos`.
1221                    // meta_content_scope only applies after the
1222                    // matched text, so it lands at `match_start_pos`.
1223                    if let Some(clear_amount) = context.clear_scopes {
1224                        first_line_ops
1225                            .push((trigger_match_start, ScopeStackOp::Clear(clear_amount)));
1226                    }
1227                    for scope in context.meta_scope.iter() {
1228                        first_line_ops.push((trigger_match_start, ScopeStackOp::Push(*scope)));
1229                    }
1230                    for scope in &trigger_pat_scope {
1231                        first_line_ops.push((trigger_match_start, ScopeStackOp::Push(*scope)));
1232                    }
1233                    // See matching comment in the same-line branch below —
1234                    // re-emit the trigger match's captures inside the
1235                    // pat_scope brackets so they survive the branch swap.
1236                    first_line_ops.extend(trigger_capture_ops.iter().cloned());
1237                    if !trigger_pat_scope.is_empty() {
1238                        first_line_ops
1239                            .push((match_start_pos, ScopeStackOp::Pop(trigger_pat_scope.len())));
1240                    }
1241                    for scope in context.meta_content_scope.iter() {
1242                        first_line_ops.push((match_start_pos, ScopeStackOp::Push(*scope)));
1243                    }
1244                    // Resume parsing from the branch match's end position.
1245                    let tail_ops =
1246                        self.parse_line_inner_from(replay_line, syntax_set, match_start_pos)?;
1247                    first_line_ops.extend(tail_ops);
1248                    first_line_ops
1249                } else {
1250                    self.parse_line_inner(replay_line, syntax_set)?
1251                };
1252                replayed_ops.push(line_ops);
1253            }
1254            // Append (rather than overwrite) in case multiple cross-line fails
1255            // fire on the same parse_line call.
1256            self.flushed_ops.extend(replayed_ops);
1257
1258            // Restart the current line from the beginning.
1259            ops.clear();
1260            *start = 0;
1261            *non_consuming_push_at = (0, 0);
1262
1263            // Guard: the replayed `parse_line_inner` calls above can
1264            // mutate `self.branch_points` (adding new branches,
1265            // removing expired or exhausted ones), which can shift or
1266            // invalidate `bp_index`. Indexing with the stale position
1267            // previously panicked outright on files that exercise
1268            // nested cross-line branching (observed on
1269            // `JavaScript/syntax_test_js.js` and
1270            // `syntax_test_typescript.ts`). Skip the bookkeeping if
1271            // the branch point has been removed — the replay already
1272            // completed, which is the essential work of the fail.
1273            if bp_index < self.branch_points.len() {
1274                self.branch_points[bp_index].ops_snapshot_len = 0;
1275            }
1276        } else {
1277            // Same-line fail: truncate ops back to the snapshot point and rewind.
1278            ops.truncate(ops_snapshot_len.min(ops.len()));
1279            *start = match_start_pos;
1280
1281            // Keep `ops_snapshot_len` pointing at the pre-branch state.
1282            // Subsequent fails on the same branch_point must truncate
1283            // back to *here* — not to the position after the pat.scope
1284            // re-emit below — otherwise a second fail would preserve
1285            // the first re-emit's (Push at trigger_match_start) while
1286            // appending another re-emit, producing the disordered
1287            // sequence (trigger, Push), (match_end, Pop), (trigger,
1288            // Push), (match_end, Pop). `ScopeRegionIterator` then
1289            // panics in `easy.rs` because position goes backwards.
1290            self.branch_points[bp_index].ops_snapshot_len = ops.len();
1291
1292            // Re-emit the branch_point match's own scopes over their
1293            // original span. Without this, keywords that trigger a
1294            // branch (e.g. `LIKE` with
1295            // `scope: keyword.operator.comparison.sql`) lose their
1296            // scope whenever alt[0] fails and alt[1..] succeeds,
1297            // because the original Push/Pop pair was truncated off
1298            // `ops` together with alt[0]'s subsequent work.
1299            //
1300            // The new alternative's `clear_scopes` and `meta_scope`
1301            // are emitted at `trigger_match_start` *before* the
1302            // trigger's `pat.scope`, mirroring the non-fail push path
1303            // in `push_meta_ops` (initial phase): meta_scope sits
1304            // below the match scope on the stack so the matched text
1305            // sees both. Placing them at `match_start_pos` would mean
1306            // the trigger character (e.g. `(` of `for (var i = 0; …)`)
1307            // never sees the alternative's `meta_scope`. The
1308            // `meta_content_scope` legitimately stays at
1309            // `match_start_pos` — mcs only applies after the matched
1310            // text.
1311            if let Some(clear_amount) = context.clear_scopes {
1312                ops.push((trigger_match_start, ScopeStackOp::Clear(clear_amount)));
1313            }
1314            for scope in context.meta_scope.iter() {
1315                ops.push((trigger_match_start, ScopeStackOp::Push(*scope)));
1316            }
1317            for scope in &trigger_pat_scope {
1318                ops.push((trigger_match_start, ScopeStackOp::Push(*scope)));
1319            }
1320            // Captures emitted alongside the original pat.scope (e.g.
1321            // `keyword.declaration.data.haskell` on the first capture of
1322            // `(data)(?:\s+(family|instance))?`) were truncated off with
1323            // alt[0]'s ops. Re-emit them inside the pat_scope brackets so
1324            // the keyword scope survives the branch swap.
1325            ops.extend(trigger_capture_ops.iter().cloned());
1326            if !trigger_pat_scope.is_empty() {
1327                ops.push((match_start_pos, ScopeStackOp::Pop(trigger_pat_scope.len())));
1328            }
1329            for scope in context.meta_content_scope.iter() {
1330                ops.push((match_start_pos, ScopeStackOp::Push(*scope)));
1331            }
1332        }
1333
1334        // Clear search cache since we're rewinding.
1335        search_cache.clear();
1336
1337        Ok(true)
1338    }
1339
1340    /// Get the syntax version for the current parse state
1341    fn current_syntax_version(&self, syntax_set: &SyntaxSet) -> u32 {
1342        if let Some(level) = self.stack.last() {
1343            let syntax_index = level.context.syntax_index;
1344            syntax_set
1345                .syntaxes()
1346                .get(syntax_index)
1347                .map_or(1, |s| s.version)
1348        } else {
1349            1
1350        }
1351    }
1352
1353    fn push_meta_ops(
1354        &self,
1355        initial: bool,
1356        index: usize,
1357        cur_context: &Context,
1358        match_op: &MatchOperation,
1359        syntax_set: &SyntaxSet,
1360        ops: &mut Vec<(usize, ScopeStackOp)>,
1361    ) -> Result<(), ParsingError> {
1362        let version = self.current_syntax_version(syntax_set);
1363        // println!("metas ops for {:?}, initial: {}",
1364        //          match_op,
1365        //          initial);
1366        // println!("{:?}", cur_context.meta_scope);
1367        match *match_op {
1368            MatchOperation::Pop(n) => {
1369                // For `pop: N` with N > 1, every context being popped
1370                // contributes scope atoms on the scope stack that must
1371                // be unwound in LIFO order. The TOP context's trigger
1372                // text must not see its own `meta_content_scope`, so
1373                // that one is popped in the initial phase; all other
1374                // scope unwinding (top context's `meta_scope`, then
1375                // each deeper context's `meta_content_scope` followed
1376                // by its `meta_scope`) happens in the non-initial
1377                // phase, immediately after the match text's own
1378                // scope has been popped.
1379                //
1380                // Before this fix only the top context's scopes were
1381                // ever popped, leaving the N-1 deeper contexts'
1382                // `meta_scope` / `meta_content_scope` atoms orphaned —
1383                // the cause of the "scope stack grows unboundedly"
1384                // cascade in Makefile and Zsh (Category A).
1385                let stack_len = self.stack.len();
1386                let pop_count = n.min(stack_len);
1387                if initial {
1388                    // v2: if the context immediately below the top has
1389                    // embed_scope_replaces, cur_context's meta_content_scope
1390                    // was never pushed, so don't generate a Pop for it.
1391                    let skip = version >= 2
1392                        && stack_len >= 2
1393                        && syntax_set
1394                            .get_context(&self.stack[stack_len - 2].context)
1395                            .map(|c| c.embed_scope_replaces)
1396                            .unwrap_or(false);
1397                    if !skip && !cur_context.meta_content_scope.is_empty() {
1398                        ops.push((
1399                            index,
1400                            ScopeStackOp::Pop(cur_context.meta_content_scope.len()),
1401                        ));
1402                    }
1403                } else {
1404                    // Top context's meta_scope comes off first (it sat
1405                    // immediately below the trigger text's scope on the
1406                    // stack).
1407                    if !cur_context.meta_scope.is_empty() {
1408                        ops.push((index, ScopeStackOp::Pop(cur_context.meta_scope.len())));
1409                    }
1410                    // Each deeper context's scopes are popped in
1411                    // top-to-bottom order: meta_content_scope first
1412                    // (pushed after its own meta_scope, hence above on
1413                    // the stack), then meta_scope, then any Restore
1414                    // paired with that context's own `clear_scopes`
1415                    // (mirrors the push order Clear → meta_scope → mcs
1416                    // in reverse). Without the Restore, a `pop: N`
1417                    // from the top that also unwinds a deeper context
1418                    // with `clear_scopes` leaves the originally cleared
1419                    // atoms orphaned — observed on Python f/t-string
1420                    // interpolation close, where `f-string-replacement-end`
1421                    // fires `pop: 2` that also pops
1422                    // `f-string-replacement-meta`.
1423                    for depth in 1..pop_count {
1424                        let level_idx = stack_len - 1 - depth;
1425                        let ctx = syntax_set.get_context(&self.stack[level_idx].context)?;
1426                        let skip_content = version >= 2
1427                            && level_idx >= 1
1428                            && syntax_set
1429                                .get_context(&self.stack[level_idx - 1].context)
1430                                .map(|c| c.embed_scope_replaces)
1431                                .unwrap_or(false);
1432                        if !skip_content && !ctx.meta_content_scope.is_empty() {
1433                            ops.push((index, ScopeStackOp::Pop(ctx.meta_content_scope.len())));
1434                        }
1435                        if !ctx.meta_scope.is_empty() {
1436                            ops.push((index, ScopeStackOp::Pop(ctx.meta_scope.len())));
1437                        }
1438                        if ctx.clear_scopes.is_some() {
1439                            ops.push((index, ScopeStackOp::Restore));
1440                        }
1441                    }
1442                }
1443
1444                // cleared scopes are restored after the scopes from match pattern that invoked the pop are applied
1445                if !initial && cur_context.clear_scopes.is_some() {
1446                    ops.push((index, ScopeStackOp::Restore))
1447                }
1448            }
1449            // for some reason the ST3 behaviour of set is convoluted and is inconsistent with the docs and other ops
1450            // - the meta_content_scope of the current context is applied to the matched thing, unlike pop
1451            // - the clear_scopes are applied after the matched token, unlike push
1452            // - the interaction with meta scopes means that the token has the meta scopes of both the current scope and the new scope.
1453            MatchOperation::Push(ref context_refs)
1454            | MatchOperation::Set {
1455                ctx_refs: ref context_refs,
1456                ..
1457            } => {
1458                let is_set = matches!(*match_op, MatchOperation::Set { .. });
1459                let set_pop_count = match *match_op {
1460                    MatchOperation::Set { pop_count, .. } => pop_count.max(1),
1461                    _ => 1,
1462                };
1463                // a match pattern that "set"s keeps the meta_content_scope and meta_scope from the previous context
1464                if initial {
1465                    // v2: pop parent's meta_content_scope so matched text does not see it
1466                    if is_set && version >= 2 && !cur_context.meta_content_scope.is_empty() {
1467                        ops.push((
1468                            index,
1469                            ScopeStackOp::Pop(cur_context.meta_content_scope.len()),
1470                        ));
1471                    }
1472                    // NOTE: cur_context.clear_scopes Restore is emitted in the
1473                    // non-initial phase below, AFTER Pop(cur.meta_scope + target.meta_scope)
1474                    // has run. Restoring here (pre-match) would place the cleared
1475                    // scopes on top of the stack above the target.meta_scope push,
1476                    // and the non-initial Pop would then remove the restored scopes
1477                    // instead of the intended meta_scopes — dropping cur's cleared
1478                    // state on the floor. Observed as duplicate
1479                    // `meta.mapping.value.json` atoms in nested JSON objects.
1480                    // add each context's meta scope
1481                    if version >= 2 {
1482                        // Push: emit Clear for every pushed context that has
1483                        // `clear_scopes`, at its own index position in the
1484                        // push order — same as v1. Sublime permits at most
1485                        // one `clear_scopes` per push list, but when it sits
1486                        // on a non-topmost entry (e.g. Python's
1487                        // `f-string-replacement-meta` at index 0 of a
1488                        // 3-context push) restricting to `i == last_idx`
1489                        // silently drops it, leaking the parent's
1490                        // `meta_content_scope` atoms into interpolation
1491                        // content.
1492                        //
1493                        // Single-context `set:` with clear_scopes on the
1494                        // target: emit Clear here — before target.meta_scope
1495                        // is pushed and before the trigger match scope — so
1496                        // the matched text sees the cleared stack.
1497                        // Observed on Lisp's `(defun fn (...)`: the
1498                        // parameter-list `(` otherwise kept the enclosing
1499                        // `meta.function.lisp` alongside
1500                        // `meta.function.parameters.lisp` because Clear
1501                        // fired only after the match in the non-initial
1502                        // phase. See
1503                        // `v2_set_to_target_with_clear_scopes_clears_parent_meta_content_scope`.
1504                        //
1505                        // Multi-context `set:` keeps Clear in the non-initial
1506                        // phase (emitted inline after preceding contexts'
1507                        // mcs pushes). Moving it to the initial phase here
1508                        // would strip atoms from below the outer mcs rather
1509                        // than from the top of the just-pushed inner mcs
1510                        // stack — Makefile's `set: [value-to-be-defined,
1511                        // eat-whitespace-then-pop]` relies on Clear eating
1512                        // the last-pushed mcs atom, which Restore then
1513                        // replaces when eat-whitespace-then-pop pops.
1514                        let single_context_set_clear = is_set && context_refs.len() == 1;
1515                        for r in context_refs.iter() {
1516                            let ctx = r.resolve(syntax_set)?;
1517
1518                            let emit_clear_here = !is_set || single_context_set_clear;
1519                            if emit_clear_here {
1520                                if let Some(clear_amount) = ctx.clear_scopes {
1521                                    ops.push((index, ScopeStackOp::Clear(clear_amount)));
1522                                }
1523                            }
1524
1525                            for scope in ctx.meta_scope.iter() {
1526                                ops.push((index, ScopeStackOp::Push(*scope)));
1527                            }
1528                        }
1529                    } else {
1530                        for r in context_refs.iter() {
1531                            let ctx = r.resolve(syntax_set)?;
1532
1533                            if !is_set {
1534                                if let Some(clear_amount) = ctx.clear_scopes {
1535                                    ops.push((index, ScopeStackOp::Clear(clear_amount)));
1536                                }
1537                            }
1538
1539                            for scope in ctx.meta_scope.iter() {
1540                                ops.push((index, ScopeStackOp::Push(*scope)));
1541                            }
1542                        }
1543                    }
1544                } else {
1545                    // `pop: N + set:` (set_pop_count > 1) unwinds N-1 deeper
1546                    // contexts in addition to the usual set-replace semantics;
1547                    // their meta_scope / meta_content_scope atoms sitting on
1548                    // the scope stack must be popped off, so force repush to
1549                    // fire even if the immediate contexts had no mcs/ms.
1550                    let repush = (is_set
1551                        && (set_pop_count > 1
1552                            || !cur_context.meta_scope.is_empty()
1553                            || !cur_context.meta_content_scope.is_empty()
1554                            // cur has clear_scopes but no meta_scope/mcs: we still
1555                            // need to Pop the target.meta_scope pushed in initial,
1556                            // Restore cur.clear_scopes, and re-push target.meta_scope
1557                            // + target.meta_content_scope in the correct order.
1558                            || cur_context.clear_scopes.is_some()))
1559                        || context_refs.iter().any(|r| {
1560                            let ctx = r.resolve(syntax_set).unwrap();
1561
1562                            !ctx.meta_content_scope.is_empty()
1563                                || (ctx.clear_scopes.is_some() && is_set)
1564                        });
1565                    if repush {
1566                        // remove previously pushed meta scopes, so that meta content scopes will be applied in the correct order
1567                        let mut num_to_pop: usize = context_refs
1568                            .iter()
1569                            .map(|r| {
1570                                let ctx = r.resolve(syntax_set).unwrap();
1571                                ctx.meta_scope.len()
1572                            })
1573                            .sum();
1574
1575                        // also pop off the original context's meta scopes
1576                        if is_set {
1577                            if version >= 2 {
1578                                // v2: set excludes parent meta_content_scope from matched text
1579                                num_to_pop += cur_context.meta_scope.len();
1580                            } else {
1581                                num_to_pop += cur_context.meta_content_scope.len()
1582                                    + cur_context.meta_scope.len();
1583                            }
1584                            // For `pop: N + set:` (set_pop_count > 1), also
1585                            // unwind the N-1 deeper contexts' scope atoms. They
1586                            // were pushed when those contexts entered; without
1587                            // this they'd linger on the stack under the new
1588                            // target's meta_scope.
1589                            if set_pop_count > 1 {
1590                                let stack_len = self.stack.len();
1591                                for depth in 1..set_pop_count.min(stack_len) {
1592                                    let level_idx = stack_len - 1 - depth;
1593                                    let ctx =
1594                                        syntax_set.get_context(&self.stack[level_idx].context)?;
1595                                    num_to_pop +=
1596                                        ctx.meta_content_scope.len() + ctx.meta_scope.len();
1597                                }
1598                            }
1599                        }
1600
1601                        // do all the popping as one operation
1602                        if num_to_pop > 0 {
1603                            ops.push((index, ScopeStackOp::Pop(num_to_pop)));
1604                        }
1605
1606                        // Restore scopes cleared by the leaving context, now that
1607                        // cur.meta_scope and the initial phase's target.meta_scope
1608                        // push have been popped off. The restored atoms land below
1609                        // the target's upcoming meta_scope / meta_content_scope push.
1610                        if is_set && cur_context.clear_scopes.is_some() {
1611                            ops.push((index, ScopeStackOp::Restore));
1612                        }
1613
1614                        // now we push meta scope and meta context scope for each context pushed
1615                        if version >= 2 {
1616                            // v2: For multi-context `set:`, Clear is emitted
1617                            // here so it strips the topmost just-pushed mcs
1618                            // atom (as Sublime does for multi-context set).
1619                            // Single-context `set:` emitted its Clear earlier
1620                            // in the initial phase (so the trigger token sees
1621                            // the cleared stack); re-emitting here would
1622                            // double-push onto clear_stack and cause Pop
1623                            // underflow when the context unwinds.
1624                            //
1625                            // Clear is emitted per-context (not only for the
1626                            // topmost) because `clear_scopes` on a non-topmost
1627                            // context is a real pattern: Bash's
1628                            // `set: [def-function-body, def-function-params,
1629                            // def-function-name]` has `clear_scopes: 1` on
1630                            // def-function-params (middle). Each Clear is
1631                            // placed just before that context's own mcs/ms
1632                            // pushes so it strips the previous iteration's
1633                            // last-pushed atom, matching Sublime's semantics.
1634                            let single_context_set_clear = is_set && context_refs.len() == 1;
1635                            let mut prev_embed_scope_replaces = false;
1636                            for r in context_refs.iter() {
1637                                let ctx = r.resolve(syntax_set)?;
1638
1639                                if is_set && !single_context_set_clear {
1640                                    if let Some(clear_amount) = ctx.clear_scopes {
1641                                        ops.push((index, ScopeStackOp::Clear(clear_amount)));
1642                                    }
1643                                }
1644
1645                                for scope in ctx.meta_scope.iter() {
1646                                    ops.push((index, ScopeStackOp::Push(*scope)));
1647                                }
1648                                // v2: if the previous context has embed_scope_replaces,
1649                                // skip this context's meta_content_scope (the embedded
1650                                // syntax's top-level scope is replaced by embed_scope)
1651                                if !prev_embed_scope_replaces {
1652                                    for scope in ctx.meta_content_scope.iter() {
1653                                        ops.push((index, ScopeStackOp::Push(*scope)));
1654                                    }
1655                                }
1656                                prev_embed_scope_replaces = ctx.embed_scope_replaces;
1657                            }
1658                        } else {
1659                            for r in context_refs {
1660                                let ctx = r.resolve(syntax_set)?;
1661
1662                                // for some reason, contrary to my reading of the docs, set does this after the token
1663                                if is_set {
1664                                    if let Some(clear_amount) = ctx.clear_scopes {
1665                                        ops.push((index, ScopeStackOp::Clear(clear_amount)));
1666                                    }
1667                                }
1668
1669                                for scope in ctx.meta_scope.iter() {
1670                                    ops.push((index, ScopeStackOp::Push(*scope)));
1671                                }
1672                                for scope in ctx.meta_content_scope.iter() {
1673                                    ops.push((index, ScopeStackOp::Push(*scope)));
1674                                }
1675                            }
1676                        }
1677                    }
1678                }
1679            }
1680            MatchOperation::Embed {
1681                ref contexts,
1682                pop_count,
1683                ..
1684            } => {
1685                // When pop_count > 0 (pop + embed), use Set semantics to handle
1686                // popping the current context's meta scopes before pushing the
1687                // embedded contexts' meta scopes.
1688                let synthetic = if pop_count > 0 {
1689                    MatchOperation::Set {
1690                        ctx_refs: contexts.clone(),
1691                        pop_count,
1692                    }
1693                } else {
1694                    MatchOperation::Push(contexts.clone())
1695                };
1696                return self.push_meta_ops(
1697                    initial,
1698                    index,
1699                    cur_context,
1700                    &synthetic,
1701                    syntax_set,
1702                    ops,
1703                );
1704            }
1705            MatchOperation::None | MatchOperation::Fail(_) => (),
1706            MatchOperation::Branch {
1707                ref alternatives,
1708                pop_count,
1709                ..
1710            } => {
1711                // Branch acts like Push for meta ops purposes (or Set when pop_count > 0).
1712                // At exec time, Branch is transformed into a synthetic Push/Set before
1713                // calling push_meta_ops, so this arm is a safety fallback.
1714                let synthetic = if pop_count > 0 {
1715                    MatchOperation::Set {
1716                        ctx_refs: alternatives.clone(),
1717                        pop_count,
1718                    }
1719                } else {
1720                    MatchOperation::Push(alternatives.clone())
1721                };
1722                return self.push_meta_ops(
1723                    initial,
1724                    index,
1725                    cur_context,
1726                    &synthetic,
1727                    syntax_set,
1728                    ops,
1729                );
1730            }
1731        }
1732
1733        Ok(())
1734    }
1735
1736    /// Returns true if the stack was changed
1737    fn perform_op(
1738        &mut self,
1739        line: &str,
1740        regions: &Region,
1741        pat: &MatchPattern,
1742        syntax_set: &SyntaxSet,
1743    ) -> Result<bool, ParsingError> {
1744        let (ctx_refs, old_proto_ids, is_embed) = match pat.operation {
1745            MatchOperation::Push(ref ctx_refs) => (ctx_refs, None, false),
1746            MatchOperation::Embed {
1747                ref contexts,
1748                pop_count,
1749                ..
1750            } => {
1751                if pop_count > 0 {
1752                    for _ in 0..pop_count {
1753                        self.stack.pop();
1754                    }
1755                    self.branch_points
1756                        .retain(|bp| bp.stack_depth <= self.stack.len());
1757                    self.escape_stack
1758                        .retain(|e| e.stack_depth < self.stack.len());
1759                }
1760                (contexts, None, true)
1761            }
1762            MatchOperation::Set {
1763                ref ctx_refs,
1764                pop_count,
1765            } => {
1766                // a `with_prototype` stays active when the context is `set`
1767                // until the context layer in the stack (where the `with_prototype`
1768                // was initially applied) is popped off. With `pop: N + set:`
1769                // (pop_count > 1), the topmost popped frame's prototypes are
1770                // what carry forward onto the new push.
1771                let pops = pop_count.max(1);
1772                let old_proto_ids = self.stack.pop().map(|s| s.prototypes);
1773                for _ in 1..pops {
1774                    self.stack.pop();
1775                }
1776                // Prune branch_points / escape_stack against the *final* stack
1777                // length (after the common push loop below), so a branch_point
1778                // captured at the pre-set depth survives a pop-1 + push-1 set
1779                // — the depth the bp references is still valid after the push.
1780                let final_len = self.stack.len() + ctx_refs.len();
1781                self.branch_points.retain(|bp| bp.stack_depth <= final_len);
1782                self.escape_stack.retain(|e| e.stack_depth < final_len);
1783                (ctx_refs, old_proto_ids, false)
1784            }
1785            MatchOperation::Pop(n) => {
1786                for _ in 0..n {
1787                    self.stack.pop();
1788                }
1789                // Invalidate branch points whose stack depth is now above current stack
1790                self.branch_points
1791                    .retain(|bp| bp.stack_depth <= self.stack.len());
1792                // Remove escape entries whose stack_depth >= current stack
1793                self.escape_stack
1794                    .retain(|e| e.stack_depth < self.stack.len());
1795                return Ok(true);
1796            }
1797            MatchOperation::None => return Ok(false),
1798            MatchOperation::Branch { .. } | MatchOperation::Fail(_) => {
1799                // Branch and Fail are handled in exec_pattern, not here
1800                return Ok(false);
1801            }
1802        };
1803
1804        // Record stack depth before pushing (for Embed escape entry)
1805        let stack_depth_before = self.stack.len();
1806
1807        for (i, r) in ctx_refs.iter().enumerate() {
1808            let mut proto_ids = if i == 0 {
1809                // it is only necessary to preserve the old prototypes
1810                // at the first stack frame pushed
1811                old_proto_ids.clone().unwrap_or_else(Vec::new)
1812            } else {
1813                Vec::new()
1814            };
1815            if i == ctx_refs.len() - 1 {
1816                // if a with_prototype was specified, and multiple contexts were pushed,
1817                // then the with_prototype applies only to the last context pushed, i.e.
1818                // top most on the stack after all the contexts are pushed - this is also
1819                // referred to as the "target" of the push by sublimehq - see
1820                // https://forum.sublimetext.com/t/dev-build-3111/19240/17 for more info
1821                if let Some(ref p) = pat.with_prototype {
1822                    proto_ids.push(p.id()?);
1823                }
1824            }
1825            let context_id = r.id()?;
1826            let context = syntax_set.get_context(&context_id)?;
1827            let captures = {
1828                let mut uses_backrefs = context.uses_backrefs;
1829                if !proto_ids.is_empty() {
1830                    uses_backrefs = uses_backrefs
1831                        || proto_ids
1832                            .iter()
1833                            .any(|id| syntax_set.get_context(id).unwrap().uses_backrefs);
1834                }
1835                if uses_backrefs {
1836                    Some((regions.clone(), line.to_owned()))
1837                } else {
1838                    None
1839                }
1840            };
1841            self.stack.push(StateLevel {
1842                context: context_id,
1843                prototypes: proto_ids,
1844                captures,
1845            });
1846        }
1847
1848        // For Embed: push an EscapeEntry with the resolved escape regex
1849        if is_embed {
1850            if let MatchOperation::Embed { ref escape, .. } = pat.operation {
1851                let resolved_regex = if escape.has_captures {
1852                    // Resolve backrefs in escape regex using the triggering match's captures
1853                    let new_regex_str =
1854                        substitute_backrefs_in_regex(escape.escape_regex.regex_str(), |i| {
1855                            regions.pos(i).map(|(s, e)| escape_str(&line[s..e]))
1856                        });
1857                    Regex::new(new_regex_str)
1858                } else {
1859                    escape.escape_regex.clone()
1860                };
1861                self.escape_stack.push(EscapeEntry {
1862                    regex: resolved_regex,
1863                    captures: escape.escape_captures.clone(),
1864                    stack_depth: stack_depth_before,
1865                });
1866            }
1867        }
1868
1869        Ok(true)
1870    }
1871
1872    /// Execute an escape match: apply escape_captures, pop stack down to
1873    /// the embed's stack_depth, and remove the escape entry.
1874    fn exec_escape(
1875        &mut self,
1876        escape_idx: usize,
1877        match_start: usize,
1878        _match_end: usize,
1879        regions: &Region,
1880        syntax_set: &SyntaxSet,
1881        ops: &mut Vec<(usize, ScopeStackOp)>,
1882    ) -> Result<(), ParsingError> {
1883        let entry = &self.escape_stack[escape_idx];
1884        let target_depth = entry.stack_depth;
1885        let escape_captures = entry.captures.clone();
1886
1887        // Pop all stack levels down to target_depth, emitting proper meta scope pops
1888        while self.stack.len() > target_depth {
1889            let level = &self.stack[self.stack.len() - 1];
1890            let ctx = syntax_set.get_context(&level.context)?;
1891
1892            // Pop meta_content_scope.  If the context below has
1893            // embed_scope_replaces (it's a v2 embed_scope wrapper), the top
1894            // context is the embedded syntax's main — whose mcs was never
1895            // pushed on the way in — so skip the pop here too.  Gating this
1896            // on `current_syntax_version >= 2` would be wrong: the version
1897            // is read from the top context (the embedded syntax), but
1898            // embed_scope_replaces is set only by the v2 host syntax.  A v2
1899            // host embedding a v1 grammar (e.g. Rails HTML embedding Ruby)
1900            // would otherwise Pop a scope that was never pushed, misaligning
1901            // every scope below until the escape closes.
1902            if !ctx.meta_content_scope.is_empty() {
1903                let skip = self.stack.len() >= 2
1904                    && syntax_set
1905                        .get_context(&self.stack[self.stack.len() - 2].context)
1906                        .map(|c| c.embed_scope_replaces)
1907                        .unwrap_or(false);
1908                if !skip {
1909                    ops.push((match_start, ScopeStackOp::Pop(ctx.meta_content_scope.len())));
1910                }
1911            }
1912
1913            // Pop meta_scope
1914            if !ctx.meta_scope.is_empty() {
1915                ops.push((match_start, ScopeStackOp::Pop(ctx.meta_scope.len())));
1916            }
1917
1918            // Restore cleared scopes
1919            if ctx.clear_scopes.is_some() {
1920                ops.push((match_start, ScopeStackOp::Restore));
1921            }
1922
1923            self.stack.pop();
1924        }
1925
1926        // Apply escape_captures scopes
1927        if let Some(ref capture_map) = escape_captures {
1928            let mut map: Vec<((usize, i32), ScopeStackOp)> = Vec::new();
1929            for &(cap_index, ref scopes) in capture_map.iter() {
1930                if let Some((cap_start, cap_end)) = regions.pos(cap_index) {
1931                    if cap_start == cap_end {
1932                        continue;
1933                    }
1934                    for scope in scopes.iter() {
1935                        map.push((
1936                            (cap_start, -((cap_end - cap_start) as i32)),
1937                            ScopeStackOp::Push(*scope),
1938                        ));
1939                    }
1940                    map.push(((cap_end, i32::MIN), ScopeStackOp::Pop(scopes.len())));
1941                }
1942            }
1943            map.sort_by(|a, b| a.0.cmp(&b.0));
1944            for ((index, _), op) in map.into_iter() {
1945                ops.push((index, op));
1946            }
1947        }
1948
1949        // Remove this escape entry and any inner (later) escape entries
1950        self.escape_stack.truncate(escape_idx);
1951
1952        // Invalidate branch points whose stack depth is now above current stack
1953        self.branch_points
1954            .retain(|bp| bp.stack_depth <= self.stack.len());
1955
1956        Ok(())
1957    }
1958}
1959
1960/// Escape a string for use in regex substitution (re-export for use in escape resolution).
1961fn escape_str(s: &str) -> String {
1962    escape(s)
1963}
1964
1965#[cfg(feature = "yaml-load")]
1966#[cfg(test)]
1967mod tests {
1968    use super::*;
1969    use crate::parsing::ScopeStackOp::{Pop, Push};
1970    use crate::parsing::{Scope, ScopeStack, SyntaxSet, SyntaxSetBuilder};
1971    use crate::util::debug_print_ops;
1972    use crate::utils::testdata;
1973
1974    const TEST_SYNTAX: &str = include_str!("../../testdata/parser_tests.sublime-syntax");
1975    #[test]
1976    fn can_parse_simple() {
1977        let ss = &*testdata::PACKAGES_SYN_SET;
1978        let mut state = {
1979            let syntax = ss.find_syntax_by_name("Ruby (Rails)").unwrap();
1980            ParseState::new(syntax)
1981        };
1982
1983        let ops1 = ops(&mut state, "module Bob::Wow::Troll::Five; 5; end", ss);
1984        // `source.ruby.rails` is pushed once — the file's top-level
1985        // scope. Earlier versions of `add_initial_contexts` inserted
1986        // the scope into `main.meta_content_scope` twice (once on
1987        // initial load, once again after `resolve_extends` re-ran),
1988        // which showed up here as a duplicate Push. That duplication
1989        // also broke assertions of the form `- source source` in the
1990        // Diff test fixtures; the initial-contexts fix removes it.
1991        let test_ops1 = vec![
1992            (0, Push(Scope::new("source.ruby.rails").unwrap())),
1993            (0, Push(Scope::new("meta.namespace.ruby").unwrap())),
1994            (
1995                0,
1996                Push(Scope::new("keyword.declaration.namespace.ruby").unwrap()),
1997            ),
1998            (6, Pop(1)),
1999            (7, Pop(1)),
2000            (7, Push(Scope::new("meta.namespace.ruby").unwrap())),
2001            (7, Push(Scope::new("entity.name.namespace.ruby").unwrap())),
2002            (7, Push(Scope::new("support.other.namespace.ruby").unwrap())),
2003        ];
2004        assert_eq!(&ops1[0..test_ops1.len()], &test_ops1[..]);
2005
2006        let ops2 = ops(&mut state, "def lol(wow = 5)", ss);
2007        let test_ops2 = [
2008            (0, Push(Scope::new("meta.function.ruby").unwrap())),
2009            (
2010                0,
2011                Push(Scope::new("keyword.declaration.function.ruby").unwrap()),
2012            ),
2013            (3, Pop(2)),
2014            (3, Push(Scope::new("meta.function.ruby").unwrap())),
2015            (4, Push(Scope::new("entity.name.function.ruby").unwrap())),
2016            (7, Pop(1)),
2017        ];
2018        assert_eq!(&ops2[0..test_ops2.len()], &test_ops2[..]);
2019    }
2020
2021    #[test]
2022    fn can_parse_yaml() {
2023        let ps = &*testdata::PACKAGES_SYN_SET;
2024        let mut state = {
2025            let syntax = ps.find_syntax_by_name("YAML").unwrap();
2026            ParseState::new(syntax)
2027        };
2028
2029        assert_eq!(
2030            ops(&mut state, "key: value\n", ps),
2031            vec![
2032                (0, Push(Scope::new("source.yaml").unwrap())),
2033                (0, Push(Scope::new("meta.mapping.key.yaml").unwrap())),
2034                (0, Push(Scope::new("meta.string.yaml").unwrap())),
2035                (
2036                    0,
2037                    Push(Scope::new("string.unquoted.plain.out.yaml").unwrap())
2038                ),
2039                (3, Pop(2)),
2040                (3, Pop(1)),
2041                (3, Push(Scope::new("meta.mapping.yaml").unwrap())),
2042                (
2043                    3,
2044                    Push(Scope::new("punctuation.separator.key-value.mapping.yaml").unwrap())
2045                ),
2046                (4, Pop(2)),
2047                (5, Push(Scope::new("meta.string.yaml").unwrap())),
2048                (
2049                    5,
2050                    Push(Scope::new("string.unquoted.plain.out.yaml").unwrap())
2051                ),
2052                (10, Pop(2)),
2053            ]
2054        );
2055    }
2056
2057    #[test]
2058    fn can_parse_includes() {
2059        let ss = &*testdata::PACKAGES_SYN_SET;
2060        let mut state = {
2061            let syntax = ss.find_syntax_by_name("HTML (Rails)").unwrap();
2062            ParseState::new(syntax)
2063        };
2064
2065        let ops = ops(&mut state, "<script>var lol = '<% def wow(", ss);
2066
2067        assert!(
2068            !ops.is_empty(),
2069            "expected non-empty ops for line with includes"
2070        );
2071        let mut stack = ScopeStack::new();
2072        for (_, op) in ops.iter() {
2073            stack.apply(op).expect("#[cfg(test)]");
2074        }
2075        let stack_str = format!("{:?}", stack.as_slice());
2076        assert!(
2077            stack_str.contains("text.html.rails"),
2078            "expected text.html.rails in scope stack, got: {:?}",
2079            stack.as_slice()
2080        );
2081    }
2082
2083    #[test]
2084    fn can_parse_backrefs() {
2085        let ss = &*testdata::PACKAGES_SYN_SET;
2086        let mut state = {
2087            let syntax = ss.find_syntax_by_name("Ruby (Rails)").unwrap();
2088            ParseState::new(syntax)
2089        };
2090
2091        // For parsing HEREDOC, the "SQL" is captured at the beginning and then used in another
2092        // regex with a backref, to match the end of the HEREDOC. Note that there can be code
2093        // after the marker (`.strip`) here.
2094        assert_eq!(
2095            ops(&mut state, "lol = <<-SQL.strip", ss),
2096            vec![
2097                (0, Push(Scope::new("source.ruby.rails").unwrap())),
2098                (
2099                    4,
2100                    Push(Scope::new("keyword.operator.assignment.ruby").unwrap())
2101                ),
2102                (5, Pop(1)),
2103                (6, Push(Scope::new("meta.string.heredoc.ruby").unwrap())),
2104                (
2105                    6,
2106                    Push(Scope::new("punctuation.definition.heredoc.ruby").unwrap())
2107                ),
2108                (9, Pop(1)),
2109                (9, Push(Scope::new("meta.tag.heredoc.ruby").unwrap())),
2110                (9, Push(Scope::new("entity.name.tag.ruby").unwrap())),
2111                (12, Pop(1)),
2112                (12, Pop(2)),
2113                (
2114                    12,
2115                    Push(Scope::new("punctuation.accessor.dot.ruby").unwrap())
2116                ),
2117                (13, Pop(1)),
2118            ]
2119        );
2120
2121        assert_eq!(
2122            ops(&mut state, "wow", ss),
2123            vec![
2124                (0, Push(Scope::new("meta.string.heredoc.ruby").unwrap())),
2125                (0, Push(Scope::new("source.sql.embedded.ruby").unwrap()),),
2126                (0, Push(Scope::new("source.sql").unwrap())),
2127                (0, Push(Scope::new("source.sql.mysql").unwrap())),
2128                (0, Push(Scope::new("source.sql.basic").unwrap())),
2129                (0, Push(Scope::new("meta.column-name.sql").unwrap())),
2130                (3, Pop(1)),
2131            ]
2132        );
2133
2134        assert_eq!(
2135            ops(&mut state, "SQL", ss),
2136            vec![
2137                (0, Pop(4)),
2138                (0, Pop(1)),
2139                (0, Push(Scope::new("meta.string.heredoc.ruby").unwrap())),
2140                (0, Push(Scope::new("meta.tag.heredoc.ruby").unwrap())),
2141                (0, Push(Scope::new("entity.name.tag.ruby").unwrap())),
2142                (3, Pop(2)),
2143                (3, Pop(1)),
2144            ]
2145        );
2146    }
2147
2148    #[test]
2149    fn can_parse_preprocessor_rules() {
2150        let ss = &*testdata::PACKAGES_SYN_SET;
2151        let mut state = {
2152            let syntax = ss.find_syntax_by_name("C").unwrap();
2153            ParseState::new(syntax)
2154        };
2155
2156        assert_eq!(
2157            ops(&mut state, "#ifdef FOO", ss),
2158            vec![
2159                (0, Push(Scope::new("source.c").unwrap())),
2160                (0, Push(Scope::new("meta.preprocessor.c").unwrap())),
2161                (0, Push(Scope::new("keyword.control.import.c").unwrap())),
2162                (6, Pop(1)),
2163                (10, Pop(1)),
2164            ]
2165        );
2166        assert_eq!(
2167            ops(&mut state, "{", ss),
2168            vec![
2169                (0, Push(Scope::new("meta.block.c").unwrap())),
2170                (
2171                    0,
2172                    Push(Scope::new("punctuation.section.block.begin.c").unwrap())
2173                ),
2174                (1, Pop(1)),
2175            ]
2176        );
2177        assert_eq!(
2178            ops(&mut state, "#else", ss),
2179            vec![
2180                (0, Push(Scope::new("meta.preprocessor.c").unwrap())),
2181                (0, Push(Scope::new("keyword.control.import.c").unwrap())),
2182                (5, Pop(1)),
2183                (5, Pop(1)),
2184            ]
2185        );
2186        assert_eq!(
2187            ops(&mut state, "{", ss),
2188            vec![
2189                (0, Push(Scope::new("meta.block.c").unwrap())),
2190                (
2191                    0,
2192                    Push(Scope::new("punctuation.section.block.begin.c").unwrap())
2193                ),
2194                (1, Pop(1)),
2195            ]
2196        );
2197        assert_eq!(
2198            ops(&mut state, "#endif", ss),
2199            vec![
2200                (0, Pop(1)),
2201                (0, Push(Scope::new("meta.block.c").unwrap())),
2202                (0, Push(Scope::new("meta.preprocessor.c").unwrap())),
2203                (0, Push(Scope::new("keyword.control.import.c").unwrap())),
2204                (6, Pop(2)),
2205                (6, Pop(2)),
2206                (6, Push(Scope::new("meta.block.c").unwrap())),
2207            ]
2208        );
2209        assert_eq!(
2210            ops(&mut state, "    foo;", ss),
2211            vec![
2212                (7, Push(Scope::new("punctuation.terminator.c").unwrap())),
2213                (8, Pop(1)),
2214            ]
2215        );
2216        assert_eq!(
2217            ops(&mut state, "}", ss),
2218            vec![
2219                (
2220                    0,
2221                    Push(Scope::new("punctuation.section.block.end.c").unwrap())
2222                ),
2223                (1, Pop(1)),
2224                (1, Pop(1)),
2225            ]
2226        );
2227    }
2228
2229    #[test]
2230    fn can_parse_issue25() {
2231        let ss = &*testdata::PACKAGES_SYN_SET;
2232        let mut state = {
2233            let syntax = ss.find_syntax_by_name("C").unwrap();
2234            ParseState::new(syntax)
2235        };
2236
2237        // test fix for issue #25
2238        assert_eq!(ops(&mut state, "struct{estruct", ss).len(), 10);
2239    }
2240
2241    #[test]
2242    fn can_compare_parse_states() {
2243        // `ParseState` equality checks the stack, active branch points,
2244        // and the buffered `pending_lines` used for cross-line branch
2245        // replay. Because `class Foo {` opens a still-unresolved branch
2246        // (`declarations`), the literal source text is retained in
2247        // `pending_lines`, so two states that parsed the same syntactic
2248        // shape with different identifiers (e.g. `Foo` vs `Bar`) compare
2249        // unequal today — unlike earlier versions of this test. Keep the
2250        // two inputs identical here and assert the remaining invariants:
2251        // identical inputs -> equal states, advancing one -> divergence.
2252        let ss = &*testdata::PACKAGES_SYN_SET;
2253        let syntax = ss.find_syntax_by_name("Java").unwrap();
2254        let mut state1 = ParseState::new(syntax);
2255        let mut state2 = ParseState::new(syntax);
2256
2257        assert_eq!(ops(&mut state1, "class Foo {", ss).len(), 13);
2258        assert_eq!(ops(&mut state2, "class Foo {", ss).len(), 13);
2259
2260        assert_eq!(state1, state2);
2261        ops(&mut state1, "}", ss);
2262        assert_ne!(state1, state2);
2263    }
2264
2265    #[test]
2266    fn can_parse_non_nested_clear_scopes() {
2267        let line = "'hello #simple_cleared_scopes_test world test \\n '";
2268        let expect = [
2269            "<source.test>, <example.meta-scope.after-clear-scopes.example>, <example.pushes-clear-scopes.example>",
2270            "<source.test>, <example.meta-scope.after-clear-scopes.example>, <example.pops-clear-scopes.example>",
2271            "<source.test>, <string.quoted.single.example>, <constant.character.escape.example>",
2272        ];
2273        expect_scope_stacks(line, &expect, TEST_SYNTAX);
2274    }
2275
2276    #[test]
2277    fn can_parse_non_nested_too_many_clear_scopes() {
2278        let line = "'hello #too_many_cleared_scopes_test world test \\n '";
2279        let expect = [
2280            "<example.meta-scope.after-clear-scopes.example>, <example.pushes-clear-scopes.example>",
2281            "<example.meta-scope.after-clear-scopes.example>, <example.pops-clear-scopes.example>",
2282            "<source.test>, <string.quoted.single.example>, <constant.character.escape.example>",
2283        ];
2284        expect_scope_stacks(line, &expect, TEST_SYNTAX);
2285    }
2286
2287    #[test]
2288    fn can_parse_nested_clear_scopes() {
2289        let line = "'hello #nested_clear_scopes_test world foo bar test \\n '";
2290        let expect = [
2291            "<source.test>, <example.meta-scope.after-clear-scopes.example>, <example.pushes-clear-scopes.example>",
2292            "<source.test>, <example.meta-scope.cleared-previous-meta-scope.example>, <foo>",
2293            "<source.test>, <example.meta-scope.after-clear-scopes.example>, <example.pops-clear-scopes.example>",
2294            "<source.test>, <string.quoted.single.example>, <constant.character.escape.example>",
2295        ];
2296        expect_scope_stacks(line, &expect, TEST_SYNTAX);
2297    }
2298
2299    #[test]
2300    fn can_parse_infinite_loop() {
2301        let line = "#infinite_loop_test 123";
2302        let expect = ["<source.test>, <constant.numeric.test>"];
2303        expect_scope_stacks(line, &expect, TEST_SYNTAX);
2304    }
2305
2306    #[test]
2307    fn can_parse_infinite_seeming_loop() {
2308        // See https://github.com/SublimeTextIssues/Core/issues/1190 for an
2309        // explanation.
2310        let line = "#infinite_seeming_loop_test hello";
2311        let expect = [
2312            "<source.test>, <keyword.test>",
2313            "<source.test>, <test>, <string.unquoted.test>",
2314            "<source.test>, <test>, <keyword.control.test>",
2315        ];
2316        expect_scope_stacks(line, &expect, TEST_SYNTAX);
2317    }
2318
2319    #[test]
2320    fn can_parse_prototype_that_pops_main() {
2321        let syntax = r#"
2322name: test
2323scope: source.test
2324contexts:
2325  prototype:
2326    # This causes us to pop out of the main context. Sublime Text handles that
2327    # by pushing main back automatically.
2328    - match: (?=!)
2329      pop: true
2330  main:
2331    - match: foo
2332      scope: test.good
2333"#;
2334
2335        let line = "foo!";
2336        let expect = ["<source.test>, <test.good>"];
2337        expect_scope_stacks(line, &expect, syntax);
2338    }
2339
2340    #[test]
2341    fn can_parse_prototype_that_pops_multiple_context() {
2342        let syntax = r#"
2343name: test
2344scope: source.test
2345contexts:
2346  prototype:
2347    - match: "!"
2348      pop: 2
2349  bar:
2350    - match: \bbaz\b
2351      push: baz
2352      scope: main.baz
2353  foo:
2354    - match: \bbar\b
2355      push: bar
2356      scope: test.bar
2357    - match: \bgood\b
2358      push: baz
2359      scope: test.good
2360  baz: []
2361    
2362  main:
2363    - match: \bfoo\b
2364      push: foo
2365      scope: test.foo
2366"#;
2367
2368        let line = "foo bar baz ! good";
2369        let expect = ["<source.test>, <test.good>"];
2370        expect_scope_stacks(line, &expect, syntax);
2371    }
2372
2373    #[test]
2374    fn can_parse_syntax_with_newline_in_character_class() {
2375        let syntax = r#"
2376name: test
2377scope: source.test
2378contexts:
2379  main:
2380    - match: foo[\n]
2381      scope: foo.end
2382    - match: foo
2383      scope: foo.any
2384"#;
2385
2386        let line = "foo";
2387        let expect = ["<source.test>, <foo.end>"];
2388        expect_scope_stacks(line, &expect, syntax);
2389
2390        let line = "foofoofoo";
2391        let expect = [
2392            "<source.test>, <foo.any>",
2393            "<source.test>, <foo.any>",
2394            "<source.test>, <foo.end>",
2395        ];
2396        expect_scope_stacks(line, &expect, syntax);
2397    }
2398
2399    #[test]
2400    fn can_parse_issue120() {
2401        let syntax = SyntaxDefinition::load_from_str(
2402            include_str!("../../testdata/embed_escape_test.sublime-syntax"),
2403            false,
2404            None,
2405        )
2406        .unwrap();
2407
2408        let line1 = "\"abctest\" foobar";
2409        let expect1 = [
2410            "<meta.attribute-with-value.style.html>, <string.quoted.double>, <punctuation.definition.string.begin.html>",
2411            "<meta.attribute-with-value.style.html>, <source.css>",
2412            "<meta.attribute-with-value.style.html>, <string.quoted.double>, <punctuation.definition.string.end.html>",
2413            "<meta.attribute-with-value.style.html>, <source.css>, <test.embedded>",
2414            "<top-level.test>",
2415        ];
2416
2417        expect_scope_stacks_with_syntax(line1, &expect1, syntax.clone());
2418
2419        let line2 = ">abctest</style>foobar";
2420        let expect2 = [
2421            "<meta.tag.style.begin.html>, <punctuation.definition.tag.end.html>",
2422            "<source.css.embedded.html>, <test.embedded>",
2423            "<top-level.test>",
2424        ];
2425        expect_scope_stacks_with_syntax(line2, &expect2, syntax);
2426    }
2427
2428    #[test]
2429    fn can_parse_non_consuming_pop_that_would_loop() {
2430        // See https://github.com/trishume/syntect/issues/127
2431        let syntax = r#"
2432name: test
2433scope: source.test
2434contexts:
2435  main:
2436    # This makes us go into "test" without consuming any characters
2437    - match: (?=hello)
2438      push: test
2439  test:
2440    # If we used this match, we'd go back to "main" without consuming anything,
2441    # and then back into "test", infinitely looping. ST detects this at this
2442    # point and ignores this match until at least one character matched.
2443    - match: (?!world)
2444      pop: true
2445    - match: \w+
2446      scope: test.matched
2447"#;
2448
2449        let line = "hello";
2450        let expect = ["<source.test>, <test.matched>"];
2451        expect_scope_stacks(line, &expect, syntax);
2452    }
2453
2454    #[test]
2455    fn can_parse_non_consuming_set_and_pop_that_would_loop() {
2456        let syntax = r#"
2457name: test
2458scope: source.test
2459contexts:
2460  main:
2461    # This makes us go into "a" without advancing
2462    - match: (?=test)
2463      push: a
2464  a:
2465    # This makes us go into "b" without advancing
2466    - match: (?=t)
2467      set: b
2468  b:
2469    # If we used this match, we'd go back to "main" without having advanced,
2470    # which means we'd have an infinite loop like with the previous test.
2471    # So even for a "set", we have to check if we're advancing or not.
2472    - match: (?=t)
2473      pop: true
2474    - match: \w+
2475      scope: test.matched
2476"#;
2477
2478        let line = "test";
2479        let expect = ["<source.test>, <test.matched>"];
2480        expect_scope_stacks(line, &expect, syntax);
2481    }
2482
2483    #[test]
2484    fn can_parse_non_consuming_set_after_consuming_push_that_does_not_loop() {
2485        let syntax = r#"
2486name: test
2487scope: source.test
2488contexts:
2489  main:
2490    # This makes us go into "a", but we consumed a character
2491    - match: t
2492      push: a
2493    - match: \w+
2494      scope: test.matched
2495  a:
2496    # This makes us go into "b" without consuming
2497    - match: (?=e)
2498      set: b
2499  b:
2500    # This match does not result in an infinite loop because we already consumed
2501    # a character to get into "a", so it's ok to pop back into "main".
2502    - match: (?=e)
2503      pop: true
2504"#;
2505
2506        let line = "test";
2507        let expect = ["<source.test>, <test.matched>"];
2508        expect_scope_stacks(line, &expect, syntax);
2509    }
2510
2511    #[test]
2512    fn can_parse_non_consuming_set_after_consuming_set_that_does_not_loop() {
2513        let syntax = r#"
2514name: test
2515scope: source.test
2516contexts:
2517  main:
2518    - match: (?=hello)
2519      push: a
2520    - match: \w+
2521      scope: test.matched
2522  a:
2523    - match: h
2524      set: b
2525  b:
2526    - match: (?=e)
2527      set: c
2528  c:
2529    # This is not an infinite loop because "a" consumed a character, so we can
2530    # actually pop back into main and then match the rest of the input.
2531    - match: (?=e)
2532      pop: true
2533"#;
2534
2535        let line = "hello";
2536        let expect = ["<source.test>, <test.matched>"];
2537        expect_scope_stacks(line, &expect, syntax);
2538    }
2539
2540    #[test]
2541    fn can_parse_non_consuming_pop_that_would_loop_at_end_of_line() {
2542        let syntax = r#"
2543name: test
2544scope: source.test
2545contexts:
2546  main:
2547    # This makes us go into "test" without consuming, even at the end of line
2548    - match: ""
2549      push: test
2550  test:
2551    - match: ""
2552      pop: true
2553    - match: \w+
2554      scope: test.matched
2555"#;
2556
2557        let line = "hello";
2558        let expect = ["<source.test>, <test.matched>"];
2559        expect_scope_stacks(line, &expect, syntax);
2560    }
2561
2562    #[test]
2563    fn non_consuming_pop_n_below_pre_push_depth_is_not_a_loop() {
2564        // Mirror of the Haskell `declaration-type-end` branch where the
2565        // fallback alternative is `immediately-pop2` (empty match with
2566        // `pop: 2`). The outer wrapper's meta_scope must come off when
2567        // the pop-2 fallback fires — pre-fix, the loop guard flagged
2568        // any non-consuming `pop` after a non-consuming push as
2569        // looping, so the parser advanced one char past the branch and
2570        // the pop-2 fired at the wrong column, leaving the wrapper's
2571        // scope covering the trailing `y` token.
2572        let syntax = r#"
2573name: test
2574scope: source.test
2575contexts:
2576  main:
2577    - match: open
2578      scope: test.open
2579      push: wrapper
2580    - match: y
2581      scope: test.main.y
2582    - match: z
2583      scope: test.main.z
2584  wrapper:
2585    - meta_scope: test.wrapper
2586    - match: ""
2587      branch_point: fallback
2588      branch:
2589        - try
2590        - give-up
2591  try:
2592    - match: x
2593      scope: test.try.match
2594    - match: (?=y)
2595      fail: fallback
2596  give-up:
2597    - match: ""
2598      pop: 2
2599"#;
2600        // With the fix, `give-up` fires pop-2 at column 4 (the fail
2601        // position), unwinding both `give-up` and `wrapper`; `y` is
2602        // then scoped by `main`'s rule. Without the fix, would_loop
2603        // advanced start past column 4, the pop-2 fired at column 5,
2604        // and `y` stayed inside the wrapper and never matched
2605        // `test.main.y`.
2606        expect_scope_stacks("openyz", &["<source.test>, <test.main.y>"], syntax);
2607    }
2608
2609    #[test]
2610    fn can_parse_empty_but_consuming_set_that_does_not_loop() {
2611        let syntax = r#"
2612name: test
2613scope: source.test
2614contexts:
2615  main:
2616    - match: (?=hello)
2617      push: a
2618    - match: ello
2619      scope: test.good
2620  a:
2621    # This is an empty match, but it consumed a character (the "h")
2622    - match: (?=e)
2623      set: b
2624  b:
2625    # .. so it's ok to pop back to main from here
2626    - match: ""
2627      pop: true
2628    - match: ello
2629      scope: test.bad
2630"#;
2631
2632        let line = "hello";
2633        let expect = ["<source.test>, <test.good>"];
2634        expect_scope_stacks(line, &expect, syntax);
2635    }
2636
2637    #[test]
2638    fn can_parse_non_consuming_pop_that_does_not_loop() {
2639        let syntax = r#"
2640name: test
2641scope: source.test
2642contexts:
2643  main:
2644    # This is a non-consuming push, so "b" will need to check for a
2645    # non-consuming pop
2646    - match: (?=hello)
2647      push: [b, a]
2648    - match: ello
2649      scope: test.good
2650  a:
2651    # This pop is ok, it consumed "h"
2652    - match: (?=e)
2653      pop: true
2654  b:
2655    # This is non-consuming, and we set to "c"
2656    - match: (?=e)
2657      set: c
2658  c:
2659    # It's ok to pop back to "main" here because we consumed a character in the
2660    # meantime.
2661    - match: ""
2662      pop: true
2663    - match: ello
2664      scope: test.bad
2665"#;
2666
2667        let line = "hello";
2668        let expect = ["<source.test>, <test.good>"];
2669        expect_scope_stacks(line, &expect, syntax);
2670    }
2671
2672    #[test]
2673    fn can_parse_non_consuming_pop_with_multi_push_that_does_not_loop() {
2674        let syntax = r#"
2675name: test
2676scope: source.test
2677contexts:
2678  main:
2679    - match: (?=hello)
2680      push: [b, a]
2681    - match: ello
2682      scope: test.good
2683  a:
2684    # This pop is ok, as we're not popping back to "main" yet (which would loop),
2685    # we're popping to "b"
2686    - match: ""
2687      pop: true
2688    - match: \w+
2689      scope: test.bad
2690  b:
2691    - match: \w+
2692      scope: test.good
2693"#;
2694
2695        let line = "hello";
2696        let expect = ["<source.test>, <test.good>"];
2697        expect_scope_stacks(line, &expect, syntax);
2698    }
2699
2700    #[test]
2701    fn can_parse_non_consuming_pop_of_recursive_context_that_does_not_loop() {
2702        let syntax = r#"
2703name: test
2704scope: source.test
2705contexts:
2706  main:
2707    - match: xxx
2708      scope: test.good
2709    - include: basic-identifiers
2710
2711  basic-identifiers:
2712    - match: '\w+::'
2713      scope: test.matched
2714      push: no-type-names
2715
2716  no-type-names:
2717      - include: basic-identifiers
2718      - match: \w+
2719        scope: test.matched.inside
2720      # This is a tricky one because when this is the best match,
2721      # we have two instances of "no-type-names" on the stack, so we're popping
2722      # back from "no-type-names" to another "no-type-names".
2723      - match: ''
2724        pop: true
2725"#;
2726
2727        let line = "foo::bar::* xxx";
2728        let expect = ["<source.test>, <test.good>"];
2729        expect_scope_stacks(line, &expect, syntax);
2730    }
2731
2732    /// Ruby's `?\u{012ACF 0gxs}`: `\h{0,6}` can match zero-width at the
2733    /// space. Without the `FIND_NOT_EMPTY` engine option, the zero-width
2734    /// match wins and hides the later non-empty match of `0`, which then
2735    /// falls through to the `\S` fallback. With the option on
2736    /// `MatchOperation::None` patterns, the engine retries past the
2737    /// zero-width position and matches `0` as `number.hex`.
2738    #[test]
2739    fn scope_only_pattern_that_matches_zero_width_finds_later_non_empty() {
2740        let syntax = r#"
2741name: test
2742scope: source.test
2743contexts:
2744  main:
2745    - match: \h{0,6}
2746      scope: number.hex
2747    - match: \S
2748      scope: invalid.illegal
2749"#;
2750
2751        let line = "012ACF 0gxs";
2752        let expect = [
2753            "<source.test>, <number.hex>",      // "012ACF" and "0"
2754            "<source.test>, <invalid.illegal>", // "g", "x", "s"
2755        ];
2756        expect_scope_stacks(line, &expect, syntax);
2757    }
2758
2759    /// Cabal's `\|\||&&||!` operator regex has a stray empty alternative
2760    /// between `&&` and `!`. Under leftmost-first matching the empty alt
2761    /// wins zero-width at the `!` position. With `FIND_NOT_EMPTY`, the
2762    /// engine rejects the empty alt and matches `!` via the real
2763    /// alternative.
2764    #[test]
2765    fn scope_only_pattern_with_middle_empty_alt_matches_bang() {
2766        let syntax = r#"
2767name: test
2768scope: source.test
2769contexts:
2770  main:
2771    - match: \|\||&&||!
2772      scope: keyword.operator
2773"#;
2774        let line = "!";
2775        let expect = ["<source.test>, <keyword.operator>"];
2776        expect_scope_stacks(line, &expect, syntax);
2777    }
2778
2779    /// Rust's `prelude_types: (?x:|Box|Option|…)` puts a `|` before every
2780    /// alternative, including the first. Under leftmost-first the leading
2781    /// empty alt wins zero-width at every position, so `\b(?x:|Box|Vec)\b`
2782    /// never matches `Box` or `Vec`. With `FIND_NOT_EMPTY`, the engine
2783    /// rejects the zero-width alt and matches `Vec` via the real
2784    /// alternative.
2785    #[test]
2786    fn scope_only_pattern_with_leading_empty_alt_in_group_matches_name() {
2787        let syntax = r#"
2788name: test
2789scope: source.test
2790contexts:
2791  main:
2792    - match: \b(?x:|Box|Vec)\b
2793      scope: support.type
2794"#;
2795        let line = "Vec";
2796        let expect = ["<source.test>, <support.type>"];
2797        expect_scope_stacks(line, &expect, syntax);
2798    }
2799
2800    #[test]
2801    fn can_parse_non_consuming_pop_order() {
2802        let syntax = r#"
2803name: test
2804scope: source.test
2805contexts:
2806  main:
2807    - match: (?=hello)
2808      push: test
2809  test:
2810    # This matches first
2811    - match: (?=e)
2812      push: good
2813    # But this (looping) match replaces it, because it's an earlier match
2814    - match: (?=h)
2815      pop: true
2816    # And this should not replace it, as it's a later match (only matches at
2817    # the same position can replace looping pops).
2818    - match: (?=o)
2819      push: bad
2820  good:
2821    - match: \w+
2822      scope: test.good
2823  bad:
2824    - match: \w+
2825      scope: test.bad
2826"#;
2827
2828        let line = "hello";
2829        let expect = ["<source.test>, <test.good>"];
2830        expect_scope_stacks(line, &expect, syntax);
2831    }
2832
2833    #[test]
2834    fn can_parse_prototype_with_embed() {
2835        let syntax = r#"
2836name: Javadoc
2837scope: text.html.javadoc
2838contexts:
2839  prototype:
2840    - match: \*
2841      scope: punctuation.definition.comment.javadoc
2842
2843  main:
2844    - meta_include_prototype: false
2845    - match: /\*\*
2846      scope: comment.block.documentation.javadoc punctuation.definition.comment.begin.javadoc
2847      embed: contents
2848      embed_scope: comment.block.documentation.javadoc text.html.javadoc
2849      escape: \*/
2850      escape_captures:
2851        0: comment.block.documentation.javadoc punctuation.definition.comment.end.javadoc
2852
2853  contents:
2854    - match: ''
2855"#;
2856
2857        let syntax = SyntaxDefinition::load_from_str(syntax, true, None).unwrap();
2858        expect_scope_stacks_with_syntax("/** * */", &["<comment.block.documentation.javadoc>, <punctuation.definition.comment.begin.javadoc>", "<comment.block.documentation.javadoc>, <text.html.javadoc>, <punctuation.definition.comment.javadoc>", "<comment.block.documentation.javadoc>, <punctuation.definition.comment.end.javadoc>"], syntax);
2859    }
2860
2861    #[test]
2862    fn can_parse_context_included_in_prototype_via_named_reference() {
2863        let syntax = r#"
2864scope: source.test
2865contexts:
2866  prototype:
2867    - match: a
2868      push: a
2869    - match: b
2870      scope: test.bad
2871  main:
2872    - match: unused
2873  # This context is included in the prototype (see `push: a`).
2874  # Because of that, ST doesn't apply the prototype to this context, so if
2875  # we're in here the "b" shouldn't match.
2876  a:
2877    - match: a
2878      scope: test.good
2879"#;
2880
2881        let stack_states = stack_states(parse("aa b", syntax));
2882        assert_eq!(
2883            stack_states,
2884            vec![
2885                "<source.test>",
2886                "<source.test>, <test.good>",
2887                "<source.test>",
2888            ],
2889            "Expected test.bad to not match"
2890        );
2891    }
2892
2893    #[test]
2894    fn can_parse_with_prototype_set() {
2895        let syntax = r#"%YAML 1.2
2896---
2897scope: source.test-set-with-proto
2898contexts:
2899  main:
2900    - match: a
2901      scope: a
2902      set: next1
2903      with_prototype:
2904        - match: '1'
2905          scope: '1'
2906        - match: '2'
2907          scope: '2'
2908        - match: '3'
2909          scope: '3'
2910        - match: '4'
2911          scope: '4'
2912    - match: '5'
2913      scope: '5'
2914      set: [next3, next2]
2915      with_prototype:
2916        - match: c
2917          scope: cwith
2918  next1:
2919    - match: b
2920      scope: b
2921      set: next2
2922  next2:
2923    - match: c
2924      scope: c
2925      push: next3
2926    - match: e
2927      scope: e
2928      pop: true
2929    - match: f
2930      scope: f
2931      set: [next1, next2]
2932  next3:
2933    - match: d
2934      scope: d
2935    - match: (?=e)
2936      pop: true
2937    - match: c
2938      scope: cwithout
2939"#;
2940
2941        expect_scope_stacks_with_syntax(
2942            "a1b2c3d4e5",
2943            &[
2944                "<a>", "<1>", "<b>", "<2>", "<c>", "<3>", "<d>", "<4>", "<e>", "<5>",
2945            ],
2946            SyntaxDefinition::load_from_str(syntax, true, None).unwrap(),
2947        );
2948        expect_scope_stacks_with_syntax(
2949            "5cfcecbedcdea",
2950            &[
2951                "<5>",
2952                "<cwith>",
2953                "<f>",
2954                "<e>",
2955                "<b>",
2956                "<d>",
2957                "<cwithout>",
2958                "<a>",
2959            ],
2960            SyntaxDefinition::load_from_str(syntax, true, None).unwrap(),
2961        );
2962    }
2963
2964    #[test]
2965    fn can_parse_issue176() {
2966        let syntax = r#"
2967scope: source.dummy
2968contexts:
2969  main:
2970    - match: (test)(?=(foo))(f)
2971      captures:
2972        1: test
2973        2: ignored
2974        3: f
2975      push:
2976        - match: (oo)
2977          captures:
2978            1: keyword
2979"#;
2980
2981        let syntax = SyntaxDefinition::load_from_str(syntax, true, None).unwrap();
2982        expect_scope_stacks_with_syntax(
2983            "testfoo",
2984            &["<test>", /*"<ignored>",*/ "<f>", "<keyword>"],
2985            syntax,
2986        );
2987    }
2988
2989    #[test]
2990    fn can_parse_two_with_prototypes_at_same_stack_level() {
2991        let syntax_yamlstr = r#"
2992%YAML 1.2
2993---
2994# See http://www.sublimetext.com/docs/3/syntax.html
2995scope: source.example-wp
2996contexts:
2997  main:
2998    - match: a
2999      scope: a
3000      push:
3001        - match: b
3002          scope: b
3003          set:
3004            - match: c
3005              scope: c
3006          with_prototype:
3007            - match: '2'
3008              scope: '2'
3009      with_prototype:
3010        - match: '1'
3011          scope: '1'
3012"#;
3013
3014        let syntax = SyntaxDefinition::load_from_str(syntax_yamlstr, true, None).unwrap();
3015        expect_scope_stacks_with_syntax("abc12", &["<1>", "<2>"], syntax);
3016    }
3017
3018    #[test]
3019    fn can_parse_two_with_prototypes_at_same_stack_level_set_multiple() {
3020        let syntax_yamlstr = r#"
3021%YAML 1.2
3022---
3023# See http://www.sublimetext.com/docs/3/syntax.html
3024scope: source.example-wp
3025contexts:
3026  main:
3027    - match: a
3028      scope: a
3029      push:
3030        - match: b
3031          scope: b
3032          set: [context1, context2, context3]
3033          with_prototype:
3034            - match: '2'
3035              scope: '2'
3036      with_prototype:
3037        - match: '1'
3038          scope: '1'
3039    - match: '1'
3040      scope: digit1
3041    - match: '2'
3042      scope: digit2
3043  context1:
3044    - match: e
3045      scope: e
3046      pop: true
3047    - match: '2'
3048      scope: digit2
3049  context2:
3050    - match: d
3051      scope: d
3052      pop: true
3053    - match: '2'
3054      scope: digit2
3055  context3:
3056    - match: c
3057      scope: c
3058      pop: true
3059"#;
3060
3061        let syntax = SyntaxDefinition::load_from_str(syntax_yamlstr, true, None).unwrap();
3062        expect_scope_stacks_with_syntax("ab12", &["<1>", "<2>"], syntax.clone());
3063        expect_scope_stacks_with_syntax("abc12", &["<1>", "<digit2>"], syntax.clone());
3064        expect_scope_stacks_with_syntax("abcd12", &["<1>", "<digit2>"], syntax.clone());
3065        expect_scope_stacks_with_syntax("abcde12", &["<digit1>", "<digit2>"], syntax);
3066    }
3067
3068    #[test]
3069    fn can_parse_two_with_prototypes_at_same_stack_level_updated_captures() {
3070        let syntax_yamlstr = r#"
3071%YAML 1.2
3072---
3073# See http://www.sublimetext.com/docs/3/syntax.html
3074scope: source.example-wp
3075contexts:
3076  main:
3077    - match: (a)
3078      scope: a
3079      push:
3080        - match: (b)
3081          scope: b
3082          set:
3083            - match: c
3084              scope: c
3085          with_prototype:
3086            - match: d
3087              scope: d
3088      with_prototype:
3089        - match: \1
3090          scope: '1'
3091          pop: true
3092"#;
3093
3094        let syntax = SyntaxDefinition::load_from_str(syntax_yamlstr, true, None).unwrap();
3095        expect_scope_stacks_with_syntax("aa", &["<a>", "<1>"], syntax.clone());
3096        expect_scope_stacks_with_syntax("abcdb", &["<a>", "<b>", "<c>", "<d>", "<1>"], syntax);
3097    }
3098
3099    #[test]
3100    fn can_parse_two_with_prototypes_at_same_stack_level_updated_captures_ignore_unexisting() {
3101        let syntax_yamlstr = r#"
3102%YAML 1.2
3103---
3104# See http://www.sublimetext.com/docs/3/syntax.html
3105scope: source.example-wp
3106contexts:
3107  main:
3108    - match: (a)(-)
3109      scope: a
3110      push:
3111        - match: (b)
3112          scope: b
3113          set:
3114            - match: c
3115              scope: c
3116          with_prototype:
3117            - match: d
3118              scope: d
3119      with_prototype:
3120        - match: \2
3121          scope: '2'
3122          pop: true
3123        - match: \1
3124          scope: '1'
3125          pop: true
3126"#;
3127
3128        let syntax = SyntaxDefinition::load_from_str(syntax_yamlstr, true, None).unwrap();
3129        expect_scope_stacks_with_syntax("a--", &["<a>", "<2>"], syntax.clone());
3130        // it seems that when ST encounters a non existing pop backreference, it just pops back to the with_prototype's original parent context - i.e. cdb is unscoped
3131        // TODO: it would be useful to have syntest functionality available here for easier testing and clarity
3132        expect_scope_stacks_with_syntax("a-bcdba-", &["<a>", "<b>"], syntax);
3133    }
3134
3135    #[test]
3136    fn can_parse_syntax_with_eol_and_newline() {
3137        let syntax = r#"
3138name: test
3139scope: source.test
3140contexts:
3141  main:
3142    - match: foo$\n
3143      scope: foo.newline
3144"#;
3145
3146        let line = "foo";
3147        let expect = ["<source.test>, <foo.newline>"];
3148        expect_scope_stacks(line, &expect, syntax);
3149    }
3150
3151    #[test]
3152    fn can_parse_syntax_with_eol_only() {
3153        let syntax = r#"
3154name: test
3155scope: source.test
3156contexts:
3157  main:
3158    - match: foo$
3159      scope: foo.newline
3160"#;
3161
3162        let line = "foo";
3163        let expect = ["<source.test>, <foo.newline>"];
3164        expect_scope_stacks(line, &expect, syntax);
3165    }
3166
3167    #[test]
3168    fn can_parse_syntax_with_beginning_of_line() {
3169        let syntax = r#"
3170name: test
3171scope: source.test
3172contexts:
3173  main:
3174    - match: \w+
3175      scope: word
3176      push:
3177        # this should not match at the end of the line
3178        - match: ^\s*$
3179          pop: true
3180        - match: =+
3181          scope: heading
3182          pop: true
3183    - match: .*
3184      scope: other
3185"#;
3186
3187        let syntax_newlines = SyntaxDefinition::load_from_str(syntax, true, None).unwrap();
3188        let syntax_set = link(syntax_newlines);
3189
3190        let mut state = ParseState::new(&syntax_set.syntaxes()[0]);
3191        assert_eq!(
3192            ops(&mut state, "foo\n", &syntax_set),
3193            vec![
3194                (0, Push(Scope::new("source.test").unwrap())),
3195                (0, Push(Scope::new("word").unwrap())),
3196                (3, Pop(1))
3197            ]
3198        );
3199        assert_eq!(
3200            ops(&mut state, "===\n", &syntax_set),
3201            vec![(0, Push(Scope::new("heading").unwrap())), (3, Pop(1))]
3202        );
3203
3204        assert_eq!(
3205            ops(&mut state, "bar\n", &syntax_set),
3206            vec![(0, Push(Scope::new("word").unwrap())), (3, Pop(1))]
3207        );
3208        // This should result in popping out of the context
3209        assert_eq!(ops(&mut state, "\n", &syntax_set), vec![]);
3210        // So now this matches other
3211        assert_eq!(
3212            ops(&mut state, "====\n", &syntax_set),
3213            vec![(0, Push(Scope::new("other").unwrap())), (4, Pop(1))]
3214        );
3215    }
3216
3217    #[test]
3218    fn can_parse_syntax_with_comment_and_eol() {
3219        let syntax = r#"
3220name: test
3221scope: source.test
3222contexts:
3223  main:
3224    - match: (//).*$
3225      scope: comment.line.double-slash
3226"#;
3227
3228        let syntax_newlines = SyntaxDefinition::load_from_str(syntax, true, None).unwrap();
3229        let syntax_set = link(syntax_newlines);
3230
3231        let mut state = ParseState::new(&syntax_set.syntaxes()[0]);
3232        assert_eq!(
3233            ops(&mut state, "// foo\n", &syntax_set),
3234            vec![
3235                (0, Push(Scope::new("source.test").unwrap())),
3236                (0, Push(Scope::new("comment.line.double-slash").unwrap())),
3237                // 6 is important here, should not be 7. The pattern should *not* consume the newline,
3238                // but instead match before it. This is important for whitespace-sensitive syntaxes
3239                // where newlines terminate statements such as Scala.
3240                (6, Pop(1))
3241            ]
3242        );
3243    }
3244
3245    #[test]
3246    fn can_parse_text_with_unicode_to_skip() {
3247        let syntax = r#"
3248name: test
3249scope: source.test
3250contexts:
3251  main:
3252    - match: (?=.)
3253      push: test
3254  test:
3255    - match: (?=.)
3256      pop: true
3257    - match: x
3258      scope: test.good
3259"#;
3260
3261        // U+03C0 GREEK SMALL LETTER PI, 2 bytes in UTF-8
3262        expect_scope_stacks("\u{03C0}x", &["<source.test>, <test.good>"], syntax);
3263        // U+0800 SAMARITAN LETTER ALAF, 3 bytes in UTF-8
3264        expect_scope_stacks("\u{0800}x", &["<source.test>, <test.good>"], syntax);
3265        // U+1F600 GRINNING FACE, 4 bytes in UTF-8
3266        expect_scope_stacks("\u{1F600}x", &["<source.test>, <test.good>"], syntax);
3267    }
3268
3269    #[test]
3270    fn can_include_backrefs() {
3271        let syntax = SyntaxDefinition::load_from_str(
3272            r#"
3273                name: Backref Include Test
3274                scope: source.backrefinc
3275                contexts:
3276                  main:
3277                    - match: (a)
3278                      scope: a
3279                      push: context1
3280                  context1:
3281                    - include: context2
3282                  context2:
3283                    - match: \1
3284                      scope: b
3285                      pop: true
3286                "#,
3287            true,
3288            None,
3289        )
3290        .unwrap();
3291
3292        expect_scope_stacks_with_syntax("aa", &["<a>", "<b>"], syntax);
3293    }
3294
3295    #[test]
3296    fn can_include_nested_backrefs() {
3297        let syntax = SyntaxDefinition::load_from_str(
3298            r#"
3299                name: Backref Include Test
3300                scope: source.backrefinc
3301                contexts:
3302                  main:
3303                    - match: (a)
3304                      scope: a
3305                      push: context1
3306                  context1:
3307                    - include: context3
3308                  context3:
3309                    - include: context2
3310                  context2:
3311                    - match: \1
3312                      scope: b
3313                      pop: true
3314                "#,
3315            true,
3316            None,
3317        )
3318        .unwrap();
3319
3320        expect_scope_stacks_with_syntax("aa", &["<a>", "<b>"], syntax);
3321    }
3322
3323    #[test]
3324    fn can_avoid_infinite_stack_depth() {
3325        let syntax = SyntaxDefinition::load_from_str(
3326            r#"
3327                name: Stack Depth Test
3328                scope: source.stack_depth
3329                contexts:
3330                  main:
3331                    - match: (a)
3332                      scope: a
3333                      push: context1
3334
3335                    
3336                  context1:
3337                    - match: b
3338                      scope: b
3339                    - match: ''
3340                      push: context1
3341                    - match: ''
3342                      pop: 1
3343                    - match: c
3344                      scope: c
3345                "#,
3346            true,
3347            None,
3348        )
3349        .unwrap();
3350
3351        let syntax_set = link(syntax);
3352        let mut state = ParseState::new(&syntax_set.syntaxes()[0]);
3353        expect_scope_stacks_for_ops(ops(&mut state, "a bc\n", &syntax_set), &["<a>"]);
3354        expect_scope_stacks_for_ops(ops(&mut state, "bc\n", &syntax_set), &["<b>"]);
3355    }
3356
3357    /// Regression guard for the "extends double-inserts top_level_scope"
3358    /// bug: `add_initial_contexts` runs once during initial YAML load and
3359    /// again from `resolve_extends` after a child inherits its parent's
3360    /// contexts. On the second run `main.meta_content_scope` already
3361    /// begins with the child's top-level scope from the first run; if the
3362    /// code naively re-inserts at position 0 and re-copies to `__main`,
3363    /// the file scope ends up pushed twice at the start of every parse
3364    /// (observed as `[source.diff.git, source.diff.git]` on Git Diff and
3365    /// all the Rails (Rails) syntaxes, which broke assertions of the
3366    /// form `- source source`). The copy to `__main` must strip an
3367    /// already-present top_level_scope prefix, and the insert into
3368    /// Regression guard for the "`meta_append` / `meta_prepend` resets
3369    /// `meta_include_prototype` to its default" bug: the SQL base
3370    /// declares `inside-like-single-quoted-string` with
3371    /// `meta_include_prototype: false` so its `--` comment rule (from
3372    /// the SQL prototype) does NOT fire inside LIKE strings. TSQL extends
3373    /// that context with `meta_append: true` to add a `[…]` character-set
3374    /// rule, but doesn't restate `meta_include_prototype: false`. Before
3375    /// the fix, the merge in `syntax_set.rs` left the child's default
3376    /// `meta_include_prototype: true`, so the SQL prototype attached to
3377    /// the merged context, and `--` inside LIKE strings was scoped as
3378    /// a comment — 4,918 cascading assertion failures in
3379    /// `syntax_test_tsql.sql`.
3380    ///
3381    /// Synthetic shape: a parent with a `prototype` matching `--` as a
3382    /// comment, a base context with `meta_include_prototype: false`,
3383    /// and a child that extends the parent and `meta_append`s a single
3384    /// rule to that base context without restating
3385    /// `meta_include_prototype`. After merge, `--` inside the base
3386    /// context's matched span must NOT take a `comment.*` scope.
3387    #[test]
3388    fn meta_append_inherits_meta_include_prototype_from_parent() {
3389        use crate::parsing::syntax_set::SyntaxSetBuilder;
3390
3391        let dir =
3392            std::env::temp_dir().join(format!("syntect-meta-append-test-{}", std::process::id()));
3393        let _ = std::fs::remove_dir_all(&dir);
3394        std::fs::create_dir_all(&dir).unwrap();
3395        std::fs::write(
3396            dir.join("parent.sublime-syntax"),
3397            r#"
3398name: Parent
3399scope: source.parent
3400file_extensions: [parent]
3401contexts:
3402  prototype:
3403    - match: '--'
3404      scope: punctuation.definition.comment
3405      push: comment-body
3406  comment-body:
3407    - meta_scope: comment.line
3408    - match: $
3409      pop: 1
3410  main:
3411    - match: \bopen\b
3412      push: inside
3413  inside:
3414    - meta_include_prototype: false
3415    - meta_scope: meta.inside
3416    - match: \bclose\b
3417      pop: 1
3418"#,
3419        )
3420        .unwrap();
3421        std::fs::write(
3422            dir.join("child.sublime-syntax"),
3423            r#"
3424name: Child
3425scope: source.child
3426file_extensions: [child]
3427extends: parent.sublime-syntax
3428contexts:
3429  inside:
3430    - meta_append: true
3431    - match: '!'
3432      scope: punctuation.bang.child
3433"#,
3434        )
3435        .unwrap();
3436        let mut builder = SyntaxSetBuilder::new();
3437        builder.add_from_folder(&dir, true).unwrap();
3438        let ss = builder.build();
3439        let syntax = ss
3440            .find_syntax_by_scope(Scope::new("source.child").unwrap())
3441            .unwrap();
3442        let mut state = ParseState::new(syntax);
3443        let o = ops(&mut state, "open -- close\n", &ss);
3444        let _ = std::fs::remove_dir_all(&dir);
3445        let comment_pushes = o
3446            .iter()
3447            .filter(|(_, op)| {
3448                matches!(op, ScopeStackOp::Push(s) if format!("{:?}", s).contains("comment"))
3449            })
3450            .count();
3451        assert_eq!(
3452            comment_pushes, 0,
3453            "`--` inside the inside context (meta_include_prototype: false in parent) \
3454             must not match the parent's prototype comment rule after meta_append merge; \
3455             ops were: {:?}",
3456            o
3457        );
3458    }
3459
3460    /// `main` must be idempotent.
3461    #[test]
3462    fn extending_syntax_does_not_double_push_top_level_scope() {
3463        use crate::parsing::SyntaxSet;
3464        let ss = SyntaxSet::load_from_folder("testdata/Packages").unwrap();
3465        // Git Diff extends Diff (Basic) — a concrete case of the bug.
3466        let syntax = ss.find_syntax_by_name("Git Diff").unwrap();
3467        let mut state = ParseState::new(syntax);
3468        let o = ops(
3469            &mut state,
3470            "From 1234567890 Mon Sep 17 00:00:00 2001\n",
3471            &ss,
3472        );
3473        let source_pushes = o
3474            .iter()
3475            .filter(|(_, op)| matches!(op, ScopeStackOp::Push(s) if format!("{:?}", s) == "<source.diff.git>"))
3476            .count();
3477        assert_eq!(
3478            source_pushes, 1,
3479            "source.diff.git should be pushed exactly once for the file's top-level scope; ops were: {:?}",
3480            o
3481        );
3482    }
3483
3484    /// Regression guard for the "branch_point match loses its own scope
3485    /// on fail-retry" bug: when the keyword that triggers a
3486    /// branch_point (e.g. `LIKE` in SQL with
3487    /// `scope: keyword.operator.comparison.sql`) has its first
3488    /// alternative fail, the Push/Pop for that scope was truncated off
3489    /// `ops` along with alt[0]'s subsequent work and never re-emitted.
3490    /// The eventual successful alternative then produced a parse where
3491    /// the keyword carried no scope — 4,942 cascading assertion
3492    /// failures in TSQL.
3493    ///
3494    /// The test triggers the same shape synthetically: a `trigger` match
3495    /// with its own scope branches into two alternatives; the first
3496    /// fails, the second succeeds; the `trigger` token must still carry
3497    /// the declared scope after the retry.
3498    #[test]
3499    fn branch_point_match_scope_survives_fail_retry() {
3500        // Expected: `trigger` gets `keyword.operator.test`; the
3501        // following word gets `ok.test` (via alt-succeeds).
3502        expect_scope_stacks(
3503            "trigger yes",
3504            &["<keyword.operator.test>", "<ok.test>"],
3505            r#"
3506                name: Branch Pat Scope Test
3507                scope: source.test
3508                contexts:
3509                  main:
3510                    - match: \btrigger\b
3511                      scope: keyword.operator.test
3512                      branch_point: t
3513                      branch:
3514                        - alt-fails
3515                        - alt-succeeds
3516                    - match: \S+
3517                      scope: text.test
3518                  alt-fails:
3519                    - match: (?=\S)
3520                      fail: t
3521                  alt-succeeds:
3522                    - match: \S+
3523                      scope: ok.test
3524                      pop: 1
3525                "#,
3526        );
3527    }
3528
3529    /// Regression guard for "branch_point fail-retry drops the
3530    /// trigger match's `captures:` scopes". The non-fail path emits
3531    /// capture Push/Pop ops inside the pat_scope brackets; the
3532    /// same-line fail re-emit must do the same — otherwise the
3533    /// inner capture scopes are truncated off `ops` together with
3534    /// alt[0]'s subsequent work and never replayed. Observed on
3535    /// Haskell's `data CtxCls ctx => ModId.QTyCls`, where the
3536    /// `(data)(?:\s+(family|instance))?` branch_point match's first
3537    /// capture `keyword.declaration.data.haskell` was dropped from
3538    /// the `data` token whenever `data-signature` failed into
3539    /// `data-context` — 22 assertion failures in
3540    /// `syntax_test_haskell.hs`.
3541    #[test]
3542    fn branch_point_capture_scopes_survive_fail_retry() {
3543        // The `(word)\s` branch_point match carries both `scope:`
3544        // and `captures:`. Alt[0] fails on the `!` lookahead,
3545        // forcing replay into alt[1]. `inner.capture` on the first
3546        // capture group must remain on the stack over `word`.
3547        expect_scope_stacks(
3548            "word !",
3549            &["<outer.match>, <inner.capture>"],
3550            r#"
3551                name: Branch Capture Re-emit Test
3552                scope: source.test
3553                contexts:
3554                  main:
3555                    - match: (word)\s
3556                      scope: outer.match
3557                      captures:
3558                        1: inner.capture
3559                      branch_point: bp
3560                      branch:
3561                        - alt-fails
3562                        - alt-succeeds
3563                  alt-fails:
3564                    - match: (?=!)
3565                      fail: bp
3566                  alt-succeeds:
3567                    - match: \S+
3568                      scope: ok.test
3569                      pop: 1
3570                "#,
3571        );
3572    }
3573
3574    /// Regression guard for "branch_point fail-retry drops the new
3575    /// alternative's `meta_scope` from the trigger character". The
3576    /// non-fail push path emits the new context's `meta_scope` at
3577    /// `match_start` so the matched text sees it. The same-line
3578    /// fail re-emit must do the same — emit `meta_scope` (and any
3579    /// `clear_scopes`) at `trigger_match_start`, before the
3580    /// trigger's `pat.scope`. Placing them after the match meant
3581    /// `for (var i = 0; …)` parsed the `(` with
3582    /// `[meta.for.js, punctuation.section.group.begin.js]` instead
3583    /// of `[meta.for.js, meta.group.js, punctuation.section.group.begin.js]`,
3584    /// failing eight assertions in `syntax_test_js_control.js`.
3585    #[test]
3586    fn branch_point_fail_retry_applies_meta_scope_to_trigger() {
3587        // Mirrors the JS for-loop shape: `\(` triggers a branch with
3588        // `pop: 1`; alt 0 fails, alt 1 succeeds; alt 1 has a
3589        // `meta_scope` that must wrap the `(` itself.
3590        expect_scope_stacks(
3591            "(x",
3592            &["<meta.group.test>, <punctuation.test>"],
3593            r#"
3594                name: Branch Meta Scope Test
3595                scope: source.test
3596                contexts:
3597                  main:
3598                    - match: ''
3599                      push: trigger
3600                  trigger:
3601                    - match: \(
3602                      scope: punctuation.test
3603                      branch_point: g
3604                      branch:
3605                        - alt-fails
3606                        - alt-succeeds
3607                      pop: 1
3608                  alt-fails:
3609                    - meta_scope: meta.group.test
3610                    - match: (?=\S)
3611                      fail: g
3612                  alt-succeeds:
3613                    - meta_scope: meta.group.test
3614                    - match: \S+
3615                      scope: ok.test
3616                      pop: 1
3617                "#,
3618        );
3619    }
3620
3621    /// Category A proper regression guard: a same-line `branch_point`
3622    /// whose alternatives all `fail` must unwind to the pre-branch
3623    /// snapshot and advance the cursor, rather than leaving the stack
3624    /// stuck in the last attempted alternative. This was the cause of
3625    /// the Zsh `meta.interpolation.brace.shell never pops` cascade
3626    /// (Zsh excludes the usual `brace-interpolation-fallback` branch,
3627    /// so `{no}` exhausted both `sequence` and `series` alternatives
3628    /// and the parser silently left the scope stack inside
3629    /// `brace-interpolation-series-begin`).
3630    #[test]
3631    fn branch_point_with_all_alternatives_failing_unwinds_state() {
3632        let syntax = SyntaxDefinition::load_from_str(
3633            r#"
3634                name: All Alternatives Fail Test
3635                scope: source.test
3636                contexts:
3637                  main:
3638                    - match: (?=\{)
3639                      branch_point: brace
3640                      branch:
3641                        - brace-strict
3642                        - brace-numeric
3643                    - match: \w+
3644                      scope: plain.test
3645                  brace-strict:
3646                    - meta_scope: meta.interpolation.brace.test
3647                    - match: \{
3648                      scope: punctuation.begin.test
3649                      push: brace-strict-body
3650                  brace-strict-body:
3651                    - meta_content_scope: inside-strict.test
3652                    - match: foo
3653                      scope: keyword.test
3654                    - match: \}
3655                      scope: punctuation.end.test
3656                      pop: 2
3657                    - match: (?=\S)
3658                      fail: brace
3659                  brace-numeric:
3660                    - meta_scope: meta.interpolation.brace.test
3661                    - match: \{
3662                      scope: punctuation.begin.test
3663                      push: brace-numeric-body
3664                  brace-numeric-body:
3665                    - meta_content_scope: inside-numeric.test
3666                    - match: \d+
3667                      scope: constant.numeric.test
3668                    - match: \}
3669                      scope: punctuation.end.test
3670                      pop: 2
3671                    - match: (?=\S)
3672                      fail: brace
3673                "#,
3674            true,
3675            None,
3676        )
3677        .unwrap();
3678
3679        let syntax_set = link(syntax);
3680        let mut state = ParseState::new(&syntax_set.syntaxes()[0]);
3681        // `{no}` — neither strict (expects `foo`) nor numeric (expects
3682        // digits) matches, so both branches fail. Before the fix, the
3683        // stack stayed in `brace-numeric-body` across the `\n`.
3684        let o = ops(&mut state, "{no}\n", &syntax_set);
3685        let mut stack = ScopeStack::new();
3686        for (_, op) in &o {
3687            stack.apply(op).unwrap();
3688        }
3689        let final_scopes: Vec<String> = stack
3690            .as_slice()
3691            .iter()
3692            .map(|s| format!("{:?}", s))
3693            .collect();
3694        assert!(
3695            !final_scopes
3696                .iter()
3697                .any(|s| s.contains("meta.interpolation.brace")),
3698            "meta.interpolation.brace leaked past end of line; stack: {:?}",
3699            final_scopes
3700        );
3701        assert!(
3702            !final_scopes
3703                .iter()
3704                .any(|s| s.contains("inside-strict") || s.contains("inside-numeric")),
3705            "inside-* meta_content_scope leaked past end of line; stack: {:?}",
3706            final_scopes
3707        );
3708    }
3709
3710    /// Regression guard for the "cross-line branch_point exhaustion
3711    /// leaves contexts on the stack forever" bug: when ALL
3712    /// alternatives of a `branch_point` fail on a line *after* the
3713    /// branch was created, the parser must restore the pre-branch
3714    /// snapshot, truncate ops, and replay the buffered lines under
3715    /// the restored state. Pre-fix, the cross-line exhaustion path
3716    /// silently removed the branch record while leaving the last
3717    /// alternative's pushed contexts on the state stack — 274
3718    /// assertion failures in `syntax_test_typescript.ts` and
3719    /// another 10 in `syntax_test_C#9.cs` cascaded from that ghost
3720    /// state (`sublimehq/Packages#3598`'s incomplete
3721    /// `type x = { bar: (cb: ( };` was the minimal reproducer).
3722    ///
3723    /// Shape: a `branch_point` with two alternatives, each with a
3724    /// distinctive `meta_scope` and a `\w+` rule scoped by the
3725    /// alternative. Line 1 fires the branch; alt[0] consumes the
3726    /// newline and stays active. Line 2 fires `fail: bp` from
3727    /// alt[0] (cross-line retry into alt[1]), then the replay puts
3728    /// alt[1] on the stack, re-parses line 2, and alt[1] also fires
3729    /// `fail: bp` — cross-line exhaustion. After line 2:
3730    ///   - `is_speculative` must be false (branch record gone);
3731    ///   - a subsequent benign line must parse under the pre-branch
3732    ///     context (`main`), not under a leaked alternative. Pre-fix,
3733    ///     `beta` remained on the stack and the next line's `\w+`
3734    ///     scoped as `beta.word.cle` instead of `main.word.cle`.
3735    #[test]
3736    fn cross_line_branch_exhaustion_unwinds_state() {
3737        let syntax_str = r#"
3738name: CrossLineExhaustion
3739scope: source.cle
3740contexts:
3741  main:
3742    - match: 'TRY'
3743      scope: trigger.cle
3744      branch_point: bp
3745      branch: [alpha, beta]
3746    - match: '\w+'
3747      scope: main.word.cle
3748  alpha:
3749    - meta_scope: meta.alpha.cle
3750    - match: '\n'
3751    - match: 'FAIL'
3752      fail: bp
3753    - match: '\w+'
3754      scope: alpha.word.cle
3755  beta:
3756    - meta_scope: meta.beta.cle
3757    - match: '\n'
3758    - match: 'FAIL'
3759      fail: bp
3760    - match: '\w+'
3761      scope: beta.word.cle
3762"#;
3763        let syntax = SyntaxDefinition::load_from_str(syntax_str, true, None).unwrap();
3764        let ss = link(syntax);
3765        let mut state = ParseState::new(&ss.syntaxes()[0]);
3766
3767        // Line 1: `TRY` fires branch `bp`; alt[0] `alpha` is pushed
3768        // and consumes the trailing newline, staying on the stack.
3769        let _out1 = state.parse_line("TRY\n", &ss).expect("parse line 1");
3770
3771        // Line 2: alpha's `FAIL` rule fires `fail: bp` — cross-line
3772        // retry into `beta`. The beta replay leaves beta on the
3773        // stack; the re-parse of line 2 under beta hits beta's
3774        // `FAIL` rule, firing `fail: bp` again with no alternatives
3775        // left — cross-line exhaustion.
3776        let out2 = state.parse_line("FAIL\n", &ss).expect("parse line 2");
3777
3778        // Exhaustion must clear every branch_point record.
3779        assert!(
3780            !state.is_speculative(),
3781            "cross-line exhaustion must drop all branch_point records"
3782        );
3783
3784        // The exhaustion path replays buffered lines under the
3785        // restored pre-branch state, so `replayed` is non-empty.
3786        assert!(
3787            !out2.replayed.is_empty(),
3788            "cross-line exhaustion must emit replayed ops for the pre-branch state"
3789        );
3790
3791        // Strong invariant: the subsequent line must be parsed under
3792        // `main` (the pre-branch context) — not under whichever
3793        // alternative was last active. Pre-fix, `beta` stayed on the
3794        // stack and `benign` would have scoped as `beta.word.cle`.
3795        let out3 = state.parse_line("benign\n", &ss).expect("parse line 3");
3796        let pushed: Vec<String> = out3
3797            .ops
3798            .iter()
3799            .filter_map(|(_, op)| match op {
3800                ScopeStackOp::Push(s) => Some(format!("{:?}", s)),
3801                _ => None,
3802            })
3803            .collect();
3804
3805        assert!(
3806            pushed.iter().any(|s| s.contains("main.word.cle")),
3807            "post-exhaustion line must be scoped under main; got pushes: {:?}",
3808            pushed
3809        );
3810        for leaked in [
3811            "meta.alpha.cle",
3812            "meta.beta.cle",
3813            "alpha.word.cle",
3814            "beta.word.cle",
3815        ] {
3816            assert!(
3817                !pushed.iter().any(|s| s.contains(leaked)),
3818                "{} leaked into post-exhaustion line; got pushes: {:?}",
3819                leaked,
3820                pushed
3821            );
3822        }
3823    }
3824
3825    /// Category E regression guard: a cross-line `fail` that triggers
3826    /// a replay which itself adds and removes branch points must not
3827    /// out-of-bounds-index the original `bp_index` afterwards. This
3828    /// test is a targeted end-to-end probe; the real reproduction lives
3829    /// in `testdata/Packages/JavaScript/tests/syntax_test_js.js` and
3830    /// `syntax_test_typescript.ts`, where nested cross-line branching
3831    /// previously panicked at `parser.rs:1014`. The guard leaves the
3832    /// scope-op stream consistent enough for the syntest harness's
3833    /// `catch_unwind` to report a file-level `PANIC` rather than
3834    /// crashing the whole run — it does not attempt to produce
3835    /// correct ops for the failing file (the replay-consistency issue
3836    /// is tracked as a follow-up).
3837    #[test]
3838    #[ignore = "requires testdata/Packages submodule"]
3839    fn cross_line_fail_with_nested_branch_does_not_panic() {
3840        use crate::parsing::SyntaxSet;
3841        use std::panic::AssertUnwindSafe;
3842        let ss = SyntaxSet::load_from_folder("testdata/Packages").unwrap();
3843        let syntax = ss
3844            .find_syntax_by_path("Packages/JavaScript/JavaScript.sublime-syntax")
3845            .unwrap();
3846        let path = "testdata/Packages/JavaScript/tests/syntax_test_js.js";
3847        let content = std::fs::read_to_string(path).unwrap();
3848        let mut state = ParseState::new(syntax);
3849        // Wrap in catch_unwind so a later unrelated panic from the
3850        // replay-consistency issue doesn't mask the parser.rs:1014
3851        // regression we care about.
3852        let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
3853            for line in content.lines() {
3854                let mut s = line.to_string();
3855                s.push('\n');
3856                let _ = state.parse_line(&s, &ss);
3857            }
3858        }));
3859        if let Err(payload) = result {
3860            // Extract the panic message and assert it is NOT the
3861            // bp_index out-of-bounds at parser.rs:1014.
3862            let msg = if let Some(s) = payload.downcast_ref::<&'static str>() {
3863                (*s).to_string()
3864            } else if let Some(s) = payload.downcast_ref::<String>() {
3865                s.clone()
3866            } else {
3867                String::from("<non-string panic payload>")
3868            };
3869            assert!(
3870                !msg.contains("index out of bounds"),
3871                "parser panicked with bounds violation (Category E \
3872                 regression): {msg}"
3873            );
3874            // A different panic (e.g. from the replay-consistency
3875            // issue) is acceptable here — that's tracked separately.
3876        }
3877    }
3878
3879    /// Minimal repro of the Category A "pop: N loses deeper contexts'
3880    /// scopes" bug. Two pushed contexts A and B (with B on top): A has
3881    /// `meta_scope: outer`, B has `meta_content_scope: inner`. When B
3882    /// fires `pop: 2`, the scope stack must come fully back to the base
3883    /// — before the fix, A's `outer` was orphaned on the scope stack
3884    /// because `push_meta_ops` only emitted pops for the top context.
3885    /// Checked against the scope stack produced by the ops (the
3886    /// context-stack pop already worked; the scope-stack pop did not).
3887    #[test]
3888    fn pop_n_unwinds_all_n_contexts_meta_scopes() {
3889        let syntax = SyntaxDefinition::load_from_str(
3890            r#"
3891                name: Pop N Test
3892                scope: source.test
3893                contexts:
3894                  main:
3895                    - match: \(
3896                      scope: open
3897                      push: [outer, inner]
3898                  outer:
3899                    - meta_scope: outer.test
3900                  inner:
3901                    - meta_content_scope: inner.test
3902                    - match: \)
3903                      scope: close
3904                      pop: 2
3905                "#,
3906            true,
3907            None,
3908        )
3909        .unwrap();
3910
3911        let syntax_set = link(syntax);
3912        let mut state = ParseState::new(&syntax_set.syntaxes()[0]);
3913        let o = ops(&mut state, "(x)\n", &syntax_set);
3914        let mut stack = ScopeStack::new();
3915        for (_, op) in &o {
3916            stack.apply(op).unwrap();
3917        }
3918        let final_scopes: Vec<String> = stack
3919            .as_slice()
3920            .iter()
3921            .map(|s| format!("{:?}", s))
3922            .collect();
3923        assert!(
3924            !final_scopes.iter().any(|s| s.contains("outer.test")),
3925            "outer.test meta_scope leaked past pop: 2; final stack: {:?}",
3926            final_scopes
3927        );
3928        assert!(
3929            !final_scopes.iter().any(|s| s.contains("inner.test")),
3930            "inner.test meta_content_scope leaked past pop: 2; final stack: {:?}",
3931            final_scopes
3932        );
3933    }
3934
3935    /// End-to-end check that `make syntest`'s Makefile failure has no
3936    /// harness-level cause: loads the real Packages Makefile syntax
3937    /// and parses two lines, asserting that after `bar := $(foo)\n`
3938    /// the scope stack no longer carries `meta.string.makefile` when
3939    /// the next source line is parsed. Gated on the test-assets
3940    /// being available; marked `#[ignore]` so it runs with
3941    /// `cargo test -- --ignored` in the repo root (the Packages
3942    /// submodule is required).
3943    #[test]
3944    #[ignore = "requires testdata/Packages submodule"]
3945    fn makefile_meta_string_does_not_leak_past_eol() {
3946        use crate::parsing::SyntaxSet;
3947        let ss = SyntaxSet::load_from_folder("testdata/Packages").unwrap();
3948        let syntax = ss
3949            .find_syntax_by_path("Packages/Makefile/Makefile.sublime-syntax")
3950            .unwrap();
3951        let mut state = ParseState::new(syntax);
3952        let mut stack = ScopeStack::new();
3953        for (_, op) in ops(&mut state, "bar := $(foo)\n", &ss) {
3954            stack.apply(&op).unwrap();
3955        }
3956        let after_assignment: Vec<String> = stack
3957            .as_slice()
3958            .iter()
3959            .map(|s| format!("{:?}", s))
3960            .collect();
3961        assert!(
3962            !after_assignment
3963                .iter()
3964                .any(|s| s.contains("meta.string.makefile")),
3965            "meta.string.makefile leaks past EOL of `bar := $(foo)\\n`; stack: {:?}",
3966            after_assignment
3967        );
3968    }
3969
3970    /// Triage repro for Category A (Zsh/TSQL/Makefile "context never
3971    /// pops" cascade) — models the shape used by Makefile's variable
3972    /// definitions: a lookahead push, then `set: [value, eat]` with a
3973    /// zero-width match inside `value` that `set`s to a third context
3974    /// carrying `meta_content_scope` and `include`ing an EOL popper.
3975    ///
3976    /// On `bar\n`, after the line terminates the stack should hold no
3977    /// atoms of `meta.string.test`; without the fix the scope leaks to
3978    /// the next line because the chained `set`s leave the popper
3979    /// without a valid non-consuming push recorded for loop protection,
3980    /// so the zero-width `$` match ends up guarded as a potential loop.
3981    #[test]
3982    fn chained_set_with_included_eol_popper_pops_at_line_boundary() {
3983        let syntax = SyntaxDefinition::load_from_str(
3984            r#"
3985                name: EOL Pop Chained Test
3986                scope: source.test
3987                contexts:
3988                  main:
3989                    - match: (?=\S)
3990                      push: outer
3991                  outer:
3992                    - match: ''
3993                      set: [value-body, eat-whitespace-then-pop]
3994                  eat-whitespace-then-pop:
3995                    - match: \s*
3996                      pop: 1
3997                  value-body:
3998                    - match: ''
3999                      set: value-content
4000                  value-content:
4001                    - meta_content_scope: meta.string.test
4002                    - include: pop-on-eol
4003                  pop-on-eol:
4004                    - match: $
4005                      pop: 1
4006                "#,
4007            true,
4008            None,
4009        )
4010        .unwrap();
4011
4012        let syntax_set = link(syntax);
4013        let mut state = ParseState::new(&syntax_set.syntaxes()[0]);
4014        let o = ops(&mut state, "bar\n", &syntax_set);
4015
4016        // Apply ops against a fresh ScopeStack and check the final set
4017        // of live scope atoms — the meta_content_scope must not survive
4018        // across the `\n` boundary.
4019        let mut stack = ScopeStack::new();
4020        for (_, op) in &o {
4021            stack.apply(op).unwrap();
4022        }
4023        let final_scopes: Vec<String> = stack
4024            .as_slice()
4025            .iter()
4026            .map(|s| format!("{:?}", s))
4027            .collect();
4028        assert!(
4029            !final_scopes.iter().any(|s| s.contains("meta.string.test")),
4030            "meta.string.test leaked past EOL; final scope stack: {:?}",
4031            final_scopes
4032        );
4033    }
4034
4035    fn expect_scope_stacks(line_without_newline: &str, expect: &[&str], syntax: &str) {
4036        println!("Parsing with newlines");
4037        let line_with_newline = format!("{}\n", line_without_newline);
4038        let syntax_newlines = SyntaxDefinition::load_from_str(syntax, true, None).unwrap();
4039        expect_scope_stacks_with_syntax(&line_with_newline, expect, syntax_newlines);
4040
4041        println!("Parsing without newlines");
4042        let syntax_nonewlines = SyntaxDefinition::load_from_str(syntax, false, None).unwrap();
4043        expect_scope_stacks_with_syntax(line_without_newline, expect, syntax_nonewlines);
4044    }
4045
4046    fn expect_scope_stacks_with_syntax(line: &str, expect: &[&str], syntax: SyntaxDefinition) {
4047        // check that each expected scope stack appears at least once while parsing the given test line
4048
4049        let syntax_set = link(syntax);
4050        let mut state = ParseState::new(&syntax_set.syntaxes()[0]);
4051        let ops = ops(&mut state, line, &syntax_set);
4052        expect_scope_stacks_for_ops(ops, expect);
4053    }
4054
4055    fn expect_scope_stacks_for_ops(ops: Vec<(usize, ScopeStackOp)>, expect: &[&str]) {
4056        let mut criteria_met = Vec::new();
4057        for stack_str in stack_states(ops) {
4058            println!("{}", stack_str);
4059            for expectation in expect.iter() {
4060                if stack_str.contains(expectation) {
4061                    criteria_met.push(expectation);
4062                }
4063            }
4064        }
4065        if let Some(missing) = expect.iter().find(|e| !criteria_met.contains(e)) {
4066            panic!("expected scope stack '{}' missing", missing);
4067        }
4068    }
4069
4070    fn parse(line: &str, syntax: &str) -> Vec<(usize, ScopeStackOp)> {
4071        let syntax = SyntaxDefinition::load_from_str(syntax, true, None).unwrap();
4072        let syntax_set = link(syntax);
4073
4074        let mut state = ParseState::new(&syntax_set.syntaxes()[0]);
4075        ops(&mut state, line, &syntax_set)
4076    }
4077
4078    fn link(syntax: SyntaxDefinition) -> SyntaxSet {
4079        let mut builder = SyntaxSetBuilder::new();
4080        builder.add(syntax);
4081        builder.build()
4082    }
4083
4084    fn ops(
4085        state: &mut ParseState,
4086        line: &str,
4087        syntax_set: &SyntaxSet,
4088    ) -> Vec<(usize, ScopeStackOp)> {
4089        let output = state.parse_line(line, syntax_set).expect("#[cfg(test)]");
4090        debug_print_ops(line, &output.ops);
4091        output.ops
4092    }
4093
4094    fn stack_states(ops: Vec<(usize, ScopeStackOp)>) -> Vec<String> {
4095        let mut states = Vec::new();
4096        let mut stack = ScopeStack::new();
4097        for (_, op) in ops.iter() {
4098            stack.apply(op).expect("#[cfg(test)]");
4099            let scopes: Vec<String> = stack
4100                .as_slice()
4101                .iter()
4102                .map(|s| format!("{:?}", s))
4103                .collect();
4104            let stack_str = scopes.join(", ");
4105            states.push(stack_str);
4106        }
4107        states
4108    }
4109
4110    const BRANCH_SYNTAX: &str = r#"
4111scope: source.branch-test
4112contexts:
4113  main:
4114    - match: '(?=\S)'
4115      branch_point: stmt
4116      branch: [let-stmt, generic-stmt]
4117
4118  let-stmt:
4119    - match: 'let'
4120      scope: keyword.declaration.branch-test
4121      set: let-assign
4122    - match: '(?=\S)'
4123      fail: stmt
4124
4125  let-assign:
4126    - match: '='
4127      scope: keyword.operator.assignment.branch-test
4128      set: let-value
4129    - match: '(?=\S)'
4130      fail: stmt
4131
4132  let-value:
4133    - match: '\w+'
4134      scope: constant.other.branch-test
4135    - match: ';'
4136      scope: punctuation.terminator.branch-test
4137      pop: true
4138
4139  generic-stmt:
4140    - match: '[^;]+'
4141      scope: string.unquoted.branch-test
4142    - match: ';'
4143      scope: punctuation.terminator.branch-test
4144      pop: true
4145"#;
4146
4147    #[test]
4148    fn branch_first_alternative_succeeds() {
4149        // "let = foo;" should parse as a let-statement (first alternative)
4150        let syntax = SyntaxDefinition::load_from_str(BRANCH_SYNTAX, true, None).unwrap();
4151        let ss = link(syntax);
4152        let mut state = ParseState::new(&ss.syntaxes()[0]);
4153        let ops = ops(&mut state, "let = foo;", &ss);
4154        let states = stack_states(ops);
4155        // Should contain keyword.declaration and keyword.operator.assignment
4156        assert!(
4157            states.iter().any(|s| s.contains("keyword.declaration")),
4158            "Expected keyword.declaration scope, got: {:?}",
4159            states
4160        );
4161        assert!(
4162            states
4163                .iter()
4164                .any(|s| s.contains("keyword.operator.assignment")),
4165            "Expected keyword.operator.assignment scope, got: {:?}",
4166            states
4167        );
4168        assert!(
4169            states.iter().any(|s| s.contains("constant.other")),
4170            "Expected constant.other scope, got: {:?}",
4171            states
4172        );
4173    }
4174
4175    #[test]
4176    fn branch_fail_backtracks_to_second_alternative() {
4177        // "hello;" is not a let-statement, should fail and use generic-stmt
4178        let syntax = SyntaxDefinition::load_from_str(BRANCH_SYNTAX, true, None).unwrap();
4179        let ss = link(syntax);
4180        let mut state = ParseState::new(&ss.syntaxes()[0]);
4181        let ops = ops(&mut state, "hello;", &ss);
4182        let states = stack_states(ops);
4183        // Should contain string.unquoted (generic-stmt), not keyword.declaration
4184        assert!(
4185            states.iter().any(|s| s.contains("string.unquoted")),
4186            "Expected string.unquoted scope, got: {:?}",
4187            states
4188        );
4189        assert!(
4190            !states.iter().any(|s| s.contains("keyword.declaration")),
4191            "Should NOT contain keyword.declaration scope, got: {:?}",
4192            states
4193        );
4194    }
4195
4196    #[test]
4197    fn branch_fail_after_partial_match() {
4198        // "let hello;" — starts like a let-stmt ('let' matches) but no '=' follows, so fail
4199        let syntax = SyntaxDefinition::load_from_str(BRANCH_SYNTAX, true, None).unwrap();
4200        let ss = link(syntax);
4201        let mut state = ParseState::new(&ss.syntaxes()[0]);
4202        let raw_ops = ops(&mut state, "let hello;", &ss);
4203
4204        // After backtracking, keyword.declaration must be absent (ops.truncate removes it)
4205        let states = stack_states(raw_ops.clone());
4206        assert!(
4207            !states.iter().any(|s| s.contains("keyword.declaration")),
4208            "keyword.declaration should be absent after backtrack, got: {:?}",
4209            states
4210        );
4211
4212        // After backtracking, should use generic-stmt
4213        assert!(
4214            states.iter().any(|s| s.contains("string.unquoted")),
4215            "Expected string.unquoted scope after backtrack, got: {:?}",
4216            states
4217        );
4218
4219        // The string.unquoted push must start at position 0 (covers "let hello", not just "hello")
4220        let unquoted_pos = raw_ops.iter().find_map(|(pos, op)| match op {
4221            ScopeStackOp::Push(s) if format!("{:?}", s).contains("string.unquoted") => Some(*pos),
4222            _ => None,
4223        });
4224        assert_eq!(
4225            unquoted_pos,
4226            Some(0),
4227            "string.unquoted should start at position 0 after rewind, got: {:?}",
4228            unquoted_pos
4229        );
4230    }
4231
4232    #[test]
4233    fn branch_all_alternatives_exhausted() {
4234        // Test with a syntax where all alternatives fail — should not panic
4235        let syntax_str = r#"
4236scope: source.exhaust-test
4237contexts:
4238  main:
4239    - match: '(?=\S)'
4240      branch_point: bp
4241      branch: [alt-a, alt-b]
4242    - match: '\S+'
4243      scope: fallback.exhaust-test
4244
4245  alt-a:
4246    - match: 'AAA'
4247      scope: alt-a.exhaust-test
4248      pop: true
4249    - match: '(?=\S)'
4250      fail: bp
4251
4252  alt-b:
4253    - match: 'BBB'
4254      scope: alt-b.exhaust-test
4255      pop: true
4256    - match: '(?=\S)'
4257      fail: bp
4258"#;
4259        let syntax = SyntaxDefinition::load_from_str(syntax_str, true, None).unwrap();
4260        let ss = link(syntax);
4261        let mut state = ParseState::new(&ss.syntaxes()[0]);
4262        // "xyz" matches neither AAA nor BBB
4263        let ops = ops(&mut state, "xyz", &ss);
4264        // Should not panic, and should eventually move past the input
4265        assert!(!ops.is_empty(), "Expected some ops, got empty");
4266    }
4267
4268    #[test]
4269    fn branch_fail_emits_meta_content_scope() {
4270        // The second alternative has meta_content_scope; after backtracking,
4271        // content inside it should have that scope applied.
4272        let syntax_str = r#"
4273scope: source.meta-test
4274contexts:
4275  main:
4276    - match: '(?=\S)'
4277      branch_point: bp
4278      branch: [try-special, fallback-ctx]
4279
4280  try-special:
4281    - match: 'SPECIAL'
4282      scope: keyword.meta-test
4283      pop: true
4284    - match: '(?=\S)'
4285      fail: bp
4286
4287  fallback-ctx:
4288    - meta_content_scope: meta.fallback.meta-test
4289    - match: '\w+'
4290      scope: variable.meta-test
4291      pop: true
4292"#;
4293        let syntax = SyntaxDefinition::load_from_str(syntax_str, true, None).unwrap();
4294        let ss = link(syntax);
4295        let mut state = ParseState::new(&ss.syntaxes()[0]);
4296        let ops = ops(&mut state, "hello", &ss);
4297        let states = stack_states(ops);
4298        // After backtracking to fallback-ctx, "hello" should have meta.fallback scope
4299        assert!(
4300            states.iter().any(|s| s.contains("meta.fallback")),
4301            "Expected meta.fallback.meta-test scope after backtrack, got: {:?}",
4302            states
4303        );
4304        assert!(
4305            states.iter().any(|s| s.contains("variable.meta-test")),
4306            "Expected variable.meta-test scope, got: {:?}",
4307            states
4308        );
4309    }
4310
4311    #[test]
4312    fn branch_fail_applies_with_prototype() {
4313        // The branch pattern has with_prototype; after backtracking to the second
4314        // alternative, the prototype should still be active.
4315        let syntax_str = r#"
4316scope: source.proto-test
4317contexts:
4318  main:
4319    - match: '(?=\S)'
4320      branch_point: bp
4321      branch: [try-num, fallback-word]
4322      with_prototype:
4323        - match: '#'
4324          scope: comment.proto-test
4325          pop: true
4326
4327  try-num:
4328    - match: '\d+'
4329      scope: constant.numeric.proto-test
4330      pop: true
4331    - match: '(?=\S)'
4332      fail: bp
4333
4334  fallback-word:
4335    - match: '\w+'
4336      scope: variable.proto-test
4337    - match: ';'
4338      pop: true
4339"#;
4340        let syntax = SyntaxDefinition::load_from_str(syntax_str, true, None).unwrap();
4341        let ss = link(syntax);
4342        let mut state = ParseState::new(&ss.syntaxes()[0]);
4343        // "abc#" — 'abc' matches fallback-word, '#' should trigger the prototype
4344        let ops = ops(&mut state, "abc#", &ss);
4345        let states = stack_states(ops);
4346        assert!(
4347            states.iter().any(|s| s.contains("variable.proto-test")),
4348            "Expected variable.proto-test scope, got: {:?}",
4349            states
4350        );
4351        assert!(
4352            states.iter().any(|s| s.contains("comment.proto-test")),
4353            "Expected comment.proto-test from with_prototype after backtrack, got: {:?}",
4354            states
4355        );
4356    }
4357
4358    #[test]
4359    fn branch_cross_line_backtrack() {
4360        // Syntax: "TRY" on line 1 triggers a branch_point.  try-ctx stays
4361        // active (consuming the trailing newline) so that it is still live on
4362        // line 2.  "FAIL" on line 2 fires `fail: bp`, which must rewind to
4363        // fallback-ctx and re-parse line 1 under that alternative.
4364        // After parsing line 2, `replayed` must contain corrected ops for
4365        // line 1 (with the `fallback.content` scope, not a `try.*` scope).
4366        let syntax_str = r#"
4367name: CrossLineTest
4368scope: source.clt
4369contexts:
4370  main:
4371    - match: 'TRY'
4372      branch_point: bp
4373      branch: [try-ctx, fallback-ctx]
4374    - match: '.*'
4375      scope: main.other
4376  try-ctx:
4377    - match: '\n'
4378      # consume newline, stay in context for the next line
4379    - match: 'FAIL'
4380      fail: bp
4381    - match: '\w+'
4382      scope: try.word
4383      pop: true
4384  fallback-ctx:
4385    - match: '.*'
4386      scope: fallback.content
4387      pop: true
4388"#;
4389        let syntax = SyntaxDefinition::load_from_str(syntax_str, true, None).unwrap();
4390        let ss = link(syntax);
4391        let mut state = ParseState::new(&ss.syntaxes()[0]);
4392
4393        // Line 1: triggers the branch, tries try-ctx first.
4394        // try-ctx consumes the newline and stays active.
4395        let out1 = state.parse_line("TRY\n", &ss).expect("parse line 1 failed");
4396        // replayed is empty on line 1 (no cross-line fail yet)
4397        assert!(
4398            out1.replayed.is_empty(),
4399            "line 1: expected no replayed ops, got {:?}",
4400            out1.replayed
4401        );
4402
4403        // Line 2: "FAIL" triggers fail: bp — cross-line backtrack.
4404        // `replayed` must contain re-parsed ops for line 1 under fallback-ctx.
4405        let out2 = state
4406            .parse_line("FAIL\n", &ss)
4407            .expect("parse line 2 failed");
4408        assert_eq!(
4409            out2.replayed.len(),
4410            1,
4411            "expected exactly one replayed line, got {:?}",
4412            out2.replayed
4413        );
4414        let has_fallback = out2.replayed[0].iter().any(|(_, op)| {
4415            matches!(op, ScopeStackOp::Push(s) if format!("{:?}", s).contains("fallback.content"))
4416        });
4417        assert!(
4418            has_fallback,
4419            "expected fallback.content scope in replayed line 1 ops, got: {:?}",
4420            out2.replayed[0]
4421        );
4422        // The try.word scope must NOT appear in the replayed ops.
4423        let has_try_word = out2.replayed[0].iter().any(|(_, op)| {
4424            matches!(op, ScopeStackOp::Push(s) if format!("{:?}", s).contains("try.word"))
4425        });
4426        assert!(
4427            !has_try_word,
4428            "try.word must not appear in replayed ops after backtrack, got: {:?}",
4429            out2.replayed[0]
4430        );
4431        // Verify current-line ops are clean (ops.clear() fired before re-parse)
4432        let current_has_try = out2.ops.iter().any(
4433            |(_, op)| matches!(op, ScopeStackOp::Push(s) if format!("{:?}", s).contains("try")),
4434        );
4435        assert!(
4436            !current_has_try,
4437            "current-line ops should not contain try.* scopes after cross-line fail, got: {:?}",
4438            out2.ops
4439        );
4440    }
4441
4442    #[test]
4443    fn cross_line_fail_preserves_pre_branch_prefix_ops() {
4444        // Replay of the first buffered line on a cross-line fail must
4445        // preserve the pre-branch prefix ops (which were correctly emitted
4446        // under the pre-branch state) rather than re-parsing the whole
4447        // line under the new alternative.
4448        //
4449        // Reduced from multi-line SQL `LIKE '…' ESCAPE '…'`: the first
4450        // buffered line contains a prefix (`prefix `) before the branch
4451        // trigger (`TRY`). Under the fallback alternative's rules, `prefix`
4452        // would be scoped as fallback.content from column 0 — but the
4453        // test expects the original `prefix.word` scope to survive the
4454        // replay because those characters were parsed under the pre-branch
4455        // (main) context.
4456        let syntax_str = r#"
4457name: CrossLinePrefix
4458scope: source.clp
4459contexts:
4460  main:
4461    - match: 'prefix'
4462      scope: prefix.word.clp
4463    - match: 'TRY'
4464      branch_point: bp
4465      branch: [try-ctx, fallback-ctx]
4466    - match: '\s+'
4467  try-ctx:
4468    - match: 'END'
4469      pop: true
4470    - match: 'FAIL'
4471      fail: bp
4472    - match: '\w+'
4473      scope: try.word.clp
4474    - match: '\s+'
4475  fallback-ctx:
4476    - match: 'END'
4477      pop: true
4478    - match: '\w+'
4479      scope: fallback.content.clp
4480    - match: '\s+'
4481"#;
4482        let syntax = SyntaxDefinition::load_from_str(syntax_str, true, None).unwrap();
4483        let ss = link(syntax);
4484        let mut state = ParseState::new(&ss.syntaxes()[0]);
4485
4486        // Line 1: "prefix TRY post\n" — prefix scoped by main, TRY triggers
4487        // branch, post is parsed under the chosen alternative.
4488        let _out1 = state
4489            .parse_line("prefix TRY post\n", &ss)
4490            .expect("parse line 1 failed");
4491
4492        // Line 2: "FAIL\n" — cross-line fail triggers replay of line 1.
4493        let out2 = state
4494            .parse_line("FAIL\n", &ss)
4495            .expect("parse line 2 failed");
4496        assert_eq!(
4497            out2.replayed.len(),
4498            1,
4499            "expected one replayed line, got {:?}",
4500            out2.replayed
4501        );
4502        // The replayed ops for line 1 must still push prefix.word at col 0
4503        // (from prefix_ops, emitted pre-branch), not overwrite with
4504        // fallback.content.
4505        let replayed_has_prefix = out2.replayed[0].iter().any(|(_, op)| {
4506            matches!(op, ScopeStackOp::Push(s) if format!("{:?}", s).contains("prefix.word"))
4507        });
4508        assert!(
4509            replayed_has_prefix,
4510            "replayed line must preserve prefix.word from pre-branch parse, got: {:?}",
4511            out2.replayed[0]
4512        );
4513        // fallback.content should appear for the post-TRY remainder.
4514        let replayed_has_fallback = out2.replayed[0].iter().any(|(_, op)| {
4515            matches!(op, ScopeStackOp::Push(s) if format!("{:?}", s).contains("fallback.content"))
4516        });
4517        assert!(
4518            replayed_has_fallback,
4519            "replayed line must apply fallback.content for post-branch remainder, got: {:?}",
4520            out2.replayed[0]
4521        );
4522    }
4523
4524    #[test]
4525    fn branch_point_expiry_after_128_lines() {
4526        // A branch point created on line 0 should be discarded when `fail`
4527        // fires after 129+ lines have elapsed, and a warning should be emitted.
4528        let syntax_str = r#"
4529name: ExpiryTest
4530scope: source.expiry-test
4531contexts:
4532  main:
4533    - match: 'START'
4534      branch_point: bp
4535      branch: [try-ctx, fallback-ctx]
4536    - match: '.*'
4537      scope: filler.expiry-test
4538  try-ctx:
4539    - match: '\n'
4540      # consume newlines, staying in context
4541    - match: 'FAIL'
4542      fail: bp
4543    - match: '\w+'
4544      scope: try.matched
4545      pop: true
4546  fallback-ctx:
4547    - match: '.*'
4548      scope: fallback.content
4549      pop: true
4550"#;
4551        let syntax = SyntaxDefinition::load_from_str(syntax_str, true, None).unwrap();
4552        let ss = link(syntax);
4553        let mut state = ParseState::new(&ss.syntaxes()[0]);
4554
4555        let out0 = state.parse_line("START\n", &ss).expect("parse START");
4556        assert!(out0.replayed.is_empty());
4557
4558        // Feed 129 empty lines to exceed the 128-line limit.
4559        // The pruning warning fires during the filler line that crosses the threshold.
4560        let mut all_warnings: Vec<String> = Vec::new();
4561        for _ in 0..129 {
4562            let out = state.parse_line("\n", &ss).expect("parse filler");
4563            all_warnings.extend(out.warnings);
4564        }
4565
4566        // Now fire fail — should be a no-op (branch point expired)
4567        let out_fail = state.parse_line("FAIL\n", &ss).expect("parse FAIL");
4568        all_warnings.extend(out_fail.warnings);
4569        assert!(
4570            out_fail.replayed.is_empty(),
4571            "branch point should have expired, but got replayed ops: {:?}",
4572            out_fail.replayed
4573        );
4574        assert!(
4575            all_warnings
4576                .iter()
4577                .any(|w| w.contains("expired") && w.contains("bp")),
4578            "expected a warning about branch point expiry, got: {:?}",
4579            all_warnings
4580        );
4581    }
4582
4583    #[test]
4584    fn branch_point_still_valid_at_128_lines() {
4585        // A branch point created on line 0 should still be alive when
4586        // exactly 128 lines have elapsed (boundary: 128 - 0 = 128 <= 128).
4587        let syntax_str = r#"
4588name: ExpiryTest
4589scope: source.expiry-test
4590contexts:
4591  main:
4592    - match: 'START'
4593      branch_point: bp
4594      branch: [try-ctx, fallback-ctx]
4595    - match: '.*'
4596      scope: filler.expiry-test
4597  try-ctx:
4598    - match: '\n'
4599      # consume newlines, staying in context
4600    - match: 'FAIL'
4601      fail: bp
4602    - match: '\w+'
4603      scope: try.matched
4604      pop: true
4605  fallback-ctx:
4606    - match: '.*'
4607      scope: fallback.content
4608      pop: true
4609"#;
4610        let syntax = SyntaxDefinition::load_from_str(syntax_str, true, None).unwrap();
4611        let ss = link(syntax);
4612        let mut state = ParseState::new(&ss.syntaxes()[0]);
4613
4614        let out0 = state.parse_line("START\n", &ss).expect("parse START");
4615        assert!(out0.replayed.is_empty());
4616
4617        // Feed exactly 127 filler lines so that FAIL lands on cur_line=128
4618        // (128 - 0 = 128 <= 128, so the branch point is still valid)
4619        let mut all_warnings: Vec<String> = Vec::new();
4620        for _ in 0..127 {
4621            let out = state.parse_line("\n", &ss).expect("parse filler");
4622            all_warnings.extend(out.warnings);
4623        }
4624
4625        // Fire fail — branch point should still be alive at the boundary
4626        let out_fail = state.parse_line("FAIL\n", &ss).expect("parse FAIL");
4627        all_warnings.extend(out_fail.warnings);
4628        assert!(
4629            !out_fail.replayed.is_empty(),
4630            "branch point should still be valid at exactly 128 lines, but got no replayed ops"
4631        );
4632        assert!(
4633            all_warnings.is_empty(),
4634            "expected no warnings at the 128-line boundary, got: {:?}",
4635            all_warnings
4636        );
4637        let has_fallback = out_fail.replayed[0].iter().any(|(_, op)| {
4638            matches!(op, ScopeStackOp::Push(s) if format!("{:?}", s).contains("fallback.content"))
4639        });
4640        assert!(
4641            has_fallback,
4642            "expected fallback.content in replayed ops, got: {:?}",
4643            out_fail.replayed[0]
4644        );
4645    }
4646
4647    #[test]
4648    fn branch_stack_depth_invalidation() {
4649        // Test two scenarios:
4650        // 1. fail fires when stack depth == bp depth (should succeed)
4651        // 2. fail fires when stack depth < bp depth (should be a no-op)
4652        let syntax_str = r#"
4653name: DepthTest
4654scope: source.depth-test
4655contexts:
4656  main:
4657    - match: '(?=\S)'
4658      branch_point: bp
4659      branch: [try-ctx, fallback-ctx]
4660  try-ctx:
4661    - match: 'OK'
4662      scope: try.ok
4663      set: post-try
4664    - match: '(?=\S)'
4665      fail: bp
4666  post-try:
4667    - match: 'FAIL'
4668      fail: bp
4669    - match: '\w+'
4670      scope: post.word
4671      pop: true
4672  fallback-ctx:
4673    - match: '.*'
4674      scope: fallback.content
4675      pop: true
4676"#;
4677        let syntax = SyntaxDefinition::load_from_str(syntax_str, true, None).unwrap();
4678        let ss = link(syntax);
4679
4680        // Scenario 1: fail at equal depth should succeed.
4681        // OK matches in try-ctx, `set` to post-try (depth unchanged since set = pop+push).
4682        // FAIL fires in post-try at the same depth as bp → backtrack succeeds.
4683        let mut state = ParseState::new(&ss.syntaxes()[0]);
4684        let line_ops = ops(&mut state, "OK FAIL\n", &ss);
4685        let states = stack_states(line_ops);
4686        assert!(
4687            states.iter().any(|s| s.contains("fallback.content")),
4688            "fail at equal depth should trigger backtrack to fallback, got: {:?}",
4689            states
4690        );
4691        assert!(
4692            !states.iter().any(|s| s.contains("try.ok")),
4693            "try.ok should be absent after backtrack, got: {:?}",
4694            states
4695        );
4696
4697        // Scenario 2: fail at shallower depth should be a no-op.
4698        // Use a syntax where the branch context pops before fail fires.
4699        let syntax_str2 = r#"
4700name: DepthTest2
4701scope: source.depth-test2
4702contexts:
4703  main:
4704    - match: 'GO'
4705      branch_point: bp
4706      branch: [try-ctx2, fallback-ctx2]
4707    - match: 'FAIL'
4708      fail: bp
4709    - match: '.*'
4710      scope: main.other
4711  try-ctx2:
4712    - match: 'OK'
4713      scope: try.ok2
4714      pop: true
4715    - match: '(?=\S)'
4716      fail: bp
4717  fallback-ctx2:
4718    - match: '.*'
4719      scope: fallback.content2
4720      pop: true
4721"#;
4722        let syntax2 = SyntaxDefinition::load_from_str(syntax_str2, true, None).unwrap();
4723        let ss2 = link(syntax2);
4724        let mut state2 = ParseState::new(&ss2.syntaxes()[0]);
4725        // GO pushes try-ctx2 (depth increases), OK pops back to main (depth decreases).
4726        // FAIL fires in main at depth < bp depth → no-op.
4727        let line_ops2 = ops(&mut state2, "GO OK FAIL\n", &ss2);
4728        let states2 = stack_states(line_ops2);
4729        assert!(
4730            !states2.iter().any(|s| s.contains("fallback.content2")),
4731            "fail should be a no-op when stack is shallower than branch point, got: {:?}",
4732            states2
4733        );
4734        assert!(
4735            states2.iter().any(|s| s.contains("try.ok2")),
4736            "expected try.ok2 from first alternative, got: {:?}",
4737            states2
4738        );
4739    }
4740
4741    #[test]
4742    fn branch_nested_overlapping_branch_points() {
4743        // Two branch points active simultaneously. The inner one fails,
4744        // the outer should remain valid.
4745        let syntax_str = r#"
4746name: NestedTest
4747scope: source.nested-test
4748contexts:
4749  main:
4750    - match: '(?=\S)'
4751      branch_point: outer
4752      branch: [outer-try, outer-fallback]
4753  outer-try:
4754    - match: 'A'
4755      scope: outer.a
4756      set: inner-branch
4757    - match: '(?=\S)'
4758      fail: outer
4759  inner-branch:
4760    - match: '(?=\S)'
4761      branch_point: inner
4762      branch: [inner-try, inner-fallback]
4763  inner-try:
4764    - match: 'X'
4765      scope: inner.x
4766      pop: true
4767    - match: '(?=\S)'
4768      fail: inner
4769  inner-fallback:
4770    - match: '\w+'
4771      scope: inner.fallback
4772      pop: true
4773  outer-fallback:
4774    - match: '.*'
4775      scope: outer.fallback
4776      pop: true
4777"#;
4778        let syntax = SyntaxDefinition::load_from_str(syntax_str, true, None).unwrap();
4779        let ss = link(syntax);
4780        let mut state = ParseState::new(&ss.syntaxes()[0]);
4781
4782        // "A B" — A matches outer-try, B fails inner → inner-fallback matches B
4783        let line_ops = ops(&mut state, "A B\n", &ss);
4784        let states = stack_states(line_ops);
4785        assert!(
4786            states.iter().any(|s| s.contains("inner.fallback")),
4787            "expected inner.fallback after inner branch fail, got: {:?}",
4788            states
4789        );
4790        assert!(
4791            !states.iter().any(|s| s.contains("outer.fallback")),
4792            "outer branch should not have failed, got: {:?}",
4793            states
4794        );
4795    }
4796
4797    /// Regression guard for the Haskell raw-string quasi-quote bug.
4798    ///
4799    /// Haskell's `brackets` context routes `[` through
4800    /// `branch_point: list-or-quasiquote` with alternatives
4801    /// `[list, quasi-quote]`. The `list` alternative matches `[` and
4802    /// `set: list-body`; `list-body` includes `list-fail` whose
4803    /// `\|\]` rule fires `fail: list-or-quasiquote` to fall back to
4804    /// the quasi-quote alternative. When a quasi-quote body contains
4805    /// a bracket expression (e.g. the regex raw string
4806    /// `[r|[a-zA-Z]+|]`), the nested `[` reopens `brackets`, creating
4807    /// a second `branch_point` with the same name. Its `list`
4808    /// alternative resolves cleanly when the nested `]` fires. Before
4809    /// the fix, the inner branch_point record stayed in the vec
4810    /// (the Pop retain predicate was `bp.stack_depth <= stack.len()`,
4811    /// non-strict), and a later `fail: list-or-quasiquote` from the
4812    /// outer list-body `rposition`'d onto the stale inner record,
4813    /// rewinding to the inner `[` instead of the outer one. The outer
4814    /// list's meta_scope stayed on the stack and the quasi-quote
4815    /// alternative never fired — cascading into ~90 col-weighted
4816    /// syntest failures across the raw-string QQ examples in
4817    /// `syntax_test_haskell.hs`.
4818    ///
4819    /// Shape mirrors Haskell: `brackets` is the branch point,
4820    /// alternatives are thin wrappers that `set:` onto their real
4821    /// bodies, so the bp's `stack_depth` lines up with the eventual
4822    /// content-body depth.
4823    #[test]
4824    fn nested_same_name_branch_point_outer_fail_replays_outer() {
4825        let syntax_str = r#"
4826name: NestedSameNameBranch
4827scope: source.nested-same-name-branch
4828contexts:
4829  main:
4830    - include: brackets
4831
4832  brackets:
4833    - match: '(?=\[)'
4834      branch_point: bp
4835      branch: [list, quasi]
4836
4837  list:
4838    - match: '\['
4839      scope: list.open
4840      set: list-body
4841
4842  list-body:
4843    - meta_scope: list.body
4844    - match: '\|\]'
4845      fail: bp
4846    - match: '\]'
4847      scope: list.close
4848      pop: true
4849    - include: brackets
4850    - match: '\w+'
4851      scope: list.word
4852
4853  quasi:
4854    - match: '\['
4855      scope: quasi.open
4856      set: quasi-body
4857
4858  quasi-body:
4859    - meta_scope: quasi.body
4860    - match: '\|\]'
4861      scope: quasi.close
4862      pop: true
4863    - match: '.'
4864      scope: quasi.char
4865"#;
4866        let syntax = SyntaxDefinition::load_from_str(syntax_str, true, None).unwrap();
4867        let ss = link(syntax);
4868        let mut state = ParseState::new(&ss.syntaxes()[0]);
4869
4870        // `[x[y]|]` — outer `[` opens bp (list first). list sets
4871        // list-body. Inside, `x` is a word, then nested `[y]` opens a
4872        // second bp whose list alternative resolves via `]`. Outer
4873        // list-body then hits `|]` and fires `fail: bp`. Expected:
4874        // outer's quasi alternative takes over — everything inside
4875        // `[...|]` ends up as `quasi.body` / `quasi.char`, with no
4876        // `list.body` meta_scope leaking past the replay.
4877        let line_ops = ops(&mut state, "[x[y]|]\n", &ss);
4878        let states = stack_states(line_ops);
4879        assert!(
4880            states.iter().any(|s| s.contains("quasi.body")),
4881            "expected quasi.body after outer fail replay, got: {:?}",
4882            states
4883        );
4884        assert!(
4885            !states.iter().any(|s| s.contains("list.body")),
4886            "list.body meta_scope leaked past outer fail replay, got: {:?}",
4887            states
4888        );
4889    }
4890
4891    #[test]
4892    fn branch_fail_nonexistent_name() {
4893        // `fail: nonexistent` should be a silent no-op — no panic, parsing continues.
4894        let syntax_str = r#"
4895name: NoNameTest
4896scope: source.noname-test
4897contexts:
4898  main:
4899    - match: '\w+'
4900      scope: word.noname-test
4901    - match: '(?=;)'
4902      fail: nonexistent
4903"#;
4904        let syntax = SyntaxDefinition::load_from_str(syntax_str, true, None).unwrap();
4905        let ss = link(syntax);
4906        let mut state = ParseState::new(&ss.syntaxes()[0]);
4907
4908        // The key assertion is that this doesn't panic
4909        let line_ops = ops(&mut state, "hello;\n", &ss);
4910        let states = stack_states(line_ops);
4911        assert!(
4912            states.iter().any(|s| s.contains("word.noname-test")),
4913            "expected word.noname-test, got: {:?}",
4914            states
4915        );
4916    }
4917
4918    #[test]
4919    fn branch_cross_line_multi_replay() {
4920        // When `fail` fires after 3+ buffered lines, all of them should be
4921        // replayed correctly under the fallback alternative.
4922        let syntax_str = r#"
4923name: MultiReplayTest
4924scope: source.multi-replay
4925contexts:
4926  main:
4927    - match: 'TRY'
4928      branch_point: bp
4929      branch: [try-ctx, fallback-ctx]
4930    - match: '.*'
4931      scope: main.other
4932  try-ctx:
4933    - match: '\n'
4934      # stay in context
4935    - match: 'FAIL'
4936      fail: bp
4937    - match: '\w+'
4938      scope: try.word
4939  fallback-ctx:
4940    - match: '.*'
4941      scope: fallback.content
4942      pop: true
4943"#;
4944        let syntax = SyntaxDefinition::load_from_str(syntax_str, true, None).unwrap();
4945        let ss = link(syntax);
4946        let mut state = ParseState::new(&ss.syntaxes()[0]);
4947
4948        let out1 = state.parse_line("TRY\n", &ss).expect("line 1");
4949        assert!(out1.replayed.is_empty());
4950
4951        let out2 = state.parse_line("aaa\n", &ss).expect("line 2");
4952        assert!(out2.replayed.is_empty());
4953
4954        let out3 = state.parse_line("bbb\n", &ss).expect("line 3");
4955        assert!(out3.replayed.is_empty());
4956
4957        // Line 4: "FAIL" triggers cross-line backtrack; lines 1-3 should be replayed
4958        let out4 = state.parse_line("FAIL\n", &ss).expect("line 4");
4959        assert_eq!(
4960            out4.replayed.len(),
4961            3,
4962            "expected 3 replayed lines (lines 1-3), got {:?}",
4963            out4.replayed
4964        );
4965
4966        // The first replayed line (replay of "TRY\n") should have fallback.content
4967        // because fallback-ctx matches `.*`. After that pop, lines 2-3 are parsed
4968        // by main, which matches `.*` → main.other.
4969        let has_fallback = out4.replayed[0].iter().any(|(_, op)| {
4970            matches!(op, ScopeStackOp::Push(s) if format!("{:?}", s).contains("fallback.content"))
4971        });
4972        assert!(
4973            has_fallback,
4974            "replayed line 0 missing fallback.content, got: {:?}",
4975            out4.replayed[0]
4976        );
4977
4978        // No replayed line should have try.word (all are under fallback path)
4979        for (i, line_ops) in out4.replayed.iter().enumerate() {
4980            let has_try_word = line_ops.iter().any(|(_, op)| {
4981                matches!(op, ScopeStackOp::Push(s) if format!("{:?}", s).contains("try.word"))
4982            });
4983            assert!(
4984                !has_try_word,
4985                "replayed line {} should not have try.word, got: {:?}",
4986                i, line_ops
4987            );
4988        }
4989        // Verify current-line ops are clean (ops.clear() fired before re-parse)
4990        let current_has_try = out4.ops.iter().any(
4991            |(_, op)| matches!(op, ScopeStackOp::Push(s) if format!("{:?}", s).contains("try")),
4992        );
4993        assert!(
4994            !current_has_try,
4995            "current-line ops should not contain try.* scopes after cross-line fail, got: {:?}",
4996            out4.ops
4997        );
4998    }
4999
5000    #[test]
5001    fn branch_cross_line_fail_with_preceding_ops() {
5002        // When the fail-triggering line has matchable content BEFORE the fail keyword,
5003        // ops are non-empty and start > 0 when fail fires. After cross-line backtrack,
5004        // those stale ops must be cleared and the line re-parsed from position 0.
5005        let syntax_str = r#"
5006name: PrecedingOpsTest
5007scope: source.preceding-ops
5008contexts:
5009  main:
5010    - match: 'TRY'
5011      branch_point: bp
5012      branch: [try-ctx, fallback-ctx]
5013    - match: '.*'
5014      scope: main.other
5015  try-ctx:
5016    - match: '\n'
5017      # stay in context across lines
5018    - match: 'FAIL'
5019      fail: bp
5020    - match: '\w+'
5021      scope: try.word
5022  fallback-ctx:
5023    - match: '.*'
5024      scope: fallback.content
5025      pop: true
5026"#;
5027        let syntax = SyntaxDefinition::load_from_str(syntax_str, true, None).unwrap();
5028        let ss = link(syntax);
5029        let mut state = ParseState::new(&ss.syntaxes()[0]);
5030
5031        // Line 1: start the branch
5032        let out1 = state.parse_line("TRY\n", &ss).expect("line 1");
5033        assert!(out1.replayed.is_empty());
5034
5035        // Line 2: "stuff FAIL" — "stuff" matches try.word (ops non-empty, start advances)
5036        // then FAIL triggers cross-line backtrack.
5037        let out2 = state.parse_line("stuff FAIL\n", &ss).expect("line 2");
5038
5039        // Should have replayed line 1 (TRY\n)
5040        assert_eq!(
5041            out2.replayed.len(),
5042            1,
5043            "expected 1 replayed line, got {}",
5044            out2.replayed.len()
5045        );
5046
5047        // Replayed line should have fallback.content, not try.word
5048        let replay_has_fallback = out2.replayed[0].iter().any(|(_, op)| {
5049            matches!(op, ScopeStackOp::Push(s) if format!("{:?}", s).contains("fallback.content"))
5050        });
5051        assert!(
5052            replay_has_fallback,
5053            "replayed line should have fallback.content, got: {:?}",
5054            out2.replayed[0]
5055        );
5056
5057        // Current-line ops must NOT contain try.word (stale ops were cleared)
5058        let current_has_try = out2.ops.iter().any(|(_, op)| {
5059            matches!(op, ScopeStackOp::Push(s) if format!("{:?}", s).contains("try.word"))
5060        });
5061        assert!(
5062            !current_has_try,
5063            "current-line ops should not contain try.word after cross-line fail, got: {:?}",
5064            out2.ops
5065        );
5066
5067        // Current-line ops should have main.other (re-parsed from position 0)
5068        let current_has_main = out2.ops.iter().any(|(_, op)| {
5069            matches!(op, ScopeStackOp::Push(s) if format!("{:?}", s).contains("main.other"))
5070        });
5071        assert!(
5072            current_has_main,
5073            "current-line should be re-parsed as main.other from position 0, got: {:?}",
5074            out2.ops
5075        );
5076    }
5077
5078    // ── Mutation-killing pass 3 ──────────────────────────────────────────
5079
5080    #[test]
5081    fn is_speculative_reflects_branch_state() {
5082        // Kills: L306 replace is_speculative -> true / false / delete !
5083        // is_speculative must be true while inside a branch_point and false otherwise.
5084        // We use a syntax where both alternatives fail, so the branch point is
5085        // fully exhausted and removed.
5086        let syntax_str = r#"
5087name: SpeculativeTest
5088scope: source.spec-test
5089contexts:
5090  main:
5091    - match: '(?=\S)'
5092      branch_point: bp
5093      branch: [alt-a, alt-b]
5094    - match: '\S+'
5095      scope: fallback.spec-test
5096
5097  alt-a:
5098    - match: 'AAA'
5099      scope: alt-a.spec-test
5100      pop: true
5101    - match: '(?=\S)'
5102      fail: bp
5103
5104  alt-b:
5105    - match: 'BBB'
5106      scope: alt-b.spec-test
5107      pop: true
5108    - match: '(?=\S)'
5109      fail: bp
5110"#;
5111        let syntax = SyntaxDefinition::load_from_str(syntax_str, true, None).unwrap();
5112        let ss = link(syntax);
5113        let mut state = ParseState::new(&ss.syntaxes()[0]);
5114
5115        // Before any branch: not speculative
5116        assert!(
5117            !state.is_speculative(),
5118            "should not be speculative before branch_point is created"
5119        );
5120
5121        // "AAA" matches first alternative, so branch stays open (could still fail)
5122        let _ = state.parse_line("AAA\n", &ss).unwrap();
5123        // Branch point is created then first alt succeeds — but bp stays until
5124        // explicitly removed.  Since AAA matched and popped, bp is still there.
5125        // Actually let's test with a failing input instead:
5126        let mut state2 = ParseState::new(&ss.syntaxes()[0]);
5127        assert!(!state2.is_speculative());
5128
5129        // "xyz" matches neither AAA nor BBB: both alternatives fail, bp exhausted & removed
5130        let _ = state2.parse_line("xyz\n", &ss).unwrap();
5131        assert!(
5132            !state2.is_speculative(),
5133            "should not be speculative after all alternatives exhausted"
5134        );
5135
5136        // Now test it IS speculative mid-branch: use a cross-line syntax
5137        let syntax_str2 = r#"
5138name: SpecCross
5139scope: source.spec-cross
5140contexts:
5141  main:
5142    - match: 'TRY'
5143      branch_point: bp
5144      branch: [try-ctx, fallback-ctx]
5145    - match: '.*'
5146      scope: main.other
5147  try-ctx:
5148    - match: '\n'
5149    - match: 'FAIL'
5150      fail: bp
5151    - match: '\w+'
5152      scope: try.word
5153      pop: true
5154  fallback-ctx:
5155    - match: '.*'
5156      scope: fallback.content
5157      pop: true
5158"#;
5159        let syntax2 = SyntaxDefinition::load_from_str(syntax_str2, true, None).unwrap();
5160        let ss2 = link(syntax2);
5161        let mut state3 = ParseState::new(&ss2.syntaxes()[0]);
5162        assert!(!state3.is_speculative());
5163
5164        let _ = state3.parse_line("TRY\n", &ss2).unwrap();
5165        assert!(
5166            state3.is_speculative(),
5167            "should be speculative after branch_point creation"
5168        );
5169    }
5170
5171    #[test]
5172    fn consuming_match_not_treated_as_loop() {
5173        // Kills: L533 replace > with < in find_best_match (consuming check)
5174        // A pop that consumes characters must NOT be treated as a loop.
5175        // If consuming is negated (> → <), a consuming pop would be flagged
5176        // as a loop and skipped, breaking the parse.
5177        let syntax_str = r#"
5178name: ConsumingTest
5179scope: source.consuming
5180contexts:
5181  main:
5182    - match: '(?=\S)'
5183      push: inner
5184    - match: '\n'
5185  inner:
5186    - match: '\w+'
5187      scope: word.consuming
5188      pop: true
5189"#;
5190        let syntax = SyntaxDefinition::load_from_str(syntax_str, true, None).unwrap();
5191        let ss = link(syntax);
5192        let mut state = ParseState::new(&ss.syntaxes()[0]);
5193
5194        // "(?=\S)" is a zero-length push (non-consuming), then "\w+" is a
5195        // consuming pop inside inner.  If the consuming check is inverted,
5196        // the pop would be treated as looping and skipped, causing the parser
5197        // to advance one character before matching, so the push position
5198        // would be 1 instead of 0.
5199        let raw_ops = ops(&mut state, "hello world\n", &ss);
5200        let first_word_pos = raw_ops
5201            .iter()
5202            .find_map(|(pos, op)| match op {
5203                ScopeStackOp::Push(s) if format!("{:?}", s).contains("word.consuming") => {
5204                    Some(*pos)
5205                }
5206                _ => None,
5207            })
5208            .expect("expected at least one word.consuming push");
5209        assert_eq!(
5210            first_word_pos, 0,
5211            "word.consuming must start at position 0 (consuming pop should not be treated as loop)"
5212        );
5213    }
5214
5215    #[test]
5216    fn capture_sort_by_span_length() {
5217        // Kills: L709 replace - with + in exec_pattern (capture sort key)
5218        // Captures are sorted so that longer spans come first (pushed before
5219        // shorter nested ones).  If the sort key sign is flipped, shorter
5220        // spans push first, producing the wrong nesting order.
5221        let syntax_str = r#"
5222name: CaptureSort
5223scope: source.capsort
5224contexts:
5225  main:
5226    - match: '((a)(b))'
5227      captures:
5228        1: outer.capsort
5229        2: inner-a.capsort
5230        3: inner-b.capsort
5231"#;
5232        let syntax = SyntaxDefinition::load_from_str(syntax_str, true, None).unwrap();
5233        let ss = link(syntax);
5234        let mut state = ParseState::new(&ss.syntaxes()[0]);
5235        let raw_ops = ops(&mut state, "ab\n", &ss);
5236
5237        // With correct sorting: outer pushes first (at pos 0), then inner-a
5238        // at the same position.  With the sign flipped, inner-a would push
5239        // before outer, which is wrong.
5240        let push_order: Vec<&str> = raw_ops
5241            .iter()
5242            .filter_map(|(_, op)| match op {
5243                ScopeStackOp::Push(s) => {
5244                    let name = format!("{:?}", s);
5245                    if name.contains("outer.capsort") {
5246                        Some("outer")
5247                    } else if name.contains("inner-a.capsort") {
5248                        Some("inner-a")
5249                    } else if name.contains("inner-b.capsort") {
5250                        Some("inner-b")
5251                    } else {
5252                        None
5253                    }
5254                }
5255                _ => None,
5256            })
5257            .collect();
5258        assert_eq!(
5259            push_order,
5260            vec!["outer", "inner-a", "inner-b"],
5261            "captures must push in longest-span-first order, got: {:?}",
5262            push_order
5263        );
5264    }
5265
5266    #[test]
5267    fn v2_set_pops_meta_content_scope_from_matched_text() {
5268        // Kills: L1009 replace += with -= or *= in push_meta_ops
5269        // When a v2 syntax uses `set`, num_to_pop must include
5270        // cur_context.meta_scope.len() so that the old meta scope is removed.
5271        let syntax_str = r#"
5272name: V2SetMeta
5273scope: source.v2setmeta
5274version: 2
5275contexts:
5276  main:
5277    - match: '(?=\S)'
5278      push: ctx-a
5279  ctx-a:
5280    - meta_scope: meta.a.v2setmeta
5281    - match: 'GO'
5282      scope: keyword.go.v2setmeta
5283      set: ctx-b
5284    - match: '\w+'
5285      scope: word.a.v2setmeta
5286  ctx-b:
5287    - meta_scope: meta.b.v2setmeta
5288    - match: '\w+'
5289      scope: word.b.v2setmeta
5290      pop: true
5291"#;
5292        let syntax = SyntaxDefinition::load_from_str(syntax_str, true, None).unwrap();
5293        let ss = link(syntax);
5294        let mut state = ParseState::new(&ss.syntaxes()[0]);
5295        let raw_ops = ops(&mut state, "GO hello\n", &ss);
5296        let states = stack_states(raw_ops);
5297
5298        // After "GO" triggers `set: ctx-b`, meta.a should be popped and
5299        // meta.b should be active on "hello".  If num_to_pop is wrong
5300        // (e.g. subtracted instead of added), meta.a would persist.
5301        let last_state = states.last().expect("expected some states");
5302        assert!(
5303            !last_state.contains("meta.a.v2setmeta"),
5304            "meta.a should have been popped after set, got: {:?}",
5305            last_state
5306        );
5307    }
5308
5309    #[test]
5310    fn v2_set_from_context_with_clear_scopes_restores_cleared_atoms() {
5311        // When a `set:` fires from a context that had `clear_scopes` of its
5312        // own (e.g. JSON's `object-value-body`), the cleared scopes must be
5313        // restored at the correct position on the scope stack: below the
5314        // target's pushed meta_scope, not on top of it.
5315        //
5316        // Previously the Restore fired in the initial phase, before the
5317        // non-initial Pop of (cur.meta_scope + target.meta_scope). The Pop
5318        // then removed the restored atoms instead of the intended meta_scopes,
5319        // dropping cur's cleared state on the floor. This surfaced in the
5320        // JSON test as duplicate `meta.mapping.value.json` atoms in nested
5321        // objects — e.g. `[source.json, meta.mapping.value.json,
5322        // meta.mapping.value.json]` instead of `[source.json,
5323        // meta.mapping.value.json, meta.mapping.json]`.
5324        //
5325        // Reduced JSON-like repro: outer mapping pushes an inner value-body
5326        // that clears the outer mapping scope, then the inner value-body
5327        // `set`s to a follow-up context. The follow-up context's matched
5328        // text must see the outer mapping scope restored below it.
5329        let syntax_str = r#"
5330name: V2SetRestore
5331scope: source.v2setrestore
5332version: 2
5333contexts:
5334  main:
5335    - meta_scope: meta.outer.v2setrestore
5336    - match: '\{'
5337      push: value-body
5338
5339  value-body:
5340    - clear_scopes: 1
5341    - meta_scope: meta.value.v2setrestore
5342    - match: 'x'
5343      scope: keyword.x.v2setrestore
5344      set: follow-up
5345
5346  follow-up:
5347    - match: '\w+'
5348      scope: word.follow.v2setrestore
5349"#;
5350        let syntax = SyntaxDefinition::load_from_str(syntax_str, true, None).unwrap();
5351        let ss = link(syntax);
5352        let mut state = ParseState::new(&ss.syntaxes()[0]);
5353        let raw_ops = ops(&mut state, "{xhello\n", &ss);
5354
5355        let states = stack_states(raw_ops);
5356        // Find the state while parsing "hello" in follow-up.
5357        let follow_states: Vec<_> = states
5358            .iter()
5359            .filter(|s| s.contains("word.follow.v2setrestore"))
5360            .collect();
5361        assert!(
5362            !follow_states.is_empty(),
5363            "expected to enter follow-up context, got states: {:?}",
5364            states
5365        );
5366        // The outer meta.outer scope must be restored below follow-up's word
5367        // scope. If the Restore landed above the target's meta_scope push (or
5368        // was dropped by the non-initial Pop), meta.outer would be missing.
5369        assert!(
5370            follow_states
5371                .iter()
5372                .any(|s| s.contains("meta.outer.v2setrestore")),
5373            "meta.outer must be restored after leaving value-body (which cleared it): {:?}",
5374            follow_states
5375        );
5376        // meta.value (from the cleared context) must NOT persist.
5377        assert!(
5378            !follow_states
5379                .iter()
5380                .any(|s| s.contains("meta.value.v2setrestore")),
5381            "meta.value (from the exited context) must not leak into follow-up: {:?}",
5382            follow_states
5383        );
5384    }
5385
5386    #[test]
5387    fn v2_set_to_target_with_clear_scopes_clears_parent_meta_content_scope() {
5388        // Reduced from Lisp `function-parameter-list` → `function-parameter-list-body`:
5389        // the enclosing `function-body` supplies `meta_content_scope:
5390        // meta.function.lisp`; the inner parameter-list-body declares
5391        // `clear_scopes: 1` so the `(` and the parameter identifiers inside
5392        // are not double-scoped with the outer `meta.function`.
5393        //
5394        // The `(` token itself (the `set:` trigger) should see the cleared
5395        // stack — i.e. `meta.function.lisp` is already gone at that column.
5396        // Previously the v2 initial phase for Set pushed target.meta_scope
5397        // above the outer mcs without clearing first, so the trigger token
5398        // reported `[..., meta.function.lisp, meta.function.parameters.lisp,
5399        // punctuation...]` instead of `[..., meta.function.parameters.lisp,
5400        // punctuation...]`.
5401        let syntax_str = r#"
5402name: V2SetTargetClear
5403scope: source.v2settargetclear
5404version: 2
5405contexts:
5406  main:
5407    - match: '\('
5408      scope: punctuation.section.parens.begin.v2settargetclear
5409      push: [body, params-open]
5410
5411  body:
5412    - meta_content_scope: meta.function.v2settargetclear
5413    - match: '\)'
5414      scope: punctuation.section.parens.end.v2settargetclear
5415      pop: 1
5416
5417  params-open:
5418    - match: '\('
5419      scope: punctuation.section.parameters.begin.v2settargetclear
5420      set: params-body
5421    - include: else-pop
5422
5423  params-body:
5424    - clear_scopes: 1
5425    - meta_scope: meta.function.parameters.v2settargetclear
5426    - match: '\)'
5427      scope: punctuation.section.parameters.end.v2settargetclear
5428      pop: 1
5429    - match: '\w+'
5430      scope: variable.parameter.v2settargetclear
5431"#;
5432        let syntax = SyntaxDefinition::load_from_str(syntax_str, true, None).unwrap();
5433        let ss = link(syntax);
5434        let mut state = ParseState::new(&ss.syntaxes()[0]);
5435        // Mirrors `(defun averagenum (n1 n2))`: outer `(...)` carries
5436        // meta.function; inner `(...)` is parameter list.
5437        let raw_ops = ops(&mut state, "( (n1 n2))\n", &ss);
5438
5439        let states = stack_states(raw_ops);
5440
5441        // Find the state covering the inner `(` at column 2 (the set trigger).
5442        // Every state recorded once the parameters context has been entered
5443        // must NOT still carry the outer meta.function atom.
5444        let param_states: Vec<_> = states
5445            .iter()
5446            .filter(|s| s.contains("meta.function.parameters.v2settargetclear"))
5447            .collect();
5448        assert!(
5449            !param_states.is_empty(),
5450            "expected to enter params-body context, got states: {:?}",
5451            states
5452        );
5453        // The outer `meta.function` atom (from `body`'s meta_content_scope)
5454        // must be absent on every state where params-body is active. Match
5455        // the exact atom name — not a prefix — so that
5456        // `meta.function.parameters.v2settargetclear` doesn't trigger.
5457        let outer = "<meta.function.v2settargetclear>";
5458        for s in &param_states {
5459            assert!(
5460                !s.contains(outer),
5461                "outer meta.function must be cleared under params-body, \
5462                 but found it alongside meta.function.parameters: {:?}",
5463                s
5464            );
5465        }
5466
5467        // After the inner `)` pops params-body the clear must Restore, so
5468        // the outer `meta.function` atom reappears before the outer `)`.
5469        let after_inner_close: Vec<_> = states
5470            .iter()
5471            .rev()
5472            .take_while(|s| !s.contains("meta.function.parameters.v2settargetclear"))
5473            .collect();
5474        assert!(
5475            after_inner_close
5476                .iter()
5477                .any(|s| s.contains("meta.function.v2settargetclear")),
5478            "meta.function must be restored after params-body pops, got trailing states: {:?}",
5479            after_inner_close
5480        );
5481    }
5482
5483    #[test]
5484    fn v2_set_clear_scopes_applies_from_every_context() {
5485        // v2: when `set:` lists multiple contexts, `clear_scopes` on any of
5486        // them — not just the topmost — applies at that context's own
5487        // position in the stack. The canonical real-world case is Bash's
5488        //   set: [def-function-body, def-function-params, def-function-name]
5489        // where `def-function-params` (the middle context) carries
5490        // `clear_scopes: 1`. The Clear strips the atom that the preceding
5491        // context's meta_content_scope just pushed, matching Sublime
5492        // Text's observed behaviour. Previously this test guessed
5493        // Sublime pinned Clear to the topmost context only; running real
5494        // v2 syntaxes (Bash function definitions, among others) refuted
5495        // that guess.
5496        let syntax_str = r#"
5497name: V2ClearMid
5498scope: source.v2clear
5499version: 2
5500contexts:
5501  main:
5502    - meta_scope: meta.main.v2clear
5503    - match: 'GO'
5504      set: [ctx-bottom, ctx-middle, ctx-top]
5505  ctx-bottom:
5506    - meta_content_scope: mcs.bottom.v2clear
5507  ctx-middle:
5508    - clear_scopes: 1
5509    - meta_content_scope: mcs.middle.v2clear
5510  ctx-top:
5511    - meta_content_scope: mcs.top.v2clear
5512    - match: '\w+'
5513      scope: word.top.v2clear
5514      pop: true
5515"#;
5516        let syntax = SyntaxDefinition::load_from_str(syntax_str, true, None).unwrap();
5517        let ss = link(syntax);
5518        let mut state = ParseState::new(&ss.syntaxes()[0]);
5519        let raw_ops = ops(&mut state, "GO hello\n", &ss);
5520        let states = stack_states(raw_ops);
5521
5522        // "hello" matches in ctx-top. At that point ctx-middle's
5523        // clear_scopes: 1 must have stripped ctx-bottom's
5524        // meta_content_scope atom.
5525        let hello_states: Vec<_> = states
5526            .iter()
5527            .filter(|s| s.contains("word.top.v2clear"))
5528            .collect();
5529        assert!(
5530            !hello_states.is_empty(),
5531            "expected word.top.v2clear, got states: {:?}",
5532            states
5533        );
5534        for s in &hello_states {
5535            assert!(
5536                !s.contains("mcs.bottom.v2clear"),
5537                "ctx-bottom's mcs should have been cleared by ctx-middle's \
5538                 clear_scopes: 1 before ctx-top's match, got: {:?}",
5539                s
5540            );
5541            assert!(
5542                s.contains("mcs.middle.v2clear"),
5543                "ctx-middle's mcs should be on the stack during ctx-top's match, \
5544                 got: {:?}",
5545                s
5546            );
5547        }
5548
5549        // Reaching this point without a panic proves the Restore emitted on
5550        // ctx-middle's pop didn't underflow the clear_stack — the
5551        // regression the Bash `func () {}` minimal reproducer uncovered.
5552    }
5553
5554    #[test]
5555    fn v2_embed_scope_replaces_skips_meta_content_pop_on_exit() {
5556        // Kills: L912 replace >= with < (version >= 2)
5557        //        L913 replace >= with < (stack.len() >= 2)
5558        //        L915 replace - with / (stack.len() - 2)
5559        // When a v2 syntax uses embed with embed_scope, the escape context
5560        // has embed_scope_replaces=true.  On pop, the meta_content_scope of
5561        // the escape context should NOT be popped because it was never pushed.
5562        // If the version check is wrong, we'd get an extra Pop.
5563        use crate::parsing::ScopeStack;
5564
5565        // The host pushes two intermediate contexts before embedding so that
5566        // the stack depth is 5 when the escape fires:
5567        //   [main, wrapper-a, wrapper-b, escape, embedded]
5568        // This distinguishes stack.len()-2 (=3, escape) from stack.len()/2
5569        // (=2, wrapper-b), catching the L915 `-` → `/` mutation.
5570        let host = SyntaxDefinition::load_from_str(
5571            r#"
5572name: V2SkipHost
5573scope: source.v2skip
5574file_extensions: [v2skip]
5575version: 2
5576contexts:
5577  main:
5578    - match: '(?=<)'
5579      push: wrapper-a
5580    - match: '\w+'
5581      scope: word.v2skip
5582  wrapper-a:
5583    - match: '(?=<)'
5584      push: wrapper-b
5585  wrapper-b:
5586    - match: '<<'
5587      embed: scope:source.v2skipemb
5588      embed_scope: meta.embedded.v2skip
5589      escape: '>>'
5590      escape_captures:
5591        0: punctuation.end.v2skip
5592"#,
5593            true,
5594            None,
5595        )
5596        .unwrap();
5597
5598        let embedded = SyntaxDefinition::load_from_str(
5599            r#"
5600name: V2SkipEmb
5601scope: source.v2skipemb
5602file_extensions: [v2skipemb]
5603version: 2
5604contexts:
5605  main:
5606    - meta_content_scope: content.v2skipemb
5607    - match: '\w+'
5608      scope: keyword.v2skipemb
5609"#,
5610            true,
5611            None,
5612        )
5613        .unwrap();
5614
5615        let mut builder = SyntaxSetBuilder::new();
5616        builder.add(host);
5617        builder.add(embedded);
5618        let ss = builder.build();
5619
5620        let syntax = ss.find_syntax_by_name("V2SkipHost").unwrap();
5621        let mut state = ParseState::new(syntax);
5622        let raw_ops = state.parse_line("<<x>> hello\n", &ss).unwrap().ops;
5623
5624        // Build scope stack through all ops and verify it ends clean.
5625        // If the skip logic is broken (mutations on L912-L915), an extra Pop
5626        // for meta_content_scope is generated, which pops a scope that was
5627        // never pushed, corrupting the stack.
5628        let mut scope_stack = ScopeStack::new();
5629        for (_, op) in &raw_ops {
5630            scope_stack
5631                .apply(op)
5632                .expect("applying op should not fail — extra Pop means the skip logic is broken");
5633        }
5634        // After ">> hello\n", we should be back in main with source.v2skip
5635        // as the only remaining scope (everything else was popped).
5636        // If the skip logic is wrong, source.v2skip would be popped too.
5637        let final_scopes: Vec<String> = scope_stack
5638            .as_slice()
5639            .iter()
5640            .map(|s| format!("{:?}", s))
5641            .collect();
5642        assert!(
5643            final_scopes.iter().any(|s| s.contains("source.v2skip")),
5644            "source.v2skip should remain on stack after all ops, got: {:?}",
5645            final_scopes
5646        );
5647    }
5648
5649    #[test]
5650    fn v2_host_embedding_v1_guest_skips_meta_content_pop_on_escape() {
5651        // Regression for the Rails html.erb syntest cluster: when a v2 host
5652        // uses `embed:` + `embed_scope:` to pull in a v1 guest grammar (e.g.
5653        // Rails/HTML embedding Ruby), `embed_scope_replaces` is set on the
5654        // wrapper context. On escape, the embedded guest's meta_content_scope
5655        // must be skipped — it was never pushed on the way in.
5656        //
5657        // The exec_escape skip logic was gated on
5658        // `current_syntax_version() >= 2`, which reads the version from the
5659        // top-of-stack context. That is the *guest* (Ruby, v1), not the host,
5660        // so the gate evaluated false and a spurious Pop fired for a scope
5661        // that was never pushed, misaligning every scope on the stack for
5662        // the remainder of the host context.
5663        use crate::parsing::ScopeStack;
5664
5665        let host = SyntaxDefinition::load_from_str(
5666            r#"
5667name: V2HostV1Guest
5668scope: source.v2host
5669file_extensions: [v2host]
5670version: 2
5671contexts:
5672  main:
5673    - match: '<<'
5674      embed: scope:source.v1guest
5675      embed_scope: meta.embedded.v2host
5676      escape: '>>'
5677      escape_captures:
5678        0: punctuation.end.v2host
5679    - match: '\w+'
5680      scope: word.v2host
5681"#,
5682            true,
5683            None,
5684        )
5685        .unwrap();
5686
5687        // Guest omits `version:` — defaults to 1. Its `scope:` lands in the
5688        // main context's meta_content_scope (source.v1guest), which the
5689        // v2 embed_scope_replaces suppresses on push. The escape must
5690        // symmetrically suppress it on pop.
5691        let guest = SyntaxDefinition::load_from_str(
5692            r#"
5693name: V1Guest
5694scope: source.v1guest
5695file_extensions: [v1guest]
5696contexts:
5697  main:
5698    - match: '\w+'
5699      scope: keyword.v1guest
5700"#,
5701            true,
5702            None,
5703        )
5704        .unwrap();
5705
5706        let mut builder = SyntaxSetBuilder::new();
5707        builder.add(host);
5708        builder.add(guest);
5709        let ss = builder.build();
5710
5711        let syntax = ss.find_syntax_by_name("V2HostV1Guest").unwrap();
5712        let mut state = ParseState::new(syntax);
5713        let raw_ops = state.parse_line("<<x>> hello\n", &ss).unwrap().ops;
5714
5715        // Before the fix, the escape emits a Pop for guest main's mcs even
5716        // though it was never pushed. Subsequent Pops then strip scopes
5717        // that should have survived. Applying the op stream must not fail,
5718        // and source.v2host must remain on the stack at the end.
5719        let mut scope_stack = ScopeStack::new();
5720        for (_, op) in &raw_ops {
5721            scope_stack.apply(op).expect(
5722                "applying op stream must succeed — a spurious Pop indicates the skip was gated \
5723                 on the guest's syntax version instead of the embed_scope_replaces flag",
5724            );
5725        }
5726        let final_scopes: Vec<String> = scope_stack
5727            .as_slice()
5728            .iter()
5729            .map(|s| format!("{:?}", s))
5730            .collect();
5731        assert!(
5732            final_scopes.iter().any(|s| s.contains("source.v2host")),
5733            "source.v2host should remain after escape; got: {:?}",
5734            final_scopes
5735        );
5736    }
5737
5738    #[test]
5739    fn nested_embed_outer_escape_wins() {
5740        // Inner embed's escape must not fire before outer embed's escape.
5741        // The outer escape at position 3 ("END") should take precedence over
5742        // the inner escape at position 5 ("zzz"), truncating the search region.
5743        let syntax = r#"
5744name: NestedEmbed
5745scope: source.nested-embed
5746contexts:
5747  main:
5748    - match: 'OUTER'
5749      embed: mid
5750      escape: 'END'
5751      escape_captures:
5752        0: keyword.escape.outer
5753    - match: '.'
5754      scope: main.char
5755
5756  mid:
5757    - match: 'INNER'
5758      embed: deep
5759      escape: 'zzz'
5760      escape_captures:
5761        0: keyword.escape.inner
5762    - match: '.'
5763      scope: mid.char
5764
5765  deep:
5766    - match: '.'
5767      scope: deep.char
5768"#;
5769        let syntax = SyntaxDefinition::load_from_str(syntax, true, None).unwrap();
5770        let ss = link(syntax);
5771        let mut state = ParseState::new(&ss.syntaxes()[0]);
5772
5773        // Line 1: enter outer embed, then inner embed
5774        let out1 = state.parse_line("OUTERINNER\n", &ss).expect("line 1");
5775        debug_print_ops("OUTERINNER\n", &out1.ops);
5776
5777        // Line 2: "xxENDzzzAFTER" — outer escape "END" at pos 2 must fire before
5778        // inner escape "zzz" at pos 5. After outer escape fires, we're back in main.
5779        let out2 = state.parse_line("xxENDzzzAFTER\n", &ss).expect("line 2");
5780        let states = stack_states(out2.ops);
5781        println!("states: {:?}", states);
5782
5783        // The outer escape scope must appear
5784        assert!(
5785            states.iter().any(|s| s.contains("keyword.escape.outer")),
5786            "outer escape must fire, got: {:?}",
5787            states
5788        );
5789        // The inner escape scope must NOT appear (outer wins)
5790        assert!(
5791            !states.iter().any(|s| s.contains("keyword.escape.inner")),
5792            "inner escape must not fire when outer escape is earlier, got: {:?}",
5793            states
5794        );
5795        // After the outer escape, "zzzAFTER" should be parsed in main context
5796        assert!(
5797            states.iter().any(|s| s.contains("main.char")),
5798            "after outer escape we should be in main, got: {:?}",
5799            states
5800        );
5801    }
5802
5803    #[test]
5804    fn embed_escape_with_backref_at_parse_time() {
5805        // The escape pattern uses \1 to backreference the opening delimiter.
5806        // Verify that the resolved regex correctly matches at parse time.
5807        let syntax = r#"
5808name: BackrefEscape
5809scope: source.backref-escape
5810contexts:
5811  main:
5812    - match: '(<<|>>)'
5813      scope: punctuation.open
5814      embed: inner
5815      escape: '\1'
5816      escape_captures:
5817        0: punctuation.close
5818    - match: '.'
5819      scope: main.char
5820
5821  inner:
5822    - match: '.'
5823      scope: inner.char
5824"#;
5825        let syntax = SyntaxDefinition::load_from_str(syntax, true, None).unwrap();
5826        let ss = link(syntax);
5827
5828        // Test 1: "<<" opens, ">>" should NOT close it, "<<" should close it
5829        let mut state = ParseState::new(&ss.syntaxes()[0]);
5830        let out1 = state.parse_line("<<>>stuff<<after\n", &ss).expect("line 1");
5831        let states = stack_states(out1.ops);
5832        println!("backref states: {:?}", states);
5833
5834        // The opening << should push punctuation.open
5835        assert!(
5836            states.iter().any(|s| s.contains("punctuation.open")),
5837            "expected punctuation.open, got: {:?}",
5838            states
5839        );
5840        // ">>" should be parsed as inner.char (not as escape)
5841        assert!(
5842            states.iter().any(|s| s.contains("inner.char")),
5843            ">> should be inner.char since escape is <<, got: {:?}",
5844            states
5845        );
5846        // "<<" at pos 9 should fire as escape (punctuation.close)
5847        assert!(
5848            states.iter().any(|s| s.contains("punctuation.close")),
5849            "matching << should trigger escape, got: {:?}",
5850            states
5851        );
5852        // After escape, "after" should be in main
5853        assert!(
5854            states.iter().any(|s| s.contains("main.char")),
5855            "after escape we should be in main, got: {:?}",
5856            states
5857        );
5858    }
5859
5860    #[test]
5861    fn embed_escape_cross_line() {
5862        // Embed on line 1, content on line 2, escape on line 3.
5863        // Verifies that escape_stack persists across parse_line calls.
5864        let syntax = r#"
5865name: CrossLineEscape
5866scope: source.cross-line-escape
5867contexts:
5868  main:
5869    - match: 'BEGIN'
5870      scope: keyword.begin
5871      embed: body
5872      escape: 'STOP'
5873      escape_captures:
5874        0: keyword.stop
5875    - match: '.'
5876      scope: main.char
5877
5878  body:
5879    - match: '.'
5880      scope: body.char
5881"#;
5882        let syntax = SyntaxDefinition::load_from_str(syntax, true, None).unwrap();
5883        let ss = link(syntax);
5884        let mut state = ParseState::new(&ss.syntaxes()[0]);
5885
5886        // Line 1: embed begins
5887        let out1 = state.parse_line("BEGIN\n", &ss).expect("line 1");
5888        let states1 = stack_states(out1.ops);
5889        assert!(
5890            states1.iter().any(|s| s.contains("keyword.begin")),
5891            "line 1 should have keyword.begin, got: {:?}",
5892            states1
5893        );
5894
5895        // Line 2: content inside the embed
5896        let out2 = state.parse_line("hello\n", &ss).expect("line 2");
5897        let states2 = stack_states(out2.ops);
5898        assert!(
5899            states2.iter().any(|s| s.contains("body.char")),
5900            "line 2 should be body content, got: {:?}",
5901            states2
5902        );
5903
5904        // Line 3: escape fires
5905        let out3 = state.parse_line("STOPafter\n", &ss).expect("line 3");
5906        let states3 = stack_states(out3.ops);
5907        assert!(
5908            states3.iter().any(|s| s.contains("keyword.stop")),
5909            "line 3 should have escape keyword.stop, got: {:?}",
5910            states3
5911        );
5912        assert!(
5913            states3.iter().any(|s| s.contains("main.char")),
5914            "after escape on line 3, should be in main, got: {:?}",
5915            states3
5916        );
5917    }
5918
5919    #[test]
5920    fn embed_inside_branch_then_fail_restores_escape_stack() {
5921        // An embed inside a branch alternative pushes to escape_stack.
5922        // When fail fires, the escape_stack must be restored (the embed's
5923        // escape entry must be removed). The fallback alternative stays on
5924        // the stack (no pop) so any stale escape entry would survive to
5925        // the next line, where it would incorrectly fire.
5926        let syntax = r#"
5927name: EmbedBranchFail
5928scope: source.embed-branch
5929contexts:
5930  main:
5931    - match: 'START'
5932      branch_point: bp
5933      branch: [try-embed, fallback]
5934    - match: '.'
5935      scope: main.char
5936
5937  try-embed:
5938    - match: 'EMB'
5939      embed: embedded
5940      escape: 'ESC'
5941      escape_captures:
5942        0: keyword.escape
5943
5944  fallback:
5945    # No pop — stays on the stack so a stale escape entry would persist
5946    - match: '\w+'
5947      scope: fallback.matched
5948    - match: '\n'
5949
5950  embedded:
5951    - match: 'FAIL'
5952      fail: bp
5953    - match: '.'
5954      scope: embedded.char
5955"#;
5956        let syntax = SyntaxDefinition::load_from_str(syntax, true, None).unwrap();
5957        let ss = link(syntax);
5958        let mut state = ParseState::new(&ss.syntaxes()[0]);
5959
5960        // "START" triggers branch, tries try-embed first.
5961        // "EMB" enters the embed (pushing escape entry for "ESC").
5962        // "FAIL" fires `fail: bp`, which must restore escape_stack and
5963        // replay under fallback alternative.
5964        let out = state
5965            .parse_line("STARTEMBFAIL\n", &ss)
5966            .expect("parse failed");
5967        let states = stack_states(out.ops);
5968        println!("embed+branch+fail states: {:?}", states);
5969
5970        // After backtracking, fallback should match
5971        assert!(
5972            states.iter().any(|s| s.contains("fallback.matched")),
5973            "fallback should match after fail, got: {:?}",
5974            states
5975        );
5976
5977        // Parse another line — "ESC" must NOT trigger the escape (it was
5978        // from a reverted branch). Without proper escape_stack restoration,
5979        // the stale escape entry would fire here.
5980        let out2 = state.parse_line("xESCy\n", &ss).expect("line 2");
5981        let states2 = stack_states(out2.ops);
5982        assert!(
5983            !states2.iter().any(|s| s.contains("keyword.escape")),
5984            "stale escape must not fire after branch revert, got: {:?}",
5985            states2
5986        );
5987    }
5988
5989    #[test]
5990    fn erb_escape_captures() {
5991        let ss = SyntaxSet::load_defaults_newlines();
5992        let syntax = ss.find_syntax_by_extension("erb").unwrap();
5993        let mut state = ParseState::new(syntax);
5994        let mut scope_stack = ScopeStack::new();
5995        let ops = state.parse_line("<%= puts \"hi\" %>\n", &ss).unwrap();
5996        eprintln!("ERB line ops:");
5997        for (pos, op) in &ops.ops {
5998            scope_stack.apply(op).ok();
5999            eprintln!(
6000                "  pos={} op={:?}  stack={:?}",
6001                pos,
6002                op,
6003                scope_stack.as_slice()
6004            );
6005        }
6006        let stack_str = format!("{:?}", scope_stack.as_slice());
6007        // After the line, the %> should have fired the escape
6008        // and we should be back in HTML context
6009        assert!(
6010            !stack_str.contains("source.ruby"),
6011            "Expected Ruby embed to have ended, got: {}",
6012            stack_str
6013        );
6014    }
6015
6016    #[test]
6017    fn embed_js_in_html() {
6018        let ss = SyntaxSet::load_defaults_newlines();
6019
6020        for ext in &["html", "erb"] {
6021            let syntax = ss.find_syntax_by_extension(ext).unwrap();
6022            let mut state = ParseState::new(syntax);
6023            let mut scope_stack = ScopeStack::new();
6024            state
6025                .parse_line("<script type=\"text/javascript\">\n", &ss)
6026                .unwrap()
6027                .ops
6028                .iter()
6029                .for_each(|(_, op)| {
6030                    scope_stack.apply(op).ok();
6031                });
6032            let ops = state.parse_line("var x = 5;\n", &ss).unwrap();
6033            for (_, op) in &ops.ops {
6034                scope_stack.apply(op).ok();
6035            }
6036            let stack_str = format!("{:?}", scope_stack.as_slice());
6037            assert!(
6038                stack_str.contains("source.js"),
6039                "Extension {}: expected source.js in scope stack, got: {}",
6040                ext,
6041                stack_str
6042            );
6043        }
6044    }
6045}