Skip to content

search + find-and-replace: lift AvaloniaEdit engine, wire Editor + ted (R9 amendment)#76

Merged
tig merged 8 commits into
developfrom
experiment/codex/search
May 12, 2026
Merged

search + find-and-replace: lift AvaloniaEdit engine, wire Editor + ted (R9 amendment)#76
tig merged 8 commits into
developfrom
experiment/codex/search

Conversation

@tig
Copy link
Copy Markdown
Member

@tig tig commented May 12, 2026

Summary

Originally the search lift alone; folded together with #79 (find-and-replace consumer migration) per CR feedback that R9 — as amended in this PR — forbids ship-the-lift-without-the-consumer. PR #79 was auto-merged into this branch when its commits became reachable here.

End-to-end feature slice:

  • Search liftISearchStrategy, RegexSearchStrategy / SearchResult, SearchStrategyFactory lifted from AvaloniaEdit d7a6b63 into src/Terminal.Gui.Text/Search/. Folder-local .editorconfig marks the lift as generated_code so dotnet format preserves upstream formatting. Single #nullable disable on RegexSearchStrategy.cs for the upstream pre-nullable shape. All modifications logged in third_party/AvaloniaEdit/UPSTREAM.md.
  • Engine swapEditor.FindReplace.cs now drives all find/replace through ISearchStrategy. Editor.SearchStrategy is the seam. String-based FindNext / FindPrevious / ReplaceNext / ReplaceAll overloads kept as convenience wrappers (build a SearchMode.Normal strategy + delegate). ReplaceAll uses FindAll + reverse iteration under one RunUpdate () scope — one rope materialization instead of N, R5 single-step undo preserved. ReplaceNext routes through ISearchResult.ReplaceWith, picking up regex $1/$2 backreference substitution for free.
  • ted UIFindReplaceDialog adds Match case / Whole word / Regex checkboxes + a status label that surfaces invalid-regex errors. Builds an ISearchStrategy from toggle state on every Find / Replace action.
  • R9 amendmentspecs/constitution.md strengthened: AvaloniaEdit lifts must ship with their Terminal.Gui.Editor + examples/ted wiring in the same feature slice. specs/plan.md restructured around end-to-end features instead of lift-as-feature rows.
  • CR fork deviations — two correctness bugs in upstream surfaced by Copilot, fixed and logged: (a) RegexSearchStrategy.Equals now includes _matchWholeWords (upstream omitted it, so two strategies differing only by whole-word setting compared equal — breaks consumer caching/dedup); (b) SearchStrategyFactory.Create rejects empty patterns with ArgumentException (upstream accepted them, compiling to a regex matching at every position — TextLength+1 zero-length results, DoS in FindAll / ReplaceAll). Both pinned by tests.
  • BenchmarksFindBenchmarks.cs (BDN) + Program.cs --quick-find (Stopwatch). New ReplaceAll is ~4× faster and ~100× less allocation than the legacy IndexOf-loop engine at N=1000 matches; FindNext is roughly equal.

Quick-find benchmark (DocSize=100 KB, 20 iters)

Matches New (ms) Old (ms) Speedup Old alloc New alloc Memory ratio
10 0.53 0.58 ~equal 2.2 MB 493 KB 4.5× less
100 0.73 2.52 3.5× faster 19.9 MB 621 KB 32× less
1000 3.55 14.84 4.2× faster 191.8 MB 1.86 MB 103× less

Run with dotnet run --project benchmarks/Terminal.Gui.Editor.Benchmarks -c Release -- --quick-find.

The win comes from replacing N rope materializations (one per IndexOf call in the legacy loop) with 1 (one FindAll). FindNext per-call cost is unchanged — both engines materialize document.Text once per call. The remaining "10 MB per keystroke" concern for incremental search needs a rope-walking matcher and is out of scope.

Copilot CR response

# Comment Resolution
1 FindAll re-scans from offset 0 on each call Deferred. Perf optimization to lifted algorithm — belongs upstream or in a separate fork-optimization issue.
2 Equals omits _matchWholeWords Fixed (d0dfa3f). Fork deviation logged in UPSTREAM.md, pinned by test.
3 Create accepts empty pattern → DoS Fixed (d0dfa3f). Fork deviation logged in UPSTREAM.md, pinned by test.
4 R9 self-contradicts this PR (lift without consumer) Resolved by folding #79 into #76 (your option a). PR now ships the full feature slice.

