diff --git a/internal/confgen/document_parser.go b/internal/confgen/document_parser.go index 4921cd16..2e2e5218 100644 --- a/internal/confgen/document_parser.go +++ b/internal/confgen/document_parser.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/tableauio/tableau/internal/importer/book" - "github.com/tableauio/tableau/internal/strcase" "github.com/tableauio/tableau/internal/types" "github.com/tableauio/tableau/internal/x/xerrors" "github.com/tableauio/tableau/internal/x/xproto" @@ -438,7 +437,7 @@ func (p *documentParser) parseUnionMessage(field *Field, msg protoreflect.Messag } // parse union type - typeNodeName := strcase.FromContext(p.ctx).ToCamel(unionDesc.TypeName()) + typeNodeName := unionDesc.TypeName() typeNode := node.FindChild(typeNodeName) if typeNode == nil && xproto.GetFieldDefaultValue(unionDesc.Type) != "" { // if this field has a default value, use virtual node diff --git a/internal/confgen/table_parser.go b/internal/confgen/table_parser.go index 8743c561..ec18eb09 100644 --- a/internal/confgen/table_parser.go +++ b/internal/confgen/table_parser.go @@ -7,7 +7,6 @@ import ( "github.com/tableauio/tableau/internal/confgen/fieldprop" "github.com/tableauio/tableau/internal/importer/book" "github.com/tableauio/tableau/internal/importer/book/tableparser" - "github.com/tableauio/tableau/internal/strcase" "github.com/tableauio/tableau/internal/types" "github.com/tableauio/tableau/internal/x/xerrors" "github.com/tableauio/tableau/internal/x/xproto" @@ -756,7 +755,7 @@ func (p *tableParser) parseUnionMessage(msg protoreflect.Message, field *Field, } // parse union type - typeColName := prefix + strcase.FromContext(p.ctx).ToCamel(unionDesc.TypeName()) + typeColName := prefix + unionDesc.TypeName() cell, err := r.Cell(typeColName, p.IsFieldOptional(field)) if err != nil { return false, err diff --git a/internal/protogen/exporter.go b/internal/protogen/exporter.go index aab7489c..149a81f6 100644 --- a/internal/protogen/exporter.go +++ b/internal/protogen/exporter.go @@ -26,7 +26,7 @@ import ( type bookExporter struct { ProtoPackage string - Edition string + Edition int ProtoFileOptions map[string]string OutputDir string FilenameSuffix string @@ -37,7 +37,7 @@ type bookExporter struct { messagerPatternRegexp *regexp.Regexp } -func newBookExporter(protoPackage string, edition string, protoFileOptions map[string]string, outputDir, filenameSuffix string, wb *internalpb.Workbook, gen *Generator) *bookExporter { +func newBookExporter(protoPackage string, edition int, protoFileOptions map[string]string, outputDir, filenameSuffix string, wb *internalpb.Workbook, gen *Generator) *bookExporter { return &bookExporter{ ProtoPackage: protoPackage, Edition: edition, @@ -60,7 +60,7 @@ func (x *bookExporter) export(checkProtoFileConflicts bool) error { p1.P("// Code generated by tableau (protogen v", Version, "). DO NOT EDIT.") p1.P("// clang-format off") p1.P("") - if x.Edition != "" { + if x.Edition != 0 { p1.P(`edition = "`, x.Edition, `";`) } else { p1.P(`syntax = "proto3";`) @@ -301,18 +301,17 @@ func (x *sheetExporter) exportUnion() error { if field.Number == 0 { continue } - ename := "TYPE_" + strcase.FromContext(x.be.gen.ctx).ToScreamingSnake(field.Name) - typ := field.Name - fullTypeName := field.FullType - if fullTypeName != "" { - typ = fullTypeName + ename := strcase.FromContext(x.be.gen.ctx).EnumValue("Type", field.Name) + typ := field.FullType + if typ == "" { + typ = strcase.FromContext(x.be.gen.ctx).ToCamel(field.Name) } // Add import for predefined types (e.g. protoconf.FruitType, protoconf.Item). if field.Predefined { - if types.IsWellKnownMessage(fullTypeName) { - importPath := types.GetWellKnownMessageImport(fullTypeName) + if types.IsWellKnownMessage(typ) { + importPath := types.GetWellKnownMessageImport(typ) x.Imports[importPath] = true - } else if typeInfo := x.typeInfos.GetByFullName(protoreflect.FullName(fullTypeName)); typeInfo != nil && + } else if typeInfo := x.typeInfos.GetByFullName(protoreflect.FullName(typ)); typeInfo != nil && typeInfo.ParentFilename != x.be.GetProtoFilePath() { x.Imports[typeInfo.ParentFilename] = true } @@ -333,7 +332,7 @@ func (x *sheetExporter) exportUnion() error { x.p.P(" TYPE_INVALID = 0;") } for _, field := range x.ws.Fields { - ename := "TYPE_" + strcase.FromContext(x.be.gen.ctx).ToScreamingSnake(field.Name) + ename := strcase.FromContext(x.be.gen.ctx).EnumValue("Type", field.Name) note := "" if field.Alias != "" { note = " // " + field.Alias @@ -349,9 +348,9 @@ func (x *sheetExporter) exportUnion() error { if len(msgField.Fields) == 0 { continue } - typ := msgField.Name - if msgField.Type != "" { - typ = msgField.Type + typ := msgField.Type + if typ == "" { + typ = strcase.FromContext(x.be.gen.ctx).ToCamel(msgField.Name) } x.p.P(" message ", typ, " {") // generate the fields diff --git a/internal/protogen/exporter_test.go b/internal/protogen/exporter_test.go index f9dd0a4a..1194ee8e 100644 --- a/internal/protogen/exporter_test.go +++ b/internal/protogen/exporter_test.go @@ -291,7 +291,7 @@ func Test_bookExporter_GetProtoFilePath(t *testing.T) { func Test_bookExporter_export(t *testing.T) { tests := []struct { name string - edition string + edition int protoFileOptions map[string]string wantContains []string wantNotContains []string @@ -311,7 +311,7 @@ func Test_bookExporter_export(t *testing.T) { }, { name: "edition-2023-with-features-utf8-validation", - edition: "2023", + edition: 2023, protoFileOptions: map[string]string{ "go_package": `"github.com/example/protoconf"`, "features.utf8_validation": "NONE", @@ -327,7 +327,7 @@ func Test_bookExporter_export(t *testing.T) { }, { name: "edition-2024-with-features-strip-enum-prefix", - edition: "2024", + edition: 2024, protoFileOptions: map[string]string{ "go_package": `"github.com/example/protoconf"`, "features.(pb.go).strip_enum_prefix": "STRIP_ENUM_PREFIX_STRIP", @@ -348,7 +348,7 @@ func Test_bookExporter_export(t *testing.T) { }, { name: "edition-2024-with-pb-cpp-features-infer-cpp-features-import", - edition: "2024", + edition: 2024, protoFileOptions: map[string]string{ "go_package": `"github.com/example/protoconf"`, "features.(pb.cpp).string_type": "VIEW", @@ -365,7 +365,7 @@ func Test_bookExporter_export(t *testing.T) { }, { name: "edition-2024-with-pb-java-features-infer-java-features-import", - edition: "2024", + edition: 2024, protoFileOptions: map[string]string{ "go_package": `"github.com/example/protoconf"`, "features.(pb.java).utf8_validation": "VERIFY", @@ -382,7 +382,7 @@ func Test_bookExporter_export(t *testing.T) { }, { name: "edition-2024-with-all-language-features-infer-all-imports", - edition: "2024", + edition: 2024, protoFileOptions: map[string]string{ "go_package": `"github.com/example/protoconf"`, "features.(pb.go).api_level": "API_OPAQUE", diff --git a/internal/protogen/protogen.go b/internal/protogen/protogen.go index ef22d554..73c25009 100644 --- a/internal/protogen/protogen.go +++ b/internal/protogen/protogen.go @@ -65,7 +65,17 @@ func NewGenerator(protoPackage, indir, outdir string, setters ...options.Option) func NewGeneratorWithOptions(protoPackage, indir, outdir string, opts *options.Options) *Generator { ctx := context.Background() - ctx = strcase.NewContext(ctx, strcase.New(opts.Acronyms)) + // STYLE2024 naming is the default. Users can opt back into the legacy + // pre-STYLE2024 algorithm via ProtoInputOption.UseLegacyNamingStyle, + // EXCEPT when the requested edition itself mandates STYLE2024 + // (>= EditionStyle2024); in that case the legacy flag is ignored. + useLegacy := opts.Proto.Input.UseLegacyNamingStyle && + opts.Proto.Output.Edition < options.EditionStyle2024 + if useLegacy { + ctx = strcase.NewContext(ctx, strcase.NewLegacy(opts.Acronyms)) + } else { + ctx = strcase.NewContext(ctx, strcase.New(opts.Acronyms)) + } ctx = metasheet.NewContext(ctx, &metasheet.Metasheet{Name: opts.Proto.Input.MetasheetName}) gen := &Generator{ diff --git a/internal/protogen/sheet_mode.go b/internal/protogen/sheet_mode.go index 04640df0..9f2f3d83 100644 --- a/internal/protogen/sheet_mode.go +++ b/internal/protogen/sheet_mode.go @@ -62,15 +62,23 @@ func parseEnumType(ws *internalpb.Worksheet, sheet *book.Sheet, parser book.Shee if err := parser.Parse(desc, sheet); err != nil { return err } - prefix := strcase.FromContext(gen.ctx).ToScreamingSnake(ws.Name) + "_" + sc := strcase.FromContext(gen.ctx) for i, value := range desc.Values { number := int32(i + 1) if value.Number != nil { number = value.GetNumber() } - name := value.Name - if gen.OutputOpt.EnumValueWithPrefix && !strings.HasPrefix(name, prefix) { - name = prefix + name + var name string + if sc.Legacy() { + // Legacy: the prefix is opt-in via EnumValueWithPrefix, matching + // the pre-STYLE2024 generator behavior. + name = value.Name + if gen.OutputOpt.EnumValueWithPrefix { + name = sc.EnumValue(ws.Name, value.Name) + } + } else { + // STYLE2024: the prefix is mandatory. + name = sc.EnumValue(ws.Name, value.Name) } field := &internalpb.Field{ Number: number, diff --git a/internal/strcase/README.md b/internal/strcase/README.md index 1c033aa1..0b029cc2 100644 --- a/internal/strcase/README.md +++ b/internal/strcase/README.md @@ -1,36 +1,63 @@ -# strcase - -> NOTE: we learned a lot from package [strcase](https://github.com/iancoleman/strcase) - -strcase is a go package for converting string case to various cases (e.g. [snake case](https://en.wikipedia.org/wiki/Snake_case) or [camel case](https://en.wikipedia.org/wiki/CamelCase)) to see the full conversion table below. - -## Example - -```go -s := "AnyKind of_string" -``` - -| Function | Result | -| ----------------------------------------- | -------------------- | -| `ToSnake(s)` | `any_kind_of_string` | -| `ToSnakeWithIgnore(s, '.')` | `any_kind.of_string` | -| `ToScreamingSnake(s)` | `ANY_KIND_OF_STRING` | -| `ToKebab(s)` | `any-kind-of-string` | -| `ToScreamingKebab(s)` | `ANY-KIND-OF-STRING` | -| `ToDelimited(s, '.')` | `any.kind.of.string` | -| `ToScreamingDelimited(s, '.', '', true)` | `ANY.KIND.OF.STRING` | -| `ToScreamingDelimited(s, '.', ' ', true)` | `ANY.KIND OF.STRING` | -| `ToCamel(s)` | `AnyKindOfString` | -| `ToLowerCamel(s)` | `anyKindOfString` | - -## Custom Acronyms - -Sometimes, text may contain specific acronyms which need to be handled in a certain way. - -```go -// For "WebAPIV3Spec": -// - ToCamel: WebApiv3Spec -// - ToLowerCamel: webApiv3Spec -// - ToSnake: web_apiv3_spec -strcase.New(map[string]string{"APIV3": "apiv3"}) -``` +# strcase + +> NOTE: we learned a lot from package [strcase](https://github.com/iancoleman/strcase) + +strcase is a go package for converting string case to various cases (e.g. [snake case](https://en.wikipedia.org/wiki/Snake_case) or [PascalCase](https://en.wikipedia.org/wiki/Camel_case)) under [STYLE2024](https://protobuf.dev/programming-guides/style/) rules. + +## Example + +```go +s := "AnyKind of_string" +``` + +| Function | Result | +| --------------------- | -------------------- | +| `ToCamel(s)` | `AnyKindOfString` | +| `ToLowerCamel(s)` | `anyKindOfString` | +| `ToSnake(s)` | `any_kind_of_string` | +| `ToScreamingSnake(s)` | `ANY_KIND_OF_STRING` | + +## STYLE2024 highlights + +- An underscore is only allowed in front of a letter; therefore no + underscore is inserted at letter <-> digit boundaries + (e.g. `Tier1` -> `tier1`, NOT `tier_1`). +- Acronyms are treated as ordinary words + (e.g. `JSONData` -> `json_data`, `userID` -> `user_id`). + +## Custom Acronyms + +Sometimes, text may contain specific acronyms which need to be handled in a certain way. + +```go +// For "WebAPIV3Spec": +// - ToCamel: WebApiv3Spec +// - ToSnake: web_apiv3_spec +strcase.New(map[string]string{"APIV3": "apiv3"}) +``` + +## Legacy (pre-STYLE2024) Mode + +Existing projects whose generated proto files were produced under the old +algorithm can opt back into it by constructing the engine with `NewLegacy` +instead of `New`. At the public tableau API level this is exposed as +`useLegacyNamingStyle: true` under the `proto.input` section of the user +`config.yaml`. + +The flag is honored ONLY by protogen — confgen always parses input under +STYLE2024 rules. It is also force-disabled when the requested edition is +>= 2024 (`proto.output.edition: 2024`), because edition 2024 itself +mandates the STYLE2024 naming rules. + +Behavioral differences vs STYLE2024: + +- Underscores ARE inserted at letter <-> digit boundaries + (e.g. `Tier1` -> `tier_1`, `DeviceTier`/`1` -> `DEVICE_TIER_1`). +- `EnumValue` does NOT inject a leading `V` for digit-led suffixes. + `ProtoOutputOption.EnumValueWithPrefix` continues to gate prefixing at the + call site under legacy mode. +- Consecutive uppercase letters are folded to lower in camel form + (e.g. `HeroNTagMFcX` -> `HeroNtagMfcX`). + +See the strcase tests for an exhaustive, side-by-side enumeration of every +divergence. diff --git a/internal/strcase/camel.go b/internal/strcase/camel.go index 23476a53..ea16daa7 100644 --- a/internal/strcase/camel.go +++ b/internal/strcase/camel.go @@ -1,74 +1,224 @@ -package strcase - -import ( - "strings" -) - -// Converts a string to camelCase/CamelCase. The first word starting with -// initial uppercase or lowercase letter. -func (ctx *Strcase) toCamelInitCase(s string, initUppercase bool) string { - s = strings.TrimSpace(s) - if s == "" { - return s - } - - n := strings.Builder{} - n.Grow(len(s)) - upperNext := initUppercase - prevIsUpper := false - - bytes := []byte(s) - for i := 0; i < len(bytes); i++ { - // treat acronyms as words, e.g.: for JSONData -> JSON is a whole word - acronym, prefix := ctx.rangeAcronym(s, i) - if acronym != nil { - val := acronym.Regexp.ReplaceAllString(prefix, acronym.Replacement) - if i > 0 || upperNext { - val = upperFirst(val) - } else { - val = lowerFirst(val) - } - n.WriteString(val) - i += len(prefix) - 1 - upperNext = true - continue - } - - v := bytes[i] - vIsUpper := isUpper(v) - vIsLower := isLower(v) - if upperNext { - if vIsLower { - v = toUpper(v) - } - } else if i == 0 { - if vIsUpper { - v = toLower(v) - } - } else if prevIsUpper && vIsUpper { - v = toLower(v) - } - prevIsUpper = vIsUpper - - if vIsUpper || vIsLower { - n.WriteByte(v) - upperNext = false - } else if isDigit(v) { - n.WriteByte(v) - upperNext = true - } else { - upperNext = isSeparator(v) - } - } - return n.String() -} - -// ToCamel converts a string to CamelCase -func (ctx *Strcase) ToCamel(s string) string { - return ctx.toCamelInitCase(s, true) -} - -// ToLowerCamel converts a string to lowerCamelCase -func (ctx *Strcase) ToLowerCamel(s string) string { - return ctx.toCamelInitCase(s, false) -} +package strcase + +import ( + "strings" +) + +// toCamelCase converts a string to PascalCase (UpperCamelCase) using a +// chunk-based rule set. The result is STYLE2024-compliant: digits stay +// attached to the adjacent letter run (e.g. "Tier1" -> "Tier1", not +// "Tier_1"), and acronyms are treated as ordinary words. +// +// The input is first split into chunks by separator characters (space / +// underscore '_' / hyphen '-' / dot '.'). Each chunk is then transformed +// independently according to these three rules (in order): +// +// 1. If the chunk is composed exclusively of upper-case letters and / or +// digits AND contains at least one letter (i.e. it matches +// `[A-Z0-9]*[A-Z][A-Z0-9]*`, the typical SCREAMING_SNAKE token), +// it is converted to "first letter upper, the rest lower". +// Examples: +// +// "PVP" -> "Pvp" +// "PVE" -> "Pve" +// "DATA" -> "Data" +// "ID" -> "Id" +// +// 2. Otherwise, if the chunk's first character is a lower-case letter, +// only its first character is upper-cased; the remaining characters +// are kept untouched. Examples: +// +// "test" -> "Test" +// "fooBar" -> "FooBar" +// "case" -> "Case" +// +// 3. Otherwise the chunk is left untouched. Examples: +// +// "HeroNTagMFcX" -> "HeroNTagMFcX" +// "TestCase" -> "TestCase" +// "123" -> "123" +// +// Chunks are then concatenated. +// +// Acronyms (registered via New) take precedence inside every chunk: at +// each byte position we first try to match an acronym; if one matches, +// its registered replacement (with its first character upper-cased) is +// emitted and we resume processing right after the matched prefix. The +// chunk's remaining contiguous non-acronym slices are each treated as +// independent sub-chunks under rules 1-3 above. +// +// Combined examples: +// +// "PVP" -> "Pvp" +// "PVE_DATA" -> "PveData" +// "HeroNTagMFcX_SCORE" -> "HeroNTagMFcXScore" +// "test_case" -> "TestCase" +// "foo-bar" -> "FooBar" +// "CONSTANT_CASE" -> "ConstantCase" +func (ctx *Strcase) toCamelCase(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return s + } + + out := strings.Builder{} + out.Grow(len(s)) + + bytes := []byte(s) + chunkStart := -1 // -1 means "not currently inside a chunk" + flushChunk := func(end int) { + if chunkStart < 0 { + return + } + chunk := s[chunkStart:end] + chunkStart = -1 + if chunk == "" { + return + } + out.WriteString(ctx.transformCamelChunk(chunk)) + } + + for i := 0; i < len(bytes); i++ { + v := bytes[i] + if isSeparator(v) { + flushChunk(i) + continue + } + if chunkStart < 0 { + chunkStart = i + } + } + flushChunk(len(bytes)) + + result := out.String() + if result == "" { + return result + } + return upperFirst(result) +} + +// transformCamelChunk applies the per-chunk transformation rules +// described in toCamelCase to a single non-empty chunk that does +// NOT contain separator bytes. Acronym handling is interleaved with the +// rule application: the chunk is walked left to right; whenever an +// acronym matches at the current position, its registered replacement +// (with its first character upper-cased) is emitted and walking +// resumes after the matched prefix. The maximal contiguous slices of +// the chunk that are NOT consumed by acronyms are themselves treated +// as independent sub-chunks and run through transformPlainChunk, which +// implements rules 1-3. +func (ctx *Strcase) transformCamelChunk(chunk string) string { + if chunk == "" { + return chunk + } + + out := strings.Builder{} + out.Grow(len(chunk)) + + plainStart := 0 + flushPlain := func(end int) { + if end <= plainStart { + return + } + out.WriteString(transformPlainChunk(chunk[plainStart:end])) + } + + for i := 0; i < len(chunk); { + acronym, prefix := ctx.rangeAcronym(chunk, i) + if acronym == nil { + i++ + continue + } + flushPlain(i) + val := acronym.Regexp.ReplaceAllString(prefix, acronym.Replacement) + // Each acronym replacement starts a new sub-word, so its + // first character is upper-cased. + val = upperFirst(val) + out.WriteString(val) + i += len(prefix) + plainStart = i + } + flushPlain(len(chunk)) + + return out.String() +} + +// transformPlainChunk applies rules 1-3 from toCamelCase to a chunk +// slice that has no acronym matches and no separators inside it. +func transformPlainChunk(chunk string) string { + if chunk == "" { + return chunk + } + // Rule 1: all upper letters and/or digits, with at least one letter + // -> first letter upper, the rest lower. + if isAllUpperOrDigitWithLetter(chunk) { + return upperFirst(strings.ToLower(chunk)) + } + // Rule 2: first character is a lower-case letter -> upper-case it + // only; keep the remaining characters untouched. + if isLower(chunk[0]) { + return upperFirst(chunk) + } + // Rule 3: leave untouched. + return chunk +} + +// isAllUpperOrDigitWithLetter reports whether s is composed exclusively +// of ASCII upper-case letters and ASCII digits, AND contains at least +// one letter. An all-digit string returns false (nothing to transform). +func isAllUpperOrDigitWithLetter(s string) bool { + hasLetter := false + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case isUpper(c): + hasLetter = true + case isDigit(c): + // allowed + default: + return false + } + } + return hasLetter +} + +// ToCamel converts a string to PascalCase (UpperCamelCase). +// +// Under STYLE2024 rules (default): +// +// "any_kind_of_string" -> "AnyKindOfString" +// "PVE_DATA" -> "PveData" +// "Tier1" -> "Tier1" +// "HeroNTagMFcX_SCORE" -> "HeroNTagMFcXScore" +// +// Under legacy rules (NewLegacy): +// +// "any_kind_of_string" -> "AnyKindOfString" +// "Tier1" -> "Tier1" +// "numbers2And55with000"-> "Numbers2And55With000" // capital W +func (ctx *Strcase) ToCamel(s string) string { + if ctx.legacy { + return ctx.toCamelInitCaseLegacy(s, true) + } + return ctx.toCamelCase(s) +} + +// ToLowerCamel converts a string to lowerCamelCase. +// +// Under STYLE2024 rules (default), it is derived from ToCamel by +// lower-casing the first character of the PascalCase result. +// +// Under legacy rules, the byte-level walker emits the result directly. +// +// Examples: +// +// "any_kind_of_string" -> "anyKindOfString" +// "PVE_DATA" -> "pveData" +// "Tier1" -> "tier1" +// "HeroNTagMFcX_SCORE" -> "heroNTagMFcXScore" +func (ctx *Strcase) ToLowerCamel(s string) string { + if ctx.legacy { + return ctx.toCamelInitCaseLegacy(s, false) + } + return lowerFirst(ctx.toCamelCase(s)) +} diff --git a/internal/strcase/camel_test.go b/internal/strcase/camel_test.go index cbb78855..7921df97 100644 --- a/internal/strcase/camel_test.go +++ b/internal/strcase/camel_test.go @@ -5,161 +5,212 @@ import ( "testing" ) -func toCamel(tb testing.TB) { - var ctx Strcase - cases := [][]string{ - {"test_case", "TestCase"}, - {"test.case", "TestCase"}, - {"test", "Test"}, - {"TestCase", "TestCase"}, - {" test case ", "TestCase"}, - {"", ""}, - {"many_many_words", "ManyManyWords"}, - {"AnyKind of_string", "AnyKindOfString"}, - {"odd-fix", "OddFix"}, - {"numbers2And55with000", "Numbers2And55With000"}, - {"ID", "Id"}, - {"CONSTANT_CASE", "ConstantCase"}, +// caseExpect describes the expected output of a conversion under both +// naming styles. If wantLegacy is the empty string, the legacy result is +// expected to match want. To express "the legacy result is intentionally +// the empty string", set wantLegacy to "" AND use the dedicated test +// helper that already shares want=="" semantics — in our test corpus the +// only legitimately empty-output input is also "" itself, so this +// shortcut is unambiguous in practice. +type caseExpect struct { + in string + want string // STYLE2024 result + wantLegacy string // legacy result; empty means same as want +} + +// expectedLegacy returns the legacy expectation, falling back to want +// when wantLegacy was left as the zero value to mark "no difference". +func (c caseExpect) expectedLegacy() string { + if c.wantLegacy == "" { + return c.want } - for _, i := range cases { - in := i[0] - out := i[1] - result := ctx.ToCamel(in) - if result != out { - tb.Errorf("%q (%q != %q)", in, result, out) + return c.wantLegacy +} + +// runCamelCases drives the same set of cases against both the STYLE2024 +// and the legacy Strcase. Differences between the two styles are +// expressed by populating wantLegacy. +func runCamelCases(tb testing.TB, fn func(*Strcase, string) string, cases []caseExpect) { + tb.Helper() + std := New(nil) + legacy := NewLegacy(nil) + for _, c := range cases { + if got := fn(std, c.in); got != c.want { + tb.Errorf("STYLE2024 %q -> %q, want %q", c.in, got, c.want) + } + if got := fn(legacy, c.in); got != c.expectedLegacy() { + tb.Errorf("legacy %q -> %q, want %q", c.in, got, c.expectedLegacy()) } } } +func camelCases() []caseExpect { + return []caseExpect{ + // Cases where STYLE2024 and legacy agree. + {in: "test_case", want: "TestCase"}, + {in: "test.case", want: "TestCase"}, + {in: "test", want: "Test"}, + {in: "TestCase", want: "TestCase"}, + {in: " test case ", want: "TestCase"}, + {in: "", want: ""}, + {in: "many_many_words", want: "ManyManyWords"}, + {in: "AnyKind of_string", want: "AnyKindOfString"}, + {in: "odd-fix", want: "OddFix"}, + {in: "ID", want: "Id"}, + {in: "CONSTANT_CASE", want: "ConstantCase"}, + + // Cases where STYLE2024 and legacy intentionally diverge. + // STYLE2024 leaves a single chunk's tail untouched after the first + // upper-cased character; legacy uppercases EVERY post-digit letter. + {in: "numbers2And55with000", want: "Numbers2And55with000", wantLegacy: "Numbers2And55With000"}, + // Each underscore-separated chunk is classified independently + // under STYLE2024. + {in: "PVP", want: "Pvp"}, + {in: "PVE_DATA", want: "PveData"}, + // Legacy lowers any uppercase letter that follows another uppercase + // letter, so "NT", "MF" and the trailing "X" all collapse. + {in: "HeroNTagMFcX_SCORE", want: "HeroNTagMFcXScore", wantLegacy: "HeroNtagMfcXScore"}, + } +} + func TestToCamel(t *testing.T) { - toCamel(t) + runCamelCases(t, (*Strcase).ToCamel, camelCases()) } func BenchmarkToCamel(b *testing.B) { - benchmarkCamelTest(b, toCamel) + cases := camelCases() + for n := 0; n < b.N; n++ { + runCamelCases(b, (*Strcase).ToCamel, cases) + } } -func toLowerCamel(tb testing.TB) { - var ctx Strcase - cases := [][]string{ - {"foo-bar", "fooBar"}, - {"TestCase", "testCase"}, - {"", ""}, - {"AnyKind of_string", "anyKindOfString"}, - {"AnyKind.of-string", "anyKindOfString"}, - {"ID", "id"}, - {"some string", "someString"}, - {" some string", "someString"}, - {"CONSTANT_CASE", "constantCase"}, - } - for _, i := range cases { - in := i[0] - out := i[1] - result := ctx.ToLowerCamel(in) - if result != out { - tb.Errorf("%q (%q != %q)", in, result, out) - } +func lowerCamelCases() []caseExpect { + return []caseExpect{ + {in: "", want: ""}, + {in: "test_case", want: "testCase"}, + {in: "test", want: "test"}, + {in: "TestCase", want: "testCase"}, + {in: " test case ", want: "testCase"}, + {in: "many_many_words", want: "manyManyWords"}, + {in: "AnyKind of_string", want: "anyKindOfString"}, + {in: "odd-fix", want: "oddFix"}, + {in: "ID", want: "id"}, + {in: "CONSTANT_CASE", want: "constantCase"}, + {in: "PVP", want: "pvp"}, + {in: "PVE_DATA", want: "pveData"}, + {in: "HeroNTagMFcX_SCORE", want: "heroNTagMFcXScore", wantLegacy: "heroNtagMfcXScore"}, + {in: "foo-bar", want: "fooBar"}, + {in: "AnyKind.of-string", want: "anyKindOfString"}, + {in: "some string", want: "someString"}, + {in: " some string", want: "someString"}, } } func TestToLowerCamel(t *testing.T) { - toLowerCamel(t) + runCamelCases(t, (*Strcase).ToLowerCamel, lowerCamelCases()) } -func TestCustomAcronymToCamel(t *testing.T) { - tests := []struct { - name string - acronyms map[string]string - args []struct { - value string - expected string - } - }{ +// customAcronymCamelCase is a single fixture for the custom-acronym +// camel/lowerCamel tests below. The legacy and STYLE2024 algorithms +// already produce identical output for these acronym scenarios. +type customAcronymCamelCase struct { + name string + acronyms map[string]string + args []struct { + value string + wantCamel string + wantLower string + } +} + +func customAcronymCamelFixtures() []customAcronymCamelCase { + return []customAcronymCamelCase{ { - name: "APIV3 Custom Acronym", - acronyms: map[string]string{ - "APIV3": "apiv3", - }, + name: "APIV3 Custom Acronym", + acronyms: map[string]string{"APIV3": "apiv3"}, args: []struct { - value string - expected string + value string + wantCamel string + wantLower string }{ - {"WebAPIV3Spec", "WebApiv3Spec"}, + {"WebAPIV3Spec", "WebApiv3Spec", "webApiv3Spec"}, }, }, { - name: "K8s Custom Acroynm", - acronyms: map[string]string{ - "K8s": "k8s", - }, + name: "K8s Custom Acroynm", + acronyms: map[string]string{"K8s": "k8s"}, args: []struct { - value string - expected string + value string + wantCamel string + wantLower string }{ - {"InK8s", "InK8s"}, + {"InK8s", "InK8s", "inK8s"}, }, }, { - name: "HandleA1000Req Custom Acronym", - acronyms: map[string]string{ - `A(1\d{3})`: "a${1}", - }, + name: "HandleA1000Req Custom Acronym", + acronyms: map[string]string{`A(1\d{3})`: "a${1}"}, args: []struct { - value string - expected string + value string + wantCamel string + wantLower string }{ - {"HandleA1000Req", "HandleA1000Req"}, - {"HandleA1001AndA1002Reply", "HandleA1001AndA1002Reply"}, - {"HandleA2000Msg", "HandleA2000Msg"}, + {"HandleA1000Req", "HandleA1000Req", "handleA1000Req"}, + {"HandleA1001AndA1002Reply", "HandleA1001AndA1002Reply", "handleA1001AndA1002Reply"}, + {"HandleA2000Msg", "HandleA2000Msg", "handleA2000Msg"}, }, }, { - name: "Mode1V1 Custom Acronym", - acronyms: map[string]string{ - `(\d)[vV](\d)`: "${1}v${2}", - }, + name: "Mode1V1 Custom Acronym", + acronyms: map[string]string{`(\d)[vV](\d)`: "${1}v${2}"}, args: []struct { - value string - expected string + value string + wantCamel string + wantLower string }{ - {"Mode1V1", "Mode1v1"}, - {"Mode1v3", "Mode1v3"}, - {"Mode2v2v2", "Mode2v2V2"}, + {"Mode1V1", "Mode1v1", "mode1v1"}, + {"Mode1v3", "Mode1v3", "mode1v3"}, + {"Mode2v2v2", "Mode2v2V2", "mode2v2V2"}, }, }, { - name: "Prefix Custom Acronym", - acronyms: map[string]string{ - `^Tom`: "tommy", - }, + name: "Prefix Custom Acronym", + acronyms: map[string]string{`^Tom`: "tommy"}, args: []struct { - value string - expected string + value string + wantCamel string + wantLower string }{ - {"TomJerry", "TommyJerry"}, - {"JerryTom", "JerryTom"}, + {"TomJerry", "TommyJerry", "tommyJerry"}, + {"JerryTom", "JerryTom", "jerryTom"}, }, }, { - name: "Suffix Custom Acronym", - acronyms: map[string]string{ - `Cat$`: "kitty", - }, + name: "Suffix Custom Acronym", + acronyms: map[string]string{`Cat$`: "kitty"}, args: []struct { - value string - expected string + value string + wantCamel string + wantLower string }{ - {"CatMouse", "CatMouse"}, - {"MouseCat", "MouseKitty"}, + {"CatMouse", "CatMouse", "catMouse"}, + {"MouseCat", "MouseKitty", "mouseKitty"}, }, }, } - for _, test := range tests { +} + +func TestCustomAcronymToCamel(t *testing.T) { + for _, test := range customAcronymCamelFixtures() { t.Run(test.name, func(t *testing.T) { - ctx := FromContext(NewContext(context.Background(), New(test.acronyms))) + std := FromContext(NewContext(context.Background(), New(test.acronyms))) + legacy := FromContext(NewContext(context.Background(), NewLegacy(test.acronyms))) for _, arg := range test.args { - if result := ctx.ToCamel(arg.value); result != arg.expected { - t.Errorf("expected custom acronym result %s, got %s", arg.expected, result) + if got := std.ToCamel(arg.value); got != arg.wantCamel { + t.Errorf("STYLE2024 ToCamel(%q) = %q, want %q", arg.value, got, arg.wantCamel) + } + if got := legacy.ToCamel(arg.value); got != arg.wantCamel { + t.Errorf("legacy ToCamel(%q) = %q, want %q", arg.value, got, arg.wantCamel) } } }) @@ -167,111 +218,18 @@ func TestCustomAcronymToCamel(t *testing.T) { } func TestCustomAcronymToLowerCamel(t *testing.T) { - tests := []struct { - name string - acronyms map[string]string - args []struct { - value string - expected string - } - }{ - { - name: "APIV3 Custom Acronym", - acronyms: map[string]string{ - "APIV3": "apiv3", - }, - args: []struct { - value string - expected string - }{ - {"WebAPIV3Spec", "webApiv3Spec"}, - }, - }, - { - name: "K8s Custom Acroynm", - acronyms: map[string]string{ - "K8s": "k8s", - }, - args: []struct { - value string - expected string - }{ - {"InK8s", "inK8s"}, - }, - }, - { - name: "HandleA1000Req Custom Acronym", - acronyms: map[string]string{ - `A(1\d{3})`: "a${1}", - }, - args: []struct { - value string - expected string - }{ - {"HandleA1000Req", "handleA1000Req"}, - {"HandleA1001AndA1002Reply", "handleA1001AndA1002Reply"}, - {"HandleA2000Msg", "handleA2000Msg"}, - }, - }, - { - name: "Mode1V1 Custom Acronym", - acronyms: map[string]string{ - `(\d)[vV](\d)`: "${1}v${2}", - }, - args: []struct { - value string - expected string - }{ - {"Mode1V1", "mode1v1"}, - {"Mode1v3", "mode1v3"}, - {"Mode2v2v2", "mode2v2V2"}, - }, - }, - { - name: "Prefix Custom Acronym", - acronyms: map[string]string{ - `^Tom`: "tommy", - }, - args: []struct { - value string - expected string - }{ - {"TomJerry", "tommyJerry"}, - {"JerryTom", "jerryTom"}, - }, - }, - { - name: "Suffix Custom Acronym", - acronyms: map[string]string{ - `Cat$`: "kitty", - }, - args: []struct { - value string - expected string - }{ - {"CatMouse", "catMouse"}, - {"MouseCat", "mouseKitty"}, - }, - }, - } - for _, test := range tests { + for _, test := range customAcronymCamelFixtures() { t.Run(test.name, func(t *testing.T) { - ctx := FromContext(NewContext(context.Background(), New(test.acronyms))) + std := FromContext(NewContext(context.Background(), New(test.acronyms))) + legacy := FromContext(NewContext(context.Background(), NewLegacy(test.acronyms))) for _, arg := range test.args { - if result := ctx.ToLowerCamel(arg.value); result != arg.expected { - t.Errorf("expected custom acronym result %s, got %s", arg.expected, result) + if got := std.ToLowerCamel(arg.value); got != arg.wantLower { + t.Errorf("STYLE2024 ToLowerCamel(%q) = %q, want %q", arg.value, got, arg.wantLower) + } + if got := legacy.ToLowerCamel(arg.value); got != arg.wantLower { + t.Errorf("legacy ToLowerCamel(%q) = %q, want %q", arg.value, got, arg.wantLower) } } }) } } - -func BenchmarkToLowerCamel(b *testing.B) { - benchmarkCamelTest(b, toLowerCamel) -} - -func benchmarkCamelTest(b *testing.B, fn func(testing.TB)) { - for n := 0; n < b.N; n++ { - fn(b) - } -} diff --git a/internal/strcase/doc.go b/internal/strcase/doc.go index 605f8e9a..7c142470 100644 --- a/internal/strcase/doc.go +++ b/internal/strcase/doc.go @@ -1,13 +1,47 @@ -// Package strcase converts strings to various cases. See the conversion table below: -// -// | Function | Result | -// |---------------------------------|--------------------| -// | ToSnake(s) | any_kind_of_string | -// | ToScreamingSnake(s) | ANY_KIND_OF_STRING | -// | ToKebab(s) | any-kind-of-string | -// | ToScreamingKebab(s) | ANY-KIND-OF-STRING | -// | ToDelimited(s, '.') | any.kind.of.string | -// | ToScreamingDelimited(s, '.') | ANY.KIND.OF.STRING | -// | ToCamel(s) | AnyKindOfString | -// | ToLowerCamel(s) | anyKindOfString | -package strcase +// Package strcase converts strings to various cases under STYLE2024 rules +// (https://protobuf.dev/programming-guides/style/). +// +// | Function | Result | +// |-------------------------|----------------------| +// | ToCamel(s) | AnyKindOfString | +// | ToLowerCamel(s) | anyKindOfString | +// | ToSnake(s) | any_kind_of_string | +// | ToScreamingSnake(s) | ANY_KIND_OF_STRING | +// +// STYLE2024 highlights enforced (or assumed) by this package: +// +// - Message / type names: PascalCase, no underscores. Example: "SongRequest". +// - Field names: lower_snake_case (repeated fields use plurals). Example: +// "song_name", "songs". +// - Oneof names: lower_snake_case. Example: "song_id". +// - Enum type names: PascalCase. Example: "FooBar". +// - Enum value names: UPPER_SNAKE_CASE. They MUST start with the enum type +// name as prefix, and the first value (zero) MUST end with "_UNSPECIFIED" +// or "_UNKNOWN". Example: "FOO_BAR_UNSPECIFIED", "FOO_BAR_FIRST_VALUE". +// - Underscores are only allowed in front of a letter. So "DEVICE_TIER_1" +// is illegal, must be "DEVICE_TIER_TIER1" instead. Likewise the snake form +// of "Tier1" is "tier1", NOT "tier_1". +// - Acronyms are treated as ordinary words: "GetDnsRequest" / "dns_request", +// NOT "GetDNSRequest" / "d_n_s_request". +// +// # Legacy (pre-STYLE2024) mode +// +// Existing projects whose generated proto files were produced under the +// pre-STYLE2024 algorithm can opt back into the old behavior by constructing +// the engine via [NewLegacy] (or, at the public API level, by setting +// proto.input.useLegacyNamingStyle: true in their tableau config). The flag +// is honored only by protogen — confgen always parses input under STYLE2024 +// rules — and is force-disabled when proto.output.edition >= 2024. +// In legacy mode: +// +// - Underscores ARE inserted at letter <-> digit boundaries +// (e.g. "Tier1" -> "tier_1", "DeviceTier" / "1" -> "DEVICE_TIER_1"). +// - EnumValue does NOT inject a leading "V" for digit-led suffixes; +// ProtoOutputOption.EnumValueWithPrefix continues to gate prefixing +// at the call site (see options.EnumValueWithPrefix). +// - Consecutive uppercase letters are folded to lower in camel form +// (e.g. "HeroNTagMFcX" -> "HeroNtagMfcX"). +// +// See the strcase tests (camel_test.go / snake_test.go / enum_value_test.go) +// for an exhaustive, side-by-side enumeration of every divergence. +package strcase diff --git a/internal/strcase/enum_value.go b/internal/strcase/enum_value.go new file mode 100644 index 00000000..827793d6 --- /dev/null +++ b/internal/strcase/enum_value.go @@ -0,0 +1,75 @@ +package strcase + +import ( + "strings" +) + +// EnumValue returns the enum value name for the given raw value name under +// the given enum type name. +// +// Under STYLE2024 rules (default): +// +// 1. enumName is converted to UPPER_SNAKE_CASE under STYLE2024 rules and used +// as the prefix. +// 2. The raw value is converted to UPPER_SNAKE_CASE under STYLE2024 rules. +// 3. If the raw value (after normalization) does NOT start with a letter +// (e.g. it starts with a digit), a leading "V" is inserted so that the +// remainder after stripping the prefix is still a valid identifier. +// Example: enum DeviceTier value "1" -> "DEVICE_TIER_V1". +// 4. The prefix is prepended unless the value is already prefixed. +// +// Under legacy rules (NewLegacy): +// +// - enumName / value are converted with the legacy ToScreamingSnake +// (so e.g. "Tier1" stays "TIER_1"). +// - No "V" is injected for digit-led suffixes; legacy generated proto +// accepted names like "DEVICE_TIER_1". +// - This helper always prefixes; legacy call sites that historically +// made prefixing conditional (via ProtoOutputOption.EnumValueWithPrefix) +// still own that decision and should branch BEFORE calling EnumValue. +// +// STYLE2024 examples: +// +// EnumValue("DeviceTier", "Tier1") -> "DEVICE_TIER_TIER1" +// EnumValue("DeviceTier", "1") -> "DEVICE_TIER_V1" +// EnumValue("ItemType", "EQUIP") -> "ITEM_TYPE_EQUIP" +// EnumValue("ItemType", "ITEM_TYPE_EQUIP") -> "ITEM_TYPE_EQUIP" +// +// Legacy examples: +// +// EnumValue("DeviceTier", "Tier1") -> "DEVICE_TIER_TIER_1" +// EnumValue("DeviceTier", "1") -> "DEVICE_TIER_1" +// EnumValue("ItemType", "EQUIP") -> "ITEM_TYPE_EQUIP" +func (ctx *Strcase) EnumValue(enumName, value string) string { + if ctx.legacy { + return ctx.enumValueLegacy(enumName, value) + } + prefix := ctx.ToScreamingSnake(enumName) + "_" + v := strings.TrimSpace(value) + if v == "" { + return prefix + } + // If user already wrote a fully-qualified value, normalize and keep it. + if strings.HasPrefix(v, prefix) { + rest := strings.TrimPrefix(v, prefix) + rest = ensureLeadingLetter(ctx.ToScreamingSnake(rest)) + return prefix + rest + } + norm := ensureLeadingLetter(ctx.ToScreamingSnake(v)) + return prefix + norm +} + +// ensureLeadingLetter prepends "V" if s does not start with a letter, so that +// the remainder (when used as the suffix part of an enum value) is still a +// valid identifier after the enum-name prefix is stripped. STYLE2024 forbids +// names like "DEVICE_TIER_1" because the suffix after stripping is "1". +func ensureLeadingLetter(s string) string { + if s == "" { + return s + } + c := s[0] + if isUpper(c) || isLower(c) { + return s + } + return "V" + s +} diff --git a/internal/strcase/enum_value_test.go b/internal/strcase/enum_value_test.go new file mode 100644 index 00000000..13094392 --- /dev/null +++ b/internal/strcase/enum_value_test.go @@ -0,0 +1,78 @@ +package strcase + +import ( + "context" + "testing" +) + +// enumValueExpect describes the EnumValue output under both naming +// styles. wantLegacy == "" means "legacy result is identical to want". +type enumValueExpect struct { + enum string + value string + want string // STYLE2024 result + wantLegacy string // legacy result; empty means same as want +} + +func (c enumValueExpect) expectedLegacy() string { + if c.wantLegacy == "" { + return c.want + } + return c.wantLegacy +} + +func enumValueCases() []enumValueExpect { + return []enumValueExpect{ + // --- agreement set --- + {enum: "ItemType", value: "EQUIP", want: "ITEM_TYPE_EQUIP"}, + {enum: "ItemType", value: "Fruit", want: "ITEM_TYPE_FRUIT"}, + // Already prefixed -> kept (and re-normalized) under both styles. + {enum: "ItemType", value: "ITEM_TYPE_EQUIP", want: "ITEM_TYPE_EQUIP"}, + // Empty value -> just the prefix. + {enum: "ItemType", value: "", want: "ITEM_TYPE_"}, + + // --- divergence set --- + // Letter <-> digit boundary: STYLE2024 keeps the digit attached + // to the trailing letter; legacy splits it. + {enum: "DeviceTier", value: "Tier1", want: "DEVICE_TIER_TIER1", wantLegacy: "DEVICE_TIER_TIER_1"}, + // Pure digit suffix: STYLE2024 injects a leading "V" so the + // post-prefix remainder is a valid identifier; legacy emits the + // raw digit (DEVICE_TIER_1 was historically accepted). + {enum: "DeviceTier", value: "1", want: "DEVICE_TIER_V1", wantLegacy: "DEVICE_TIER_1"}, + {enum: "DeviceTier", value: "2A", want: "DEVICE_TIER_V2_A", wantLegacy: "DEVICE_TIER_2_A"}, + } +} + +func TestEnumValue(t *testing.T) { + std := New(nil) + legacy := NewLegacy(nil) + for _, c := range enumValueCases() { + if got := std.EnumValue(c.enum, c.value); got != c.want { + t.Errorf("STYLE2024 EnumValue(%q, %q) = %q, want %q", + c.enum, c.value, got, c.want) + } + if got := legacy.EnumValue(c.enum, c.value); got != c.expectedLegacy() { + t.Errorf("legacy EnumValue(%q, %q) = %q, want %q", + c.enum, c.value, got, c.expectedLegacy()) + } + } +} + +func TestStyle2024_AcronymsAndContext(t *testing.T) { + ctx := FromContext(NewContext(context.Background(), New(map[string]string{ + `(\d)[vV](\d)`: "${1}v${2}", + }))) + cases := []struct { + fn func(string) string + in, want string + }{ + {ctx.ToSnake, "Mode1V1", "mode1v1"}, + {ctx.ToScreamingSnake, "Mode1V1", "MODE1V1"}, + } + for _, c := range cases { + got := c.fn(c.in) + if got != c.want { + t.Errorf("style2024 acronym(%q) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/internal/strcase/legacy.go b/internal/strcase/legacy.go new file mode 100644 index 00000000..b6c48bd5 --- /dev/null +++ b/internal/strcase/legacy.go @@ -0,0 +1,172 @@ +package strcase + +import ( + "strings" +) + +// This file holds the pre-STYLE2024 ("legacy") conversion algorithms. +// They are byte-for-byte ports of the implementation that shipped before +// the STYLE2024 refactor, kept here so users who opt in via +// useLegacyNamingStyle keep producing the exact same generated names as +// before. Behavioral differences vs the STYLE2024 algorithm: +// +// - Underscores ARE inserted at letter <-> digit boundaries +// (e.g. "Tier1" -> "tier_1", "1A2" -> "1_a_2"). +// - Acronyms are folded against an UPPER_lower transition rather than +// treated as ordinary words, but in practice both algorithms produce +// "JSONData" -> "json_data" / "userID" -> "user_id". +// - EnumValue does NOT inject a leading "V" for digit-led suffixes +// (legacy callers were free to produce "DEVICE_TIER_1"). + +// toCamelInitCaseLegacy is the legacy camel-case engine. It walks the +// input one byte at a time, replacing acronyms eagerly and inserting +// case transitions at every separator / digit boundary. +func (ctx *Strcase) toCamelInitCaseLegacy(s string, initUppercase bool) string { + s = strings.TrimSpace(s) + if s == "" { + return s + } + + n := strings.Builder{} + n.Grow(len(s)) + upperNext := initUppercase + prevIsUpper := false + + bytes := []byte(s) + for i := 0; i < len(bytes); i++ { + // treat acronyms as words, e.g.: for JSONData -> JSON is a whole word + acronym, prefix := ctx.rangeAcronym(s, i) + if acronym != nil { + val := acronym.Regexp.ReplaceAllString(prefix, acronym.Replacement) + if i > 0 || upperNext { + val = upperFirst(val) + } else { + val = lowerFirst(val) + } + n.WriteString(val) + i += len(prefix) - 1 + upperNext = true + continue + } + + v := bytes[i] + vIsUpper := isUpper(v) + vIsLower := isLower(v) + if upperNext { + if vIsLower { + v = toUpper(v) + } + } else if i == 0 { + if vIsUpper { + v = toLower(v) + } + } else if prevIsUpper && vIsUpper { + v = toLower(v) + } + prevIsUpper = vIsUpper + + if vIsUpper || vIsLower { + n.WriteByte(v) + upperNext = false + } else if isDigit(v) { + n.WriteByte(v) + upperNext = true + } else { + upperNext = isSeparator(v) + } + } + return n.String() +} + +// toScreamingDelimitedLegacy is the legacy snake/screaming-snake engine. +// It always inserts a delimiter at every case-transition boundary, +// including letter <-> digit. +func (ctx *Strcase) toScreamingDelimitedLegacy(s string, delimiter uint8, screaming bool) string { + n := strings.Builder{} + n.Grow(len(s) + 2) // nominal 2 bytes of extra space for inserted delimiters + + s = strings.TrimSpace(s) + bytes := []byte(s) + for i := 0; i < len(bytes); i++ { + // treat acronyms as words, e.g.: for JSONData -> JSON is a whole word + acronym, prefix := ctx.rangeAcronym(s, i) + if acronym != nil { + val := acronym.Regexp.ReplaceAllString(prefix, acronym.Replacement) + if screaming { + n.WriteString(strings.ToUpper(val)) + } else { + n.WriteString(val) + } + i += len(prefix) - 1 + if i+1 < len(bytes) { + next := bytes[i+1] + if belong(next, Upper, Lower, Digit) { + n.WriteByte(delimiter) + } + } + continue + } + + v := bytes[i] + vIsUpper := isUpper(v) + vIsLow := isLower(v) + if vIsLow && screaming { + v = toUpper(v) + } else if vIsUpper && !screaming { + v = toLower(v) + } + + // treat acronyms as words, eg for JSONData -> JSON is a whole word + if i+1 < len(s) { + next := s[i+1] + vIsDigit := isDigit(v) + nextIsUpper := isUpper(next) + nextIsLower := isLower(next) + nextIsDigit := isDigit(next) + // add underscore if next letter case type is changed + if (vIsUpper && (nextIsLower || nextIsDigit)) || + (vIsLow && (nextIsUpper || nextIsDigit)) || + (vIsDigit && (nextIsUpper || nextIsLower)) { + if vIsUpper && nextIsLower { + if prevIsCap := i > 0 && isUpper(s[i-1]); prevIsCap { + n.WriteByte(delimiter) + } + } + n.WriteByte(v) + if vIsLow || vIsDigit || nextIsDigit { + n.WriteByte(delimiter) + } + continue + } + } + + if isSeparator(v) { + // replace space/underscore/hyphen/dot with delimiter + n.WriteByte(delimiter) + } else { + n.WriteByte(v) + } + } + + return n.String() +} + +// enumValueLegacy mirrors how legacy callers built enum value names: just +// "_", with the +// already-prefixed-value idempotent behavior kept for parity with the new +// EnumValue. NOTE: legacy callers chose whether to actually prefix at the +// call site (via ProtoOutputOption.EnumValueWithPrefix); this helper +// always prefixes. Call sites that want the legacy "opt-in" behavior must +// check EnumValueWithPrefix themselves before calling EnumValue. +func (ctx *Strcase) enumValueLegacy(enumName, value string) string { + prefix := ctx.toScreamingDelimitedLegacy(enumName, '_', true) + "_" + v := strings.TrimSpace(value) + if v == "" { + return prefix + } + if strings.HasPrefix(v, prefix) { + rest := strings.TrimPrefix(v, prefix) + return prefix + ctx.toScreamingDelimitedLegacy(rest, '_', true) + } + return prefix + ctx.toScreamingDelimitedLegacy(v, '_', true) +} diff --git a/internal/strcase/snake.go b/internal/strcase/snake.go index fd565601..cf9f065c 100644 --- a/internal/strcase/snake.go +++ b/internal/strcase/snake.go @@ -1,112 +1,171 @@ -package strcase - -import ( - "strings" -) - -// ToSnake converts a string to snake_case -func (ctx *Strcase) ToSnake(s string) string { - return ctx.ToDelimited(s, '_') -} - -func (ctx *Strcase) ToSnakeWithIgnore(s string, ignore string) string { - return ctx.ToScreamingDelimited(s, '_', ignore, false) -} - -// ToScreamingSnake converts a string to SCREAMING_SNAKE_CASE -func (ctx *Strcase) ToScreamingSnake(s string) string { - return ctx.ToScreamingDelimited(s, '_', "", true) -} - -// ToKebab converts a string to kebab-case -func (ctx *Strcase) ToKebab(s string) string { - return ctx.ToDelimited(s, '-') -} - -// ToScreamingKebab converts a string to SCREAMING-KEBAB-CASE -func (ctx *Strcase) ToScreamingKebab(s string) string { - return ctx.ToScreamingDelimited(s, '-', "", true) -} - -// ToDelimited converts a string to delimited.snake.case -// (in this case `delimiter = '.'`) -func (ctx *Strcase) ToDelimited(s string, delimiter uint8) string { - return ctx.ToScreamingDelimited(s, delimiter, "", false) -} - -// ToScreamingDelimited converts a string to SCREAMING.DELIMITED.SNAKE.CASE -// (in this case `delimiter = '.'; screaming = true`) -// or delimited.snake.case -// (in this case `delimiter = '.'; screaming = false`) -func (ctx *Strcase) ToScreamingDelimited(s string, delimiter uint8, ignore string, screaming bool) string { - n := strings.Builder{} - n.Grow(len(s) + 2) // nominal 2 bytes of extra space for inserted delimiters - - s = strings.TrimSpace(s) - bytes := []byte(s) - for i := 0; i < len(bytes); i++ { - // treat acronyms as words, e.g.: for JSONData -> JSON is a whole word - acronym, prefix := ctx.rangeAcronym(s, i) - if acronym != nil { - val := acronym.Regexp.ReplaceAllString(prefix, acronym.Replacement) - if screaming { - n.WriteString(strings.ToUpper(val)) - } else { - n.WriteString(val) - } - i += len(prefix) - 1 - if i+1 < len(bytes) { - next := bytes[i+1] - if belong(next, Upper, Lower, Digit) && !strings.ContainsAny(string(next), ignore) { - n.WriteByte(delimiter) - } - } - continue - } - - v := bytes[i] - vIsUpper := isUpper(v) - vIsLow := isLower(v) - if vIsLow && screaming { - v = toUpper(v) - } else if vIsUpper && !screaming { - v = toLower(v) - } - - // treat acronyms as words, eg for JSONData -> JSON is a whole word - if i+1 < len(s) { - next := s[i+1] - vIsDigit := isDigit(v) - nextIsUpper := isUpper(next) - nextIsLower := isLower(next) - nextIsDigit := isDigit(next) - // add underscore if next letter case type is changed - if (vIsUpper && (nextIsLower || nextIsDigit)) || - (vIsLow && (nextIsUpper || nextIsDigit)) || - (vIsDigit && (nextIsUpper || nextIsLower)) { - prevIgnore := ignore != "" && i > 0 && strings.ContainsAny(string(s[i-1]), ignore) - if !prevIgnore { - if vIsUpper && nextIsLower { - if prevIsCap := i > 0 && isUpper(s[i-1]); prevIsCap { - n.WriteByte(delimiter) - } - } - n.WriteByte(v) - if vIsLow || vIsDigit || nextIsDigit { - n.WriteByte(delimiter) - } - continue - } - } - } - - if isSeparator(v) && !strings.ContainsAny(string(v), ignore) { - // replace space/underscore/hyphen/dot with delimiter - n.WriteByte(delimiter) - } else { - n.WriteByte(v) - } - } - - return n.String() -} +package strcase + +import ( + "strings" +) + +// ToSnake converts a string to snake_case. +// +// Under STYLE2024 rules (default), see toDelimited for the detailed rule +// set. Under legacy rules (NewLegacy), letter <-> digit boundaries are +// also split with an underscore (e.g. "Tier1" -> "tier_1"). +// +// STYLE2024 examples: +// +// "Tier1" -> "tier1" +// "numbers2and55with000" -> "numbers2and55with000" +// "AB1AB2AB3" -> "ab1_ab2_ab3" +// "userID" -> "user_id" +// "JSONData" -> "json_data" +// +// Legacy examples: +// +// "Tier1" -> "tier_1" +// "numbers2and55with000" -> "numbers_2_and_55_with_000" +// "AB1AB2AB3" -> "ab_1_ab_2_ab_3" +// "userID" -> "user_id" +// "JSONData" -> "json_data" +func (ctx *Strcase) ToSnake(s string) string { + if ctx.legacy { + return ctx.toScreamingDelimitedLegacy(s, '_', false) + } + return ctx.toDelimited(s, '_', false) +} + +// ToScreamingSnake converts a string to SCREAMING_SNAKE_CASE. Same +// semantics as ToSnake, but the result is upper cased. +// +// STYLE2024 examples: +// +// "Tier1" -> "TIER1" +// "numbers2and55with000" -> "NUMBERS2AND55WITH000" +// "AB1AB2AB3" -> "AB1_AB2_AB3" +// +// Legacy examples: +// +// "Tier1" -> "TIER_1" +// "numbers2and55with000" -> "NUMBERS_2_AND_55_WITH_000" +// "AB1AB2AB3" -> "AB_1_AB_2_AB_3" +func (ctx *Strcase) ToScreamingSnake(s string) string { + if ctx.legacy { + return ctx.toScreamingDelimitedLegacy(s, '_', true) + } + return ctx.toDelimited(s, '_', true) +} + +// toDelimited is the STYLE2024-aware low-level converter shared by +// ToSnake and ToScreamingSnake. +// +// Compared with a classic snake_case converter, the key behavioral +// rule is: never insert a delimiter at a letter <-> digit boundary; +// the digit run is kept attached to the preceding (or following) +// letter run. Delimiters are inserted at: +// +// - lower -> upper (e.g. "userId" -> "user_id") +// - upper -> lower with previous upper (acronym boundary, e.g. +// "JSONData" -> "json_data") +// - digit -> upper-letter (e.g. "AB1AB2" -> "ab1_ab2") +// - explicit separator (' ', '_', '-', '.') between tokens +// +// Acronym replacements (regex) are honored exactly like in the +// classic converter; after an acronym match we add a delimiter only +// when the next byte is a letter (NOT a digit). +// +// STYLE2024 forbids names where any underscore-separated segment +// starts with a digit (an underscore is "only allowed in front of a +// letter"). Therefore at every potential split point we suppress the +// delimiter when the next non-separator byte would be a digit; the +// digit run is glued onto the preceding segment instead. Examples: +// +// "AB1 2CD" -> "ab12_cd" (NOT "ab1_2cd") +// "foo_1bar" -> "foo1bar" (NOT "foo_1bar") +// "v1.2" -> "v12" (NOT "v1_2") +func (ctx *Strcase) toDelimited(s string, delimiter uint8, screaming bool) string { + n := strings.Builder{} + n.Grow(len(s) + 2) + + s = strings.TrimSpace(s) + bytes := []byte(s) + for i := 0; i < len(bytes); i++ { + // treat acronyms as words, e.g. for JSONData -> JSON is a whole word + acronym, prefix := ctx.rangeAcronym(s, i) + if acronym != nil { + val := acronym.Regexp.ReplaceAllString(prefix, acronym.Replacement) + if screaming { + n.WriteString(strings.ToUpper(val)) + } else { + n.WriteString(val) + } + i += len(prefix) - 1 + if i+1 < len(bytes) { + next := bytes[i+1] + // In STYLE2024 we do NOT insert a delimiter before a digit + // (a digit-led segment would violate "underscore only in + // front of a letter"). + if belong(next, Upper, Lower) { + n.WriteByte(delimiter) + } + } + continue + } + + v := bytes[i] + vIsUpper := isUpper(v) + vIsLow := isLower(v) + if vIsLow && screaming { + v = toUpper(v) + } else if vIsUpper && !screaming { + v = toLower(v) + } + + if i+1 < len(s) { + next := s[i+1] + nextIsUpper := isUpper(next) + nextIsLower := isLower(next) + nextIsDigit := isDigit(next) + vIsDigit := isDigit(v) + // STYLE2024: an underscore is only allowed in FRONT OF A LETTER. + // Therefore we never split between a letter and an adjacent digit + // (digit<->letter boundary stays glued together). The remaining + // boundaries that warrant a delimiter are: + // - lower -> upper ("userId" -> "user_id") + // - upper -> upper-then-lower ("JSONData" -> "json_data") + // - digit -> upper-letter ("AB1AB2" -> "AB1_AB2") + if vIsUpper && nextIsLower { + if prevIsCap := i > 0 && isUpper(s[i-1]); prevIsCap { + n.WriteByte(delimiter) + } + n.WriteByte(v) + continue + } + if vIsLow && nextIsUpper { + n.WriteByte(v) + n.WriteByte(delimiter) + continue + } + if vIsDigit && nextIsUpper { + n.WriteByte(v) + n.WriteByte(delimiter) + continue + } + // Explicit separator followed by a digit: we MUST NOT emit a + // delimiter, because that would produce a segment starting + // with a digit. Glue the digit run onto the previous segment + // instead. The natural digit -> upper-letter split inside + // that run is still honored on a later iteration, so e.g. + // "AB1 2CD" yields "ab12_cd" (NOT "ab1_2cd" and NOT + // "ab12cd"): both segments are letter-initial. + if isSeparator(v) && nextIsDigit { + continue + } + } + + if isSeparator(v) { + n.WriteByte(delimiter) + } else { + n.WriteByte(v) + } + } + + return n.String() +} diff --git a/internal/strcase/snake_test.go b/internal/strcase/snake_test.go index f0ed1c63..3cb30d3f 100644 --- a/internal/strcase/snake_test.go +++ b/internal/strcase/snake_test.go @@ -7,205 +7,268 @@ import ( "github.com/stretchr/testify/assert" ) -func toSnake(tb testing.TB) { - var ctx Strcase - cases := [][]string{ - {"testCase", "test_case"}, - {"TestCase", "test_case"}, - {"Test Case", "test_case"}, - {" Test Case", "test_case"}, - {"Test Case ", "test_case"}, - {" Test Case ", "test_case"}, - {"test", "test"}, - {"test_case", "test_case"}, - {"Test", "test"}, - {"", ""}, - {"ManyManyWords", "many_many_words"}, - {"manyManyWords", "many_many_words"}, - {"AnyKind of_string", "any_kind_of_string"}, - {"numbers2and55with000", "numbers_2_and_55_with_000"}, - {"JSONData", "json_data"}, - {"userID", "user_id"}, - {"AAAbbb", "aa_abbb"}, - {"1A2", "1_a_2"}, - {"A1B", "a_1_b"}, - {"A1A2A3", "a_1_a_2_a_3"}, - {"A1 A2 A3", "a_1_a_2_a_3"}, - {"AB1AB2AB3", "ab_1_ab_2_ab_3"}, - {"AB1 AB2 AB3", "ab_1_ab_2_ab_3"}, - {"some string", "some_string"}, - {" some string", "some_string"}, - } - for _, i := range cases { - in := i[0] - out := i[1] - result := ctx.ToSnake(in) - if result != out { - tb.Errorf("%q (%q != %q)", in, result, out) +// runSnakeCases drives the same set of cases against both the STYLE2024 +// and the legacy Strcase. Differences between the two styles are +// expressed by populating wantLegacy on the caseExpect. +func runSnakeCases(tb testing.TB, fn func(*Strcase, string) string, cases []caseExpect) { + tb.Helper() + std := New(nil) + legacy := NewLegacy(nil) + for _, c := range cases { + if got := fn(std, c.in); got != c.want { + tb.Errorf("STYLE2024 %q -> %q, want %q", c.in, got, c.want) + } + if got := fn(legacy, c.in); got != c.expectedLegacy() { + tb.Errorf("legacy %q -> %q, want %q", c.in, got, c.expectedLegacy()) } } } -func TestToSnake(t *testing.T) { toSnake(t) } +func snakeCases() []caseExpect { + return []caseExpect{ + // --- agreement set --- + {in: "testCase", want: "test_case"}, + {in: "TestCase", want: "test_case"}, + {in: "Test Case", want: "test_case"}, + {in: " Test Case", want: "test_case"}, + {in: "Test Case ", want: "test_case"}, + {in: " Test Case ", want: "test_case"}, + {in: "test", want: "test"}, + {in: "test_case", want: "test_case"}, + {in: "Test", want: "test"}, + {in: "", want: ""}, + {in: "ManyManyWords", want: "many_many_words"}, + {in: "manyManyWords", want: "many_many_words"}, + {in: "AnyKind of_string", want: "any_kind_of_string"}, + {in: "JSONData", want: "json_data"}, + {in: "userID", want: "user_id"}, + {in: "AAAbbb", want: "aa_abbb"}, + {in: "DeviceTier", want: "device_tier"}, + {in: "some string", want: "some_string"}, + {in: " some string", want: "some_string"}, -func BenchmarkToSnake(b *testing.B) { - benchmarkSnakeTest(b, toSnake) + // --- divergence set: letter <-> digit boundaries --- + // STYLE2024 keeps the digit run glued to the adjacent letter run; + // legacy splits it. + {in: "numbers2and55with000", want: "numbers2and55with000", wantLegacy: "numbers_2_and_55_with_000"}, + {in: "1A2", want: "1_a2", wantLegacy: "1_a_2"}, + {in: "A1B", want: "a1_b", wantLegacy: "a_1_b"}, + {in: "A1A2A3", want: "a1_a2_a3", wantLegacy: "a_1_a_2_a_3"}, + {in: "A1 A2 A3", want: "a1_a2_a3", wantLegacy: "a_1_a_2_a_3"}, + {in: "AB1AB2AB3", want: "ab1_ab2_ab3", wantLegacy: "ab_1_ab_2_ab_3"}, + {in: "AB1 AB2 AB3", want: "ab1_ab2_ab3", wantLegacy: "ab_1_ab_2_ab_3"}, + {in: "Tier1", want: "tier1", wantLegacy: "tier_1"}, + + // --- divergence set: explicit-separator + digit-led next token --- + // STYLE2024 glues the digit run onto the previous segment to + // avoid a digit-led segment; legacy keeps each separator as a + // single delimiter and additionally splits at the letter<->digit + // boundary. + {in: "AB1 2CD", want: "ab12_cd", wantLegacy: "ab_1_2_cd"}, + {in: "foo_1bar", want: "foo1bar", wantLegacy: "foo_1_bar"}, + {in: "foo 1bar", want: "foo1bar", wantLegacy: "foo_1_bar"}, + {in: "foo-1bar", want: "foo1bar", wantLegacy: "foo_1_bar"}, + {in: "v1.2", want: "v12", wantLegacy: "v_1_2"}, + {in: "foo_123_bar", want: "foo123_bar", wantLegacy: "foo_123_bar"}, + } } -func toSnakeWithIgnore(tb testing.TB) { - var ctx Strcase - cases := [][]string{ - {"testCase", "test_case"}, - {"TestCase", "test_case"}, - {"Test Case", "test_case"}, - {" Test Case", "test_case"}, - {"Test Case ", "test_case"}, - {" Test Case ", "test_case"}, - {"test", "test"}, - {"test_case", "test_case"}, - {"Test", "test"}, - {"", ""}, - {"ManyManyWords", "many_many_words"}, - {"manyManyWords", "many_many_words"}, - {"AnyKind of_string", "any_kind_of_string"}, - {"numbers2and55with000", "numbers_2_and_55_with_000"}, - {"JSONData", "json_data"}, - {"AwesomeActivity.UserID", "awesome_activity.user_id", "."}, - {"AwesomeActivity.User.Id", "awesome_activity.user.id", "."}, - {"AwesomeUsername@Awesome.Com", "awesome_username@awesome.com", ".@"}, - {"lets-ignore all.of dots-and-dashes", "lets-ignore_all.of_dots-and-dashes", ".-"}, +func TestToSnake(t *testing.T) { runSnakeCases(t, (*Strcase).ToSnake, snakeCases()) } + +func BenchmarkToSnake(b *testing.B) { + cases := snakeCases() + for n := 0; n < b.N; n++ { + runSnakeCases(b, (*Strcase).ToSnake, cases) } - for _, i := range cases { - in := i[0] - out := i[1] - var ignore string - ignore = "" - if len(i) == 3 { - ignore = i[2] - } - result := ctx.ToSnakeWithIgnore(in, ignore) - if result != out { - istr := "" - if len(i) == 3 { - istr = " ignoring '" + i[2] + "'" - } - tb.Errorf("%q (%q != %q%s)", in, result, out, istr) - } +} + +func screamingSnakeCases() []caseExpect { + return []caseExpect{ + {in: "testCase", want: "TEST_CASE"}, + {in: "JSONData", want: "JSON_DATA"}, + {in: "userID", want: "USER_ID"}, + {in: "DeviceTier", want: "DEVICE_TIER"}, + + // --- divergence set: letter <-> digit boundaries --- + {in: "Tier1", want: "TIER1", wantLegacy: "TIER_1"}, + {in: "numbers2and55with000", want: "NUMBERS2AND55WITH000", wantLegacy: "NUMBERS_2_AND_55_WITH_000"}, + {in: "AB1AB2AB3", want: "AB1_AB2_AB3", wantLegacy: "AB_1_AB_2_AB_3"}, + {in: "V1.2", want: "V12", wantLegacy: "V_1_2"}, + + // --- divergence set: explicit-separator + digit-led next token --- + {in: "AB1 2CD", want: "AB12_CD", wantLegacy: "AB_1_2_CD"}, + {in: "FOO_1BAR", want: "FOO1_BAR", wantLegacy: "FOO_1_BAR"}, } } -func TestToSnakeWithIgnore(t *testing.T) { toSnakeWithIgnore(t) } +func TestToScreamingSnake(t *testing.T) { + runSnakeCases(t, (*Strcase).ToScreamingSnake, screamingSnakeCases()) +} + +func BenchmarkToScreamingSnake(b *testing.B) { + cases := screamingSnakeCases() + for n := 0; n < b.N; n++ { + runSnakeCases(b, (*Strcase).ToScreamingSnake, cases) + } +} -func BenchmarkToSnakeWithIgnore(b *testing.B) { - benchmarkSnakeTest(b, toSnakeWithIgnore) +// customAcronymSnakeCase shares one fixture across ToSnake / +// ToScreamingSnake for both naming styles. wantSnakeLegacy / +// wantScreamingLegacy hold the legacy expectation when it differs from +// STYLE2024; an empty string means "same as the STYLE2024 want". +type customAcronymSnakeCase struct { + name string + acronyms map[string]string + args []struct { + value string + wantSnake string + wantScreaming string + wantSnakeLegacy string + wantScreamingLegacy string + } } -func TestCustomAcronymsToSnake(t *testing.T) { - tests := []struct { - name string - acronyms map[string]string - args []struct { - value string - expected string - } - }{ +func customAcronymSnakeFixtures() []customAcronymSnakeCase { + return []customAcronymSnakeCase{ { - name: "APIV3 Custom Acronym", - acronyms: map[string]string{ - "APIV3": "apiv3", - }, + name: "APIV3 Custom Acronym", + acronyms: map[string]string{"APIV3": "apiv3"}, args: []struct { - value string - expected string + value string + wantSnake string + wantScreaming string + wantSnakeLegacy string + wantScreamingLegacy string }{ - {"WebAPIV3Spec", "web_apiv3_spec"}, + {value: "WebAPIV3Spec", wantSnake: "web_apiv3_spec", wantScreaming: "WEB_APIV3_SPEC"}, }, }, { - name: "K8s Custom Acroynm", - acronyms: map[string]string{ - "K8s": "k8s", - }, + name: "K8s Custom Acroynm", + acronyms: map[string]string{"K8s": "k8s"}, args: []struct { - value string - expected string + value string + wantSnake string + wantScreaming string + wantSnakeLegacy string + wantScreamingLegacy string }{ - {"InK8s", "in_k8s"}, + {value: "InK8s", wantSnake: "in_k8s", wantScreaming: "IN_K8S"}, }, }, { - name: "K8s Custom Acroynm with spaces", - acronyms: map[string]string{ - "K8s": "k8s", - }, + name: "HandleA1000Req Custom Acronym", + acronyms: map[string]string{`A(1\d{3})`: "a${1}"}, args: []struct { - value string - expected string + value string + wantSnake string + wantScreaming string + wantSnakeLegacy string + wantScreamingLegacy string }{ - {" InK8s XX", "in_k8s__xx"}, + {value: "HandleA1000Req", wantSnake: "handle_a1000_req", wantScreaming: "HANDLE_A1000_REQ"}, + {value: "HandleA1001AndA1002Reply", wantSnake: "handle_a1001_and_a1002_reply", wantScreaming: "HANDLE_A1001_AND_A1002_REPLY"}, + // "A2000" doesn't match the acronym pattern, so legacy + // splits at the letter <-> digit boundary while + // STYLE2024 keeps the digit attached. + { + value: "HandleA2000Msg", wantSnake: "handle_a2000_msg", wantScreaming: "HANDLE_A2000_MSG", + wantSnakeLegacy: "handle_a_2000_msg", wantScreamingLegacy: "HANDLE_A_2000_MSG", + }, }, }, { - name: "HandleA1000Req Custom Acronym", - acronyms: map[string]string{ - `A(1\d{3})`: "a${1}", - }, - args: []struct { - value string - expected string - }{ - {"HandleA1000Req", "handle_a1000_req"}, - {"HandleA1001AndA1002Reply", "handle_a1001_and_a1002_reply"}, - {"HandleA2000Msg", "handle_a_2000_msg"}, - }, - }, - { - name: "Mode1V1 Custom Acronym", - acronyms: map[string]string{ - `(\d)[vV](\d)`: "${1}v${2}", - }, + name: "Mode1V1 Custom Acronym", + acronyms: map[string]string{`(\d)[vV](\d)`: "${1}v${2}"}, args: []struct { - value string - expected string + value string + wantSnake string + wantScreaming string + wantSnakeLegacy string + wantScreamingLegacy string }{ - {"Mode1V1", "mode_1v1"}, - {"Mode1v3", "mode_1v3"}, - {"Mode2v2v2", "mode_2v2_v_2"}, + // Acronym match emits "1v1"; STYLE2024 keeps "Mode" + // glued to it, legacy splits before the digit. + {value: "Mode1V1", wantSnake: "mode1v1", wantScreaming: "MODE1V1", wantSnakeLegacy: "mode_1v1", wantScreamingLegacy: "MODE_1V1"}, + {value: "Mode1v3", wantSnake: "mode1v3", wantScreaming: "MODE1V3", wantSnakeLegacy: "mode_1v3", wantScreamingLegacy: "MODE_1V3"}, + // Two overlapping matches consume "2v2" then leave + // "v2"; legacy further splits the trailing "v_2". + {value: "Mode2v2v2", wantSnake: "mode2v2_v2", wantScreaming: "MODE2V2_V2", wantSnakeLegacy: "mode_2v2_v_2", wantScreamingLegacy: "MODE_2V2_V_2"}, }, }, { - name: "Prefix Custom Acronym", - acronyms: map[string]string{ - `^Tom`: "tommy", - }, + name: "Prefix Custom Acronym", + acronyms: map[string]string{`^Tom`: "tommy"}, args: []struct { - value string - expected string + value string + wantSnake string + wantScreaming string + wantSnakeLegacy string + wantScreamingLegacy string }{ - {"TomJerry", "tommy_jerry"}, - {"JerryTom", "jerry_tom"}, + {value: "TomJerry", wantSnake: "tommy_jerry", wantScreaming: "TOMMY_JERRY"}, + {value: "JerryTom", wantSnake: "jerry_tom", wantScreaming: "JERRY_TOM"}, }, }, { - name: "Suffix Custom Acronym", - acronyms: map[string]string{ - `Cat$`: "kitty", - }, + name: "Suffix Custom Acronym", + acronyms: map[string]string{`Cat$`: "kitty"}, args: []struct { - value string - expected string + value string + wantSnake string + wantScreaming string + wantSnakeLegacy string + wantScreamingLegacy string }{ - {"CatMouse", "cat_mouse"}, - {"MouseCat", "mouse_kitty"}, + {value: "CatMouse", wantSnake: "cat_mouse", wantScreaming: "CAT_MOUSE"}, + {value: "MouseCat", wantSnake: "mouse_kitty", wantScreaming: "MOUSE_KITTY"}, }, }, } - for _, test := range tests { +} + +// snakeLegacyExpect / screamingLegacyExpect default to want when +// wantSnakeLegacy / wantScreamingLegacy is the empty string. +func snakeLegacyExpect(arg struct { + value string + wantSnake string + wantScreaming string + wantSnakeLegacy string + wantScreamingLegacy string +}, +) string { + if arg.wantSnakeLegacy == "" { + return arg.wantSnake + } + return arg.wantSnakeLegacy +} + +func screamingLegacyExpect(arg struct { + value string + wantSnake string + wantScreaming string + wantSnakeLegacy string + wantScreamingLegacy string +}, +) string { + if arg.wantScreamingLegacy == "" { + return arg.wantScreaming + } + return arg.wantScreamingLegacy +} + +func TestCustomAcronymsToSnake(t *testing.T) { + for _, test := range customAcronymSnakeFixtures() { t.Run(test.name, func(t *testing.T) { - ctx := FromContext(NewContext(context.Background(), New(test.acronyms))) + std := FromContext(NewContext(context.Background(), New(test.acronyms))) + legacy := FromContext(NewContext(context.Background(), NewLegacy(test.acronyms))) for _, arg := range test.args { - if result := ctx.ToSnake(arg.value); result != arg.expected { - t.Errorf("expected custom acronym result %s, got %s", arg.expected, result) + if got := std.ToSnake(arg.value); got != arg.wantSnake { + t.Errorf("STYLE2024 ToSnake(%q) = %q, want %q", arg.value, got, arg.wantSnake) + } + wantLegacy := snakeLegacyExpect(arg) + if got := legacy.ToSnake(arg.value); got != wantLegacy { + t.Errorf("legacy ToSnake(%q) = %q, want %q", arg.value, got, wantLegacy) } } }) @@ -213,99 +276,17 @@ func TestCustomAcronymsToSnake(t *testing.T) { } func TestCustomAcronymsToScreamingSnake(t *testing.T) { - tests := []struct { - name string - acronyms map[string]string - args []struct { - value string - expected string - } - }{ - { - name: "APIV3 Custom Acronym", - acronyms: map[string]string{ - "APIV3": "apiv3", - }, - args: []struct { - value string - expected string - }{ - {"WebAPIV3Spec", "WEB_APIV3_SPEC"}, - }, - }, - { - name: "K8s Custom Acroynm", - acronyms: map[string]string{ - "K8s": "k8s", - }, - args: []struct { - value string - expected string - }{ - {"InK8s", "IN_K8S"}, - }, - }, - { - name: "HandleA1000Req Custom Acronym", - acronyms: map[string]string{ - `A(1\d{3})`: "a${1}", - }, - args: []struct { - value string - expected string - }{ - {"HandleA1000Req", "HANDLE_A1000_REQ"}, - {"HandleA1001AndA1002Reply", "HANDLE_A1001_AND_A1002_REPLY"}, - {"HandleA2000Msg", "HANDLE_A_2000_MSG"}, - }, - }, - { - name: "Mode1V1 Custom Acronym", - acronyms: map[string]string{ - `(\d)[vV](\d)`: "${1}v${2}", - }, - args: []struct { - value string - expected string - }{ - {"Mode1V1", "MODE_1V1"}, - {"Mode1v3", "MODE_1V3"}, - {"Mode2v2v2", "MODE_2V2_V_2"}, - }, - }, - { - name: "Prefix Custom Acronym", - acronyms: map[string]string{ - `^Tom`: "tommy", - }, - args: []struct { - value string - expected string - }{ - {"TomJerry", "TOMMY_JERRY"}, - {"JerryTom", "JERRY_TOM"}, - }, - }, - { - name: "Suffix Custom Acronym", - acronyms: map[string]string{ - `Cat$`: "kitty", - }, - args: []struct { - value string - expected string - }{ - {"CatMouse", "CAT_MOUSE"}, - {"MouseCat", "MOUSE_KITTY"}, - }, - }, - } - for _, test := range tests { + for _, test := range customAcronymSnakeFixtures() { t.Run(test.name, func(t *testing.T) { - ctx := FromContext(NewContext(context.Background(), New(test.acronyms))) + std := FromContext(NewContext(context.Background(), New(test.acronyms))) + legacy := FromContext(NewContext(context.Background(), NewLegacy(test.acronyms))) for _, arg := range test.args { - if result := ctx.ToScreamingSnake(arg.value); result != arg.expected { - t.Errorf("expected custom acronym result %s, got %s", arg.expected, result) + if got := std.ToScreamingSnake(arg.value); got != arg.wantScreaming { + t.Errorf("STYLE2024 ToScreamingSnake(%q) = %q, want %q", arg.value, got, arg.wantScreaming) + } + wantLegacy := screamingLegacyExpect(arg) + if got := legacy.ToScreamingSnake(arg.value); got != wantLegacy { + t.Errorf("legacy ToScreamingSnake(%q) = %q, want %q", arg.value, got, wantLegacy) } } }) @@ -319,181 +300,22 @@ func TestPanicOnMultipleAcronymMatches(t *testing.T) { arg string }{ { - name: "APIV3 Custom Acronym", - acronyms: map[string]string{ - "API": "api", - "APIV3": "apiv3", - }, - arg: "WebAPIV3Spec", + name: "APIV3 Custom Acronym", + acronyms: map[string]string{"API": "api", "APIV3": "apiv3"}, + arg: "WebAPIV3Spec", }, { - name: "HandleA1000Req Custom Acronym", - acronyms: map[string]string{ - `A(1\d{3})`: "a${1}", - `A(\d{4})`: "a${1}", - }, - arg: "HandleA1000Req", + name: "HandleA1000Req Custom Acronym", + acronyms: map[string]string{`A(1\d{3})`: "a${1}", `A(\d{4})`: "a${1}"}, + arg: "HandleA1000Req", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - ctx := FromContext(NewContext(context.Background(), New(test.acronyms))) - assert.Panics(t, func() { ctx.ToScreamingSnake(test.arg) }) + std := FromContext(NewContext(context.Background(), New(test.acronyms))) + legacy := FromContext(NewContext(context.Background(), NewLegacy(test.acronyms))) + assert.Panics(t, func() { std.ToScreamingSnake(test.arg) }) + assert.Panics(t, func() { legacy.ToScreamingSnake(test.arg) }) }) } } - -func toDelimited(tb testing.TB) { - var ctx Strcase - cases := [][]string{ - {"testCase", "test@case"}, - {"TestCase", "test@case"}, - {"Test Case", "test@case"}, - {" Test Case", "test@case"}, - {"Test Case ", "test@case"}, - {" Test Case ", "test@case"}, - {"test", "test"}, - {"test_case", "test@case"}, - {"Test", "test"}, - {"", ""}, - {"ManyManyWords", "many@many@words"}, - {"manyManyWords", "many@many@words"}, - {"AnyKind of_string", "any@kind@of@string"}, - {"numbers2and55with000", "numbers@2@and@55@with@000"}, - {"JSONData", "json@data"}, - {"userID", "user@id"}, - {"AAAbbb", "aa@abbb"}, - {"test-case", "test@case"}, - } - for _, i := range cases { - in := i[0] - out := i[1] - result := ctx.ToDelimited(in, '@') - if result != out { - tb.Errorf("%q (%q != %q)", in, result, out) - } - } -} - -func TestToDelimited(t *testing.T) { toDelimited(t) } - -func BenchmarkToDelimited(b *testing.B) { - benchmarkSnakeTest(b, toDelimited) -} - -func toScreamingSnake(tb testing.TB) { - var ctx Strcase - cases := [][]string{ - {"testCase", "TEST_CASE"}, - } - for _, i := range cases { - in := i[0] - out := i[1] - result := ctx.ToScreamingSnake(in) - if result != out { - tb.Errorf("%q (%q != %q)", in, result, out) - } - } -} - -func TestToScreamingSnake(t *testing.T) { toScreamingSnake(t) } - -func BenchmarkToScreamingSnake(b *testing.B) { - benchmarkSnakeTest(b, toScreamingSnake) -} - -func toKebab(tb testing.TB) { - var ctx Strcase - cases := [][]string{ - {"testCase", "test-case"}, - } - for _, i := range cases { - in := i[0] - out := i[1] - result := ctx.ToKebab(in) - if result != out { - tb.Errorf("%q (%q != %q)", in, result, out) - } - } -} - -func TestToKebab(t *testing.T) { toKebab(t) } - -func BenchmarkToKebab(b *testing.B) { - benchmarkSnakeTest(b, toKebab) -} - -func toScreamingKebab(tb testing.TB) { - var ctx Strcase - cases := [][]string{ - {"testCase", "TEST-CASE"}, - } - for _, i := range cases { - in := i[0] - out := i[1] - result := ctx.ToScreamingKebab(in) - if result != out { - tb.Errorf("%q (%q != %q)", in, result, out) - } - } -} - -func TestToScreamingKebab(t *testing.T) { toScreamingKebab(t) } - -func BenchmarkToScreamingKebab(b *testing.B) { - benchmarkSnakeTest(b, toScreamingKebab) -} - -func toScreamingDelimited(tb testing.TB) { - var ctx Strcase - cases := [][]string{ - {"testCase", "TEST.CASE"}, - } - for _, i := range cases { - in := i[0] - out := i[1] - result := ctx.ToScreamingDelimited(in, '.', "", true) - if result != out { - tb.Errorf("%q (%q != %q)", in, result, out) - } - } -} - -func TestToScreamingDelimited(t *testing.T) { toScreamingDelimited(t) } - -func BenchmarkToScreamingDelimited(b *testing.B) { - benchmarkSnakeTest(b, toScreamingDelimited) -} - -func toScreamingDelimitedWithIgnore(tb testing.TB) { - var ctx Strcase - cases := [][]string{ - {"AnyKind of_string", "ANY.KIND OF.STRING", ".", " "}, - } - for _, i := range cases { - in := i[0] - out := i[1] - delimiter := i[2][0] - ignore := i[3][0] - result := ctx.ToScreamingDelimited(in, delimiter, string(ignore), true) - if result != out { - istr := "" - if len(i) == 4 { - istr = " ignoring '" + i[3] + "'" - } - tb.Errorf("%q (%q != %q%s)", in, result, out, istr) - } - } -} - -func TestToScreamingDelimitedWithIgnore(t *testing.T) { toScreamingDelimitedWithIgnore(t) } - -func BenchmarkToScreamingDelimitedWithIgnore(b *testing.B) { - benchmarkSnakeTest(b, toScreamingDelimitedWithIgnore) -} - -func benchmarkSnakeTest(b *testing.B, fn func(testing.TB)) { - for n := 0; n < b.N; n++ { - fn(b) - } -} diff --git a/internal/strcase/strcase.go b/internal/strcase/strcase.go index 1b7ee3e3..92529e16 100644 --- a/internal/strcase/strcase.go +++ b/internal/strcase/strcase.go @@ -1,69 +1,102 @@ -package strcase - -import ( - "fmt" - "regexp" - "strings" -) - -type acronymRegex struct { - Regexp *regexp.Regexp - Pattern string - Replacement string -} - -type Strcase struct { - acronyms map[string]*acronymRegex -} - -// New creates a new Strcase with the given acronyms. -// -// Examples: -// -// - "API": "api" -// - "K8s": "k8s" -// - "3D": "3d" -// - `A(1\d{3})`: "a$l1}" -// - `(\d)[vV](\d)`: "${1}v${2}" -func New(acronyms map[string]string) *Strcase { - parsedAcronyms := make(map[string]*acronymRegex, len(acronyms)) - for pattern, replacement := range acronyms { - parsedAcronyms[pattern] = &acronymRegex{ - Regexp: regexp.MustCompile(pattern), - Pattern: pattern, - Replacement: replacement, - } - } - return &Strcase{ - acronyms: parsedAcronyms, - } -} - -func (ctx *Strcase) rangeAcronym(full string, pos int) (*acronymRegex, string) { - var ( - acronym *acronymRegex - prefix string - ) - for _, regex := range ctx.acronyms { - if strings.HasPrefix(regex.Pattern, "^") && pos != 0 { - // no need to match if current position is not the start of the string - continue - } - remain := full[pos:] - matches := regex.Regexp.FindStringSubmatch(remain) - if len(matches) == 0 { - continue - } - if !strings.HasPrefix(remain, matches[0]) { - // not current position - continue - } - if acronym != nil { - panic(fmt.Sprintf(`"%s" (remain: "%s") match multiple patterns: "%s" and "%s"`, - full, remain, (*acronym).Pattern, regex.Pattern)) - } - acronym = regex - prefix = matches[0] - } - return acronym, prefix -} +package strcase + +import ( + "fmt" + "regexp" + "strings" +) + +type acronymRegex struct { + Regexp *regexp.Regexp + Pattern string + Replacement string +} + +// Strcase is the case conversion engine. It supports two naming styles: +// +// - Default (STYLE2024): follows the Protobuf STYLE2024 guide +// (https://protobuf.dev/programming-guides/style/). See package doc. +// - Legacy: the conversion behavior shipped before STYLE2024 was adopted. +// Mainly retained so that existing projects whose generated proto / conf +// files were produced under the old rules can keep regenerating without +// surprise renames. +// +// All public methods (ToCamel, ToLowerCamel, ToSnake, ToScreamingSnake, +// EnumValue, ...) honor the configured style; the call sites do not need to +// know which style is in effect. +type Strcase struct { + acronyms map[string]*acronymRegex + // legacy selects the pre-STYLE2024 conversion algorithm. When false + // (default) STYLE2024 rules apply. + legacy bool +} + +// New creates a new Strcase with the given acronyms. The returned instance +// uses STYLE2024 rules (legacy == false). +// +// Acronym examples: +// +// - "API": "api" +// - "K8s": "k8s" +// - "3D": "3d" +// - `A(1\d{3})`: "a${1}" +// - `(\d)[vV](\d)`: "${1}v${2}" +func New(acronyms map[string]string) *Strcase { + return newStrcase(acronyms, false) +} + +// NewLegacy creates a new Strcase that uses the legacy (pre-STYLE2024) +// conversion algorithm. See package doc for the behavioral differences. +func NewLegacy(acronyms map[string]string) *Strcase { + return newStrcase(acronyms, true) +} + +func newStrcase(acronyms map[string]string, legacy bool) *Strcase { + parsedAcronyms := make(map[string]*acronymRegex, len(acronyms)) + for pattern, replacement := range acronyms { + parsedAcronyms[pattern] = &acronymRegex{ + Regexp: regexp.MustCompile(pattern), + Pattern: pattern, + Replacement: replacement, + } + } + return &Strcase{ + acronyms: parsedAcronyms, + legacy: legacy, + } +} + +// Legacy reports whether this Strcase uses the legacy (pre-STYLE2024) +// algorithm. +func (ctx *Strcase) Legacy() bool { + return ctx.legacy +} + +func (ctx *Strcase) rangeAcronym(full string, pos int) (*acronymRegex, string) { + var ( + acronym *acronymRegex + prefix string + ) + for _, regex := range ctx.acronyms { + if strings.HasPrefix(regex.Pattern, "^") && pos != 0 { + // no need to match if current position is not the start of the string + continue + } + remain := full[pos:] + matches := regex.Regexp.FindStringSubmatch(remain) + if len(matches) == 0 { + continue + } + if !strings.HasPrefix(remain, matches[0]) { + // not current position + continue + } + if acronym != nil { + panic(fmt.Sprintf(`"%s" (remain: "%s") match multiple patterns: "%s" and "%s"`, + full, remain, (*acronym).Pattern, regex.Pattern)) + } + acronym = regex + prefix = matches[0] + } + return acronym, prefix +} diff --git a/options/options.go b/options/options.go index 1f9ed285..68f0641c 100644 --- a/options/options.go +++ b/options/options.go @@ -168,6 +168,39 @@ type ProtoInputOption struct { // // Default: "". MessagerPattern string `yaml:"messagerPattern"` + + // UseLegacyNamingStyle switches the case-conversion engine used during + // proto generation to the pre-STYLE2024 algorithm. + // + // When false (default), all generated proto field names and enum value + // names follow the Protobuf STYLE2024 guide + // (https://protobuf.dev/programming-guides/style/). Notably: + // - No underscore is inserted at letter <-> digit boundaries + // ("Tier1" -> "tier1", NOT "tier_1"). + // - Acronyms are treated as ordinary words + // ("JSONData" -> "json_data", "userID" -> "user_id"). + // - Enum value names are always prefixed with the UPPER_SNAKE_CASE form + // of their enum type, and a leading "V" is injected when the suffix + // would otherwise start with a digit ("DeviceTier"."1" -> + // "DEVICE_TIER_V1"). + // + // When true, the algorithm shipped before STYLE2024 is used. This option + // exists so that existing projects whose .proto files were generated + // under the old rules can keep regenerating without a one-shot rename + // storm. It also re-enables ProtoOutputOption.EnumValueWithPrefix + // (which is otherwise a no-op under STYLE2024). + // + // NOTE: this flag is ignored (i.e. STYLE2024 is forced) when + // ProtoOutputOption.Edition >= EditionStyle2024, because edition 2024 + // itself mandates the STYLE2024 naming rules. + // + // NOTE: confgen never honors this flag — conf-side parsing always uses + // the STYLE2024 algorithm so that generated proto produced under either + // style still loads correctly. The only consumer of this flag is the + // proto generator. + // + // Default: false. + UseLegacyNamingStyle bool `yaml:"useLegacyNamingStyle"` } // Output options for generating proto files. @@ -187,11 +220,18 @@ type ProtoOutputOption struct { // Default: "". FilenameSuffix string `yaml:"filenameSuffix"` - // Specify the generated protobuf file's edition. + // Specify the generated protobuf file's edition (e.g. 2023, 2024). // See https://protobuf.dev/editions/overview/. // - // Default: "". - Edition string `yaml:"edition"` + // 0 (the zero value) means "do not emit an edition declaration"; the + // generator falls back to `syntax = "proto3";` instead. + // + // NOTE: when Edition >= EditionStyle2024, ProtoInputOption.UseLegacyNamingStyle + // is force-disabled because edition 2024 itself mandates the STYLE2024 + // naming rules. + // + // Default: 0. + Edition int `yaml:"edition"` // Specify options (including features) at file level. // Examples: "go_package", "csharp_namespace", "features.(pb.go).strip_enum_prefix" etc. @@ -212,6 +252,12 @@ type ProtoOutputOption struct { // not be prefixed again. // // Default: false. + // + // NOTE: under the default STYLE2024 naming style this option is a no-op, + // because STYLE2024 mandates the prefix unconditionally. It is only + // honored when ProtoInputOption.UseLegacyNamingStyle is true (and + // Edition < EditionStyle2024); in that case it preserves the legacy + // "opt-in prefix" behavior. EnumValueWithPrefix bool `yaml:"enumValueWithPrefix"` // In Protocol Buffers (Protobuf), to guarantee both backward and forward @@ -384,6 +430,11 @@ const ( DefaultVersionPattern = "255.255.255" ) +// EditionStyle2024 is the protobuf edition number at which the STYLE2024 +// naming rules become mandatory. When ProtoOutputOption.Edition is at or +// above this value, ProtoInputOption.UseLegacyNamingStyle is ignored. +const EditionStyle2024 = 2024 + // Option is the functional option type. type Option func(*Options) diff --git a/test/functest/proto/default/csv__bom__utf_8_bom.proto b/test/functest/proto/default/csv__bom__utf8_bom.proto similarity index 100% rename from test/functest/proto/default/csv__bom__utf_8_bom.proto rename to test/functest/proto/default/csv__bom__utf8_bom.proto diff --git a/test/functest/proto/default/excel__dev__hero__hero.proto b/test/functest/proto/default/excel__dev__hero__hero.proto index 8e8fc7e9..febeaec2 100644 --- a/test/functest/proto/default/excel__dev__hero__hero.proto +++ b/test/functest/proto/default/excel__dev__hero__hero.proto @@ -60,8 +60,8 @@ message FirstHListField { int32 num = 2 [(tableau.field) = {name:"Num"}]; } repeated int32 param_list = 9 [(tableau.field) = {name:"Param" layout:LAYOUT_HORIZONTAL}]; // Paramater - repeated int32 incell_param_1_list = 10 [(tableau.field) = {name:"IncellParam1" layout:LAYOUT_INCELL}]; // Incell param1 - repeated int32 incell_param_2_list = 11 [(tableau.field) = {name:"IncellParam2" layout:LAYOUT_INCELL}]; // Incell param2 + repeated int32 incell_param1_list = 10 [(tableau.field) = {name:"IncellParam1" layout:LAYOUT_INCELL}]; // Incell param1 + repeated int32 incell_param2_list = 11 [(tableau.field) = {name:"IncellParam2" layout:LAYOUT_INCELL}]; // Incell param2 repeated Tip incell_struct_tip_list = 12 [(tableau.field) = {name:"IncellStructTip" layout:LAYOUT_HORIZONTAL span:SPAN_INNER_CELL}]; // Incell struct tip message Tip { int32 id = 1 [(tableau.field) = {name:"ID"}]; diff --git a/test/functest/proto/default/excel__dev__test.proto b/test/functest/proto/default/excel__dev__test.proto index 396944c6..e526cfca 100644 --- a/test/functest/proto/default/excel__dev__test.proto +++ b/test/functest/proto/default/excel__dev__test.proto @@ -43,14 +43,14 @@ message Activity { Task task = 6 [(tableau.field) = {name:"Task"}]; message Task { int32 type = 1 [(tableau.field) = {name:"Type" prop:{range:"~,20"}}]; // 任务类型 - int32 param_1 = 2 [(tableau.field) = {name:"Param1"}]; // 参数1 - int32 param_2 = 3 [(tableau.field) = {name:"Param2"}]; // 参数2 - int32 param_3 = 4 [(tableau.field) = {name:"Param3"}]; // 参数3 - repeated int32 incell_param_1_list = 5 [(tableau.field) = {name:"IncellParam1" layout:LAYOUT_INCELL prop:{range:"1,50"}}]; // 参数列表1 - repeated int32 incell_param_2_list = 6 [(tableau.field) = {name:"IncellParam2" layout:LAYOUT_INCELL}]; // 参数列表2 - repeated int32 incell_param_3_list = 7 [(tableau.field) = {name:"IncellParam3" layout:LAYOUT_INCELL}]; // 参数列表3 - map incell_param_4_map = 8 [(tableau.field) = {name:"IncellParam4" layout:LAYOUT_INCELL}]; // 参数列表4 - map incell_param_5_map = 9 [(tableau.field) = {name:"IncellParam5" layout:LAYOUT_INCELL prop:{range:"1,100"}}]; // 参数列表5 + int32 param1 = 2 [(tableau.field) = {name:"Param1"}]; // 参数1 + int32 param2 = 3 [(tableau.field) = {name:"Param2"}]; // 参数2 + int32 param3 = 4 [(tableau.field) = {name:"Param3"}]; // 参数3 + repeated int32 incell_param1_list = 5 [(tableau.field) = {name:"IncellParam1" layout:LAYOUT_INCELL prop:{range:"1,50"}}]; // 参数列表1 + repeated int32 incell_param2_list = 6 [(tableau.field) = {name:"IncellParam2" layout:LAYOUT_INCELL}]; // 参数列表2 + repeated int32 incell_param3_list = 7 [(tableau.field) = {name:"IncellParam3" layout:LAYOUT_INCELL}]; // 参数列表3 + map incell_param4_map = 8 [(tableau.field) = {name:"IncellParam4" layout:LAYOUT_INCELL}]; // 参数列表4 + map incell_param5_map = 9 [(tableau.field) = {name:"IncellParam5" layout:LAYOUT_INCELL prop:{range:"1,100"}}]; // 参数列表5 int32 target = 10 [(tableau.field) = {name:"Target" prop:{range:"~,10"}}]; // 目标 } Info bonus_info = 7 [(tableau.field) = {name:"BonusInfo" span:SPAN_INNER_CELL}]; // 津贴信息 diff --git a/test/functest/proto/default/excel__fieldprop__field_prop.proto b/test/functest/proto/default/excel__fieldprop__field_prop.proto index 766bf357..2c2befc7 100644 --- a/test/functest/proto/default/excel__fieldprop__field_prop.proto +++ b/test/functest/proto/default/excel__fieldprop__field_prop.proto @@ -30,7 +30,7 @@ message FieldProp { string color = 1 [(tableau.field) = {name:"Color"}]; // Appearance color string shape = 2 [(tableau.field) = {name:"Shape"}]; // Appearance shape } - int32 buff_id_1 = 4 [(tableau.field) = {name:"BuffID1"}, json_name="buff_id_1"]; // Buff ID 1 + int32 buff_id1 = 4 [(tableau.field) = {name:"BuffID1"}, json_name="buff_id_1"]; // Buff ID 1 repeated uint32 award_id_list = 5 [(tableau.field) = {name:"AwardID" layout:LAYOUT_INCELL prop:{refer:"ItemConf.ID"}}]; // Award ID list uint32 optional_bonus_id = 6 [(tableau.field) = {name:"OptionalBonusID" prop:{refer:"ItemConf.ID"}}]; // Optional bonus ID protoconf.Item incell_struct = 7 [(tableau.field) = {name:"IncellStruct" span:SPAN_INNER_CELL}]; // Incell struct @@ -40,8 +40,8 @@ message FieldProp { message FieldPropForm { option (tableau.worksheet) = {name:"FieldPropForm"}; - protoconf.Transform transform_1 = 1 [(tableau.field) = {name:"Transform1" span:SPAN_INNER_CELL prop:{form:FORM_TEXT}}]; // Box transform1 - protoconf.Transform transform_2 = 2 [(tableau.field) = {name:"Transform2" span:SPAN_INNER_CELL prop:{form:FORM_JSON}}]; // Box transform2 + protoconf.Transform transform1 = 1 [(tableau.field) = {name:"Transform1" span:SPAN_INNER_CELL prop:{form:FORM_TEXT}}]; // Box transform1 + protoconf.Transform transform2 = 2 [(tableau.field) = {name:"Transform2" span:SPAN_INNER_CELL prop:{form:FORM_JSON}}]; // Box transform2 } message FieldPropOrder { diff --git a/test/functest/proto/default/excel__metasheet__merger_1.proto b/test/functest/proto/default/excel__metasheet__merger1.proto similarity index 100% rename from test/functest/proto/default/excel__metasheet__merger_1.proto rename to test/functest/proto/default/excel__metasheet__merger1.proto diff --git a/test/functest/proto/default/excel__metasheet__scatter_1.proto b/test/functest/proto/default/excel__metasheet__scatter1.proto similarity index 100% rename from test/functest/proto/default/excel__metasheet__scatter_1.proto rename to test/functest/proto/default/excel__metasheet__scatter1.proto diff --git a/test/functest/proto/default/excel__metasheet__sheet_mode.proto b/test/functest/proto/default/excel__metasheet__sheet_mode.proto index e193cd4c..4a140222 100644 --- a/test/functest/proto/default/excel__metasheet__sheet_mode.proto +++ b/test/functest/proto/default/excel__metasheet__sheet_mode.proto @@ -198,8 +198,8 @@ message SimpleTarget { oneof value { option (tableau.oneof) = {field:"Field"}; - PVP pvp = 1; // Bound to enum value: TYPE_PVP. - PVE pve = 2; // Bound to enum value: TYPE_PVE. + Pvp pvp = 1; // Bound to enum value: TYPE_PVP. + Pve pve = 2; // Bound to enum value: TYPE_PVE. Skill skill = 3; // Bound to enum value: TYPE_SKILL. } @@ -210,12 +210,12 @@ message SimpleTarget { TYPE_SKILL = 3 [(tableau.evalue).name = "AliasSkill"]; // AliasSkill } - message PVP { + message Pvp { uint32 id = 1 [(tableau.field) = {name:"ID"}]; // Note int64 damage = 2 [(tableau.field) = {name:"Damage"}]; // Note protoconf.FruitType type = 3 [(tableau.field) = {name:"Type"}]; // Note } - message PVE { + message Pve { repeated uint32 hero_list = 1 [(tableau.field) = {name:"Hero" layout:LAYOUT_INCELL}]; // Note map dungeon_map = 2 [(tableau.field) = {name:"Dungeon" layout:LAYOUT_INCELL}]; // Note } @@ -232,8 +232,8 @@ message TaskTarget { oneof value { option (tableau.oneof) = {field:"Field"}; - PVP pvp = 1; // Bound to enum value: TYPE_PVP. - PVE pve = 2; // Bound to enum value: TYPE_PVE. + Pvp pvp = 1; // Bound to enum value: TYPE_PVP. + Pve pve = 2; // Bound to enum value: TYPE_PVE. Story story = 3; // Bound to enum value: TYPE_STORY. Hobby hobby = 4; // Bound to enum value: TYPE_HOBBY. Skill skill = 5; // Bound to enum value: TYPE_SKILL. @@ -250,12 +250,12 @@ message TaskTarget { TYPE_EMPTY = 6 [(tableau.evalue).name = "AliasEmpty"]; // AliasEmpty } - message PVP { + message Pvp { uint32 id = 1 [(tableau.field) = {name:"ID"}]; // Note int64 damage = 2 [(tableau.field) = {name:"Damage"}]; // Note repeated protoconf.FruitType type_list = 3 [(tableau.field) = {name:"Type" layout:LAYOUT_INCELL}]; // Note } - message PVE { + message Pve { Mission mission = 1 [(tableau.field) = {name:"Mission" span:SPAN_INNER_CELL}]; // Note message Mission { uint32 id = 1 [(tableau.field) = {name:"ID"}]; @@ -349,8 +349,8 @@ message BattleTarget { oneof value { option (tableau.oneof) = {note:"BattleTarget note" field:"Field"}; - PVP pvp = 1; // Bound to enum value: TYPE_PVP. - PVE pve = 2; // Bound to enum value: TYPE_PVE. + Pvp pvp = 1; // Bound to enum value: TYPE_PVP. + Pve pve = 2; // Bound to enum value: TYPE_PVE. } enum Type { @@ -359,11 +359,11 @@ message BattleTarget { TYPE_PVE = 2 [(tableau.evalue).name = "BattlePVE"]; // BattlePVE } - message PVP { + message Pvp { int32 battle_id = 1 [(tableau.field) = {name:"BattleID"}]; int64 damage = 2 [(tableau.field) = {name:"Damage"}]; } - message PVE { + message Pve { repeated int32 hero_id_list = 1 [(tableau.field) = {name:"HeroID" layout:LAYOUT_INCELL}]; map dungeon_map = 2 [(tableau.field) = {name:"Dungeon" layout:LAYOUT_INCELL}]; Boss boss = 3 [(tableau.field) = {name:"Boss" span:SPAN_INNER_CELL}]; diff --git a/test/functest/proto/default/excel__union__union.proto b/test/functest/proto/default/excel__union__union.proto index 9eb88b74..af69c69f 100644 --- a/test/functest/proto/default/excel__union__union.proto +++ b/test/functest/proto/default/excel__union__union.proto @@ -18,8 +18,8 @@ message PredefinedIncellUnion { map task_map = 1 [(tableau.field) = {key:"ID" layout:LAYOUT_VERTICAL}]; message Task { int32 id = 1 [(tableau.field) = {name:"ID"}]; // ID - union.Target target_1 = 2 [(tableau.field) = {name:"Target1" span:SPAN_INNER_CELL prop:{form:FORM_TEXT}}]; // Target1 - union.Target target_2 = 3 [(tableau.field) = {name:"Target2" span:SPAN_INNER_CELL prop:{form:FORM_JSON}}]; // Target2 + union.Target target1 = 2 [(tableau.field) = {name:"Target1" span:SPAN_INNER_CELL prop:{form:FORM_TEXT}}]; // Target1 + union.Target target2 = 3 [(tableau.field) = {name:"Target2" span:SPAN_INNER_CELL prop:{form:FORM_JSON}}]; // Target2 int32 progress = 4 [(tableau.field) = {name:"Progress"}]; // Progress } } diff --git a/test/functest/proto/default/excel__wellknown__well_known.proto b/test/functest/proto/default/excel__wellknown__well_known.proto index 291c1631..5d02b26c 100644 --- a/test/functest/proto/default/excel__wellknown__well_known.proto +++ b/test/functest/proto/default/excel__wellknown__well_known.proto @@ -39,8 +39,8 @@ message WellKnownTypeDatetime { message WellKnownTypeDuration { option (tableau.worksheet) = {name:"WellKnownTypeDuration"}; - google.protobuf.Duration duration_1 = 1 [(tableau.field) = {name:"Duration1"}]; // Duration 1 - google.protobuf.Duration duration_2 = 2 [(tableau.field) = {name:"Duration2"}]; // Duration 2 + google.protobuf.Duration duration1 = 1 [(tableau.field) = {name:"Duration1"}]; // Duration 1 + google.protobuf.Duration duration2 = 2 [(tableau.field) = {name:"Duration2"}]; // Duration 2 repeated google.protobuf.Duration duration_list = 3 [(tableau.field) = {name:"Duration" layout:LAYOUT_INCELL}]; // Duration } diff --git a/test/functest/proto/default/xml__list__list.proto b/test/functest/proto/default/xml__list__list.proto index 25f39460..1ec22f27 100644 --- a/test/functest/proto/default/xml__list__list.proto +++ b/test/functest/proto/default/xml__list__list.proto @@ -17,7 +17,7 @@ message ListConf { Parent parent = 1 [(tableau.field) = {name:"Parent"}]; message Parent { - Parent2 parent_2 = 1 [(tableau.field) = {name:"Parent2"}]; + Parent2 parent2 = 1 [(tableau.field) = {name:"Parent2"}]; message Parent2 { repeated CrossCell cross_cell_list = 1 [(tableau.field) = {name:"CrossCell"}]; message CrossCell { @@ -36,7 +36,7 @@ message ListConf2 { Parent parent = 1 [(tableau.field) = {name:"Parent"}]; message Parent { - Parent2 parent_2 = 1 [(tableau.field) = {name:"Parent2"}]; + Parent2 parent2 = 1 [(tableau.field) = {name:"Parent2"}]; message Parent2 { Incell incell = 1 [(tableau.field) = {name:"Incell"}]; message Incell { diff --git a/test/functest/proto/default/xml__struct.proto b/test/functest/proto/default/xml__struct.proto index 20421c31..75b9fa73 100644 --- a/test/functest/proto/default/xml__struct.proto +++ b/test/functest/proto/default/xml__struct.proto @@ -20,7 +20,7 @@ message XMLStructConf { uint32 id = 1 [(tableau.field) = {name:"ID"}]; string name = 2 [(tableau.field) = {name:"Name"}]; } - OtherItem item_2 = 2 [(tableau.field) = {name:"Item2"}]; + OtherItem item2 = 2 [(tableau.field) = {name:"Item2"}]; message OtherItem { uint32 id = 1 [(tableau.field) = {name:"ID"}]; string name = 2 [(tableau.field) = {name:"Name"}]; diff --git a/test/functest/proto/default/yaml__struct.proto b/test/functest/proto/default/yaml__struct.proto index 9f5e8e85..ceee132f 100644 --- a/test/functest/proto/default/yaml__struct.proto +++ b/test/functest/proto/default/yaml__struct.proto @@ -34,7 +34,7 @@ message YamlStructConf { uint32 id = 1 [(tableau.field) = {name:"ID"}]; int32 num = 2 [(tableau.field) = {name:"Num"}]; } - InplaceIncellItem incell_struct_2 = 6 [(tableau.field) = {name:"IncellStruct2" span:SPAN_INNER_CELL}]; + InplaceIncellItem incell_struct2 = 6 [(tableau.field) = {name:"IncellStruct2" span:SPAN_INNER_CELL}]; message InplaceIncellItem { uint32 id = 1 [(tableau.field) = {name:"ID"}]; int32 num = 2 [(tableau.field) = {name:"Num"}]; diff --git a/test/functest/util.go b/test/functest/util.go index f81f6064..6ee8ed2d 100644 --- a/test/functest/util.go +++ b/test/functest/util.go @@ -81,7 +81,6 @@ func genProto(logLevel, logMode string) error { FileOptions: map[string]string{ "go_package": `"github.com/tableauio/tableau/test/functest/protoconf"`, }, - EnumValueWithPrefix: true, }, }, ),