diff --git a/go-runtime/compile/schema.go b/go-runtime/compile/schema.go index f41d14004..688bc6ace 100644 --- a/go-runtime/compile/schema.go +++ b/go-runtime/compile/schema.go @@ -21,7 +21,6 @@ import ( var ( fset = token.NewFileSet() - ftlCallFuncPath = "github.com/TBD54566975/ftl/go-runtime/ftl.Call" ftlFSMFuncPath = "github.com/TBD54566975/ftl/go-runtime/ftl.FSM" ftlTransitionFuncPath = "github.com/TBD54566975/ftl/go-runtime/ftl.Transition" ftlStartFuncPath = "github.com/TBD54566975/ftl/go-runtime/ftl.Start" @@ -117,76 +116,11 @@ func visitCallExpr(pctx *parseContext, node *ast.CallExpr, stack []ast.Node) { if fn == nil { return } - switch fn.FullName() { - case ftlCallFuncPath: - parseCall(pctx, node, stack) - - case ftlFSMFuncPath: + if fn.FullName() == ftlFSMFuncPath { parseFSMDecl(pctx, node, stack) } } -func parseCall(pctx *parseContext, node *ast.CallExpr, stack []ast.Node) { - var activeFuncDecl *ast.FuncDecl - for i := len(stack) - 1; i >= 0; i-- { - if found, ok := stack[i].(*ast.FuncDecl); ok { - activeFuncDecl = found - break - } - // use element - } - if activeFuncDecl == nil { - return - } - expectedVerbName := strcase.ToLowerCamel(activeFuncDecl.Name.Name) - var activeVerb *schema.Verb - for _, decl := range pctx.module.Decls { - if aVerb, ok := decl.(*schema.Verb); ok && aVerb.Name == expectedVerbName { - activeVerb = aVerb - break - } - } - if activeVerb == nil { - return - } - if len(node.Args) != 3 { - pctx.errors.add(errorf(node, "call must have exactly three arguments")) - return - } - ref := parseVerbRef(pctx, node.Args[1]) - if ref == nil { - var suffix string - var ok bool - ref, ok = parseSelectorRef(node.Args[1]) - if ok && pctx.schema.Resolve(ref).Ok() { - suffix = ", does it need to be exported?" - } - if sel, ok := node.Args[1].(*ast.SelectorExpr); ok { - pctx.errors.add(errorf(node.Args[1], "call first argument must be a function but is an unresolved reference to %s.%s%s", sel.X, sel.Sel, suffix)) - } - pctx.errors.add(errorf(node.Args[1], "call first argument must be a function in an ftl module%s", suffix)) - return - } - activeVerb.AddCall(ref) -} - -func parseSelectorRef(node ast.Expr) (*schema.Ref, bool) { - sel, ok := node.(*ast.SelectorExpr) - if !ok { - return nil, false - } - ident, ok := sel.X.(*ast.Ident) - if !ok { - return nil, false - } - return &schema.Ref{ - Pos: goPosToSchemaPos(node.Pos()), - Module: ident.Name, - Name: strcase.ToLowerCamel(sel.Sel.Name), - }, true - -} - func parseVerbRef(pctx *parseContext, node ast.Expr) *schema.Ref { _, verbFn := deref[*types.Func](pctx.pkg, node) if verbFn == nil { diff --git a/go-runtime/compile/schema_test.go b/go-runtime/compile/schema_test.go index 72a036274..b84364848 100644 --- a/go-runtime/compile/schema_test.go +++ b/go-runtime/compile/schema_test.go @@ -248,8 +248,13 @@ func TestExtractModuleSchemaTwo(t *testing.T) { export verb callsTwo(two.Payload) two.Payload +calls two.two + export verb callsTwoAndThree(two.Payload) two.Payload + +calls two.three, two.two + export verb returnsUser(Unit) two.UserResponse + export verb three(two.Payload) two.Payload + export verb two(two.Payload) two.Payload } ` @@ -547,10 +552,10 @@ func TestErrorReporting(t *testing.T) { `31:3-3: unexpected directive "ftl:export" attached for verb, did you mean to use '//ftl:verb export' instead?`, `37:40-40: 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`, + `38:16-29: call first argument must be a function but is an unresolved reference to lib.OtherFunc, does it need to be exported?`, + `38:16-29: call first argument must be a function in an ftl module, does it need to be exported?`, `39:2-46: call must have exactly three arguments`, - `40:16-25: call first argument must be a function in an ftl module`, + `40:16-25: call first argument must be a function in an ftl module, does it need to be exported?`, `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`, diff --git a/go-runtime/compile/testdata/go/two/two.go b/go-runtime/compile/testdata/go/two/two.go index 3bd40ef3a..0dd6e78fa 100644 --- a/go-runtime/compile/testdata/go/two/two.go +++ b/go-runtime/compile/testdata/go/two/two.go @@ -54,11 +54,22 @@ func Two(ctx context.Context, req Payload[string]) (Payload[string], error) { return Payload[string]{}, nil } +//ftl:verb export +func Three(ctx context.Context, req Payload[string]) (Payload[string], error) { + return Payload[string]{}, nil +} + //ftl:verb export func CallsTwo(ctx context.Context, req Payload[string]) (Payload[string], error) { return ftl.Call(ctx, Two, req) } +//ftl:verb export +func CallsTwoAndThree(ctx context.Context, req Payload[string]) (Payload[string], error) { + err := transitiveVerbCall(ctx, req) + return Payload[string]{}, err +} + //ftl:verb export func ReturnsUser(ctx context.Context) (UserResponse, error) { return UserResponse{ @@ -87,3 +98,19 @@ type ExplicitAliasAlias = lib.NonFTLType type TransitiveAliasType lib.NonFTLType type TransitiveAliasAlias = lib.NonFTLType + +type TransitiveAlias lib.NonFTLType + +func transitiveVerbCall(ctx context.Context, req Payload[string]) error { + _, err := ftl.Call(ctx, Two, req) + if err != nil { + return err + } + err = superTransitiveVerbCall(ctx, req) + return err +} + +func superTransitiveVerbCall(ctx context.Context, req Payload[string]) error { + _, err := ftl.Call(ctx, Three, req) + return err +} diff --git a/go-runtime/schema/call/analyzer.go b/go-runtime/schema/call/analyzer.go index 469118a91..aaf455e33 100644 --- a/go-runtime/schema/call/analyzer.go +++ b/go-runtime/schema/call/analyzer.go @@ -5,6 +5,8 @@ import ( "go/types" "strings" + "github.com/TBD54566975/ftl/backend/schema" + "github.com/TBD54566975/ftl/backend/schema/strcase" "github.com/TBD54566975/ftl/go-runtime/schema/common" "github.com/TBD54566975/golang-tools/go/analysis" "github.com/TBD54566975/golang-tools/go/analysis/passes/inspect" @@ -12,6 +14,7 @@ import ( ) const ( + ftlCallFuncPath = "github.com/TBD54566975/ftl/go-runtime/ftl.Call" ftlPkgPath = "github.com/TBD54566975/ftl/go-runtime/ftl" ftlTopicHandleTypeName = "TopicHandle" ) @@ -23,19 +26,73 @@ type Tag struct{} // Tag uniquely identifies the fact type for this extractor. type Fact = common.DefaultFact[Tag] func Extract(pass *analysis.Pass) (interface{}, error) { - //TODO: implement call metadata extraction (for now this just validates all calls) - in := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) //nolint:forcetypeassert nodeFilter := []ast.Node{ + (*ast.FuncDecl)(nil), (*ast.CallExpr)(nil), } + var currentFunc *ast.FuncDecl in.Preorder(nodeFilter, func(n ast.Node) { - node := n.(*ast.CallExpr) //nolint:forcetypeassert - validateCallExpr(pass, node) + switch node := n.(type) { + case *ast.FuncDecl: + currentFunc = node + case *ast.CallExpr: + validateCallExpr(pass, node) + if currentFunc == nil { + return + } + parentFuncObj, ok := common.GetObjectForNode(pass.TypesInfo, currentFunc).Get() + if !ok { + return + } + _, fn := common.Deref[*types.Func](pass, node.Fun) + if fn == nil { + return + } + if fn.FullName() == ftlCallFuncPath { + extractVerbCall(pass, parentFuncObj, node) + return + } + common.MarkFunctionCall(pass, parentFuncObj, fn) + } }) return common.NewExtractorResult(pass), nil } +func extractVerbCall(pass *analysis.Pass, parentFuncObj types.Object, node *ast.CallExpr) { + if len(node.Args) != 3 { + common.Errorf(pass, node, "call must have exactly three arguments") + return + } + ref := parseVerbRef(pass, node.Args[1]) + if ref == nil { + if sel, ok := node.Args[1].(*ast.SelectorExpr); ok { + common.Errorf(pass, node.Args[1], "call first argument must be a function but is an unresolved "+ + "reference to %s.%s, does it need to be exported?", sel.X, sel.Sel) + } + common.Errorf(pass, node.Args[1], "call first argument must be a function in an ftl module, does "+ + "it need to be exported?") + return + } + common.MarkVerbCall(pass, parentFuncObj, ref) +} + +func parseVerbRef(pass *analysis.Pass, node ast.Expr) *schema.Ref { + _, verbFn := common.Deref[*types.Func](pass, node) + if verbFn == nil { + return nil + } + moduleName, err := common.FtlModuleFromGoPackage(verbFn.Pkg().Path()) + if err != nil { + return nil + } + return &schema.Ref{ + Pos: common.GoPosToSchemaPos(pass.Fset, node.Pos()), + Module: moduleName, + Name: strcase.ToLowerCamel(verbFn.Name()), + } +} + // validateCallExpr validates all function calls // checks if the function call is: // - a direct verb call to an external module diff --git a/go-runtime/schema/common/fact.go b/go-runtime/schema/common/fact.go index 53b05428d..9b1fb0012 100644 --- a/go-runtime/schema/common/fact.go +++ b/go-runtime/schema/common/fact.go @@ -99,6 +99,22 @@ type ExternalType struct{} func (*ExternalType) schemaFactValue() {} +// FunctionCall is a fact for marking an outbound function call on a function. +type FunctionCall struct { + // The function being called. + Callee types.Object +} + +func (*FunctionCall) schemaFactValue() {} + +// VerbCall is a fact for marking a call to an FTL verb on a function. +type VerbCall struct { + // The verb being called. + VerbRef *schema.Ref +} + +func (*VerbCall) schemaFactValue() {} + // MarkSchemaDecl marks the given object as having been extracted to the given schema decl. func MarkSchemaDecl(pass *analysis.Pass, obj types.Object, decl schema.Decl) { fact := newFact(pass, obj) @@ -148,6 +164,20 @@ func MarkMaybeTypeEnum(pass *analysis.Pass, obj types.Object, enum *schema.Enum) pass.ExportObjectFact(obj, fact) } +// MarkFunctionCall marks the given object as having an outbound function call. +func MarkFunctionCall(pass *analysis.Pass, obj types.Object, callee types.Object) { + fact := newFact(pass, obj) + fact.Add(&FunctionCall{Callee: callee}) + pass.ExportObjectFact(obj, fact) +} + +// MarkVerbCall marks the given object as having a call to an FTL verb. +func MarkVerbCall(pass *analysis.Pass, obj types.Object, verbRef *schema.Ref) { + fact := newFact(pass, obj) + fact.Add(&VerbCall{VerbRef: verbRef}) + pass.ExportObjectFact(obj, fact) +} + // GetAllFactsExtractionStatus merges schema facts inclusive of all available results and the present pass facts. // For a given object, it provides the current extraction status. // @@ -197,9 +227,9 @@ func GetAllFactsExtractionStatus(pass *analysis.Pass) map[types.Object]SchemaFac return facts } -// GetAllFacts returns all facts of the provided type marked on objects, across the current pass and results from +// GetAllFactsOfType returns all facts of the provided type marked on objects, across the current pass and results from // prior passes. If multiple of the same fact type are marked on a single object, the first fact is returned. -func GetAllFacts[T SchemaFactValue](pass *analysis.Pass) map[types.Object]T { +func GetAllFactsOfType[T SchemaFactValue](pass *analysis.Pass) map[types.Object]T { return getFactsScoped[T](allFacts(pass)) } @@ -265,6 +295,18 @@ func GetFactsForObject[T SchemaFactValue](pass *analysis.Pass, obj types.Object) return facts } +func GetAllFacts(pass *analysis.Pass) map[types.Object][]SchemaFactValue { + facts := make(map[types.Object][]SchemaFactValue) + for _, fact := range allFacts(pass) { + sf, ok := fact.Fact.(SchemaFact) + if !ok { + continue + } + facts[fact.Object] = sf.Get() + } + return facts +} + func allFacts(pass *analysis.Pass) []analysis.ObjectFact { var all []analysis.ObjectFact all = append(all, pass.AllObjectFacts()...) diff --git a/go-runtime/schema/enum/analyzer.go b/go-runtime/schema/enum/analyzer.go index 8b75408ee..b2f0cb766 100644 --- a/go-runtime/schema/enum/analyzer.go +++ b/go-runtime/schema/enum/analyzer.go @@ -67,7 +67,7 @@ func Extract(pass *analysis.Pass, node *ast.TypeSpec, obj types.Object) optional func findValueEnumVariants(pass *analysis.Pass, obj types.Object) []*schema.EnumVariant { var variants []*schema.EnumVariant - for o, fact := range common.GetAllFacts[*common.MaybeValueEnumVariant](pass) { + for o, fact := range common.GetAllFactsOfType[*common.MaybeValueEnumVariant](pass) { if o.Type() == obj.Type() && validateVariant(pass, o, fact.Variant) { variants = append(variants, fact.Variant) } @@ -79,7 +79,7 @@ func findValueEnumVariants(pass *analysis.Pass, obj types.Object) []*schema.Enum } func validateVariant(pass *analysis.Pass, obj types.Object, variant *schema.EnumVariant) bool { - for _, fact := range common.GetAllFacts[*common.ExtractedDecl](pass) { + for _, fact := range common.GetAllFactsOfType[*common.ExtractedDecl](pass) { if fact.Decl == nil { continue } @@ -100,7 +100,7 @@ func validateVariant(pass *analysis.Pass, obj types.Object, variant *schema.Enum func findTypeValueVariants(pass *analysis.Pass, obj types.Object) []*schema.EnumVariant { var variants []*schema.EnumVariant - for vObj, fact := range common.GetAllFacts[*common.MaybeTypeEnumVariant](pass) { + for vObj, fact := range common.GetAllFactsOfType[*common.MaybeTypeEnumVariant](pass) { if fact.Parent != obj { continue } diff --git a/go-runtime/schema/extract.go b/go-runtime/schema/extract.go index dcc43531a..b84a26b01 100644 --- a/go-runtime/schema/extract.go +++ b/go-runtime/schema/extract.go @@ -3,6 +3,8 @@ package schema import ( "fmt" "go/types" + "slices" + "strings" "github.com/TBD54566975/ftl/go-runtime/schema/call" "github.com/TBD54566975/ftl/go-runtime/schema/configsecret" @@ -14,6 +16,8 @@ import ( "github.com/TBD54566975/ftl/go-runtime/schema/typeenum" "github.com/TBD54566975/ftl/go-runtime/schema/typeenumvariant" "github.com/TBD54566975/ftl/go-runtime/schema/valueenumvariant" + checker "github.com/TBD54566975/golang-tools/go/analysis/programmaticchecker" + "github.com/TBD54566975/golang-tools/go/packages" "github.com/alecthomas/types/optional" "github.com/alecthomas/types/tuple" sets "github.com/deckarep/golang-set/v2" @@ -29,8 +33,6 @@ import ( "github.com/TBD54566975/ftl/go-runtime/schema/verb" "github.com/TBD54566975/golang-tools/go/analysis" "github.com/TBD54566975/golang-tools/go/analysis/passes/inspect" - checker "github.com/TBD54566975/golang-tools/go/analysis/programmaticchecker" - "github.com/TBD54566975/golang-tools/go/packages" ) // Extractors contains all schema extractors that will run. @@ -44,7 +46,6 @@ var Extractors = [][]*analysis.Analyzer{ inspect.Analyzer, }, { - call.Extractor, metadata.Extractor, }, { @@ -63,10 +64,10 @@ var Extractors = [][]*analysis.Analyzer{ verb.Extractor, }, { + call.Extractor, // must run after valueenumvariant.Extractor and typeenumvariant.Extractor; // visits a node and aggregates its enum variants if present enum.Extractor, - // must run after topic.Extractor subscription.Extractor, }, { @@ -105,6 +106,173 @@ func Extract(moduleDir string) (Result, error) { return combineAllPackageResults(results, diagnostics) } +type refResultType int + +const ( + failed refResultType = iota + widened +) + +type refResult struct { + typ refResultType + obj types.Object + fqName optional.Option[string] +} + +type combinedData struct { + module *schema.Module + errs []*schema.Error + + nativeNames map[schema.Node]string + functionCalls map[types.Object]sets.Set[types.Object] + verbCalls map[types.Object]sets.Set[*schema.Ref] + refResults map[schema.RefKey]refResult + extractedDecls map[schema.Decl]types.Object + externalTypeAliases sets.Set[*schema.TypeAlias] + // for detecting duplicates + typeUniqueness map[string]tuple.Pair[types.Object, schema.Position] + globalUniqueness map[string]tuple.Pair[types.Object, schema.Position] +} + +func newCombinedData(diagnostics []analysis.SimpleDiagnostic) *combinedData { + return &combinedData{ + errs: diagnosticsToSchemaErrors(diagnostics), + nativeNames: make(map[schema.Node]string), + functionCalls: make(map[types.Object]sets.Set[types.Object]), + verbCalls: make(map[types.Object]sets.Set[*schema.Ref]), + refResults: make(map[schema.RefKey]refResult), + extractedDecls: make(map[schema.Decl]types.Object), + externalTypeAliases: sets.NewSet[*schema.TypeAlias](), + typeUniqueness: make(map[string]tuple.Pair[types.Object, schema.Position]), + globalUniqueness: make(map[string]tuple.Pair[types.Object, schema.Position]), + } +} + +func (cd *combinedData) error(err *schema.Error) { + cd.errs = append(cd.errs, err) +} + +func (cd *combinedData) update(fr finalize.Result) { + for decl, obj := range fr.Extracted { + cd.validateDecl(decl, obj) + cd.extractedDecls[decl] = obj + } + copyFailedRefs(cd.refResults, fr.Failed) + maps.Copy(cd.nativeNames, fr.NativeNames) + maps.Copy(cd.functionCalls, fr.FunctionCalls) + maps.Copy(cd.verbCalls, fr.VerbCalls) +} + +func (cd *combinedData) toResult() Result { + cd.module.AddDecls(maps.Keys(cd.extractedDecls)) + cd.updateDeclVisibility() + cd.propagateTypeErrors() + schema.SortErrorsByPosition(cd.errs) + return Result{ + Module: cd.module, + NativeNames: cd.nativeNames, + Errors: cd.errs, + } +} + +func (cd *combinedData) updateModule(fr finalize.Result) error { + if cd.module == nil { + cd.module = &schema.Module{Name: fr.ModuleName, Comments: fr.ModuleComments} + } else { + if cd.module.Name != fr.ModuleName { + return fmt.Errorf("unexpected schema extraction result module name: %s", fr.ModuleName) + } + if len(cd.module.Comments) == 0 { + cd.module.Comments = fr.ModuleComments + } + } + return nil +} + +func (cd *combinedData) validateDecl(decl schema.Decl, obj types.Object) { + typename := common.GetDeclTypeName(decl) + typeKey := fmt.Sprintf("%s-%s", typename, decl.GetName()) + if value, ok := cd.typeUniqueness[typeKey]; ok && value.A != obj { + cd.error(schema.Errorf(decl.Position(), decl.Position().Column, + "duplicate %s declaration for %q; already declared at %q", typename, + cd.module.Name+"."+decl.GetName(), value.B)) + } else if value, ok := cd.globalUniqueness[decl.GetName()]; ok && value.A != obj { + cd.error(schema.Errorf(decl.Position(), decl.Position().Column, + "schema declaration with name %q already exists for module %q; previously declared at %q", + decl.GetName(), cd.module.Name, value.B)) + } + cd.typeUniqueness[typeKey] = tuple.Pair[types.Object, schema.Position]{A: obj, B: decl.Position()} + cd.globalUniqueness[decl.GetName()] = tuple.Pair[types.Object, schema.Position]{A: obj, B: decl.Position()} +} + +func (cd *combinedData) getVerbCalls(obj types.Object) sets.Set[*schema.Ref] { + calls := sets.NewSet[*schema.Ref]() + if cls, ok := cd.verbCalls[obj]; ok { + calls.Append(cls.ToSlice()...) + } + if fnCall, ok := cd.functionCalls[obj]; ok { + for _, calleeObj := range fnCall.ToSlice() { + calls.Append(cd.getVerbCalls(calleeObj).ToSlice()...) + } + } + return calls +} + +// updateDeclVisibility traverses the module schema via refs and updates visibility as needed. +func (cd *combinedData) updateDeclVisibility() { + for _, d := range cd.module.Decls { + if d.IsExported() { + updateTransitiveVisibility(d, cd.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 (cd *combinedData) propagateTypeErrors() { + _ = schema.VisitWithParent(cd.module, nil, func(n schema.Node, p schema.Node, next func() error) error { //nolint:errcheck + if p == nil { + return next() + } + ref, ok := n.(*schema.Ref) + if !ok { + return next() + } + + result, ok := cd.refResults[ref.ToRefKey()] + if !ok { + return next() + } + + switch result.typ { + case failed: + refNativeName := common.GetNativeName(result.obj) + switch pt := p.(type) { + case *schema.Verb: + if pt.Request == n { + cd.error(schema.Errorf(pt.Request.Position(), pt.Request.Position().Column, + "unsupported request type %q", refNativeName)) + } + if pt.Response == n { + cd.error(schema.Errorf(pt.Response.Position(), pt.Response.Position().Column, + "unsupported response type %q", refNativeName)) + } + case *schema.Field: + cd.error(schema.Errorf(pt.Position(), pt.Position().Column, "unsupported type %q for "+ + "field %q", refNativeName, pt.Name)) + default: + cd.error(schema.Errorf(p.Position(), p.Position().Column, "unsupported type %q", + refNativeName)) + } + case widened: + cd.error(schema.Warnf(n.Position(), n.Position().Column, "external type %q will be "+ + "widened to Any", result.fqName.MustGet())) + } + + return next() + }) +} + func analyzersWithDependencies() []*analysis.Analyzer { var as []*analysis.Analyzer // observes dependencies as specified by tiered list ordering in Extractors and applies the dependency @@ -121,110 +289,63 @@ func analyzersWithDependencies() []*analysis.Analyzer { return as } -type refResultType int - -const ( - failed refResultType = iota - widened -) - -type refResult struct { - typ refResultType - obj types.Object - fqName optional.Option[string] +func dependenciesBeforeIndex(idx int) []*analysis.Analyzer { + var deps []*analysis.Analyzer + for i := range idx { + deps = append(deps, Extractors[i]...) + } + return deps } -// 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) (Result, error) { + cd := newCombinedData(diagnostics) + fResults, ok := results[finalize.Analyzer] if !ok { return Result{}, fmt.Errorf("schema extraction finalizer result not found") } - combined := Result{ - NativeNames: make(map[schema.Node]string), - Errors: diagnosticsToSchemaErrors(diagnostics), - } - refResults := make(map[schema.RefKey]refResult) - extractedDecls := make(map[schema.Decl]types.Object) - // for identifying duplicates - typeUniqueness := make(map[string]tuple.Pair[types.Object, schema.Position]) - globalUniqueness := make(map[string]tuple.Pair[types.Object, schema.Position]) for _, r := range fResults { fr, ok := r.(finalize.Result) if !ok { return Result{}, fmt.Errorf("unexpected schema extraction result type: %T", r) } - if combined.Module == nil { - combined.Module = &schema.Module{Name: fr.ModuleName, Comments: fr.ModuleComments} - } else { - 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 - } - } - copyFailedRefs(refResults, fr.Failed) - for decl, obj := range fr.Extracted { - // check for duplicates and add the Decl to the module schema - typename := common.GetDeclTypeName(decl) - typeKey := fmt.Sprintf("%s-%s", typename, decl.GetName()) - if value, ok := typeUniqueness[typeKey]; ok && value.A != obj { - // decls redeclared in subpackage - combined.Errors = append(combined.Errors, schema.Errorf(decl.Position(), decl.Position().Column, - "duplicate %s declaration for %q; already declared at %q", typename, - combined.Module.Name+"."+decl.GetName(), value.B)) - continue - } - if value, ok := globalUniqueness[decl.GetName()]; ok && value.A != obj { - combined.Errors = append(combined.Errors, schema.Errorf(decl.Position(), decl.Position().Column, - "schema declaration with name %q already exists for module %q; previously declared at %q", - decl.GetName(), combined.Module.Name, value.B)) - } - typeUniqueness[typeKey] = tuple.Pair[types.Object, schema.Position]{A: obj, B: decl.Position()} - globalUniqueness[decl.GetName()] = tuple.Pair[types.Object, schema.Position]{A: obj, B: decl.Position()} - extractedDecls[decl] = obj + if err := cd.updateModule(fr); err != nil { + return Result{}, err } - maps.Copy(combined.NativeNames, fr.NativeNames) + cd.update(fr) } - combined.Module.AddDecls(maps.Keys(extractedDecls)) - externalTypeAliases := sets.NewSet[*schema.TypeAlias]() - for decl, obj := range extractedDecls { - if ta, ok := decl.(*schema.TypeAlias); ok && len(ta.Metadata) > 0 { - fqName, err := goQualifiedNameForWidenedType(obj, ta.Metadata) - if err != nil { - combined.Errors = append(combined.Errors, &schema.Error{Pos: ta.Position(), EndColumn: ta.Pos.Column, - Msg: err.Error(), Level: schema.ERROR}) - continue + for decl, obj := range cd.extractedDecls { + moduleName := cd.module.Name + switch d := decl.(type) { + case *schema.TypeAlias: + if len(d.Metadata) > 0 { + fqName, err := goQualifiedNameForWidenedType(obj, d.Metadata) + if err != nil { + cd.error(&schema.Error{Pos: d.Position(), EndColumn: d.Pos.Column, + Msg: err.Error(), Level: schema.ERROR}) + } + cd.refResults[schema.RefKey{Module: moduleName, Name: d.Name}] = refResult{typ: widened, obj: obj, + fqName: optional.Some(fqName)} + cd.externalTypeAliases.Add(d) + cd.nativeNames[d] = common.GetNativeName(obj) + } + case *schema.Verb: + calls := cd.getVerbCalls(obj).ToSlice() + slices.SortFunc(calls, func(i, j *schema.Ref) int { + if i.Module != j.Module { + return strings.Compare(i.Module, j.Module) + } + return strings.Compare(i.Name, j.Name) + }) + if len(calls) > 0 { + d.Metadata = append(d.Metadata, &schema.MetadataCalls{Calls: calls}) } - refResults[schema.RefKey{Module: combined.Module.Name, Name: ta.Name}] = - refResult{typ: widened, obj: obj, fqName: optional.Some(fqName)} - externalTypeAliases.Add(ta) + default: } - combined.NativeNames[decl] = common.GetNativeName(obj) - } - combined.Errors = append(combined.Errors, propagateTypeErrors(combined.Module, refResults)...) - schema.SortErrorsByPosition(combined.Errors) - updateVisibility(combined.Module) - // TODO: validate schema once we have the full schema here - return combined, nil -} - -func copyFailedRefs(parsedRefs map[schema.RefKey]refResult, failedRefs map[schema.RefKey]types.Object) { - for ref, obj := range failedRefs { - parsedRefs[ref] = refResult{typ: failed, obj: obj} } -} -// updateVisibility traverses the module schema via refs and updates visibility as needed. -func updateVisibility(module *schema.Module) { - for _, d := range module.Decls { - if d.IsExported() { - updateTransitiveVisibility(d, module) - } - } + return cd.toResult(), nil } // updateTransitiveVisibility updates any decls that are transitively visible from d. @@ -264,57 +385,6 @@ 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, - refResults map[schema.RefKey]refResult, -) []*schema.Error { - var errs []*schema.Error - _ = schema.VisitWithParent(module, nil, func(n schema.Node, p schema.Node, next func() error) error { //nolint:errcheck - if p == nil { - return next() - } - ref, ok := n.(*schema.Ref) - if !ok { - return next() - } - - result, ok := refResults[ref.ToRefKey()] - if !ok { - return next() - } - - switch result.typ { - case failed: - refNativeName := common.GetNativeName(result.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)) - } - case widened: - errs = append(errs, schema.Warnf(n.Position(), n.Position().Column, "external type %q will be "+ - "widened to Any", result.fqName.MustGet())) - } - - return next() - }) - return errs -} - func diagnosticsToSchemaErrors(diagnostics []analysis.SimpleDiagnostic) []*schema.Error { if len(diagnostics) == 0 { return nil @@ -331,20 +401,9 @@ 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, - Offset: pos.Offset, - Line: pos.Line, - Column: pos.Column, +func copyFailedRefs(parsedRefs map[schema.RefKey]refResult, failedRefs map[schema.RefKey]types.Object) { + for ref, obj := range failedRefs { + parsedRefs[ref] = refResult{typ: failed, obj: obj} } } @@ -364,3 +423,12 @@ func goQualifiedNameForWidenedType(obj types.Object, metadata []schema.Metadata) } return nativeName, nil } + +func simplePosToSchemaPos(pos analysis.SimplePosition) schema.Position { + return schema.Position{ + Filename: pos.Filename, + Offset: pos.Offset, + Line: pos.Line, + Column: pos.Column, + } +} diff --git a/go-runtime/schema/finalize/analyzer.go b/go-runtime/schema/finalize/analyzer.go index 8eefaa3f5..5d95b1900 100644 --- a/go-runtime/schema/finalize/analyzer.go +++ b/go-runtime/schema/finalize/analyzer.go @@ -12,6 +12,7 @@ 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" ) // Analyzer aggregates the results of all extractors. @@ -34,6 +35,10 @@ type Result struct { Failed map[schema.RefKey]types.Object // Native names that can't be derived outside of the analysis pass. NativeNames map[schema.Node]string + // FunctionCalls contains all function calls; key is the parent function, value is the called functions. + FunctionCalls map[types.Object]sets.Set[types.Object] + // VerbCalls contains all verb calls; key is the parent function, value is the called verbs. + VerbCalls map[types.Object]sets.Set[*schema.Ref] } func Run(pass *analysis.Pass) (interface{}, error) { @@ -45,6 +50,7 @@ func Run(pass *analysis.Pass) (interface{}, error) { failed := make(map[schema.RefKey]types.Object) // for identifying duplicates declKeys := make(map[string]types.Object) + nativeNames := make(map[schema.Node]string) for obj, fact := range common.GetAllFactsExtractionStatus(pass) { switch f := fact.(type) { case *common.ExtractedDecl: @@ -56,24 +62,46 @@ func Run(pass *analysis.Pass) (interface{}, error) { if f.Decl != nil && pass.Pkg.Path() == obj.Pkg().Path() { extracted[f.Decl] = obj declKeys[f.Decl.String()] = obj + nativeNames[f.Decl] = common.GetNativeName(obj) } case *common.FailedExtraction: failed[schema.RefKey{Module: moduleName, Name: strcase.ToUpperCamel(obj.Name())}] = obj } } - nativeNames := make(map[schema.Node]string) - for obj, fact := range common.GetAllFacts[*common.MaybeTypeEnumVariant](pass) { + for obj, fact := range common.GetAllFactsOfType[*common.MaybeTypeEnumVariant](pass) { nativeNames[fact.Variant] = common.GetNativeName(obj) } + fnCalls, verbCalls := getCalls(pass) return Result{ ModuleName: moduleName, ModuleComments: extractModuleComments(pass), Extracted: extracted, Failed: failed, NativeNames: nativeNames, + FunctionCalls: fnCalls, + VerbCalls: verbCalls, }, nil } +func getCalls(pass *analysis.Pass) (functionCalls map[types.Object]sets.Set[types.Object], verbCalls map[types.Object]sets.Set[*schema.Ref]) { + fnCalls := make(map[types.Object]sets.Set[types.Object]) + for obj, fnCall := range common.GetAllFactsOfType[*common.FunctionCall](pass) { + if fnCalls[obj] == nil { + fnCalls[obj] = sets.NewSet[types.Object]() + } + fnCalls[obj].Add(fnCall.Callee) + } + + vCalls := make(map[types.Object]sets.Set[*schema.Ref]) + for obj, vCall := range common.GetAllFactsOfType[*common.VerbCall](pass) { + if vCalls[obj] == nil { + vCalls[obj] = sets.NewSet[*schema.Ref]() + } + vCalls[obj].Add(vCall.VerbRef) + } + return fnCalls, vCalls +} + func extractModuleComments(pass *analysis.Pass) []string { in := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) //nolint:forcetypeassert nodeFilter := []ast.Node{ diff --git a/go-runtime/schema/metadata/analyzer.go b/go-runtime/schema/metadata/analyzer.go index dca95b3fe..2552b0c1f 100644 --- a/go-runtime/schema/metadata/analyzer.go +++ b/go-runtime/schema/metadata/analyzer.go @@ -206,7 +206,7 @@ func canRepeatDirective(dir common.Directive) bool { // TODO: fix - this doesn't work for member functions. // // func getDuplicate(pass *analysis.Pass, name string, newMd *common.ExtractedMetadata) optional.Option[types.Object] { -// for obj, md := range common.GetAllFacts[*common.ExtractedMetadata](pass) { +// for obj, md := range common.GetAllFactsOfType[*common.ExtractedMetadata](pass) { // if reflect.TypeOf(md.Type) == reflect.TypeOf(newMd.Type) && obj.Ref() == name { // return optional.Some(obj) // } diff --git a/go-runtime/schema/transitive/analyzer.go b/go-runtime/schema/transitive/analyzer.go index ebedb0200..bd47fe9b4 100644 --- a/go-runtime/schema/transitive/analyzer.go +++ b/go-runtime/schema/transitive/analyzer.go @@ -116,7 +116,7 @@ func inferDeclType(pass *analysis.Pass, node ast.Node, obj types.Object) optiona } if !common.IsSelfReference(pass, obj, t) { // if this is a type alias and it has enum variants, infer to be a value enum - for o := range common.GetAllFacts[*common.MaybeValueEnumVariant](pass) { + for o := range common.GetAllFactsOfType[*common.MaybeValueEnumVariant](pass) { if o.Type() == obj.Type() { return optional.Some[schema.Decl](&schema.Enum{}) } diff --git a/go-runtime/schema/typeenumvariant/analyzer.go b/go-runtime/schema/typeenumvariant/analyzer.go index a199bc9c3..b8cd94843 100644 --- a/go-runtime/schema/typeenumvariant/analyzer.go +++ b/go-runtime/schema/typeenumvariant/analyzer.go @@ -48,7 +48,7 @@ func extractEnumVariant(pass *analysis.Pass, node *ast.TypeSpec, obj types.Objec if md, ok := common.GetFactForObject[*common.ExtractedMetadata](pass, obj).Get(); ok { variant.Comments = md.Comments } - for o := range common.GetAllFacts[*common.MaybeTypeEnum](pass) { + for o := range common.GetAllFactsOfType[*common.MaybeTypeEnum](pass) { named, ok := pass.TypesInfo.TypeOf(node.Name).(*types.Named) if !ok { continue