Skip to content

Commit 75d95d6

Browse files
authored
[31] Balanced Search Strategy (#42)
* Balanced strategy * `[!CAUTION]` note to `README.md`
1 parent d4ca286 commit 75d95d6

16 files changed

Lines changed: 985 additions & 6 deletions

File tree

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,23 @@ const transformer = new ReplaceContentTransformer(
181181
> [!CAUTION]
182182
> The `regex` search strategy is marginally less performant than static string anchors, and does not support all regular expression features. See [limitations](./src/search-strategies/regex/README.md#limitations).
183183
184+
#### Replacing Balanced Pairs (respecting nesting)
185+
186+
Match and replace delimiter pairs that may be nested, using `BalancedPairSearchStrategy`. The match only completes when the outermost pair is fully balanced:
187+
188+
```typescript
189+
import { BalancedPairSearchStrategy } from "replace-content-transformer";
190+
191+
// "(a (b) c)" becomes "[a (b) c]"
192+
// "(d)" becomes "[d]"
193+
const transformer = new ReplaceContentTransformer(
194+
new SyncReplacementTransformEngine({
195+
searchStrategy: new BalancedPairSearchStrategy("(", ")"),
196+
replacement: (match: string) => `[${match.slice(1, -1)}]`
197+
})
198+
);
199+
```
200+
184201
#### Async Replacement
185202

186203
Replace with asynchronous content. Ensures each async replacement completes before the next starts.
@@ -389,6 +406,7 @@ This should ensure in-flight requests are cancelled along with ongoing replaceme
389406

390407
Use the Node adapters (`ReplaceContentTransform`, `AsyncReplaceContentTransform`) for a native [`stream.Transform`](https://nodejs.org/api/stream.html#class-streamtransform) implementation, if performance cost of [`toWeb`](https://nodejs.org/api/stream.html#streamreadabletowebstreamreadable-options) / [`fromWeb`](https://nodejs.org/api/stream.html#streamreadabletowebstreamreadable-options) conversion is a concern.
391408

409+
> [!CAUTION]
392410
> **Encoding**: these adapters assume UTF-8 input. Node's default `decodeStrings: true` behaviour re-encodes any string written to the writable side as a UTF-8 `Buffer`; non-UTF-8 byte streams will be mis-decoded silently.
393411
394412
`AsyncReplaceContentTransform` accepts any `AsyncTransformEngine`, including `AsyncLookaheadTransformEngine`. It shares the same engine and options as its web counterpart, so pipelined in-order async replacement, nested `nested()` re-scanning, bounded concurrency, and `highWaterMark` backpressure behave identically across runtimes. The standard `TransformOptions.highWaterMark` controls Node-stream backpressure independently of the engine's own `highWaterMark`.
@@ -476,6 +494,7 @@ The `flush` is called by the engine to extract anything buffered from the search
476494
Each strategy contains the pattern-matching logic for a specific use case:
477495

478496
- **[`StringAnchorSearchStrategy`](./src/search-strategies/looped-indexOf-anchored/README.md)** - finds either single tokens, or "anchor" tokens delimiting start/end (or in sequence in-between) of a match
497+
- **[`BalancedPairSearchStrategy`](./src/search-strategies/balanced-pair/README.md)** - Matches balanced delimiter pairs, correctly handling arbitrary nesting depth (e.g. `(outer (inner) rest)`)
479498
- **[`RegexSearchStrategy`](./src/search-strategies/regex/README.md)** - Matches against regular expressions (with some caveats)
480499

481500
See [search strategies](./src/search-strategies/README.md) for detail of functionality, and development of the strategies.
@@ -510,6 +529,9 @@ import { RegexSearchStrategy } from "replace-content-transformer";
510529
const searchStrategy = new RegexSearchStrategy(/<div>.+?<\/div>/s); // regular expression for complete match
511530
```
512531

532+
> [!IMPORTANT]
533+
> The `BalancedPairSearchStrategy` is not returned by this factory, since it cannot be determined from input, so import this directly, as described above.
534+
513535
### 🦾 Engines
514536

515537
Engines process chunks from the `Transformer` (web) / `stream.Transform` (node), orchestrating replacement using a search strategy. They implement one of two interfaces:
@@ -673,3 +695,4 @@ Please feel free to raise an [Issue](https://github.com/TomStrepsil/replace-cont
673695
- [stream-replace-string](https://github.com/ChocolateLoverRaj/stream-replace-string) - A Node stream transform (abandoned)
674696
- [replacestream](https://github.com/eugeneware/replacestream) - a Node stream transform, supporting regex matching (last update 2016)
675697
- [string-searching](https://github.com/string-searching) - various string searching algorithms in javascript
698+
- [balanced-match](https://github.com/juliangruber/balanced-match/) - balanced string matching

docs/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- `BalancedPairSearchStrategy` for matching two string anchors, but respecting nesting levels so only matching where "balanced"
13+
1014
### Changed
1115

1216
- Removed note regarding lack of `cancel?` in the `TransformStream/transformer` type after resolution of https://github.com/nodejs/node/issues/62540
@@ -17,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1721
### Fixed
1822

1923
- Path in lookahead transformer [`README.md`](../src/engines/async-lookahead-transform-engine/README.md) back to [main `README.md`](../README.md)
24+
- Added `[!CAUTION]` to `README.md` re: `UTF-8`
2025

2126
## [2.0.0] - 2026-05-11
2227

src/search-strategies/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ Single or Multiple-token sequential matching using smart buffering (only when th
1515
- **Use Case**: Single or Sequential multi-delimiter patterns in streaming content
1616
- **Exported As**: `StringAnchorSearchStrategy`
1717

18+
## ⚖️ Balanced Pair (Nesting-Aware Matching)
19+
20+
**[balanced-pair](./balanced-pair/README.md)** - Nesting-aware delimiter matching strategy implementation
21+
22+
Matches opening/closing delimiter pairs where nesting is meaningful: the match only completes once every inner opening has a corresponding closing. Built on top of `StringAnchorSearchStrategy`, adding a nesting-level counter that keeps the match open until the outermost pair is balanced.
23+
24+
- **Algorithm**: Nesting-level tracking over sequential anchor matching
25+
- **Performance**: O(n+m) for scanning + O(k) per match increment to count inner openings (k = length of new match content)
26+
- **Use Case**: Balanced/nested structures — parentheses, braces, custom multi-character delimiters
27+
- **Exported As**: `BalancedPairSearchStrategy`
28+
1829
## 🧠 Regex
1930

2031
**[regex](./regex/README.md)** - Pattern matching using regular expressions
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
# Balanced Pair Search Strategy
2+
3+
A search strategy for matching **balanced delimiter pairs** — including nested occurrences — by layering nesting-level tracking on top of the [`StringAnchorSearchStrategy`](../looped-indexOf-anchored/README.md).
4+
5+
## Algorithm Overview
6+
7+
This strategy finds opening/closing delimiter pairs where nesting is meaningful: the match only completes when every opening delimiter inside it has a corresponding closing delimiter. For example:
8+
9+
- `(`, `)` matches `(content)`, `((a) b)`, `(((deep)))`
10+
- `{{`, `}}` matches `{{value}}`, `{{{{nested}}}}`
11+
- `[`, `]` matches `[outer [inner] rest]`
12+
13+
A simple anchor search would match the _first_ closing delimiter after the opening, producing incomplete matches in nested input. `BalancedPairSearchStrategy` extends that underlying search so every inner pair is counted and the match only closes when the depth returns to zero.
14+
15+
### Nesting Detection
16+
17+
```
18+
Opening: "(" Closing: ")"
19+
Input: "((inner) outer)"
20+
21+
Position: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
22+
Char: ( ( i n n e r ) o u t e r )
23+
↑ ↑ ↑
24+
match starts first ) outer )
25+
nestingLevel=0 found found
26+
27+
On first ) at position 7:
28+
nestingLevel-- → -1
29+
Count "(" in "((inner)": two openings → nestingLevel = -1 + 2 = 1
30+
Not yet balanced (nestingLevel > 0) — keep looking for more ")"
31+
32+
On outer ) at position 14:
33+
nestingLevel-- → 0
34+
Count "(" in " outer)": zero openings → nestingLevel = 0
35+
Balanced! Yield "((inner) outer)" as a match
36+
```
37+
38+
**Without nesting awareness:**
39+
40+
```
41+
Anchor strategy alone:
42+
Input: "((inner) outer)"
43+
Match: "((inner)" ← stops at first ), incomplete!
44+
```
45+
46+
## Implementation Details
47+
48+
`BalancedPairSearchStrategy` wraps `LoopedIndexOfAnchoredSearchStrategy` and post-processes each match it yields:
49+
50+
```typescript
51+
class BalancedPairSearchStrategy implements SearchStrategy<
52+
BalancedPairSearchState,
53+
string
54+
> {
55+
private anchorStringSearchStrategy: LoopedIndexOfAnchoredSearchStrategy;
56+
57+
constructor(opening: string, closing: string) {
58+
this.anchorStringSearchStrategy = new LoopedIndexOfAnchoredSearchStrategy([
59+
opening,
60+
closing
61+
]);
62+
}
63+
}
64+
```
65+
66+
When the underlying anchor strategy yields a match (opening–closing pair found), the balanced pair strategy:
67+
68+
1. Counts any additional opening delimiters within the new match content
69+
2. Adjusts `nestingLevel` accordingly
70+
3. If `nestingLevel > 0`, resets `currentNeedleIndex = 1` on the shared state so the anchor strategy resumes scanning for the next closing delimiter — without discarding already-accumulated match content
71+
4. Accumulates content in `balancedBuffer` until `nestingLevel` returns to zero, then yields the whole span as a single match
72+
73+
### Incremental Content Tracking
74+
75+
The underlying anchor strategy's match content grows cumulatively (from the first opening needle to each subsequent closing needle). The balanced pair strategy slices only the _new_ portion of each yielded match to count openings in it:
76+
77+
```typescript
78+
const newContent = matchResult.content.slice(prevMatchLength);
79+
prevMatchLength = matchResult.content.length;
80+
// ...
81+
state.nestingLevel--;
82+
let cursor = 0;
83+
while ((cursor = newContent.indexOf(this.opening, cursor)) !== -1) {
84+
state.nestingLevel++;
85+
cursor += this.opening.length;
86+
}
87+
```
88+
89+
This ensures each opening is counted exactly once, regardless of how many intermediate closing delimiters were found.
90+
91+
### Inner Buffer Adjustment
92+
93+
When `nestingLevel > 0` and the underlying strategy's buffer still holds in-flight content, the balanced pair strategy trims the anchor strategy's buffer to exclude content already accounted for in `balancedBuffer`:
94+
95+
```typescript
96+
if (state.nestingLevel > 0) {
97+
state.buffer = state.buffer.slice(state.balancedBuffer.length);
98+
}
99+
```
100+
101+
This prevents double-counting when the anchor strategy's cross-chunk buffer is combined with the next chunk.
102+
103+
## State Management
104+
105+
`BalancedPairSearchState` extends `LoopedIndexOfAnchoredSearchState`:
106+
107+
```typescript
108+
interface BalancedPairSearchState extends LoopedIndexOfAnchoredSearchState {
109+
/** How many unmatched opening delimiters have been seen inside the current match */
110+
nestingLevel: number;
111+
/** Accumulated content of the outermost balanced match in progress */
112+
balancedBuffer: string;
113+
/** Absolute stream offset at which the outermost opening delimiter was found */
114+
balancedBufferStart: number;
115+
}
116+
```
117+
118+
**State transitions:**
119+
120+
| Condition | `nestingLevel` | `balancedBuffer` | Action |
121+
| --------------------------------- | --------------------- | -------------------------- | ----------------------------------- |
122+
| No match found | unchanged (0) | `""` | Pass through as non-match |
123+
| Opening found, no inner opens | `0` (after `--` + 0) | accumulated → cleared | Yield as complete match |
124+
| Opening found, inner opens exist | `> 0` | accumulating | Set `currentNeedleIndex = 1`, continue |
125+
| Subsequent close balances depth | decrements toward `0` | accumulating | Continue or yield when reaching `0` |
126+
| Flush mid-match | reset to `0` | cleared (returned as-is) | Return buffered content unflushed |
127+
128+
**Flush behaviour:**
129+
130+
If a stream ends while a balanced match is in progress (e.g. `((inner)` with no outer `)`), `flush` returns all accumulated content in `balancedBuffer` plus any remaining content in the anchor strategy's buffer:
131+
132+
```typescript
133+
flush(state: BalancedPairSearchState): string {
134+
const flushed = state.balancedBuffer;
135+
state.balancedBuffer = "";
136+
state.balancedBufferStart = 0;
137+
state.nestingLevel = 0;
138+
return flushed + this.anchorStringSearchStrategy.flush(state);
139+
}
140+
```
141+
142+
## Cross-Chunk Matching
143+
144+
The strategy correctly handles matches that span chunk boundaries at every level:
145+
146+
```
147+
Opening: "(" Closing: ")"
148+
Chunk 1: "((inner)"
149+
Chunk 2: " outer)"
150+
151+
Process Chunk 1:
152+
- Anchor strategy finds "((inner)" as match
153+
- nestingLevel: 0 − 1 + 2 = 1 (two "(" inside, one ")" closes)
154+
- currentNeedleIndex reset to 1 — continue scanning for ")"
155+
- balancedBuffer: "((inner)"
156+
157+
Process Chunk 2:
158+
- Anchor strategy finds " outer)" as the continuation
159+
- nestingLevel: 1 − 1 = 0 (no new "(" in " outer)")
160+
- Balanced! Yield "((inner) outer)", streamIndices: [0, 15]
161+
```
162+
163+
## Stream Indices
164+
165+
Matches report `streamIndices` as absolute offsets into the full stream, not relative to the current chunk:
166+
167+
```
168+
Stream: "prefix ((inner) outer) suffix"
169+
0 7 15 22
170+
171+
Match yields: { isMatch: true, content: "((inner) outer)", streamIndices: [7, 22] }
172+
```
173+
174+
`streamIndices[0]` is the start of the outermost opening delimiter; `streamIndices[1]` is the end of the outermost closing delimiter (exclusive, following the same convention as [`String.prototype.slice`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/slice)).
175+
176+
## Performance Characteristics
177+
178+
`BalancedPairSearchStrategy` inherits the smart partial-match buffering of `LoopedIndexOfAnchoredSearchStrategy`. The nesting layer adds a linear scan of each new match increment to count opening delimiters, which is proportional to match content size rather than stream size.
179+
180+
| Scenario | Characteristic |
181+
| -------------------------------- | ------------------------------------------------------------------- |
182+
| No matches in stream | Same cost as anchor strategy — near-zero buffering overhead |
183+
| Flat (non-nested) matches | Minimal overhead over anchor strategy — one scan per match |
184+
| Deeply nested matches | Proportional to depth × average inner-match size |
185+
| Partial matches at boundaries | Inherits smart buffering — only buffers on genuine partial matches |
186+
187+
## Usage Examples
188+
189+
### Single-Character Delimiters
190+
191+
```typescript
192+
import { BalancedPairSearchStrategy } from "replace-content-transformer";
193+
194+
const strategy = new BalancedPairSearchStrategy("(", ")");
195+
```
196+
197+
### Multi-Character Delimiters
198+
199+
```typescript
200+
// Matches {{value}}, {{{{doubly nested}}}}, etc.
201+
const strategy = new BalancedPairSearchStrategy("{{", "}}");
202+
```
203+
204+
### With an Engine
205+
206+
```typescript
207+
import {
208+
BalancedPairSearchStrategy,
209+
SyncReplacementTransformEngine
210+
} from "replace-content-transformer";
211+
import { ReplaceContentTransformer } from "replace-content-transformer/web";
212+
213+
// Replace every top-level balanced `(...)` expression, including nested ones
214+
const transformer = new ReplaceContentTransformer(
215+
new SyncReplacementTransformEngine({
216+
searchStrategy: new BalancedPairSearchStrategy("(", ")"),
217+
replacement: (match: string) => `[${match.slice(1, -1)}]`
218+
})
219+
);
220+
```
221+
222+
### Detecting Nesting Depth
223+
224+
Since the full matched content is available in the replacement function, nesting depth can be calculated from the match itself:
225+
226+
```typescript
227+
const strategy = new BalancedPairSearchStrategy("(", ")");
228+
229+
const transformer = new ReplaceContentTransformer(
230+
new SyncReplacementTransformEngine({
231+
searchStrategy: strategy,
232+
replacement: (match: string) => {
233+
const depth = (match.match(/\(/g) ?? []).length;
234+
return `/* depth ${depth} */${match}`;
235+
}
236+
})
237+
);
238+
```
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { BalancedPairSearchStrategy } from "./search-strategy.js";

0 commit comments

Comments
 (0)