package codegen import ( "sort" "strings" "github.com/zzet/gortex/internal/graph" ) // annotationGen describes the codegen tool an annotation implies and // the member kinds it produces — members that exist after compilation // but never appear in source, so the indexer cannot see them. type annotationGen struct { tool string members []string } // annotationGenerators maps a generation-implying annotation (Java // Lombok, MapStruct, Kotlin compiler plugins) to the tool plus the // members it synthesises. Keyed by the bare annotation name — no `@`, // no package qualifier. var annotationGenerators = map[string]annotationGen{ // Lombok. "Data": {"lombok", []string{"getters", "setters", "equals_hashcode", "tostring", "required_args_constructor"}}, "Value": {"lombok", []string{"getters", "equals_hashcode", "tostring", "all_args_constructor"}}, "Getter": {"lombok", []string{"getters"}}, "Setter": {"lombok", []string{"setters"}}, "Builder": {"lombok", []string{"builder"}}, "SuperBuilder": {"lombok", []string{"builder"}}, "AllArgsConstructor": {"lombok", []string{"all_args_constructor"}}, "NoArgsConstructor": {"lombok", []string{"no_args_constructor"}}, "RequiredArgsConstructor": {"lombok", []string{"required_args_constructor"}}, "EqualsAndHashCode": {"lombok", []string{"equals_hashcode"}}, "ToString": {"lombok", []string{"tostring"}}, "Slf4j": {"lombok", []string{"logger"}}, // MapStruct — generates the Impl implementation class. "Mapper": {"mapstruct", []string{"mapper_impl"}}, // Kotlin compiler plugins (KAPT / kotlin-parcelize). "Parcelize": {"kapt", []string{"parcelable"}}, // CommunityToolkit.Mvvm .NET source generators: [ObservableProperty] // on a field generates the public property + change notification; // [RelayCommand] on a method generates an ICommand property. "ObservableProperty": {"mvvm_toolkit", []string{"observable_property"}}, "RelayCommand": {"mvvm_toolkit", []string{"relay_command"}}, } // AnnotationGeneratedStats reports what MarkAnnotatedGenerated did. type AnnotationGeneratedStats struct { NodesMarked int EdgesAdded int } // normalizeAnnotationName strips a leading `@` and any package // qualifier, leaving the bare annotation name. func normalizeAnnotationName(name string) string { name = strings.TrimPrefix(strings.TrimSpace(name), "@") if i := strings.LastIndexByte(name, '.'); i >= 0 { name = name[i+1:] } return name } // MarkAnnotatedGenerated scans one file's extraction output for // EdgeAnnotated edges that point at a generation-implying annotation // (Lombok / MapStruct / Kotlin codegen) and stamps each annotated // symbol with codegen-visibility metadata: has_generated_members, // codegen_tool, and the generated_members list. It returns the // EdgeGeneratedBy edges the caller must append to result.Edges. // // This makes generated members visible without materialising them: a // `@Data` class is flagged as carrying Lombok-generated accessors, so // dead-code analysis and agents know the fields are not unused and // that a missing getName() is compiler output, not a bug. func MarkAnnotatedGenerated(nodes []*graph.Node, edges []*graph.Edge) (extra []*graph.Edge, stats AnnotationGeneratedStats) { if len(edges) == 0 { return nil, stats } annoName := map[string]string{} // annotation node ID → bare name byID := make(map[string]*graph.Node, len(nodes)) for _, n := range nodes { byID[n.ID] = n if n.Meta != nil { if k, _ := n.Meta["kind"].(string); k == "annotation" { annoName[n.ID] = normalizeAnnotationName(n.Name) } } } seenEdge := map[string]bool{} for _, e := range edges { if e.Kind != graph.EdgeAnnotated { continue } gen, ok := annotationGenerators[annoName[e.To]] if !ok { continue } host := byID[e.From] if host == nil { continue } if host.Meta == nil { host.Meta = map[string]any{} } host.Meta["has_generated_members"] = true host.Meta["codegen_tool"] = gen.tool host.Meta["generated_members"] = mergeStringSet(host.Meta["generated_members"], gen.members) stats.NodesMarked++ key := e.From + "\x00" + gen.tool if !seenEdge[key] { seenEdge[key] = true extra = append(extra, &graph.Edge{ From: e.From, To: "external::generator-tool:" + gen.tool, Kind: graph.EdgeGeneratedBy, FilePath: host.FilePath, Origin: graph.OriginASTResolved, Meta: map[string]any{"tool": gen.tool}, }) stats.EdgesAdded++ } } return extra, stats } // lombokGeneratorTool is the external node every materialized Lombok member // is generated by. const lombokGeneratorTool = "external::generator-tool:lombok" // MaterializeLombokAccessors mints the actual member nodes a Lombok class // generates — getters / setters / a builder() factory + Builder type / an // Slf4j log field — for every class MarkAnnotatedGenerated already flagged // with codegen_tool="lombok". Without these nodes, `obj.getName()` cannot // resolve to a graph node and a call chain through a Lombok accessor breaks. // The synthetic methods carry the same Meta["receiver"] + bare Name shape the // Java method-call resolver expects, so a `user.getName()` lands on the // minted getName. IDs are deterministic and existence-checked, so a // hand-written accessor wins and a reindex never duplicates. func MaterializeLombokAccessors(nodes []*graph.Node) (newNodes []*graph.Node, newEdges []*graph.Edge) { byID := make(map[string]*graph.Node, len(nodes)) for _, n := range nodes { byID[n.ID] = n } // Group instance fields by their owning class (file + receiver). Static // constants (KindConstant) are excluded — Lombok does not accessor them. fieldsByClass := map[string][]*graph.Node{} for _, n := range nodes { if n.Kind != graph.KindField || n.Meta == nil { continue } if recv, _ := n.Meta["receiver"].(string); recv != "" { key := n.FilePath + "\x00" + recv fieldsByClass[key] = append(fieldsByClass[key], n) } } for _, host := range nodes { if host.Kind != graph.KindType && host.Kind != graph.KindInterface { continue } if host.Meta == nil { continue } if tool, _ := host.Meta["codegen_tool"].(string); tool != "lombok" { continue } members := metaStringSlice(host.Meta["generated_members"]) if len(members) == 0 { continue } mset := map[string]bool{} for _, m := range members { mset[m] = true } class := host.Name mint := func(id, name string, kind graph.NodeKind, member string, meta map[string]any) bool { if byID[id] != nil { return false // a hand-written member or an already-minted one wins } meta["synthesized"] = true meta["generated"] = true meta["codegen_tool"] = "lombok" meta["generated_member"] = member node := &graph.Node{ ID: id, Kind: kind, Name: name, FilePath: host.FilePath, StartLine: host.StartLine, EndLine: host.StartLine, Language: host.Language, Meta: meta, } byID[id] = node newNodes = append(newNodes, node) newEdges = append(newEdges, &graph.Edge{From: host.ID, To: id, Kind: graph.EdgeDefines, FilePath: host.FilePath, Line: host.StartLine}, &graph.Edge{From: id, To: lombokGeneratorTool, Kind: graph.EdgeGeneratedBy, FilePath: host.FilePath, Origin: graph.OriginASTInferred, Meta: map[string]any{"tool": "lombok"}}, ) return true } for _, f := range fieldsByClass[host.FilePath+"\x00"+class] { ftype, _ := f.Meta["field_type"].(string) if mset["getters"] { g := lombokGetterName(f.Name, ftype) id := host.FilePath + "::" + class + "." + g if mint(id, g, graph.KindMethod, "getter", map[string]any{"receiver": class, "scope_class": class, "return_type": ftype, "signature": g + "()"}) && ftype != "" { newEdges = append(newEdges, &graph.Edge{From: id, To: "unresolved::" + simpleTypeName(ftype), Kind: graph.EdgeReferences, FilePath: host.FilePath, Origin: graph.OriginASTInferred, Meta: map[string]any{"ref_context": "return"}}) } } if mset["setters"] { s := "set" + pascalCase(f.Name) mint(host.FilePath+"::"+class+"."+s, s, graph.KindMethod, "setter", map[string]any{"receiver": class, "scope_class": class, "return_type": "void", "signature": s + "(" + ftype + ")"}) } } if mset["builder"] { mint(host.FilePath+"::"+class+".builder", "builder", graph.KindMethod, "builder", map[string]any{"receiver": class, "scope_class": class, "is_static": true, "return_type": class + "Builder", "signature": "builder()"}) mint(host.FilePath+"::"+class+"Builder", class+"Builder", graph.KindType, "builder", map[string]any{}) } if mset["logger"] { mint(host.FilePath+"::"+class+".log", "log", graph.KindField, "logger", map[string]any{"receiver": class, "field_type": "org.slf4j.Logger"}) } } return newNodes, newEdges } // lombokGetterName returns the Lombok getter name for a field — `is` for // a boolean (kept as-is when already `is`-prefixed), else `get`. func lombokGetterName(field, ftype string) string { if ftype == "boolean" { if len(field) > 2 && field[0:2] == "is" && field[2] >= 'A' && field[2] <= 'Z' { return field } return "is" + pascalCase(field) } return "get" + pascalCase(field) } // pascalCase upper-cases the first letter of s. func pascalCase(s string) string { if s == "" { return s } return strings.ToUpper(s[:1]) + s[1:] } // simpleTypeName strips generic arguments and a package qualifier from a Java // type, leaving the bare type name for a reference edge. func simpleTypeName(t string) string { if i := strings.IndexByte(t, '<'); i >= 0 { t = t[:i] } t = strings.TrimSpace(t) if i := strings.LastIndexByte(t, '.'); i >= 0 { t = t[i+1:] } return strings.TrimSuffix(t, "[]") } // metaStringSlice coerces a Meta value to []string, tolerating the []any form // a persistence round-trip can produce. func metaStringSlice(v any) []string { switch s := v.(type) { case []string: return s case []any: out := make([]string, 0, len(s)) for _, e := range s { if str, ok := e.(string); ok { out = append(out, str) } } return out } return nil } // mergeStringSet merges add into an existing []string-typed meta value, // returning a sorted, de-duplicated slice. func mergeStringSet(existing any, add []string) []string { set := map[string]bool{} if cur, ok := existing.([]string); ok { for _, s := range cur { set[s] = true } } for _, s := range add { set[s] = true } out := make([]string, 0, len(set)) for s := range set { out = append(out, s) } sort.Strings(out) return out }