package exporter import ( "encoding/json" "encoding/xml" "fmt" "io" "github.com/zzet/gortex/internal/graph" ) // WriteGraphML emits the graph as GraphML XML, the de-facto interchange // format for graph visualizers (yEd, Gephi, Cytoscape Desktop, networkx). // // All Gortex node properties are projected to GraphML attributes. // Free-form Meta is JSON-encoded into a single `meta_json` attribute so no // information is lost — viewers that don't care about it ignore it. func WriteGraphML(w io.Writer, g graph.Store, opts Options) (Stats, error) { cw := &countingWriter{w: w} nodes, edges, _ := snapshot(g, opts) stats := Stats{} if _, err := io.WriteString(cw, xml.Header); err != nil { return stats, err } if _, err := io.WriteString(cw, ``+"\n", ); err != nil { return stats, err } // declarations. Any data element used inside / needs // a key. We declare a fixed set covering the strongly-typed gortex fields // plus a `meta_json` catch-all per element type. keys := []graphMLKey{ // Node keys. {ID: "name", For: "node", AttrName: "name", AttrType: "string"}, {ID: "kind", For: "node", AttrName: "kind", AttrType: "string"}, {ID: "qual_name", For: "node", AttrName: "qual_name", AttrType: "string"}, {ID: "file_path", For: "node", AttrName: "file_path", AttrType: "string"}, {ID: "start_line", For: "node", AttrName: "start_line", AttrType: "int"}, {ID: "end_line", For: "node", AttrName: "end_line", AttrType: "int"}, {ID: "language", For: "node", AttrName: "language", AttrType: "string"}, {ID: "repo_prefix", For: "node", AttrName: "repo_prefix", AttrType: "string"}, {ID: "workspace_id", For: "node", AttrName: "workspace_id", AttrType: "string"}, {ID: "project_id", For: "node", AttrName: "project_id", AttrType: "string"}, {ID: "node_meta", For: "node", AttrName: "meta_json", AttrType: "string"}, // Edge keys. {ID: "edge_kind", For: "edge", AttrName: "kind", AttrType: "string"}, {ID: "confidence", For: "edge", AttrName: "confidence", AttrType: "double"}, {ID: "confidence_label", For: "edge", AttrName: "confidence_label", AttrType: "string"}, {ID: "origin", For: "edge", AttrName: "origin", AttrType: "string"}, {ID: "edge_file_path", For: "edge", AttrName: "file_path", AttrType: "string"}, {ID: "edge_line", For: "edge", AttrName: "line", AttrType: "int"}, {ID: "cross_repo", For: "edge", AttrName: "cross_repo", AttrType: "boolean"}, {ID: "edge_meta", For: "edge", AttrName: "meta_json", AttrType: "string"}, } for _, k := range keys { if _, err := fmt.Fprintf(cw, ` `+"\n", k.ID, k.For, k.AttrName, k.AttrType, ); err != nil { return stats, err } } if _, err := io.WriteString(cw, ` `+"\n"); err != nil { return stats, err } for _, n := range nodes { if err := writeGraphMLNode(cw, n); err != nil { return stats, err } stats.NodesWritten++ } for i, e := range edges { if err := writeGraphMLEdge(cw, e, i); err != nil { return stats, err } stats.EdgesWritten++ } if _, err := io.WriteString(cw, " \n\n"); err != nil { return stats, err } stats.BytesWritten = cw.n return stats, nil } type graphMLKey struct { ID string For string AttrName string AttrType string } func writeGraphMLNode(w io.Writer, n *graph.Node) error { if _, err := fmt.Fprintf(w, ` `+"\n", n.ID); err != nil { return err } writeData := func(key, val string) error { if val == "" { return nil } _, err := fmt.Fprintf(w, ` %s`+"\n", key, xmlEscape(val)) return err } writeIntData := func(key string, val int) error { if val <= 0 { return nil } _, err := fmt.Fprintf(w, ` %d`+"\n", key, val) return err } if err := writeData("name", n.Name); err != nil { return err } if err := writeData("kind", string(n.Kind)); err != nil { return err } if err := writeData("qual_name", n.QualName); err != nil { return err } if err := writeData("file_path", n.FilePath); err != nil { return err } if err := writeIntData("start_line", n.StartLine); err != nil { return err } if err := writeIntData("end_line", n.EndLine); err != nil { return err } if err := writeData("language", n.Language); err != nil { return err } if err := writeData("repo_prefix", n.RepoPrefix); err != nil { return err } if err := writeData("workspace_id", n.WorkspaceID); err != nil { return err } if err := writeData("project_id", n.ProjectID); err != nil { return err } if metaJSON := metaToJSON(n.Meta); metaJSON != "" { if err := writeData("node_meta", metaJSON); err != nil { return err } } _, err := io.WriteString(w, " \n") return err } func writeGraphMLEdge(w io.Writer, e *graph.Edge, idx int) error { if _, err := fmt.Fprintf(w, ` `+"\n", idx, e.From, e.To, ); err != nil { return err } writeData := func(key, val string) error { if val == "" { return nil } _, err := fmt.Fprintf(w, ` %s`+"\n", key, xmlEscape(val)) return err } if err := writeData("edge_kind", string(e.Kind)); err != nil { return err } if e.Confidence > 0 { if _, err := fmt.Fprintf(w, ` %g`+"\n", e.Confidence); err != nil { return err } } if err := writeData("confidence_label", e.ConfidenceLabel); err != nil { return err } if err := writeData("origin", e.Origin); err != nil { return err } if err := writeData("edge_file_path", e.FilePath); err != nil { return err } if e.Line > 0 { if _, err := fmt.Fprintf(w, ` %d`+"\n", e.Line); err != nil { return err } } if e.CrossRepo { if _, err := io.WriteString(w, ` true`+"\n"); err != nil { return err } } if metaJSON := metaToJSON(e.Meta); metaJSON != "" { if err := writeData("edge_meta", metaJSON); err != nil { return err } } _, err := io.WriteString(w, " \n") return err } // xmlEscape escapes XML reserved characters for use inside bodies. // Uses encoding/xml's EscapeText behind a small wrapper so this stays // faithful to the standard library's rules without re-implementing them. func xmlEscape(s string) string { var buf escapeBuffer _ = xml.EscapeText(&buf, []byte(s)) return buf.String() } type escapeBuffer struct { bytes []byte } func (e *escapeBuffer) Write(p []byte) (int, error) { e.bytes = append(e.bytes, p...) return len(p), nil } func (e *escapeBuffer) String() string { return string(e.bytes) } // metaToJSON serializes Meta to a JSON object string. Returns empty when // Meta is nil/empty so the caller can omit the data element entirely. func metaToJSON(meta map[string]any) string { if len(meta) == 0 { return "" } // Use the flattened, sanitized, sorted view so the output is stable. pairs := flattenMeta(meta) if len(pairs) == 0 { return "" } out := make(map[string]any, len(pairs)) for _, p := range pairs { out[p.Key] = p.Value } data, err := json.Marshal(out) if err != nil { return "" } return string(data) }