Skip to content

chore: break name package dep on root package#82

Open
stanislas-m wants to merge 4 commits into
mainfrom
feat/modernize-optimize
Open

chore: break name package dep on root package#82
stanislas-m wants to merge 4 commits into
mainfrom
feat/modernize-optimize

Conversation

@stanislas-m

Copy link
Copy Markdown
Member

Performance optimisations & internal refactor

Context

The original package had no internal structure — all logic lived flat in the root package and depended on testify for tests. This PR does two things:

  1. Structural refactor: moves all implementation into internal/core, leaving the root and name packages as thin public wrappers. A shared internal/testhelpers package replaces the testify dependency, making the module dependency-free.

  2. Performance pass: seven targeted fixes to internal/core identified by profiling and allocation analysis.


Performance fixes

1. isSpace: slices.Containsswitch (core.go)

The hot inner loop in toParts called slices.Contains over a 5-element slice on every character. Replaced with a switch statement that compiles to a jump table. Also removed the now-unused spaces var and slices import.

2. xappend: single TrimFunc pass instead of loop (core.go)

The five-iteration trim loop called strings.Trim(s, string(x)) per space character — five allocations per xappend call. Replaced with one strings.TrimFunc(s, isSpace) pass.

3. WriteString(string(rune))WriteRune (camelize.go, dasherize.go)

Four sites were converting a rune to string before passing it to strings.Builder.WriteString, allocating a tiny string per character. Replaced with WriteRune.

4. Redundant strings.ToUpper calls in toParts and xappend (ident.go, core.go)

  • The whole-string acronym fast-path called strings.ToUpper(s) twice; now computed once.
  • The hot-loop acronym check (BaseAcronyms[strings.ToUpper(x.String())]) was provably redundant: when that branch fires, both the current and previous rune are uppercase, so x is always all-uppercase. The strings.ToUpper call is dropped entirely.
  • xappend's acronym check also called strings.ToUpper twice; now computed once.

5. O(n²) rule building at init → O(n) (plural_rules.go)

InsertPluralRule and InsertSingularRule prepend by allocating a new backing array each call. With ~150 init-time insertions this was O(n²). The second init() now builds pluralRules and singularRules directly in one pre-allocated forward pass, replicating the same priority order. InsertPluralRule/InsertSingularRule are left unchanged for runtime user customisation.

6. Mutex held across O(n) rule scan → released before scan (pluralize.go, singularize.go)

defer pluralMoot.RUnlock() held the lock for the entire function body, including the linear scan of 200+ rules. Replaced with explicit per-return-site unlocks. The rules slice header is captured under the lock before release — safe because InsertPluralRule always creates a new backing array rather than mutating in place.

7. New() re-parse on transform output eliminated (9 files)

Every transform method ended with return New(result), running toParts on the output string unnecessarily. For transforms whose result is never chained into another method that reads .Parts (Camelize, Pascalize, Titleize, Humanize, Dasherize, Capitalize, Ordinalize, ToUpper, ToLower), this is replaced with return Ident{Original: result}. Underscore, Pluralize, and Singularize retain New() because they are chained in name package chains that access .Parts downstream.

Bonus: the x.String() != "" empty check in Camelize is replaced with x.Len() > 0 (saves one allocation per part).

8. fmt.Sprintfstrconv.Itoa in Ordinalize (ordinalize.go)

Four fmt.Sprintf("%d<suffix>", number) calls replaced with a single strconv.Itoa(number) + suffix after computing the suffix in a switch. The fmt import is removed entirely.


Benchmark results

All benchmarks run with -benchmem -benchtime=3s on AMD Ryzen 9 3900X. Each benchmark exercises 12 representative inputs covering plain words, snake_case, CamelCase, acronyms, and multi-segment paths.

Benchmark ns/op before ns/op after speedup allocs before allocs after alloc drop
Camelize 24 736 11 381 2.17× 437 147 66%
Pascalize 36 088 13 569 2.66× 575 180 69%
Dasherize 29 972 16 077 1.86× 499 202 60%
Titleize 24 958 12 848 1.94× 331 207 37%
Humanize 33 904 17 716 1.91× 408 243 40%
Capitalize 21 107 10 139 2.08× 223 114 49%
Ordinalize 7 486 2 859 2.62× 85 40 53%
New 21 904 19 419 1.13× 225 217 4%
Underscore 12 445 11 673 1.07× 153 150 2%
Pluralize* 728 915 681 433 1.07× 3 764 3 681 2%
Singularize* 933 600 892 226 1.05× 5 369 5 234 3%

* Pluralize/Singularize iterate the full inflection table (~200 words), not the 12-input set, so they are not directly comparable to the others. Their modest gain comes from the mutex narrowing fix; the bottleneck is the O(n) rule scan itself.

Pascalize is the biggest winner (2.66×, −69% allocs) because it chains through Camelize internally — before this PR it paid toParts three times per call (input parse, Camelize output re-parse, Pascalize output re-parse) and now pays it once.


Other changes

  • SHOULDERS.md removed: listed six packages that were all transitive testify dependencies; the module now has zero external dependencies (go.sum is empty).
  • bench_test.go added: benchmarks with b.ReportAllocs() for all transforms that previously had none.

- Introduce an internal package to share core functions
- Avoid any dep from inner packages to outer / root packages
- Fix performance / allocation issues (validated with benchmarks)
@stanislas-m stanislas-m force-pushed the feat/modernize-optimize branch from 1061120 to e0f91ec Compare June 28, 2026 09:36
@stanislas-m stanislas-m requested a review from paganotoni June 28, 2026 09:39

@paganotoni paganotoni left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is great @stanislas-m, thanks for putting the time into modernizing flect. A couple of suggestions:

  1. Could we reduce the number of files at the root and simply have a flect.go file in there.

  2. instead of naming the internal package "core" could we call if flect or simply have the core files live at the root of the internal folder and be part of the flect/internal package?

@stanislas-m

stanislas-m commented Jul 1, 2026

Copy link
Copy Markdown
Member Author

Hey @paganotoni, it's been a while. ;)

  1. We can, my main intent was to avoid any breaking change but a "barrel" flect.go file at the root that aggregates all the aliases works. Otherwise we go v2 and drop the aliases, but probably worth another update.
  2. Sure, I'll test different options. :)

We only need to check for existance of the key, map[string]struct{}
takes no space to store the value.
@stanislas-m

Copy link
Copy Markdown
Member Author

Should be good now:

  • Barrel alias file at the root
  • Renamed internal subpackage to flect (putting at the root of internal is messier)
  • Also changed the acronyms map to use an empty struct (that costs no memory for the value)

@stanislas-m stanislas-m requested a review from paganotoni July 6, 2026 17:51
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