diff --git a/backend/schema/errors.go b/backend/schema/errors.go index 218686462f..e607f4a3d3 100644 --- a/backend/schema/errors.go +++ b/backend/schema/errors.go @@ -3,6 +3,7 @@ package schema import ( "errors" "fmt" + "sort" schemapb "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1/schema" ) @@ -66,11 +67,12 @@ func ErrorListFromProto(e *schemapb.ErrorList) *ErrorList { } } -func Errorf(pos Position, endColumn int, format string, args ...any) Error { - return Error{Msg: fmt.Sprintf(format, args...), Pos: pos, EndColumn: endColumn} +func Errorf(pos Position, endColumn int, format string, args ...any) *Error { + err := Error{Msg: fmt.Sprintf(format, args...), Pos: pos, EndColumn: endColumn} + return &err } -func Wrapf(pos Position, endColumn int, err error, format string, args ...any) Error { +func Wrapf(pos Position, endColumn int, err error, format string, args ...any) *Error { if format == "" { format = "%s" } else { @@ -88,5 +90,18 @@ func Wrapf(pos Position, endColumn int, err error, format string, args ...any) E newEndColumn = endColumn args = append(args, err) } - return Error{Msg: fmt.Sprintf(format, args...), Pos: newPos, EndColumn: newEndColumn} + e := Error{Msg: fmt.Sprintf(format, args...), Pos: newPos, EndColumn: newEndColumn} + return &e +} + +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) + } + return merr[i].Error() < merr[j].Error() + }) } diff --git a/buildengine/build.go b/buildengine/build.go index ebf68e4a53..694a9fc8cf 100644 --- a/buildengine/build.go +++ b/buildengine/build.go @@ -54,6 +54,7 @@ func buildModule(ctx context.Context, sch *schema.Schema, module Module) error { for _, e := range errorList.Errors { errs = append(errs, *e) } + schema.SortErrorsByPosition(errs) return errors.Join(errs...) } diff --git a/buildengine/build_go_test.go b/buildengine/build_go_test.go index cf0daaec54..e97aefcc71 100644 --- a/buildengine/build_go_test.go +++ b/buildengine/build_go_test.go @@ -184,6 +184,6 @@ func TestExternalType(t *testing.T) { sch: &schema.Schema{}, } testBuild(t, bctx, true, []assertion{ - assertBuildProtoErrors("field Month: unsupported external type time.Month"), + assertBuildProtoErrors("unsupported external type \"time.Month\""), }) } diff --git a/go-runtime/compile/build.go b/go-runtime/compile/build.go index 2fe53ca021..e370be8a9e 100644 --- a/go-runtime/compile/build.go +++ b/go-runtime/compile/build.go @@ -105,9 +105,9 @@ func Build(ctx context.Context, moduleDir string, sch *schema.Schema) error { if originalErr := err; err != nil { var schemaErrs []*schema.Error for _, e := range errors.DeduplicateErrors(errors.UnwrapAll(err)) { - var ce schema.Error + var ce *schema.Error if errors.As(e, &ce) { - schemaErrs = append(schemaErrs, &ce) + schemaErrs = append(schemaErrs, ce) } } el := schema.ErrorList{ diff --git a/go-runtime/compile/parser.go b/go-runtime/compile/parser.go index 2feed44796..6443816822 100644 --- a/go-runtime/compile/parser.go +++ b/go-runtime/compile/parser.go @@ -72,7 +72,7 @@ var directiveParser = participle.MustBuild[directiveWrapper]( participle.Union[schema.IngressPathComponent](&schema.IngressPathLiteral{}, &schema.IngressPathParameter{}), ) -func parseDirectives(fset *token.FileSet, docs *ast.CommentGroup) ([]directive, error) { +func parseDirectives(node ast.Node, fset *token.FileSet, docs *ast.CommentGroup) ([]directive, *schema.Error) { if docs == nil { return nil, nil } @@ -86,17 +86,18 @@ func parseDirectives(fset *token.FileSet, docs *ast.CommentGroup) ([]directive, directive, err := directiveParser.ParseString(pos.Filename, line.Text[2:]) if err != nil { // Adjust the Participle-reported position relative to the AST node. + var scerr *schema.Error var perr participle.Error if errors.As(err, &perr) { ppos := schema.Position{} ppos.Filename = pos.Filename ppos.Column += pos.Column + 2 ppos.Line = pos.Line - err = schema.Errorf(ppos, ppos.Column, "%s", perr.Message()) + scerr = schema.Errorf(ppos, ppos.Column, "%s", perr.Message()) } else { - err = fmt.Errorf("%s: %w", pos, err) + scerr = wrapf(node, err, "") } - return nil, fmt.Errorf("invalid directive: %w", err) + return nil, scerr } directives = append(directives, directive.Directive) } diff --git a/go-runtime/compile/schema.go b/go-runtime/compile/schema.go index f22128b131..9b224f648a 100644 --- a/go-runtime/compile/schema.go +++ b/go-runtime/compile/schema.go @@ -1,7 +1,6 @@ package compile import ( - "errors" "fmt" "go/ast" "go/token" @@ -21,6 +20,7 @@ import ( "github.com/TBD54566975/ftl/backend/schema" "github.com/TBD54566975/ftl/backend/schema/strcase" + "github.com/TBD54566975/ftl/internal/errors" "github.com/TBD54566975/ftl/internal/goast" ) @@ -45,7 +45,11 @@ type NativeNames map[schema.Decl]string type enums map[string]*schema.Enum -func tokenErrorf(pos token.Pos, tokenText string, format string, args ...interface{}) schema.Error { +func noEndColumnErrorf(pos token.Pos, format string, args ...interface{}) *schema.Error { + return tokenErrorf(pos, "", format, args...) +} + +func tokenErrorf(pos token.Pos, tokenText string, format string, args ...interface{}) *schema.Error { goPos := goPosToSchemaPos(pos) endColumn := goPos.Column if len(tokenText) > 0 { @@ -54,12 +58,12 @@ func tokenErrorf(pos token.Pos, tokenText string, format string, args ...interfa return schema.Errorf(goPosToSchemaPos(pos), endColumn, format, args...) } -func errorf(node ast.Node, format string, args ...interface{}) schema.Error { +func errorf(node ast.Node, format string, args ...interface{}) *schema.Error { pos, endCol := goNodePosToSchemaPos(node) return schema.Errorf(pos, endCol, format, args...) } -func tokenWrapf(pos token.Pos, tokenText string, err error, format string, args ...interface{}) schema.Error { +func tokenWrapf(pos token.Pos, tokenText string, err error, format string, args ...interface{}) *schema.Error { goPos := goPosToSchemaPos(pos) endColumn := goPos.Column if len(tokenText) > 0 { @@ -68,7 +72,7 @@ func tokenWrapf(pos token.Pos, tokenText string, err error, format string, args return schema.Wrapf(goPos, endColumn, err, format, args...) } -func wrapf(node ast.Node, err error, format string, args ...interface{}) schema.Error { +func wrapf(node ast.Node, err error, format string, args ...interface{}) *schema.Error { pos, endCol := goNodePosToSchemaPos(node) return schema.Wrapf(pos, endCol, err, format, args...) } @@ -105,7 +109,7 @@ func ExtractModuleSchema(dir string) (NativeNames, *schema.Module, error) { } } } - pctx := &parseContext{pkg: pkg, pkgs: pkgs, module: module, nativeNames: NativeNames{}, enums: enums{}} + pctx := &parseContext{pkg: pkg, pkgs: pkgs, module: module, nativeNames: NativeNames{}, enums: enums{}, errors: []*schema.Error{}} for _, file := range pkg.Syntax { err := goast.Visit(file, func(node ast.Node, next func() error) (err error) { defer func() { @@ -115,18 +119,13 @@ func ExtractModuleSchema(dir string) (NativeNames, *schema.Module, error) { }() switch node := node.(type) { case *ast.CallExpr: - if err := visitCallExpr(pctx, node); err != nil { - return err - } + visitCallExpr(pctx, node) case *ast.File: visitFile(pctx, node) case *ast.FuncDecl: - verb, err := visitFuncDecl(pctx, node) - if err != nil { - return err - } + verb := visitFuncDecl(pctx, node) pctx.activeVerb = verb err = next() if err != nil { @@ -136,9 +135,7 @@ func ExtractModuleSchema(dir string) (NativeNames, *schema.Module, error) { return nil case *ast.GenDecl: - if err = visitGenDecl(pctx, node); err != nil { - return err - } + visitGenDecl(pctx, node) default: } @@ -154,6 +151,13 @@ func ExtractModuleSchema(dir string) (NativeNames, *schema.Module, error) { for _, e := range maps.Values(pctx.enums) { 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...) + } } if len(merr) > 0 { return nil, nil, errors.Join(merr...) @@ -161,45 +165,41 @@ func ExtractModuleSchema(dir string) (NativeNames, *schema.Module, error) { return nativeNames, module, schema.ValidateModule(module) } -func visitCallExpr(pctx *parseContext, node *ast.CallExpr) error { +func visitCallExpr(pctx *parseContext, node *ast.CallExpr) { _, fn := deref[*types.Func](pctx.pkg, node.Fun) if fn == nil { - return nil + return } switch fn.FullName() { case ftlCallFuncPath: - err := parseCall(pctx, node) - if err != nil { - return err - } + parseCall(pctx, node) case ftlConfigFuncPath, ftlSecretFuncPath: // Secret/config declaration: ftl.Config[]() - err := parseConfigDecl(pctx, node, fn) - if err != nil { - return err - } + parseConfigDecl(pctx, node, fn) } - return nil } -func parseCall(pctx *parseContext, node *ast.CallExpr) error { +func parseCall(pctx *parseContext, node *ast.CallExpr) { if len(node.Args) != 3 { - return errorf(node, "call must have exactly three arguments") + pctx.errors = append(pctx.errors, 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 { - return 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 but is an unresolved reference to %s.%s", sel.X, sel.Sel)) } - return errorf(node.Args[1], "call first argument must be a function but is %T", node.Args[1]) + pctx.errors = append(pctx.errors, errorf(node.Args[1], "call first argument must be a function")) + return } if pctx.activeVerb == nil { - return nil + return } moduleName, ok := ftlModuleFromGoModule(verbFn.Pkg().Path()).Get() if !ok { - return errorf(node.Args[1], "call first argument must be a function in an ftl module") + pctx.errors = append(pctx.errors, errorf(node.Args[1], "call first argument must be a function in an ftl module")) + return } ref := &schema.Ref{ Pos: goPosToSchemaPos(node.Pos()), @@ -207,30 +207,30 @@ func parseCall(pctx *parseContext, node *ast.CallExpr) error { Name: strcase.ToLowerCamel(verbFn.Name()), } pctx.activeVerb.AddCall(ref) - return nil } -func parseConfigDecl(pctx *parseContext, node *ast.CallExpr, fn *types.Func) error { +func parseConfigDecl(pctx *parseContext, node *ast.CallExpr, fn *types.Func) { var name string if len(node.Args) == 1 { if literal, ok := node.Args[0].(*ast.BasicLit); ok && literal.Kind == token.STRING { var err error name, err = strconv.Unquote(literal.Value) if err != nil { - return wrapf(node, err, "") + pctx.errors = append(pctx.errors, wrapf(node, err, "")) + return } } } if name == "" { - return errorf(node, "config and secret declarations must have a single string literal argument") + pctx.errors = append(pctx.errors, errorf(node, "config and secret declarations must have a single string literal argument")) } index := node.Fun.(*ast.IndexExpr) //nolint:forcetypeassert // Type parameter tp := pctx.pkg.TypesInfo.Types[index.Index].Type - st, err := visitType(pctx, index.Index.Pos(), tp) - if err != nil { - return err + st, ok := visitType(pctx, index.Index.Pos(), tp).Get() + if !ok { + return } // Add the declaration to the module. @@ -249,7 +249,6 @@ func parseConfigDecl(pctx *parseContext, node *ast.CallExpr, fn *types.Func) err } } pctx.module.Decls = append(pctx.module.Decls, decl) - return nil } func visitFile(pctx *parseContext, node *ast.File) { @@ -267,49 +266,48 @@ func isType[T types.Type](t types.Type) bool { return ok } -func checkSignature(sig *types.Signature) (req, resp *types.Var, err error) { +func checkSignature(pctx *parseContext, node *ast.FuncDecl, sig *types.Signature) (req, resp optional.Option[*types.Var]) { params := sig.Params() results := sig.Results() if params.Len() > 2 { - return nil, nil, fmt.Errorf("must have at most two parameters (context.Context, struct)") + pctx.errors = append(pctx.errors, errorf(node, "must have at most two parameters (context.Context, struct)")) } if params.Len() == 0 { - return nil, nil, fmt.Errorf("first parameter must be context.Context") - } - if !types.AssertableTo(contextIfaceType(), params.At(0).Type()) { - return nil, nil, fmt.Errorf("first parameter must be of type context.Context but is %s", params.At(0).Type()) + pctx.errors = append(pctx.errors, errorf(node, "first parameter must be context.Context")) + } else if !types.AssertableTo(contextIfaceType(), params.At(0).Type()) { + pctx.errors = append(pctx.errors, errorf(node, "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()) { - return nil, nil, fmt.Errorf("second parameter must be a struct but is %s", params.At(1).Type()) + pctx.errors = append(pctx.errors, errorf(node, "second parameter must be a struct but is %s", params.At(1).Type())) } if params.At(1).Type().String() == ftlUnitTypePath { - return nil, nil, fmt.Errorf("second parameter must not be ftl.Unit") + pctx.errors = append(pctx.errors, errorf(node, "second parameter must not be ftl.Unit")) } - req = params.At(1) + req = optional.Some(params.At(1)) } if results.Len() > 2 { - return nil, nil, fmt.Errorf("must have at most two results (struct, error)") + pctx.errors = append(pctx.errors, errorf(node, "must have at most two results (struct, error)")) } if results.Len() == 0 { - return nil, nil, fmt.Errorf("must at least return an error") - } - if !types.AssertableTo(errorIFaceType(), results.At(results.Len()-1).Type()) { - return nil, nil, fmt.Errorf("must return an error but is %s", results.At(0).Type()) + pctx.errors = append(pctx.errors, errorf(node, "must at least return an error")) + } else if !types.AssertableTo(errorIFaceType(), results.At(results.Len()-1).Type()) { + pctx.errors = append(pctx.errors, errorf(node, "must return an error but is %s", results.At(0).Type())) } if results.Len() == 2 { if !isType[*types.Struct](results.At(0).Type()) { - return nil, nil, fmt.Errorf("first result must be a struct but is %s", results.At(0).Type()) + pctx.errors = append(pctx.errors, errorf(node, "first result must be a struct but is %s", results.At(0).Type())) } if results.At(1).Type().String() == ftlUnitTypePath { - return nil, nil, fmt.Errorf("first result must not be ftl.Unit") + pctx.errors = append(pctx.errors, errorf(node, "second result must not be ftl.Unit")) } - resp = results.At(0) + resp = optional.Some(results.At(0)) } - return req, resp, nil + return req, resp } func goPosToSchemaPos(pos token.Pos) schema.Position { @@ -322,27 +320,29 @@ func goNodePosToSchemaPos(node ast.Node) (schema.Position, int) { return schema.Position{Filename: p.Filename, Line: p.Line, Column: p.Column, Offset: p.Offset}, fset.Position(node.End()).Column } -func visitGenDecl(pctx *parseContext, node *ast.GenDecl) error { +func visitGenDecl(pctx *parseContext, node *ast.GenDecl) { switch node.Tok { case token.TYPE: if node.Doc == nil { - return nil + return } - directives, err := parseDirectives(fset, node.Doc) + directives, err := parseDirectives(node, fset, node.Doc) if err != nil { - return err + pctx.errors = append(pctx.errors, err) } for _, dir := range directives { switch dir.(type) { case *directiveExport: if len(node.Specs) != 1 { - return errorf(node, "error parsing ftl export directive: expected exactly one type "+ - "declaration") + pctx.errors = append(pctx.errors, 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 { - return errorf(node, "type export directive must be in the module package") + } 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")) + return } if t, ok := node.Specs[0].(*ast.TypeSpec); ok { if _, ok := pctx.pkg.TypesInfo.TypeOf(t.Type).Underlying().(*types.Basic); ok { @@ -352,16 +352,13 @@ func visitGenDecl(pctx *parseContext, node *ast.GenDecl) error { } pctx.enums[t.Name.Name] = enum } - err := visitTypeSpec(pctx, t) - if err != nil { - return err - } + visitTypeSpec(pctx, t) } case *directiveIngress, *directiveCronJob: } } - return nil + return case token.CONST: var typ ast.Expr @@ -377,70 +374,58 @@ func visitGenDecl(pctx *parseContext, node *ast.GenDecl) error { } else if v.Type == nil { v.Type = typ } - err := visitValueSpec(pctx, v) - if err != nil { - return err - } + visitValueSpec(pctx, v) } - return nil + return default: - return nil + return } } -func visitTypeSpec(pctx *parseContext, node *ast.TypeSpec) error { +func visitTypeSpec(pctx *parseContext, node *ast.TypeSpec) { if enum, ok := pctx.enums[node.Name.Name]; ok { - typ, err := visitType(pctx, node.Pos(), pctx.pkg.TypesInfo.TypeOf(node.Type)) - if err != nil { - return err + if typ, ok := visitType(pctx, node.Pos(), pctx.pkg.TypesInfo.TypeOf(node.Type)).Get(); ok { + enum.Name = strcase.ToUpperCamel(node.Name.Name) + enum.Type = typ + pctx.nativeNames[enum] = node.Name.Name } - - enum.Name = strcase.ToUpperCamel(node.Name.Name) - enum.Type = typ - pctx.nativeNames[enum] = node.Name.Name } else { - _, err := visitType(pctx, node.Pos(), pctx.pkg.TypesInfo.Defs[node.Name].Type()) - if err != nil { - return err - } + visitType(pctx, node.Pos(), pctx.pkg.TypesInfo.Defs[node.Name].Type()) } - return nil } -func visitValueSpec(pctx *parseContext, node *ast.ValueSpec) error { +func visitValueSpec(pctx *parseContext, node *ast.ValueSpec) { var enum *schema.Enum if i, ok := node.Type.(*ast.Ident); ok { enum = pctx.enums[i.Name] } if enum == nil { - return nil + return } c, ok := pctx.pkg.TypesInfo.Defs[node.Names[0]].(*types.Const) if !ok { - return errorf(node, "could not extract enum %s: expected exactly one variant name", enum.Name) - } - value, err := visitConst(c) - if err != nil { - return err + pctx.errors = append(pctx.errors, errorf(node, "could not extract enum %s: expected exactly one variant name", enum.Name)) + return } - variant := &schema.EnumVariant{ - Pos: goPosToSchemaPos(c.Pos()), - Comments: visitComments(node.Doc), - Name: strcase.ToUpperCamel(c.Id()), - Value: value, + if value, ok := visitConst(pctx, c).Get(); ok { + variant := &schema.EnumVariant{ + Pos: goPosToSchemaPos(c.Pos()), + Comments: visitComments(node.Doc), + Name: strcase.ToUpperCamel(c.Id()), + Value: value, + } + enum.Variants = append(enum.Variants, variant) } - enum.Variants = append(enum.Variants, variant) - return nil } -func visitFuncDecl(pctx *parseContext, node *ast.FuncDecl) (verb *schema.Verb, err error) { +func visitFuncDecl(pctx *parseContext, node *ast.FuncDecl) (verb *schema.Verb) { if node.Doc == nil { - return nil, nil + return nil } - directives, err := parseDirectives(fset, node.Doc) + directives, err := parseDirectives(node, fset, node.Doc) if err != nil { - return nil, err + pctx.errors = append(pctx.errors, err) } var metadata []schema.Metadata isVerb := false @@ -451,7 +436,7 @@ func visitFuncDecl(pctx *parseContext, node *ast.FuncDecl) (verb *schema.Verb, e if pctx.module.Name == "" { pctx.module.Name = pctx.pkg.Name } else if pctx.module.Name != pctx.pkg.Name { - return nil, errorf(node, "function export directive must be in the module package") + pctx.errors = append(pctx.errors, errorf(node, "function export directive must be in the module package")) } case *directiveIngress: @@ -475,70 +460,55 @@ func visitFuncDecl(pctx *parseContext, node *ast.FuncDecl) (verb *schema.Verb, e } } if !isVerb { - return nil, nil + return nil } for _, name := range pctx.nativeNames { if name == node.Name.Name { - return nil, errorf(node, "verb %q already exported", node.Name.Name) + pctx.errors = append(pctx.errors, errorf(node, "verb %q already exported", node.Name.Name)) + return nil } } fnt := pctx.pkg.TypesInfo.Defs[node.Name].(*types.Func) //nolint:forcetypeassert sig := fnt.Type().(*types.Signature) //nolint:forcetypeassert if sig.Recv() != nil { - return nil, errorf(node, "ftl:export cannot be a method") + pctx.errors = append(pctx.errors, errorf(node, "ftl:export cannot be a method")) + return nil } params := sig.Params() results := sig.Results() - reqt, respt, err := checkSignature(sig) - if err != nil { - return nil, wrapf(node, err, "") - } - var req schema.Type - if reqt != nil { - req, err = visitType(pctx, node.Pos(), params.At(1).Type()) - if err != nil { - return nil, err - } + reqt, respt := checkSignature(pctx, node, sig) + + var req optional.Option[schema.Type] + if reqt.Ok() { + req = visitType(pctx, node.Pos(), params.At(1).Type()) } else { - req = &schema.Unit{} + req = optional.Some[schema.Type](&schema.Unit{}) } - var resp schema.Type - if respt != nil { - resp, err = visitType(pctx, node.Pos(), results.At(0).Type()) - if err != nil { - return nil, err - } + var resp optional.Option[schema.Type] + if respt.Ok() { + resp = visitType(pctx, node.Pos(), results.At(0).Type()) } else { - resp = &schema.Unit{} + resp = optional.Some[schema.Type](&schema.Unit{}) + } + reqV, reqOk := req.Get() + resV, respOk := resp.Get() + if !reqOk || !respOk { + return nil } + verb = &schema.Verb{ Pos: goPosToSchemaPos(node.Pos()), Comments: visitComments(node.Doc), Name: strcase.ToLowerCamel(node.Name.Name), - Request: req, - Response: resp, + Request: reqV, + Response: resV, Metadata: metadata, } pctx.nativeNames[verb] = node.Name.Name pctx.module.Decls = append(pctx.module.Decls, verb) - return verb, nil -} - -func parsePathComponents(path string, pos schema.Position) []schema.IngressPathComponent { - var out []schema.IngressPathComponent - for _, part := range strings.Split(path, "/") { - if part == "" { - continue - } - if strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") { - out = append(out, &schema.IngressPathParameter{Pos: pos, Name: part[1 : len(part)-1]}) - } else { - out = append(out, &schema.IngressPathLiteral{Pos: pos, Text: part}) - } - } - return out + return verb } func visitComments(doc *ast.CommentGroup) []string { @@ -557,16 +527,18 @@ func ftlModuleFromGoModule(pkgPath string) optional.Option[string] { return optional.Some(strings.TrimSuffix(parts[1], "_test")) } -func visitStruct(pctx *parseContext, pos token.Pos, tnode types.Type) (*schema.Ref, error) { +func visitStruct(pctx *parseContext, pos token.Pos, tnode types.Type) optional.Option[*schema.Ref] { named, ok := tnode.(*types.Named) if !ok { - return nil, tokenErrorf(pos, tnode.String(), "expected named type but got %s", tnode) + pctx.errors = append(pctx.errors, 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 { - return nil, tokenErrorf(pos, nodePath, "struct declared in non-FTL module %s", nodePath) + pctx.errors = append(pctx.errors, tokenErrorf(pos, nodePath, "struct declared in non-FTL module %s", nodePath)) + return optional.None[*schema.Ref]() } dataRef := &schema.Ref{ Pos: goPosToSchemaPos(pos), @@ -574,22 +546,18 @@ func visitStruct(pctx *parseContext, pos token.Pos, tnode types.Type) (*schema.R Name: named.Obj().Name(), } for i := range named.TypeArgs().Len() { - arg := named.TypeArgs().At(i) - typeArg, err := visitType(pctx, pos, arg) - if err != nil { - return nil, tokenWrapf(pos, arg.String(), err, "type parameter %s", arg.String()) - } - - // Fully qualify the Ref if needed - if arg, okArg := typeArg.(*schema.Ref); okArg { - if arg.Module == "" { - arg.Module = destModule + if typeArg, ok := visitType(pctx, pos, named.TypeArgs().At(i)).Get(); ok { + // Fully qualify the Ref if needed + if ref, okArg := typeArg.(*schema.Ref); okArg { + if ref.Module == "" { + ref.Module = destModule + } + typeArg = ref } - typeArg = arg + dataRef.TypeParameters = append(dataRef.TypeParameters, typeArg) } - dataRef.TypeParameters = append(dataRef.TypeParameters, typeArg) } - return dataRef, nil + return optional.Some[*schema.Ref](dataRef) } out := &schema.Data{ @@ -608,11 +576,9 @@ func visitStruct(pctx *parseContext, pos token.Pos, tnode types.Type) (*schema.R Pos: goPosToSchemaPos(pos), Name: param.Obj().Name(), }) - typeArg, err := visitType(pctx, pos, named.TypeArgs().At(i)) - if err != nil { - return nil, tokenWrapf(pos, param.Obj().Name(), err, "type parameter %s", param.Obj().Name()) + if typeArg, ok := visitType(pctx, pos, named.TypeArgs().At(i)).Get(); ok { + dataRef.TypeParameters = append(dataRef.TypeParameters, typeArg) } - dataRef.TypeParameters = append(dataRef.TypeParameters, typeArg) } // If the struct is generic, we need to use the origin type to get the @@ -644,89 +610,93 @@ func visitStruct(pctx *parseContext, pos token.Pos, tnode types.Type) (*schema.R s, ok := named.Underlying().(*types.Struct) if !ok { - return nil, tokenErrorf(pos, named.String(), "expected struct but got %s", named) + pctx.errors = append(pctx.errors, tokenErrorf(pos, named.String(), "expected struct but got %s", named)) + return optional.None[*schema.Ref]() } for i := range s.NumFields() { f := s.Field(i) - ft, err := visitType(pctx, f.Pos(), f.Type()) - if err != nil { - return nil, tokenWrapf(f.Pos(), f.Name(), err, "field %s", f.Name()) - } - - // Check if field is exported - if len(f.Name()) > 0 && unicode.IsLower(rune(f.Name()[0])) { - return nil, tokenErrorf(f.Pos(), f.Name(), "params field %s must be exported by starting with an uppercase letter", f.Name()) - } + 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())) + } - // Extract the JSON tag and split it to get just the field name - tagContent := reflect.StructTag(s.Tag(i)).Get(aliasFieldTag) - tagParts := strings.Split(tagContent, ",") - jsonFieldName := "" - if len(tagParts) > 0 { - jsonFieldName = tagParts[0] - } + // Extract the JSON tag and split it to get just the field name + tagContent := reflect.StructTag(s.Tag(i)).Get(aliasFieldTag) + tagParts := strings.Split(tagContent, ",") + jsonFieldName := "" + if len(tagParts) > 0 { + jsonFieldName = tagParts[0] + } - var metadata []schema.Metadata - if jsonFieldName != "" { - metadata = append(metadata, &schema.MetadataAlias{ - Pos: goPosToSchemaPos(f.Pos()), - Kind: schema.AliasKindJSON, - Alias: jsonFieldName, + var metadata []schema.Metadata + if jsonFieldName != "" { + metadata = append(metadata, &schema.MetadataAlias{ + Pos: goPosToSchemaPos(f.Pos()), + Kind: schema.AliasKindJSON, + Alias: jsonFieldName, + }) + } + out.Fields = append(out.Fields, &schema.Field{ + Pos: goPosToSchemaPos(f.Pos()), + Name: strcase.ToLowerCamel(f.Name()), + Type: ft, + Metadata: metadata, }) } - out.Fields = append(out.Fields, &schema.Field{ - Pos: goPosToSchemaPos(f.Pos()), - Name: strcase.ToLowerCamel(f.Name()), - Type: ft, - Metadata: metadata, - }) } pctx.module.AddData(out) - return dataRef, nil + return optional.Some[*schema.Ref](dataRef) } -func visitConst(cnode *types.Const) (schema.Value, error) { +func visitConst(pctx *parseContext, cnode *types.Const) optional.Option[schema.Value] { 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 + pctx.errors = append(pctx.errors, tokenWrapf(cnode.Pos(), cnode.Val().String(), err, "error parsing string constant")) + return optional.None[schema.Value]() } - return &schema.StringValue{Pos: goPosToSchemaPos(cnode.Pos()), Value: value}, nil + return optional.Some[schema.Value](&schema.StringValue{Pos: goPosToSchemaPos(cnode.Pos()), Value: value}) case types.Int, types.Int64: value, err := strconv.ParseInt(cnode.Val().String(), 10, 64) if err != nil { - return nil, err + pctx.errors = append(pctx.errors, tokenWrapf(cnode.Pos(), cnode.Val().String(), err, "error parsing int constant")) + return optional.None[schema.Value]() } - return &schema.IntValue{Pos: goPosToSchemaPos(cnode.Pos()), Value: int(value)}, nil + return optional.Some[schema.Value](&schema.IntValue{Pos: goPosToSchemaPos(cnode.Pos()), Value: int(value)}) default: - return nil, tokenErrorf(cnode.Pos(), "", "unsupported basic type %s", b) + pctx.errors = append(pctx.errors, noEndColumnErrorf(cnode.Pos(), "unsupported basic type %q", b)) } } - return nil, tokenErrorf(cnode.Pos(), cnode.Type().String(), "unsupported const type %s", cnode.Type()) + pctx.errors = append(pctx.errors, noEndColumnErrorf(cnode.Pos(), "unsupported const type %q", cnode.Type())) + return optional.None[schema.Value]() } -func visitType(pctx *parseContext, pos token.Pos, tnode types.Type) (schema.Type, error) { +func visitType(pctx *parseContext, pos token.Pos, tnode types.Type) optional.Option[schema.Type] { if tparam, ok := tnode.(*types.TypeParam); ok { - return &schema.Ref{Pos: goPosToSchemaPos(pos), Name: tparam.Obj().Id()}, nil + return optional.Some[schema.Type](&schema.Ref{Pos: goPosToSchemaPos(pos), Name: tparam.Obj().Id()}) } switch underlying := tnode.Underlying().(type) { case *types.Basic: if named, ok := tnode.(*types.Named); ok { nodePath := named.Obj().Pkg().Path() if pctx.enums[named.Obj().Name()] != nil { - return &schema.Ref{ + return optional.Some[schema.Type](&schema.Ref{ Pos: goPosToSchemaPos(pos), Module: pctx.module.Name, Name: named.Obj().Name(), - }, nil + }) } else if !strings.HasPrefix(nodePath, pctx.pkg.PkgPath) { // 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/") { - return nil, fmt.Errorf("unsupported external type %s", named.Obj().Pkg().Path()+"."+named.Obj().Name()) + pctx.errors = append(pctx.errors, + noEndColumnErrorf(pos, "unsupported external type %q", named.Obj().Pkg().Path()+"."+named.Obj().Name())) + return optional.None[schema.Type]() } base := path.Dir(pctx.pkg.PkgPath) destModule := path.Base(strings.TrimPrefix(nodePath, base+"/")) @@ -735,53 +705,61 @@ func visitType(pctx *parseContext, pos token.Pos, tnode types.Type) (schema.Type Module: destModule, Name: named.Obj().Name(), } - return enumRef, nil + return optional.Some[schema.Type](enumRef) } } switch underlying.Kind() { case types.String: - return &schema.String{Pos: goPosToSchemaPos(pos)}, nil + return optional.Some[schema.Type](&schema.String{Pos: goPosToSchemaPos(pos)}) case types.Int, types.Int64: - return &schema.Int{Pos: goPosToSchemaPos(pos)}, nil + return optional.Some[schema.Type](&schema.Int{Pos: goPosToSchemaPos(pos)}) case types.Bool: - return &schema.Bool{Pos: goPosToSchemaPos(pos)}, nil + return optional.Some[schema.Type](&schema.Bool{Pos: goPosToSchemaPos(pos)}) case types.Float64: - return &schema.Float{Pos: goPosToSchemaPos(pos)}, nil + return optional.Some[schema.Type](&schema.Float{Pos: goPosToSchemaPos(pos)}) default: - return nil, tokenErrorf(pos, "", "unsupported basic type %s", underlying) + pctx.errors = append(pctx.errors, noEndColumnErrorf(pos, "unsupported basic type %q", underlying)) + return optional.None[schema.Type]() } case *types.Struct: named, ok := tnode.(*types.Named) if !ok { - return visitStruct(pctx, pos, tnode) + if ref, ok := visitStruct(pctx, pos, tnode).Get(); ok { + return optional.Some[schema.Type](ref) + } + return optional.None[schema.Type]() } // Special-cased types. switch named.Obj().Pkg().Path() + "." + named.Obj().Name() { case "time.Time": - return &schema.Time{Pos: goPosToSchemaPos(pos)}, nil + return optional.Some[schema.Type](&schema.Time{Pos: goPosToSchemaPos(pos)}) case "github.com/TBD54566975/ftl/go-runtime/ftl.Unit": - return &schema.Unit{Pos: goPosToSchemaPos(pos)}, nil + return optional.Some[schema.Type](&schema.Unit{Pos: goPosToSchemaPos(pos)}) case "github.com/TBD54566975/ftl/go-runtime/ftl.Option": - underlying, err := visitType(pctx, pos, named.TypeArgs().At(0)) - if err != nil { - return nil, err + if underlying, ok := visitType(pctx, pos, named.TypeArgs().At(0)).Get(); ok { + return optional.Some[schema.Type](&schema.Optional{Pos: goPosToSchemaPos(pos), Type: underlying}) } - return &schema.Optional{Type: underlying}, nil + return optional.None[schema.Type]() + default: nodePath := named.Obj().Pkg().Path() if !strings.HasPrefix(nodePath, pctx.pkg.PkgPath) && !strings.HasPrefix(nodePath, "ftl/") { - return nil, fmt.Errorf("unsupported external type %s", nodePath+"."+named.Obj().Name()) + pctx.errors = append(pctx.errors, noEndColumnErrorf(pos, "unsupported external type %s", nodePath+"."+named.Obj().Name())) + return optional.None[schema.Type]() } - return visitStruct(pctx, pos, tnode) + if ref, ok := visitStruct(pctx, pos, tnode).Get(); ok { + return optional.Some[schema.Type](ref) + } + return optional.None[schema.Type]() } case *types.Map: @@ -792,44 +770,49 @@ func visitType(pctx *parseContext, pos token.Pos, tnode types.Type) (schema.Type case *types.Interface: if underlying.String() == "any" { - return &schema.Any{Pos: goPosToSchemaPos(pos)}, nil + return optional.Some[schema.Type](&schema.Any{Pos: goPosToSchemaPos(pos)}) } - return nil, tokenErrorf(pos, "", "unsupported type %q", tnode) + pctx.errors = append(pctx.errors, noEndColumnErrorf(pos, "unsupported type %q", tnode)) + return optional.None[schema.Type]() default: - return nil, tokenErrorf(pos, "", "unsupported type %q", tnode) + pctx.errors = append(pctx.errors, noEndColumnErrorf(pos, "unsupported type %q", tnode)) + return optional.None[schema.Type]() } } -func visitMap(pctx *parseContext, pos token.Pos, tnode *types.Map) (*schema.Map, error) { - key, err := visitType(pctx, pos, tnode.Key()) - if err != nil { - return nil, err +func visitMap(pctx *parseContext, pos token.Pos, tnode *types.Map) optional.Option[schema.Type] { + key, ok := visitType(pctx, pos, tnode.Key()).Get() + if !ok { + return optional.None[schema.Type]() } - value, err := visitType(pctx, pos, tnode.Elem()) - if err != nil { - return nil, err + + value, ok := visitType(pctx, pos, tnode.Elem()).Get() + if !ok { + return optional.None[schema.Type]() } - return &schema.Map{ + + return optional.Some[schema.Type](&schema.Map{ Pos: goPosToSchemaPos(pos), Key: key, Value: value, - }, nil + }) } -func visitSlice(pctx *parseContext, pos token.Pos, tnode *types.Slice) (schema.Type, error) { +func visitSlice(pctx *parseContext, pos token.Pos, tnode *types.Slice) optional.Option[schema.Type] { // If it's a []byte, treat it as a Bytes type. if basic, ok := tnode.Elem().Underlying().(*types.Basic); ok && basic.Kind() == types.Byte { - return &schema.Bytes{Pos: goPosToSchemaPos(pos)}, nil + return optional.Some[schema.Type](&schema.Bytes{Pos: goPosToSchemaPos(pos)}) } - value, err := visitType(pctx, pos, tnode.Elem()) - if err != nil { - return nil, err + value, ok := visitType(pctx, pos, tnode.Elem()).Get() + if !ok { + return optional.None[schema.Type]() } - return &schema.Array{ + + return optional.Some[schema.Type](&schema.Array{ Pos: goPosToSchemaPos(pos), Element: value, - }, nil + }) } func once[T any](f func() T) func() T { @@ -887,6 +870,7 @@ type parseContext struct { nativeNames NativeNames enums enums activeVerb *schema.Verb + errors []*schema.Error } // 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 7fa7856b81..e17ef0f4b9 100644 --- a/go-runtime/compile/schema_test.go +++ b/go-runtime/compile/schema_test.go @@ -13,6 +13,7 @@ import ( "github.com/alecthomas/participle/v2/lexer" "github.com/TBD54566975/ftl/backend/schema" + "github.com/TBD54566975/ftl/internal/errors" "github.com/TBD54566975/ftl/internal/exec" "github.com/TBD54566975/ftl/internal/log" "github.com/TBD54566975/ftl/internal/slices" @@ -203,9 +204,9 @@ func TestParseDirectives(t *testing.T) { func TestParseTypesTime(t *testing.T) { timeRef := mustLoadRef("time", "Time").Type() - parsed, err := visitType(nil, token.NoPos, timeRef) - assert.NoError(t, err) - _, ok := parsed.(*schema.Time) + parsed, ok := visitType(nil, token.NoPos, timeRef).Get() + assert.True(t, ok) + _, ok = parsed.(*schema.Time) assert.True(t, ok) } @@ -222,8 +223,8 @@ func TestParseBasicTypes(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - parsed, err := visitType(nil, token.NoPos, tt.input) - assert.NoError(t, err) + parsed, ok := visitType(nil, token.NoPos, tt.input).Get() + assert.True(t, ok) assert.Equal(t, tt.expected, parsed) }) } @@ -239,7 +240,32 @@ func TestErrorReporting(t *testing.T) { } pwd, _ := os.Getwd() _, _, err := ExtractModuleSchema("testdata/failing") - assert.EqualError(t, err, filepath.Join(pwd, `testdata/failing/failing.go`)+`:14:2-46: call must have exactly three arguments`) + merr := errors.DeduplicateErrors(errors.UnwrapAll(err)) + schema.SortErrorsByPosition(merr) + + filename := filepath.Join(pwd, `testdata/failing/failing.go`) + assert.EqualError(t, errors.Join(merr...), + filename+":10:13-35: config and secret declarations must have a single string literal argument\n"+ + filename+":13:2-2: unsupported type \"error\"\n"+ + filename+":16:2-2: unsupported basic type \"uint64\"\n"+ + filename+":19:3-3: unexpected token \"verb\" (expected Directive)\n"+ + filename+":26:16-29: call first argument must be a function in an ftl module\n"+ + filename+":27:2-46: call must have exactly three arguments\n"+ + filename+":28:16-25: call first argument must be a function\n"+ + filename+":33:1-2: must have at most two parameters (context.Context, struct)\n"+ + filename+":38:1-2: first parameter must be of type context.Context but is ftl/failing.Request\n"+ + filename+":38:1-2: second parameter must be a struct but is string\n"+ + filename+":43:1-2: second parameter must not be ftl.Unit\n"+ + filename+":48:1-2: first parameter must be context.Context\n"+ + filename+":53:1-2: must have at most two results (struct, error)\n"+ + filename+":58:1-2: must at least return an error\n"+ + filename+":62:1-2: must return an error but is ftl/failing.Response\n"+ + filename+":67:1-2: first result must be a struct but is string\n"+ + filename+":67:1-2: must return an error but is string\n"+ + filename+":67:1-2: second result must not be ftl.Unit\n"+ + filename+":74:1-2: verb \"WrongResponse\" already exported\n"+ + filename+":80:2-12: struct field unexported must be exported by starting with an uppercase letter", + ) } func TestDuplicateVerbNames(t *testing.T) { diff --git a/go-runtime/compile/testdata/failing/failing.go b/go-runtime/compile/testdata/failing/failing.go index e86f6615af..17c24b0e2a 100644 --- a/go-runtime/compile/testdata/failing/failing.go +++ b/go-runtime/compile/testdata/failing/failing.go @@ -3,14 +3,79 @@ package failing import ( "context" + lib "github.com/TBD54566975/ftl/go-runtime/compile/testdata" "github.com/TBD54566975/ftl/go-runtime/ftl" ) -type Request struct{} -type Response struct{} +var empty = ftl.Config[string]("") + +type Request struct { + BadParam error +} +type Response struct { + AnotherBadParam uint64 +} + +//ftl:verb +func WrongDirective(ctx context.Context, req Request) (Response, error) { + return Response{}, nil +} //ftl:export -func FailingVerb(ctx context.Context, req Request) (Response, error) { +func BadCalls(ctx context.Context, req Request) (Response, error) { + ftl.Call(ctx, lib.OtherFunc, lib.Request{}) ftl.Call(ctx, "failing", "failingVerb", req) + ftl.Call(ctx, "failing", req) + return Response{}, nil +} + +//ftl:export +func TooManyParams(ctx context.Context, req Request, req2 Request) (Response, error) { + return Response{}, nil +} + +//ftl:export +func WrongParamOrder(first Request, second string) (Response, error) { + return Response{}, nil +} + +//ftl:export +func UnitSecondParam(ctx context.Context, unit ftl.Unit) (Response, error) { + return Response{}, nil +} + +//ftl:export +func NoParams() (Response, error) { return Response{}, nil } + +//ftl:export +func TooManyReturn(ctx context.Context, req Request) (Response, Response, error) { + return "", Response{}, nil +} + +//ftl:export +func NoReturn(ctx context.Context, req Request) { +} + +//ftl:export +func NoError(ctx context.Context, req Request) Response { + return Response{} +} + +//ftl:export +func WrongResponse(ctx context.Context, req Request) (string, ftl.Unit) { + return "", ftl.Unit{} +} + +// Duplicate +// +//ftl:export +func WrongResponse(ctx context.Context, req Request) (string, ftl.Unit) { + return "", ftl.Unit{} +} + +//ftl:export +type BadStruct struct { + unexported string +} diff --git a/go-runtime/compile/testdata/lib.go b/go-runtime/compile/testdata/lib.go new file mode 100644 index 0000000000..6032f8278f --- /dev/null +++ b/go-runtime/compile/testdata/lib.go @@ -0,0 +1,13 @@ +package lib + +import "context" + +type Request struct { +} + +type Response struct { +} + +func OtherFunc(ctx context.Context, req Request) (Response, error) { + return Response{}, nil +}