diff --git a/backend/schema/errors.go b/backend/schema/errors.go index be293c12a2..cbdb58ae39 100644 --- a/backend/schema/errors.go +++ b/backend/schema/errors.go @@ -94,14 +94,12 @@ func Wrapf(pos Position, endColumn int, err error, format string, args ...any) * return &e } -func SortErrorsByPosition(merr []error) { +func SortErrorsByPosition(merr []*Error) { sort.Slice(merr, func(i, j int) bool { - var ipe, jpe Error - if errors.As(merr[i], &ipe) && errors.As(merr[j], &jpe) { - ipp := ipe.Pos - jpp := jpe.Pos - return ipp.Line < jpp.Line || (ipp.Line == jpp.Line && ipp.Column < jpp.Column) || (ipp.Line == jpp.Line && ipp.Column == jpp.Column && ipe.EndColumn < jpe.EndColumn) - } - return merr[i].Error() < merr[j].Error() + ipp := merr[i].Pos + jpp := merr[j].Pos + return ipp.Line < jpp.Line || (ipp.Line == jpp.Line && ipp.Column < jpp.Column) || + (ipp.Line == jpp.Line && ipp.Column == jpp.Column && merr[i].EndColumn < merr[j].EndColumn) || + (ipp.Line == jpp.Line && ipp.Column == jpp.Column && merr[i].EndColumn == merr[j].EndColumn && merr[i].Msg < merr[j].Msg) }) } diff --git a/buildengine/build.go b/buildengine/build.go index b069d5c1aa..bae4ca96fa 100644 --- a/buildengine/build.go +++ b/buildengine/build.go @@ -11,6 +11,7 @@ import ( schemapb "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1/schema" "github.com/TBD54566975/ftl/backend/schema" + "github.com/TBD54566975/ftl/common/moduleconfig" "github.com/TBD54566975/ftl/internal/errors" "github.com/TBD54566975/ftl/internal/log" "github.com/TBD54566975/ftl/internal/slices" @@ -33,6 +34,11 @@ func buildModule(ctx context.Context, sch *schema.Schema, module Module) error { logger := log.FromContext(ctx).Scope(module.Module) ctx = log.ContextWithLogger(ctx, logger) + // clear stale module errors before extracting schema + if err := os.RemoveAll(filepath.Join(module.AbsDeployDir(), module.Errors)); err != nil { + return fmt.Errorf("failed to clear errors: %w", err) + } + logger.Infof("Building module") var err error switch module.Language { @@ -44,18 +50,21 @@ func buildModule(ctx context.Context, sch *schema.Schema, module Module) error { return fmt.Errorf("unknown language %q", module.Language) } + var errs []error if err != nil { - errs := errors.UnwrapAllExcludingIntermediaries(err) - // read runtime-specific build errors from the build directory - errorList, err := loadProtoErrors(module.AbsDeployDir()) - if err != nil { - return fmt.Errorf("failed to read build errors for module: %w", err) - } - for _, e := range errorList.Errors { - errs = append(errs, e) - } - errs = errors.DeduplicateErrors(errs) - schema.SortErrorsByPosition(errs) + errs = append(errs, err) + } + // read runtime-specific build errors from the build directory + errorList, err := loadProtoErrors(module.ModuleConfig) + if err != nil { + return fmt.Errorf("failed to read build errors for module: %w", err) + } + schema.SortErrorsByPosition(errorList.Errors) + for _, e := range errorList.Errors { + errs = append(errs, e) + } + + if len(errs) > 0 { return errors.Join(errs...) } @@ -88,8 +97,8 @@ func buildExternalLibrary(ctx context.Context, sch *schema.Schema, lib ExternalL return nil } -func loadProtoErrors(buildDir string) (*schema.ErrorList, error) { - f := filepath.Join(buildDir, "errors.pb") +func loadProtoErrors(module moduleconfig.ModuleConfig) (*schema.ErrorList, error) { + f := filepath.Join(module.AbsDeployDir(), module.Errors) if _, err := os.Stat(f); errors.Is(err, os.ErrNotExist) { return &schema.ErrorList{Errors: make([]*schema.Error, 0)}, nil } diff --git a/buildengine/build_test.go b/buildengine/build_test.go index b2e2884282..bb544e22a9 100644 --- a/buildengine/build_test.go +++ b/buildengine/build_test.go @@ -9,6 +9,7 @@ import ( "github.com/alecthomas/assert/v2" "github.com/TBD54566975/ftl/backend/schema" + "github.com/TBD54566975/ftl/common/moduleconfig" "github.com/TBD54566975/ftl/internal/log" ) @@ -65,10 +66,12 @@ func assertGeneratedModule(generatedModulePath string, expectedContent string) a func assertBuildProtoErrors(msgs ...string) assertion { return func(t testing.TB, bctx buildContext) error { t.Helper() - errorList, err := loadProtoErrors(filepath.Join(bctx.moduleDir, bctx.buildDir)) + config, err := moduleconfig.LoadModuleConfig(bctx.moduleDir) + assert.NoError(t, err, "Error loading module config") + errorList, err := loadProtoErrors(config) assert.NoError(t, err, "Error loading proto errors") - expected := make([]error, 0, len(msgs)) + expected := make([]*schema.Error, 0, len(msgs)) for _, msg := range msgs { expected = append(expected, &schema.Error{Msg: msg}) } @@ -78,13 +81,7 @@ func assertBuildProtoErrors(msgs ...string) assertion { e.EndColumn = 0 } - errs := make([]error, 0, len(errorList.Errors)) - for _, e := range errorList.Errors { - errs = append(errs, e) - } - schema.SortErrorsByPosition(errs) - - assert.Equal(t, errs, expected, assert.Exclude[schema.Position]()) + assert.Equal(t, errorList.Errors, expected, assert.Exclude[schema.Position]()) return nil } } diff --git a/buildengine/discover_test.go b/buildengine/discover_test.go index fbb4e871d1..ab943f864e 100644 --- a/buildengine/discover_test.go +++ b/buildengine/discover_test.go @@ -24,6 +24,7 @@ func TestDiscoverModules(t *testing.T) { Deploy: []string{"main"}, DeployDir: "_ftl", Schema: "schema.pb", + Errors: "errors.pb", Watch: []string{"**/*.go", "go.mod", "go.sum"}, }, }, @@ -36,6 +37,7 @@ func TestDiscoverModules(t *testing.T) { Deploy: []string{"main"}, DeployDir: "_ftl", Schema: "schema.pb", + Errors: "errors.pb", Watch: []string{"**/*.go", "go.mod", "go.sum"}, }, }, @@ -54,6 +56,7 @@ func TestDiscoverModules(t *testing.T) { }, DeployDir: "target", Schema: "schema.pb", + Errors: "errors.pb", Watch: []string{ "pom.xml", "src/**", @@ -73,6 +76,7 @@ func TestDiscoverModules(t *testing.T) { }, DeployDir: "_ftl", Schema: "schema.pb", + Errors: "errors.pb", Watch: []string{ "**/*.go", "go.mod", @@ -95,6 +99,7 @@ func TestDiscoverModules(t *testing.T) { }, DeployDir: "target", Schema: "schema.pb", + Errors: "errors.pb", Watch: []string{ "pom.xml", "src/**", @@ -111,6 +116,7 @@ func TestDiscoverModules(t *testing.T) { Deploy: []string{"main"}, DeployDir: "_ftl", Schema: "schema.pb", + Errors: "errors.pb", Watch: []string{"**/*.go", "go.mod", "go.sum"}, }, }, @@ -123,6 +129,7 @@ func TestDiscoverModules(t *testing.T) { Deploy: []string{"main"}, DeployDir: "_ftl", Schema: "schema.pb", + Errors: "errors.pb", Watch: []string{"**/*.go", "go.mod", "go.sum"}, }, }, diff --git a/common/moduleconfig/moduleconfig.go b/common/moduleconfig/moduleconfig.go index 20457e1abb..512eb8751f 100644 --- a/common/moduleconfig/moduleconfig.go +++ b/common/moduleconfig/moduleconfig.go @@ -32,6 +32,8 @@ type ModuleConfig struct { DeployDir string `toml:"deploy-dir"` // Schema is the name of the schema file relative to the DeployDir. Schema string `toml:"schema"` + // Errors is the name of the error file relative to the DeployDir. + Errors string `toml:"errors"` // Watch is the list of files to watch for changes. Watch []string `toml:"watch"` @@ -66,6 +68,9 @@ func setConfigDefaults(moduleDir string, config *ModuleConfig) error { if config.Schema == "" { config.Schema = "schema.pb" } + if config.Errors == "" { + config.Errors = "errors.pb" + } switch config.Language { case "kotlin": if config.Build == "" { diff --git a/go-runtime/compile/build.go b/go-runtime/compile/build.go index 2916a4c570..f9122773f2 100644 --- a/go-runtime/compile/build.go +++ b/go-runtime/compile/build.go @@ -23,7 +23,6 @@ import ( "github.com/TBD54566975/ftl/backend/schema" "github.com/TBD54566975/ftl/common/moduleconfig" "github.com/TBD54566975/ftl/internal" - "github.com/TBD54566975/ftl/internal/errors" "github.com/TBD54566975/ftl/internal/exec" "github.com/TBD54566975/ftl/internal/log" "github.com/TBD54566975/ftl/internal/reflect" @@ -108,29 +107,15 @@ func Build(ctx context.Context, moduleDir string, sch *schema.Schema) error { buildDir := buildDir(moduleDir) logger.Debugf("Extracting schema") - nativeNames, main, err := ExtractModuleSchema(moduleDir) - if originalErr := err; err != nil { - var schemaErrs []*schema.Error - for _, e := range errors.DeduplicateErrors(errors.UnwrapAll(err)) { - var ce *schema.Error - if errors.As(e, &ce) { - schemaErrs = append(schemaErrs, ce) - } - } - el := schema.ErrorList{ - Errors: schemaErrs, - } - elBytes, err := proto.Marshal(el.ToProto()) - if err != nil { - return fmt.Errorf("failed to marshal errors: %w", err) - } - - err = os.WriteFile(filepath.Join(buildDir, "errors.pb"), elBytes, 0600) - if err != nil { + nativeNames, main, schemaErrs, err := ExtractModuleSchema(moduleDir) + if err != nil { + return fmt.Errorf("failed to extract module schema: %w", err) + } + if len(schemaErrs) > 0 { + if err := writeSchemaErrors(config, schemaErrs); err != nil { return fmt.Errorf("failed to write errors: %w", err) } - - return fmt.Errorf("failed to extract module schema: %w", originalErr) + return nil } schemaBytes, err := proto.Marshal(main.ToProto()) if err != nil { @@ -409,3 +394,14 @@ func shouldUpdateVersion(goModfile *modfile.File) bool { } return true } + +func writeSchemaErrors(config moduleconfig.ModuleConfig, errors []*schema.Error) error { + el := schema.ErrorList{ + Errors: errors, + } + elBytes, err := proto.Marshal(el.ToProto()) + if err != nil { + return fmt.Errorf("failed to marshal errors: %w", err) + } + return os.WriteFile(filepath.Join(config.AbsDeployDir(), config.Errors), elBytes, 0600) +} diff --git a/go-runtime/compile/schema.go b/go-runtime/compile/schema.go index 87a695cf00..926cf19b0b 100644 --- a/go-runtime/compile/schema.go +++ b/go-runtime/compile/schema.go @@ -77,46 +77,50 @@ func wrapf(node ast.Node, err error, format string, args ...interface{}) *schema return schema.Wrapf(pos, endCol, err, format, args...) } +type errorSet map[string]*schema.Error + +func (e errorSet) add(err *schema.Error) { + e[err.Error()] = err +} + +func (e errorSet) addAll(errs ...*schema.Error) { + for _, err := range errs { + e.add(err) + } +} + // ExtractModuleSchema statically parses Go FTL module source into a schema.Module. -func ExtractModuleSchema(dir string) (NativeNames, *schema.Module, error) { +func ExtractModuleSchema(dir string) (NativeNames, *schema.Module, []*schema.Error /*schema errors*/, error /*exceptions*/) { pkgs, err := packages.Load(&packages.Config{ Dir: dir, Fset: fset, Mode: packages.NeedName | packages.NeedFiles | packages.NeedSyntax | packages.NeedTypes | packages.NeedTypesInfo, }, "./...") if err != nil { - return nil, nil, err + return nil, nil, nil, err } if len(pkgs) == 0 { - return nil, nil, fmt.Errorf("no packages found in %q, does \"go mod tidy\" need to be run?", dir) + return nil, nil, nil, fmt.Errorf("no packages found in %q, does \"go mod tidy\" need to be run?", dir) } nativeNames := NativeNames{} // Find module name module := &schema.Module{} merr := []error{} + schemaErrs := []*schema.Error{} for _, pkg := range pkgs { moduleName, ok := ftlModuleFromGoModule(pkg.PkgPath).Get() if !ok { - return nil, nil, fmt.Errorf("package %q is not in the ftl namespace", pkg.PkgPath) + return nil, nil, nil, fmt.Errorf("package %q is not in the ftl namespace", pkg.PkgPath) } module.Name = moduleName if len(pkg.Errors) > 0 { for _, perr := range pkg.Errors { - if len(pkg.Syntax) > 0 { - merr = append(merr, wrapf(pkg.Syntax[0], perr, "%s", pkg.PkgPath)) - } else { - merr = append(merr, fmt.Errorf("%s: %w", pkg.PkgPath, perr)) - } + merr = append(merr, perr) } } - pctx := &parseContext{pkg: pkg, pkgs: pkgs, module: module, nativeNames: NativeNames{}, enums: enums{}, errors: []*schema.Error{}} + pctx := &parseContext{pkg: pkg, pkgs: pkgs, module: module, nativeNames: NativeNames{}, enums: enums{}, errors: errorSet{}} for _, file := range pkg.Syntax { err := goast.Visit(file, func(node ast.Node, next func() error) (err error) { - defer func() { - if err != nil { - err = wrapf(node, err, "") - } - }() switch node := node.(type) { case *ast.CallExpr: visitCallExpr(pctx, node) @@ -142,7 +146,7 @@ func ExtractModuleSchema(dir string) (NativeNames, *schema.Module, error) { return next() }) if err != nil { - return nil, nil, err + return nil, nil, nil, err } } for decl, nativeName := range pctx.nativeNames { @@ -152,17 +156,17 @@ func ExtractModuleSchema(dir string) (NativeNames, *schema.Module, error) { pctx.module.Decls = append(pctx.module.Decls, e) } if len(pctx.errors) > 0 { - var errs []error - for _, e := range pctx.errors { - errs = append(errs, e) - } - return nil, nil, errors.Join(errs...) + schemaErrs = append(schemaErrs, maps.Values(pctx.errors)...) } } + if len(schemaErrs) > 0 { + schema.SortErrorsByPosition(schemaErrs) + return nil, nil, schemaErrs, nil + } if len(merr) > 0 { - return nil, nil, errors.Join(merr...) + return nil, nil, nil, errors.Join(merr...) } - return nativeNames, module, schema.ValidateModule(module) + return nativeNames, module, nil, schema.ValidateModule(module) } func visitCallExpr(pctx *parseContext, node *ast.CallExpr) { @@ -182,15 +186,15 @@ func visitCallExpr(pctx *parseContext, node *ast.CallExpr) { func parseCall(pctx *parseContext, node *ast.CallExpr) { if len(node.Args) != 3 { - pctx.errors = append(pctx.errors, errorf(node, "call must have exactly three arguments")) + pctx.errors.add(errorf(node, "call must have exactly three arguments")) return } _, verbFn := deref[*types.Func](pctx.pkg, node.Args[1]) if verbFn == nil { if sel, ok := node.Args[1].(*ast.SelectorExpr); ok { - pctx.errors = append(pctx.errors, errorf(node.Args[1], "call first argument must be a function but is an unresolved reference to %s.%s", sel.X, sel.Sel)) + pctx.errors.add(errorf(node.Args[1], "call first argument must be a function but is an unresolved reference to %s.%s", sel.X, sel.Sel)) } - pctx.errors = append(pctx.errors, errorf(node.Args[1], "call first argument must be a function")) + pctx.errors.add(errorf(node.Args[1], "call first argument must be a function")) return } if pctx.activeVerb == nil { @@ -198,7 +202,7 @@ func parseCall(pctx *parseContext, node *ast.CallExpr) { } moduleName, ok := ftlModuleFromGoModule(verbFn.Pkg().Path()).Get() if !ok { - pctx.errors = append(pctx.errors, errorf(node.Args[1], "call first argument must be a function in an ftl module")) + pctx.errors.add(errorf(node.Args[1], "call first argument must be a function in an ftl module")) return } ref := &schema.Ref{ @@ -216,13 +220,13 @@ func parseConfigDecl(pctx *parseContext, node *ast.CallExpr, fn *types.Func) { var err error name, err = strconv.Unquote(literal.Value) if err != nil { - pctx.errors = append(pctx.errors, wrapf(node, err, "")) + pctx.errors.add(wrapf(node, err, "")) return } } } if name == "" { - pctx.errors = append(pctx.errors, errorf(node, "config and secret declarations must have a single string literal argument")) + pctx.errors.add(errorf(node, "config and secret declarations must have a single string literal argument")) } index := node.Fun.(*ast.IndexExpr) //nolint:forcetypeassert @@ -230,7 +234,7 @@ func parseConfigDecl(pctx *parseContext, node *ast.CallExpr, fn *types.Func) { tp := pctx.pkg.TypesInfo.Types[index.Index].Type st, ok := visitType(pctx, index.Index.Pos(), tp).Get() if !ok { - pctx.errors = append(pctx.errors, errorf(index.Index, "unsupported type %q", tp)) + pctx.errors.add(errorf(index.Index, "unsupported type %q", tp)) return } @@ -272,39 +276,39 @@ func checkSignature(pctx *parseContext, node *ast.FuncDecl, sig *types.Signature results := sig.Results() if params.Len() > 2 { - pctx.errors = append(pctx.errors, errorf(node, "must have at most two parameters (context.Context, struct)")) + pctx.errors.add(errorf(node, "must have at most two parameters (context.Context, struct)")) } if params.Len() == 0 { - pctx.errors = append(pctx.errors, errorf(node, "first parameter must be context.Context")) + pctx.errors.add(errorf(node, "first parameter must be context.Context")) } else if !types.AssertableTo(contextIfaceType(), params.At(0).Type()) { - pctx.errors = append(pctx.errors, tokenErrorf(params.At(0).Pos(), params.At(0).Name(), "first parameter must be of type context.Context but is %s", params.At(0).Type())) + pctx.errors.add(tokenErrorf(params.At(0).Pos(), params.At(0).Name(), "first parameter must be of type context.Context but is %s", params.At(0).Type())) } if params.Len() == 2 { if !isType[*types.Struct](params.At(1).Type()) { - pctx.errors = append(pctx.errors, tokenErrorf(params.At(1).Pos(), params.At(1).Name(), "second parameter must be a struct but is %s", params.At(1).Type())) + pctx.errors.add(tokenErrorf(params.At(1).Pos(), params.At(1).Name(), "second parameter must be a struct but is %s", params.At(1).Type())) } if params.At(1).Type().String() == ftlUnitTypePath { - pctx.errors = append(pctx.errors, tokenErrorf(params.At(1).Pos(), params.At(1).Name(), "second parameter must not be ftl.Unit")) + pctx.errors.add(tokenErrorf(params.At(1).Pos(), params.At(1).Name(), "second parameter must not be ftl.Unit")) } req = optional.Some(params.At(1)) } if results.Len() > 2 { - pctx.errors = append(pctx.errors, errorf(node, "must have at most two results (struct, error)")) + pctx.errors.add(errorf(node, "must have at most two results (struct, error)")) } if results.Len() == 0 { - pctx.errors = append(pctx.errors, errorf(node, "must at least return an error")) + pctx.errors.add(errorf(node, "must at least return an error")) } else if !types.AssertableTo(errorIFaceType(), results.At(results.Len()-1).Type()) { - pctx.errors = append(pctx.errors, tokenErrorf(results.At(results.Len()-1).Pos(), results.At(results.Len()-1).Name(), "must return an error but is %s", results.At(0).Type())) + pctx.errors.add(tokenErrorf(results.At(results.Len()-1).Pos(), results.At(results.Len()-1).Name(), "must return an error but is %s", results.At(0).Type())) } if results.Len() == 2 { if !isType[*types.Struct](results.At(0).Type()) { - pctx.errors = append(pctx.errors, tokenErrorf(results.At(0).Pos(), results.At(0).Name(), "first result must be a struct but is %s", results.At(0).Type())) + pctx.errors.add(tokenErrorf(results.At(0).Pos(), results.At(0).Name(), "first result must be a struct but is %s", results.At(0).Type())) } if results.At(1).Type().String() == ftlUnitTypePath { - pctx.errors = append(pctx.errors, tokenErrorf(results.At(1).Pos(), results.At(1).Name(), "second result must not be ftl.Unit")) + pctx.errors.add(tokenErrorf(results.At(1).Pos(), results.At(1).Name(), "second result must not be ftl.Unit")) } resp = optional.Some(results.At(0)) } @@ -329,20 +333,20 @@ func visitGenDecl(pctx *parseContext, node *ast.GenDecl) { } directives, err := parseDirectives(node, fset, node.Doc) if err != nil { - pctx.errors = append(pctx.errors, err) + pctx.errors.add(err) } for _, dir := range directives { switch dir.(type) { case *directiveExport: if len(node.Specs) != 1 { - pctx.errors = append(pctx.errors, errorf(node, "error parsing ftl export directive: expected "+ + pctx.errors.add(errorf(node, "error parsing ftl export directive: expected "+ "exactly one type declaration")) return } if pctx.module.Name == "" { pctx.module.Name = pctx.pkg.Name } else if pctx.module.Name != pctx.pkg.Name && strings.TrimPrefix(pctx.pkg.Name, "ftl/") != pctx.module.Name { - pctx.errors = append(pctx.errors, errorf(node, "type export directive must be in the module package")) + pctx.errors.add(errorf(node, "type export directive must be in the module package")) return } if t, ok := node.Specs[0].(*ast.TypeSpec); ok { @@ -391,7 +395,7 @@ func visitTypeSpec(pctx *parseContext, node *ast.TypeSpec) { enum.Type = typ pctx.nativeNames[enum] = node.Name.Name } else { - pctx.errors = append(pctx.errors, errorf(node, "unsupported type %q", + pctx.errors.add(errorf(node, "unsupported type %q", pctx.pkg.TypesInfo.TypeOf(node.Type).Underlying())) } } else { @@ -409,7 +413,7 @@ func visitValueSpec(pctx *parseContext, node *ast.ValueSpec) { } c, ok := pctx.pkg.TypesInfo.Defs[node.Names[0]].(*types.Const) if !ok { - pctx.errors = append(pctx.errors, errorf(node, "could not extract enum %s: expected exactly one variant name", enum.Name)) + pctx.errors.add(errorf(node, "could not extract enum %s: expected exactly one variant name", enum.Name)) return } if value, ok := visitConst(pctx, c).Get(); ok { @@ -421,7 +425,7 @@ func visitValueSpec(pctx *parseContext, node *ast.ValueSpec) { } enum.Variants = append(enum.Variants, variant) } else { - pctx.errors = append(pctx.errors, errorf(node, "unsupported type %q for enum variant %q", c.Type(), c.Name())) + pctx.errors.add(errorf(node, "unsupported type %q for enum variant %q", c.Type(), c.Name())) } } @@ -431,7 +435,7 @@ func visitFuncDecl(pctx *parseContext, node *ast.FuncDecl) (verb *schema.Verb) { } directives, err := parseDirectives(node, fset, node.Doc) if err != nil { - pctx.errors = append(pctx.errors, err) + pctx.errors.add(err) } var metadata []schema.Metadata isVerb := false @@ -442,7 +446,7 @@ func visitFuncDecl(pctx *parseContext, node *ast.FuncDecl) (verb *schema.Verb) { if pctx.module.Name == "" { pctx.module.Name = pctx.pkg.Name } else if pctx.module.Name != pctx.pkg.Name { - pctx.errors = append(pctx.errors, errorf(node, "function export directive must be in the module package")) + pctx.errors.add(errorf(node, "function export directive must be in the module package")) } case *directiveIngress: @@ -471,7 +475,7 @@ func visitFuncDecl(pctx *parseContext, node *ast.FuncDecl) (verb *schema.Verb) { for _, name := range pctx.nativeNames { if name == node.Name.Name { - pctx.errors = append(pctx.errors, noEndColumnErrorf(node.Pos(), "verb %q already exported", node.Name.Name)) + pctx.errors.add(noEndColumnErrorf(node.Pos(), "verb %q already exported", node.Name.Name)) return nil } } @@ -479,7 +483,7 @@ func visitFuncDecl(pctx *parseContext, node *ast.FuncDecl) (verb *schema.Verb) { fnt := pctx.pkg.TypesInfo.Defs[node.Name].(*types.Func) //nolint:forcetypeassert sig := fnt.Type().(*types.Signature) //nolint:forcetypeassert if sig.Recv() != nil { - pctx.errors = append(pctx.errors, errorf(node, "ftl:export cannot be a method")) + pctx.errors.add(errorf(node, "ftl:export cannot be a method")) return nil } params := sig.Params() @@ -501,11 +505,11 @@ func visitFuncDecl(pctx *parseContext, node *ast.FuncDecl) (verb *schema.Verb) { reqV, reqOk := req.Get() resV, respOk := resp.Get() if !reqOk { - pctx.errors = append(pctx.errors, tokenErrorf(params.At(1).Pos(), params.At(1).Name(), + pctx.errors.add(tokenErrorf(params.At(1).Pos(), params.At(1).Name(), "unsupported request type %q", params.At(1).Type())) } if !respOk { - pctx.errors = append(pctx.errors, tokenErrorf(results.At(0).Pos(), results.At(0).Name(), + pctx.errors.add(tokenErrorf(results.At(0).Pos(), results.At(0).Name(), "unsupported response type %q", results.At(0).Type())) } verb = &schema.Verb{ @@ -540,14 +544,14 @@ func ftlModuleFromGoModule(pkgPath string) optional.Option[string] { func visitStruct(pctx *parseContext, pos token.Pos, tnode types.Type) optional.Option[*schema.Ref] { named, ok := tnode.(*types.Named) if !ok { - pctx.errors = append(pctx.errors, noEndColumnErrorf(pos, "expected named type but got %s", tnode)) + pctx.errors.add(noEndColumnErrorf(pos, "expected named type but got %s", tnode)) return optional.None[*schema.Ref]() } nodePath := named.Obj().Pkg().Path() if !strings.HasPrefix(nodePath, pctx.pkg.PkgPath) { destModule, ok := ftlModuleFromGoModule(nodePath).Get() if !ok { - pctx.errors = append(pctx.errors, tokenErrorf(pos, nodePath, "struct declared in non-FTL module %s", nodePath)) + pctx.errors.add(tokenErrorf(pos, nodePath, "struct declared in non-FTL module %s", nodePath)) return optional.None[*schema.Ref]() } dataRef := &schema.Ref{ @@ -620,7 +624,7 @@ func visitStruct(pctx *parseContext, pos token.Pos, tnode types.Type) optional.O s, ok := named.Underlying().(*types.Struct) if !ok { - pctx.errors = append(pctx.errors, tokenErrorf(pos, named.String(), "expected struct but got %s", named)) + pctx.errors.add(tokenErrorf(pos, named.String(), "expected struct but got %s", named)) return optional.None[*schema.Ref]() } @@ -630,8 +634,8 @@ func visitStruct(pctx *parseContext, pos token.Pos, tnode types.Type) optional.O if ft, ok := visitType(pctx, f.Pos(), f.Type()).Get(); ok { // Check if field is exported if len(f.Name()) > 0 && unicode.IsLower(rune(f.Name()[0])) { - pctx.errors = append(pctx.errors, - tokenErrorf(f.Pos(), f.Name(), "struct field %s must be exported by starting with an uppercase letter", f.Name())) + pctx.errors.add(tokenErrorf(f.Pos(), f.Name(), + "struct field %s must be exported by starting with an uppercase letter", f.Name())) fieldErrors = true } @@ -658,7 +662,7 @@ func visitStruct(pctx *parseContext, pos token.Pos, tnode types.Type) optional.O Metadata: metadata, }) } else { - pctx.errors = append(pctx.errors, tokenErrorf(f.Pos(), f.Name(), "unsupported type %q for field %q", f.Type(), f.Name())) + pctx.errors.add(tokenErrorf(f.Pos(), f.Name(), "unsupported type %q for field %q", f.Type(), f.Name())) fieldErrors = true } } @@ -676,7 +680,7 @@ func visitConst(pctx *parseContext, cnode *types.Const) optional.Option[schema.V case types.String: value, err := strconv.Unquote(cnode.Val().String()) if err != nil { - pctx.errors = append(pctx.errors, tokenWrapf(cnode.Pos(), cnode.Val().String(), err, "unsupported string constant")) + pctx.errors.add(tokenWrapf(cnode.Pos(), cnode.Val().String(), err, "unsupported string constant")) return optional.None[schema.Value]() } return optional.Some[schema.Value](&schema.StringValue{Pos: goPosToSchemaPos(cnode.Pos()), Value: value}) @@ -684,7 +688,7 @@ func visitConst(pctx *parseContext, cnode *types.Const) optional.Option[schema.V case types.Int, types.Int64: value, err := strconv.ParseInt(cnode.Val().String(), 10, 64) if err != nil { - pctx.errors = append(pctx.errors, tokenWrapf(cnode.Pos(), cnode.Val().String(), err, "unsupported int constant")) + pctx.errors.add(tokenWrapf(cnode.Pos(), cnode.Val().String(), err, "unsupported int constant")) return optional.None[schema.Value]() } return optional.Some[schema.Value](&schema.IntValue{Pos: goPosToSchemaPos(cnode.Pos()), Value: int(value)}) @@ -716,8 +720,8 @@ func visitType(pctx *parseContext, pos token.Pos, tnode types.Type) optional.Opt // If this type is named and declared in another module, it's a reference. // The only basic-typed references supported are enums. if !strings.HasPrefix(named.Obj().Pkg().Path(), "ftl/") { - pctx.errors = append(pctx.errors, - noEndColumnErrorf(pos, "unsupported external type %q", named.Obj().Pkg().Path()+"."+named.Obj().Name())) + pctx.errors.add(noEndColumnErrorf(pos, + "unsupported external type %q", named.Obj().Pkg().Path()+"."+named.Obj().Name())) return optional.None[schema.Type]() } base := path.Dir(pctx.pkg.PkgPath) @@ -774,7 +778,7 @@ func visitType(pctx *parseContext, pos token.Pos, tnode types.Type) optional.Opt default: nodePath := named.Obj().Pkg().Path() if !strings.HasPrefix(nodePath, pctx.pkg.PkgPath) && !strings.HasPrefix(nodePath, "ftl/") { - pctx.errors = append(pctx.errors, noEndColumnErrorf(pos, "unsupported external type %s", nodePath+"."+named.Obj().Name())) + pctx.errors.add(noEndColumnErrorf(pos, "unsupported external type %s", nodePath+"."+named.Obj().Name())) return optional.None[schema.Type]() } if ref, ok := visitStruct(pctx, pos, tnode).Get(); ok { @@ -793,11 +797,9 @@ func visitType(pctx *parseContext, pos token.Pos, tnode types.Type) optional.Opt if underlying.String() == "any" { return optional.Some[schema.Type](&schema.Any{Pos: goPosToSchemaPos(pos)}) } - pctx.errors = append(pctx.errors, noEndColumnErrorf(pos, "unsupported type %q", tnode)) return optional.None[schema.Type]() default: - pctx.errors = append(pctx.errors, noEndColumnErrorf(pos, "unsupported type %q", tnode)) return optional.None[schema.Type]() } } @@ -891,7 +893,7 @@ type parseContext struct { nativeNames NativeNames enums enums activeVerb *schema.Verb - errors []*schema.Error + errors errorSet } // pathEnclosingInterval returns the PackageInfo and ast.Node that diff --git a/go-runtime/compile/schema_test.go b/go-runtime/compile/schema_test.go index 196430f6c2..9854882684 100644 --- a/go-runtime/compile/schema_test.go +++ b/go-runtime/compile/schema_test.go @@ -43,7 +43,7 @@ func TestExtractModuleSchema(t *testing.T) { } prebuildTestModule(t, "testdata/one", "testdata/two") - _, actual, err := ExtractModuleSchema("testdata/one") + _, actual, _, err := ExtractModuleSchema("testdata/one") assert.NoError(t, err) actual = schema.Normalise(actual) expected := `module one { @@ -127,7 +127,7 @@ func TestExtractModuleSchemaTwo(t *testing.T) { if testing.Short() { t.SkipNow() } - _, actual, err := ExtractModuleSchema("testdata/two") + _, actual, _, err := ExtractModuleSchema("testdata/two") assert.NoError(t, err) actual = schema.Normalise(actual) expected := `module two { @@ -239,15 +239,13 @@ func TestErrorReporting(t *testing.T) { t.SkipNow() } pwd, _ := os.Getwd() - _, _, err := ExtractModuleSchema("testdata/failing") - merr := errors.DeduplicateErrors(errors.UnwrapAll(err)) - schema.SortErrorsByPosition(merr) + _, _, schemaErrs, err := ExtractModuleSchema("testdata/failing") + assert.NoError(t, err) filename := filepath.Join(pwd, `testdata/failing/failing.go`) - assert.EqualError(t, errors.Join(merr...), + assert.EqualError(t, errors.Join(genericizeErrors(schemaErrs)...), filename+":10:13-35: config and secret declarations must have a single string literal argument\n"+ filename+":13:2-10: unsupported type \"error\" for field \"BadParam\"\n"+ - filename+":13:2-2: unsupported type \"error\"\n"+ filename+":16:2-17: unsupported type \"uint64\" for field \"AnotherBadParam\"\n"+ filename+":19:3-3: unexpected token \"verb\" (expected Directive)\n"+ filename+":25:36-39: unsupported request type \"ftl/failing.Request\"\n"+ @@ -284,6 +282,15 @@ func TestDuplicateVerbNames(t *testing.T) { t.SkipNow() } pwd, _ := os.Getwd() - _, _, err := ExtractModuleSchema("testdata/duplicateverbs") - assert.EqualError(t, err, filepath.Join(pwd, `testdata/duplicateverbs/duplicateverbs.go`)+`:23:1-1: verb "Time" already exported`) + _, _, schemaErrs, err := ExtractModuleSchema("testdata/duplicateverbs") + assert.NoError(t, err) + assert.EqualError(t, errors.Join(genericizeErrors(schemaErrs)...), filepath.Join(pwd, `testdata/duplicateverbs/duplicateverbs.go`)+`:23:1-1: verb "Time" already exported`) +} + +func genericizeErrors(schemaErrs []*schema.Error) []error { + errs := make([]error, len(schemaErrs)) + for i, schemaErr := range schemaErrs { + errs[i] = schemaErr + } + return errs } diff --git a/internal/errors/errors.go b/internal/errors/errors.go index cc4e804cba..a3c7afad2e 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -25,27 +25,6 @@ func UnwrapAll(err error) []error { return out } -// UnwrapAllExcludingIntermediaries recursively unwraps all errors in err, excluding all intermediate errors. -// -//nolint:errorlint -func UnwrapAllExcludingIntermediaries(err error) []error { - out := []error{} - if inner, ok := err.(interface{ Unwrap() []error }); ok { - for _, e := range inner.Unwrap() { - out = append(out, UnwrapAllExcludingIntermediaries(e)...) - } - return out - } - if inner, ok := err.(interface{ Unwrap() error }); ok && inner.Unwrap() != nil { - out = append(out, UnwrapAllExcludingIntermediaries(inner.Unwrap())...) - } - // only add the error if it is not an intermediary error - if len(out) == 0 { - out = append(out, err) - } - return out -} - // Innermost returns true if err cannot be further unwrapped. // //nolint:errorlint