ADDED:
* stringsx:
** RunesInString
** RuneMapFromString
** SquashConsec
** SquashConsecRunes
** SquashConsecRunesAll
** SquashMap
** SquashWhitespace
This commit is contained in:
brent saner
2026-07-13 19:17:39 -04:00
parent 42c0e48081
commit c9f3e7a639
8 changed files with 1006 additions and 2 deletions
+457
View File
@@ -8,6 +8,7 @@ import (
"slices"
"strings"
"unicode"
`unicode/utf8`
)
/*
@@ -498,6 +499,462 @@ func Reverse(s string) (revS string) {
return
}
/*
RunesInString returns an ordered slice of all unique runes in
UTF-8 string `s`.
If `s` is an empty string, runes will be a non-nil empty slice.
*/
func RunesInString(s string) (runes []rune) {
var r rune
var idx int
var runeMap map[rune]uint64 = RuneMapFromString(s)
runes = make([]rune, len(runeMap))
idx = 0
for r, _ = range runeMap {
runes[idx] = r
idx++
}
slices.Sort(runes)
return
}
/*
RuneMapFromString returns a map of unique runes found in
UTF-8 string `s` along with a count of their occurrence.
If `s` is an empty string, runes will be a non-nil empty map.
Non-UTF-8 runes are skipped.
*/
func RuneMapFromString(s string) (runes map[rune]uint64) {
var r rune
var runeSz int
runes = make(map[rune]uint64)
for idx := 0; idx < len(s); {
r, runeSz = utf8.DecodeRuneInString(s[idx:])
if r == utf8.RuneError && runeSz == 1 {
idx += runeSz
continue
}
runes[r]++
idx += runeSz
}
return
}
/*
SquashConsec squashes/collapses consecutive instances of sequences
seq to a single instance.
It is expected that s is UTF-8 or a compatible subset (e.g. ASCII).
If seq is nil/empty, any n+1 consecutive instance of *any rune*
will be squashed to a single instance. This is *much* more performant
(as it simply wraps [SquashConsecRunesAll]) at the cost of lack of scoping.
If you are trying to squash/collapse whitespace instead, [SquashWhitespace]
may be more apropos.
If you want more fine-grained control over sequence replacement, see [SquashMap].
If s is an empty string, SquashConsec will return `s` as-is.
*/
func SquashConsec(s string, seq ...string) (squashed string) {
var idx int
var sIdx int
var kLen int
var start int
var seqIdx int
var nextNum int
var nextPos int
var sb *strings.Builder
squashed = s
if len(s) == 0 {
return
}
if len(seq) == 0 {
squashed = SquashConsecRunesAll(s)
return
}
for seqIdx = range seq {
if seq[seqIdx] == "" {
continue
}
sb = new(strings.Builder)
sb.Grow(len(squashed))
kLen = len(seq[seqIdx])
for idx = 0; idx < len(squashed); {
sIdx = strings.Index(squashed[idx:], seq[seqIdx])
if sIdx < 0 {
sb.WriteString(squashed[idx:])
break
}
start = idx + sIdx
sb.WriteString(squashed[idx:start])
nextNum = 0
nextPos = start
for strings.HasPrefix(squashed[nextPos:], seq[seqIdx]) {
nextNum++
nextPos += kLen
}
if nextNum >= 1 {
sb.WriteString(seq[seqIdx])
}
idx = nextPos
}
squashed = sb.String()
}
return
}
/*
SquashConsecRunes squashes/collapses consecutive instances of sequences
seq with a single instance.
It is expected that s is UTF-8 or a compatible subset (e.g. ASCII).
If seq is nil/empty, any n+1 consecutive instance of *any rune*
will be squashed to a single instance. This is *much* more performant
(as it simply wraps [SquashConsecRunesAll]) at the cost of lack of scoping.
If s is an empty string, SquashConsecRunes will return `s` as-is.
*/
func SquashConsecRunes(s string, seq ...rune) (squashed string) {
var r rune
var idx int
var prev rune
var seqIdx int
var runeSz int
var sb *strings.Builder
squashed = s
if len(s) == 0 {
return
}
if len(seq) == 0 {
squashed = SquashConsecRunesAll(s)
return
}
for seqIdx = range seq {
if seq[seqIdx] < 0 {
continue
}
sb = new(strings.Builder)
sb.Grow(len(squashed))
prev = -1
for idx = 0; idx < len(squashed); {
r, runeSz = utf8.DecodeRuneInString(squashed[idx:])
if r == utf8.RuneError && runeSz == 1 {
prev = -1
sb.WriteString(squashed[idx : idx+runeSz])
} else {
if r != seq[seqIdx] || prev != r {
prev = r
sb.WriteRune(r)
}
}
idx += runeSz
}
squashed = sb.String()
}
return
}
/*
SquashConsecRunesAll squashes/condenses/collapses all consecutive occurrences
of every rune in `s` to a single instance.
It is expected that s is UTF-8 or a compatible subset (e.g. ASCII);
non-UTF-8 runes will be written as-is to squashed (even if duplicate).
That is to say, for an `s` of:
* `fo`
* `foo`
* `fooo`
* `foooo`
SquashConsecRunesAll would return `fo` for all of them.
SquashConsecRunesAll will be a no-op and return an empty string
if `s` is an empty string.
*/
func SquashConsecRunesAll(s string) (squashed string) {
var r rune
var runeSz int
var prev rune = -1
var sb *strings.Builder
squashed = s
if len(s) == 0 {
return
}
sb = new(strings.Builder)
sb.Grow(len(s))
for idx := 0; idx < len(squashed); {
r, runeSz = utf8.DecodeRuneInString(squashed[idx:])
if r == utf8.RuneError && runeSz == 1 {
prev = -1
sb.WriteString(squashed[idx : idx+runeSz])
} else {
if prev != r {
prev = r
sb.WriteRune(r)
}
}
idx += runeSz
}
squashed = sb.String()
return
}
/*
SquashMap offers replacement of consecutive string series.
It is expected that s is UTF-8 or a compatible subset (e.g. ASCII).
If single is set to true, *single/standalone* instances of a sequence
in replaceMap keys will also be replaced with its corresponding value.
Be aware that this function is not ideal for very large replaceMap maps
or for very large strings, as s will be passed over for each key in replaceMap.
If dealing with very large strings or a very large explicit set of replacements,
you may want to directly use [strings.Map] or [strings.NewReplacer] (if multi-rune
replacement is needed) with your own logic.
Matching string replacements are done longest-to-shortest sequentially.
This means that given the string `fooo` (and assuming single==true),
and a replaceMap of:
map[string]string{
"o": "a",
"oo": "b",
}
the result will be "fba", not:
* "faaa"
* "fab"
Thus you may want to execute several calls if this is undesired.
Keys that are the same length are ordered alphabetically/alphanumerically
so this function is deterministic.
Empty keys in replaceMap (but NOT empty values) are skipped.
Keys in replaceMap that are not UTF-8 are skipped.
Note that this function offers high-granularity control over replacements
at the cost of complex setup.
If you want more simple squashing/collapsing of whitespace, [SquashWhitespace]
may be more apropos.
If you want more simple squashing/collapsing of non-whitespace, [SquashConsec]
may be more apropos.
If replaceMap is nil or empty, s will be returned as-is.
If s is an empty string, SquashMap will no-op.
*/
func SquashMap(s string, replaceMap map[string]string, single bool) (squashed string) {
var idx int
var sIdx int
var k string
var v string
var start int
var nextNum int
var nextPos int
var sb *strings.Builder
type kLen struct {
// k is the Key name.
k string
// r is the replacement string.
r string
// l is the length of k (in UTF-8 runes).
l int
// bl is the length of k (in bytes).
bl int
}
var kl kLen
var keysByLen []kLen
squashed = s
if len(replaceMap) == 0 || len(s) == 0 {
return
}
keysByLen = make([]kLen, 0, len(replaceMap))
for k, v = range replaceMap {
if k == "" {
continue
}
if !utf8.ValidString(k) {
continue
}
keysByLen = append(
keysByLen,
kLen{
k: k,
r: v,
l: utf8.RuneCountInString(k),
bl: len(k),
},
)
}
slices.SortFunc(
keysByLen,
func(a, b kLen) (cmp int) {
cmp = 0
// Sort so that the longer comes first.
if a.l > b.l {
cmp = -1
} else if a.l < b.l {
cmp = 1
} else if a.l == b.l {
if a.k < b.k {
cmp = -1
} else if a.k > b.k {
cmp = 1
}
}
return
},
)
for _, kl = range keysByLen {
switch strings.Count(squashed, kl.k) {
case 0:
continue
case 1:
if !single {
continue
}
squashed = strings.ReplaceAll(squashed, kl.k, kl.r)
default:
sb = new(strings.Builder)
sb.Grow(len(squashed))
for idx = 0; idx < len(squashed); {
if sIdx = strings.Index(squashed[idx:], kl.k); sIdx < 0 {
sb.WriteString(squashed[idx:])
break
}
start = idx + sIdx
sb.WriteString(squashed[idx:start])
nextNum = 0
nextPos = start
for strings.HasPrefix(squashed[nextPos:], kl.k) {
nextNum++
nextPos += kl.bl
}
if nextNum > 1 || (nextNum == 1 && single) {
sb.WriteString(kl.r)
} else {
sb.WriteString(kl.k)
}
idx = nextPos
}
squashed = sb.String()
}
}
return
}
/*
SquashWhitespace is used to collapse/squash consecutive whitespace.
It is expected that s is unicode or a compatible subset (e.g. ASCII).
If single is set to true, *single/standalone* instances of whitespace
will also be replaced with ws.
ws is a string that should replace *consecutive* whitespace.
ws does not necessarily have to be a whitespace string, and may be empty.
If you are trying to squash/collapse non-whitespace, [SquashConsec]
is more apropos.
If you want more fine-grained control over sequence replacement, see [SquashMap].
*/
func SquashWhitespace(s, ws string, single bool) (squashed string) {
var r rune
var runeSz int
var wsStart int
var invalid bool
var prevWs uint64
var sb *strings.Builder
squashed = s
if s == "" {
return
}
sb = new(strings.Builder)
sb.Grow(len(s))
for rIdx := 0; rIdx < len(s); {
r, runeSz = utf8.DecodeRuneInString(s[rIdx:])
invalid = r == utf8.RuneError && runeSz == 1
if invalid || !unicode.IsSpace(r) {
if prevWs > 1 || (prevWs == 1 && single) {
sb.WriteString(ws)
} else if prevWs == 1 {
sb.WriteString(s[wsStart:rIdx])
}
prevWs = 0
if invalid {
// Invalid UTF-8 rune; write byte as-is and advance index.
sb.WriteString(s[rIdx : rIdx+runeSz])
} else {
sb.WriteRune(r)
}
rIdx += runeSz
continue
}
if prevWs == 0 {
wsStart = rIdx
}
prevWs++
rIdx += runeSz
}
if prevWs > 1 || (prevWs == 1 && single) {
sb.WriteString(ws)
} else if prevWs == 1 {
sb.WriteString(s[wsStart:])
}
squashed = sb.String()
return
}
/*
StripWhitespace removes all leading, trailing, and INNER whitespace
from unicode string `s`.