From b709b177076dc0d721c17e821a5d53d71ee66146 Mon Sep 17 00:00:00 2001 From: netztaucher Date: Sat, 3 Jan 2026 09:19:07 +0100 Subject: [PATCH] fix(mcp): refactor render engine to use io.Writer to fix EOF error --- main.go | 6 +++--- mcp/main.go | 34 ++++++++--------------------- render/depgraph.go | 47 ++++++++++++++++++++-------------------- render/skyline.go | 54 ++++++++++++++++++++++++---------------------- render/tree.go | 37 +++++++++++++++---------------- 5 files changed, 83 insertions(+), 95 deletions(-) diff --git a/main.go b/main.go index aed252b..9fe4542 100644 --- a/main.go +++ b/main.go @@ -219,9 +219,9 @@ func main() { if *jsonMode { json.NewEncoder(os.Stdout).Encode(project) } else if *skylineMode { - render.Skyline(project, *animateMode) + render.Skyline(os.Stdout, project, *animateMode) } else { - render.Tree(project) + render.Tree(os.Stdout, project) } } @@ -255,7 +255,7 @@ func runDepsMode(absRoot, root string, jsonMode bool, diffRef string, changedFil if jsonMode { json.NewEncoder(os.Stdout).Encode(depsProject) } else { - render.Depgraph(depsProject) + render.Depgraph(os.Stdout, depsProject) } } diff --git a/mcp/main.go b/mcp/main.go index ab5ea1b..85d836d 100644 --- a/mcp/main.go +++ b/mcp/main.go @@ -184,9 +184,9 @@ func handleGetStructure(ctx context.Context, req *mcp.CallToolRequest, input Pat Files: files, } - output := captureOutput(func() { - render.Tree(project) - }) + var buf bytes.Buffer + render.Tree(&buf, project) + output := stripANSI(buf.String()) // Add hub file summary fg, err := scanner.BuildFileGraph(input.Path) @@ -229,9 +229,9 @@ func handleGetDependencies(ctx context.Context, req *mcp.CallToolRequest, input ExternalDeps: scanner.ReadExternalDeps(absRoot), } - output := captureOutput(func() { - render.Depgraph(depsProject) - }) + var buf bytes.Buffer + render.Depgraph(&buf, depsProject) + output := buf.String() return textResult(output), nil, nil } @@ -273,9 +273,9 @@ func handleGetDiff(ctx context.Context, req *mcp.CallToolRequest, input DiffInpu Impact: impact, } - output := captureOutput(func() { - render.Tree(project) - }) + var buf bytes.Buffer + render.Tree(&buf, project) + output := stripANSI(buf.String()) return textResult(output), nil, nil } @@ -472,22 +472,6 @@ func stripANSI(s string) string { return ansiRegex.ReplaceAllString(s, "") } -// captureOutput captures stdout from a function and strips ANSI codes -func captureOutput(f func()) string { - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - f() - - w.Close() - os.Stdout = old - - var buf bytes.Buffer - buf.ReadFrom(r) - return stripANSI(buf.String()) -} - // === WATCH HANDLERS === func handleStartWatch(ctx context.Context, req *mcp.CallToolRequest, input WatchInput) (*mcp.CallToolResult, any, error) { diff --git a/render/depgraph.go b/render/depgraph.go index 0702a7a..bead6bc 100644 --- a/render/depgraph.go +++ b/render/depgraph.go @@ -2,6 +2,7 @@ package render import ( "fmt" + "io" "path/filepath" "regexp" "sort" @@ -48,13 +49,13 @@ func getSystemName(dirPath string) string { } // Depgraph renders the dependency flow visualization -func Depgraph(project scanner.DepsProject) { +func Depgraph(w io.Writer, project scanner.DepsProject) { files := project.Files externalDeps := project.ExternalDeps projectName := filepath.Base(project.Root) if len(files) == 0 { - fmt.Println(" No source files found.") + fmt.Fprintln(w, " No source files found.") return } @@ -127,7 +128,7 @@ func Depgraph(project scanner.DepsProject) { systems[system] = append(systems[system], f) } - fmt.Println() + fmt.Fprintln(w) // Build external deps by language extByLang := make(map[string][]string) @@ -184,12 +185,12 @@ func Depgraph(project scanner.DepsProject) { innerWidth := maxWidth - 2 // Print header box - fmt.Printf("╭%s╮\n", strings.Repeat("─", innerWidth)) + fmt.Fprintf(w, "╭%s╮\n", strings.Repeat("─", innerWidth)) titlePadded := CenterString(title, innerWidth) - fmt.Printf("│%s│\n", titlePadded) + fmt.Fprintf(w, "│%s│\n", titlePadded) if len(depLines) > 0 { - fmt.Printf("├%s┤\n", strings.Repeat("─", innerWidth)) + fmt.Fprintf(w, "├%s┤\n", strings.Repeat("─", innerWidth)) contentWidth := innerWidth - 2 for _, line := range depLines { @@ -200,15 +201,15 @@ func Depgraph(project scanner.DepsProject) { } else { breakAt++ } - fmt.Printf("│ %-*s │\n", contentWidth, line[:breakAt]) + fmt.Fprintf(w, "│ %-*s │\n", contentWidth, line[:breakAt]) line = " " + strings.TrimLeft(line[breakAt:], " ") } - fmt.Printf("│ %-*s │\n", contentWidth, line) + fmt.Fprintf(w, "│ %-*s │\n", contentWidth, line) } } - fmt.Printf("╰%s╯\n", strings.Repeat("─", innerWidth)) - fmt.Println() + fmt.Fprintf(w, "╰%s╯\n", strings.Repeat("─", innerWidth)) + fmt.Fprintln(w) // Sort systems var systemNames []string @@ -239,7 +240,7 @@ func Depgraph(project scanner.DepsProject) { if headerLen < 1 { headerLen = 1 } - fmt.Printf("%s %s\n", systemName, strings.Repeat("═", headerLen)) + fmt.Fprintf(w, "%s %s\n", systemName, strings.Repeat("═", headerLen)) rendered := make(map[string]bool) @@ -282,9 +283,9 @@ func Depgraph(project scanner.DepsProject) { if len(subTargets) > 3 { chain += fmt.Sprintf(" +%d", len(subTargets)-3) } - fmt.Printf(" %s\n", chain) + fmt.Fprintf(w, " %s\n", chain) } else { - fmt.Printf(" %s ───▶ %s\n", nameNoExt, tName) + fmt.Fprintf(w, " %s ───▶ %s\n", nameNoExt, tName) } } else { var targetStrs []string @@ -293,13 +294,13 @@ func Depgraph(project scanner.DepsProject) { } if len(targets) <= 4 { - fmt.Printf(" %s ───▶ %s\n", nameNoExt, strings.Join(targetStrs, ", ")) + fmt.Fprintf(w, " %s ───▶ %s\n", nameNoExt, strings.Join(targetStrs, ", ")) } else { - fmt.Printf(" %s ──┬──▶ %s\n", nameNoExt, targetStrs[0]) + fmt.Fprintf(w, " %s ──┬──▶ %s\n", nameNoExt, targetStrs[0]) for _, t := range targetStrs[1 : len(targetStrs)-1] { - fmt.Printf(" %s ├──▶ %s\n", strings.Repeat(" ", len(nameNoExt)), t) + fmt.Fprintf(w, " %s ├──▶ %s\n", strings.Repeat(" ", len(nameNoExt)), t) } - fmt.Printf(" %s └──▶ %s\n", strings.Repeat(" ", len(nameNoExt)), targetStrs[len(targetStrs)-1]) + fmt.Fprintf(w, " %s └──▶ %s\n", strings.Repeat(" ", len(nameNoExt)), targetStrs[len(targetStrs)-1]) } } @@ -316,10 +317,10 @@ func Depgraph(project scanner.DepsProject) { } if standaloneCount > 0 { - fmt.Printf(" +%d standalone files\n", standaloneCount) + fmt.Fprintf(w, " +%d standalone files\n", standaloneCount) } - fmt.Println() + fmt.Fprintln(w) } // HUBS section @@ -342,12 +343,12 @@ func Depgraph(project scanner.DepsProject) { } if len(hubs) > 0 { - fmt.Println(strings.Repeat("─", 61)) + fmt.Fprintln(w, strings.Repeat("─", 61)) var hubStrs []string for _, h := range hubs { hubStrs = append(hubStrs, fmt.Sprintf("%s (%d←)", extPattern.ReplaceAllString(h.name, ""), h.count)) } - fmt.Printf("HUBS: %s\n", strings.Join(hubStrs, ", ")) + fmt.Fprintf(w, "HUBS: %s\n", strings.Join(hubStrs, ", ")) } } @@ -360,6 +361,6 @@ func Depgraph(project scanner.DepsProject) { for _, targets := range internalDeps { internalCount += len(targets) } - fmt.Printf("%d files · %d functions · %d deps\n", len(files), totalFuncs, internalCount) - fmt.Println() + fmt.Fprintf(w, "%d files · %d functions · %d deps\n", len(files), totalFuncs, internalCount) + fmt.Fprintln(w) } diff --git a/render/skyline.go b/render/skyline.go index 20cd9f9..bd0c593 100644 --- a/render/skyline.go +++ b/render/skyline.go @@ -2,6 +2,7 @@ package render import ( "fmt" + "io" "math" "math/rand/v2" "os" @@ -206,8 +207,8 @@ func createBuildings(sorted []extAgg, width int) []building { return arranged } -// Skyline renders the city skyline visualization -func Skyline(project scanner.Project, animate bool) { +// Skyline renders the city skyline visualization to the given writer +func Skyline(w io.Writer, project scanner.Project, animate bool) { files := project.Files projectName := filepath.Base(project.Root) @@ -221,7 +222,7 @@ func Skyline(project scanner.Project, animate bool) { arranged := createBuildings(sorted, width) if len(arranged) == 0 { - fmt.Println(Dim + "No source files to display" + Reset) + fmt.Fprintln(w, Dim+"No source files to display"+Reset) return } @@ -236,15 +237,16 @@ func Skyline(project scanner.Project, animate bool) { sceneRight := min(width, leftMargin+totalWidth+scenePadding) sceneWidth := sceneRight - sceneLeft - if animate { - renderAnimated(arranged, width, leftMargin, sceneLeft, sceneRight, sceneWidth, codeFiles, projectName, sorted) + // If writer is not os.Stdout, disable animation + if animate && w == os.Stdout { + renderAnimated(w, arranged, width, leftMargin, sceneLeft, sceneRight, sceneWidth, codeFiles, projectName, sorted) } else { - renderStatic(arranged, width, leftMargin, sceneLeft, sceneRight, sceneWidth, codeFiles, projectName, sorted) + renderStatic(w, arranged, width, leftMargin, sceneLeft, sceneRight, sceneWidth, codeFiles, projectName, sorted) } } -// renderStatic renders static skyline -func renderStatic(arranged []building, width, leftMargin, sceneLeft, sceneRight, sceneWidth int, +// renderStatic renders static skyline to the given writer +func renderStatic(w io.Writer, arranged []building, width, leftMargin, sceneLeft, sceneRight, sceneWidth int, codeFiles []scanner.FileInfo, projectName string, sorted []extAgg) { // Build grid grid := make([][]rune, skyHeight+maxHeight+1) @@ -310,7 +312,7 @@ func renderStatic(arranged []building, width, leftMargin, sceneLeft, sceneRight, col += buildingWidth + b.gap } - fmt.Println() + fmt.Fprintln(w) // Print with colors colPositions := make([][3]interface{}, 0) // start, end, color @@ -326,14 +328,14 @@ func renderStatic(arranged []building, width, leftMargin, sceneLeft, sceneRight, ch := grid[row][c] switch ch { case '◐': - fmt.Print(Bold + Yellow + string(ch) + Reset) + fmt.Fprintf(w, "%s%s%s", Bold, Yellow, string(ch), Reset) case '·', '✦', '*': - fmt.Print(DimWhite + string(ch) + Reset) + fmt.Fprintf(w, "%s%s%s", DimWhite, string(ch), Reset) default: - fmt.Print(" ") + fmt.Fprint(w, " ") } } - fmt.Println() + fmt.Fprintln(w) } // Building rows @@ -341,7 +343,7 @@ func renderStatic(arranged []building, width, leftMargin, sceneLeft, sceneRight, for c := 0; c < width; c++ { ch := grid[row][c] if ch == ' ' { - fmt.Print(" ") + fmt.Fprint(w, " ") } else if ch == '▄' { color := White for _, pos := range colPositions { @@ -350,11 +352,11 @@ func renderStatic(arranged []building, width, leftMargin, sceneLeft, sceneRight, break } } - fmt.Print(color + string(ch) + Reset) + fmt.Fprintf(w, "%s%s%s", color, string(ch), Reset) } else if ch == '.' || (ch >= 'a' && ch <= 'z') { - fmt.Print(DimWhite + string(ch) + Reset) + fmt.Fprintf(w, "%s%s%s", DimWhite, string(ch), Reset) } else if (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' { - fmt.Print(BoldWhite + string(ch) + Reset) + fmt.Fprintf(w, "%s%s%s", BoldWhite, string(ch), Reset) } else { color := White for _, pos := range colPositions { @@ -363,28 +365,28 @@ func renderStatic(arranged []building, width, leftMargin, sceneLeft, sceneRight, break } } - fmt.Print(color + string(ch) + Reset) + fmt.Fprintf(w, "%s%s%s", color, string(ch), Reset) } } - fmt.Println() + fmt.Fprintln(w) } // Ground ground := strings.Repeat(" ", max(0, sceneLeft)) + strings.Repeat("▀", sceneWidth) - fmt.Println(DimWhite + ground + Reset) + fmt.Fprintln(w, DimWhite+ground+Reset) // Stats - fmt.Println() + fmt.Fprintln(w) title := fmt.Sprintf("─── %s ───", projectName) - fmt.Printf("%s%s%s\n", BoldWhite, CenterString(title, width), Reset) + fmt.Fprintf(w, "%s%s%s\n", BoldWhite, CenterString(title, width), Reset) var codeSize int64 for _, f := range codeFiles { codeSize += f.Size } stats := fmt.Sprintf("%d languages · %d files · %s", len(sorted), len(codeFiles), formatSize(codeSize)) - fmt.Printf("%s%s%s\n", Cyan, CenterString(stats, width), Reset) - fmt.Println() + fmt.Fprintf(w, "%s%s%s\n", Cyan, CenterString(stats, width), Reset) + fmt.Fprintln(w) } // animationModel holds state for bubbletea animation @@ -596,7 +598,7 @@ func (m animationModel) View() string { } // renderAnimated renders animated skyline using bubbletea -func renderAnimated(arranged []building, width, leftMargin, sceneLeft, sceneRight, sceneWidth int, +func renderAnimated(w io.Writer, arranged []building, width, leftMargin, sceneLeft, sceneRight, sceneWidth int, codeFiles []scanner.FileInfo, projectName string, sorted []extAgg) { // Generate star positions var starPositions [][2]int @@ -638,7 +640,7 @@ func renderAnimated(arranged []building, width, leftMargin, sceneLeft, sceneRigh p.Run() // After animation, print static final frame to main screen - renderStatic(arranged, width, leftMargin, sceneLeft, sceneRight, sceneWidth, codeFiles, projectName, sorted) + renderStatic(w, arranged, width, leftMargin, sceneLeft, sceneRight, sceneWidth, codeFiles, projectName, sorted) } func max(a, b int) int { diff --git a/render/tree.go b/render/tree.go index dfd78d3..f9cfa9f 100644 --- a/render/tree.go +++ b/render/tree.go @@ -2,6 +2,7 @@ package render import ( "fmt" + "io" "os" "path/filepath" "sort" @@ -103,8 +104,8 @@ func formatSize(size int64) string { return fmt.Sprintf("%.1f%s", fsize, units[len(units)-1]) } -// Tree renders the file tree to stdout -func Tree(project scanner.Project) { +// Tree renders the file tree to the given writer +func Tree(w io.Writer, project scanner.Project) { files := project.Files projectName := filepath.Base(project.Root) isDiffMode := project.DiffRef != "" @@ -168,7 +169,7 @@ func Tree(project scanner.Project) { padding := innerWidth - len(titleLine) leftPad := padding / 2 rightPad := padding - leftPad - fmt.Printf("╭%s%s%s╮\n", strings.Repeat("─", leftPad), titleLine, strings.Repeat("─", rightPad)) + fmt.Fprintf(w, "╭%s%s%s╮\n", strings.Repeat("─", leftPad), titleLine, strings.Repeat("─", rightPad)) // Stats line - different for diff mode var statsLine string @@ -181,36 +182,36 @@ func Tree(project scanner.Project) { } else { statsLine = fmt.Sprintf("Files: %d | Size: %s", totalFiles, formatSize(totalSize)) } - fmt.Printf("│ %-*s │\n", innerWidth-2, statsLine) + fmt.Fprintf(w, "│ %-*s │\n", innerWidth-2, statsLine) // Extensions line if extLine != "" { - fmt.Printf("│ %-*s │\n", innerWidth-2, extLine) + fmt.Fprintf(w, "│ %-*s │\n", innerWidth-2, extLine) } - fmt.Printf("╰%s╯\n", strings.Repeat("─", innerWidth)) + fmt.Fprintf(w, "╰%s╯\n", strings.Repeat("─", innerWidth)) // Build and render tree root := buildTreeStructure(files) - fmt.Printf("%s%s%s\n", Bold, projectName, Reset) - printTreeNode(root, "", true, topLarge, 1, maxDepth) + fmt.Fprintf(w, "%s%s%s\n", Bold, projectName, Reset) + printTreeNode(w, root, "", true, topLarge, 1, maxDepth) // Print impact footer for diff mode if isDiffMode && len(project.Impact) > 0 { - fmt.Println() + fmt.Fprintln(w) for _, imp := range project.Impact { files := "files" if imp.UsedBy == 1 { files = "file" } - fmt.Printf("%s⚠ %s is used by %d other %s%s\n", Yellow, imp.File, imp.UsedBy, files, Reset) + fmt.Fprintf(w, "%s⚠ %s is used by %d other %s%s\n", Yellow, imp.File, imp.UsedBy, files, Reset) } } } // printTreeNode recursively prints tree nodes // currentDepth starts at 1 for the root level, maxDepth 0 means unlimited -func printTreeNode(node *treeNode, prefix string, isLast bool, topLarge map[string]bool, currentDepth, maxDepth int) { +func printTreeNode(w io.Writer, node *treeNode, prefix string, isLast bool, topLarge map[string]bool, currentDepth, maxDepth int) { // Check if we've exceeded depth limit if maxDepth > 0 && currentDepth > maxDepth { return @@ -289,7 +290,7 @@ func printTreeNode(node *treeNode, prefix string, isLast bool, topLarge map[stri connector = "└── " } - fmt.Printf("%s%s%s %s/%s %s(%s)%s\n", + fmt.Fprintf(w, "%s%s%s %s/%s %s(%s)%s\n", prefix, connector, BoldBlue, mergedName, Reset, Dim, strings.Join(statsParts, ", "), Reset) newPrefix := prefix + "│ " @@ -325,10 +326,10 @@ func printTreeNode(node *treeNode, prefix string, isLast bool, topLarge map[stri parts = append(parts, fmt.Sprintf("%d files", hiddenFiles)) } } - fmt.Printf("%s└── %s... %s%s\n", newPrefix, Dim, strings.Join(parts, ", "), Reset) + fmt.Fprintf(w, "%s└── %s... %s%s\n", newPrefix, Dim, strings.Join(parts, ", "), Reset) } } else { - printTreeNode(current, newPrefix, isLastDir, topLarge, currentDepth+1, maxDepth) + printTreeNode(w, current, newPrefix, isLastDir, topLarge, currentDepth+1, maxDepth) } } @@ -435,9 +436,9 @@ func printTreeNode(node *treeNode, prefix string, isLast bool, topLarge map[stri // Print in column-major order (like Python) for row := 0; row < numRows; row++ { if row == 0 { - fmt.Printf("%s%s", prefix, connector) + fmt.Fprintf(w, "%s%s", prefix, connector) } else { - fmt.Printf("%s ", prefix) + fmt.Fprintf(w, "%s ", prefix) } for col := 0; col < numCols; col++ { idx := col*numRows + row @@ -448,10 +449,10 @@ func printTreeNode(node *treeNode, prefix string, isLast bool, topLarge map[stri if padding < 0 { padding = 0 } - fmt.Printf("%s%s", e.colored, strings.Repeat(" ", padding)) + fmt.Fprintf(w, "%s%s", e.colored, strings.Repeat(" ", padding)) } } - fmt.Println() + fmt.Fprintln(w) } } }