diff --git a/CHANGELOG.md b/CHANGELOG.md index 92f93af..422ebfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.1] - 2026-09-06 + +Patch release: LeftmostLongest early termination optimization (community contribution by [@MikeeI](https://github.com/MikeeI)). + +### Performance + +- **`Find` with `LeftmostLongest`: early return at maximum pattern length**. + Once the best match reaches the length of the longest compiled pattern, + no later match can replace it — `Find` now returns immediately instead of + scanning the remaining haystack. Eliminates O(n·m) behavior in `Count` + + `LeftmostLongest` on dense workloads. On a 2048-byte single-byte haystack: + 8.1 ms → 30 µs per `Count` call. + +- **`FindAt` with `LeftmostLongest`: same early return** applied for consistency. + +### Added + +- Correctness tests for `LeftmostLongest` early termination: `Find`, `FindAt`, + and `Count` with prefix chains, equal-length ties, and dense single-byte inputs. + ## [0.3.0] - 2026-08-05 Zero-allocation API release. Breaking change: `Find` and `FindAt` now return `(Match, bool)` instead of `*Match`. @@ -112,7 +132,8 @@ Initial release of the high-performance Aho-Corasick library for Go. - Precomputed root transitions (no failure link following for root) - Zero-allocation `IsMatch()` hot path -[Unreleased]: https://github.com/coregx/ahocorasick/compare/v0.3.0...HEAD +[Unreleased]: https://github.com/coregx/ahocorasick/compare/v0.3.1...HEAD +[0.3.1]: https://github.com/coregx/ahocorasick/compare/v0.3.0...v0.3.1 [0.3.0]: https://github.com/coregx/ahocorasick/compare/v0.2.1...v0.3.0 [0.2.1]: https://github.com/coregx/ahocorasick/compare/v0.1.0...v0.2.1 [0.1.0]: https://github.com/coregx/ahocorasick/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 677393b..66c49ac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,125 +2,49 @@ Thank you for considering contributing to ahocorasick! This document outlines the development workflow and guidelines. -## Git Workflow (Git-Flow) +## Git Workflow (GitHub Flow) -This project uses Git-Flow branching model for development. +This project uses GitHub Flow — a single `main` branch with feature branches merged via pull requests. ### Branch Structure ``` main # Production-ready code (tagged releases) - └─ develop # Integration branch for next release - ├─ feature/* # New features - ├─ bugfix/* # Bug fixes - └─ hotfix/* # Critical fixes from main + ├─ feat/* # New features + ├─ fix/* # Bug fixes + ├─ perf/* # Performance improvements + └─ release/* # Release preparation ``` -### Branch Purposes +### Workflow -- **main**: Production-ready code. Only releases are merged here. -- **develop**: Active development branch. All features merge here first. -- **feature/\***: New features. Branch from `develop`, merge back to `develop`. -- **bugfix/\***: Bug fixes. Branch from `develop`, merge back to `develop`. -- **hotfix/\***: Critical production fixes. Branch from `main`, merge to both `main` and `develop`. - -### Workflow Commands - -#### Starting a New Feature - -```bash -# Create feature branch from develop -git checkout develop -git pull origin develop -git checkout -b feature/my-new-feature - -# Work on your feature... -git add . -git commit -m "feat: add my new feature" - -# When done, merge back to develop -git checkout develop -git merge --squash feature/my-new-feature # Squash merge for clean history -git commit -m "feat: my new feature (squashed)" -git branch -d feature/my-new-feature -git push origin develop -``` - -#### Fixing a Bug - -```bash -# Create bugfix branch from develop -git checkout develop -git pull origin develop -git checkout -b bugfix/fix-issue-123 - -# Fix the bug... -git add . -git commit -m "fix: resolve issue #123" - -# Merge back to develop -git checkout develop -git merge --squash bugfix/fix-issue-123 # Squash merge for clean history -git commit -m "fix: resolve issue #123 (squashed)" -git branch -d bugfix/fix-issue-123 -git push origin develop -``` - -#### Creating a Release - -```bash -# Create release branch from develop -git checkout develop -git pull origin develop -git checkout -b release/v0.2.0 - -# Update version numbers, CHANGELOG, etc. -git add . -git commit -m "chore: prepare release v0.2.0" - -# Merge to main and tag -git checkout main -git merge --no-ff release/v0.2.0 -git tag -a v0.2.0 -m "Release v0.2.0" - -# Merge back to develop -git checkout develop -git merge --no-ff release/v0.2.0 - -# Delete release branch -git branch -d release/v0.2.0 - -# Push everything -git push origin main develop --tags -``` - -#### Hotfix (Critical Production Bug) - -```bash -# Create hotfix branch from main -git checkout main -git pull origin main -git checkout -b hotfix/critical-bug +1. **Create a feature branch** from `main`: + ```bash + git checkout main + git pull origin main + git checkout -b feat/my-new-feature + ``` -# Fix the bug... -git add . -git commit -m "fix: critical production bug" +2. **Work on your changes**, committing as you go: + ```bash + git add . + git commit -m "feat: add my new feature" + ``` -# Merge to main and tag -git checkout main -git merge --no-ff hotfix/critical-bug -git tag -a v0.1.1 -m "Hotfix v0.1.1" +3. **Push and create a pull request**: + ```bash + git push -u origin feat/my-new-feature + gh pr create --title "feat: my new feature" + ``` -# Merge to develop -git checkout develop -git merge --no-ff hotfix/critical-bug +4. **Wait for CI** — all checks must pass (tests, lint, formatting on 3 OS). -# Delete hotfix branch -git branch -d hotfix/critical-bug +5. **Squash merge** into `main` after review: + ```bash + gh pr merge --squash + ``` -# Push everything -git push origin main develop --tags -``` +Small fixes (typos, docs) can go directly to `main`. ## Commit Message Guidelines @@ -230,12 +154,11 @@ go test ./... # Run with coverage go test -cover ./... -# Run with race detector -go test -race ./... +# Run with race detector (requires CGO) +CGO_ENABLED=1 go test -race ./... # Run benchmarks -go test -bench=. -benchmem ./simd/ -go test -bench=. -benchmem ./prefilter/ +go test -bench=. -benchmem ./... ``` ### Running Linter @@ -246,9 +169,6 @@ golangci-lint run # Run with verbose output golangci-lint run -v - -# Verify config -golangci-lint config verify ``` ## Project Structure @@ -258,32 +178,35 @@ ahocorasick/ ├── .github/ # GitHub workflows and templates │ ├── CODEOWNERS # Code ownership │ └── workflows/ # CI/CD pipelines -├── ahocorasick.go # Package entry point -├── automaton.go # Search API (Find, IsMatch, FindAll) -├── builder.go # Pattern builder -├── byteclasses.go # Alphabet compression -├── match.go # Match types and semantics -├── nfa.go # Trie + failure links -├── *_test.go # Tests and benchmarks +├── ahocorasick.go # Package doc and version constant +├── automaton.go # Search API (Find, FindAt, FindAll, IsMatch, Count) +├── builder.go # Builder pattern for configuration +├── byteclasses.go # Alphabet compression (256 → N equivalence classes) +├── dfa.go # DFA compilation (flat transition table) +├── match.go # Match, MatchKind, PatternID, StateID types +├── nfa.go # NFA construction (trie + failure links) +├── *_test.go # Tests, benchmarks, fuzz tests ├── .golangci.yml # Linter configuration +├── AGENTS.md # AI agent documentation ├── CHANGELOG.md # Version history ├── CONTRIBUTING.md # This file ├── LICENSE # MIT License -└── README.md # Main documentation +├── README.md # Main documentation +└── llms.txt # LLM discovery file ``` ## Adding New Features 1. Check if issue exists, if not create one 2. Discuss approach in the issue -3. Create feature branch from `develop` +3. Create feature branch from `main` 4. Implement feature with tests 5. Update documentation -6. Run quality checks (`bash scripts/pre-release-check.sh`) -7. Create pull request to `develop` -8. Wait for code review +6. Run quality checks (build, test, lint, format) +7. Create pull request to `main` +8. Wait for CI and code review 9. Address feedback -10. Merge when approved +10. Squash merge when approved ## Code Style Guidelines diff --git a/ahocorasick.go b/ahocorasick.go index c94f363..db9323a 100644 --- a/ahocorasick.go +++ b/ahocorasick.go @@ -9,4 +9,4 @@ package ahocorasick // Version is the current library version. -const Version = "0.3.0" +const Version = "0.3.1" diff --git a/ahocorasick_test.go b/ahocorasick_test.go index 447ba3e..b71c70b 100644 --- a/ahocorasick_test.go +++ b/ahocorasick_test.go @@ -498,6 +498,49 @@ func TestFindAt(t *testing.T) { } } +func TestFindAtLeftmostLongest(t *testing.T) { + ac, err := NewBuilder(). + SetMatchKind(LeftmostLongest). + AddStrings([]string{"a", "ab", "abc"}). + Build() + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + haystack string + start int + wantID int + wantLen int + }{ + {"longest at start", "abcdef", 0, 2, 3}, + {"longest at offset", "XXabcdef", 2, 2, 3}, + {"no match at offset", "XXabcdef", 1, -1, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m, found := ac.FindAt([]byte(tt.haystack), tt.start) + if tt.wantID < 0 { + if found { + t.Errorf("expected no match, got %+v", m) + } + return + } + if !found { + t.Fatal("expected match, got none") + } + if m.PatternID != tt.wantID { + t.Errorf("PatternID = %d, want %d", m.PatternID, tt.wantID) + } + if m.Len() != tt.wantLen { + t.Errorf("Len() = %d, want %d", m.Len(), tt.wantLen) + } + }) + } +} + func TestLeftmostLongest(t *testing.T) { ac, err := NewBuilder(). SetMatchKind(LeftmostLongest). @@ -517,6 +560,125 @@ func TestLeftmostLongest(t *testing.T) { } } +func TestLeftmostLongestMaxLenEarlyReturn(t *testing.T) { + tests := []struct { + name string + patterns []string + haystack string + wantID int + wantLen int + }{ + { + name: "returns longest when max-length match found early", + patterns: []string{"a", "abc"}, + haystack: "abcXXXXXXXXXXXX", + wantID: 1, + wantLen: 3, + }, + { + name: "prefix chain selects longest", + patterns: []string{"a", "ab", "abc", "abcd"}, + haystack: "abcdYYYYYYYYYYYY", + wantID: 3, + wantLen: 4, + }, + { + name: "single pattern returns immediately", + patterns: []string{"xyz"}, + haystack: "xyzxyzxyz", + wantID: 0, + wantLen: 3, + }, + { + name: "equal-length patterns: first max-length match wins", + patterns: []string{"abc", "xyz"}, + haystack: "abcxyz", + wantID: 0, + wantLen: 3, + }, + { + name: "match not at start of haystack", + patterns: []string{"a", "abcdef"}, + haystack: "ZZZZZZabcdefZZZZ", + wantID: 1, + wantLen: 6, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ac, err := NewBuilder(). + SetMatchKind(LeftmostLongest). + AddStrings(tt.patterns). + Build() + if err != nil { + t.Fatal(err) + } + + m, found := ac.Find([]byte(tt.haystack), 0) + if !found { + t.Fatal("expected match, got none") + } + if m.PatternID != tt.wantID { + t.Errorf("PatternID = %d, want %d", m.PatternID, tt.wantID) + } + if m.Len() != tt.wantLen { + t.Errorf("Len() = %d, want %d", m.Len(), tt.wantLen) + } + }) + } +} + +func TestLeftmostLongestCount(t *testing.T) { + tests := []struct { + name string + patterns []string + haystack string + want int + }{ + { + name: "dense single-byte", + patterns: []string{"a"}, + haystack: "aaaa", + want: 4, + }, + { + name: "dense with longer pattern", + patterns: []string{"a", "aa"}, + haystack: "aaaa", + want: 2, + }, + { + name: "non-overlapping multi-pattern", + patterns: []string{"ab", "abcd"}, + haystack: "abcdabcd", + want: 2, + }, + { + name: "no match", + patterns: []string{"xyz"}, + haystack: "aaaa", + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ac, err := NewBuilder(). + SetMatchKind(LeftmostLongest). + AddStrings(tt.patterns). + Build() + if err != nil { + t.Fatal(err) + } + + if got := ac.Count([]byte(tt.haystack)); got != tt.want { + t.Errorf("Count() = %d, want %d", got, tt.want) + } + }) + } +} + func TestAutomatonAccessors(t *testing.T) { patterns := []string{"foo", "bar", "baz"} ac, err := NewBuilder(). diff --git a/automaton.go b/automaton.go index 9952479..98c152c 100644 --- a/automaton.go +++ b/automaton.go @@ -145,6 +145,9 @@ func (a *Automaton) FindAt(haystack []byte, start int) (Match, bool) { found = true } } + if found && bestMatch.Len() == d.maxPatternLen { + return bestMatch, true + } } return bestMatch, found