From e3e8806aec771e482899ed3470e920f7b291fa95 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 9 Aug 2026 11:17:08 +0900 Subject: refactor(symfony-finder): extract symfony/finder into the shirabe-symfony-finder crate Move `Symfony\Component\Finder` out of shirabe-external-packages and into its own crate, so the path is `shirabe_symfony_finder::Finder` instead of `shirabe_external_packages::symfony::finder::Finder`. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/symfony/finder/glob.rs | 466 --------------------- 1 file changed, 466 deletions(-) delete mode 100644 crates/shirabe-external-packages/src/symfony/finder/glob.rs (limited to 'crates/shirabe-external-packages/src/symfony/finder/glob.rs') diff --git a/crates/shirabe-external-packages/src/symfony/finder/glob.rs b/crates/shirabe-external-packages/src/symfony/finder/glob.rs deleted file mode 100644 index 247b772f..00000000 --- a/crates/shirabe-external-packages/src/symfony/finder/glob.rs +++ /dev/null @@ -1,466 +0,0 @@ -//! ref: composer/vendor/symfony/finder/Glob.php - -// Regex pattern compatibility: -// PHP's Glob::toRegex builds its regex in a single character loop and emits PCRE-only -// constructs: the look-ahead `(?=[^\.])` enforcing the strict-leading-dot rule, and a -// possessive `[^/]++` inside the `/**/` construct. The regex crate supports neither, so -// instead of a verbatim port of the loop, the glob is first tokenized (mirroring the -// loop's dispatch exactly) and every no-dot constraint is then resolved by recursive -// union expansion, producing a regex-crate-compatible pattern that matches the same -// inputs: -// -// (?=[^.])c => c (branch is unmatchable when c is `\.`) -// (?=[^.])[^/] => [^/.] -// (?=[^.])[abc] => [[abc]&&[^.]] (character class intersection) -// (?=[^.])[^/]*R => (?:[^/.][^/]*R | expand((?=[^.])R)) -// (?=[^.])(a|b)R => (?:expand((?=[^.])aR) | expand((?=[^.])bR)) -// (?=[^.])$ => branch dropped (no next character can exist) -// (?=[^.])(?:$|/) => / (dir-boundary variant only) -// [^/]++ => [^/]+ (the regex crate never backtracks) -// -// Unmatchable branches are dropped; a wholly unmatchable glob renders as `\b\B`, which -// compiles and never matches, just like PHP's own output for such globs. - -#[derive(Debug)] -pub struct Glob; - -#[derive(Debug, Clone, Copy)] -struct Ctx { - strict_leading_dot: bool, - strict_wildcard_slash: bool, -} - -#[derive(Debug, Clone, Copy)] -enum Terminal { - /// The `$` anchor of `to_regex` follows the body. - EndAnchor, - /// The `(?:$|/)` suffix of `to_regex_dir_boundary` ends the body. - EndOrSlash, -} - -#[derive(Debug, Clone)] -enum Token { - /// A literal regex piece for one glob character, escaped as needed. - Text(String), - /// An unescaped `*`. - Star, - /// An unescaped `?`. - Question, - /// A `/**/` (or trailing `/**`) segment, leading slash included. - Globstar { trailing_slash_optional: bool }, - /// An `{a,b}` alternation. `closed` is false when the `}` is missing. - Group { - alternatives: Vec>, - closed: bool, - }, - /// The position of PHP's `(?=[^\.])` look-ahead. - NoDot, - /// End of the pattern. - End(Terminal), -} - -#[derive(Debug)] -enum Out { - /// Fully rendered. - Ok(String), - /// Cannot match anything; the branch must be dropped. - Dead, - /// A no-dot constraint reached the end of an isolated sub-sequence (e.g. `{a/,b}` - /// puts a look-ahead right before the closing brace); the caller must splice in the - /// following context and re-render. - Escapes, -} - -impl Glob { - pub fn to_regex(glob: &str, strict_leading_dot: bool, strict_wildcard_slash: bool) -> String { - format!( - "#^{}$#", - expand( - glob, - strict_leading_dot, - strict_wildcard_slash, - Terminal::EndAnchor - ) - ) - } - - /// Not part of Symfony's Glob. Composer's BaseExcludeFilter derives its patterns - /// from toRegex output by stripping the delimiters and anchors and appending the - /// `(?=$|/)` dir-boundary look-ahead. Here that suffix has to take part in the - /// union expansion — when a trailing `*` matches zero characters, the no-dot - /// constraint falls onto the boundary itself — so it cannot be appended by the - /// caller after the fact. Returns the undelimited, unanchored body with the - /// boundary woven in as the consuming `(?:$|/)` (equivalent under boolean - /// matching, since nothing follows it). - pub fn to_regex_dir_boundary( - glob: &str, - strict_leading_dot: bool, - strict_wildcard_slash: bool, - ) -> String { - expand( - glob, - strict_leading_dot, - strict_wildcard_slash, - Terminal::EndOrSlash, - ) - } -} - -fn expand( - glob: &str, - strict_leading_dot: bool, - strict_wildcard_slash: bool, - terminal: Terminal, -) -> String { - let ctx = Ctx { - strict_leading_dot, - strict_wildcard_slash, - }; - let mut tokens = tokenize(glob, ctx); - tokens.push(Token::End(terminal)); - match render_seq(&tokens, ctx) { - Out::Ok(body) => body, - Out::Dead => "\\b\\B".to_string(), - Out::Escapes => unreachable!("the terminal token resolves every constraint"), - } -} - -// PHP iterates over bytes; iterating over chars is equivalent for valid UTF-8 because -// every character the loop treats specially is ASCII. -fn tokenize(glob: &str, ctx: Ctx) -> Vec { - let chars: Vec = glob.chars().collect(); - let mut first_byte = true; - let mut escaping = false; - // stack[0] is the root sequence (a pseudo-group with a single alternative); deeper - // frames are open `{` groups, so PHP's $inCurlies == stack.len() - 1. - let mut stack: Vec>> = vec![vec![Vec::new()]]; - - fn push(stack: &mut [Vec>], token: Token) { - stack.last_mut().unwrap().last_mut().unwrap().push(token); - } - - let mut i = 0; - while i < chars.len() { - let car = chars[i]; - if first_byte && ctx.strict_leading_dot && car != '.' { - push(&mut stack, Token::NoDot); - } - - first_byte = car == '/'; - - if first_byte - && ctx.strict_wildcard_slash - && i + 2 < chars.len() - && chars[i + 1] == '*' - && chars[i + 2] == '*' - && (i + 3 >= chars.len() || chars[i + 3] == '/') - { - push( - &mut stack, - Token::Globstar { - trailing_slash_optional: i + 3 >= chars.len(), - }, - ); - i += 2 + usize::from(i + 3 < chars.len()); - escaping = false; - i += 1; - continue; - } - - if car == '#' - || car == '.' - || car == '(' - || car == ')' - || car == '|' - || car == '+' - || car == '^' - || car == '$' - { - push(&mut stack, Token::Text(format!("\\{car}"))); - } else if car == '*' { - push( - &mut stack, - if escaping { - Token::Text("\\*".to_string()) - } else { - Token::Star - }, - ); - } else if car == '?' { - push( - &mut stack, - if escaping { - Token::Text("\\?".to_string()) - } else { - Token::Question - }, - ); - } else if car == '{' { - if escaping { - push(&mut stack, Token::Text("\\{".to_string())); - } else { - stack.push(vec![Vec::new()]); - } - } else if car == '}' && stack.len() > 1 { - if escaping { - push(&mut stack, Token::Text("}".to_string())); - } else { - let alternatives = stack.pop().unwrap(); - push( - &mut stack, - Token::Group { - alternatives, - closed: true, - }, - ); - } - } else if car == ',' && stack.len() > 1 { - if escaping { - push(&mut stack, Token::Text(",".to_string())); - } else { - stack.last_mut().unwrap().push(Vec::new()); - } - } else if car == '\\' { - if escaping { - push(&mut stack, Token::Text("\\\\".to_string())); - escaping = false; - } else { - escaping = true; - } - i += 1; - continue; - } else { - push(&mut stack, Token::Text(car.to_string())); - } - escaping = false; - i += 1; - } - - // PHP leaves an unterminated `(` for an unclosed `{`; keep the broken shape so the - // resulting pattern fails to compile just as the PCRE one does. - while stack.len() > 1 { - let alternatives = stack.pop().unwrap(); - push( - &mut stack, - Token::Group { - alternatives, - closed: false, - }, - ); - } - stack.pop().unwrap().pop().unwrap() -} - -fn star(ctx: Ctx) -> &'static str { - if ctx.strict_wildcard_slash { - "[^/]*" - } else { - ".*" - } -} - -fn question(ctx: Ctx) -> &'static str { - if ctx.strict_wildcard_slash { - "[^/]" - } else { - "." - } -} - -fn globstar(ctx: Ctx, trailing_slash_optional: bool) -> String { - // PHP emits `/(?:(?=[^\.])[^/]++/)*`; `(?=[^\.])[^/]+` collapses to `[^/.][^/]*`. - let step = if ctx.strict_leading_dot { - "[^/.][^/]*" - } else { - "[^/]+" - }; - let slash = if trailing_slash_optional { "/?" } else { "/" }; - format!("/(?:{step}{slash})*") -} - -fn render_seq(tokens: &[Token], ctx: Ctx) -> Out { - let mut out = String::new(); - for (idx, token) in tokens.iter().enumerate() { - match token { - Token::Text(text) => out.push_str(text), - Token::Star => out.push_str(star(ctx)), - Token::Question => out.push_str(question(ctx)), - Token::Globstar { - trailing_slash_optional, - } => out.push_str(&globstar(ctx, *trailing_slash_optional)), - Token::End(Terminal::EndAnchor) => {} - Token::End(Terminal::EndOrSlash) => out.push_str("(?:$|/)"), - Token::NoDot => { - return prefix(out, apply_no_dot(&tokens[idx + 1..], ctx)); - } - Token::Group { - alternatives, - closed, - } => match render_group_isolated(alternatives, *closed, ctx) { - Out::Ok(group) => out.push_str(&group), - Out::Dead => return Out::Dead, - Out::Escapes => { - // A constraint inside the group applies to what follows it; splice - // the remainder into each alternative and re-render. - let rest = &tokens[idx + 1..]; - let mut branches = Vec::new(); - for alternative in alternatives { - let mut seq = alternative.clone(); - seq.extend_from_slice(rest); - match render_seq(&seq, ctx) { - Out::Ok(branch) => branches.push(branch), - Out::Dead => {} - Out::Escapes => return Out::Escapes, - } - } - return match join_branches(branches) { - Some(joined) => Out::Ok(out + &joined), - None => Out::Dead, - }; - } - }, - } - } - Out::Ok(out) -} - -fn render_group_isolated(alternatives: &[Vec], closed: bool, ctx: Ctx) -> Out { - let mut rendered = Vec::new(); - for alternative in alternatives { - match render_seq(alternative, ctx) { - Out::Ok(branch) => rendered.push(branch), - Out::Dead => {} - Out::Escapes => { - if !closed { - // The unterminated `(` makes the output uncompilable either way; - // resolve the dangling constraint as if the pattern ended here. - let mut seq = alternative.clone(); - seq.push(Token::End(Terminal::EndAnchor)); - if let Out::Ok(branch) = render_seq(&seq, ctx) { - rendered.push(branch); - } - } else { - return Out::Escapes; - } - } - } - } - if rendered.is_empty() { - return Out::Dead; - } - let close = if closed { ")" } else { "" }; - Out::Ok(format!("({}{close}", rendered.join("|"))) -} - -/// Renders the remainder of the pattern under the constraint that the next matched -/// character must not be a dot. -fn apply_no_dot(tokens: &[Token], ctx: Ctx) -> Out { - let Some((first, rest)) = tokens.split_first() else { - return Out::Escapes; - }; - match first { - Token::Text(text) if text == "\\." => Out::Dead, - Token::Text(text) if text == "[" => { - if let Some((class, after)) = scan_char_class(tokens) { - prefix( - format!("[{class}&&[^.]]"), - render_seq(&tokens[after..], ctx), - ) - } else { - prefix("[".to_string(), render_seq(rest, ctx)) - } - } - Token::Text(text) => prefix(text.clone(), render_seq(rest, ctx)), - Token::Star => { - // Either the `*` consumes at least one character, which then carries the - // constraint, or it consumes none and the constraint moves past it. - let head = if ctx.strict_wildcard_slash { - "[^/.][^/]*" - } else { - "[^.\\n].*" - }; - let consumed = prefix(head.to_string(), render_seq(rest, ctx)); - let skipped = apply_no_dot(rest, ctx); - match (consumed, skipped) { - (Out::Escapes, _) | (_, Out::Escapes) => Out::Escapes, - (Out::Ok(a), Out::Ok(b)) => Out::Ok(format!("(?:{a}|{b})")), - (Out::Ok(a), Out::Dead) => Out::Ok(a), - (Out::Dead, Out::Ok(b)) => Out::Ok(b), - (Out::Dead, Out::Dead) => Out::Dead, - } - } - Token::Question => { - let head = if ctx.strict_wildcard_slash { - "[^/.]" - } else { - "[^.\\n]" - }; - prefix(head.to_string(), render_seq(rest, ctx)) - } - // The construct starts with a literal `/`, which satisfies the constraint. - Token::Globstar { .. } => render_seq(tokens, ctx), - Token::Group { - alternatives, - closed: true, - } => { - let mut branches = Vec::new(); - for alternative in alternatives { - let mut seq = alternative.clone(); - seq.extend_from_slice(rest); - match apply_no_dot(&seq, ctx) { - Out::Ok(branch) => branches.push(branch), - Out::Dead => {} - Out::Escapes => return Out::Escapes, - } - } - match join_branches(branches) { - Some(joined) => Out::Ok(joined), - None => Out::Dead, - } - } - // The unterminated `(` makes the output uncompilable either way; skip the - // expansion. - Token::Group { closed: false, .. } => render_seq(tokens, ctx), - // `(?=[^.])$`: no next character can exist. - Token::End(Terminal::EndAnchor) => Out::Dead, - // `(?=[^.])(?=$|/)`: the next character must exist, must not be a dot, and must - // be either the end (impossible) or a slash — exactly one consumable `/`. - Token::End(Terminal::EndOrSlash) => Out::Ok("/".to_string()), - Token::NoDot => unreachable!("PHP never emits two consecutive look-aheads"), - } -} - -fn prefix(head: String, tail: Out) -> Out { - match tail { - Out::Ok(tail) => Out::Ok(head + &tail), - other => other, - } -} - -fn join_branches(mut branches: Vec) -> Option { - match branches.len() { - 0 => None, - 1 => branches.pop(), - _ => Some(format!("(?:{})", branches.join("|"))), - } -} - -/// A `[` passes through the PHP loop untouched and opens a character class in the -/// final regex, so a no-dot constraint has to intersect the class rather than be -/// satisfied by the `[` itself. Collects a class spanning plain text tokens; None when -/// no plain closing `]` follows (the `[` is then treated as a literal). -fn scan_char_class(tokens: &[Token]) -> Option<(String, usize)> { - let mut class = String::from("["); - let mut idx = 1; - loop { - match tokens.get(idx)? { - Token::Text(text) if text == "]" => { - class.push(']'); - return Some((class, idx + 1)); - } - Token::Text(text) => class.push_str(text), - _ => return None, - } - idx += 1; - } -} -- cgit v1.3.1-4-g156e