Validation

  • dotnet build Terminal.Gui.Text.slnx — 0 warnings, 0 errors
  • dotnet format --verify-no-changes — clean
  • Terminal.Gui.Text.Tests — 227 / 0 fail (15 in Search/, of which 3 new for the fork deviations)
  • Terminal.Gui.Editor.Tests — 88 / 0 fail (10 new in EditorFindReplaceTests.cs)
  • Terminal.Gui.Editor.IntegrationTests — 105 / 0 fail

Test plan

  • Open ted: dotnet run --project examples/ted. Ctrl+F. Search for \d+ with Regex on — numbers select one at a time. Toggle Whole word + search cat against cat catalog scatter — only the first selects.
  • Invalid regex (e.g. () surfaces "Regex error: ..." on the dialog status label.
  • Replace All on a doc with N matches collapses to one Ctrl+Z undo.
  • Verify git log specs/find-and-replace/spec.md shows FR-003 / FR-005 / FR-006 as the next slice (hit highlighting, F3 / Ctrl+F / Ctrl+H keybindings).

🤖 Generated with Claude Code

tig and others added 2 commits May 12, 2026 06:05
Bring ISearchStrategy, RegexSearchStrategy / SearchResult, and
SearchStrategyFactory into src/Terminal.Gui.Text/Search/ per the
AvaloniaEdit fork policy. Pinned commit d7a6b63.

The lift provides what the bespoke string.IndexOf engine in
Editor.FindReplace.cs cannot:

- Regex search (RegexOptions.Multiline + IgnoreCase).
- Whole-word matching via TextUtilities.GetNextCaretPosition.
- Anchored results: SearchResult inherits TextSegment, so attaching
  results to a TextSegmentCollection<TextSegment>(document) makes
  offsets track edits automatically.
- Range-restricted FindAll for incremental / viewport-scoped search.
- A factory abstraction the find dialog can target instead of hand-
  written matching code.

Per-call perf is roughly equivalent to the existing IndexOf path —
RegexSearchStrategy still calls document.Text once per FindAll, same
as the bespoke engine's IndexOf. The win is structural (one
materialization per search instead of N per ReplaceAll) and
capability-shaped (regex, whole-word, anchored results); a rope-
walking matcher would be a separate, follow-on optimization.

Modifications to lifted source are minimal: namespace transform,
"Adapted for Terminal.Gui from AvaloniaEdit d7a6b63" header, and a
single #nullable disable on RegexSearchStrategy.cs (upstream predates
nullable reference types and the IEquatable<T>.Equals override,
SearchResult.Data auto-property, and FindAll().FirstOrDefault()
pattern all trip CS warnings under nullable enable). Folder-local
.editorconfig marks the lift as generated_code so dotnet format
preserves upstream formatting on re-syncs. All logged in UPSTREAM.md.

12 tests in tests/Terminal.Gui.Text.Tests/Search/ cover the six spec
scenarios (case sensitive/insensitive, whole-word, regex, cross-line,
anchored tracking via TextSegmentCollection) plus wildcard mode,
replacement backreferences, factory equality, invalid-regex
exception, and range-restricted FindAll.

Editor + ted are not yet wired to the new engine — that ships with
the find-and-replace PR, in line with the new constitution R9
amendment landing in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Strengthen constitution R9 to forbid pure-plumbing PRs: every
AvaloniaEdit lift must include the Terminal.Gui.Editor integration
and examples/ted wiring that exercises the lifted surface, in the
same change. Split work horizontally (smaller user-visible slices),
not vertically (model first, view later). The principle: a customer
must be able to *experience* the new capability the moment the PR
merges.

Plan.md restructured accordingly. Lift-only feature rows (search,
indentation, folding, syntax-highlighting) collapse into the
end-to-end features that ship them to the user (find-and-replace,
auto-indent, folding-ui, syntax-colorizer). The "Bundles" column
makes the composition explicit so the lift step is visible in
review but does not appear as a separate Ready item.

