a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
256 lines
7.3 KiB
Go
256 lines
7.3 KiB
Go
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 <data> 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,
|
|
`<graphml xmlns="http://graphml.graphdrawing.org/xmlns" `+
|
|
`xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" `+
|
|
`xsi:schemaLocation="http://graphml.graphdrawing.org/xmlns `+
|
|
`http://graphml.graphdrawing.org/xmlns/1.0/graphml.xsd">`+"\n",
|
|
); err != nil {
|
|
return stats, err
|
|
}
|
|
|
|
// <key> declarations. Any data element used inside <node> / <edge> 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,
|
|
` <key id=%q for=%q attr.name=%q attr.type=%q/>`+"\n",
|
|
k.ID, k.For, k.AttrName, k.AttrType,
|
|
); err != nil {
|
|
return stats, err
|
|
}
|
|
}
|
|
|
|
if _, err := io.WriteString(cw, ` <graph id="G" edgedefault="directed">`+"\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, " </graph>\n</graphml>\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, ` <node id=%q>`+"\n", n.ID); err != nil {
|
|
return err
|
|
}
|
|
writeData := func(key, val string) error {
|
|
if val == "" {
|
|
return nil
|
|
}
|
|
_, err := fmt.Fprintf(w, ` <data key=%q>%s</data>`+"\n", key, xmlEscape(val))
|
|
return err
|
|
}
|
|
writeIntData := func(key string, val int) error {
|
|
if val <= 0 {
|
|
return nil
|
|
}
|
|
_, err := fmt.Fprintf(w, ` <data key=%q>%d</data>`+"\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, " </node>\n")
|
|
return err
|
|
}
|
|
|
|
func writeGraphMLEdge(w io.Writer, e *graph.Edge, idx int) error {
|
|
if _, err := fmt.Fprintf(w,
|
|
` <edge id="e%d" source=%q target=%q>`+"\n",
|
|
idx, e.From, e.To,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
|
|
writeData := func(key, val string) error {
|
|
if val == "" {
|
|
return nil
|
|
}
|
|
_, err := fmt.Fprintf(w, ` <data key=%q>%s</data>`+"\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, ` <data key="confidence">%g</data>`+"\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, ` <data key="edge_line">%d</data>`+"\n", e.Line); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if e.CrossRepo {
|
|
if _, err := io.WriteString(w, ` <data key="cross_repo">true</data>`+"\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, " </edge>\n")
|
|
return err
|
|
}
|
|
|
|
// xmlEscape escapes XML reserved characters for use inside <data> 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)
|
|
}
|