Skip to content

Commit

Permalink
fix: fix weird case transforms like FTLName -> fTLName (#901)
Browse files Browse the repository at this point in the history
This should now be determinstically mapped to upper/lower camel for
types and functions/values respectively.

Note that this casing differs from native Go casing in that
initialisms/acronyms are never all-caps. That is, FTL casing is: Ftl not
FTL.

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
  • Loading branch information
alecthomas and github-actions[bot] authored Feb 8, 2024
1 parent d83db73 commit be2bda1
Show file tree
Hide file tree
Showing 26 changed files with 424 additions and 59 deletions.
3 changes: 2 additions & 1 deletion backend/schema/protobuf.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import (
"strconv"
"strings"

"github.com/iancoleman/strcase"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"

"github.com/TBD54566975/ftl/backend/schema/strcase"
)

var typesWithRuntime = map[string]bool{
Expand Down
164 changes: 164 additions & 0 deletions backend/schema/strcase/case.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// Package strcase provides programming case conversion functions for strings.
//
// These case conversion functions are used to deterministically convert strings
// to various programming cases.
package strcase

// NOTE: This code is from https://github.com/fatih/camelcase. MIT license.

import (
"strings"
"unicode"
"unicode/utf8"
)

func title(s string) string {
r, n := utf8.DecodeRuneInString(s)
return string(unicode.ToTitle(r)) + strings.ToLower(s[n:])
}

func ToLowerCamel(s string) string {
parts := split(s)
for i := range parts {
parts[i] = title(parts[i])
}
return strings.ToLower(parts[0]) + strings.Join(parts[1:], "")
}

func ToUpperCamel(s string) string {
parts := split(s)
for i := range parts {
parts[i] = title(parts[i])
}
return strings.Join(parts, "")
}

func ToLowerSnake(s string) string {
parts := split(s)
out := make([]string, 0, len(parts)*2)
for i := range parts {
if parts[i] == "_" {
continue
}
out = append(out, strings.ToLower(parts[i]))
}
return strings.Join(out, "_")
}

func ToUpperSnake(s string) string {
parts := split(s)
out := make([]string, 0, len(parts)*2)
for i := range parts {
if parts[i] == "_" {
continue
}
out = append(out, strings.ToUpper(parts[i]))
}
return strings.Join(out, "_")
}

func ToLowerKebab(s string) string {
parts := split(s)
out := make([]string, 0, len(parts)*2)
for i := range parts {
if parts[i] == "-" || parts[i] == "_" {
continue
}
out = append(out, strings.ToLower(parts[i]))
}
return strings.Join(out, "-")
}

func ToUpperKebab(s string) string {
parts := split(s)
out := make([]string, 0, len(parts)*2)
for i := range parts {
if parts[i] == "-" || parts[i] == "_" {
continue
}
out = append(out, strings.ToUpper(parts[i]))
}
return strings.Join(out, "-")
}

// Splits a camelcase word and returns a list of words. It also
// supports digits. Both lower camel case and upper camel case are supported.
// For more info please check: http://en.wikipedia.org/wiki/CamelCase
//
// Examples
//
// "" => [""]
// "lowercase" => ["lowercase"]
// "Class" => ["Class"]
// "MyClass" => ["My", "Class"]
// "MyC" => ["My", "C"]
// "HTML" => ["HTML"]
// "PDFLoader" => ["PDF", "Loader"]
// "AString" => ["A", "String"]
// "SimpleXMLParser" => ["Simple", "XML", "Parser"]
// "vimRPCPlugin" => ["vim", "RPC", "Plugin"]
// "GL11Version" => ["GL", "11", "Version"]
// "99Bottles" => ["99", "Bottles"]
// "May5" => ["May", "5"]
// "BFG9000" => ["BFG", "9000"]
// "BöseÜberraschung" => ["Böse", "Überraschung"]
// "Two spaces" => ["Two", " ", "spaces"]
// "BadUTF8\xe2\xe2\xa1" => ["BadUTF8\xe2\xe2\xa1"]
//
// Splitting rules
//
// 1. If string is not valid UTF-8, return it without splitting as
// single item array.
// 2. Assign all unicode characters into one of 4 sets: lower case
// letters, upper case letters, numbers, and all other characters.
// 3. Iterate through characters of string, introducing splits
// between adjacent characters that belong to different sets.
// 4. Iterate through array of split strings, and if a given string
// is upper case:
// if subsequent string is lower case:
// move last character of upper case string to beginning of
// lower case string
func split(src string) (entries []string) {
// don't split invalid utf8
if !utf8.ValidString(src) {
return []string{src}
}
entries = []string{}
var runes [][]rune
lastClass := 0
// split into fields based on class of unicode character
for _, r := range src {
var class int
switch {
case unicode.IsLower(r):
class = 1
case unicode.IsUpper(r):
class = 2
case unicode.IsDigit(r):
class = 3
default:
class = 4
}
if class == lastClass {
runes[len(runes)-1] = append(runes[len(runes)-1], r)
} else {
runes = append(runes, []rune{r})
}
lastClass = class
}
// handle upper case -> lower case sequences, e.g.
// "PDFL", "oader" -> "PDF", "Loader"
for i := 0; i < len(runes)-1; i++ {
if unicode.IsUpper(runes[i][0]) && unicode.IsLower(runes[i+1][0]) {
runes[i+1] = append([]rune{runes[i][len(runes[i])-1]}, runes[i+1]...)
runes[i] = runes[i][:len(runes[i])-1]
}
}
// construct []string from results
for _, s := range runes {
if len(s) > 0 {
entries = append(entries, string(s))
}
}
return entries
}
191 changes: 191 additions & 0 deletions backend/schema/strcase/case_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package strcase

import (
"testing"

"github.com/alecthomas/assert/v2"
)

func TestCamelCase(t *testing.T) {
for _, tt := range []struct {
input string
expected []string
}{
{"lowercase", []string{"lowercase"}},
{"Class", []string{"Class"}},
{"MyClass", []string{"My", "Class"}},
{"MyC", []string{"My", "C"}},
{"HTML", []string{"HTML"}},
{"PDFLoader", []string{"PDF", "Loader"}},
{"AString", []string{"A", "String"}},
{"SimpleXMLParser", []string{"Simple", "XML", "Parser"}},
{"vimRPCPlugin", []string{"vim", "RPC", "Plugin"}},
{"GL11Version", []string{"GL", "11", "Version"}},
{"99Bottles", []string{"99", "Bottles"}},
{"May5", []string{"May", "5"}},
{"BFG9000", []string{"BFG", "9000"}},
{"BöseÜberraschung", []string{"Böse", "Überraschung"}},
{"Two spaces", []string{"Two", " ", "spaces"}},
{"BadUTF8\xe2\xe2\xa1", []string{"BadUTF8\xe2\xe2\xa1"}},
{"snake_case", []string{"snake", "_", "case"}},
} {
actual := split(tt.input)
assert.Equal(t, tt.expected, actual, "camelCase(%q) = %v; want %v", tt.input, actual, tt.expected)
}
}

func TestLowerCamelCase(t *testing.T) {
for _, tt := range []struct {
input string
expected string
}{
{"lowercase", "lowercase"},
{"Class", "class"},
{"MyClass", "myClass"},
{"MyC", "myC"},
{"HTML", "html"},
{"PDFLoader", "pdfLoader"},
{"AString", "aString"},
{"SimpleXMLParser", "simpleXmlParser"},
{"vimRPCPlugin", "vimRpcPlugin"},
{"GL11Version", "gl11Version"},
{"99Bottles", "99Bottles"},
{"May5", "may5"},
{"BFG9000", "bfg9000"},
{"BöseÜberraschung", "böseÜberraschung"},
{"snake_case", "snake_Case"},
} {
actual := ToLowerCamel(tt.input)
assert.Equal(t, tt.expected, actual, "LowerCamelCase(%q) = %v; want %v", tt.input, actual, tt.expected)
}
}

func TestUpperCamelCase(t *testing.T) {
for _, tt := range []struct {
input string
expected string
}{
{"lowercase", "Lowercase"},
{"Class", "Class"},
{"MyClass", "MyClass"},
{"MyC", "MyC"},
{"HTML", "Html"},
{"PDFLoader", "PdfLoader"},
{"AString", "AString"},
{"SimpleXMLParser", "SimpleXmlParser"},
{"vimRPCPlugin", "VimRpcPlugin"},
{"GL11Version", "Gl11Version"},
{"99Bottles", "99Bottles"},
{"May5", "May5"},
{"BFG9000", "Bfg9000"},
{"BöseÜberraschung", "BöseÜberraschung"},
{"snake_case", "Snake_Case"},
} {
actual := ToUpperCamel(tt.input)
assert.Equal(t, tt.expected, actual, "UpperCamelCase(%q) = %v; want %v", tt.input, actual, tt.expected)
}
}

func TestLowerSnake(t *testing.T) {
for _, tt := range []struct {
input string
expected string
}{
{"lowercase", "lowercase"},
{"Class", "class"},
{"MyClass", "my_class"},
{"MyC", "my_c"},
{"HTML", "html"},
{"PDFLoader", "pdf_loader"},
{"AString", "a_string"},
{"SimpleXMLParser", "simple_xml_parser"},
{"vimRPCPlugin", "vim_rpc_plugin"},
{"GL11Version", "gl_11_version"},
{"99Bottles", "99_bottles"},
{"May5", "may_5"},
{"BFG9000", "bfg_9000"},
{"BöseÜberraschung", "böse_überraschung"},
{"snake_case", "snake_case"},
} {
actual := ToLowerSnake(tt.input)
assert.Equal(t, tt.expected, actual, "LowerSnakeCase(%q) = %v; want %v", tt.input, actual, tt.expected)
}
}

func TestUpperSnake(t *testing.T) {
for _, tt := range []struct {
input string
expected string
}{
{"lowercase", "LOWERCASE"},
{"Class", "CLASS"},
{"MyClass", "MY_CLASS"},
{"MyC", "MY_C"},
{"HTML", "HTML"},
{"PDFLoader", "PDF_LOADER"},
{"AString", "A_STRING"},
{"SimpleXMLParser", "SIMPLE_XML_PARSER"},
{"vimRPCPlugin", "VIM_RPC_PLUGIN"},
{"GL11Version", "GL_11_VERSION"},
{"99Bottles", "99_BOTTLES"},
{"May5", "MAY_5"},
{"BFG9000", "BFG_9000"},
{"BöseÜberraschung", "BÖSE_ÜBERRASCHUNG"},
{"snake_case", "SNAKE_CASE"},
} {
actual := ToUpperSnake(tt.input)
assert.Equal(t, tt.expected, actual, "UpperSnakeCase(%q) = %v; want %v", tt.input, actual, tt.expected)
}
}

func TestLowerKebabCase(t *testing.T) {
for _, tt := range []struct {
input string
expected string
}{
{"lowercase", "lowercase"},
{"Class", "class"},
{"MyClass", "my-class"},
{"MyC", "my-c"},
{"HTML", "html"},
{"PDFLoader", "pdf-loader"},
{"AString", "a-string"},
{"SimpleXMLParser", "simple-xml-parser"},
{"vimRPCPlugin", "vim-rpc-plugin"},
{"GL11Version", "gl-11-version"},
{"99Bottles", "99-bottles"},
{"May5", "may-5"},
{"BFG9000", "bfg-9000"},
{"BöseÜberraschung", "böse-überraschung"},
{"snake_case", "snake-case"},
} {
actual := ToLowerKebab(tt.input)
assert.Equal(t, tt.expected, actual, "LowerKebabCase(%q) = %v; want %v", tt.input, actual, tt.expected)
}
}

func TestUpperKebabCase(t *testing.T) {
for _, tt := range []struct {
input string
expected string
}{
{"lowercase", "LOWERCASE"},
{"Class", "CLASS"},
{"MyClass", "MY-CLASS"},
{"MyC", "MY-C"},
{"HTML", "HTML"},
{"PDFLoader", "PDF-LOADER"},
{"AString", "A-STRING"},
{"SimpleXMLParser", "SIMPLE-XML-PARSER"},
{"vimRPCPlugin", "VIM-RPC-PLUGIN"},
{"GL11Version", "GL-11-VERSION"},
{"99Bottles", "99-BOTTLES"},
{"May5", "MAY-5"},
{"BFG9000", "BFG-9000"},
{"BöseÜberraschung", "BÖSE-ÜBERRASCHUNG"},
{"snake_case", "SNAKE-CASE"},
} {
actual := ToUpperKebab(tt.input)
assert.Equal(t, tt.expected, actual, "UpperKebabCase(%q) = %v; want %v", tt.input, actual, tt.expected)
}
}
12 changes: 6 additions & 6 deletions cmd/ftl/cmd_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ import (

"github.com/TBD54566975/scaffolder"
"github.com/beevik/etree"
"github.com/iancoleman/strcase"

"github.com/TBD54566975/ftl/backend/common/exec"
"github.com/TBD54566975/ftl/backend/common/log"
"github.com/TBD54566975/ftl/backend/schema"
"github.com/TBD54566975/ftl/backend/schema/strcase"
goruntime "github.com/TBD54566975/ftl/go-runtime"
"github.com/TBD54566975/ftl/internal"
kotlinruntime "github.com/TBD54566975/ftl/kotlin-runtime"
Expand Down Expand Up @@ -144,12 +144,12 @@ func scaffold(hermit bool, source *zip.Reader, destination string, ctx any, opti
}

var scaffoldFuncs = template.FuncMap{
"snake": strcase.ToSnake,
"screamingSnake": strcase.ToScreamingSnake,
"camel": strcase.ToCamel,
"snake": strcase.ToLowerSnake,
"screamingSnake": strcase.ToUpperSnake,
"camel": strcase.ToUpperCamel,
"lowerCamel": strcase.ToLowerCamel,
"kebab": strcase.ToKebab,
"screamingKebab": strcase.ToScreamingKebab,
"kebab": strcase.ToLowerKebab,
"screamingKebab": strcase.ToUpperKebab,
"upper": strings.ToUpper,
"lower": strings.ToLower,
"title": strings.Title,
Expand Down
Loading

0 comments on commit be2bda1

Please sign in to comment.