The just-landed search lift exposes this gap — the AvaloniaEdit
search engine is in place but Editor.FindReplace.cs still uses
string.IndexOf and ted's FindReplaceDialog has no toggles for the
new regex / whole-word capabilities. The next PR (find-and-replace)
is the first one held to the amended R9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@tig tig requested a review from Copilot May 12, 2026 13:08
tig and others added 3 commits May 12, 2026 07:08
Replace Editor.FindReplace.cs's bespoke document.Text.IndexOf engine
with the lifted ISearchStrategy seam from PR #76. The string-based
overloads (FindNext/FindPrevious/ReplaceNext/ReplaceAll taking a
search-text string) remain as convenience wrappers: each builds a
SearchMode.Normal strategy from its arguments and delegates to the
no-argument property-driven overloads. Callers that want regex,
whole-word, or wildcard mode assign their own ISearchStrategy to
Editor.SearchStrategy (typically constructed via
SearchStrategyFactory.Create) and then call the no-arg overloads.

Why the engine swap is worth doing alone:

- ReplaceAll on N matches now does ONE document.Text materialization
  (via SearchStrategy.FindAll) instead of N (one per IndexOf call in
  the legacy loop). Replacements iterate in reverse so offsets stay
  valid as later matches shrink/grow the document. Both paths still
  use Document.RunUpdate() for single-step undo (R5).
- ReplaceNext routes through ISearchResult.ReplaceWith, picking up
  regex $1/$2 backreference substitution for free.
- FindPrevious is now real backwards search via FindAll over [0, end)
  and LastOrDefault, replacing the LastIndexOf-with-clamped-start
  approach that mis-handled cursor-inside-a-match.

Quick-find Stopwatch microbench (benchmarks/.../Program.cs
--quick-find, DocSize=100KB):

  Matches  New (ms)  Old (ms)  Speedup       Memory ratio
       10      0.53      0.58  ~equal        4.5x less
      100      0.73      2.52  3.5x faster   32x less
     1000      3.55     14.84  4.2x faster   103x less

FindNext is roughly equal between engines (both materialize once);
the win is concentrated in ReplaceAll where the legacy path's per-
iteration rope materialization dominates GC pressure.

specs/find-and-replace/spec.md updated to mark FR-001/002/004 done
and FR-003 (SearchHitRenderer) / FR-005 (F3 / Ctrl+F / Ctrl+H
keybindings) / FR-006 (hit-highlight invalidation) explicitly
deferred to a follow-up slice. specs/public-api.md change log
records Editor.SearchStrategy landing.

10 new tests in EditorFindReplaceTests.cs cover: property round-trip,
FindNext returning false when strategy not set, regex match through
Editor, whole-word match through Editor, FindPrevious finds the
rightmost match before the caret, FindPrevious wraparound, regex
backreference substitution via ReplaceNext, ReplaceAll via regex
strategy, R5 single-step undo with regex, reverse-iteration safety
when replacement length differs from match length.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add three CheckBox controls above the Find/Replace tab pane that
together build the Editor.SearchStrategy for each Find / Replace
action. The handlers route through a single TryBuildStrategy helper
that constructs SearchStrategyFactory.Create from the toggle state,
assigns to editor.SearchStrategy, and catches SearchPatternException
to surface invalid-regex errors on a status label rather than
silently failing.

This is the ted-facing payoff for the engine swap in the previous
commit and the R9 amendment in PR #76: a customer (the user of ted)
can now exercise the new ISearchStrategy capabilities — regex
\d{3}, whole-word "cat", case-sensitive match — without writing
code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new benchmark paths in the benchmarks project:

- FindBenchmarks.cs: BenchmarkDotNet [ShortRunJob] [MemoryDiagnoser]
  comparison of FindNext (new vs legacy) and ReplaceAll (new vs
  legacy) for the engine itself — no Editor wrapper, no event
  handlers, no visual-line caches. The Editor's per-edit notification
  cost is real but separate, and would apply to both engines once
  landed (PR #77 already fixes Editor's all-lines-walk on Document
  swap incrementally).

- Program.cs --quick-find: lightweight Stopwatch microbench that
  prints the new-vs-old comparison in ~10 seconds. BDN's full
  statistical rigor is overkill for "is the new path faster or
  slower?" and adds 5-10 minutes per run due to sub-process spawns
  and pilot phases for the slow ReplaceAll cases.

Sample output (DocSize=100KB, 20 iterations):

  Matches  New (ms)  Old (ms)  Speedup       Memory ratio
       10      0.53      0.58  ~equal        4.5x less
      100      0.73      2.52  3.5x faster   32x less
     1000      3.55     14.84  4.2x faster   103x less

