Files
kjol/tools/tsgo/internal/stringutil/js_case.go
2026-07-09 16:50:43 -04:00

158 lines
4.7 KiB
Go

package stringutil
import (
"strings"
"unicode"
"unicode/utf8"
)
func ToLowerJS(str string) string {
if ascii, ok := toLowerASCII(str); ok {
return ascii
}
var builder strings.Builder
builder.Grow(len(str))
// casedBefore tracks whether the most recent non-Case_Ignorable code point is
// "cased", which is the backward half of the Final_Sigma context. We
// accumulate it as we stream so we never have to scan (or decode) backwards.
casedBefore := false
for i := 0; i < len(str); {
r, size := DecodeJSStringRune(str[i:])
i += size
if IsSurrogate(r) {
// A lone surrogate has no case mapping; preserve it verbatim, matching
// String.prototype.toLowerCase. EncodeJSStringRune restores the sentinel
// bytes because WriteRune would re-encode the surrogate as U+FFFD.
builder.WriteString(EncodeJSStringRune(r))
} else if mapping, ok := specialCasingMappings[r]; ok {
if mapping.condition == specialCasingConditionFinalSigma && isFinalSigmaContext(casedBefore, str, i) {
builder.WriteString(mapping.conditionalLower)
} else {
builder.WriteString(mapping.lower)
}
} else {
builder.WriteRune(r)
}
if !isUnicodeCaseIgnorable(r) {
casedBefore = isSigmaCased(r)
}
}
return builder.String()
}
func ToUpperJS(str string) string {
if ascii, ok := toUpperASCII(str); ok {
return ascii
}
var builder strings.Builder
builder.Grow(len(str))
for i := 0; i < len(str); {
r, size := DecodeJSStringRune(str[i:])
if IsSurrogate(r) {
// A lone surrogate has no case mapping; preserve it verbatim, matching
// String.prototype.toUpperCase. Copy the sentinel bytes directly because
// WriteRune would re-encode the surrogate as U+FFFD.
builder.WriteString(str[i : i+size])
} else if mapping, ok := specialCasingMappings[r]; ok {
builder.WriteString(mapping.upper)
} else {
builder.WriteRune(r)
}
i += size
}
return builder.String()
}
func toLowerASCII(str string) (string, bool) {
needsMapping := false
for i := range len(str) {
ch := str[i]
if ch >= utf8.RuneSelf {
return "", false
}
needsMapping = needsMapping || ('A' <= ch && ch <= 'Z')
}
if !needsMapping {
return str, true
}
buf := []byte(str)
for i, ch := range buf {
if 'A' <= ch && ch <= 'Z' {
buf[i] = ch + ('a' - 'A')
}
}
return string(buf), true
}
func toUpperASCII(str string) (string, bool) {
needsMapping := false
for i := range len(str) {
ch := str[i]
if ch >= utf8.RuneSelf {
return "", false
}
needsMapping = needsMapping || ('a' <= ch && ch <= 'z')
}
if !needsMapping {
return str, true
}
buf := []byte(str)
for i, ch := range buf {
if 'a' <= ch && ch <= 'z' {
buf[i] = ch - ('a' - 'A')
}
}
return string(buf), true
}
// isFinalSigmaContext reports whether a sigma at the current position is in
// Final_Sigma context: it is preceded by a cased code point and not followed by
// one. casedBefore carries the backward half (tracked incrementally by the
// caller so we never scan backwards); afterOffset is the byte offset just past
// the sigma, from which we scan forward.
//
// ECMAScript points at Unicode Default Case Conversion for toLowerCase, and
// modern V8 reaches that behavior through Intl::ConvertToLower, which uses
// ICU root-locale lowercasing for non-Latin1 strings like Greek sigma.
// We intentionally do not delegate this to golang.org/x/text/cases: x/text
// is a general Unicode casing library, but its root-locale behavior is not
// an exact match for the JS semantics exercised by String.prototype
// .toLowerCase(), especially around Final_Sigma context. TypeScript needs the
// JS behavior itself here, so we keep the context-sensitive part explicit.
// SpiderMonkey models Final_Sigma with a more explicit context walk, while
// Unicode Table 3-17 describes it in terms of Cased and Case_Ignorable.
// We model the exposed V8/ICU behavior directly here: skip Case_Ignorable code
// points and then look for a Cased code point, exactly as Unicode Table 3-17
// defines the Final_Sigma condition. The Cased property already subsumes
// lowercase, uppercase, and titlecase letters, including the
// DerivedCoreProperties Lowercase/Uppercase extras such as ª, º, and Roman
// numerals.
func isFinalSigmaContext(casedBefore bool, str string, afterOffset int) bool {
return casedBefore && !hasSigmaCasedAfter(str, afterOffset)
}
func hasSigmaCasedAfter(str string, start int) bool {
for i := start; i < len(str); {
r, size := DecodeJSStringRune(str[i:])
i += size
if isUnicodeCaseIgnorable(r) {
continue
}
return isSigmaCased(r)
}
return false
}
func isSigmaCased(r rune) bool {
return unicode.Is(unicodeCasedRanges, r)
}
func isUnicodeCaseIgnorable(r rune) bool {
return unicode.Is(unicodeCaseIgnorableRanges, r)
}