diff --git a/.gitignore b/.gitignore index 53c37a1..a1ed284 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -dist \ No newline at end of file +dist +/isucrud diff --git a/dbdoc/dbdoc.go b/dbdoc/dbdoc.go index 3c478c6..b0aaa7c 100644 --- a/dbdoc/dbdoc.go +++ b/dbdoc/dbdoc.go @@ -3,7 +3,6 @@ package dbdoc import ( "fmt" "go/token" - "os" "golang.org/x/tools/go/packages" "golang.org/x/tools/go/ssa" @@ -19,7 +18,7 @@ type Config struct { DestinationFilePath string } -func Run(conf Config) error { +func Run(conf Config) ([]*Node, error) { ctx := &Context{ FileSet: token.NewFileSet(), WorkDir: conf.WorkDir, @@ -27,17 +26,17 @@ func Run(conf Config) error { ssaProgram, pkgs, err := BuildSSA(ctx, conf.BuildArgs) if err != nil { - return fmt.Errorf("failed to build ssa: %w", err) + return nil, fmt.Errorf("failed to build ssa: %w", err) } loopRangeMap, err := BuildLoopRangeMap(ctx) if err != nil { - return fmt.Errorf("failed to build loop range map: %w", err) + return nil, fmt.Errorf("failed to build loop range map: %w", err) } funcs, err := BuildFuncs(ctx, pkgs, ssaProgram, loopRangeMap) if err != nil { - return fmt.Errorf("failed to build funcs: %w", err) + return nil, fmt.Errorf("failed to build funcs: %w", err) } nodes := BuildGraph( @@ -46,18 +45,7 @@ func Run(conf Config) error { conf.IgnoreMain, conf.IgnoreInitialize, ) - f, err := os.Create(conf.DestinationFilePath) - if err != nil { - return fmt.Errorf("failed to make directory: %w", err) - } - defer f.Close() - - err = WriteMermaid(f, nodes) - if err != nil { - return fmt.Errorf("failed to write mermaid: %w", err) - } - - return nil + return nodes, nil } func BuildSSA(ctx *Context, args []string) (*ssa.Program, []*packages.Package, error) { diff --git a/dbdoc/mermaid.go b/dbdoc/mermaid.go deleted file mode 100644 index a65944a..0000000 --- a/dbdoc/mermaid.go +++ /dev/null @@ -1,165 +0,0 @@ -package dbdoc - -import ( - "fmt" - "io" - "log" - "strconv" - "strings" -) - -const ( - funcNodeColor = "1976D2" - tableNodeColor = "795548" - insertLinkColor = "CDDC39" - deleteLinkColor = "F44336" - selectLinkColor = "78909C" - updateLinkColor = "FF9800" - callLinkColor = "BBDEFB" -) - -var ( - nodeTypes = []struct { - name string - label string - color string - valid bool - }{ - NodeTypeTable: {"table", "テーブル", tableNodeColor, true}, - NodeTypeFunction: {"func", "関数", funcNodeColor, true}, - } - edgeTypes = []struct { - label string - color string - valid bool - }{ - EdgeTypeInsert: {"INSERT", insertLinkColor, true}, - EdgeTypeUpdate: {"UPDATE", updateLinkColor, true}, - EdgeTypeDelete: {"DELETE", deleteLinkColor, true}, - EdgeTypeSelect: {"SELECT", selectLinkColor, true}, - EdgeTypeCall: {"関数呼び出し", callLinkColor, true}, - } -) - -func WriteMermaid(w io.StringWriter, nodes []*Node) error { - _, err := w.WriteString("# DB Graph\n") - if err != nil { - return fmt.Errorf("failed to write header: %w", err) - } - - _, err = w.WriteString("node: ") - if err != nil { - return fmt.Errorf("failed to write node description start: %w", err) - } - - for _, nodeType := range nodeTypes { - if nodeType.valid { - _, err = w.WriteString(fmt.Sprintf("![](https://via.placeholder.com/16/%s/FFFFFF/?text=%%20) `%s` ", nodeType.color, nodeType.label)) - if err != nil { - return fmt.Errorf("failed to write node description: %w", err) - } - } - } - - _, err = w.WriteString("\n\n") - if err != nil { - return fmt.Errorf("failed to write node description end: %w", err) - } - - _, err = w.WriteString("edge: ") - if err != nil { - return fmt.Errorf("failed to write edge description start: %w", err) - } - - for _, edgeType := range edgeTypes { - if edgeType.valid { - _, err = w.WriteString(fmt.Sprintf("![](https://via.placeholder.com/16/%s/FFFFFF/?text=%%20) `%s` ", edgeType.color, edgeType.label)) - if err != nil { - return fmt.Errorf("failed to write edge description: %w", err) - } - } - } - - _, err = w.WriteString("\n") - if err != nil { - return fmt.Errorf("failed to write edge description end: %w", err) - } - - _, err = w.WriteString("```mermaid\n" + - "graph LR\n") - if err != nil { - return fmt.Errorf("failed to write header: %w", err) - } - - for _, nodeType := range nodeTypes { - if nodeType.valid { - _, err = w.WriteString(fmt.Sprintf(" classDef %s fill:#%s,fill-opacity:0.5\n", nodeType.name, nodeType.color)) - if err != nil { - return fmt.Errorf("failed to write class def: %w", err) - } - } - } - - edgeLinksMap := map[EdgeType][]string{} - edgeID := 0 - for _, node := range nodes { - var src string - if nodeType := nodeTypes[node.NodeType]; nodeType.valid { - src = fmt.Sprintf("%s[%s]:::%s", node.ID, node.Label, nodeType.name) - } else { - log.Printf("unknown node type: %v\n", node.NodeType) - continue - } - - for _, edge := range node.Edges { - var dst string - if nodeType := nodeTypes[edge.Node.NodeType]; nodeType.valid { - dst = fmt.Sprintf("%s[%s]:::%s", edge.Node.ID, edge.Node.Label, nodeType.name) - } else { - log.Printf("unknown node type: %v\n", node.NodeType) - continue - } - - line := "--" - if edge.InLoop { - line = "==" - } - - var edgeExpr string - if edge.Label == "" { - edgeExpr = fmt.Sprintf("%s>", line) - } else { - edgeExpr = fmt.Sprintf("%s %s %s>", line, edge.Label, line) - } - _, err = w.WriteString(fmt.Sprintf(" %s %s %s\n", src, edgeExpr, dst)) - if err != nil { - return fmt.Errorf("failed to write edge: %w", err) - } - - edgeLinksMap[edge.EdgeType] = append(edgeLinksMap[edge.EdgeType], strconv.Itoa(edgeID)) - - edgeID++ - } - } - - for edgeType, links := range edgeLinksMap { - if len(links) == 0 { - continue - } - if info := edgeTypes[edgeType]; info.valid { - _, err = w.WriteString(fmt.Sprintf(" linkStyle %s stroke:#%s,stroke-width:2px\n", strings.Join(links, ","), info.color)) - if err != nil { - return fmt.Errorf("failed to write link style: %w", err) - } - } else { - log.Printf("unknown edge type: %v\n", edgeType) - } - } - - _, err = w.WriteString("```") - if err != nil { - return fmt.Errorf("failed to write footer: %w", err) - } - - return nil -} diff --git a/go.mod b/go.mod index c57c850..f917535 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/mazrean/isucrud -go 1.22.0 +go 1.23 toolchain go1.22.9 diff --git a/internal/pkg/list/queue.go b/internal/pkg/list/queue.go index 0c13853..1f76794 100644 --- a/internal/pkg/list/queue.go +++ b/internal/pkg/list/queue.go @@ -39,6 +39,19 @@ func (q Queue[T]) Peek() (T, bool) { return e.Value() } +func (q *Queue[T]) Iter(yield func(T) bool) { + for e := q.l.Front(); e.e != nil; e = q.l.Front() { + q.l.Remove(e) + + v, ok := e.Value() + if !ok { + break + } + + yield(v) + } +} + func (q Queue[T]) Clear() { q.l.Init() } diff --git a/internal/ui/asset/template.html b/internal/ui/asset/template.html new file mode 100644 index 0000000..55de7a6 --- /dev/null +++ b/internal/ui/asset/template.html @@ -0,0 +1,41 @@ + + + + + + Mermaid + + + + + + {{if .IsFiltered}}{{end}} +
+ nodes: + {{range .NodeTypes}} + + {{.Label}} + {{end}} +
+ edges: + {{range .EdgeTypes}} + + {{.Label}} + {{end}} +
+
{{.MermaidData}}
+ + + \ No newline at end of file diff --git a/internal/ui/asset/template.md b/internal/ui/asset/template.md new file mode 100644 index 0000000..ccd3222 --- /dev/null +++ b/internal/ui/asset/template.md @@ -0,0 +1,7 @@ +# DB Graph +node: {{range .NodeTypes}}![](https://via.placeholder.com/16/{{.Color}}/FFFFFF/?text=%20) `{{.Label}}` {{end}} + +edge: {{range .EdgeTypes}}![](https://via.placeholder.com/16/{{.Color}}/FFFFFF/?text=%20) `{{.Label}}` {{end}} +```mermaid +{{.MermaidData}} +``` diff --git a/internal/ui/mermaid.go b/internal/ui/mermaid.go new file mode 100644 index 0000000..51e41f9 --- /dev/null +++ b/internal/ui/mermaid.go @@ -0,0 +1,135 @@ +package ui + +import ( + "fmt" + "io" + "log" + "strconv" + "strings" + + "github.com/mazrean/isucrud/dbdoc" +) + +const ( + funcNodeColor = "1976D2" + tableNodeColor = "795548" + insertLinkColor = "CDDC39" + deleteLinkColor = "F44336" + selectLinkColor = "78909C" + updateLinkColor = "FF9800" + callLinkColor = "BBDEFB" +) + +type NodeType struct { + name string + Label string + Color string + valid bool +} + +type EdgeType struct { + Label string + Color string + valid bool +} + +var ( + nodeTypes = []NodeType{ + dbdoc.NodeTypeTable: {"table", "テーブル", tableNodeColor, true}, + dbdoc.NodeTypeFunction: {"func", "関数", funcNodeColor, true}, + } + edgeTypes = []EdgeType{ + dbdoc.EdgeTypeInsert: {"INSERT", insertLinkColor, true}, + dbdoc.EdgeTypeUpdate: {"UPDATE", updateLinkColor, true}, + dbdoc.EdgeTypeDelete: {"DELETE", deleteLinkColor, true}, + dbdoc.EdgeTypeSelect: {"SELECT", selectLinkColor, true}, + dbdoc.EdgeTypeCall: {"関数呼び出し", callLinkColor, true}, + } +) + +func RenderMermaid( + w io.StringWriter, + nodes []*dbdoc.Node, + isHttp bool, +) error { + _, err := w.WriteString("graph LR\n") + if err != nil { + return fmt.Errorf("failed to write header: %w", err) + } + + for _, nodeType := range nodeTypes { + if nodeType.valid { + _, err = w.WriteString(fmt.Sprintf(" classDef %s fill:#%s,fill-opacity:0.5\n", nodeType.name, nodeType.Color)) + if err != nil { + return fmt.Errorf("failed to write class def: %w", err) + } + } + } + + edgeLinksMap := map[dbdoc.EdgeType][]string{} + edgeID := 0 + for _, node := range nodes { + var src string + if nodeType := nodeTypes[node.NodeType]; nodeType.valid { + src = fmt.Sprintf("%s[%s]:::%s", node.ID, node.Label, nodeType.name) + } else { + log.Printf("unknown node type: %v\n", node.NodeType) + continue + } + + for _, edge := range node.Edges { + var dst string + if nodeType := nodeTypes[edge.Node.NodeType]; nodeType.valid { + dst = fmt.Sprintf("%s[%s]:::%s", edge.Node.ID, edge.Node.Label, nodeType.name) + } else { + log.Printf("unknown node type: %v\n", node.NodeType) + continue + } + + line := "--" + if edge.InLoop { + line = "==" + } + + var edgeExpr string + if edge.Label == "" { + edgeExpr = fmt.Sprintf("%s>", line) + } else { + edgeExpr = fmt.Sprintf("%s %s %s>", line, edge.Label, line) + } + _, err = w.WriteString(fmt.Sprintf(" %s %s %s\n", src, edgeExpr, dst)) + if err != nil { + return fmt.Errorf("failed to write edge: %w", err) + } + + edgeLinksMap[edge.EdgeType] = append(edgeLinksMap[edge.EdgeType], strconv.Itoa(edgeID)) + + edgeID++ + } + } + + if isHttp { + for _, node := range nodes { + _, err = w.WriteString(fmt.Sprintf(" click %s \"/?node=%s\"\n", node.ID, node.ID)) + if err != nil { + return fmt.Errorf("failed to write click event: %w", err) + } + } + } + + for edgeType, links := range edgeLinksMap { + if len(links) == 0 { + continue + } + if info := edgeTypes[edgeType]; info.valid { + _, err = w.WriteString(fmt.Sprintf(" linkStyle %s stroke:#%s,stroke-width:2px\n", strings.Join(links, ","), info.Color)) + if err != nil { + return fmt.Errorf("failed to write link style: %w", err) + } + } else { + log.Printf("unknown edge type: %v\n", edgeType) + } + } + + return nil +} diff --git a/internal/ui/render.go b/internal/ui/render.go new file mode 100644 index 0000000..ebdcde1 --- /dev/null +++ b/internal/ui/render.go @@ -0,0 +1,178 @@ +package ui + +import ( + _ "embed" + "fmt" + htmlTemplate "html/template" + "io" + "os" + "strings" + "text/template" + + "github.com/mazrean/isucrud/dbdoc" + "github.com/mazrean/isucrud/internal/pkg/list" +) + +var ( + //go:embed asset/template.md + templateMarkdown string + //go:embed asset/template.html + templateHTML string +) + +type TemplateParam struct { + IsFiltered bool + NodeTypes []NodeType + EdgeTypes []EdgeType + Nodes []*dbdoc.Node + MermaidData string +} + +func RenderMarkdown(dest string, nodes []*dbdoc.Node) error { + f, err := os.Create(dest) + if err != nil { + return fmt.Errorf("failed to make directory: %w", err) + } + defer f.Close() + + sb := &strings.Builder{} + err = RenderMermaid( + sb, + nodes, + false, + ) + if err != nil { + return fmt.Errorf("failed to write mermaid: %w", err) + } + + tmpl, err := template.New("markdown").Parse(templateMarkdown) + if err != nil { + return fmt.Errorf("failed to parse template: %w", err) + } + + err = tmpl.Execute(f, TemplateParam{ + IsFiltered: false, + NodeTypes: nodeTypes[1:], + EdgeTypes: edgeTypes[1:], + Nodes: nodes, + MermaidData: sb.String(), + }) + if err != nil { + return fmt.Errorf("failed to execute template: %w", err) + } + + return nil +} + +func RenderHTML(w io.Writer, nodes []*dbdoc.Node, targetNodeID string) error { + filtered := false + filteredNodes := nodes + if targetNodeID != "" { + filtered = true + filteredNodes = filterNodes(targetNodeID, nodes) + } + + sb := &strings.Builder{} + err := RenderMermaid( + sb, + filteredNodes, + true, + ) + if err != nil { + return fmt.Errorf("failed to write mermaid: %w", err) + } + + tmpl, err := htmlTemplate.New("html").Parse(templateHTML) + if err != nil { + return fmt.Errorf("failed to parse template: %w", err) + } + + err = tmpl.Execute(w, TemplateParam{ + IsFiltered: filtered, + NodeTypes: nodeTypes[1:], + EdgeTypes: edgeTypes[1:], + Nodes: nodes, + MermaidData: sb.String(), + }) + if err != nil { + return fmt.Errorf("failed to execute template: %w", err) + } + + return nil +} + +func filterNodes(targetNodeID string, nodes []*dbdoc.Node) []*dbdoc.Node { + nodeMap := make(map[string]*dbdoc.Node) + for _, node := range nodes { + nodeMap[node.ID] = node + } + + targetNode, exists := nodeMap[targetNodeID] + if !exists { + return []*dbdoc.Node{} + } + + parentMap := make(map[string][]*dbdoc.Node) + for _, node := range nodes { + for _, edge := range node.Edges { + if edge.Node != nil { + parentMap[edge.Node.ID] = append(parentMap[edge.Node.ID], node) + } + } + } + + visited := make(map[string]struct{}, len(nodes)) + relatedNodeMap := make(map[string]*dbdoc.Node) + + childNodeQueue := list.NewQueue[*dbdoc.Node]() + childNodeQueue.Push(targetNode) + for current := range childNodeQueue.Iter { + // Skip if already visited. + if _, ok := visited[current.ID]; ok { + continue + } + visited[current.ID] = struct{}{} + + relatedNodeMap[current.ID] = current + + for _, edge := range current.Edges { + childNodeQueue.Push(edge.Node) + } + } + + visited = make(map[string]struct{}, len(nodes)) + parentNodeQueue := list.NewQueue[*dbdoc.Node]() + parentNodeQueue.Push(targetNode) + for current := range parentNodeQueue.Iter { + // Skip if already visited. + if _, ok := visited[current.ID]; ok { + continue + } + visited[current.ID] = struct{}{} + + relatedNodeMap[current.ID] = current + + for _, parent := range parentMap[current.ID] { + parentNodeQueue.Push(parent) + } + } + + relatedNodes := make([]*dbdoc.Node, 0, len(relatedNodeMap)) + for _, node := range relatedNodeMap { + newEdges := []dbdoc.Edge{} + for _, edge := range node.Edges { + if _, ok := relatedNodeMap[edge.Node.ID]; ok { + newEdges = append(newEdges, edge) + } + } + + relatedNodes = append(relatedNodes, &dbdoc.Node{ + ID: node.ID, + Label: node.Label, + Edges: newEdges, + NodeType: node.NodeType, + }) + } + + return relatedNodes +} diff --git a/main.go b/main.go index b84f054..d1101e2 100644 --- a/main.go +++ b/main.go @@ -3,9 +3,12 @@ package main import ( "flag" "fmt" + "log" + "net/http" "os" "github.com/mazrean/isucrud/dbdoc" + "github.com/mazrean/isucrud/internal/ui" ) var ( @@ -17,6 +20,8 @@ var ( ignores sliceString ignorePrefixes sliceString ignoreMain, ignoreInitialize bool + web bool + addr string ) func init() { @@ -27,6 +32,8 @@ func init() { flag.Var(&ignorePrefixes, "ignorePrefix", "ignore function") flag.BoolVar(&ignoreMain, "ignoreMain", true, "ignore main function") flag.BoolVar(&ignoreInitialize, "ignoreInitialize", true, "ignore functions with 'initialize' in the name") + flag.BoolVar(&web, "web", false, "run as web server") + flag.StringVar(&addr, "addr", "localhost:7070", "address to listen on") } func main() { @@ -42,7 +49,7 @@ func main() { panic(fmt.Errorf("failed to get working directory: %w", err)) } - err = dbdoc.Run(dbdoc.Config{ + nodes, err := dbdoc.Run(dbdoc.Config{ WorkDir: wd, BuildArgs: flag.Args(), IgnoreFuncs: ignores, @@ -54,4 +61,28 @@ func main() { if err != nil { panic(fmt.Errorf("failed to run dbdoc: %w", err)) } + + if web { + mux := http.NewServeMux() + mux.Handle("GET /", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Header.Set("Content-Type", "text/html") + + targetNodeID := r.URL.Query().Get("node") + + err := ui.RenderHTML(w, nodes, targetNodeID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + })) + + log.Printf("open http://%s/ in your browser\n", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + panic(fmt.Errorf("server exit: %w", err)) + } + } else { + err = ui.RenderMarkdown(dst, nodes) + if err != nil { + panic(fmt.Errorf("failed to render markdown: %w", err)) + } + } }