diff --git a/backend/schema/visit.go b/backend/schema/visit.go index 56c29716e5..fef5849914 100644 --- a/backend/schema/visit.go +++ b/backend/schema/visit.go @@ -12,6 +12,18 @@ func Visit(n Node, visit func(n Node, next func() error) error) error { }) } +// VisitWithParent all nodes in the schema providing the parent node when visiting its schema children. +func VisitWithParent(n Node, parent Node, visit func(n Node, parent Node, next func() error) error) error { + return visit(n, parent, func() error { + for _, child := range n.schemaChildren() { + if err := VisitWithParent(child, n, visit); err != nil { + return err + } + } + return nil + }) +} + // VisitExcludingMetadataChildren visits all nodes in the schema except the children of metadata nodes. // This is used when generating external modules to avoid adding imports only referenced in the bodies of // stubbed verbs. diff --git a/go-runtime/compile/build.go b/go-runtime/compile/build.go index ef81eec123..5718f7b7d7 100644 --- a/go-runtime/compile/build.go +++ b/go-runtime/compile/build.go @@ -14,7 +14,6 @@ import ( "strings" "unicode" - "github.com/TBD54566975/ftl/go-runtime/schema/finalize" sets "github.com/deckarep/golang-set/v2" gomaps "golang.org/x/exp/maps" "golang.org/x/mod/modfile" @@ -620,23 +619,24 @@ func getExternalTypeEnums(module *schema.Module, sch *schema.Schema) []externalE // // TODO: once migrated off of the legacy extractor, we can inline `extract.Extract(dir)` and delete this // function -func ExtractModuleSchema(dir string, sch *schema.Schema) (finalize.Result, error) { +func ExtractModuleSchema(dir string, sch *schema.Schema) (extract.Result, error) { result, err := extract.Extract(dir) if err != nil { - return finalize.Result{}, err + return extract.Result{}, err } // merge with legacy results for now if err = legacyExtractModuleSchema(dir, sch, &result); err != nil { - return finalize.Result{}, err + return extract.Result{}, err } schema.SortErrorsByPosition(result.Errors) - if !schema.ContainsTerminalError(result.Errors) { - err = schema.ValidateModule(result.Module) - if err != nil { - return finalize.Result{}, err - } + if schema.ContainsTerminalError(result.Errors) { + return result, nil + } + err = schema.ValidateModule(result.Module) + if err != nil { + return extract.Result{}, err } updateVisibility(result.Module) return result, nil diff --git a/go-runtime/compile/schema.go b/go-runtime/compile/schema.go index 951bafa372..96e92f9060 100644 --- a/go-runtime/compile/schema.go +++ b/go-runtime/compile/schema.go @@ -16,10 +16,9 @@ import ( "github.com/alecthomas/types/optional" "golang.org/x/exp/maps" - "github.com/TBD54566975/ftl/go-runtime/schema/finalize" - "github.com/TBD54566975/ftl/backend/schema" "github.com/TBD54566975/ftl/backend/schema/strcase" + extract "github.com/TBD54566975/ftl/go-runtime/schema" "github.com/TBD54566975/ftl/internal/goast" "github.com/TBD54566975/golang-tools/go/ast/astutil" "github.com/TBD54566975/golang-tools/go/packages" @@ -93,7 +92,7 @@ func (e errorSet) add(err *schema.Error) { e[err.Error()] = err } -func legacyExtractModuleSchema(dir string, sch *schema.Schema, out *finalize.Result) error { +func legacyExtractModuleSchema(dir string, sch *schema.Schema, out *extract.Result) error { pkgs, err := packages.Load(&packages.Config{ Dir: dir, Fset: fset, @@ -1467,7 +1466,7 @@ type parseContext struct { topicsByPos map[schema.Position]*schema.Topic } -func newParseContext(pkg *packages.Package, pkgs []*packages.Package, sch *schema.Schema, out *finalize.Result) *parseContext { +func newParseContext(pkg *packages.Package, pkgs []*packages.Package, sch *schema.Schema, out *extract.Result) *parseContext { if out.NativeNames == nil { out.NativeNames = NativeNames{} } diff --git a/go-runtime/compile/schema_test.go b/go-runtime/compile/schema_test.go index 211301901f..1965ffed58 100644 --- a/go-runtime/compile/schema_test.go +++ b/go-runtime/compile/schema_test.go @@ -10,12 +10,12 @@ import ( "strings" "testing" - "github.com/TBD54566975/ftl/go-runtime/schema/finalize" "github.com/TBD54566975/golang-tools/go/packages" "github.com/alecthomas/assert/v2" "github.com/alecthomas/participle/v2/lexer" "github.com/TBD54566975/ftl/backend/schema" + extract "github.com/TBD54566975/ftl/go-runtime/schema" "github.com/TBD54566975/ftl/internal/errors" "github.com/TBD54566975/ftl/internal/exec" "github.com/TBD54566975/ftl/internal/log" @@ -462,7 +462,7 @@ func TestParsedirectives(t *testing.T) { func TestParseTypesTime(t *testing.T) { timeRef := mustLoadRef("time", "Time").Type() - pctx := newParseContext(nil, []*packages.Package{}, &schema.Schema{}, &finalize.Result{Module: &schema.Module{}}) + pctx := newParseContext(nil, []*packages.Package{}, &schema.Schema{}, &extract.Result{Module: &schema.Module{}}) parsed, ok := visitType(pctx, token.NoPos, timeRef, false).Get() assert.True(t, ok) _, ok = parsed.(*schema.Time) @@ -509,55 +509,65 @@ func TestErrorReporting(t *testing.T) { assert.NoError(t, err) filename := filepath.Join(pwd, `testdata/failing/failing.go`) - actual := slices.Map(r.Errors, func(e *schema.Error) string { return strings.TrimPrefix(e.Error(), filename+":") }) + subFilename := filepath.Join(pwd, `testdata/failing/child/child.go`) + actual := slices.Map(r.Errors, func(e *schema.Error) string { + str := strings.ReplaceAll(e.Error(), filename+":", "") + str = strings.ReplaceAll(str, subFilename+":", "") + return str + }) expected := []string{ - `12:13-34: expected string literal for argument at index 0`, - `15:18-52: duplicate config declaration at 14:18-52`, - `18:18-52: duplicate secret declaration at 17:18-52`, - `21:14-44: duplicate database declaration at 20:14-44`, - `24:2-10: unsupported type "error" for field "BadParam"`, - `27:2-17: unsupported type "uint64" for field "AnotherBadParam"`, - `30:3-3: unexpected directive "ftl:export" attached for verb, did you mean to use '//ftl:verb export' instead?`, - `36:36-39: unsupported request type "ftl/failing.Request"`, - `36:50-50: unsupported response type "ftl/failing.Response"`, - `37:16-29: call first argument must be a function but is an unresolved reference to lib.OtherFunc`, - `37:16-29: call first argument must be a function in an ftl module`, - `38:2-46: call must have exactly three arguments`, - `39:16-25: call first argument must be a function in an ftl module`, - `44:1-2: must have at most two parameters (context.Context, struct)`, - `44:69-69: unsupported response type "ftl/failing.Response"`, - `49:22-27: first parameter must be of type context.Context but is ftl/failing.Request`, - `49:37-43: second parameter must be a struct but is string`, - `49:53-53: unsupported response type "ftl/failing.Response"`, - `54:43-47: second parameter must not be ftl.Unit`, - `54:59-59: unsupported response type "ftl/failing.Response"`, - `59:1-2: first parameter must be context.Context`, - `59:18-18: unsupported response type "ftl/failing.Response"`, - `64:1-2: must have at most two results (, error)`, - `64:41-44: unsupported request type "ftl/failing.Request"`, - `69:1-2: must at least return an error`, - `69:36-39: unsupported request type "ftl/failing.Request"`, - `73:35-38: unsupported request type "ftl/failing.Request"`, - `73:48-48: must return an error but is ftl/failing.Response`, - `78:41-44: unsupported request type "ftl/failing.Request"`, - `78:55-55: first result must be a struct but is string`, - `78:63-63: must return an error but is string`, - `78:63-63: second result must not be ftl.Unit`, - // `85:1-1: duplicate verb name "WrongResponse"`, TODO: fix - `89:3-3: unexpected directive "ftl:verb"`, - `103:2-24: cannot attach enum value to BadValueEnum because it is a variant of type enum TypeEnum, not a value enum`, - `110:2-41: cannot attach enum value to BadValueEnumOrderDoesntMatter because it is a variant of type enum TypeEnum, not a value enum`, - `123:21-60: config and secret names must be valid identifiers`, - `129:1-1: schema declaration contains conflicting directives`, - `129:1-26: only one directive expected when directive "ftl:enum" is present, found multiple`, - `129:1-26: only one directive expected when directive "ftl:typealias" is present, found multiple`, - `145:1-35: type can not be a variant of more than 1 type enums (TypeEnum1, TypeEnum2)`, - `151:27-27: enum discriminator "TypeEnum3" cannot contain exported methods`, - `154:1-35: enum discriminator "NoMethodsTypeEnum" must define at least one method`, - `166:3-14: unexpected token "d"`, - `173:2-62: can not publish directly to topics in other modules`, - `174:9-26: can not call verbs in other modules directly: use ftl.Call(…) instead`, - `179:2-12: struct field unexported must be exported by starting with an uppercase letter`, + // failing/child/child.go + `4:2-6: unsupported type "uint64" for field "Body"`, + + // failing/failing.go + `13:13-34: expected string literal for argument at index 0`, + `16:18-52: duplicate config declaration at 15:18-52`, + `19:18-52: duplicate secret declaration at 18:18-52`, + `22:14-44: duplicate database declaration at 21:14-44`, + `25:2-10: unsupported type "error" for field "BadParam"`, + `28:2-17: unsupported type "uint64" for field "AnotherBadParam"`, + `31:3-3: unexpected directive "ftl:export" attached for verb, did you mean to use '//ftl:verb export' instead?`, + `37:36-36: unsupported request type "ftl/failing.Request"`, + `37:50-50: unsupported response type "ftl/failing.Response"`, + `38:16-29: call first argument must be a function but is an unresolved reference to lib.OtherFunc`, + `38:16-29: call first argument must be a function in an ftl module`, + `39:2-46: call must have exactly three arguments`, + `40:16-25: call first argument must be a function in an ftl module`, + `45:1-2: must have at most two parameters (context.Context, struct)`, + `45:69-69: unsupported response type "ftl/failing.Response"`, + `50:22-27: first parameter must be of type context.Context but is ftl/failing.Request`, + `50:37-43: second parameter must be a struct but is string`, + `50:53-53: unsupported response type "ftl/failing.Response"`, + `55:43-47: second parameter must not be ftl.Unit`, + `55:59-59: unsupported response type "ftl/failing.Response"`, + `60:1-2: first parameter must be context.Context`, + `60:18-18: unsupported response type "ftl/failing.Response"`, + `65:1-2: must have at most two results (, error)`, + `65:41-41: unsupported request type "ftl/failing.Request"`, + `70:1-2: must at least return an error`, + `70:36-36: unsupported request type "ftl/failing.Request"`, + `74:35-35: unsupported request type "ftl/failing.Request"`, + `74:48-48: must return an error but is ftl/failing.Response`, + `79:41-41: unsupported request type "ftl/failing.Request"`, + `79:55-55: first result must be a struct but is string`, + `79:63-63: must return an error but is string`, + `79:63-63: second result must not be ftl.Unit`, + `86:1-2: duplicate declaration of "WrongResponse" at 79:6`, + `90:3-3: unexpected directive "ftl:verb"`, + `104:2-24: cannot attach enum value to BadValueEnum because it is a variant of type enum TypeEnum, not a value enum`, + `111:2-41: cannot attach enum value to BadValueEnumOrderDoesntMatter because it is a variant of type enum TypeEnum, not a value enum`, + `124:21-60: config and secret names must be valid identifiers`, + `130:1-1: schema declaration contains conflicting directives`, + `130:1-26: only one directive expected when directive "ftl:enum" is present, found multiple`, + `130:1-26: only one directive expected when directive "ftl:typealias" is present, found multiple`, + `146:1-35: type can not be a variant of more than 1 type enums (TypeEnum1, TypeEnum2)`, + `152:27-27: enum discriminator "TypeEnum3" cannot contain exported methods`, + `155:1-35: enum discriminator "NoMethodsTypeEnum" must define at least one method`, + `167:3-14: unexpected token "d"`, + `174:2-62: can not publish directly to topics in other modules`, + `175:9-26: can not call verbs in other modules directly: use ftl.Call(…) instead`, + `180:2-12: struct field unexported must be exported by starting with an uppercase letter`, + `184:6-6: unsupported type "ftl/failing/child.BadChildStruct" for field "child"`, } assert.Equal(t, expected, actual) } diff --git a/go-runtime/compile/testdata/failing/child/child.go b/go-runtime/compile/testdata/failing/child/child.go new file mode 100644 index 0000000000..b0294f36bc --- /dev/null +++ b/go-runtime/compile/testdata/failing/child/child.go @@ -0,0 +1,5 @@ +package child + +type BadChildStruct struct { + Body uint64 +} diff --git a/go-runtime/compile/testdata/failing/failing.go b/go-runtime/compile/testdata/failing/failing.go index 7205b8598d..b3f335208a 100644 --- a/go-runtime/compile/testdata/failing/failing.go +++ b/go-runtime/compile/testdata/failing/failing.go @@ -6,6 +6,7 @@ import ( lib "github.com/TBD54566975/ftl/go-runtime/compile/testdata" "github.com/TBD54566975/ftl/go-runtime/ftl" + "ftl/failing/child" ps "ftl/pubsub" ) @@ -178,3 +179,8 @@ func BadPublish(ctx context.Context) error { type UnexportedFieldStruct struct { unexported string } + +//ftl:data +type BadChildField struct { + Child child.BadChildStruct +} diff --git a/go-runtime/schema/common/common.go b/go-runtime/schema/common/common.go index e0dd7b53f9..028b990535 100644 --- a/go-runtime/schema/common/common.go +++ b/go-runtime/schema/common/common.go @@ -195,24 +195,6 @@ func ExtractType(pass *analysis.Pass, pos token.Pos, tnode types.Type) optional. } } -func InferDeclType(pass *analysis.Pass, node ast.Node, obj types.Object) optional.Option[schema.Decl] { - ts, ok := node.(*ast.TypeSpec) - if !ok { - return optional.None[schema.Decl]() - } - if _, ok := ts.Type.(*ast.InterfaceType); ok { - return optional.Some[schema.Decl](&schema.Enum{}) - } - t, ok := ExtractTypeForNode(pass, obj, ts.Type, nil).Get() - if !ok { - return optional.None[schema.Decl]() - } - if !IsSelfReference(pass, obj, t) { - return optional.Some[schema.Decl](&schema.TypeAlias{}) - } - return optional.Some[schema.Decl](&schema.Data{}) -} - func ExtractFuncForDecl(t schema.Decl) (ExtractDeclFunc[schema.Decl, ast.Node], error) { if f, ok := extractorRegistery.Load(reflect.TypeOf(t)); ok { return f, nil @@ -469,3 +451,7 @@ func isLocalRef(pass *analysis.Pass, ref *schema.Ref) bool { } return ref.Module == "" || ref.Module == moduleName } + +func GetNativeName(obj types.Object) string { + return obj.Pkg().Path() + "." + obj.Name() +} diff --git a/go-runtime/schema/common/fact.go b/go-runtime/schema/common/fact.go index f9b3ba98fb..ca3306d345 100644 --- a/go-runtime/schema/common/fact.go +++ b/go-runtime/schema/common/fact.go @@ -176,6 +176,20 @@ func GetFactsForObject[T SchemaFactValue](pass *analysis.Pass, obj types.Object) return facts } +func GetFacts[T SchemaFactValue](pass *analysis.Pass) map[types.Object]T { + facts := make(map[types.Object]T) + for _, fact := range allFactsForPass(pass) { + sf, ok := fact.Fact.(SchemaFact) + if !ok { + continue + } + if f, ok := sf.Get().(T); ok { + facts[fact.Object] = f + } + } + return facts +} + // GetFactForObject returns the first fact of the provided type marked on the object. func GetFactForObject[T SchemaFactValue](pass *analysis.Pass, obj types.Object) optional.Option[T] { for _, fact := range allFactsForPass(pass) { diff --git a/go-runtime/schema/extract.go b/go-runtime/schema/extract.go index 172a5c626e..ef85c8c6cf 100644 --- a/go-runtime/schema/extract.go +++ b/go-runtime/schema/extract.go @@ -2,6 +2,7 @@ package schema import ( "fmt" + "go/types" "golang.org/x/exp/maps" @@ -46,8 +47,18 @@ var Extractors = [][]*analysis.Analyzer{ }, } +// Result contains the final schema extraction result. +type Result struct { + // Module is the extracted module schema. + Module *schema.Module + // NativeNames maps schema nodes to their native Go names. + NativeNames map[schema.Node]string + // Errors is a list of errors encountered during schema extraction. + Errors []*schema.Error +} + // Extract statically parses Go FTL module source into a schema.Module -func Extract(moduleDir string) (finalize.Result, error) { +func Extract(moduleDir string) (Result, error) { pkgConfig := packages.Config{ Dir: moduleDir, Mode: packages.NeedName | packages.NeedFiles | packages.NeedSyntax | packages.NeedTypes | packages.NeedTypesInfo | packages.NeedImports, @@ -59,7 +70,7 @@ func Extract(moduleDir string) (finalize.Result, error) { } results, diagnostics, err := checker.Run(cConfig, analyzersWithDependencies()...) if err != nil { - return finalize.Result{}, err + return Result{}, err } return combineAllPackageResults(results, diagnostics) } @@ -82,45 +93,47 @@ func analyzersWithDependencies() []*analysis.Analyzer { // the run will produce finalizer results for all packages it executes on, so we need to aggregate the results into a // single schema -func combineAllPackageResults(results map[*analysis.Analyzer][]any, diagnostics []analysis.SimpleDiagnostic) (finalize.Result, error) { +func combineAllPackageResults(results map[*analysis.Analyzer][]any, diagnostics []analysis.SimpleDiagnostic) (Result, error) { fResults, ok := results[finalize.Analyzer] if !ok { - return finalize.Result{}, fmt.Errorf("schema extraction finalizer result not found") + return Result{}, fmt.Errorf("schema extraction finalizer result not found") } - combined := finalize.Result{ + combined := Result{ NativeNames: make(map[schema.Node]string), Errors: diagnosticsToSchemaErrors(diagnostics), } + failedRefs := make(map[schema.RefKey]types.Object) + extractedDecls := make(map[schema.Decl]types.Object) for _, r := range fResults { fr, ok := r.(finalize.Result) if !ok { - return finalize.Result{}, fmt.Errorf("unexpected schema extraction result type: %T", r) + return Result{}, fmt.Errorf("unexpected schema extraction result type: %T", r) } - if combined.Module == nil { - combined.Module = fr.Module + combined.Module = &schema.Module{Name: fr.ModuleName, Comments: fr.ModuleComments} } else { - if combined.Module.Name != fr.Module.Name { - return finalize.Result{}, fmt.Errorf("unexpected schema extraction result module name: %s", fr.Module.Name) + if combined.Module.Name != fr.ModuleName { + return Result{}, fmt.Errorf("unexpected schema extraction result module name: %s", fr.ModuleName) + } + if len(combined.Module.Comments) == 0 { + combined.Module.Comments = fr.ModuleComments } - combined.Module.AddDecls(fr.Module.Decls) } - maps.Copy(combined.NativeNames, fr.NativeNames) + maps.Copy(failedRefs, fr.Failed) + maps.Copy(extractedDecls, fr.Extracted) } + + combined.Module.AddDecls(maps.Keys(extractedDecls)) + for decl, obj := range extractedDecls { + combined.NativeNames[decl] = common.GetNativeName(obj) + } + combined.Errors = append(combined.Errors, propagateTypeErrors(combined.Module, failedRefs)...) schema.SortErrorsByPosition(combined.Errors) updateVisibility(combined.Module) // TODO: validate schema once we have the full schema here return combined, nil } -func dependenciesBeforeIndex(idx int) []*analysis.Analyzer { - var deps []*analysis.Analyzer - for i := range idx { - deps = append(deps, Extractors[i]...) - } - return deps -} - // updateVisibility traverses the module schema via refs and updates visibility as needed. func updateVisibility(module *schema.Module) { for _, d := range module.Decls { @@ -166,6 +179,43 @@ func updateTransitiveVisibility(d schema.Decl, module *schema.Module) { }) } +// propagateTypeErrors propagates type errors to referencing nodes. This improves error messaging for the LSP client by +// surfacing errors all the way up the schema chain. +func propagateTypeErrors(module *schema.Module, failedRefs map[schema.RefKey]types.Object) []*schema.Error { + var errs []*schema.Error + _ = schema.VisitWithParent(module, nil, func(n schema.Node, p schema.Node, next func() error) error { + if p == nil { + return next() + } + ref, ok := n.(*schema.Ref) + if !ok { + return next() + } + if obj, ok := failedRefs[ref.ToRefKey()]; ok { + refNativeName := common.GetNativeName(obj) + switch pt := p.(type) { + case *schema.Verb: + if pt.Request == n { + errs = append(errs, schema.Errorf(pt.Request.Position(), pt.Request.Position().Column, + "unsupported request type %q", refNativeName)) + } + if pt.Response == n { + errs = append(errs, schema.Errorf(pt.Response.Position(), pt.Response.Position().Column, + "unsupported response type %q", refNativeName)) + } + case *schema.Field: + errs = append(errs, schema.Errorf(pt.Position(), pt.Position().Column, "unsupported type %q for "+ + "field %q", refNativeName, pt.Name)) + default: + errs = append(errs, schema.Errorf(p.Position(), p.Position().Column, "unsupported type %q", + refNativeName)) + } + } + return next() + }) + return errs +} + func diagnosticsToSchemaErrors(diagnostics []analysis.SimpleDiagnostic) []*schema.Error { if len(diagnostics) == 0 { return nil @@ -182,6 +232,14 @@ func diagnosticsToSchemaErrors(diagnostics []analysis.SimpleDiagnostic) []*schem return errors } +func dependenciesBeforeIndex(idx int) []*analysis.Analyzer { + var deps []*analysis.Analyzer + for i := range idx { + deps = append(deps, Extractors[i]...) + } + return deps +} + func simplePosToSchemaPos(pos analysis.SimplePosition) schema.Position { return schema.Position{ Filename: pos.Filename, diff --git a/go-runtime/schema/finalize/analyzer.go b/go-runtime/schema/finalize/analyzer.go index 64d042d351..ce193244ea 100644 --- a/go-runtime/schema/finalize/analyzer.go +++ b/go-runtime/schema/finalize/analyzer.go @@ -11,8 +11,6 @@ import ( "github.com/TBD54566975/golang-tools/go/analysis" "github.com/TBD54566975/golang-tools/go/analysis/passes/inspect" "github.com/TBD54566975/golang-tools/go/ast/inspector" - sets "github.com/deckarep/golang-set/v2" - "golang.org/x/exp/maps" ) // Analyzer aggregates the results of all extractors. @@ -26,12 +24,13 @@ var Analyzer = &analysis.Analyzer{ // Result contains the final schema extraction result. type Result struct { - // Module is the extracted module schema. - Module *schema.Module - // NativeNames maps schema nodes to their native Go names. - NativeNames map[schema.Node]string - // Errors is a list of errors encountered during schema extraction. - Errors []*schema.Error + ModuleName string + ModuleComments []string + + // Extracted contains all objects successfully extracted to schema.Decls. + Extracted map[schema.Decl]types.Object + // Failed contains all objects that failed extraction. + Failed map[schema.RefKey]types.Object } func Run(pass *analysis.Pass) (interface{}, error) { @@ -39,15 +38,23 @@ func Run(pass *analysis.Pass) (interface{}, error) { if err != nil { return nil, err } - module := &schema.Module{ - Name: moduleName, - Comments: extractModuleComments(pass), + extracted := make(map[schema.Decl]types.Object) + failed := make(map[schema.RefKey]types.Object) + for obj, fact := range common.MergeAllFacts(pass) { + switch f := fact.Get().(type) { + case *common.ExtractedDecl: + if f.Decl != nil { + extracted[f.Decl] = obj + } + case *common.FailedExtraction: + failed[schema.RefKey{Module: moduleName, Name: obj.Name()}] = obj + } } - result := combineExtractorResults(pass, moduleName) - module.AddDecls(result.decls) return Result{ - Module: module, - NativeNames: result.nativeNames, + ModuleName: moduleName, + ModuleComments: extractModuleComments(pass), + Extracted: extracted, + Failed: failed, }, nil } @@ -66,68 +73,3 @@ func extractModuleComments(pass *analysis.Pass) []string { }) return comments } - -type combinedResult struct { - decls []schema.Decl - nativeNames map[schema.Node]string -} - -func combineExtractorResults(pass *analysis.Pass, moduleName string) combinedResult { - nn := make(map[schema.Node]string) - extracted := make(map[types.Object]schema.Decl) - failed := sets.NewSet[schema.RefKey]() - for obj, fact := range common.MergeAllFacts(pass) { - switch f := fact.Get().(type) { - case *common.ExtractedDecl: - if f.Decl != nil { - extracted[obj] = f.Decl - } - nn[f.Decl] = obj.Pkg().Path() + "." + obj.Name() - case *common.FailedExtraction: - failed.Add(schema.RefKey{Module: moduleName, Name: obj.Name()}) - } - } - propagateTypeErrors(pass, extracted, failed) - return combinedResult{ - nativeNames: nn, - decls: maps.Values(extracted), - } -} - -// propagateTypeErrors propagates type errors to referencing nodes. This improves error messaging for the LSP client by -// surfacing errors all the way up the schema chain. -func propagateTypeErrors(pass *analysis.Pass, extracted map[types.Object]schema.Decl, failed sets.Set[schema.RefKey]) { - for obj, sch := range extracted { - switch t := sch.(type) { - case *schema.Verb: - fnt := obj.(*types.Func) //nolint:forcetypeassert - sig := fnt.Type().(*types.Signature) //nolint:forcetypeassert - params := sig.Params() - results := sig.Results() - if hasFailedRef(t.Request, failed) { - common.TokenErrorf(pass, params.At(1).Pos(), params.At(1).Name(), - "unsupported request type %q", params.At(1).Type()) - } - if hasFailedRef(t.Response, failed) { - common.TokenErrorf(pass, results.At(0).Pos(), results.At(0).Name(), - "unsupported response type %q", results.At(0).Type()) - } - default: - } - } -} - -func hasFailedRef(node schema.Node, failedRefs sets.Set[schema.RefKey]) bool { - failed := false - _ = schema.Visit(node, func(n schema.Node, next func() error) error { - ref, ok := n.(*schema.Ref) - if !ok { - return next() - } - if failedRefs.Contains(ref.ToRefKey()) { - failed = true - } - return next() - }) - return failed -} diff --git a/go-runtime/schema/metadata/analyzer.go b/go-runtime/schema/metadata/analyzer.go index f7f78e12c4..e9c445a020 100644 --- a/go-runtime/schema/metadata/analyzer.go +++ b/go-runtime/schema/metadata/analyzer.go @@ -3,6 +3,7 @@ package metadata import ( "go/ast" "go/token" + "go/types" "reflect" "github.com/alecthomas/types/optional" @@ -29,20 +30,29 @@ func Extract(pass *analysis.Pass) (interface{}, error) { } in.Preorder(nodeFilter, func(n ast.Node) { var doc *ast.CommentGroup + var name string switch n := n.(type) { case *ast.TypeSpec: doc = n.Doc + name = n.Name.Name case *ast.GenDecl: doc = n.Doc - if doc == nil && len(n.Specs) > 0 { - if ts, ok := n.Specs[0].(*ast.TypeSpec); ok { + if ts, ok := n.Specs[0].(*ast.TypeSpec); len(n.Specs) > 0 && ok { + if doc == nil { doc = ts.Doc } + name = ts.Name.Name } case *ast.FuncDecl: doc = n.Doc + name = n.Name.Name } if mdFact, ok := extractMetadata(pass, n, doc).Get(); ok { + if prev, ok := getDuplicate(pass, name, mdFact).Get(); ok { + common.Errorf(pass, n, "duplicate declaration of %q at %s", name, + common.GoPosToSchemaPos(pass.Fset, prev.Pos())) + } + obj, ok := common.GetObjectForNode(pass.TypesInfo, n).Get() if !ok { return @@ -182,3 +192,12 @@ func isAnnotatingValidGoNode(dir common.Directive, node ast.Node) bool { } return false } + +func getDuplicate(pass *analysis.Pass, name string, newMd *common.ExtractedMetadata) optional.Option[types.Object] { + for obj, md := range common.GetFacts[*common.ExtractedMetadata](pass) { + if reflect.TypeOf(md.Type) == reflect.TypeOf(newMd.Type) && obj.Name() == name { + return optional.Some(obj) + } + } + return optional.None[types.Object]() +} diff --git a/go-runtime/schema/transitive/analyzer.go b/go-runtime/schema/transitive/analyzer.go index bb2c2dbcee..51cc5f7577 100644 --- a/go-runtime/schema/transitive/analyzer.go +++ b/go-runtime/schema/transitive/analyzer.go @@ -4,6 +4,7 @@ import ( "go/ast" "go/types" + "github.com/alecthomas/types/optional" sets "github.com/deckarep/golang-set/v2" "github.com/TBD54566975/ftl/backend/schema" @@ -41,6 +42,20 @@ func Extract(pass *analysis.Pass) (interface{}, error) { return common.NewExtractorResult(pass), nil } +func refreshNeedsExtraction(pass *analysis.Pass) sets.Set[types.Object] { + facts := sets.NewSet[types.Object]() + for _, fact := range pass.AllObjectFacts() { + f, ok := fact.Fact.(common.SchemaFact) + if !ok { + continue + } + if _, ok := f.Get().(*common.NeedsExtraction); ok { + facts.Add(fact.Object) + } + } + return facts +} + func extractTransitive(pass *analysis.Pass, needsExtraction sets.Set[types.Object]) { in := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) //nolint:forcetypeassert nodeFilter := []ast.Node{ @@ -55,7 +70,7 @@ func extractTransitive(pass *analysis.Pass, needsExtraction sets.Set[types.Objec if !needsExtraction.Contains(obj) { return } - schType, ok := common.InferDeclType(pass, n, obj).Get() + schType, ok := inferDeclType(pass, n, obj).Get() if !ok { // if we can't infer the type, try to extract it as data schType = &schema.Data{} @@ -75,16 +90,26 @@ func extractTransitive(pass *analysis.Pass, needsExtraction sets.Set[types.Objec }) } -func refreshNeedsExtraction(pass *analysis.Pass) sets.Set[types.Object] { - facts := sets.NewSet[types.Object]() - for _, fact := range pass.AllObjectFacts() { - f, ok := fact.Fact.(common.SchemaFact) - if !ok { - continue - } - if _, ok := f.Get().(*common.NeedsExtraction); ok { - facts.Add(fact.Object) +func inferDeclType(pass *analysis.Pass, node ast.Node, obj types.Object) optional.Option[schema.Decl] { + if md, ok := common.GetFactForObject[*common.ExtractedMetadata](pass, obj).Get(); ok { + if md.Type != nil { + return optional.Some[schema.Decl](md.Type) } } - return facts + + ts, ok := node.(*ast.TypeSpec) + if !ok { + return optional.None[schema.Decl]() + } + if _, ok := ts.Type.(*ast.InterfaceType); ok { + return optional.Some[schema.Decl](&schema.Enum{}) + } + t, ok := common.ExtractTypeForNode(pass, obj, ts.Type, nil).Get() + if !ok { + return optional.None[schema.Decl]() + } + if !common.IsSelfReference(pass, obj, t) { + return optional.Some[schema.Decl](&schema.TypeAlias{}) + } + return optional.Some[schema.Decl](&schema.Data{}) } diff --git a/go-runtime/schema/typealias/analyzer.go b/go-runtime/schema/typealias/analyzer.go index 4e7608ec43..36c6103cf1 100644 --- a/go-runtime/schema/typealias/analyzer.go +++ b/go-runtime/schema/typealias/analyzer.go @@ -20,6 +20,7 @@ func Extract(pass *analysis.Pass, node *ast.TypeSpec, obj types.Object) optional if !ok { return optional.None[*schema.TypeAlias]() } + // type aliases must have an underlying type, and the type cannot be a reference to the alias itself. if common.IsSelfReference(pass, obj, schType) { return optional.None[*schema.TypeAlias]() } @@ -29,9 +30,6 @@ func Extract(pass *analysis.Pass, node *ast.TypeSpec, obj types.Object) optional Type: schType, } if md, ok := common.GetFactForObject[*common.ExtractedMetadata](pass, obj).Get(); ok { - if _, ok := md.Type.(*schema.TypeAlias); !ok { - return optional.None[*schema.TypeAlias]() - } alias.Comments = md.Comments alias.Export = md.IsExported } diff --git a/go-runtime/schema/verb/analyzer.go b/go-runtime/schema/verb/analyzer.go index f4086464c7..4095b441b2 100644 --- a/go-runtime/schema/verb/analyzer.go +++ b/go-runtime/schema/verb/analyzer.go @@ -41,11 +41,11 @@ func Extract(pass *analysis.Pass, root *ast.FuncDecl, obj types.Object) optional reqt, respt := checkSignature(pass, root, sig) req := optional.Some[schema.Type](&schema.Unit{}) if reqt.Ok() { - req = common.ExtractType(pass, root.Pos(), params.At(1).Type()) + req = common.ExtractType(pass, params.At(1).Pos(), params.At(1).Type()) } resp := optional.Some[schema.Type](&schema.Unit{}) if respt.Ok() { - resp = common.ExtractType(pass, root.Pos(), results.At(0).Type()) + resp = common.ExtractType(pass, results.At(0).Pos(), results.At(0).Type()) } reqV, ok := req.Get() if !ok {