The wart this lift fixed: the legacy engine materialized
document.Text once per IndexOf call inside the ReplaceAll loop
(N rope materializations at 100KB each = 191MB allocated for 1000
matches). The new engine calls FindAll once, materializes once, and
iterates the cached result list in reverse. Per-call FindNext cost
is roughly equal between engines — the incremental-search per-
keystroke materialization remains and would need a rope-walking
matcher to truly fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Lifts AvaloniaEdit’s search-engine abstractions into Terminal.Gui.Text to serve as the future seam for Terminal.Gui.Editor find/replace (regex, wildcard, whole-word), and updates project specs/logs to reflect the lift and the revised “end-to-end feature per PR” policy.

Changes:

  • Added Terminal.Gui.Text.Search search engine surface (ISearchStrategy, RegexSearchStrategy/SearchResult, SearchStrategyFactory) plus folder-local .editorconfig to treat the lift as generated code.
  • Added SearchStrategyTests covering core scenarios (case sensitivity, whole-word, regex, wildcard, range, edit-tracking via TextSegmentCollection).
  • Updated third_party/AvaloniaEdit/UPSTREAM.md and amended specs/plan.md / specs/constitution.md (R9 composition rule).

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
third_party/AvaloniaEdit/UPSTREAM.md Records the new Search/ lift and what was intentionally skipped.
src/Terminal.Gui.Text/Search/.editorconfig Marks lifted search code as generated and suppresses analyzers/style.
src/Terminal.Gui.Text/Search/ISearchStrategy.cs Introduces search interfaces, modes, and SearchPatternException.
src/Terminal.Gui.Text/Search/RegexSearchStrategy.cs Implements regex-based searching + SearchResult.
src/Terminal.Gui.Text/Search/SearchStrategyFactory.cs Adds factory for Normal/Wildcard/RegEx strategies.
tests/Terminal.Gui.Text.Tests/Search/SearchStrategyTests.cs Adds unit tests for the lifted search surface.
specs/plan.md Restructures plan around end-to-end user-visible features and bundles lifts.
specs/constitution.md Amends R9 to require lifts ship with Editor + ted wiring in the same PR.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Terminal.Gui.Text/Search/RegexSearchStrategy.cs
Comment thread src/Terminal.Gui.Text/Search/RegexSearchStrategy.cs Outdated
Comment thread src/Terminal.Gui.Text/Search/SearchStrategyFactory.cs
Comment thread specs/constitution.md
tig and others added 3 commits May 12, 2026 07:40
Two correctness deviations from AvaloniaEdit d7a6b63, both surfaced by
Copilot review on PR #76. Logged in third_party/AvaloniaEdit/UPSTREAM.md.

1. RegexSearchStrategy.Equals omitted _matchWholeWords. Two strategies
   that differ only by whole-word matching compared equal, which would
   break consumer caching / dedup (e.g. an Editor wanting to skip a
   re-search when the strategy hasn't actually changed). Add it to the
   chain. One-line change.

2. SearchStrategyFactory.Create accepted an empty pattern, which then
   compiles to a regex matching at every position — FindAll returns
   TextLength+1 zero-length results and ReplaceAll DoS's on it. ted's
   FindReplaceDialog already short-circuits on IsNullOrEmpty before
   calling Create, so the in-repo consumer was safe, but the factory
   is public and a third-party caller could trip it. Reject empty
   patterns with ArgumentException. Whitespace patterns remain
   legitimate (literal-space search in Normal mode, space-char match
   in Regex mode).

Both deviations pinned by tests in SearchStrategyTests so a future
re-sync from upstream that drops them will fail loudly.

Issue #1 from the Copilot review (FindAll re-scanning the document
prefix from offset 0 each call) is left to address upstream or as a
separate fork-optimization issue — that one is a perf, not correctness,
change to the matching algorithm itself and belongs out of this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@tig tig changed the title search: lift AvaloniaEdit search engine + R9 amendment search + find-and-replace: lift AvaloniaEdit engine, wire Editor + ted (R9 amendment) May 12, 2026
@tig tig merged commit ca3cb8d into develop May 12, 2026
6 checks passed
@tig tig deleted the experiment/codex/search branch May 12, 2026 13:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants