[
https://issues.apache.org/jira/browse/GROOVY-12133?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Daniel Sun updated GROOVY-12133:
--------------------------------
Description:
h2. Summary
Add a GDK utility to extract nested balanced groups from text (parentheses,
braces, HTML-like tags, etc.), providing a structured alternative to .NET regex
balancing groups for the Java/Groovy regex stack.
h2. Motivation
{{java.util.regex.Pattern}} has no equivalent of .NET balancing groups
({{{}(?<name1-name2>…){}}}). Nested constructs are therefore awkward to handle
with pure Java regex and often lead to fragile or backtracking-heavy patterns.
Groovy users need a first-class, safe way to recover hierarchical balanced
spans with clear source offsets.
h2. Solution
Introduce {{groovy.util.regex.BalancedGroup}} and a GDK method
{{{}CharSequence.findBalancedGroups(...){}}}, backed by a single-pass tokenizer
+ stack (not recursive regex). Nesting is resolved by an explicit stack, so
balancing itself does not introduce ReDoS; cost scales with tokenizer matches
and the user-supplied open/close/ignore patterns.
h3. API
* {{BalancedGroup.find(text, openRegex, closeRegex[, options])}} — core entry
point
* {{CharSequence.findBalancedGroups(openRegex, closeRegex[, options])}} — GDK
sugar (delegates to the above)
* {{BalancedGroup.MatchOptions}} — immutable options ({{{}ignoreRegex{}}},
{{{}includeEdges{}}}) with wither methods
* Result tree: {{{}matchedString{}}},
{{{}start{}}}/{{{}end{}}}/{{{}length{}}}, {{{}fullStart{}}}/{{{}fullEnd{}}},
{{{}children{}}}, {{{}parent{}}}, {{depth}}
h3. Behaviour notes
* *Open/close* are user regexes for delimiters (must be non-empty).
* {*}{{includeEdges}}{*}: when {{{}false{}}}, {{matchedString}} is the
interior only (interval between open and close), while
{{{}fullStart{}}}/{{{}fullEnd{}}} still cover the full pair including
delimiters.
* {*}{{ignoreRegex}}{*}: spans to skip while scanning (e.g. string literals,
comments, self-closing tags).
* Unmatched closers are ignored. Unclosed openers are dropped at end-of-input;
completed groups nested inside them are promoted (“orphan rescue”).
* Named groups {{{}OPEN{}}}, {{{}CLOSE{}}}, and {{IGNORE}} are reserved by the
internal tokenizer.
* Compiled tokenizers are cached (bounded LRU) for repeated open/close/ignore
triples.
h2. Examples
{code:groovy}
def roots = 'Root: (A + (B * C) + (D(E)))'.findBalancedGroups(/\(/, /\)/)
assert roots.size() == 1
assert roots[0].matchedString == '(A + (B * C) + (D(E)))'
assert roots[0].children*.matchedString == ['(B * C)', '(D(E))']
def opts = BalancedGroup.MatchOptions.defaults().withIncludeEdges(false)
assert 'func(arg1, arg2)'.findBalancedGroups(/\(/, /\)/, opts)[0].matchedString
== 'arg1, arg2'
def code = 'if (true) { String s = "}"; }'
def ignoreStrings =
BalancedGroup.MatchOptions.defaults().withIgnoreRegex(/"(?:\\.|[^"\\])*"/)
assert code.findBalancedGroups(/\{/, /\}/, ignoreStrings)[0].matchedString ==
'{ String s = "}"; }'
{code}
was:
h2. Summary
Add a GDK utility to extract nested balanced groups from text (parentheses,
braces, HTML-like tags, etc.), providing a structured alternative to .NET regex
balancing groups for the Java/Groovy regex stack.
h2. Motivation
{{java.util.regex.Pattern}} has no equivalent of .NET balancing groups
({{(?<name1-name2>…)}}). Nested constructs are therefore awkward to handle with
pure Java regex and often lead to fragile or backtracking-heavy patterns.
Groovy users need a first-class, safe way to recover hierarchical balanced
spans with clear source offsets.
h2. Solution
Introduce {{groovy.util.regex.BalancedGroup}} and a GDK method
{{CharSequence.findBalancedGroups(...)}}, backed by a single-pass tokenizer +
stack (not recursive regex). Nesting is resolved by an explicit stack, so
balancing itself does not introduce ReDoS; cost scales with tokenizer matches
and the user-supplied open/close/ignore patterns.
h3. API
* {{BalancedGroup.find(text, openRegex, closeRegex[, options])}} — core entry
point
* {{CharSequence.findBalancedGroups(openRegex, closeRegex[, options])}} — GDK
sugar (delegates to the above)
* {{BalancedGroup.MatchOptions}} — immutable options ({{ignoreRegex}},
{{includeEdges}}) with wither methods
* Result tree: {{matchedString}}, {{start}}/{{end}}/{{length}},
{{fullStart}}/{{fullEnd}}, {{children}}, {{parent}}, {{depth}}
h3. Behaviour notes
* *Open/close* are user regexes for delimiters (must be non-empty).
* *{{includeEdges}}*: when {{false}}, {{matchedString}} is the interior only
(interval between open and close), while {{fullStart}}/{{fullEnd}} still cover
the full pair including delimiters.
* *{{ignoreRegex}}*: spans to skip while scanning (e.g. string literals,
comments, self-closing tags).
* Unmatched closers are ignored. Unclosed openers are dropped at end-of-input;
completed groups nested inside them are promoted (“orphan rescue”).
* Named groups {{OPEN}}, {{CLOSE}}, and {{IGNORE}} are reserved by the internal
tokenizer.
* Compiled tokenizers are cached (bounded LRU) for repeated open/close/ignore
triples.
h2. Examples
{code:groovy}
def roots = 'Root: (A + (B * C) + (D(E)))'.findBalancedGroups(/\(/, /\)/)
assert roots.size() == 1
assert roots[0].matchedString == '(A + (B * C) + (D(E)))'
assert roots[0].children*.matchedString == ['(B * C)', '(D(E))']
def opts = BalancedGroup.MatchOptions.defaults().withIncludeEdges(false)
assert 'func(arg1, arg2)'.findBalancedGroups(/\(/, /\)/, opts)[0].matchedString
== 'arg1, arg2'
def code = 'if (true) { String s = "}"; }'
def ignoreStrings = BalancedGroup.MatchOptions.defaults()
.withIgnoreRegex(/"(?:\\.|[^"\\])*"/)
assert code.findBalancedGroups(/\{/, /\}/, ignoreStrings)[0].matchedString ==
'{ String s = "}"; }'
{code}
> Implement findBalancedGroups utility to support Regex Balanced Groups
> ---------------------------------------------------------------------
>
> Key: GROOVY-12133
> URL: https://issues.apache.org/jira/browse/GROOVY-12133
> Project: Groovy
> Issue Type: Improvement
> Reporter: Daniel Sun
> Priority: Major
> Fix For: 6.0.0-beta-1
>
>
> h2. Summary
> Add a GDK utility to extract nested balanced groups from text (parentheses,
> braces, HTML-like tags, etc.), providing a structured alternative to .NET
> regex balancing groups for the Java/Groovy regex stack.
> h2. Motivation
> {{java.util.regex.Pattern}} has no equivalent of .NET balancing groups
> ({{{}(?<name1-name2>…){}}}). Nested constructs are therefore awkward to
> handle with pure Java regex and often lead to fragile or backtracking-heavy
> patterns. Groovy users need a first-class, safe way to recover hierarchical
> balanced spans with clear source offsets.
> h2. Solution
> Introduce {{groovy.util.regex.BalancedGroup}} and a GDK method
> {{{}CharSequence.findBalancedGroups(...){}}}, backed by a single-pass
> tokenizer + stack (not recursive regex). Nesting is resolved by an explicit
> stack, so balancing itself does not introduce ReDoS; cost scales with
> tokenizer matches and the user-supplied open/close/ignore patterns.
> h3. API
> * {{BalancedGroup.find(text, openRegex, closeRegex[, options])}} — core
> entry point
> * {{CharSequence.findBalancedGroups(openRegex, closeRegex[, options])}} —
> GDK sugar (delegates to the above)
> * {{BalancedGroup.MatchOptions}} — immutable options ({{{}ignoreRegex{}}},
> {{{}includeEdges{}}}) with wither methods
> * Result tree: {{{}matchedString{}}},
> {{{}start{}}}/{{{}end{}}}/{{{}length{}}}, {{{}fullStart{}}}/{{{}fullEnd{}}},
> {{{}children{}}}, {{{}parent{}}}, {{depth}}
> h3. Behaviour notes
> * *Open/close* are user regexes for delimiters (must be non-empty).
> * {*}{{includeEdges}}{*}: when {{{}false{}}}, {{matchedString}} is the
> interior only (interval between open and close), while
> {{{}fullStart{}}}/{{{}fullEnd{}}} still cover the full pair including
> delimiters.
> * {*}{{ignoreRegex}}{*}: spans to skip while scanning (e.g. string literals,
> comments, self-closing tags).
> * Unmatched closers are ignored. Unclosed openers are dropped at
> end-of-input; completed groups nested inside them are promoted (“orphan
> rescue”).
> * Named groups {{{}OPEN{}}}, {{{}CLOSE{}}}, and {{IGNORE}} are reserved by
> the internal tokenizer.
> * Compiled tokenizers are cached (bounded LRU) for repeated
> open/close/ignore triples.
> h2. Examples
> {code:groovy}
> def roots = 'Root: (A + (B * C) + (D(E)))'.findBalancedGroups(/\(/, /\)/)
> assert roots.size() == 1
> assert roots[0].matchedString == '(A + (B * C) + (D(E)))'
> assert roots[0].children*.matchedString == ['(B * C)', '(D(E))']
> def opts = BalancedGroup.MatchOptions.defaults().withIncludeEdges(false)
> assert 'func(arg1, arg2)'.findBalancedGroups(/\(/, /\)/,
> opts)[0].matchedString == 'arg1, arg2'
> def code = 'if (true) { String s = "}"; }'
> def ignoreStrings =
> BalancedGroup.MatchOptions.defaults().withIgnoreRegex(/"(?:\\.|[^"\\])*"/)
> assert code.findBalancedGroups(/\{/, /\}/, ignoreStrings)[0].matchedString ==
> '{ String s = "}"; }'
> {code}
--
This message was sent by Atlassian Jira
(v8.20.10#820010)