diff --git a/MIGRATION_PLAN.md b/MIGRATION_PLAN.md new file mode 100644 index 00000000..f52efd17 --- /dev/null +++ b/MIGRATION_PLAN.md @@ -0,0 +1,186 @@ +# Migration Plan: Terminal Output Redesign + +## Overview + +This document outlines the migration plan from the current messy terminal output system (`treenest.zig`, `dank.zig`, `AnsiWriter.zig`) to the new clean, composable architecture. + +## Current Usage Analysis + +Based on code analysis, the current system is used in: + +### 1. Tracing/Logging (`src/layout.zig`, `src/dom.zig`, etc.) +- **Current**: `Trace = @import("ansi").FileTrace` +- **Pattern**: Hierarchical debug output with `trace.enter()`, `trace.exit()`, `trace.put()`, `trace.fields()` +- **Volume**: Heavy usage across layout engine, DOM manipulation + +### 2. Test Runner (`src/lib/test_runner.zig`) +- **Current**: Complex usage of `TreeNest` and `Dank` for formatted test output +- **Pattern**: Test results, stack traces, progress indicators, colored output +- **Volume**: Moderate usage in test infrastructure + +### 3. Stack Trace Dumping (`src/lib/libansi.zig`) +- **Current**: `dumpConciseStackTrace()` using `tree.dk()` methods +- **Pattern**: Source code display, file paths, syntax highlighting +- **Volume**: Error handling paths + +### 4. General Debug Output (various files) +- **Current**: Ad-hoc usage of `tree.info()`, `tree.note()`, etc. +- **Pattern**: Simple styled output for debugging +- **Volume**: Light usage scattered throughout + +## Migration Strategy + +### Phase 1: Foundation (Week 1) +**Goal**: Establish new system alongside old system + +1. **Create new terminal module** ✅ + - [x] `src/lib/terminal/` directory structure + - [x] Core components (`AnsiStreamer`, `TreeFormatter`, `StyleApplier`, `BufferedTerminal`) + - [x] Main module with convenience functions and helpers + - [x] Comprehensive test coverage + +2. **Integration with existing build system** + - [ ] Update `build.zig` to include new terminal module + - [ ] Add new module to `src/lib/libansi.zig` exports + - [ ] Ensure compatibility with existing imports + +### Phase 2: New Tracing API (Week 2) +**Goal**: Create drop-in replacement for tracing functionality + +1. **Create trace wrapper** + ```zig + // src/lib/terminal/TraceWrapper.zig + pub fn TraceWrapper(comptime Writer: type) type { + return struct { + terminal: BufferedTerminal(Writer, 4096), + + pub fn enter(self: *Self) !void { try self.terminal.enterTree(); } + pub fn exit(self: *Self) void { self.terminal.exitTree(); } + pub fn put(self: *Self, comptime key: []const u8, value: anytype) !void { + // Format key-value pair and write as tree line + } + pub fn fields(self: *Self, comptime label: []const u8, data: anytype) !void { + // Write structured data section + } + }; + } + ``` + +2. **Update import aliases** + ```zig + // src/lib/libansi.zig + pub const FileTrace = terminal.TraceWrapper(std.fs.File.Writer); + // Keep old aliases working during migration + pub const nest = @import("treenest.zig"); // deprecated + ``` + +### Phase 3: Test Runner Migration (Week 3) +**Goal**: Convert test runner to new API + +1. **Create test output helpers** + ```zig + // src/lib/terminal/TestHelpers.zig + pub fn writeTestSuite(terminal: anytype, name: []const u8) !void { ... } + pub fn writeTestCase(terminal: anytype, name: []const u8, result: TestResult) !void { ... } + pub fn writeStackTrace(terminal: anytype, stack_trace: std.builtin.StackTrace) !void { ... } + ``` + +2. **Update test runner** + - Replace `TreeNest` usage with `BufferedTerminal` + - Replace `Dank` helpers with new helper functions + - Maintain exact same output format for compatibility + +### Phase 4: Stack Trace Migration (Week 4) +**Goal**: Convert stack trace dumping + +1. **Create stack trace helpers** + ```zig + // src/lib/terminal/StackTraceHelpers.zig + pub fn writeSourceBlock(terminal: anytype, code: []const u8, highlight_line: ?u64, highlight_col: ?u64) !void { ... } + pub fn writeStackFrame(terminal: anytype, file: []const u8, line: u64, col: u64, func: []const u8) !void { ... } + ``` + +2. **Update libansi.zig functions** + - Convert `dumpConciseStackTrace()` to use new API + - Maintain visual output format + +### Phase 5: General Usage Migration (Week 5) +**Goal**: Convert remaining usage sites + +1. **Create migration helpers** + ```zig + // Temporary compatibility shims + pub fn legacyInfo(terminal: anytype, message: []const u8) !void { + try terminal.helpers.writeInfo(terminal, message); + } + ``` + +2. **Convert each usage site** + - Replace `trace.info()`, `trace.note()` calls + - Update import statements + - Test each conversion + +### Phase 6: Cleanup (Week 6) +**Goal**: Remove old system + +1. **Remove deprecated files** + - [ ] Delete `src/lib/treenest.zig` (650 lines removed) + - [ ] Delete `src/lib/dank.zig` (471 lines removed) + - [ ] Update `src/lib/AnsiWriter.zig` (remove redundant parts) + +2. **Clean up imports** + - Remove deprecated exports from `libansi.zig` + - Update all import statements + - Remove compatibility shims + +3. **Final testing** + - Run full test suite + - Visual regression testing for output formats + - Performance benchmarking + +## Risk Mitigation + +### Compatibility Risks +- **Risk**: Output format changes break automation/scripts +- **Mitigation**: Exact format preservation during migration, extensive testing + +### Performance Risks +- **Risk**: New system slower than old system +- **Mitigation**: Benchmarking at each phase, optimize BufferedTerminal buffer sizes + +### Integration Risks +- **Risk**: Build system issues with new module structure +- **Mitigation**: Gradual integration, maintain old system until fully migrated + +## Success Criteria + +1. **Code Quality** + - [ ] Reduce total lines of code by >50% (current: ~1400 lines, target: <700) + - [ ] All components have >90% test coverage + - [ ] Zero allocation in core hot paths (tracing) + +2. **API Quality** + - [ ] Simple APIs: core components have <10 public methods each + - [ ] Composable: can mix and match components easily + - [ ] Consistent error handling throughout + +3. **Performance** + - [ ] No performance regression in tracing benchmarks + - [ ] Memory usage reduced (no allocations in core paths) + - [ ] Build time not significantly impacted + +4. **Maintainability** + - [ ] Clear separation of concerns + - [ ] Easy to add new domain-specific helpers + - [ ] Comprehensive documentation and examples + +## Timeline Summary + +- **Week 1**: Foundation established ✅ +- **Week 2**: Tracing API replacement +- **Week 3**: Test runner migration +- **Week 4**: Stack trace migration +- **Week 5**: General usage migration +- **Week 6**: Cleanup and removal of old system + +Total effort: ~6 weeks for complete migration while maintaining compatibility. \ No newline at end of file diff --git a/TERMINAL_OUTPUT_REDESIGN.md b/TERMINAL_OUTPUT_REDESIGN.md new file mode 100644 index 00000000..aad01d8b --- /dev/null +++ b/TERMINAL_OUTPUT_REDESIGN.md @@ -0,0 +1,182 @@ +# Terminal Output Redesign Specification + +## Problem Analysis + +The current terminal output system (`treenest.zig`, `dank.zig`, `AnsiWriter.zig`) has several issues: + +### Current Issues +- **Memory allocation**: Components allocate memory for formatting operations +- **Mixed responsibilities**: Tree structure, styling, ANSI output, and domain formatting intertwined +- **Complex APIs**: Many methods with overlapping functionality +- **Inconsistent error handling**: Some methods ignore errors, others propagate +- **Hard to compose**: Monolithic components that try to do everything +- **Not testable**: Complex state makes unit testing difficult + +### Usage Patterns +- Tracing/logging with hierarchical structure +- Test runner output with formatted results +- Stack trace dumping with source code snippets +- Debug output with tree-like visualization + +## New Design Principles + +1. **Separation of Concerns**: Separate tree structure, styling, and ANSI output +2. **Non-allocating**: Use stack buffers, streaming, or caller-provided buffers +3. **Composable**: Small, focused components that can be combined +4. **Simple APIs**: Few methods with clear responsibilities +5. **Correct Error Handling**: Consistent error propagation throughout +6. **Zero-cost Abstractions**: No runtime overhead for unused features + +## Core Architecture + +### 1. AnsiStreamer - ANSI Escape Sequence Generation +**Responsibility**: Generate ANSI escape sequences without allocation + +```zig +pub const AnsiStreamer = struct { + no_color: bool = false, + + // Non-allocating methods that write directly to provided writer + pub fn resetStyle(writer: anytype) !void + pub fn setForegroundRgb(writer: anytype, r: u8, g: u8, b: u8) !void + pub fn setBold(writer: anytype) !void + pub fn moveCursor(writer: anytype, row: u32, col: u32) !void + // ... other ANSI operations +}; +``` + +### 2. TreeFormatter - Tree Structure Rendering +**Responsibility**: Format hierarchical tree structures without allocation + +```zig +pub const TreeFormatter = struct { + depth: u8 = 0, + stack: [32]LevelState, // Fixed-size stack + + pub const LevelState = struct { has_more: bool }; + + pub fn enter(self: *Self) !void + pub fn exit(self: *Self) !void + pub fn writeIndent(self: *Self, writer: anytype, is_last: bool) !void + pub fn writeContinuation(self: *Self, writer: anytype) !void +}; +``` + +### 3. StyleApplier - Style Management +**Responsibility**: Apply styles without allocation using streaming approach + +```zig +pub const Style = packed struct { + fg_r: u8 = 0, fg_g: u8 = 0, fg_b: u8 = 0, has_fg: bool = false, + bg_r: u8 = 0, bg_g: u8 = 0, bg_b: u8 = 0, has_bg: bool = false, + bold: bool = false, + italic: bool = false, + underline: bool = false, +}; + +pub const StyleApplier = struct { + current: Style = .{}, + + pub fn apply(self: *Self, writer: anytype, ansi: *AnsiStreamer, new_style: Style) !void + pub fn reset(self: *Self, writer: anytype, ansi: *AnsiStreamer) !void +}; +``` + +### 4. BufferedTerminal - Output Coordination +**Responsibility**: Coordinate the other components with optional buffering + +```zig +pub fn BufferedTerminal(comptime Writer: type, comptime buffer_size: usize) type { + return struct { + writer: Writer, + buffer: [buffer_size]u8, + pos: usize = 0, + + ansi: AnsiStreamer, + tree: TreeFormatter, + style: StyleApplier, + + pub fn init(writer: Writer, no_color: bool) Self + pub fn flush(self: *Self) !void + pub fn writeStyled(self: *Self, text: []const u8, style: Style) !void + pub fn enterTree(self: *Self) !void + pub fn exitTree(self: *Self) !void + pub fn writeTreeLine(self: *Self, text: []const u8, style: Style, is_last: bool) !void + }; +} +``` + +## API Usage Examples + +### Basic Styled Output +```zig +var terminal = BufferedTerminal(File.Writer, 4096).init(stdout.writer(), false); + +// Simple styled text +try terminal.writeStyled("Hello", .{ .fg_r = 255, .has_fg = true, .bold = true }); + +// Tree structure +try terminal.enterTree(); +try terminal.writeTreeLine("Root", .{}, false); +try terminal.enterTree(); +try terminal.writeTreeLine("Child 1", .{ .fg_g = 200, .has_fg = true }, false); +try terminal.writeTreeLine("Child 2", .{ .fg_g = 200, .has_fg = true }, true); +try terminal.exitTree(); +try terminal.exitTree(); +``` + +### Domain-Specific Extensions +```zig +// Extensions built on top of core components +pub fn writeTestResult(terminal: anytype, name: []const u8, passed: bool) !void { + const style = if (passed) + Style{ .fg_g = 200, .has_fg = true } + else + Style{ .fg_r = 200, .has_fg = true }; + const icon = if (passed) "✓" else "✗"; + + try terminal.writeStyled(icon, style); + try terminal.writeStyled(" ", .{}); + try terminal.writeStyled(name, .{}); +} +``` + +## Migration Strategy + +### Phase 1: Core Components +1. Implement `AnsiStreamer` +2. Implement `TreeFormatter` +3. Implement `StyleApplier` +4. Implement `BufferedTerminal` +5. Write comprehensive tests + +### Phase 2: Domain Extensions +1. Create domain-specific helper functions (test output, tracing, etc.) +2. Port existing `dank.zig` functionality as composable functions + +### Phase 3: Incremental Migration +1. Update `libansi.zig` to expose new API alongside old +2. Port test runner to new API +3. Port tracing usage sites +4. Remove old components + +## Benefits + +1. **Memory Efficient**: No allocations in core paths +2. **Composable**: Components can be mixed and matched +3. **Testable**: Simple, focused components are easy to test +4. **Fast**: Direct streaming with minimal overhead +5. **Flexible**: Easy to extend with domain-specific functionality +6. **Maintainable**: Clear separation of concerns + +## File Structure + +``` +src/lib/terminal/ +├── AnsiStreamer.zig # ANSI escape sequences +├── TreeFormatter.zig # Tree structure rendering +├── StyleApplier.zig # Style management +├── BufferedTerminal.zig # Coordinating component +├── helpers.zig # Domain-specific helpers +└── terminal.zig # Main module exports +``` \ No newline at end of file diff --git a/src/lib/terminal/AnsiStreamer.zig b/src/lib/terminal/AnsiStreamer.zig new file mode 100644 index 00000000..39196c23 --- /dev/null +++ b/src/lib/terminal/AnsiStreamer.zig @@ -0,0 +1,177 @@ +const std = @import("std"); + +/// Non-allocating ANSI escape sequence generator +/// Writes ANSI codes directly to any writer without allocation +pub const AnsiStreamer = struct { + no_color: bool = false, + + pub fn init(no_color: bool) AnsiStreamer { + return .{ .no_color = no_color }; + } + + // === Screen Management === + + pub fn clearScreen(self: AnsiStreamer, writer: anytype) !void { + if (self.no_color) return; + try writer.writeAll("\x1b[2J"); + } + + pub fn moveCursorHome(self: AnsiStreamer, writer: anytype) !void { + if (self.no_color) return; + try writer.writeAll("\x1b[H"); + } + + pub fn moveCursor(self: AnsiStreamer, writer: anytype, row: u32, col: u32) !void { + if (self.no_color) return; + try writer.print("\x1b[{d};{d}H", .{ row, col }); + } + + pub fn hideCursor(self: AnsiStreamer, writer: anytype) !void { + if (self.no_color) return; + try writer.writeAll("\x1b[?25l"); + } + + pub fn showCursor(self: AnsiStreamer, writer: anytype) !void { + if (self.no_color) return; + try writer.writeAll("\x1b[?25h"); + } + + // === Style Management === + + pub fn resetStyle(self: AnsiStreamer, writer: anytype) !void { + if (self.no_color) return; + try writer.writeAll("\x1b[0m"); + } + + pub fn setBold(self: AnsiStreamer, writer: anytype) !void { + if (self.no_color) return; + try writer.writeAll("\x1b[1m"); + } + + pub fn setDim(self: AnsiStreamer, writer: anytype) !void { + if (self.no_color) return; + try writer.writeAll("\x1b[2m"); + } + + pub fn setItalic(self: AnsiStreamer, writer: anytype) !void { + if (self.no_color) return; + try writer.writeAll("\x1b[3m"); + } + + pub fn setUnderline(self: AnsiStreamer, writer: anytype) !void { + if (self.no_color) return; + try writer.writeAll("\x1b[4m"); + } + + pub fn resetBold(self: AnsiStreamer, writer: anytype) !void { + if (self.no_color) return; + try writer.writeAll("\x1b[22m"); + } + + pub fn resetDim(self: AnsiStreamer, writer: anytype) !void { + if (self.no_color) return; + try writer.writeAll("\x1b[22m"); + } + + pub fn resetItalic(self: AnsiStreamer, writer: anytype) !void { + if (self.no_color) return; + try writer.writeAll("\x1b[23m"); + } + + pub fn resetUnderline(self: AnsiStreamer, writer: anytype) !void { + if (self.no_color) return; + try writer.writeAll("\x1b[24m"); + } + + // === Color Management === + + pub fn setForegroundRgb(self: AnsiStreamer, writer: anytype, r: u8, g: u8, b: u8) !void { + if (self.no_color) return; + try writer.print("\x1b[38;2;{d};{d};{d}m", .{ r, g, b }); + } + + pub fn setBackgroundRgb(self: AnsiStreamer, writer: anytype, r: u8, g: u8, b: u8) !void { + if (self.no_color) return; + try writer.print("\x1b[48;2;{d};{d};{d}m", .{ r, g, b }); + } + + pub fn resetForeground(self: AnsiStreamer, writer: anytype) !void { + if (self.no_color) return; + try writer.writeAll("\x1b[39m"); + } + + pub fn resetBackground(self: AnsiStreamer, writer: anytype) !void { + if (self.no_color) return; + try writer.writeAll("\x1b[49m"); + } + + // === Alternate Screen Buffer === + + pub fn enterAlternateScreen(self: AnsiStreamer, writer: anytype) !void { + if (self.no_color) return; + try writer.writeAll("\x1b[?1049h"); + } + + pub fn exitAlternateScreen(self: AnsiStreamer, writer: anytype) !void { + if (self.no_color) return; + try writer.writeAll("\x1b[?1049l"); + } + + // === Convenience Combinations === + + pub fn initializeTerminal(self: AnsiStreamer, writer: anytype) !void { + try self.enterAlternateScreen(writer); + try self.hideCursor(writer); + try self.clearScreen(writer); + try self.moveCursorHome(writer); + } + + pub fn restoreTerminal(self: AnsiStreamer, writer: anytype) !void { + try self.resetStyle(writer); + try self.showCursor(writer); + try self.exitAlternateScreen(writer); + } +}; + +// === Tests === + +test "AnsiStreamer basic operations" { + var buffer = std.ArrayList(u8).init(std.testing.allocator); + defer buffer.deinit(); + + const ansi = AnsiStreamer.init(false); + + try ansi.resetStyle(buffer.writer()); + try ansi.moveCursor(buffer.writer(), 10, 20); + try ansi.setForegroundRgb(buffer.writer(), 255, 128, 64); + + const expected = "\x1b[0m\x1b[10;20H\x1b[38;2;255;128;64m"; + try std.testing.expectEqualStrings(expected, buffer.items); +} + +test "AnsiStreamer no color mode" { + var buffer = std.ArrayList(u8).init(std.testing.allocator); + defer buffer.deinit(); + + const ansi = AnsiStreamer.init(true); // no_color = true + + try ansi.resetStyle(buffer.writer()); + try ansi.setBold(buffer.writer()); + try ansi.setForegroundRgb(buffer.writer(), 255, 0, 0); + + // Should produce no output in no-color mode + try std.testing.expectEqualStrings("", buffer.items); +} + +test "AnsiStreamer terminal initialization" { + var buffer = std.ArrayList(u8).init(std.testing.allocator); + defer buffer.deinit(); + + const ansi = AnsiStreamer.init(false); + + try ansi.initializeTerminal(buffer.writer()); + try ansi.restoreTerminal(buffer.writer()); + + const expected = "\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H\x1b[0m\x1b[?25h\x1b[?1049l"; + try std.testing.expectEqualStrings(expected, buffer.items); +} \ No newline at end of file diff --git a/src/lib/terminal/BufferedTerminal.zig b/src/lib/terminal/BufferedTerminal.zig new file mode 100644 index 00000000..d349e2ab --- /dev/null +++ b/src/lib/terminal/BufferedTerminal.zig @@ -0,0 +1,278 @@ +const std = @import("std"); +const AnsiStreamer = @import("AnsiStreamer.zig").AnsiStreamer; +const TreeFormatter = @import("TreeFormatter.zig").TreeFormatter; +const StyleApplier = @import("StyleApplier.zig"); + +pub const Style = StyleApplier.Style; + +/// Buffered terminal that coordinates ANSI output, tree formatting, and styling +pub fn BufferedTerminal(comptime Writer: type, comptime buffer_size: usize) type { + return struct { + const Self = @This(); + + writer: Writer, + buffer: [buffer_size]u8, + pos: usize = 0, + + ansi: AnsiStreamer, + tree: TreeFormatter, + style: StyleApplier.StyleApplier, + + pub fn init(writer: Writer, no_color: bool) Self { + return .{ + .writer = writer, + .buffer = [_]u8{0} ** buffer_size, + .ansi = AnsiStreamer.init(no_color), + .tree = TreeFormatter.init(), + .style = StyleApplier.StyleApplier.init(), + }; + } + + /// Flush buffered content to the underlying writer + pub fn flush(self: *Self) !void { + if (self.pos > 0) { + try self.writer.writeAll(self.buffer[0..self.pos]); + self.pos = 0; + } + } + + /// Get a writer that writes to the internal buffer + fn bufferWriter(self: *Self) BufferWriter { + return BufferWriter{ .terminal = self }; + } + + /// Internal writer that automatically flushes when buffer is full + const BufferWriter = struct { + terminal: *Self, + + const WriteError = error{BufferFull} || @TypeOf(@as(Writer, undefined)).Error; + + pub fn writeAll(self: BufferWriter, bytes: []const u8) WriteError!void { + // If data is too large for buffer, flush and write directly + if (bytes.len > buffer_size) { + try self.terminal.flush(); + return self.terminal.writer.writeAll(bytes); + } + + // If buffer would overflow, flush first + if (self.terminal.pos + bytes.len > buffer_size) { + try self.terminal.flush(); + } + + // Copy to buffer + @memcpy(self.terminal.buffer[self.terminal.pos..self.terminal.pos + bytes.len], bytes); + self.terminal.pos += bytes.len; + } + + pub fn print(self: BufferWriter, comptime fmt: []const u8, args: anytype) WriteError!void { + // Estimate size needed - use a reasonable stack buffer + var temp_buffer: [512]u8 = undefined; + const formatted = std.fmt.bufPrint(&temp_buffer, fmt, args) catch { + // If too large for stack buffer, flush and write directly + try self.terminal.flush(); + return self.terminal.writer.print(fmt, args); + }; + try self.writeAll(formatted); + } + }; + + // === Basic Output === + + pub fn writeText(self: *Self, text: []const u8) !void { + try self.bufferWriter().writeAll(text); + } + + pub fn writeStyledText(self: *Self, text: []const u8, style: Style) !void { + const writer = self.bufferWriter(); + try self.style.apply(writer, &self.ansi, style); + try writer.writeAll(text); + } + + pub fn writeLine(self: *Self, text: []const u8) !void { + const writer = self.bufferWriter(); + try writer.writeAll(text); + try writer.writeAll("\n"); + } + + pub fn writeStyledLine(self: *Self, text: []const u8, style: Style) !void { + const writer = self.bufferWriter(); + try self.style.apply(writer, &self.ansi, style); + try writer.writeAll(text); + try writer.writeAll("\n"); + } + + // === Tree Output === + + pub fn enterTree(self: *Self) !void { + try self.tree.enter(); + } + + pub fn exitTree(self: *Self) void { + self.tree.exit(); + } + + pub fn writeTreeNode(self: *Self, text: []const u8, style: Style, is_last: bool) !void { + const writer = self.bufferWriter(); + + // Apply tree indentation + if (self.tree.getCurrentDepth() > 0) { + try self.tree.writeIndent(writer, is_last); + } + + // Apply style and write text + try self.style.apply(writer, &self.ansi, style); + try writer.writeAll(text); + try writer.writeAll("\n"); + + // Update tree state + if (is_last) { + self.tree.setLast(); + } + } + + pub fn writeTreeLine(self: *Self, text: []const u8, style: Style) !void { + const writer = self.bufferWriter(); + + // Apply tree continuation + if (self.tree.getCurrentDepth() > 0) { + try self.tree.writeContinuation(writer); + } + + // Apply style and write text + try self.style.apply(writer, &self.ansi, style); + try writer.writeAll(text); + try writer.writeAll("\n"); + } + + // === Style Management === + + pub fn resetStyle(self: *Self) !void { + try self.style.reset(self.bufferWriter(), &self.ansi); + } + + // === Terminal Control === + + pub fn clearScreen(self: *Self) !void { + try self.ansi.clearScreen(self.bufferWriter()); + } + + pub fn moveCursor(self: *Self, row: u32, col: u32) !void { + try self.ansi.moveCursor(self.bufferWriter(), row, col); + } + + pub fn initializeTerminal(self: *Self) !void { + try self.ansi.initializeTerminal(self.bufferWriter()); + } + + pub fn restoreTerminal(self: *Self) !void { + try self.ansi.restoreTerminal(self.bufferWriter()); + } + + // === Utility === + + pub fn reset(self: *Self) void { + self.pos = 0; + self.tree.reset(); + self.style = StyleApplier.StyleApplier.init(); + } + + pub fn getCurrentDepth(self: *const Self) u8 { + return self.tree.getCurrentDepth(); + } + }; +} + +/// Convenience type for stdout with 4KB buffer +pub const StdoutTerminal = BufferedTerminal(std.fs.File.Writer, 4096); + +/// Create a terminal that writes to stdout +pub fn stdout(no_color: bool) StdoutTerminal { + return StdoutTerminal.init(std.io.getStdOut().writer(), no_color); +} + +/// Convenience type for testing with ArrayList +pub const TestTerminal = BufferedTerminal(std.ArrayList(u8).Writer, 1024); + +/// Create a terminal that writes to an ArrayList (for testing) +pub fn arrayListTerminal(list: *std.ArrayList(u8), no_color: bool) TestTerminal { + return TestTerminal.init(list.writer(), no_color); +} + +// === Tests === + +test "BufferedTerminal basic output" { + var buffer = std.ArrayList(u8).init(std.testing.allocator); + defer buffer.deinit(); + + var terminal = arrayListTerminal(&buffer, false); + + try terminal.writeLine("Hello, World!"); + try terminal.flush(); + + try std.testing.expectEqualStrings("Hello, World!\n", buffer.items); +} + +test "BufferedTerminal styled output" { + var buffer = std.ArrayList(u8).init(std.testing.allocator); + defer buffer.deinit(); + + var terminal = arrayListTerminal(&buffer, false); + + const red_style = Style.withForeground(255, 0, 0); + try terminal.writeStyledLine("Error!", red_style); + try terminal.flush(); + + try std.testing.expectEqualStrings("\x1b[38;2;255;0;0mError!\n", buffer.items); +} + +test "BufferedTerminal tree output" { + var buffer = std.ArrayList(u8).init(std.testing.allocator); + defer buffer.deinit(); + + var terminal = arrayListTerminal(&buffer, true); // no_color for simpler testing + + try terminal.writeLine("Root"); + try terminal.enterTree(); + try terminal.writeTreeNode("Child 1", Style.init(), false); + try terminal.writeTreeLine("Details for child 1", Style.init()); + try terminal.writeTreeNode("Child 2", Style.init(), true); + terminal.exitTree(); + try terminal.flush(); + + const expected = + \\Root + \\┌─ Child 1 + \\│ Details for child 1 + \\└─ Child 2 + \\ + ; + try std.testing.expectEqualStrings(expected, buffer.items); +} + +test "BufferedTerminal no color mode" { + var buffer = std.ArrayList(u8).init(std.testing.allocator); + defer buffer.deinit(); + + var terminal = arrayListTerminal(&buffer, true); // no_color = true + + const red_style = Style.withForeground(255, 0, 0); + try terminal.writeStyledLine("No color", red_style); + try terminal.flush(); + + // Should produce no ANSI codes in no-color mode + try std.testing.expectEqualStrings("No color\n", buffer.items); +} + +test "BufferedTerminal automatic flushing" { + var buffer = std.ArrayList(u8).init(std.testing.allocator); + defer buffer.deinit(); + + // Create terminal with small buffer to test auto-flush + var terminal = BufferedTerminal(std.ArrayList(u8).Writer, 10).init(buffer.writer(), true); + + // Write more than buffer size + try terminal.writeText("This is a long string that exceeds the buffer size"); + try terminal.flush(); + + try std.testing.expectEqualStrings("This is a long string that exceeds the buffer size", buffer.items); +} \ No newline at end of file diff --git a/src/lib/terminal/StyleApplier.zig b/src/lib/terminal/StyleApplier.zig new file mode 100644 index 00000000..2c558d85 --- /dev/null +++ b/src/lib/terminal/StyleApplier.zig @@ -0,0 +1,230 @@ +const std = @import("std"); +const AnsiStreamer = @import("AnsiStreamer.zig").AnsiStreamer; + +/// Compact style representation (64 bits total) +pub const Style = packed struct { + // Foreground color (24 bits + 1 flag) + fg_r: u8 = 0, + fg_g: u8 = 0, + fg_b: u8 = 0, + has_fg: bool = false, + + // Background color (24 bits + 1 flag) + bg_r: u8 = 0, + bg_g: u8 = 0, + bg_b: u8 = 0, + has_bg: bool = false, + + // Text attributes (4 bits) + bold: bool = false, + dim: bool = false, + italic: bool = false, + underline: bool = false, + + // Padding to align to 64 bits + _pad: u4 = 0, + + pub fn init() Style { + return .{}; + } + + pub fn withForeground(r: u8, g: u8, b: u8) Style { + return .{ .fg_r = r, .fg_g = g, .fg_b = b, .has_fg = true }; + } + + pub fn withBackground(r: u8, g: u8, b: u8) Style { + return .{ .bg_r = r, .bg_g = g, .bg_b = b, .has_bg = true }; + } + + pub fn withBold() Style { + return .{ .bold = true }; + } + + pub fn withDim() Style { + return .{ .dim = true }; + } + + pub fn withItalic() Style { + return .{ .italic = true }; + } + + pub fn withUnderline() Style { + return .{ .underline = true }; + } + + pub fn equals(self: Style, other: Style) bool { + const self_int: u64 = @bitCast(self); + const other_int: u64 = @bitCast(other); + return self_int == other_int; + } +}; + +/// Non-allocating style applier +/// Tracks current style state and applies minimal ANSI sequences +pub const StyleApplier = struct { + current: Style = .{}, + + pub fn init() StyleApplier { + return .{}; + } + + /// Apply a style, outputting only the necessary ANSI sequences + pub fn apply(self: *StyleApplier, writer: anytype, ansi: *AnsiStreamer, new_style: Style) !void { + const old_style = self.current; + + // If styles are identical, no work needed + if (new_style.equals(old_style)) return; + + // Check if we need to reset first + const needs_reset = + // Turning off attributes requires reset + (old_style.bold and !new_style.bold) or + (old_style.dim and !new_style.dim) or + (old_style.italic and !new_style.italic) or + (old_style.underline and !new_style.underline) or + // Color removal requires reset + (old_style.has_fg and !new_style.has_fg) or + (old_style.has_bg and !new_style.has_bg); + + if (needs_reset) { + try ansi.resetStyle(writer); + self.current = .{}; + } + + // Apply new foreground color + if (new_style.has_fg and + (!self.current.has_fg or + self.current.fg_r != new_style.fg_r or + self.current.fg_g != new_style.fg_g or + self.current.fg_b != new_style.fg_b)) { + try ansi.setForegroundRgb(writer, new_style.fg_r, new_style.fg_g, new_style.fg_b); + } + + // Apply new background color + if (new_style.has_bg and + (!self.current.has_bg or + self.current.bg_r != new_style.bg_r or + self.current.bg_g != new_style.bg_g or + self.current.bg_b != new_style.bg_b)) { + try ansi.setBackgroundRgb(writer, new_style.bg_r, new_style.bg_g, new_style.bg_b); + } + + // Apply attributes + if (new_style.bold and !self.current.bold) { + try ansi.setBold(writer); + } + if (new_style.dim and !self.current.dim) { + try ansi.setDim(writer); + } + if (new_style.italic and !self.current.italic) { + try ansi.setItalic(writer); + } + if (new_style.underline and !self.current.underline) { + try ansi.setUnderline(writer); + } + + // Update current style + self.current = new_style; + } + + /// Reset to no style + pub fn reset(self: *StyleApplier, writer: anytype, ansi: *AnsiStreamer) !void { + try ansi.resetStyle(writer); + self.current = .{}; + } + + /// Get current style (for inspection/debugging) + pub fn getCurrentStyle(self: *const StyleApplier) Style { + return self.current; + } +}; + +// === Convenience Functions === + +/// Common color constants +pub const Colors = struct { + pub const red = Style.withForeground(255, 0, 0); + pub const green = Style.withForeground(0, 200, 0); + pub const blue = Style.withForeground(0, 0, 255); + pub const yellow = Style.withForeground(200, 200, 0); + pub const cyan = Style.withForeground(0, 200, 200); + pub const magenta = Style.withForeground(200, 0, 200); + pub const white = Style.withForeground(255, 255, 255); + pub const gray = Style.withForeground(150, 150, 150); + pub const dim_gray = Style.withForeground(100, 100, 100); +}; + +// === Tests === + +test "Style creation and comparison" { + const style1 = Style.withForeground(255, 0, 0); + const style2 = Style.withForeground(255, 0, 0); + const style3 = Style.withForeground(0, 255, 0); + + try std.testing.expect(style1.equals(style2)); + try std.testing.expect(!style1.equals(style3)); +} + +test "StyleApplier basic operations" { + var buffer = std.ArrayList(u8).init(std.testing.allocator); + defer buffer.deinit(); + + var ansi = AnsiStreamer.init(false); + var applier = StyleApplier.init(); + + // Apply red foreground + const red_style = Style.withForeground(255, 0, 0); + try applier.apply(buffer.writer(), &ansi, red_style); + + // Should output foreground color code + try std.testing.expectEqualStrings("\x1b[38;2;255;0;0m", buffer.items); + + // Clear buffer + buffer.clearRetainingCapacity(); + + // Apply same style again - should output nothing + try applier.apply(buffer.writer(), &ansi, red_style); + try std.testing.expectEqualStrings("", buffer.items); +} + +test "StyleApplier reset behavior" { + var buffer = std.ArrayList(u8).init(std.testing.allocator); + defer buffer.deinit(); + + var ansi = AnsiStreamer.init(false); + var applier = StyleApplier.init(); + + // Apply bold red + const bold_red = Style{ .fg_r = 255, .has_fg = true, .bold = true }; + try applier.apply(buffer.writer(), &ansi, bold_red); + + buffer.clearRetainingCapacity(); + + // Apply plain text (should reset then apply nothing) + const plain = Style{}; + try applier.apply(buffer.writer(), &ansi, plain); + + try std.testing.expectEqualStrings("\x1b[0m", buffer.items); +} + +test "StyleApplier incremental changes" { + var buffer = std.ArrayList(u8).init(std.testing.allocator); + defer buffer.deinit(); + + var ansi = AnsiStreamer.init(false); + var applier = StyleApplier.init(); + + // Apply red + try applier.apply(buffer.writer(), &ansi, Style.withForeground(255, 0, 0)); + buffer.clearRetainingCapacity(); + + // Change to blue (should only change color, not reset) + try applier.apply(buffer.writer(), &ansi, Style.withForeground(0, 0, 255)); + try std.testing.expectEqualStrings("\x1b[38;2;0;0;255m", buffer.items); + + buffer.clearRetainingCapacity(); + + // Add bold (should add bold without changing color) + try applier.apply(buffer.writer(), &ansi, Style{ .fg_r = 0, .fg_g = 0, .fg_b = 255, .has_fg = true, .bold = true }); + try std.testing.expectEqualStrings("\x1b[1m", buffer.items); +} \ No newline at end of file diff --git a/src/lib/terminal/TreeFormatter.zig b/src/lib/terminal/TreeFormatter.zig new file mode 100644 index 00000000..9d583f1d --- /dev/null +++ b/src/lib/terminal/TreeFormatter.zig @@ -0,0 +1,209 @@ +const std = @import("std"); + +/// Non-allocating tree structure formatter +/// Manages tree indentation and structure without allocation +pub const TreeFormatter = struct { + pub const MAX_DEPTH = 32; + + depth: u8 = 0, + stack: [MAX_DEPTH]LevelState = [_]LevelState{.{}} ** MAX_DEPTH, + + pub const LevelState = struct { + has_more: bool = true, + }; + + pub fn init() TreeFormatter { + return .{}; + } + + /// Enter a new tree level + pub fn enter(self: *TreeFormatter) !void { + if (self.depth >= MAX_DEPTH - 1) { + return error.TreeDepthExceeded; + } + + // Push current state to stack and increment depth + self.stack[self.depth] = .{ .has_more = true }; + self.depth += 1; + } + + /// Exit current tree level + pub fn exit(self: *TreeFormatter) void { + if (self.depth > 0) { + self.depth -= 1; + } + } + + /// Mark current level as the last item (no more siblings) + pub fn setLast(self: *TreeFormatter) void { + if (self.depth > 0) { + self.stack[self.depth - 1].has_more = false; + } + } + + /// Write tree indentation for a new node + pub fn writeIndent(self: *const TreeFormatter, writer: anytype, is_last: bool) !void { + // Draw vertical lines for parent levels + for (self.stack[0..self.depth], 0..) |level, i| { + if (i == self.depth - 1) { + // Current level - draw the branch + if (is_last) { + try writer.writeAll("└─ "); + } else if (i == 0) { + try writer.writeAll("┌─ "); + } else { + try writer.writeAll("├─ "); + } + } else { + // Parent levels - draw vertical lines + if (level.has_more) { + try writer.writeAll("│ "); + } else { + try writer.writeAll(" "); + } + } + } + } + + /// Write tree continuation for content within a node + pub fn writeContinuation(self: *const TreeFormatter, writer: anytype) !void { + // Draw vertical lines for continuation + for (self.stack[0..self.depth]) |level| { + if (level.has_more) { + try writer.writeAll("│ "); + } else { + try writer.writeAll(" "); + } + } + } + + /// Write a node with proper indentation + pub fn writeNode(self: *TreeFormatter, writer: anytype, text: []const u8, is_last: bool) !void { + if (self.depth > 0) { + try self.writeIndent(writer, is_last); + } + try writer.writeAll(text); + try writer.writeAll("\n"); + if (is_last) { + self.setLast(); + } + } + + /// Write content within a node (continuation lines) + pub fn writeLine(self: *const TreeFormatter, writer: anytype, text: []const u8) !void { + if (self.depth > 0) { + try self.writeContinuation(writer); + } + try writer.writeAll(text); + try writer.writeAll("\n"); + } + + /// Get current depth for debugging + pub fn getCurrentDepth(self: *const TreeFormatter) u8 { + return self.depth; + } + + /// Reset formatter to initial state + pub fn reset(self: *TreeFormatter) void { + self.depth = 0; + self.stack = [_]LevelState{.{}} ** MAX_DEPTH; + } +}; + +// === Tests === + +test "TreeFormatter basic tree structure" { + var buffer = std.ArrayList(u8).init(std.testing.allocator); + defer buffer.deinit(); + + var tree = TreeFormatter.init(); + + // Root level + try tree.writeNode(buffer.writer(), "Root", false); + + // First child + try tree.enter(); + try tree.writeNode(buffer.writer(), "Child 1", false); + + // Grandchild + try tree.enter(); + try tree.writeNode(buffer.writer(), "Grandchild 1", true); + tree.exit(); + + // Second child (last) + try tree.writeNode(buffer.writer(), "Child 2", true); + tree.exit(); + + const expected = + \\Root + \\┌─ Child 1 + \\│ └─ Grandchild 1 + \\└─ Child 2 + \\ + ; + try std.testing.expectEqualStrings(expected, buffer.items); +} + +test "TreeFormatter continuation lines" { + var buffer = std.ArrayList(u8).init(std.testing.allocator); + defer buffer.deinit(); + + var tree = TreeFormatter.init(); + + try tree.enter(); + try tree.writeNode(buffer.writer(), "Node", false); + try tree.writeLine(buffer.writer(), "Line 1"); + try tree.writeLine(buffer.writer(), "Line 2"); + tree.exit(); + + const expected = + \\┌─ Node + \\│ Line 1 + \\│ Line 2 + \\ + ; + try std.testing.expectEqualStrings(expected, buffer.items); +} + +test "TreeFormatter depth tracking" { + var tree = TreeFormatter.init(); + + try std.testing.expectEqual(@as(u8, 0), tree.getCurrentDepth()); + + try tree.enter(); + try std.testing.expectEqual(@as(u8, 1), tree.getCurrentDepth()); + + try tree.enter(); + try std.testing.expectEqual(@as(u8, 2), tree.getCurrentDepth()); + + tree.exit(); + try std.testing.expectEqual(@as(u8, 1), tree.getCurrentDepth()); + + tree.exit(); + try std.testing.expectEqual(@as(u8, 0), tree.getCurrentDepth()); +} + +test "TreeFormatter max depth protection" { + var tree = TreeFormatter.init(); + + // Fill up to max depth + var i: u8 = 0; + while (i < TreeFormatter.MAX_DEPTH - 1) : (i += 1) { + try tree.enter(); + } + + // This should fail + const result = tree.enter(); + try std.testing.expectError(error.TreeDepthExceeded, result); +} + +test "TreeFormatter reset" { + var tree = TreeFormatter.init(); + + try tree.enter(); + try tree.enter(); + try std.testing.expectEqual(@as(u8, 2), tree.getCurrentDepth()); + + tree.reset(); + try std.testing.expectEqual(@as(u8, 0), tree.getCurrentDepth()); +} \ No newline at end of file diff --git a/src/lib/terminal/terminal.zig b/src/lib/terminal/terminal.zig new file mode 100644 index 00000000..7b43d3c8 --- /dev/null +++ b/src/lib/terminal/terminal.zig @@ -0,0 +1,196 @@ +//! Modern terminal output library for XTC +//! +//! This module provides a composable, non-allocating approach to terminal output +//! with support for ANSI styling, tree structures, and buffered output. +//! +//! Key principles: +//! - Non-allocating: Core operations use no heap allocation +//! - Composable: Small, focused components that work together +//! - Simple: Clear APIs with single responsibilities +//! - Efficient: Minimal ANSI output and optional buffering + +const std = @import("std"); + +// === Core Components === + +pub const AnsiStreamer = @import("AnsiStreamer.zig").AnsiStreamer; +pub const TreeFormatter = @import("TreeFormatter.zig").TreeFormatter; +pub const StyleApplier = @import("StyleApplier.zig").StyleApplier; +pub const Style = @import("StyleApplier.zig").Style; +pub const Colors = @import("StyleApplier.zig").Colors; +pub const BufferedTerminal = @import("BufferedTerminal.zig").BufferedTerminal; + +// === Convenience Types === + +pub const StdoutTerminal = @import("BufferedTerminal.zig").StdoutTerminal; +pub const TestTerminal = @import("BufferedTerminal.zig").TestTerminal; + +// === Convenience Functions === + +pub const stdout = @import("BufferedTerminal.zig").stdout; +pub const arrayListTerminal = @import("BufferedTerminal.zig").arrayListTerminal; + +// === Domain-Specific Helpers === + +/// Domain-specific helper functions built on top of the core components +pub const helpers = struct { + /// Write a test result with appropriate icon and styling + pub fn writeTestResult(terminal: anytype, name: []const u8, passed: bool) !void { + const style = if (passed) Colors.green else Colors.red; + const icon = if (passed) "✓" else "✗"; + + try terminal.writeStyledText(icon, style); + try terminal.writeText(" "); + try terminal.writeLine(name); + } + + /// Write a progress bar + pub fn writeProgressBar(terminal: anytype, current: usize, total: usize, width: usize) !void { + const filled = if (total > 0) (current * width) / total else 0; + const empty = width - filled; + + try terminal.writeText("["); + + // Filled portion in green + if (filled > 0) { + var i: usize = 0; + while (i < filled) : (i += 1) { + try terminal.writeStyledText("█", Colors.green); + } + } + + // Empty portion in gray + if (empty > 0) { + var i: usize = 0; + while (i < empty) : (i += 1) { + try terminal.writeStyledText("░", Colors.gray); + } + } + + try terminal.writeText("] "); + + // Use a temporary buffer for formatting the progress text + var buf: [32]u8 = undefined; + const progress_text = std.fmt.bufPrint(&buf, "{d}/{d}", .{ current, total }) catch "?/?"; + try terminal.writeText(progress_text); + } + + /// Write a section header + pub fn writeSection(terminal: anytype, title: []const u8) !void { + try terminal.enterTree(); + try terminal.writeTreeNode(title, Style.withBold(), false); + } + + /// Write an error message with icon + pub fn writeError(terminal: anytype, message: []const u8) !void { + try terminal.writeStyledText("✗", Colors.red); + try terminal.writeText(" "); + try terminal.writeStyledLine(message, Colors.red); + } + + /// Write a success message with icon + pub fn writeSuccess(terminal: anytype, message: []const u8) !void { + try terminal.writeStyledText("✓", Colors.green); + try terminal.writeText(" "); + try terminal.writeStyledLine(message, Colors.green); + } + + /// Write a warning message with icon + pub fn writeWarning(terminal: anytype, message: []const u8) !void { + try terminal.writeStyledText("⚠", Colors.yellow); + try terminal.writeText(" "); + try terminal.writeStyledLine(message, Colors.yellow); + } + + /// Write an info message with icon + pub fn writeInfo(terminal: anytype, message: []const u8) !void { + try terminal.writeStyledText("ℹ", Colors.cyan); + try terminal.writeText(" "); + try terminal.writeStyledLine(message, Colors.cyan); + } +}; + +// === Tests === + +test "terminal module basic functionality" { + var buffer = std.ArrayList(u8).init(std.testing.allocator); + defer buffer.deinit(); + + var terminal = arrayListTerminal(&buffer, true); // no color for simpler testing + + try helpers.writeTestResult(&terminal, "sample test", true); + try terminal.flush(); + + try std.testing.expectEqualStrings("✓ sample test\n", buffer.items); +} + +test "terminal module tree structure" { + var buffer = std.ArrayList(u8).init(std.testing.allocator); + defer buffer.deinit(); + + var terminal = arrayListTerminal(&buffer, true); + + try helpers.writeSection(&terminal, "Test Results"); + try helpers.writeTestResult(&terminal, "test 1", true); + try helpers.writeTestResult(&terminal, "test 2", false); + terminal.exitTree(); + try terminal.flush(); + + const expected = + \\┌─ Test Results + \\│ ✓ test 1 + \\│ ✗ test 2 + \\ + ; + try std.testing.expectEqualStrings(expected, buffer.items); +} + +test "terminal module progress bar" { + var buffer = std.ArrayList(u8).init(std.testing.allocator); + defer buffer.deinit(); + + var terminal = arrayListTerminal(&buffer, true); // no color for cleaner output + + try helpers.writeProgressBar(&terminal, 7, 10, 20); + try terminal.flush(); + + // Should show 14 filled chars out of 20, plus progress text + try std.testing.expectEqualStrings("[██████████████░░░░░░] 7/10", buffer.items); +} + +// === Integration Tests === + +test "terminal module complete workflow" { + var buffer = std.ArrayList(u8).init(std.testing.allocator); + defer buffer.deinit(); + + var terminal = arrayListTerminal(&buffer, true); + + // Test suite header + try helpers.writeSection(&terminal, "Running Tests"); + + // Individual test results + try helpers.writeTestResult(&terminal, "test_addition", true); + try helpers.writeTestResult(&terminal, "test_subtraction", true); + try helpers.writeTestResult(&terminal, "test_division", false); + + // Nested section + try helpers.writeSection(&terminal, "Integration Tests"); + try helpers.writeTestResult(&terminal, "test_integration_1", true); + terminal.exitTree(); // integration tests + + terminal.exitTree(); // main section + + // Summary + try terminal.writeLine(""); + try helpers.writeProgressBar(&terminal, 3, 4, 20); + try terminal.writeLine(""); + try helpers.writeWarning(&terminal, "1 test failed"); + + try terminal.flush(); + + // Verify the structure is reasonable (exact content would be quite long to match) + try std.testing.expect(std.mem.indexOf(u8, buffer.items, "Running Tests") != null); + try std.testing.expect(std.mem.indexOf(u8, buffer.items, "test_addition") != null); + try std.testing.expect(std.mem.indexOf(u8, buffer.items, "✗ test_division") != null); +} \ No newline at end of file