diff --git a/backend/schema/any.go b/backend/schema/any.go index 446b2e5f7..7fac26ec6 100644 --- a/backend/schema/any.go +++ b/backend/schema/any.go @@ -20,3 +20,4 @@ func (*Any) schemaType() {} func (*Any) schemaDecl() {} func (*Any) String() string { return "Any" } func (a *Any) ToProto() proto.Message { return &schemapb.Any{Pos: posToProto(a.Pos)} } +func (*Any) GetName() string { return "" } diff --git a/backend/schema/bool.go b/backend/schema/bool.go index b11d133df..c369141b2 100644 --- a/backend/schema/bool.go +++ b/backend/schema/bool.go @@ -21,3 +21,4 @@ func (*Bool) schemaType() {} func (*Bool) schemaDecl() {} func (*Bool) String() string { return "Bool" } func (b *Bool) ToProto() proto.Message { return &schemapb.Bool{Pos: posToProto(b.Pos)} } +func (*Bool) GetName() string { return "" } diff --git a/backend/schema/bytes.go b/backend/schema/bytes.go index 792ef1064..8af87cade 100644 --- a/backend/schema/bytes.go +++ b/backend/schema/bytes.go @@ -21,3 +21,4 @@ func (*Bytes) schemaType() {} func (*Bytes) schemaDecl() {} func (*Bytes) String() string { return "Bytes" } func (b *Bytes) ToProto() proto.Message { return &schemapb.Bytes{Pos: posToProto(b.Pos)} } +func (*Bytes) GetName() string { return "" } diff --git a/backend/schema/data.go b/backend/schema/data.go index e534b303e..3b51f549e 100644 --- a/backend/schema/data.go +++ b/backend/schema/data.go @@ -132,6 +132,8 @@ func (d *Data) schemaChildren() []Node { return children } +func (d *Data) GetName() string { return d.Name } + func (d *Data) String() string { w := &strings.Builder{} fmt.Fprint(w, encodeComments(d.Comments)) diff --git a/backend/schema/database.go b/backend/schema/database.go index aab4e757b..2315ab6fd 100644 --- a/backend/schema/database.go +++ b/backend/schema/database.go @@ -35,6 +35,9 @@ func (d *Database) ToProto() proto.Message { Comments: d.Comments, } } + +func (d *Database) GetName() string { return d.Name } + func DatabaseFromProto(s *schemapb.Database) *Database { return &Database{ Pos: posFromProto(s.Pos), diff --git a/backend/schema/enum.go b/backend/schema/enum.go index e47387903..06d80a07e 100644 --- a/backend/schema/enum.go +++ b/backend/schema/enum.go @@ -51,6 +51,8 @@ func (e *Enum) ToProto() proto.Message { } } +func (e *Enum) GetName() string { return e.Name } + func EnumFromProto(s *schemapb.Enum) *Enum { return &Enum{ Pos: posFromProto(s.Pos), diff --git a/backend/schema/float.go b/backend/schema/float.go index 8f5b7679f..e0d3f05a0 100644 --- a/backend/schema/float.go +++ b/backend/schema/float.go @@ -21,3 +21,4 @@ func (*Float) schemaType() {} func (*Float) schemaDecl() {} func (*Float) String() string { return "Float" } func (f *Float) ToProto() proto.Message { return &schemapb.Float{Pos: posToProto(f.Pos)} } +func (*Float) GetName() string { return "" } diff --git a/backend/schema/int.go b/backend/schema/int.go index 51fb82168..2a15bf89d 100644 --- a/backend/schema/int.go +++ b/backend/schema/int.go @@ -21,3 +21,4 @@ func (*Int) schemaChildren() []Node { return nil } func (*Int) schemaType() {} func (*Int) String() string { return "Int" } func (i *Int) ToProto() proto.Message { return &schemapb.Int{Pos: posToProto(i.Pos)} } +func (*Int) GetName() string { return "" } diff --git a/backend/schema/metadatacalls.go b/backend/schema/metadatacalls.go index 4e6f1577d..b6a5970e1 100644 --- a/backend/schema/metadatacalls.go +++ b/backend/schema/metadatacalls.go @@ -35,7 +35,7 @@ func (m *MetadataCalls) String() string { w += len(str) fmt.Fprint(out, str) } - fmt.Fprintln(out) + fmt.Fprint(out) return out.String() } diff --git a/backend/schema/module.go b/backend/schema/module.go index fa62c25f4..166185c35 100644 --- a/backend/schema/module.go +++ b/backend/schema/module.go @@ -188,6 +188,8 @@ func (m *Module) ToProto() proto.Message { } } +func (m *Module) GetName() string { return m.Name } + // ModuleFromProtoFile loads a module from the given proto-encoded file. func ModuleFromProtoFile(filename string) (*Module, error) { data, err := os.ReadFile(filename) diff --git a/backend/schema/parser.go b/backend/schema/parser.go index 031397a82..a0542bde0 100644 --- a/backend/schema/parser.go +++ b/backend/schema/parser.go @@ -123,6 +123,7 @@ type Value interface { //sumtype:decl type Decl interface { Node + GetName() string schemaDecl() } diff --git a/backend/schema/schema_test.go b/backend/schema/schema_test.go index b743145e7..bf1a2ce3e 100644 --- a/backend/schema/schema_test.go +++ b/backend/schema/schema_test.go @@ -40,7 +40,6 @@ module todo { verb create(todo.CreateRequest) todo.CreateResponse +calls todo.destroy - verb destroy(builtin.HttpRequest) builtin.HttpResponse +ingress http GET /todo/destroy/{id} } diff --git a/backend/schema/string.go b/backend/schema/string.go index 19b747603..4181a78b3 100644 --- a/backend/schema/string.go +++ b/backend/schema/string.go @@ -21,3 +21,4 @@ func (*String) schemaType() {} func (*String) schemaDecl() {} func (*String) String() string { return "String" } func (s *String) ToProto() proto.Message { return &schemapb.String{Pos: posToProto(s.Pos)} } +func (*String) GetName() string { return "" } diff --git a/backend/schema/time.go b/backend/schema/time.go index 9e47db2e4..450ea23e7 100644 --- a/backend/schema/time.go +++ b/backend/schema/time.go @@ -21,3 +21,4 @@ func (*Time) schemaType() {} func (*Time) schemaDecl() {} func (*Time) String() string { return "Time" } func (t *Time) ToProto() proto.Message { return &schemapb.Time{Pos: posToProto(t.Pos)} } +func (*Time) GetName() string { return "" } diff --git a/backend/schema/typeparameter.go b/backend/schema/typeparameter.go index d6d9bb266..6dead5139 100644 --- a/backend/schema/typeparameter.go +++ b/backend/schema/typeparameter.go @@ -21,6 +21,7 @@ func (t *TypeParameter) ToProto() protoreflect.ProtoMessage { } func (t *TypeParameter) schemaChildren() []Node { return nil } func (t *TypeParameter) schemaDecl() {} +func (t *TypeParameter) GetName() string { return t.Name } func typeParametersToSchema(s []*schemapb.TypeParameter) []*TypeParameter { var out []*TypeParameter diff --git a/backend/schema/unit.go b/backend/schema/unit.go index 630fbca52..b84dcdd0e 100644 --- a/backend/schema/unit.go +++ b/backend/schema/unit.go @@ -21,3 +21,4 @@ func (u *Unit) schemaDecl() {} func (u *Unit) String() string { return "Unit" } func (u *Unit) ToProto() protoreflect.ProtoMessage { return &schemapb.Unit{Pos: posToProto(u.Pos)} } func (u *Unit) schemaChildren() []Node { return nil } +func (u *Unit) GetName() string { return "" } diff --git a/backend/schema/validate.go b/backend/schema/validate.go index 74f787397..46b7f241c 100644 --- a/backend/schema/validate.go +++ b/backend/schema/validate.go @@ -294,6 +294,16 @@ func ValidateModule(module *Module) error { return next() }) merr = cleanErrors(merr) + sort.SliceStable(module.Decls, func(i, j int) bool { + iDecl := module.Decls[i] + jDecl := module.Decls[j] + iType := reflect.TypeOf(iDecl).String() + jType := reflect.TypeOf(jDecl).String() + if iType == jType { + return iDecl.GetName() < jDecl.GetName() + } + return iType < jType + }) return errors.Join(merr...) } diff --git a/backend/schema/verb.go b/backend/schema/verb.go index 0bd193897..01dcf5383 100644 --- a/backend/schema/verb.go +++ b/backend/schema/verb.go @@ -43,6 +43,9 @@ func (v *Verb) schemaChildren() []Node { } return children } + +func (v *Verb) GetName() string { return v.Name } + func (v *Verb) String() string { w := &strings.Builder{} fmt.Fprint(w, encodeComments(v.Comments)) diff --git a/buildengine/build_go_test.go b/buildengine/build_go_test.go new file mode 100644 index 000000000..0b7d617ad --- /dev/null +++ b/buildengine/build_go_test.go @@ -0,0 +1,89 @@ +package buildengine + +import ( + "testing" + + "github.com/TBD54566975/ftl/backend/schema" +) + +func TestGenerateGoModule(t *testing.T) { + sch := &schema.Schema{ + Modules: []*schema.Module{ + schema.Builtins(), + {Name: "other", Decls: []schema.Decl{ + &schema.Enum{ + Name: "Color", + Type: &schema.String{}, + Variants: []*schema.EnumVariant{ + {Name: "Red", Value: &schema.StringValue{Value: "Red"}}, + {Name: "Blue", Value: &schema.StringValue{Value: "Blue"}}, + {Name: "Green", Value: &schema.StringValue{Value: "Green"}}, + }, + }, + &schema.Enum{ + Name: "ColorInt", + Type: &schema.Int{}, + Variants: []*schema.EnumVariant{ + {Name: "RedInt", Value: &schema.IntValue{Value: 0}}, + {Name: "BlueInt", Value: &schema.IntValue{Value: 1}}, + {Name: "GreenInt", Value: &schema.IntValue{Value: 2}}, + }, + }, + &schema.Data{Name: "EchoRequest"}, + &schema.Data{Name: "EchoResponse"}, + &schema.Verb{ + Name: "echo", + Request: &schema.DataRef{Name: "EchoRequest"}, + Response: &schema.DataRef{Name: "EchoResponse"}, + }, + }}, + {Name: "test"}, + }, + } + expected := `// Code generated by FTL. DO NOT EDIT. + +//ftl:module other +package other + +import ( + "context" +) + +var _ = context.Background + +//ftl:enum +type Color string +const ( + Red Color = "Red" + Blue Color = "Blue" + Green Color = "Green" +) + +//ftl:enum +type ColorInt int +const ( + RedInt ColorInt = 0 + BlueInt ColorInt = 1 + GreenInt ColorInt = 2 +) + +type EchoRequest struct { +} + +type EchoResponse struct { +} + +//ftl:verb +func Echo(context.Context, EchoRequest) (EchoResponse, error) { + panic("Verb stubs should not be called directly, instead use github.com/TBD54566975/ftl/runtime-go/ftl.Call()") +} +` + bctx := buildContext{ + moduleDir: "testdata/modules/another", + buildDir: "_ftl", + sch: sch, + } + testBuild(t, bctx, []assertion{ + assertGeneratedModule("go/modules/other/external_module.go", expected), + }) +} diff --git a/buildengine/build_kotlin.go b/buildengine/build_kotlin.go index d2abd506b..f1d3da87f 100644 --- a/buildengine/build_kotlin.go +++ b/buildengine/build_kotlin.go @@ -158,36 +158,7 @@ var scaffoldFuncs = scaffolder.FuncMap{ "imports": func(m *schema.Module) []string { imports := sets.NewSet[string]() _ = schema.Visit(m, func(n schema.Node, next func() error) error { - switch n := n.(type) { - case *schema.DataRef: - decl := m.Resolve(schema.Ref{ - Module: n.Module, - Name: n.Name, - }) - if decl != nil { - if data, ok := decl.Decl.(*schema.Data); ok { - if len(data.Fields) == 0 { - imports.Add("ftl.builtin.Empty") - break - } - } - } - - if n.Module == "" { - break - } - - imports.Add("ftl." + n.Module + "." + n.Name) - - for _, tp := range n.TypeParameters { - tpRef, err := schema.ParseDataRef(tp.String()) - if err != nil { - return err - } - if tpRef.Module != "" && tpRef.Module != m.Name { - imports.Add("ftl." + tpRef.Module + "." + tpRef.Name) - } - } + switch n.(type) { case *schema.Verb: imports.Append("xyz.block.ftl.Context", "xyz.block.ftl.Ignore", "xyz.block.ftl.Verb") @@ -214,12 +185,15 @@ func genType(module *schema.Module, t schema.Type) string { if decl != nil { if data, ok := decl.Decl.(*schema.Data); ok { if len(data.Fields) == 0 { - return "Empty" + return "ftl.builtin.Empty" } } } desc := t.Name + if t.Module != "" { + desc = "ftl." + t.Module + "." + desc + } if len(t.TypeParameters) > 0 { desc += "<" for i, tp := range t.TypeParameters { diff --git a/buildengine/build_kotlin_test.go b/buildengine/build_kotlin_test.go index f5c3cf840..ade45db03 100644 --- a/buildengine/build_kotlin_test.go +++ b/buildengine/build_kotlin_test.go @@ -1,31 +1,42 @@ package buildengine import ( - "context" - "os" - "path/filepath" "testing" - "github.com/alecthomas/assert/v2" - "github.com/TBD54566975/ftl/backend/schema" - "github.com/TBD54566975/ftl/internal/log" ) func TestGenerateBasicModule(t *testing.T) { sch := &schema.Schema{ - Modules: []*schema.Module{{Name: "test"}}, + Modules: []*schema.Module{ + schema.Builtins(), + {Name: "test"}, + }, } expected := `// Code generated by FTL. DO NOT EDIT. package ftl.test ` - assertExpectedSchema(t, sch, "test/Test.kt", expected) + bctx := buildContext{ + moduleDir: "testdata/modules/echokotlin", + buildDir: "target", + sch: sch, + } + testBuild(t, bctx, []assertion{ + assertGeneratedModule("generated-sources/ftl/test/Test.kt", expected), + }) } func TestGenerateAllTypes(t *testing.T) { sch := &schema.Schema{ Modules: []*schema.Module{ + schema.Builtins(), + { + Name: "other", + Decls: []schema.Decl{ + &schema.Data{Name: "TestRequest", Fields: []*schema.Field{{Name: "field", Type: &schema.Int{}}}}, + }, + }, { Name: "test", Comments: []string{"Module comments"}, @@ -37,6 +48,7 @@ func TestGenerateAllTypes(t *testing.T) { {Name: "t", Type: &schema.DataRef{Name: "T"}}, }, }, + &schema.Data{Name: "TestRequest", Fields: []*schema.Field{{Name: "field", Type: &schema.Int{}}}}, &schema.Data{ Name: "TestResponse", Comments: []string{"Response comments"}, @@ -72,7 +84,7 @@ func TestGenerateAllTypes(t *testing.T) { {Name: "any", Type: &schema.Any{}}, {Name: "parameterizedDataRef", Type: &schema.DataRef{ Name: "ParamTestData", - TypeParameters: []schema.Type{&schema.DataRef{Name: "T"}}, + TypeParameters: []schema.Type{&schema.String{}}, }, }, {Name: "withAlias", Type: &schema.String{}, Metadata: []schema.Metadata{&schema.MetadataAlias{Alias: "a"}}}, @@ -90,14 +102,16 @@ func TestGenerateAllTypes(t *testing.T) { */ package ftl.test -import ftl.other.TestRequest -import ftl.test.TestRequest import java.time.OffsetDateTime data class ParamTestData( val t: T, ) +data class TestRequest( + val field: Long, +) + /** * Response comments */ @@ -111,24 +125,32 @@ data class TestResponse( val optional: String? = null, val array: List, val nestedArray: List>, - val dataRefArray: List, + val dataRefArray: List, val map: Map, val nestedMap: Map>, val dataRef: TestRequest, - val externalDataRef: TestRequest, + val externalDataRef: ftl.other.TestRequest, val any: Any, - val parameterizedDataRef: ParamTestData, + val parameterizedDataRef: ParamTestData, val withAlias: String, val unit: Unit, ) ` - assertExpectedSchema(t, sch, "test/Test.kt", expected) + bctx := buildContext{ + moduleDir: "testdata/modules/echokotlin", + buildDir: "target", + sch: sch, + } + testBuild(t, bctx, []assertion{ + assertGeneratedModule("generated-sources/ftl/test/Test.kt", expected), + }) } func TestGenerateAllVerbs(t *testing.T) { sch := &schema.Schema{ Modules: []*schema.Module{ + schema.Builtins(), { Name: "test", Comments: []string{"Module comments"}, @@ -156,7 +178,6 @@ func TestGenerateAllVerbs(t *testing.T) { */ package ftl.test -import ftl.builtin.Empty import xyz.block.ftl.Context import xyz.block.ftl.Ignore import xyz.block.ftl.Verb @@ -170,10 +191,17 @@ data class Request( */ @Verb @Ignore -fun testVerb(context: Context, req: Request): Empty = throw +fun testVerb(context: Context, req: Request): ftl.builtin.Empty = throw NotImplementedError("Verb stubs should not be called directly, instead use context.call(::testVerb, ...)") ` - assertExpectedSchema(t, sch, "test/Test.kt", expected) + bctx := buildContext{ + moduleDir: "testdata/modules/echokotlin", + buildDir: "target", + sch: sch, + } + testBuild(t, bctx, []assertion{ + assertGeneratedModule("generated-sources/ftl/test/Test.kt", expected), + }) } func TestGenerateBuiltins(t *testing.T) { @@ -212,12 +240,20 @@ data class HttpResponse( class Empty ` - assertExpectedSchema(t, sch, "builtin/Builtin.kt", expected) + bctx := buildContext{ + moduleDir: "testdata/modules/echokotlin", + buildDir: "target", + sch: sch, + } + testBuild(t, bctx, []assertion{ + assertGeneratedModule("generated-sources/ftl/builtin/Builtin.kt", expected), + }) } func TestGenerateEmptyDataRefs(t *testing.T) { sch := &schema.Schema{ Modules: []*schema.Module{ + schema.Builtins(), { Name: "test", Decls: []schema.Decl{ @@ -236,35 +272,21 @@ func TestGenerateEmptyDataRefs(t *testing.T) { expected := `// Code generated by FTL. DO NOT EDIT. package ftl.test -import ftl.builtin.Empty import xyz.block.ftl.Context import xyz.block.ftl.Ignore import xyz.block.ftl.Verb @Verb @Ignore -fun emptyVerb(context: Context, req: Empty): Empty = throw +fun emptyVerb(context: Context, req: ftl.builtin.Empty): ftl.builtin.Empty = throw NotImplementedError("Verb stubs should not be called directly, instead use context.call(::emptyVerb, ...)") ` - assertExpectedSchema(t, sch, "test/Test.kt", expected) -} - -func assertExpectedSchema(t *testing.T, sch *schema.Schema, outputPath string, expectedContent string) { - t.Helper() - ctx := log.ContextWithLogger(context.Background(), log.Configure(os.Stderr, log.Config{})) - module, err := LoadModule(ctx, "testdata/modules/echokotlin") - assert.NoError(t, err) - - err = generateExternalModules(ctx, module, sch) - assert.NoError(t, err) - - target := filepath.Join("testdata/modules/echokotlin", "target") - output := filepath.Join(target, "generated-sources", "ftl", outputPath) - - fileContent, err := os.ReadFile(output) - assert.NoError(t, err) - assert.Equal(t, expectedContent, string(fileContent)) - - err = os.RemoveAll(target) - assert.NoError(t, err, "Error removing target directory") + bctx := buildContext{ + moduleDir: "testdata/modules/echokotlin", + buildDir: "target", + sch: sch, + } + testBuild(t, bctx, []assertion{ + assertGeneratedModule("generated-sources/ftl/test/Test.kt", expected), + }) } diff --git a/buildengine/build_test.go b/buildengine/build_test.go new file mode 100644 index 000000000..e959980e6 --- /dev/null +++ b/buildengine/build_test.go @@ -0,0 +1,55 @@ +package buildengine + +import ( + "context" + "github.com/alecthomas/assert/v2" + "os" + "path/filepath" + "testing" + + "github.com/TBD54566975/ftl/backend/schema" + "github.com/TBD54566975/ftl/internal/log" +) + +type buildContext struct { + moduleDir string + buildDir string + sch *schema.Schema +} + +type assertion func(t testing.TB, bctx buildContext) error + +func testBuild( + t *testing.T, + bctx buildContext, + assertions []assertion, +) { + t.Helper() + ctx := log.ContextWithLogger(context.Background(), log.Configure(os.Stderr, log.Config{})) + module, err := LoadModule(ctx, bctx.moduleDir) + assert.NoError(t, err) + + err = Build(ctx, bctx.sch, module) + assert.NoError(t, err) + + for _, a := range assertions { + err = a(t, bctx) + assert.NoError(t, err) + } + + err = os.RemoveAll(bctx.buildDir) + assert.NoError(t, err, "Error removing build directory") +} + +func assertGeneratedModule(generatedModulePath string, expectedContent string) assertion { + return func(t testing.TB, bctx buildContext) error { + t.Helper() + target := filepath.Join(bctx.moduleDir, bctx.buildDir) + output := filepath.Join(target, generatedModulePath) + + fileContent, err := os.ReadFile(output) + assert.NoError(t, err) + assert.Equal(t, expectedContent, string(fileContent)) + return nil + } +} diff --git a/buildengine/testdata/modules/echokotlin/pom.xml b/buildengine/testdata/modules/echokotlin/pom.xml new file mode 100644 index 000000000..3945bbad4 --- /dev/null +++ b/buildengine/testdata/modules/echokotlin/pom.xml @@ -0,0 +1,185 @@ + + + 4.0.0 + + ftl + echo + 1.0-SNAPSHOT + + + 1.0-SNAPSHOT + 1.8 + 1.9.22 + true + ${java.version} + ${java.version} + + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + xyz.block + ftl-runtime + ${ftl.version} + + + org.postgresql + postgresql + 42.7.2 + + + + + + + + kotlin-maven-plugin + org.jetbrains.kotlin + ${kotlin.version} + + + compile + + compile + + + + ${project.basedir}/src/main/kotlin + + + + + test-compile + + test-compile + + + + ${project.basedir}/src/test/kotlin + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.6.1 + + + + copy-dependencies + compile + + copy-dependencies + + + ${project.build.directory}/dependency + runtime + + + + + build-classpath + compile + + build-classpath + + + ${project.build.directory}/classpath.txt + dependency + + + + build-classpath-property + compile + + build-classpath + + + generated.classpath + ${project.build.directory}/dependency + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.5.0 + + + generate-sources + + add-source + + + + ${project.build.directory}/generated-sources/ftl + + + + + + + com.github.ozsie + detekt-maven-plugin + 1.23.5 + + true + ${generated.classpath} + ${java.version} + ${java.home} + ${project.build.directory}/detekt.yml + + + ${project.build.directory}/dependency/ftl-runtime-${ftl.version}.jar + + + ${project.basedir}/src/main/kotlin,${project.build.directory}/generated-sources + + + + compile + + check-with-type-resolution + + + + + + xyz.block + ftl-runtime + ${ftl.version} + + + + + + + + kotlin-maven-plugin + org.jetbrains.kotlin + + + org.apache.maven.plugins + maven-dependency-plugin + + + + org.codehaus.mojo + build-helper-maven-plugin + + + + com.github.ozsie + detekt-maven-plugin + + + + \ No newline at end of file diff --git a/buildengine/testdata/modules/echokotlin/src/main/kotlin/ftl/echo/Echo.kt b/buildengine/testdata/modules/echokotlin/src/main/kotlin/ftl/echo/Echo.kt index aa3fc31de..08f08bebf 100644 --- a/buildengine/testdata/modules/echokotlin/src/main/kotlin/ftl/echo/Echo.kt +++ b/buildengine/testdata/modules/echokotlin/src/main/kotlin/ftl/echo/Echo.kt @@ -1,6 +1,5 @@ -package ftl.echo2 +package ftl.echo -import ftl.test.TestResponse import xyz.block.ftl.Context import xyz.block.ftl.Method import xyz.block.ftl.Verb diff --git a/examples/kotlin/time/pom.xml b/examples/kotlin/time/pom.xml index e82227baf..86748ecee 100644 --- a/examples/kotlin/time/pom.xml +++ b/examples/kotlin/time/pom.xml @@ -3,7 +3,7 @@ 4.0.0 ftl - echo2 + time 1.0-SNAPSHOT diff --git a/go-runtime/compile/build.go b/go-runtime/compile/build.go index 21700a187..487d48183 100644 --- a/go-runtime/compile/build.go +++ b/go-runtime/compile/build.go @@ -192,6 +192,15 @@ var scaffoldFuncs = scaffolder.FuncMap{ }) return imports }, + "value": func(v schema.Value) string { + switch t := v.(type) { + case *schema.StringValue: + return fmt.Sprintf("%q", t.Value) + case *schema.IntValue: + return fmt.Sprintf("%d", t.Value) + } + panic(fmt.Sprintf("unsupported value %T", v)) + }, } func genType(module *schema.Module, t schema.Type) string { diff --git a/go-runtime/compile/external-module-template/_ftl/go/modules/{{ range .NonMainModules }}{{ push .Name . }}{{ end }}/external_module.go b/go-runtime/compile/external-module-template/_ftl/go/modules/{{ range .NonMainModules }}{{ push .Name . }}{{ end }}/external_module.go index 0ade747c2..55ba14072 100644 --- a/go-runtime/compile/external-module-template/_ftl/go/modules/{{ range .NonMainModules }}{{ push .Name . }}{{ end }}/external_module.go +++ b/go-runtime/compile/external-module-template/_ftl/go/modules/{{ range .NonMainModules }}{{ push .Name . }}{{ end }}/external_module.go @@ -13,7 +13,16 @@ import ( var _ = context.Background {{- range .Decls }} -{{if is "Data" . }} +{{if is "Enum" . }} +{{$enumName := .Name -}} +//ftl:enum +type {{.Name|title}} {{ type $ .Type }} +const ( + {{- range .Variants }} + {{.Name|title}} {{$enumName}} = {{.Value|value}} + {{- end}} +) +{{- else if is "Data" . }} type {{.Name|title}} {{- if .TypeParameters}}[ {{- range $i, $tp := .TypeParameters}} @@ -24,7 +33,7 @@ type {{.Name|title}} {{.Name|title}} {{type $ .Type}} `json:"{{.Name}}"` {{- end}} } -{{- else if is "Verb" .}} +{{- else if is "Verb" . -}} {{.Comments|comment }} {{if .Comments}}// {{end -}} diff --git a/go-runtime/compile/parser.go b/go-runtime/compile/parser.go index c1abbf9b8..66aadfe8f 100644 --- a/go-runtime/compile/parser.go +++ b/go-runtime/compile/parser.go @@ -60,12 +60,21 @@ type directiveModule struct { func (*directiveModule) directive() {} func (d *directiveModule) String() string { return "ftl:module" } +type directiveEnum struct { + Pos lexer.Position + + Enum bool `parser:"@'enum'"` +} + +func (*directiveEnum) directive() {} +func (d *directiveEnum) String() string { return "ftl:enum" } + var directiveParser = participle.MustBuild[directiveWrapper]( participle.Lexer(schema.Lexer), participle.Elide("Whitespace"), participle.Unquote(), participle.UseLookahead(2), - participle.Union[directive](&directiveVerb{}, &directiveIngress{}, &directiveModule{}), + participle.Union[directive](&directiveVerb{}, &directiveIngress{}, &directiveModule{}, &directiveEnum{}), participle.Union[schema.IngressPathComponent](&schema.IngressPathLiteral{}, &schema.IngressPathParameter{}), ) diff --git a/go-runtime/compile/schema.go b/go-runtime/compile/schema.go index 516cf1614..f7d116de3 100644 --- a/go-runtime/compile/schema.go +++ b/go-runtime/compile/schema.go @@ -8,10 +8,12 @@ import ( "go/types" "path" "reflect" + "strconv" "strings" "sync" "unicode" + "golang.org/x/exp/maps" "golang.org/x/tools/go/ast/astutil" "golang.org/x/tools/go/packages" @@ -36,6 +38,8 @@ var ( // NativeNames is a map of top-level declarations to their native Go names. type NativeNames map[schema.Decl]string +type enums map[string]*schema.Enum + // ExtractModuleSchema statically parses Go FTL module source into a schema.Module. func ExtractModuleSchema(dir string) (NativeNames, *schema.Module, error) { pkgs, err := packages.Load(&packages.Config{ @@ -58,7 +62,7 @@ func ExtractModuleSchema(dir string) (NativeNames, *schema.Module, error) { merr = append(merr, fmt.Errorf("%s: %w", pkg.PkgPath, perr)) } } - pctx := &parseContext{pkg: pkg, pkgs: pkgs, module: module, nativeNames: NativeNames{}} + pctx := &parseContext{pkg: pkg, pkgs: pkgs, module: module, nativeNames: NativeNames{}, enums: enums{}} for _, file := range pkg.Syntax { var verb *schema.Verb err := goast.Visit(file, func(node ast.Node, next func() error) (err error) { @@ -93,6 +97,11 @@ func ExtractModuleSchema(dir string) (NativeNames, *schema.Module, error) { verb = nil return nil + case *ast.GenDecl: + if err = visitGenDecl(pctx, node); err != nil { + return err + } + case nil: default: } @@ -105,6 +114,9 @@ func ExtractModuleSchema(dir string) (NativeNames, *schema.Module, error) { for decl, nativeName := range pctx.nativeNames { nativeNames[decl] = nativeName } + for _, e := range maps.Values(pctx.enums) { + pctx.module.Decls = append(pctx.module.Decls, e) + } } if len(merr) > 0 { return nil, nil, errors.Join(merr...) @@ -224,6 +236,103 @@ func goPosToSchemaPos(pos token.Pos) schema.Position { return schema.Position{Filename: p.Filename, Line: p.Line, Column: p.Column, Offset: p.Offset} } +func visitGenDecl(pctx *parseContext, node *ast.GenDecl) error { + switch node.Tok { + case token.TYPE: + if node.Doc == nil { + return nil + } + directives, err := parseDirectives(fset, node.Doc) + if err != nil { + return err + } + for _, dir := range directives { + switch dir.(type) { + case *directiveEnum: + enum := &schema.Enum{ + Pos: goPosToSchemaPos(node.Pos()), + Comments: visitComments(node.Doc), + } + if len(node.Specs) != 1 { + return fmt.Errorf("%s: error parsing ftl enum %s: expected exactly one type spec", + goPosToSchemaPos(node.Pos()), enum.Name) + } + if t, ok := node.Specs[0].(*ast.TypeSpec); ok { + pctx.enums[t.Name.Name] = enum + err := visitTypeSpec(pctx, t) + if err != nil { + return err + } + } + case *directiveIngress, *directiveModule, *directiveVerb: + } + } + return nil + case token.CONST: + var typ ast.Expr + for i, s := range node.Specs { + if v, ok := s.(*ast.ValueSpec); ok { + // In an iota enum, only the first value has a type. + // Hydrate this to subsequent values so we can associate them with the enum. + if i == 0 && isIotaEnum(v) { + typ = v.Type + } else if v.Type == nil { + v.Type = typ + } + err := visitValueSpec(pctx, v) + if err != nil { + return err + } + } + } + return nil + default: + return nil + } +} + +func visitTypeSpec(pctx *parseContext, node *ast.TypeSpec) error { + enum := pctx.enums[node.Name.Name] + if enum == nil { + return nil + } + typ, err := visitType(pctx, node, pctx.pkg.TypesInfo.TypeOf(node.Type)) + if err != nil { + return err + } + enum.Name = strcase.ToUpperCamel(node.Name.Name) + enum.Type = typ + pctx.nativeNames[enum] = node.Name.Name + return nil +} + +func visitValueSpec(pctx *parseContext, node *ast.ValueSpec) error { + var enum *schema.Enum + if i, ok := node.Type.(*ast.Ident); ok { + enum = pctx.enums[i.Name] + } + if enum == nil { + return nil + } + + if c, ok := pctx.pkg.TypesInfo.Defs[node.Names[0]].(*types.Const); ok { + value, err := visitConst(c) + if err != nil { + return err + } + variant := &schema.EnumVariant{ + Pos: goPosToSchemaPos(c.Pos()), + Name: strcase.ToUpperCamel(c.Id()), + Value: value, + } + enum.Variants = append(enum.Variants, variant) + return nil + } + + return fmt.Errorf("%s: could not extract enum %s: expected exactly one variant name", + goPosToSchemaPos(node.Pos()), enum.Name) +} + func visitFuncDecl(pctx *parseContext, node *ast.FuncDecl) (verb *schema.Verb, err error) { if node.Doc == nil { return nil, nil @@ -236,6 +345,8 @@ func visitFuncDecl(pctx *parseContext, node *ast.FuncDecl) (verb *schema.Verb, e isVerb := false for _, dir := range directives { switch dir := dir.(type) { + case *directiveEnum: + case *directiveModule: case *directiveVerb: @@ -450,12 +561,52 @@ func visitStruct(pctx *parseContext, node ast.Node, tnode types.Type) (*schema.D return dataRef, nil } +func visitConst(cnode *types.Const) (schema.Value, error) { + if b, ok := cnode.Type().Underlying().(*types.Basic); ok { + switch b.Kind() { + case types.String: + value, err := strconv.Unquote(cnode.Val().String()) + if err != nil { + return nil, err + } + return &schema.StringValue{Pos: goPosToSchemaPos(cnode.Pos()), Value: value}, nil + + case types.Int, types.Int64: + value, err := strconv.ParseInt(cnode.Val().String(), 10, 64) + if err != nil { + return nil, err + } + return &schema.IntValue{Pos: goPosToSchemaPos(cnode.Pos()), Value: int(value)}, nil + default: + return nil, fmt.Errorf("%s: unsupported basic type %s", goPosToSchemaPos(cnode.Pos()), + b) + } + } + return nil, fmt.Errorf("%s: unsupported const type %s", goPosToSchemaPos(cnode.Pos()), cnode.Type()) +} + func visitType(pctx *parseContext, node ast.Node, tnode types.Type) (schema.Type, error) { if tparam, ok := tnode.(*types.TypeParam); ok { return &schema.DataRef{Pos: goPosToSchemaPos(node.Pos()), Name: tparam.Obj().Id()}, nil } switch underlying := tnode.Underlying().(type) { case *types.Basic: + // If this type is named and declared in another module, it's a reference. The only basic-typed references + // supported are enums. + if named, ok := tnode.(*types.Named); ok { + nodePath := named.Obj().Pkg().Path() + if !strings.HasPrefix(nodePath, pctx.pkg.PkgPath) { + base := path.Dir(pctx.pkg.PkgPath) + destModule := path.Base(strings.TrimPrefix(nodePath, base+"/")) + enumRef := &schema.EnumRef{ + Pos: goPosToSchemaPos(node.Pos()), + Module: destModule, + Name: named.Obj().Name(), + } + return enumRef, nil + } + } + switch underlying.Kind() { case types.String: return &schema.String{Pos: goPosToSchemaPos(node.Pos())}, nil @@ -596,6 +747,7 @@ type parseContext struct { pkgs []*packages.Package module *schema.Module nativeNames NativeNames + enums enums } // pathEnclosingInterval returns the PackageInfo and ast.Node that @@ -629,3 +781,19 @@ func tokenFileContainsPos(f *token.File, pos token.Pos) bool { base := f.Base() return base <= p && p < base+f.Size() } + +func isIotaEnum(node ast.Node) bool { + switch t := node.(type) { + case *ast.ValueSpec: + if len(t.Values) != 1 { + return false + } + return isIotaEnum(t.Values[0]) + case *ast.Ident: + return t.Name == "iota" + case *ast.BinaryExpr: + return isIotaEnum(t.X) || isIotaEnum(t.Y) + default: + return false + } +} diff --git a/go-runtime/compile/schema_test.go b/go-runtime/compile/schema_test.go index c8e8c668b..6f7b8cd36 100644 --- a/go-runtime/compile/schema_test.go +++ b/go-runtime/compile/schema_test.go @@ -33,11 +33,38 @@ func TestExtractModuleSchema(t *testing.T) { time Time user two.User +alias json "u" bytes Bytes + enumRef two.TwoEnum } data Resp { } + enum Color(String) { + Red("Red") + Blue("Blue") + Green("Green") + Yellow("Yellow") + } + + enum ColorInt(Int) { + RedInt(0) + BlueInt(1) + GreenInt(2) + YellowInt(3) + } + + enum IotaExpr(Int) { + First(1) + Second(3) + Third(5) + } + + enum SimpleIota(Int) { + Zero(0) + One(1) + Two(2) + } + verb verb(one.Req) one.Resp } ` @@ -52,12 +79,17 @@ func TestExtractModuleSchemaTwo(t *testing.T) { data Payload { body T } - - verb two(two.Payload) two.Payload + + enum TwoEnum(String) { + Red("Red") + Blue("Blue") + Green("Green") + } verb callsTwo(two.Payload) two.Payload +calls two.two + verb two(two.Payload) two.Payload } ` assert.Equal(t, normaliseString(expected), normaliseString(actual.String())) diff --git a/go-runtime/compile/testdata/one/one.go b/go-runtime/compile/testdata/one/one.go index ed59a5013..78c6e51fe 100644 --- a/go-runtime/compile/testdata/one/one.go +++ b/go-runtime/compile/testdata/one/one.go @@ -9,6 +9,42 @@ import ( "github.com/TBD54566975/ftl/go-runtime/ftl" ) +//ftl:enum +type Color string + +const ( + Red Color = "Red" + Blue Color = "Blue" + Green Color = "Green" +) + +//ftl:enum +type ColorInt int + +const ( + RedInt ColorInt = 0 + BlueInt ColorInt = 1 + GreenInt ColorInt = 2 +) + +//ftl:enum +type SimpleIota int + +const ( + Zero SimpleIota = iota + One + Two +) + +//ftl:enum +type IotaExpr int + +const ( + First IotaExpr = iota*2 + 1 + Second + Third +) + type Nested struct { } @@ -24,6 +60,7 @@ type Req struct { Time time.Time User two.User `json:"u"` Bytes []byte + EnumRef two.TwoEnum } type Resp struct{} @@ -31,3 +68,7 @@ type Resp struct{} func Verb(ctx context.Context, req Req) (Resp, error) { return Resp{}, nil } + +const Yellow Color = "Yellow" + +const YellowInt ColorInt = 3 diff --git a/go-runtime/compile/testdata/two/two.go b/go-runtime/compile/testdata/two/two.go index 9edc45b76..dd9919c69 100644 --- a/go-runtime/compile/testdata/two/two.go +++ b/go-runtime/compile/testdata/two/two.go @@ -7,6 +7,15 @@ import ( "github.com/TBD54566975/ftl/go-runtime/ftl" ) +//ftl:enum +type TwoEnum string + +const ( + Red TwoEnum = "Red" + Blue TwoEnum = "Blue" + Green TwoEnum = "Green" +) + type User struct { Name string }