Skip to content

fix(string): split the digit that follows an acronym in Words/SnakeCase/KebabCase/CamelCase/PascalCase - #1010

Open
yu2971512385-ui wants to merge 1 commit into
samber:masterfrom
yu2971512385-ui:fix/words-acronym-digit-boundary
Open

yu2971512385-ui wants to merge 1 commit into
samber:masterfrom
yu2971512385-ui:fix/words-acronym-digit-boundary

Conversation

@yu2971512385-ui

Copy link
Copy Markdown

Describe your changes

A digit that directly follows an acronym is not treated as its own word, so it stays glued to the preceding letters:

lo.Words("IPv6Address")      // [I Pv6 Address]   want [I Pv 6 Address]
lo.SnakeCase("IPv6Address")  // i_pv6_address     want i_pv_6_address
lo.KebabCase("IPv6Address")  // i-pv6-address     want i-pv-6-address

Every other letter/digit boundary splits, including the Int8Value case the test table already pins ([Int 8 Value], int_8_value), so the same boundary is handled two different ways depending on what precedes it. lodash — which the case helpers mirror, as confirmed in #948 — returns ["I", "Pv", "6", "Address"] and i_pv_6_address.

Root cause

In splitWordBoundaries, the branch for the ([A-Z])([A-Z])([a-z]) alternative appends all three bytes and advances i += 3:

if e := s[i+2]; isASCIIUpper(c) && isASCIIUpper(d) && isASCIILower(e) {
    out = append(out, c, ' ', d, e)
    i += 3
    continue
}

For IPv6Address that consumes I, P, v in one step, so the scan resumes at 6 and the v/6 pair is never tested against the letter→digit rule. The lowercase letter is only the marker for where the acronym ends — it belongs to the next word, so consuming it here also throws away its left edge for the following boundary check.

Fix

Emit c and d and advance by 2, leaving the lowercase letter to start the next iteration. One-line change, no new allocations, no change to the scan's linear cost.

Behaviour after the change

input before after lodash
IPv6Address i_pv6_address i_pv_6_address i_pv_6_address
IPv4Address i_pv4_address i_pv_4_address i_pv_4_address
ABc1 a_bc1 a_bc_1 a_bc_1
HTMLv5Doc htm_lv5_doc htm_lv_5_doc htm_lv_5_doc
Int8Value int_8_value int_8_value (unchanged) int_8_value
XMLHttpRequest xml_http_request xml_http_request (unchanged) xml_http_request
IOStream, HTTPCode, PINEAPPLE, LogRouterS3BucketName unchanged unchanged same

I also diffed Words/CamelCase/PascalCase/KebabCase/SnakeCase against lodash over a 79-string corpus (acronyms, digits, separators, unicode, apostrophes): the three IPv6Address divergences disappear and no new divergence appears — 89 differences before, 86 after, all remaining ones pre-existing and unrelated (unicode folding, apostrophes).

Tests

  • TestWords: IPv6Address, ABc1, ABCdEf
  • TestAllCase: IPv6Address, HTMLv5Doc across all four case helpers

Without the source change 5 subtests fail; with it the full go test ./... suite passes.

Checklist before requesting a review

  • 👓 I have performed a self-review of my code
  • 🧪 The change is covered by tests that fail without it
  • 🏎️ No extra allocation; the scan stays single-pass

…tter

Words() consumed three bytes when the "([A-Z])([A-Z])([a-z])" branch of
the word-boundary scan matched, so the scan resumed past the lowercase
letter and never checked the boundary it forms with the next byte. A
digit right after an acronym therefore stayed glued to it:

    lo.Words("IPv6Address")     // [I Pv6 Address]
    lo.SnakeCase("IPv6Address") // i_pv6_address

while the same letter/digit boundary splits everywhere else, including
the Int8Value case already covered by the tests, and lodash - which the
case helpers mirror - returns [I Pv 6 Address] / i_pv_6_address.

The trailing lowercase letter only marks where the acronym ends; it
opens the next word, so leave it for the following iteration instead of
consuming it.

This branch has not been deployed

No deployments
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.

1 participant