diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..8b4c3b4 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,608 @@ +# GitHub Copilot Instructions for mermaid-ascii + +# 🛑🛑🛑 STOP - MANDATORY PRE-FLIGHT - READ THIS BEFORE RESPONDING 🛑🛑🛑 + +☐ State which user and project copilot-instructions.md sections apply to this request +☐ Check if any Agent Skills apply (list them explicitly) +☐ If multi-step work: Create todo list with #manage_todo_list +☐ Mark tasks in-progress and completed as you work +☐ Use #code-review before finalizing ANY code changes +☐ Monitor and report token usage at checkpoints (700K/850K/950K) + +**If you cannot check ALL boxes above, STOP and ask for clarification.** + +**Example Response Format:** +``` +**Following copilot-instructions.md sections: Go Testing, Build Patterns** +**Applicable Agent Skills: #go-testing, #code-review** +**Will use #manage_todo_list for multi-step tracking** + +We need to... +``` + +--- + +# 📖 REQUIRED READING + +**ALWAYS read the user-level copilot-instructions.md file first:** +- **Location**: `/home/warnes/src/agent-config/copilot-instructions.md` +- **Contains**: Communication style, token monitoring, cross-project development patterns +- **Why**: Establishes baseline behavior and standards across all projects + +**This file (project-specific) provides:** +- Go development best practices and anti-patterns +- Testing patterns with table-driven tests +- Build and release workflows +- mermaid-ascii specific patterns (diagram rendering, text wrapping, CLI flags) +- Agent Skills specific to Go development + +--- + +## Quick Skill Reference + +**Workflow & Quality:** +- **#code-review** - REQUIRED before finalizing any code changes +- **#git-commit-message** - For commit message generation +- **#manage_todo_list** - For multi-step task tracking and planning + +**Go Development:** +- **#go-testing** - Table-driven tests, test coverage, Go testing patterns +- **#go-build-and-test** - Build, test, and release workflows +- **#go-struct-patterns** - Struct initialization, configuration patterns + +--- + +## Project Overview + +**mermaid-ascii** is a Go-based tool that converts Mermaid diagram syntax to ASCII art diagrams for terminal/documentation display. + +**This fork adds:** +- `
` and `
` HTML tag support for multi-line node labels +- `-w/--maxWidth` CLI flag for diagram width control +- Enhanced text wrapping with proper line splitting + +**Core Components:** +- `cmd/` - CLI commands, parsing, rendering, graph layout +- `internal/diagram/` - Configuration, validation, rendering infrastructure +- `internal/sequence/` - Sequence diagram specific rendering +- `main.go` - Entry point, Cobra CLI setup + +--- + +## ⚠️ CRITICAL WORKFLOW CHECKLIST + +**Before implementing ANY code changes, verify you will:** + +1. ✅ **Create/update unit tests** - Go requires tests alongside code +2. ✅ **Follow anti-patterns** - Check relevant sections below before coding +3. ✅ **Review changes** - Use systematic code review before finalizing +4. ✅ **Update documentation** - Update comments and README for exported functions + +**After making changes, verify you have:** + +1. ✅ **Tests passing** - All new/modified code has passing tests +2. ✅ **Documentation updated** - Comments and README current +3. ✅ **No anti-patterns** - Reviewed against project-specific warnings +4. ✅ **User informed** - Confirmed completion to user + +--- + +## Go Development Best Practices + +### CRITICAL: Always Write Tests + +**Go convention: Tests live alongside code in `*_test.go` files** + +```go +// ✅ CORRECT - Test file next to implementation +cmd/ + graph.go + graph_test.go // Tests for graph.go + parse.go + parse_test.go // Tests for parse.go +``` + +**NEVER commit code without tests:** +```go +// ❌ INCORRECT - No test file +cmd/ + new_feature.go // No new_feature_test.go! + +// ✅ CORRECT - Test file included +cmd/ + new_feature.go + new_feature_test.go +``` + +### Table-Driven Tests + +**ALWAYS use table-driven test pattern for multiple scenarios:** + +```go +// ✅ CORRECT - Table-driven test +func TestFeature(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {name: "case1", input: "a", expected: "A"}, + {name: "case2", input: "b", expected: "B"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Feature(tt.input) + if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) + } + }) + } +} + +// ❌ INCORRECT - Separate test functions (harder to maintain) +func TestFeatureCase1(t *testing.T) { /* ... */ } +func TestFeatureCase2(t *testing.T) { /* ... */ } +``` + +### Error Handling + +**Always check and handle errors explicitly:** + +```go +// ✅ CORRECT - Explicit error handling +result, err := SomeFunction() +if err != nil { + return fmt.Errorf("failed to do something: %w", err) +} + +// ❌ INCORRECT - Ignoring errors +result, _ := SomeFunction() // Silent failure! +``` + +### Struct Initialization + +**Use named fields for clarity:** + +```go +// ✅ CORRECT - Named fields +config := &Config{ + MaxWidth: 100, + PaddingX: 5, + PaddingY: 2, + UseAscii: true, +} + +// ❌ INCORRECT - Positional (brittle if struct changes) +config := &Config{100, 5, 2, true} +``` + +--- + +## mermaid-ascii Specific Patterns + +### Text Processing + +**String manipulation best practices:** + +```go +// ✅ CORRECT - Use strings package for efficiency +name := strings.ReplaceAll(strings.ReplaceAll(n.name, "
", "\n"), "
", "\n") + +// ✅ CORRECT - Use strings.Builder for concatenation in loops +var sb strings.Builder +for _, line := range lines { + sb.WriteString(line) + sb.WriteString("\n") +} +result := sb.String() + +// ❌ INCORRECT - Repeated string concatenation (inefficient) +result := "" +for _, line := range lines { + result += line + "\n" // Creates new string each iteration! +} +``` + +### Configuration Management + +**Config structs should validate themselves:** + +```go +// ✅ CORRECT - Validation method +type Config struct { + MaxWidth int + PaddingX int +} + +func (c *Config) Validate() error { + if c.MaxWidth < 0 { + return fmt.Errorf("maxWidth cannot be negative") + } + if c.PaddingX < 0 { + return fmt.Errorf("paddingX cannot be negative") + } + return nil +} + +// Usage +config := NewConfig(...) +if err := config.Validate(); err != nil { + return err +} +``` + +### CLI Flag Patterns (Cobra) + +**Use persistent flags for global options:** + +```go +// ✅ CORRECT - Persistent flags available to all subcommands +var verbose bool +rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Verbose output") + +// ✅ CORRECT - IntVarP for integer flags with short form +var maxWidth int +rootCmd.PersistentFlags().IntVarP(&maxWidth, "maxWidth", "w", 0, "Maximum width") +``` + +--- + +## Testing Patterns + +### Test File Organization + +```go +package cmd + +import ( + "testing" + "strings" + "github.com/AlexanderGrooff/mermaid-ascii/internal/diagram" +) + +// Helper functions at top +func helperFunction(t *testing.T, input string) string { + t.Helper() // Mark as helper for better error reporting + // ... helper logic +} + +// Table-driven tests +func TestMainFeature(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + // Test cases + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test logic + }) + } +} + +// Edge cases in separate tests if needed +func TestEdgeCase(t *testing.T) { + // Specific edge case +} +``` + +### Test Output Validation + +```go +// ✅ CORRECT - Clear error messages with actual output +if !strings.Contains(output, expected) { + t.Errorf("expected output to contain %q, got:\n%s", expected, output) +} + +// ✅ CORRECT - Helper functions with t.Helper() +func assertContains(t *testing.T, output, expected string) { + t.Helper() + if !strings.Contains(output, expected) { + t.Errorf("expected output to contain %q, got:\n%s", expected, output) + } +} + +// ❌ INCORRECT - Vague error messages +if !strings.Contains(output, expected) { + t.Error("test failed") // Not helpful! +} +``` + +--- + +## Build and Test Workflows + +### Running Tests + +```bash +# Run all tests +go test ./... + +# Run specific package tests +go test ./cmd + +# Run specific test +go test ./cmd -run TestBRTag + +# Verbose output +go test -v ./... + +# With coverage +go test -cover ./... +go test -coverprofile=coverage.out ./... +go tool cover -html=coverage.out +``` + +### Building + +```bash +# Build binary +go build -o mermaid-ascii . + +# Build with version info +go build -ldflags "-X main.version=1.0.0" -o mermaid-ascii . + +# Build for multiple platforms +GOOS=linux GOARCH=amd64 go build -o mermaid-ascii-linux-amd64 . +GOOS=darwin GOARCH=amd64 go build -o mermaid-ascii-darwin-amd64 . +GOOS=windows GOARCH=amd64 go build -o mermaid-ascii-windows-amd64.exe . +``` + +--- + +## Common Gotchas + +### 1. Pointer vs Value Receivers + +```go +// ✅ CORRECT - Use pointer receiver for methods that modify state +func (g *graph) setLabelLines() { + g.labelWidth = calculateWidth() // Modifies graph +} + +// ✅ CORRECT - Use value receiver for read-only methods +func (c Config) IsValid() bool { + return c.MaxWidth >= 0 // Doesn't modify config +} +``` + +### 2. String Immutability + +**Remember:** Strings in Go are immutable. Use `strings.Builder` for efficient concatenation. + +### 3. Slice Append + +```go +// ✅ CORRECT - Assign result back +lines = append(lines, newLine) + +// ❌ INCORRECT - Missing assignment +append(lines, newLine) // Does nothing! +``` + +### 4. Range Loop Variables + +```go +// ✅ CORRECT - Use index or create copy +for i := range tests { + t.Run(tests[i].name, func(t *testing.T) { + // Use tests[i] + }) +} + +// ⚠️ WARNING - Loop variable capture (Go < 1.22) +for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // tt may be reused across iterations in older Go versions + // Fixed in Go 1.22+ + }) +} +``` + +### 5. Error Wrapping + +```go +// ✅ CORRECT - Use %w to wrap errors (enables errors.Is/As) +return fmt.Errorf("failed to parse: %w", err) + +// ❌ INCORRECT - Use %v (loses error chain) +return fmt.Errorf("failed to parse: %v", err) +``` + +--- + +## Code Review Practices + +### Review Modified Files + +**Always review all modified files for errors, omissions, anti-patterns, or other issues before finalizing changes:** + +- **Errors**: Syntax errors, logic bugs, unhandled errors, type mismatches +- **Omissions**: Missing tests, incomplete implementations, missing error handling +- **Anti-patterns**: + - Missing error checks + - Inefficient string operations + - Non-table-driven tests + - Missing test cases + - Unvalidated configuration +- **Design Issues**: Poor naming, missing documentation, unclear logic +- **Performance Issues**: Inefficient algorithms, unnecessary allocations, repeated work + +**Use systematic review process:** +1. Check each modified file for completeness +2. Verify all errors are handled +3. Ensure tests exist and pass +4. Validate documentation is up-to-date +5. Look for edge cases and boundary conditions +6. Confirm Go idioms are followed + +--- + +## Documentation Standards + +### Function Documentation + +All exported functions must have godoc comments: + +```go +// RenderDiagram converts Mermaid diagram syntax to ASCII art. +// It returns the rendered ASCII output and any error encountered. +// +// The input should be valid Mermaid syntax. The config parameter +// controls rendering options like width, padding, and style. +// +// Example: +// +// diagram := "graph LR\nA --> B" +// output, err := RenderDiagram(diagram, config) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Println(output) +func RenderDiagram(input string, config *Config) (string, error) { + // Implementation +} +``` + +### Package Documentation + +```go +// Package cmd implements the command-line interface and core +// rendering logic for converting Mermaid diagrams to ASCII art. +// +// This package provides diagram parsing, layout calculation, +// and ASCII rendering with support for various Mermaid diagram types. +package cmd +``` + +--- + +## Git Commit Messages + +### Summarizing Changes + +**When preparing a commit message, briefly summarize all changed files using a small number of high-level bullet points:** + +```bash +# ✅ CORRECT - High-level summary +feat: Add HTML tag support for multi-line labels + +- Add
and
tag conversion in graph rendering +- Update text wrapping to split on converted newlines +- Add comprehensive test coverage with table-driven tests +- Update documentation with usage examples + +# ❌ INCORRECT - Too detailed or file-by-file +Update cmd/graph.go +Update cmd/graph_test.go +Update internal/diagram/config.go +... +``` + +**Guidelines:** +- **Use high-level themes** instead of listing individual file changes +- **Group related changes** into conceptual bullet points (3-5 bullets) +- **Focus on user-facing changes** and their benefits +- **Include context** about why changes were made when relevant +- Review modified files to ensure all changes are represented + +--- + +## Agent Skills + +This project includes Agent Skills in `.github/skills/` for common procedural patterns. + +### Available Skills + +1. **#go-testing** - Create and maintain Go unit tests with table-driven patterns + - **Use when**: Adding tests for new/modified functions + - Covers table-driven tests, helper functions, test organization + +2. **#go-build-and-test** - Build, test, and validate Go code + - **Use when**: Building binaries, running test suites, checking coverage + - Covers build flags, cross-compilation, test execution + +3. **#code-review** - Systematically review modified files before finalizing changes + - **Use when**: Before committing, after completing edits, or preparing pull requests + - Checks for errors, omissions, anti-patterns, design issues + +4. **#git-commit-message** - Generate concise, thematic commit messages + - **Use when**: Preparing commits with multiple file changes + - Creates high-level summaries grouped by theme + +### When to Use Agent Skills + +**Invoke skills explicitly** (using `#skill-name` in your message) when: +- You need step-by-step guidance through a multi-step procedural task +- The pattern is well-defined and documented in a skill file +- You want the agent to follow a specific structured approach +- You're less familiar with a particular pattern + +**Skills are automatically selected** when: +- Your request clearly matches a skill's purpose +- Copilot recognizes the task fits a documented skill pattern +- No explicit skill reference is needed for straightforward requests + +--- + +## Cross-Project Development + +**When making changes from other project directories, always check for and use project-specific guidance:** + +- **`.github/copilot-instructions.md`** - Project-specific instructions and anti-patterns +- **`.github/skills/`** - Agent Skills with procedural patterns + +These files contain critical project-specific context including: +- Language-specific patterns (R, Python, Go, etc.) +- Testing standards and code review requirements +- Common gotcas and error patterns +- Development workflows + +--- + +## Project Structure + +``` +mermaid-ascii/ +├── cmd/ # CLI commands and core rendering +│ ├── graph.go # Graph diagram rendering +│ ├── graph_test.go # Graph tests +│ ├── parse.go # Mermaid syntax parsing +│ ├── root.go # Cobra CLI root command +│ └── ... +├── internal/ +│ ├── diagram/ # Configuration and rendering infrastructure +│ │ ├── config.go +│ │ └── config_test.go +│ └── sequence/ # Sequence diagram rendering +├── main.go # Entry point +├── go.mod # Go module definition +├── go.sum # Dependency checksums +└── README.md # Documentation +``` + +--- + +## Development Workflow + +1. **Make changes** - Modify code with clear intent +2. **Write tests** - Table-driven tests alongside code +3. **Run tests** - `go test ./...` +4. **Review code** - Use #code-review skill +5. **Build** - `go build -o mermaid-ascii .` +6. **Manual test** - Test CLI with sample diagrams +7. **Commit** - Use #git-commit-message for message + +--- + +## References + +- **Go Documentation**: https://go.dev/doc/ +- **Go Testing**: https://go.dev/doc/tutorial/add-a-test +- **Cobra CLI**: https://github.com/spf13/cobra +- **Mermaid Syntax**: https://mermaid.js.org/intro/ +- **Table-Driven Tests**: https://go.dev/wiki/TableDrivenTests diff --git a/.gitignore b/.gitignore index f225735..65d3586 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,9 @@ mermaid-ascii *.tar.gz *.tar.zst result +mermaid-ascii-fork +mermaid-ascii-go +mermaid-ascii-patched + +# Backup files +*.bak diff --git a/LICENSE b/LICENSE index d05e0f0..e8ff560 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2023 Alexander Grooff +Copyright (c) 2026 Gregory R. Warnes Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 98babb6..7dd3165 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,15 @@ # Mermaid ASCII -Render mermaid diagrams in your terminal: +Render mermaid diagrams in your terminal. + +## Features + +This fork adds: +- **Multi-line node labels**: Use `
` or `
` HTML tags in node labels to create multi-line text +- **Diagram width control**: Use `-w/--maxWidth` to constrain diagram width, with automatic layout fitting +- **Label alignment control**: Use `--center-multi-line-labels` to control how multi-line labels are centered (default: left-justified block) +- **Version flag**: Use `--version` to show version information +- **Enhanced UTF-8 support**: Proper handling of wide characters (CJK, emoji) in node labels ## Installation @@ -127,6 +136,60 @@ $ mermaid-ascii -f ./test.mermaid │ │ └───┘ +# Multi-line node labels (using
or
tags) +$ cat test.mermaid +graph LR +A["First
Second"] --> B["Line 1
Line 2
Line 3"] +$ mermaid-ascii -f ./test.mermaid +┌────────┐ ┌────────┐ +│ First │ │ Line 1 │ +│ Second ├────►│ Line 2 │ +│ │ │ Line 3 │ +└────────┘ └────────┘ + +# Multi-line labels with tree-like structure (default: left-justified) +$ cat test.mermaid +graph LR +A["┌─ TIMER
├─> Step 1
└─> Step 2"] +$ mermaid-ascii -f ./test.mermaid +┌────────────┐ +│ ┌─ TIMER │ +│ ├─> Step 1 │ +│ └─> Step 2 │ +└────────────┘ + +# Center each line individually with --center-multi-line-labels +$ cat test.mermaid +graph LR +A["┌─ TIMER
├─> Step 1
└─> Step 2"] +$ mermaid-ascii -f ./test.mermaid --center-multi-line-labels +┌────────────┐ +│ ┌─ TIMER │ +│ ├─> Step 1 │ +│ └─> Step 2 │ +└────────────┘ + +# Control diagram width +$ cat test.mermaid +graph LR +A --> B --> C --> D --> E +$ mermaid-ascii -f ./test.mermaid -w 50 +┌───┐ ┌───┐ ┌───┐ +│ │ │ │ │ │ +│ A ├────►│ B ├────►│ C │ +│ │ │ │ │ │ +└─┬─┘ └───┘ └───┘ + │ + │ + │ + │ + ▼ +┌───┐ ┌───┐ +│ │ │ │ +│ D ├────►│ E │ +│ │ │ │ +└───┘ └───┘ + # Top-down layout $ cat test.mermaid graph TD @@ -355,13 +418,18 @@ Available Commands: web HTTP server for rendering mermaid diagrams. Flags: - -p, --borderPadding int Padding between text and border (default 1) - -c, --coords Show coordinates - -f, --file string Mermaid file to parse - -h, --help help for mermaid-ascii - -x, --paddingX int Horizontal space between nodes (default 5) - -y, --paddingY int Vertical space between nodes (default 5) - -v, --verbose Verbose output + -a, --ascii Don't use extended character set + -p, --borderPadding int Padding between text and border (default 1) + --center-multi-line-labels Center multi-line node labels as a block + -c, --coords Show coordinates + -f, --file string Mermaid file to parse (use '-' for stdin) + --fit Force automatic fitting even without width constraint + -h, --help help for mermaid-ascii + -w, --maxWidth int Maximum diagram width in characters (0 = unlimited) + -x, --paddingX int Horizontal space between nodes (default 5) + -y, --paddingY int Vertical space between nodes (default 5) + -v, --verbose Verbose output + --version Show version information Use "mermaid-ascii [command] --help" for more information about a command. @@ -509,10 +577,12 @@ Note that with `--coords` enabled, the grid-coords shown show the starting locat ### Graphs / Flowcharts ✅ - [x] Graph directions (`graph LR` and `graph TD`) - [x] Labelled edges (like `A -->|label| B`) +- [x] Multi-line node labels (using `
` or `
` tags) - [x] Multiple arrows on one line (like `A --> B --> C`) - [x] `A & B` syntax - [x] `classDef` and `class` for colored output - [x] Prevent arrows overlapping nodes +- [x] Control diagram width (via `-w/--maxWidth` flag) - [ ] `subgraph` support - [ ] Shapes other than rectangles - [ ] Diagonal arrows @@ -547,9 +617,9 @@ The baseline components for Mermaid work, but there are a lot of things that are ### Rendering - [x] Prevent arrows overlapping nodes +- [x] Control maximum diagram width (via `-w/--maxWidth` flag) - [ ] Diagonal arrows - [ ] Place nodes in a more compact way -- [ ] Prevent rendering more than X characters wide (like default 80 for terminal width) ### Sequence Diagram Improvements diff --git a/cmd/diagram.go b/cmd/diagram.go index 8b5de1e..c36e147 100644 --- a/cmd/diagram.go +++ b/cmd/diagram.go @@ -84,6 +84,20 @@ func (gd *GraphDiagram) Render(config *diagram.Config) (string, error) { } gd.properties.styleType = styleType gd.properties.useAscii = config.UseAscii + if config.GraphDirection != "" { + gd.properties.graphDirection = config.GraphDirection + } + gd.properties.paddingX = config.PaddingBetweenX + gd.properties.paddingY = config.PaddingBetweenY + gd.properties.boxBorderPadding = config.BoxBorderPadding + gd.properties.labelWrapWidth = config.LabelWrapWidth + gd.properties.edgeLabelPolicy = config.EdgeLabelPolicy + gd.properties.edgeLabelMaxWidth = config.EdgeLabelMaxWidth + gd.properties.centerMultiLineLabels = config.CenterMultiLineLabels + + if config.FitPolicy == diagram.FitPolicyAuto && config.MaxWidth > 0 { + return fitGraphToWidth(gd.properties, config), nil + } return drawMap(gd.properties), nil } diff --git a/cmd/direction.go b/cmd/direction.go index d48ea08..8d22faa 100644 --- a/cmd/direction.go +++ b/cmd/direction.go @@ -45,23 +45,23 @@ func (c drawingCoord) Direction(dir direction) drawingCoord { return drawingCoord{x: c.x + dir.x, y: c.y + dir.y} } -func selfReferenceDirection(e *edge) (direction, direction, direction, direction) { - if graphDirection == "LR" { +func (g *graph) selfReferenceDirection(e *edge) (direction, direction, direction, direction) { + if g.graphDirection == "LR" { return Right, Down, Down, Right } return Down, Right, Right, Down } -func determineStartAndEndDir(e *edge) (direction, direction, direction, direction) { +func (g *graph) determineStartAndEndDir(e *edge) (direction, direction, direction, direction) { if e.from == e.to { - return selfReferenceDirection(e) + return g.selfReferenceDirection(e) } d := determineDirection(genericCoord(*e.from.gridCoord), genericCoord(*e.to.gridCoord)) var preferredDir, preferredOppositeDir, alternativeDir, alternativeOppositeDir direction // Check if this is a backwards flowing edge isBackwards := false - if graphDirection == "LR" { + if g.graphDirection == "LR" { // In LR mode, backwards flow is when edge goes from right to left (Left direction) isBackwards = (d == Left || d == UpperLeft || d == LowerLeft) } else { // TD mode @@ -75,7 +75,7 @@ func determineStartAndEndDir(e *edge) (direction, direction, direction, directio // For backwards edges, use special start positions: Down in LR mode, Right in TD mode switch d { case LowerRight: - if graphDirection == "LR" { + if g.graphDirection == "LR" { preferredDir = Down preferredOppositeDir = Left alternativeDir = Right @@ -87,7 +87,7 @@ func determineStartAndEndDir(e *edge) (direction, direction, direction, directio alternativeOppositeDir = Left } case UpperRight: - if graphDirection == "LR" { + if g.graphDirection == "LR" { preferredDir = Up preferredOppositeDir = Left alternativeDir = Right @@ -99,7 +99,7 @@ func determineStartAndEndDir(e *edge) (direction, direction, direction, directio alternativeOppositeDir = Left } case LowerLeft: - if graphDirection == "LR" { + if g.graphDirection == "LR" { // Backwards flow in LR mode - start from Down, arrive at Down preferredDir = Down preferredOppositeDir = Down // Edge goes to bottom of destination @@ -112,7 +112,7 @@ func determineStartAndEndDir(e *edge) (direction, direction, direction, directio alternativeOppositeDir = Right } case UpperLeft: - if graphDirection == "LR" { + if g.graphDirection == "LR" { // Backwards flow in LR mode - start from Down, arrive at Down preferredDir = Down preferredOppositeDir = Down // Edge goes to bottom of destination @@ -128,13 +128,13 @@ func determineStartAndEndDir(e *edge) (direction, direction, direction, directio default: // Handle direct backwards flow cases if isBackwards { - if graphDirection == "LR" && d == Left { + if g.graphDirection == "LR" && d == Left { // Direct left flow in LR mode - start from Down, arrive at Down preferredDir = Down preferredOppositeDir = Down // Edge goes to bottom of destination alternativeDir = Left alternativeOppositeDir = Right - } else if graphDirection == "TD" && d == Up { + } else if g.graphDirection == "TD" && d == Up { // Direct up flow in TD mode - start from Right, arrive at Right preferredDir = Right preferredOppositeDir = Right // Edge goes to right of destination diff --git a/cmd/draw.go b/cmd/draw.go index 06adcf3..b5ba504 100644 --- a/cmd/draw.go +++ b/cmd/draw.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/gookit/color" + "github.com/mattn/go-runewidth" log "github.com/sirupsen/logrus" ) @@ -155,7 +156,14 @@ func drawMap(properties *graphProperties) string { g.setStyleClasses(properties) g.paddingX = properties.paddingX g.paddingY = properties.paddingY + g.graphDirection = properties.graphDirection + g.boxBorderPadding = properties.boxBorderPadding + g.labelWrapWidth = properties.labelWrapWidth + g.edgeLabelPolicy = properties.edgeLabelPolicy + g.edgeLabelMaxWidth = properties.edgeLabelMaxWidth g.useAscii = properties.useAscii + g.centerMultiLineLabels = properties.centerMultiLineLabels + g.setLabelLines() g.setSubgraphs(properties.subgraphs) g.createMapping() d := g.draw() @@ -228,10 +236,81 @@ func drawBox(n *node, g graph) *drawing { boxDrawing[to.x][to.y] = "+" // Bottom right corner } // Draw text - textY := from.y + h/2 - textX := from.x + w/2 - CeilDiv(len(n.name), 2) + 1 - for x := 0; x < len(n.name); x++ { - boxDrawing[textX+x][textY] = wrapTextInColor(string(n.name[x]), n.styleClass.styles["color"], g.styleType) + labelLines := n.labelLines + if len(labelLines) == 0 { + labelLines = []string{n.name} + } + innerLeft := from.x + 1 + innerRight := to.x - 1 + innerTop := from.y + 1 + innerBottom := to.y - 1 + innerWidth := innerRight - innerLeft + 1 + innerHeight := innerBottom - innerTop + 1 + startY := innerTop + if innerHeight > len(labelLines) { + startY = innerTop + (innerHeight-len(labelLines))/2 + } + maxLines := Min(len(labelLines), innerHeight) + isMultiLine := len(labelLines) > 1 + + // When centerMultiLineLabels is false and we have multiple lines, + // pad all lines to the same width before centering as a block + linesToDraw := labelLines + if isMultiLine && !g.centerMultiLineLabels { + // Find max line width accounting for character widths + maxLineWidth := 0 + for _, line := range labelLines[:maxLines] { + lineWidth := runewidth.StringWidth(line) + if lineWidth > maxLineWidth { + maxLineWidth = lineWidth + } + } + // Pad each line to max width with trailing spaces + linesToDraw = make([]string, len(labelLines)) + for i, line := range labelLines { + lineWidth := runewidth.StringWidth(line) + if lineWidth < maxLineWidth { + linesToDraw[i] = line + strings.Repeat(" ", maxLineWidth-lineWidth) + } else { + linesToDraw[i] = line + } + } + } + + for lineIdx := 0; lineIdx < maxLines; lineIdx++ { + line := linesToDraw[lineIdx] + runes := []rune(line) + // Use display width (accounts for CJK full-width chars, emoji, etc.) + lineWidth := runewidth.StringWidth(line) + + startX := innerLeft + // Center single-line labels, or multi-line when centerMultiLineLabels is true + if (!isMultiLine || g.centerMultiLineLabels) && innerWidth > lineWidth { + startX = innerLeft + (innerWidth-lineWidth)/2 + } else if isMultiLine && !g.centerMultiLineLabels && innerWidth > lineWidth { + // Block centering: center the entire padded block + startX = innerLeft + (innerWidth-lineWidth)/2 + } + + // Place characters at display positions - wide chars (CJK, emoji) occupy 2 display columns + displayPos := 0 + for _, r := range runes { + charWidth := runewidth.RuneWidth(r) + // Check if character will fit in remaining display width + if displayPos+charWidth > innerWidth { + break + } + if startX+displayPos > innerRight { + break + } + // Place character at its display position + boxDrawing[startX+displayPos][startY+lineIdx] = wrapTextInColor(string(r), n.styleClass.styles["color"], g.styleType) + // For wide characters, clear the next cell (the character spans it visually) + if charWidth > 1 && startX+displayPos+1 <= innerRight { + boxDrawing[startX+displayPos+1][startY+lineIdx] = "" + } + displayPos += charWidth + } } return &boxDrawing diff --git a/cmd/fit_graph.go b/cmd/fit_graph.go new file mode 100644 index 0000000..e08a4cd --- /dev/null +++ b/cmd/fit_graph.go @@ -0,0 +1,209 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/AlexanderGrooff/mermaid-ascii/internal/diagram" +) + +type graphFitPlan struct { + paddingX int + paddingY int + boxBorderPadding int + graphDirection string + labelWrapWidth int + edgeLabelPolicy string + edgeLabelMaxWidth int +} + +func fitGraphToWidth(properties *graphProperties, config *diagram.Config) string { + basePlan := graphFitPlan{ + paddingX: properties.paddingX, + paddingY: properties.paddingY, + boxBorderPadding: properties.boxBorderPadding, + graphDirection: properties.graphDirection, + labelWrapWidth: properties.labelWrapWidth, + edgeLabelPolicy: properties.edgeLabelPolicy, + edgeLabelMaxWidth: properties.edgeLabelMaxWidth, + } + + plans := graphFitPlans(basePlan, config.MaxWidth) + bestOutput := "" + bestWidth := 0 + for idx, plan := range plans { + candidate := applyGraphFitPlan(properties, plan) + output := drawMap(candidate) + width := maxOutputLineWidth(output) + if idx == 0 || width < bestWidth { + bestWidth = width + bestOutput = output + } + if width <= config.MaxWidth { + return output + } + } + + return bestOutput +} + +func applyGraphFitPlan(base *graphProperties, plan graphFitPlan) *graphProperties { + candidate := *base + candidate.paddingX = plan.paddingX + candidate.paddingY = plan.paddingY + candidate.boxBorderPadding = plan.boxBorderPadding + candidate.graphDirection = plan.graphDirection + candidate.labelWrapWidth = plan.labelWrapWidth + candidate.edgeLabelPolicy = plan.edgeLabelPolicy + candidate.edgeLabelMaxWidth = plan.edgeLabelMaxWidth + return &candidate +} + +func graphFitPlans(base graphFitPlan, maxWidth int) []graphFitPlan { + plans := []graphFitPlan{} + seen := map[string]struct{}{} + addPlan := func(plan graphFitPlan) { + key := graphFitPlanKey(plan) + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + plans = append(plans, plan) + } + + addPlan(base) + + compact := base + compact.paddingX = Min(base.paddingX, 2) + compact.paddingY = Min(base.paddingY, 1) + compact.boxBorderPadding = Min(base.boxBorderPadding, 1) + addPlan(compact) + + tight := compact + tight.paddingX = Min(tight.paddingX, 1) + tight.paddingY = Min(tight.paddingY, 1) + tight.boxBorderPadding = 0 + addPlan(tight) + + wrap := base + wrap.labelWrapWidth = reduceWrapWidth(base.labelWrapWidth, labelWrapWidthFor(maxWidth, base.boxBorderPadding)) + addPlan(wrap) + + wrapCompact := compact + wrapCompact.labelWrapWidth = reduceWrapWidth(compact.labelWrapWidth, labelWrapWidthFor(maxWidth, compact.boxBorderPadding)) + addPlan(wrapCompact) + + wrapTight := tight + wrapTight.labelWrapWidth = reduceWrapWidth(tight.labelWrapWidth, labelWrapWidthFor(maxWidth, tight.boxBorderPadding)) + addPlan(wrapTight) + + // TODO: Support splitting long linear chains into multiple rows while preserving LR direction + // + // This would enable wrapping like: + // + // A -> B -> C -> D + // | + // v + // +--------------+ + // | + // v + // E -> F -> G + // + // Or: + // + // A -> B -> C -> D + // | + // v + // G <- F <- E + // + // instead of flipping to TD direction. + // + // Requires: + // - Detecting linear chain patterns + // - Breaking chain at optimal points based on maxWidth + // - Creating multiple horizontal rows with vertical connectors + // - Calculating row breaks that minimize total height while respecting width constraint + + flippedDirection := flipGraphDirection(base.graphDirection) + if flippedDirection != "" { + flipped := base + flipped.graphDirection = flippedDirection + addPlan(flipped) + + flippedWrap := flipped + flippedWrap.labelWrapWidth = reduceWrapWidth(flipped.labelWrapWidth, labelWrapWidthFor(maxWidth, flipped.boxBorderPadding)) + addPlan(flippedWrap) + + flippedCompact := compact + flippedCompact.graphDirection = flippedDirection + flippedCompact.labelWrapWidth = reduceWrapWidth(flippedCompact.labelWrapWidth, labelWrapWidthFor(maxWidth, flippedCompact.boxBorderPadding)) + addPlan(flippedCompact) + } + + ellipsis := wrapCompact + ellipsis.edgeLabelPolicy = diagram.EdgeLabelPolicyEllipsis + ellipsis.edgeLabelMaxWidth = edgeLabelMaxWidthFor(maxWidth) + addPlan(ellipsis) + + drop := ellipsis + drop.edgeLabelPolicy = diagram.EdgeLabelPolicyDrop + addPlan(drop) + + return plans +} + +func graphFitPlanKey(plan graphFitPlan) string { + return fmt.Sprintf("%d:%d:%d:%s:%d:%s:%d", + plan.paddingX, + plan.paddingY, + plan.boxBorderPadding, + plan.graphDirection, + plan.labelWrapWidth, + plan.edgeLabelPolicy, + plan.edgeLabelMaxWidth, + ) +} + +func labelWrapWidthFor(maxWidth, boxBorderPadding int) int { + if maxWidth <= 0 { + return 0 + } + available := maxWidth - (2*boxBorderPadding + 2) + if available < 1 { + return 1 + } + return available +} + +func edgeLabelMaxWidthFor(maxWidth int) int { + if maxWidth <= 0 { + return 0 + } + if maxWidth <= 3 { + return maxWidth + } + return maxWidth - 4 +} + +func reduceWrapWidth(current, target int) int { + if current <= 0 || target < current { + return target + } + return current +} + +func flipGraphDirection(direction string) string { + switch direction { + case "LR": + return "TD" + case "TD": + return "LR" + default: + return "" + } +} + +func maxOutputLineWidth(output string) int { + lines := strings.Split(output, "\n") + return maxLineWidth(lines) +} diff --git a/cmd/fit_graph_test.go b/cmd/fit_graph_test.go new file mode 100644 index 0000000..9092bd0 --- /dev/null +++ b/cmd/fit_graph_test.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/AlexanderGrooff/mermaid-ascii/internal/diagram" +) + +func TestGraphFitRespectsMaxWidth(t *testing.T) { + input := "flowchart TB\n" + + "A[Very Long Node Label Here] --> B[Another Long Label]\n" + cfg := diagram.NewTestConfig(true, "cli") + cfg.MaxWidth = 20 + cfg.FitPolicy = diagram.FitPolicyAuto + + out, err := RenderDiagram(input, cfg) + if err != nil { + t.Fatalf("render failed: %v", err) + } + if maxOutputLineWidth(out) > cfg.MaxWidth { + t.Fatalf("expected width <= %d, got %d", cfg.MaxWidth, maxOutputLineWidth(out)) + } + if !strings.Contains(out, "Very") { + t.Fatalf("expected label content retained") + } +} diff --git a/cmd/graph.go b/cmd/graph.go index 9c9a615..1aa02c7 100644 --- a/cmd/graph.go +++ b/cmd/graph.go @@ -1,6 +1,11 @@ +// Copyright (c) 2023 Alexander Grooff +// Copyright (c) 2026 Gregory R. Warnes +// Multi-line label support (
and
tags) added by Gregory R. Warnes + package cmd import ( + "strings" "errors" "github.com/elliotchance/orderedmap/v2" @@ -30,20 +35,26 @@ func (g graph) lineToDrawing(line []gridCoord) []drawingCoord { } type graph struct { - nodes []*node - edges []*edge - drawing *drawing - grid map[gridCoord]*node - columnWidth map[int]int - rowHeight map[int]int - styleClasses map[string]styleClass - styleType string - paddingX int - paddingY int - subgraphs []*subgraph - offsetX int - offsetY int - useAscii bool + nodes []*node + edges []*edge + drawing *drawing + grid map[gridCoord]*node + columnWidth map[int]int + rowHeight map[int]int + styleClasses map[string]styleClass + styleType string + paddingX int + paddingY int + graphDirection string + boxBorderPadding int + labelWrapWidth int + edgeLabelPolicy string + edgeLabelMaxWidth int + subgraphs []*subgraph + offsetX int + offsetY int + useAscii bool + centerMultiLineLabels bool } type subgraph struct { @@ -104,6 +115,16 @@ func (g *graph) setStyleClasses(properties *graphProperties) { } } +func (g *graph) setLabelLines() { + for _, n := range g.nodes { + // Support
,
, and \n for multi-line labels + name := strings.ReplaceAll(strings.ReplaceAll(n.name, "
", "\n"), "
", "\n") + // Always split on \n, wrap only if labelWrapWidth > 0 + n.labelLines = wrapLabelLines(name, g.labelWrapWidth) + n.labelWidth = maxLineWidth(n.labelLines) + } +} + func (g *graph) setSubgraphs(textSubgraphs []*textSubgraph) { g.subgraphs = []*subgraph{} @@ -194,7 +215,7 @@ func (g *graph) createMapping() { // Separate root nodes by whether they're in subgraphs, but only if we have both types // AND there are edges in subgraphs (indicating intentional layout structure) - shouldSeparate := graphDirection == "LR" && hasExternalRoots && hasSubgraphRootsWithEdges + shouldSeparate := g.graphDirection == "LR" && hasExternalRoots && hasSubgraphRootsWithEdges externalRootNodes := []*node{} subgraphRootNodes := []*node{} @@ -214,7 +235,7 @@ func (g *graph) createMapping() { // Place external root nodes first at level 0 for _, n := range externalRootNodes { var mappingCoord *gridCoord - if graphDirection == "LR" { + if g.graphDirection == "LR" { mappingCoord = g.reserveSpotInGrid(g.nodes[n.index], &gridCoord{x: 0, y: highestPositionPerLevel[0]}) } else { mappingCoord = g.reserveSpotInGrid(g.nodes[n.index], &gridCoord{x: highestPositionPerLevel[0], y: 0}) @@ -230,7 +251,7 @@ func (g *graph) createMapping() { subgraphLevel := 4 for _, n := range subgraphRootNodes { var mappingCoord *gridCoord - if graphDirection == "LR" { + if g.graphDirection == "LR" { mappingCoord = g.reserveSpotInGrid(g.nodes[n.index], &gridCoord{x: subgraphLevel, y: highestPositionPerLevel[subgraphLevel]}) } else { mappingCoord = g.reserveSpotInGrid(g.nodes[n.index], &gridCoord{x: highestPositionPerLevel[subgraphLevel], y: subgraphLevel}) @@ -245,7 +266,7 @@ func (g *graph) createMapping() { log.Debugf("Creating mapping for node %s at %v", n.name, n.gridCoord) var childLevel int // Next column is 4 coords further. This is because every node is 3 coords wide + 1 coord inbetween. - if graphDirection == "LR" { + if g.graphDirection == "LR" { childLevel = n.gridCoord.x + 4 } else { childLevel = n.gridCoord.y + 4 @@ -258,7 +279,7 @@ func (g *graph) createMapping() { } var mappingCoord *gridCoord - if graphDirection == "LR" { + if g.graphDirection == "LR" { mappingCoord = g.reserveSpotInGrid(g.nodes[child.index], &gridCoord{x: childLevel, y: highestPosition}) } else { mappingCoord = g.reserveSpotInGrid(g.nodes[child.index], &gridCoord{x: highestPosition, y: childLevel}) diff --git a/cmd/graph_br_tag_test.go b/cmd/graph_br_tag_test.go new file mode 100644 index 0000000..f9952c3 --- /dev/null +++ b/cmd/graph_br_tag_test.go @@ -0,0 +1,130 @@ +package cmd + +import ( +"strings" +"testing" + +"github.com/AlexanderGrooff/mermaid-ascii/internal/diagram" +) + +// TestBRTagConversion tests that
and
tags are converted to newlines +func TestBRTagConversion(t *testing.T) { +tests := []struct { +name string +input string +expected []string // Expected strings to appear on separate lines +}{ +{ +name: "br_with_slash", +input: "graph LR\nA[\"Line 1
Line 2\"]\n", +expected: []string{"Line 1", "Line 2"}, +}, +{ +name: "br_without_slash", +input: "graph LR\nA[\"Line 1
Line 2\"]\n", +expected: []string{"Line 1", "Line 2"}, +}, +{ +name: "multiple_br_tags", +input: "graph LR\nA[\"First
Second
Third\"]\n", +expected: []string{"First", "Second", "Third"}, +}, +{ +name: "mixed_br_tags", +input: "graph LR\nA[\"Line 1
Line 2
Line 3\"]\n", +expected: []string{"Line 1", "Line 2", "Line 3"}, +}, +} + +for _, tt := range tests { +t.Run(tt.name, func(t *testing.T) { +cfg := diagram.NewTestConfig(true, "cli") +cfg.LabelWrapWidth = 0 // Disable automatic wrapping +out, err := RenderDiagram(tt.input, cfg) +if err != nil { +t.Fatalf("render failed: %v", err) +} + +// Check that all expected strings appear on different lines +for i := 0; i < len(tt.expected)-1; i++ { +if !appearsOnDifferentLines(out, tt.expected[i], tt.expected[i+1]) { +t.Errorf("expected %q and %q to appear on different lines\nOutput:\n%s", +tt.expected[i], tt.expected[i+1], out) +} +} +}) +} +} + +// TestBRTagWithWrapping tests that
tags work in combination with label wrapping +func TestBRTagWithWrapping(t *testing.T) { +input := "graph LR\nA[\"First Line
Second Line with long text that should wrap\"]\n" +cfg := diagram.NewTestConfig(true, "cli") +cfg.LabelWrapWidth = 15 // Enable wrapping at 15 chars +out, err := RenderDiagram(input, cfg) +if err != nil { +t.Fatalf("render failed: %v", err) +} + +// Check that "First Line" and "Second Line" appear on different lines +if !appearsOnDifferentLines(out, "First Line", "Second Line") { +t.Errorf("expected
to create separate lines even with wrapping enabled\nOutput:\n%s", out) +} +} + +// TestBRTagInMultipleNodes tests
tags in multiple nodes +func TestBRTagInMultipleNodes(t *testing.T) { +input := `graph LR +A["Node A
Line 2"] +B["Node B
Line 2"] +A --> B +` +cfg := diagram.NewTestConfig(true, "cli") +cfg.LabelWrapWidth = 0 +out, err := RenderDiagram(input, cfg) +if err != nil { +t.Fatalf("render failed: %v", err) +} + +// Check that both nodes have multi-line labels +if !appearsOnDifferentLines(out, "Node A", "Line 2") { +t.Errorf("expected Node A to have multi-line label\nOutput:\n%s", out) +} +if !appearsOnDifferentLines(out, "Node B", "Line 2") { +t.Errorf("expected Node B to have multi-line label\nOutput:\n%s", out) +} +} + +// TestLiteralNewline tests that literal \n still works +func TestLiteralNewline(t *testing.T) { +input := "graph LR\nA[\"Line 1\\nLine 2\"]\n" +cfg := diagram.NewTestConfig(true, "cli") +cfg.LabelWrapWidth = 0 +out, err := RenderDiagram(input, cfg) +if err != nil { +t.Fatalf("render failed: %v", err) +} + +if !appearsOnDifferentLines(out, "Line 1", "Line 2") { +t.Errorf("expected literal \\n to create separate lines\nOutput:\n%s", out) +} +} + +// appearsOnDifferentLines checks if two strings appear on different lines in the output +func appearsOnDifferentLines(output, first, second string) bool { +lines := strings.Split(output, "\n") +firstLine := -1 +secondLine := -1 + +for i, line := range lines { +if strings.Contains(line, first) && firstLine == -1 { +firstLine = i +} +if strings.Contains(line, second) && secondLine == -1 { +secondLine = i +} +} + +// Both strings found and on different lines +return firstLine != -1 && secondLine != -1 && firstLine != secondLine +} diff --git a/cmd/graph_direction_test.go b/cmd/graph_direction_test.go new file mode 100644 index 0000000..40381e9 --- /dev/null +++ b/cmd/graph_direction_test.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/AlexanderGrooff/mermaid-ascii/internal/diagram" +) + +func TestGraphDirectionOverride(t *testing.T) { + input := "graph LR\nA --> B\n" + cfg := diagram.NewTestConfig(true, "cli") + cfg.GraphDirection = "TD" + out, err := RenderDiagram(input, cfg) + if err != nil { + t.Fatalf("render failed: %v", err) + } + if !containsVerticalFlow(out) { + t.Fatalf("expected TD render to be vertical") + } +} + +func containsVerticalFlow(out string) bool { + lines := strings.Split(out, "\n") + idxA := -1 + idxB := -1 + for i, line := range lines { + if strings.Contains(line, "| A |") { + idxA = i + } + if strings.Contains(line, "| B |") { + idxB = i + } + if strings.Contains(line, "| A |") && strings.Contains(line, "| B |") { + return false + } + } + return idxA != -1 && idxB != -1 && idxB > idxA +} diff --git a/cmd/graph_edge_label_policy_test.go b/cmd/graph_edge_label_policy_test.go new file mode 100644 index 0000000..7489a1a --- /dev/null +++ b/cmd/graph_edge_label_policy_test.go @@ -0,0 +1,25 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/AlexanderGrooff/mermaid-ascii/internal/diagram" +) + +func TestGraphEdgeLabelEllipsis(t *testing.T) { + input := "graph LR\nA -->|This is a long edge label| B\n" + cfg := diagram.NewTestConfig(true, "cli") + cfg.EdgeLabelPolicy = "ellipsis" + cfg.EdgeLabelMaxWidth = 6 + out, err := RenderDiagram(input, cfg) + if err != nil { + t.Fatalf("render failed: %v", err) + } + if !strings.Contains(out, "...") { + t.Fatalf("expected ellipsis edge label") + } + if strings.Contains(out, "This is a long edge label") { + t.Fatalf("expected edge label to be truncated") + } +} diff --git a/cmd/graph_label_wrap_test.go b/cmd/graph_label_wrap_test.go new file mode 100644 index 0000000..1e18a0f --- /dev/null +++ b/cmd/graph_label_wrap_test.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/AlexanderGrooff/mermaid-ascii/internal/diagram" +) + +func TestGraphLabelWrapping(t *testing.T) { + input := "graph LR\nA[This is a long label]\nA --> B\n" + cfg := diagram.NewTestConfig(true, "cli") + cfg.LabelWrapWidth = 8 + out, err := RenderDiagram(input, cfg) + if err != nil { + t.Fatalf("render failed: %v", err) + } + if !containsWrappedLabel(out, "This is", "a long") { + t.Fatalf("expected wrapped label to span multiple lines") + } +} + +func containsWrappedLabel(out, first, second string) bool { + lines := strings.Split(out, "\n") + firstIdx := -1 + secondIdx := -1 + for i, line := range lines { + if strings.Contains(line, first) { + firstIdx = i + } + if strings.Contains(line, second) { + secondIdx = i + } + } + return firstIdx != -1 && secondIdx != -1 && firstIdx != secondIdx +} diff --git a/cmd/mapping_edge.go b/cmd/mapping_edge.go index 2e6ff19..63b64a7 100644 --- a/cmd/mapping_edge.go +++ b/cmd/mapping_edge.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" + "github.com/AlexanderGrooff/mermaid-ascii/internal/diagram" log "github.com/sirupsen/logrus" ) @@ -21,7 +22,7 @@ func (g *graph) determinePath(e *edge) { var preferredPath, alternativePath []gridCoord var from, to gridCoord var err error - preferredDir, preferredOppositeDir, alternativeDir, alternativeOppositeDir := determineStartAndEndDir(e) + preferredDir, preferredOppositeDir, alternativeDir, alternativeOppositeDir := g.determineStartAndEndDir(e) from = e.from.gridCoord.Direction(preferredDir) to = e.to.gridCoord.Direction(preferredOppositeDir) @@ -70,6 +71,7 @@ func (g *graph) determinePath(e *edge) { func (g *graph) determineLabelLine(e *edge) { // What line on the path should the label be placed? + e.text = g.applyEdgeLabelPolicy(e.text) lenLabel := len(e.text) if lenLabel == 0 { return @@ -114,3 +116,31 @@ func (g graph) calculateLineWidth(line []gridCoord) int { } return totalSize } + +func (g *graph) applyEdgeLabelPolicy(label string) string { + if label == "" { + return "" + } + switch g.edgeLabelPolicy { + case "", diagram.EdgeLabelPolicyFull: + return label + case diagram.EdgeLabelPolicyDrop: + return "" + case diagram.EdgeLabelPolicyEllipsis: + return g.ellipsisLabel(label) + default: + return label + } +} + +func (g *graph) ellipsisLabel(label string) string { + maxWidth := g.edgeLabelMaxWidth + if maxWidth <= 0 || len(label) <= maxWidth { + return label + } + ellipsis := "..." + if len(ellipsis) >= maxWidth { + return ellipsis[:maxWidth] + } + return label[:maxWidth-len(ellipsis)] + ellipsis +} diff --git a/cmd/mapping_node.go b/cmd/mapping_node.go index eec5d54..6b8e9aa 100644 --- a/cmd/mapping_node.go +++ b/cmd/mapping_node.go @@ -6,6 +6,8 @@ import ( type node struct { name string + labelLines []string + labelWidth int drawing *drawing drawingCoord *drawingCoord gridCoord *gridCoord @@ -30,16 +32,25 @@ func (n *node) setDrawing(g graph) *drawing { } func (g *graph) setColumnWidth(n *node) { + labelLines := n.labelLines + if len(labelLines) == 0 { + labelLines = []string{n.name} + } + labelWidth := n.labelWidth + if labelWidth == 0 { + labelWidth = maxLineWidth(labelLines) + } + // For every node there are three columns: // - 2 lines of border // - 1 line of text // - 2x padding // - 2x margin col1 := 1 - col2 := 2*boxBorderPadding + len(n.name) + col2 := 2*g.boxBorderPadding + labelWidth col3 := 1 colsToBePlaced := []int{col1, col2, col3} - rowsToBePlaced := []int{1, 1 + 2*boxBorderPadding, 1} // Border, padding + line, border + rowsToBePlaced := []int{1, len(labelLines) + 2*g.boxBorderPadding, 1} // Border, padding + line(s), border for idx, col := range colsToBePlaced { // Set new width for column if the size increased @@ -90,7 +101,7 @@ func (g *graph) reserveSpotInGrid(n *node, requestedCoord *gridCoord) *gridCoord if g.grid[*requestedCoord] != nil { log.Debugf("Coord %d,%d is already taken", requestedCoord.x, requestedCoord.y) // Next column is 4 coords further. This is because every node is 3 coords wide + 1 coord inbetween. - if graphDirection == "LR" { + if g.graphDirection == "LR" { return g.reserveSpotInGrid(n, &gridCoord{x: requestedCoord.x, y: requestedCoord.y + 4}) } else { return g.reserveSpotInGrid(n, &gridCoord{x: requestedCoord.x + 4, y: requestedCoord.y}) diff --git a/cmd/options.go b/cmd/options.go new file mode 100644 index 0000000..b1cca1e --- /dev/null +++ b/cmd/options.go @@ -0,0 +1,26 @@ +package cmd + +import "github.com/AlexanderGrooff/mermaid-ascii/internal/diagram" + +type RenderOption func(*diagram.Config) + +func WithMaxWidth(maxWidth int) RenderOption { + return func(cfg *diagram.Config) { + cfg.MaxWidth = maxWidth + if maxWidth > 0 && (cfg.FitPolicy == "" || cfg.FitPolicy == diagram.FitPolicyNone) { + cfg.FitPolicy = diagram.FitPolicyAuto + } + } +} + +func WithAscii() RenderOption { + return func(cfg *diagram.Config) { + cfg.UseAscii = true + } +} + +func WithFitPolicy(policy string) RenderOption { + return func(cfg *diagram.Config) { + cfg.FitPolicy = policy + } +} diff --git a/cmd/parse.go b/cmd/parse.go index fed6eb0..efdb0d6 100644 --- a/cmd/parse.go +++ b/cmd/parse.go @@ -12,14 +12,20 @@ import ( ) type graphProperties struct { - data *orderedmap.OrderedMap[string, []textEdge] - styleClasses *map[string]styleClass - graphDirection string - styleType string - paddingX int - paddingY int - subgraphs []*textSubgraph - useAscii bool + data *orderedmap.OrderedMap[string, []textEdge] + styleClasses *map[string]styleClass + graphDirection string + styleType string + paddingX int + paddingY int + boxBorderPadding int + labelWrapWidth int + edgeLabelPolicy string + edgeLabelMaxWidth int + subgraphs []*textSubgraph + useAscii bool + centerMultiLineLabels bool + nodeAliases map[string]string } type textNode struct { @@ -40,17 +46,65 @@ type textSubgraph struct { children []*textSubgraph } -func parseNode(line string) textNode { +var nodeLabelRegex = regexp.MustCompile(`^([A-Za-z0-9_.-]+)\[(.*)\]$`) + +func trimOptionalQuotes(label string) string { + label = strings.TrimSpace(label) + if len(label) < 2 { + return label + } + if (label[0] == '"' && label[len(label)-1] == '"') || (label[0] == '\'' && label[len(label)-1] == '\'') { + return label[1 : len(label)-1] + } + return label +} + +func parseLabeledIdentifier(raw string) (string, string, bool) { + match := nodeLabelRegex.FindStringSubmatch(strings.TrimSpace(raw)) + if match == nil { + return "", "", false + } + id := strings.TrimSpace(match[1]) + label := trimOptionalQuotes(strings.TrimSpace(match[2])) + if label == "" { + label = id + } + return id, label, true +} + +func normalizeSubgraphName(name string) string { + if _, label, ok := parseLabeledIdentifier(name); ok { + return label + } + return trimOptionalQuotes(strings.TrimSpace(name)) +} + +func (gp *graphProperties) parseNode(line string) textNode { // Trim any whitespace from the line that might be left after comment removal trimmedLine := strings.TrimSpace(line) nodeWithClass, _ := regexp.Compile(`^(.+):::(.+)$`) if match := nodeWithClass.FindStringSubmatch(trimmedLine); match != nil { - return textNode{strings.TrimSpace(match[1]), strings.TrimSpace(match[2])} - } else { - return textNode{trimmedLine, ""} + nodePart := strings.TrimSpace(match[1]) + styleClass := strings.TrimSpace(match[2]) + if id, label, ok := parseLabeledIdentifier(nodePart); ok { + gp.nodeAliases[id] = label + return textNode{label, styleClass} + } + if alias, ok := gp.nodeAliases[nodePart]; ok { + return textNode{alias, styleClass} + } + return textNode{nodePart, styleClass} + } + if id, label, ok := parseLabeledIdentifier(trimmedLine); ok { + gp.nodeAliases[id] = label + return textNode{label, ""} + } + if alias, ok := gp.nodeAliases[trimmedLine]; ok { + return textNode{alias, ""} } + return textNode{trimmedLine, ""} } func parseStyleClass(matchedLine []string) styleClass { @@ -124,10 +178,10 @@ func (gp *graphProperties) parseString(line string) ([]textNode, error) { regex: regexp.MustCompile(`^(.+)\s+-->\s+(.+)$`), handler: func(match []string) ([]textNode, error) { if lhs, err = gp.parseString(match[0]); err != nil { - lhs = []textNode{parseNode(match[0])} + lhs = []textNode{gp.parseNode(match[0])} } if rhs, err = gp.parseString(match[1]); err != nil { - rhs = []textNode{parseNode(match[1])} + rhs = []textNode{gp.parseNode(match[1])} } return setArrow(lhs, rhs, gp.data), nil }, @@ -136,10 +190,10 @@ func (gp *graphProperties) parseString(line string) ([]textNode, error) { regex: regexp.MustCompile(`^(.+)\s+-->\|(.+)\|\s+(.+)$`), handler: func(match []string) ([]textNode, error) { if lhs, err = gp.parseString(match[0]); err != nil { - lhs = []textNode{parseNode(match[0])} + lhs = []textNode{gp.parseNode(match[0])} } if rhs, err = gp.parseString(match[2]); err != nil { - rhs = []textNode{parseNode(match[2])} + rhs = []textNode{gp.parseNode(match[2])} } return setArrowWithLabel(lhs, rhs, match[1], gp.data), nil }, @@ -158,11 +212,11 @@ func (gp *graphProperties) parseString(line string) ([]textNode, error) { log.Debugf("Found & pattern node %v to %v", match[0], match[1]) var node textNode if lhs, err = gp.parseString(match[0]); err != nil { - node = parseNode(match[0]) + node = gp.parseNode(match[0]) lhs = []textNode{node} } if rhs, err = gp.parseString(match[1]); err != nil { - node = parseNode(match[1]) + node = gp.parseNode(match[1]) rhs = []textNode{node} } return append(lhs, rhs...), nil @@ -212,13 +266,18 @@ func mermaidFileToMap(mermaid, styleType string) (*graphProperties, error) { data := orderedmap.NewOrderedMap[string, []textEdge]() styleClasses := make(map[string]styleClass) properties := graphProperties{ - data: data, - styleClasses: &styleClasses, - graphDirection: "", - styleType: styleType, - paddingX: paddingBetweenX, - paddingY: paddingBetweenY, - subgraphs: []*textSubgraph{}, + data: data, + styleClasses: &styleClasses, + graphDirection: "", + styleType: styleType, + paddingX: paddingBetweenX, + paddingY: paddingBetweenY, + boxBorderPadding: boxBorderPadding, + labelWrapWidth: 0, + edgeLabelPolicy: "full", + edgeLabelMaxWidth: 0, + subgraphs: []*textSubgraph{}, + nodeAliases: make(map[string]string), } // Pick up optional padding directives before the graph definition @@ -250,6 +309,7 @@ func mermaidFileToMap(mermaid, styleType string) (*graphProperties, error) { } // First line should either say "graph TD" or "graph LR" + graphDirection := "" switch lines[0] { case "graph LR", "flowchart LR": graphDirection = "LR" @@ -259,6 +319,7 @@ func mermaidFileToMap(mermaid, styleType string) (*graphProperties, error) { return &properties, fmt.Errorf("unsupported graph type '%s'. Supported types: graph TD, graph TB, graph LR, flowchart TD, flowchart TB, flowchart LR", lines[0]) } lines = lines[1:] + properties.graphDirection = graphDirection // Track subgraph context using a stack subgraphStack := []*textSubgraph{} @@ -271,7 +332,7 @@ func mermaidFileToMap(mermaid, styleType string) (*graphProperties, error) { // Check for subgraph start if match := subgraphRegex.FindStringSubmatch(trimmedLine); match != nil { - subgraphName := strings.TrimSpace(match[1]) + subgraphName := normalizeSubgraphName(match[1]) newSubgraph := &textSubgraph{ name: subgraphName, nodes: []string{}, @@ -311,7 +372,7 @@ func mermaidFileToMap(mermaid, styleType string) (*graphProperties, error) { nodes, err := properties.parseString(line) if err != nil { log.Debugf("Parsing remaining text to node %v", line) - node := parseNode(line) + node := properties.parseNode(line) addNode(node, properties.data) } else { // Ensure all returned nodes are in the map diff --git a/cmd/parse_label_test.go b/cmd/parse_label_test.go new file mode 100644 index 0000000..68965a7 --- /dev/null +++ b/cmd/parse_label_test.go @@ -0,0 +1,35 @@ +package cmd + +import "testing" + +func TestGraphNodeLabelAlias(t *testing.T) { + mermaid := "graph LR\nA[Alpha]\nA --> B\n" + properties, err := mermaidFileToMap(mermaid, "cli") + if err != nil { + t.Fatalf("Failed to parse mermaid: %v", err) + } + + if _, ok := properties.data.Get("A"); ok { + t.Fatalf("expected node id 'A' to resolve to its label") + } + if _, ok := properties.data.Get("A[Alpha]"); ok { + t.Fatalf("expected raw label syntax not to be treated as a node name") + } + if _, ok := properties.data.Get("Alpha"); !ok { + t.Fatalf("expected label node 'Alpha' to exist") + } +} + +func TestSubgraphLabelAlias(t *testing.T) { + mermaid := "graph LR\nsubgraph Foo[\"Group Label\"]\nA\nend\n" + properties, err := mermaidFileToMap(mermaid, "cli") + if err != nil { + t.Fatalf("Failed to parse mermaid: %v", err) + } + if len(properties.subgraphs) != 1 { + t.Fatalf("expected 1 subgraph, got %d", len(properties.subgraphs)) + } + if properties.subgraphs[0].name != "Group Label" { + t.Fatalf("expected subgraph label %q, got %q", "Group Label", properties.subgraphs[0].name) + } +} diff --git a/cmd/render.go b/cmd/render.go index 2a6868c..8e0ccaa 100644 --- a/cmd/render.go +++ b/cmd/render.go @@ -27,3 +27,11 @@ func RenderDiagram(input string, config *diagram.Config) (string, error) { return output, nil } + +func RenderDiagramWithOptions(input string, options ...RenderOption) (string, error) { + config := diagram.DefaultConfig() + for _, opt := range options { + opt(config) + } + return RenderDiagram(input, config) +} diff --git a/cmd/render_fit_test.go b/cmd/render_fit_test.go new file mode 100644 index 0000000..103868c --- /dev/null +++ b/cmd/render_fit_test.go @@ -0,0 +1,14 @@ +package cmd + +import "testing" + +func TestRenderDiagramWithOptions(t *testing.T) { + input := "graph LR\nA[This is a long label] --> B[Another long label]\n" + out, err := RenderDiagramWithOptions(input, WithMaxWidth(10), WithAscii()) + if err != nil { + t.Fatalf("render failed: %v", err) + } + if maxOutputLineWidth(out) > 10 { + t.Fatalf("expected width <= 10, got %d", maxOutputLineWidth(out)) + } +} diff --git a/cmd/root.go b/cmd/root.go index e0e9eb5..1299de5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,3 +1,7 @@ +// Copyright (c) 2023 Alexander Grooff +// Copyright (c) 2026 Gregory R. Warnes +// MaxWidth CLI flag added by Gregory R. Warnes + package cmd import ( @@ -10,20 +14,33 @@ import ( "github.com/spf13/cobra" ) +// Version of mermaid-ascii +const Version = "1.0.0-fccdata" + // Global flags var Verbose bool var Coords bool var boxBorderPadding = 1 var paddingBetweenX = 5 var paddingBetweenY = 5 -var graphDirection = "LR" +var graphDirection = "" var useAscii = false +var maxWidth = 0 +var fitDiagram = false +var centerMultiLineLabels = false +var showVersion bool // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "mermaid-ascii", Short: "Generate ASCII diagrams from mermaid code.", Run: func(cmd *cobra.Command, args []string) { + // Handle version flag + if showVersion { + fmt.Printf("mermaid-ascii version %s\n", Version) + return + } + if Verbose { log.SetLevel(log.DebugLevel) } else { @@ -58,12 +75,19 @@ var rootCmd = &cobra.Command{ boxBorderPadding, paddingBetweenX, paddingBetweenY, + maxWidth, graphDirection, + centerMultiLineLabels, ) if err != nil { log.Fatalf("Invalid configuration: %v", err) } + // Automatically enable fitting if width is specified, or if --fit flag is used + if maxWidth > 0 || fitDiagram { + config.FitPolicy = diagram.FitPolicyAuto + } + // Render diagram (automatically detects type) output, err := RenderDiagram(string(mermaid), config) if err != nil { @@ -87,12 +111,16 @@ func init() { // Cobra supports persistent flags, which, if defined here, // will be global for your application. + rootCmd.Flags().BoolVar(&showVersion, "version", false, "Show version information") rootCmd.PersistentFlags().BoolVarP(&Verbose, "verbose", "v", false, "Verbose output") rootCmd.PersistentFlags().BoolVarP(&useAscii, "ascii", "a", false, "Don't use extended character set") rootCmd.PersistentFlags().BoolVarP(&Coords, "coords", "c", false, "Show coordinates") rootCmd.PersistentFlags().IntVarP(&paddingBetweenX, "paddingX", "x", paddingBetweenX, "Horizontal space between nodes") rootCmd.PersistentFlags().IntVarP(&paddingBetweenY, "paddingY", "y", paddingBetweenY, "Vertical space between nodes") rootCmd.PersistentFlags().IntVarP(&boxBorderPadding, "borderPadding", "p", boxBorderPadding, "Padding between text and border") + rootCmd.PersistentFlags().IntVarP(&maxWidth, "maxWidth", "w", maxWidth, "Maximum diagram width in characters (0 = unlimited)") + rootCmd.PersistentFlags().BoolVar(&fitDiagram, "fit", false, "Force automatic fitting even without width constraint") + rootCmd.PersistentFlags().BoolVar(¢erMultiLineLabels, "center-multi-line-labels", false, "Center multi-line node labels as a block") // Cobra also supports local flags, which will only run // when this action is called directly. diff --git a/cmd/testdata/ascii/two_layer_single_graph_longer_names.txt b/cmd/testdata/ascii/two_layer_single_graph_longer_names.txt index efe45a8..725a8af 100644 --- a/cmd/testdata/ascii/two_layer_single_graph_longer_names.txt +++ b/cmd/testdata/ascii/two_layer_single_graph_longer_names.txt @@ -4,7 +4,7 @@ ABC --> CDEFGHI --- +-----+ +---------+ | | | | -| ABC |---->| BCDEFG | +| ABC |---->| BCDEFG | | | | | +-----+ +---------+ | diff --git a/cmd/testdata/extended-chars/two_layer_single_graph_longer_names.txt b/cmd/testdata/extended-chars/two_layer_single_graph_longer_names.txt index 5d112d5..83d4fbd 100644 --- a/cmd/testdata/extended-chars/two_layer_single_graph_longer_names.txt +++ b/cmd/testdata/extended-chars/two_layer_single_graph_longer_names.txt @@ -4,7 +4,7 @@ ABC --> CDEFGHI --- ┌─────┐ ┌─────────┐ │ │ │ │ -│ ABC ├────►│ BCDEFG │ +│ ABC ├────►│ BCDEFG │ │ │ │ │ └──┬──┘ └─────────┘ │ diff --git a/cmd/testdata/sequence/block_extra_long_message.txt b/cmd/testdata/sequence/block_extra_long_message.txt new file mode 100644 index 0000000..78703cd --- /dev/null +++ b/cmd/testdata/sequence/block_extra_long_message.txt @@ -0,0 +1,20 @@ +sequenceDiagram + participant A + participant B + alt Success scenario with an extremely long conditional label that needs lots of space + A->>B: This message has an extraordinarily long label that should cause the block to expand significantly beyond the normal participant width + end +--- +┌───┐ ┌───┐ +│ A │ │ B │ +└─┬─┘ └─┬─┘ + │ │ +┌┄┴┄┄┄┄┄┄┄┄┄┴┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐ +┆ alt Success scenario with an extremely long conditional label that needs lots of space ┆ +├┄┼┄┄┄┄┄┄┄┄┄┼┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┤ +┆ │ │ ┆ +┆ │ This message has an extraordinarily long label that should cause the block to expand significantly beyond the normal participant width ┆ +┆ ├────────►│ ┆ +┆ │ │ ┆ +└┄┬┄┄┄┄┄┄┄┄┄┬┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘ + │ │ diff --git a/cmd/testdata/sequence/block_long_message.txt b/cmd/testdata/sequence/block_long_message.txt new file mode 100644 index 0000000..bd23223 --- /dev/null +++ b/cmd/testdata/sequence/block_long_message.txt @@ -0,0 +1,20 @@ +sequenceDiagram + participant A + participant B + loop Check status + A->>B: This is a very long message that should extend the block width + end +--- +┌───┐ ┌───┐ +│ A │ │ B │ +└─┬─┘ └─┬─┘ + │ │ +╭─┴─────────┴───────────────────────────────────────────────────────╮ +│ loop Check status │ +├─┼─────────┼───────────────────────────────────────────────────────┤ +│ │ │ │ +│ │ This is a very long message that should extend the block width │ +│ ├────────►│ │ +│ │ │ │ +╰─┬─────────┬───────────────────────────────────────────────────────╯ + │ │ diff --git a/cmd/textwrap.go b/cmd/textwrap.go new file mode 100644 index 0000000..b8680dd --- /dev/null +++ b/cmd/textwrap.go @@ -0,0 +1,125 @@ +package cmd + +import ( + "strings" + + "github.com/mattn/go-runewidth" +) + +func wrapLabelLines(text string, width int) []string { + lines := splitLabelLines(text) + if width <= 0 { + return lines + } + wrapped := []string{} + for _, line := range lines { + wrapped = append(wrapped, wrapLine(line, width)...) + } + if len(wrapped) == 0 { + return []string{""} + } + return wrapped +} + +func splitLabelLines(text string) []string { + if text == "" { + return []string{""} + } + return strings.Split(text, "\n") +} + +func wrapLine(line string, width int) []string { + if width <= 0 || runewidth.StringWidth(line) <= width { + return []string{line} + } + words := strings.Fields(line) + if len(words) == 0 { + return []string{""} + } + lines := []string{} + current := "" + currentWidth := 0 + for _, word := range words { + wordWidth := runewidth.StringWidth(word) + if current == "" { + if wordWidth <= width { + current = word + currentWidth = wordWidth + continue + } + parts := hardWrapWord(word, width) + if len(parts) > 1 { + lines = append(lines, parts[:len(parts)-1]...) + } + current = parts[len(parts)-1] + currentWidth = runewidth.StringWidth(current) + continue + } + if currentWidth+1+wordWidth <= width { + current += " " + word + currentWidth += 1 + wordWidth + continue + } + lines = append(lines, current) + current = "" + currentWidth = 0 + if wordWidth <= width { + current = word + currentWidth = wordWidth + continue + } + parts := hardWrapWord(word, width) + if len(parts) > 1 { + lines = append(lines, parts[:len(parts)-1]...) + } + current = parts[len(parts)-1] + currentWidth = runewidth.StringWidth(current) + } + if current != "" { + lines = append(lines, current) + } + if len(lines) == 0 { + return []string{""} + } + return lines +} + +func hardWrapWord(word string, width int) []string { + if width <= 0 || runewidth.StringWidth(word) <= width { + return []string{word} + } + parts := []string{} + runes := []rune(word) + currentPart := []rune{} + currentWidth := 0 + + for _, r := range runes { + runeW := runewidth.RuneWidth(r) + if currentWidth+runeW > width && len(currentPart) > 0 { + parts = append(parts, string(currentPart)) + currentPart = []rune{r} + currentWidth = runeW + } else { + currentPart = append(currentPart, r) + currentWidth += runeW + } + } + if len(currentPart) > 0 { + parts = append(parts, string(currentPart)) + } + if len(parts) == 0 { + return []string{""} + } + return parts +} + +func maxLineWidth(lines []string) int { + maxWidth := 0 + for _, line := range lines { + lineWidth := runewidth.StringWidth(line) + if lineWidth > maxWidth { + maxWidth = lineWidth + } + } + return maxWidth +} diff --git a/cmd/utf8_test.go b/cmd/utf8_test.go new file mode 100644 index 0000000..8ed588d --- /dev/null +++ b/cmd/utf8_test.go @@ -0,0 +1,151 @@ +package cmd + +import ( + "testing" + + "github.com/AlexanderGrooff/mermaid-ascii/internal/diagram" +) + +// TestUTF8MultiLineNode tests that multi-line nodes with UTF-8 characters +// render properly without content being split across multiple boxes. +func TestUTF8MultiLineNode(t *testing.T) { + input := `flowchart TD + a["┌─ TIMER
├─> Step 1
└─> Step 2"] + b["日本語 🎉"] + a --> b` + + expected := `+------------+ +| | +| ┌─ TIMER | +| ├─> Step 1 | +| └─> Step 2 | +| | ++------------+ + | + | + | + | + v ++------------+ +| | +| 日本語 🎉 | +| | ++------------+` + + config := diagram.NewTestConfig(true, "cli") + output, err := RenderDiagram(input, config) + if err != nil { + t.Fatalf("RenderDiagram failed: %v", err) + } + + // Check that output exactly matches expected + if output != expected { + t.Errorf("Output does not match expected.\nExpected:\n%s\n\nGot:\n%s", expected, output) + + // Additional diagnostics + t.Logf("Expected length: %d, Got length: %d", len(expected), len(output)) + + // Show byte-by-byte comparison for first difference + minLen := len(expected) + if len(output) < minLen { + minLen = len(output) + } + for i := 0; i < minLen; i++ { + if expected[i] != output[i] { + t.Logf("First difference at position %d: expected byte %#x (%q), got byte %#x (%q)", + i, expected[i], string(expected[i]), output[i], string(output[i])) + break + } + } + } + + // Verify all UTF-8 characters are present (secondary check) + expectedChars := []string{"┌─", "├─>", "└─>", "日本語", "🎉"} + for _, char := range expectedChars { + if !contains(output, char) { + t.Errorf("Output missing expected UTF-8 character %q", char) + } + } + + // Verify no corruption markers + if contains(output, "\ufffd") || contains(output, "ââ") { + t.Errorf("Output contains UTF-8 corruption markers") + } +} + +// TestUTF8Characters verifies that Unicode characters (including multi-byte UTF-8) +// are rendered correctly without corruption. +func TestUTF8Characters(t *testing.T) { + tests := []struct { + name string + input string + expected []string // Strings that should appear in output + }{ + { + name: "Box drawing characters", + input: `flowchart TD + node["├─> test └─>"]`, + expected: []string{"├─>", "└─>"}, + }, + { + name: "Mixed ASCII and UTF-8", + input: `flowchart TD + node["Hello ├─> World"]`, + expected: []string{"Hello", "├─>", "World"}, + }, + { + name: "Japanese characters", + input: `flowchart TD + node["こんにちは"]`, + expected: []string{"こんにちは"}, + }, + { + name: "Emoji", + input: `flowchart TD + node["✓ Success ✗ Failure"]`, + expected: []string{"✓", "Success", "✗", "Failure"}, + }, + { + name: "Multi-line with UTF-8", + input: `flowchart TD + node["Line 1
├─> Line 2
└─> Line 3"]`, + expected: []string{"Line 1", "├─>", "Line 2", "└─>", "Line 3"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := diagram.NewTestConfig(true, "cli") + output, err := RenderDiagram(tt.input, config) + if err != nil { + t.Fatalf("RenderDiagram failed: %v", err) + } + + for _, expected := range tt.expected { + if !contains(output, expected) { + t.Errorf("Output missing expected string %q\nGot:\n%s", expected, output) + } + } + + // Verify no corruption markers (typical UTF-8 corruption creates � or strange byte sequences) + if contains(output, "\ufffd") || contains(output, "ââ") { + t.Errorf("Output contains UTF-8 corruption markers\nGot:\n%s", output) + } + }) + } +} + +// Helper function to check if a string contains a substring +func contains(haystack, needle string) bool { + return len(haystack) >= len(needle) && + (haystack == needle || len(haystack) > len(needle) && indexOf(haystack, needle) >= 0) +} + +func indexOf(haystack, needle string) int { + for i := 0; i <= len(haystack)-len(needle); i++ { + if haystack[i:i+len(needle)] == needle { + return i + } + } + return -1 +} diff --git a/docs/plans/2026-01-20-block-syntax-design.md b/docs/plans/2026-01-20-block-syntax-design.md new file mode 100644 index 0000000..6ae18e3 --- /dev/null +++ b/docs/plans/2026-01-20-block-syntax-design.md @@ -0,0 +1,195 @@ +# Sequence Diagram Block Syntax Support + +## Overview + +Add support for Mermaid's block syntax in sequence diagrams. Blocks group related messages and can be nested arbitrarily. + +## Supported Block Types + +| Block | Syntax | Purpose | +|-------|--------|---------| +| `loop` | `loop [label]` ... `end` | Repeated actions | +| `alt` | `alt [label]` ... `else [label]` ... `end` | Conditional branches | +| `opt` | `opt [label]` ... `end` | Optional block | +| `par` | `par [label]` ... `and [label]` ... `end` | Parallel execution | +| `critical` | `critical [label]` ... `option [label]` ... `end` | Critical section with options | +| `break` | `break [label]` ... `end` | Break out of loop | +| `rect` | `rect [color]` ... `end` | Highlight region (color ignored) | + +All keywords are case-insensitive. + +## Visual Examples + +### Loop +``` + │ │ + │ ┌──────┴───────────────────┐ + │ │ loop Video Stream │ + │ │ ┌───────────────────────┤ + │ │ │ H.264 Frame │ + │ ├──┼──────────────────────►│ + │ │ │ │ + │ │ │ H.264 Frame │ + │◄─┼──┼───────────────────────┤ + │ └──┴───────────────────────┘ + │ │ +``` + +### Alt/Else +``` + │ ┌──────────────────────┐ + │ │ alt Success │ + │ │ ┌───────────────────┤ + │ │ │ Response: 200 │ + │◄─┼──┼───────────────────┤ + │ ├──┼───────────────────┤ + │ │ │ else Error │ + │ │ ├───────────────────┤ + │ │ │ Response: 500 │ + │◄─┼──┼───────────────────┤ + │ └──┴───────────────────┘ +``` + +### Par (Parallel) +``` + │ │ │ + │ ┌──────┴─────────┴──────┐ + │ │ par Control Commands │ + │ │ ┌────────────────────┤ + │ │ │ Touch Event │ + │ ├──┼───────────────────►│ + │ ├──┼────────────────────┤ + │ │ │ and Events │ + │ │ ├────────────────────┤ + │ │ │ Clipboard Changed │ + │ │ │◄───────────────────┤ + │ └──┴────────────────────┘ +``` + +## Data Model + +### New Types + +```go +type BlockType int + +const ( + BlockLoop BlockType = iota + BlockAlt + BlockOpt + BlockPar + BlockCritical + BlockBreak + BlockRect +) + +type Block struct { + Type BlockType + Label string + Sections []*BlockSection +} + +type BlockSection struct { + Label string + Elements []DiagramElement +} + +func (*Block) isElement() {} +``` + +### Block Type Characteristics + +| Block | Divider Keywords | Min Sections | Max Sections | +|-------|------------------|--------------|--------------| +| `loop` | - | 1 | 1 | +| `alt` | `else` | 1 | N | +| `opt` | - | 1 | 1 | +| `par` | `and` | 1 | N | +| `critical` | `option` | 1 | N | +| `break` | - | 1 | 1 | +| `rect` | - | 1 | 1 | + +## Parser Design + +### Regexes + +```go +blockStartRegex = regexp.MustCompile(`(?i)^\s*(loop|alt|opt|par|critical|break|rect)\s*(.*)$`) +blockDividerRegex = regexp.MustCompile(`(?i)^\s*(else|and|option)\s*(.*)$`) +blockEndRegex = regexp.MustCompile(`(?i)^\s*end\s*$`) +``` + +### Recursive Descent Parser + +```go +func (sd *SequenceDiagram) parseBlock(lines []string, startIdx int, participants map[string]*Participant) (*Block, int, error) { + // 1. Parse block start line to get type and label + // 2. Create block with first section + // 3. Loop through lines: + // - If nested block start: recurse, add to current section + // - If divider: validate for block type, start new section + // - If end: return completed block + // - Otherwise: parse as message/note, add to current section + // 4. Return block and index after 'end' +} +``` + +### Divider Validation + +| Block Type | Valid Dividers | +|------------|----------------| +| `alt` | `else` | +| `par` | `and` | +| `critical` | `option` | +| others | none (error if divider found) | + +## Renderer Design + +### Entry Point + +```go +func renderBlock(block *Block, layout *diagramLayout, chars BoxChars, depth int) []string { + // 1. Find participant range (leftmost/rightmost used in block) + // 2. Calculate box bounds with indent based on depth + // 3. Render top border with block type and label + // 4. For each section: + // a. Render section elements recursively + // b. If not last, render divider line with section label + // 5. Render bottom border +} +``` + +### Helper Functions + +```go +func findBlockParticipantRange(block *Block) (minIdx, maxIdx int) +func renderBlockBorder(label string, isTop bool, ...) string +func renderBlockDivider(label string, ...) string +``` + +### Nesting + +The `depth` parameter tracks nesting level: +- `depth=0`: outermost block, minimal indent +- `depth=1`: one level nested, additional indent +- etc. + +Each nesting level adds ~2 characters of left indent to avoid overlapping borders. + +## Testing Strategy + +1. **Parser tests**: Each block type, multi-section, nesting, case insensitivity, invalid dividers +2. **Renderer tests**: Box drawing, dividers, nested blocks, mixed content +3. **Integration**: `scratch/note-syntax.mermaid` with loop and par + +## Backward Compatibility + +- Existing diagrams without blocks work unchanged +- Blocks integrate with existing messages and notes via `DiagramElement` interface +- Autonumber applies only to messages, not block structure + +## Future Considerations + +- `activate`/`deactivate` for participant activation bars +- `box` for grouping participants +- Background colors for `rect` blocks (would require terminal color support) diff --git a/docs/plans/2026-01-20-block-syntax-implementation.md b/docs/plans/2026-01-20-block-syntax-implementation.md new file mode 100644 index 0000000..b608f78 --- /dev/null +++ b/docs/plans/2026-01-20-block-syntax-implementation.md @@ -0,0 +1,1076 @@ +# Block Syntax Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add support for Mermaid's block syntax (loop, alt, opt, par, critical, break, rect) with arbitrary nesting in sequence diagrams. + +**Architecture:** Extend parser with recursive descent for nested blocks, add Block/BlockSection structs implementing DiagramElement, modify renderer to draw block boxes with dividers and handle nesting via depth parameter. + +**Tech Stack:** Go, regexp, existing BoxChars for Unicode/ASCII rendering + +--- + +## Task 1: Add Block Data Types + +**Files:** +- Modify: `internal/sequence/parser.go` + +**Step 1: Add BlockType enum after NotePosition** + +```go +type BlockType int + +const ( + BlockLoop BlockType = iota + BlockAlt + BlockOpt + BlockPar + BlockCritical + BlockBreak + BlockRect +) + +func (b BlockType) String() string { + switch b { + case BlockLoop: + return "loop" + case BlockAlt: + return "alt" + case BlockOpt: + return "opt" + case BlockPar: + return "par" + case BlockCritical: + return "critical" + case BlockBreak: + return "break" + case BlockRect: + return "rect" + default: + return fmt.Sprintf("BlockType(%d)", b) + } +} +``` + +**Step 2: Add Block and BlockSection structs** + +```go +type BlockSection struct { + Label string + Elements []DiagramElement +} + +type Block struct { + Type BlockType + Label string + Sections []*BlockSection +} + +func (*Block) isElement() {} +``` + +**Step 3: Run build to verify no syntax errors** + +Run: `go build ./...` +Expected: Success + +**Step 4: Commit** + +```bash +git add internal/sequence/parser.go +git commit -m "feat(sequence): add Block data types" +``` + +--- + +## Task 2: Add Block Regexes + +**Files:** +- Modify: `internal/sequence/parser.go` + +**Step 1: Add block regexes to var block** + +```go +// blockStartRegex matches block start: loop, alt, opt, par, critical, break, rect +blockStartRegex = regexp.MustCompile(`(?i)^\s*(loop|alt|opt|par|critical|break|rect)\s*(.*)$`) + +// blockDividerRegex matches block dividers: else, and, option +blockDividerRegex = regexp.MustCompile(`(?i)^\s*(else|and|option)\s*(.*)$`) + +// blockEndRegex matches block end +blockEndRegex = regexp.MustCompile(`(?i)^\s*end\s*$`) +``` + +**Step 2: Run build to verify no syntax errors** + +Run: `go build ./...` +Expected: Success + +**Step 3: Commit** + +```bash +git add internal/sequence/parser.go +git commit -m "feat(sequence): add block regexes" +``` + +--- + +## Task 3: Implement parseBlock Method + +**Files:** +- Modify: `internal/sequence/parser.go` + +**Step 1: Add helper to map keyword to BlockType** + +```go +func parseBlockType(keyword string) (BlockType, error) { + switch strings.ToLower(keyword) { + case "loop": + return BlockLoop, nil + case "alt": + return BlockAlt, nil + case "opt": + return BlockOpt, nil + case "par": + return BlockPar, nil + case "critical": + return BlockCritical, nil + case "break": + return BlockBreak, nil + case "rect": + return BlockRect, nil + default: + return 0, fmt.Errorf("unknown block type: %q", keyword) + } +} +``` + +**Step 2: Add helper to validate divider for block type** + +```go +func isValidDivider(blockType BlockType, divider string) bool { + divider = strings.ToLower(divider) + switch blockType { + case BlockAlt: + return divider == "else" + case BlockPar: + return divider == "and" + case BlockCritical: + return divider == "option" + default: + return false + } +} +``` + +**Step 3: Implement parseBlock method** + +```go +func (sd *SequenceDiagram) parseBlock(lines []string, startIdx int, participants map[string]*Participant) (*Block, int, error) { + if startIdx >= len(lines) { + return nil, startIdx, fmt.Errorf("unexpected end of input") + } + + // Parse start line + match := blockStartRegex.FindStringSubmatch(lines[startIdx]) + if match == nil { + return nil, startIdx, fmt.Errorf("expected block start") + } + + blockType, err := parseBlockType(match[1]) + if err != nil { + return nil, startIdx, err + } + + block := &Block{ + Type: blockType, + Label: strings.TrimSpace(match[2]), + Sections: []*BlockSection{ + {Label: "", Elements: []DiagramElement{}}, + }, + } + + currentSection := block.Sections[0] + idx := startIdx + 1 + + for idx < len(lines) { + line := lines[idx] + trimmed := strings.TrimSpace(line) + + if trimmed == "" { + idx++ + continue + } + + // Check for block end + if blockEndRegex.MatchString(trimmed) { + return block, idx + 1, nil + } + + // Check for divider + if divMatch := blockDividerRegex.FindStringSubmatch(trimmed); divMatch != nil { + divider := divMatch[1] + if !isValidDivider(block.Type, divider) { + return nil, idx, fmt.Errorf("invalid divider %q for block type %s", divider, block.Type) + } + currentSection = &BlockSection{ + Label: strings.TrimSpace(divMatch[2]), + Elements: []DiagramElement{}, + } + block.Sections = append(block.Sections, currentSection) + idx++ + continue + } + + // Check for nested block + if blockStartRegex.MatchString(trimmed) { + nestedBlock, nextIdx, err := sd.parseBlock(lines, idx, participants) + if err != nil { + return nil, idx, fmt.Errorf("nested block: %w", err) + } + currentSection.Elements = append(currentSection.Elements, nestedBlock) + idx = nextIdx + continue + } + + // Check for note + if noteRegex.MatchString(trimmed) { + if matched, err := sd.parseNote(trimmed, participants); err != nil { + return nil, idx, err + } else if matched { + // Move last element from sd.Elements to currentSection + if len(sd.Elements) > 0 { + lastElem := sd.Elements[len(sd.Elements)-1] + sd.Elements = sd.Elements[:len(sd.Elements)-1] + currentSection.Elements = append(currentSection.Elements, lastElem) + } + idx++ + continue + } + } + + // Check for message + if messageRegex.MatchString(trimmed) { + if matched, err := sd.parseMessage(trimmed, participants); err != nil { + return nil, idx, err + } else if matched { + // Move last element from sd.Elements to currentSection + if len(sd.Elements) > 0 { + lastElem := sd.Elements[len(sd.Elements)-1] + sd.Elements = sd.Elements[:len(sd.Elements)-1] + currentSection.Elements = append(currentSection.Elements, lastElem) + } + idx++ + continue + } + } + + return nil, idx, fmt.Errorf("invalid syntax in block: %q", trimmed) + } + + return nil, idx, fmt.Errorf("block not closed, missing 'end'") +} +``` + +**Step 4: Run build to verify no syntax errors** + +Run: `go build ./...` +Expected: Success + +**Step 5: Commit** + +```bash +git add internal/sequence/parser.go +git commit -m "feat(sequence): implement parseBlock method" +``` + +--- + +## Task 4: Integrate Block Parsing into Main Parse Loop + +**Files:** +- Modify: `internal/sequence/parser.go` + +**Step 1: Refactor Parse to use line index tracking** + +The current Parse function iterates with `for i, line := range lines`. We need to refactor to use index-based iteration so parseBlock can advance the index. + +Replace the parse loop with: + +```go +func Parse(input string) (*SequenceDiagram, error) { + input = strings.TrimSpace(input) + if input == "" { + return nil, fmt.Errorf("empty input") + } + + rawLines := diagram.SplitLines(input) + lines := diagram.RemoveComments(rawLines) + if len(lines) == 0 { + return nil, fmt.Errorf("no content found") + } + + if !strings.HasPrefix(strings.TrimSpace(lines[0]), SequenceDiagramKeyword) { + return nil, fmt.Errorf("expected %q keyword", SequenceDiagramKeyword) + } + + sd := &SequenceDiagram{ + Participants: []*Participant{}, + Messages: []*Message{}, + Elements: []DiagramElement{}, + Autonumber: false, + } + participantMap := make(map[string]*Participant) + + idx := 1 + for idx < len(lines) { + line := lines[idx] + trimmed := strings.TrimSpace(line) + + if trimmed == "" { + idx++ + continue + } + + // Check for autonumber + if autonumberRegex.MatchString(trimmed) { + sd.Autonumber = true + idx++ + continue + } + + // Check for participant + if matched, err := sd.parseParticipant(trimmed, participantMap); err != nil { + return nil, fmt.Errorf("line %d: %w", idx+1, err) + } else if matched { + idx++ + continue + } + + // Check for block start + if blockStartRegex.MatchString(trimmed) { + block, nextIdx, err := sd.parseBlock(lines, idx, participantMap) + if err != nil { + return nil, fmt.Errorf("line %d: %w", idx+1, err) + } + sd.Elements = append(sd.Elements, block) + idx = nextIdx + continue + } + + // Check for message + if matched, err := sd.parseMessage(trimmed, participantMap); err != nil { + return nil, fmt.Errorf("line %d: %w", idx+1, err) + } else if matched { + idx++ + continue + } + + // Check for note + if matched, err := sd.parseNote(trimmed, participantMap); err != nil { + return nil, fmt.Errorf("line %d: %w", idx+1, err) + } else if matched { + idx++ + continue + } + + return nil, fmt.Errorf("line %d: invalid syntax: %q", idx+1, trimmed) + } + + if len(sd.Participants) == 0 { + return nil, fmt.Errorf("no participants found") + } + + return sd, nil +} +``` + +**Step 2: Run existing tests to verify no regression** + +Run: `go test ./internal/sequence/... -v` +Expected: All existing tests pass + +**Step 3: Commit** + +```bash +git add internal/sequence/parser.go +git commit -m "feat(sequence): integrate block parsing into main loop" +``` + +--- + +## Task 5: Write Parser Tests for Blocks + +**Files:** +- Modify: `internal/sequence/sequence_test.go` + +**Step 1: Write test for simple loop block** + +```go +func TestParseBlockLoop(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + loop Every minute + A->>B: Ping + B-->>A: Pong + end` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(sd.Elements) != 1 { + t.Fatalf("expected 1 element, got %d", len(sd.Elements)) + } + + block, ok := sd.Elements[0].(*Block) + if !ok { + t.Fatalf("expected Block, got %T", sd.Elements[0]) + } + + if block.Type != BlockLoop { + t.Errorf("expected BlockLoop, got %v", block.Type) + } + if block.Label != "Every minute" { + t.Errorf("expected label 'Every minute', got %q", block.Label) + } + if len(block.Sections) != 1 { + t.Errorf("expected 1 section, got %d", len(block.Sections)) + } + if len(block.Sections[0].Elements) != 2 { + t.Errorf("expected 2 elements in section, got %d", len(block.Sections[0].Elements)) + } +} +``` + +**Step 2: Write test for alt/else block** + +```go +func TestParseBlockAltElse(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + alt Success + A->>B: 200 OK + else Failure + A->>B: 500 Error + end` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + block, ok := sd.Elements[0].(*Block) + if !ok { + t.Fatalf("expected Block, got %T", sd.Elements[0]) + } + + if block.Type != BlockAlt { + t.Errorf("expected BlockAlt, got %v", block.Type) + } + if len(block.Sections) != 2 { + t.Errorf("expected 2 sections, got %d", len(block.Sections)) + } + if block.Sections[1].Label != "Failure" { + t.Errorf("expected section label 'Failure', got %q", block.Sections[1].Label) + } +} +``` + +**Step 3: Write test for par/and block** + +```go +func TestParseBlockParAnd(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + participant C + par Task 1 + A->>B: Do X + and Task 2 + A->>C: Do Y + end` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + block, ok := sd.Elements[0].(*Block) + if !ok { + t.Fatalf("expected Block, got %T", sd.Elements[0]) + } + + if block.Type != BlockPar { + t.Errorf("expected BlockPar, got %v", block.Type) + } + if len(block.Sections) != 2 { + t.Errorf("expected 2 sections, got %d", len(block.Sections)) + } +} +``` + +**Step 4: Write test for nested blocks** + +```go +func TestParseBlockNested(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + loop Outer + alt Check + A->>B: Request + else Skip + A->>B: Skip + end + end` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + outerBlock, ok := sd.Elements[0].(*Block) + if !ok { + t.Fatalf("expected Block, got %T", sd.Elements[0]) + } + + if outerBlock.Type != BlockLoop { + t.Errorf("expected BlockLoop, got %v", outerBlock.Type) + } + + innerBlock, ok := outerBlock.Sections[0].Elements[0].(*Block) + if !ok { + t.Fatalf("expected nested Block, got %T", outerBlock.Sections[0].Elements[0]) + } + + if innerBlock.Type != BlockAlt { + t.Errorf("expected nested BlockAlt, got %v", innerBlock.Type) + } +} +``` + +**Step 5: Run all block parser tests** + +Run: `go test ./internal/sequence/... -run TestParseBlock -v` +Expected: All PASS + +**Step 6: Commit** + +```bash +git add internal/sequence/sequence_test.go +git commit -m "test(sequence): add parser tests for block syntax" +``` + +--- + +## Task 6: Add renderBlock Stub and Update Render Loop + +**Files:** +- Modify: `internal/sequence/renderer.go` + +**Step 1: Add renderBlock stub** + +```go +func renderBlock(block *Block, layout *diagramLayout, chars BoxChars, depth int) []string { + // TODO: implement + return nil +} +``` + +**Step 2: Add findBlockParticipantRange helper** + +```go +func findBlockParticipantRange(block *Block) (minIdx, maxIdx int) { + minIdx = -1 + maxIdx = -1 + + var findInElements func(elements []DiagramElement) + findInElements = func(elements []DiagramElement) { + for _, elem := range elements { + switch e := elem.(type) { + case *Message: + if minIdx == -1 || e.From.Index < minIdx { + minIdx = e.From.Index + } + if minIdx == -1 || e.To.Index < minIdx { + minIdx = e.To.Index + } + if e.From.Index > maxIdx { + maxIdx = e.From.Index + } + if e.To.Index > maxIdx { + maxIdx = e.To.Index + } + case *Note: + for _, actor := range e.Actors { + if minIdx == -1 || actor.Index < minIdx { + minIdx = actor.Index + } + if actor.Index > maxIdx { + maxIdx = actor.Index + } + } + case *Block: + nestedMin, nestedMax := findBlockParticipantRange(e) + if nestedMin != -1 && (minIdx == -1 || nestedMin < minIdx) { + minIdx = nestedMin + } + if nestedMax > maxIdx { + maxIdx = nestedMax + } + } + } + } + + for _, section := range block.Sections { + findInElements(section.Elements) + } + + return minIdx, maxIdx +} +``` + +**Step 3: Update Render loop to handle blocks** + +In the element type switch, add case for Block: + +```go +case *Block: + blockLines := renderBlock(e, layout, chars, 0) + if blockLines != nil { + lines = append(lines, blockLines...) + } +``` + +**Step 4: Run existing tests to verify no regression** + +Run: `go test ./internal/sequence/... -v` +Expected: All existing tests pass + +**Step 5: Commit** + +```bash +git add internal/sequence/renderer.go +git commit -m "feat(sequence): add renderBlock stub and update render loop" +``` + +--- + +## Task 7: Implement renderBlock + +**Files:** +- Modify: `internal/sequence/renderer.go` + +**Step 1: Write test for block rendering** + +```go +func TestRenderBlockLoop(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + loop Every minute + A->>B: Ping + end` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("parse error: %v", err) + } + + output, err := Render(sd, nil) + if err != nil { + t.Fatalf("render error: %v", err) + } + + if !strings.Contains(output, "loop") { + t.Errorf("output should contain 'loop':\n%s", output) + } + if !strings.Contains(output, "Every minute") { + t.Errorf("output should contain label:\n%s", output) + } + if !strings.Contains(output, "Ping") { + t.Errorf("output should contain message:\n%s", output) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/sequence/... -run TestRenderBlockLoop -v` +Expected: FAIL + +**Step 3: Implement renderBlock** + +```go +func renderBlock(block *Block, layout *diagramLayout, chars BoxChars, depth int) []string { + var lines []string + + minIdx, maxIdx := findBlockParticipantRange(block) + if minIdx == -1 || maxIdx == -1 { + return nil + } + + indent := depth * 2 + leftCenter := layout.participantCenters[minIdx] + rightCenter := layout.participantCenters[maxIdx] + + boxLeft := leftCenter - 3 - indent + if boxLeft < 0 { + boxLeft = 0 + } + boxRight := rightCenter + 3 + + // Ensure minimum width for label + headerLabel := fmt.Sprintf("%s %s", block.Type, block.Label) + labelWidth := runewidth.StringWidth(headerLabel) + if boxRight-boxLeft < labelWidth+4 { + boxRight = boxLeft + labelWidth + 4 + } + + ensureWidth := boxRight + 1 + if ensureWidth < layout.totalWidth { + ensureWidth = layout.totalWidth + } + + // Helper to create a line with lifelines + makeLine := func() []rune { + line := make([]rune, ensureWidth+1) + for i := range line { + line[i] = ' ' + } + for _, c := range layout.participantCenters { + if c < len(line) { + line[c] = chars.Vertical + } + } + return line + } + + // Draw top border + topLine := makeLine() + topLine[boxLeft] = chars.TopLeft + for i := boxLeft + 1; i < boxRight; i++ { + if topLine[i] == chars.Vertical { + topLine[i] = chars.TeeUp + } else { + topLine[i] = chars.Horizontal + } + } + topLine[boxRight] = chars.TopRight + lines = append(lines, strings.TrimRight(string(topLine), " ")) + + // Draw header label line + headerLine := makeLine() + headerLine[boxLeft] = chars.Vertical + headerLine[boxRight] = chars.Vertical + col := boxLeft + 2 + for _, r := range headerLabel { + if col < boxRight { + headerLine[col] = r + col++ + } + } + lines = append(lines, strings.TrimRight(string(headerLine), " ")) + + // Draw header separator + sepLine := makeLine() + sepLine[boxLeft] = chars.Vertical + for i := boxLeft + 1; i < boxRight; i++ { + if sepLine[i] == chars.Vertical { + // keep lifeline + } else { + sepLine[i] = chars.Horizontal + } + } + sepLine[boxRight] = chars.Vertical + lines = append(lines, strings.TrimRight(string(sepLine), " ")) + + // Render each section + for sectionIdx, section := range block.Sections { + // Draw section divider if not first section + if sectionIdx > 0 { + divLine := makeLine() + divLine[boxLeft] = chars.TeeRight + for i := boxLeft + 1; i < boxRight; i++ { + if divLine[i] == chars.Vertical { + divLine[i] = chars.Cross + } else { + divLine[i] = chars.Horizontal + } + } + divLine[boxRight] = chars.TeeLeft + lines = append(lines, strings.TrimRight(string(divLine), " ")) + + // Section label + if section.Label != "" { + labelLine := makeLine() + labelLine[boxLeft] = chars.Vertical + labelLine[boxRight] = chars.Vertical + col := boxLeft + 2 + for _, r := range section.Label { + if col < boxRight { + labelLine[col] = r + col++ + } + } + lines = append(lines, strings.TrimRight(string(labelLine), " ")) + } + } + + // Render section elements + for _, elem := range section.Elements { + // Add spacing line + spaceLine := makeLine() + spaceLine[boxLeft] = chars.Vertical + spaceLine[boxRight] = chars.Vertical + lines = append(lines, strings.TrimRight(string(spaceLine), " ")) + + switch e := elem.(type) { + case *Message: + msgLines := renderMessage(e, layout, chars) + for _, ml := range msgLines { + // Add block borders to message lines + mlRunes := []rune(ml) + for len(mlRunes) <= ensureWidth { + mlRunes = append(mlRunes, ' ') + } + mlRunes[boxLeft] = chars.Vertical + mlRunes[boxRight] = chars.Vertical + lines = append(lines, strings.TrimRight(string(mlRunes), " ")) + } + case *Note: + noteLines := renderNote(e, layout, chars) + for _, nl := range noteLines { + nlRunes := []rune(nl) + for len(nlRunes) <= ensureWidth { + nlRunes = append(nlRunes, ' ') + } + nlRunes[boxLeft] = chars.Vertical + nlRunes[boxRight] = chars.Vertical + lines = append(lines, strings.TrimRight(string(nlRunes), " ")) + } + case *Block: + nestedLines := renderBlock(e, layout, chars, depth+1) + for _, nl := range nestedLines { + nlRunes := []rune(nl) + for len(nlRunes) <= ensureWidth { + nlRunes = append(nlRunes, ' ') + } + nlRunes[boxLeft] = chars.Vertical + nlRunes[boxRight] = chars.Vertical + lines = append(lines, strings.TrimRight(string(nlRunes), " ")) + } + } + } + } + + // Add spacing before bottom + spaceLine := makeLine() + spaceLine[boxLeft] = chars.Vertical + spaceLine[boxRight] = chars.Vertical + lines = append(lines, strings.TrimRight(string(spaceLine), " ")) + + // Draw bottom border + bottomLine := makeLine() + bottomLine[boxLeft] = chars.BottomLeft + for i := boxLeft + 1; i < boxRight; i++ { + if bottomLine[i] == chars.Vertical { + bottomLine[i] = chars.TeeDown + } else { + bottomLine[i] = chars.Horizontal + } + } + bottomLine[boxRight] = chars.BottomRight + lines = append(lines, strings.TrimRight(string(bottomLine), " ")) + + return lines +} +``` + +**Step 4: Add Cross character to BoxChars if missing** + +Check `internal/sequence/charset.go` and add `Cross` character ('+' for ASCII, '┼' for Unicode) if not present. + +**Step 5: Run test to verify it passes** + +Run: `go test ./internal/sequence/... -run TestRenderBlockLoop -v` +Expected: PASS + +**Step 6: Commit** + +```bash +git add internal/sequence/renderer.go internal/sequence/charset.go +git commit -m "feat(sequence): implement renderBlock" +``` + +--- + +## Task 8: Write Renderer Tests for Multi-Section and Nested Blocks + +**Files:** +- Modify: `internal/sequence/sequence_test.go` + +**Step 1: Write test for alt/else rendering** + +```go +func TestRenderBlockAltElse(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + alt Success + A->>B: OK + else Error + A->>B: Fail + end` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("parse error: %v", err) + } + + output, err := Render(sd, nil) + if err != nil { + t.Fatalf("render error: %v", err) + } + + if !strings.Contains(output, "alt") { + t.Errorf("output should contain 'alt':\n%s", output) + } + if !strings.Contains(output, "Error") { + t.Errorf("output should contain 'Error' divider:\n%s", output) + } +} +``` + +**Step 2: Write test for nested blocks rendering** + +```go +func TestRenderBlockNested(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + loop Retry + opt Check + A->>B: Verify + end + end` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("parse error: %v", err) + } + + output, err := Render(sd, nil) + if err != nil { + t.Fatalf("render error: %v", err) + } + + if !strings.Contains(output, "loop") { + t.Errorf("output should contain 'loop':\n%s", output) + } + if !strings.Contains(output, "opt") { + t.Errorf("output should contain nested 'opt':\n%s", output) + } +} +``` + +**Step 3: Run all render tests** + +Run: `go test ./internal/sequence/... -run TestRenderBlock -v` +Expected: All PASS + +**Step 4: Commit** + +```bash +git add internal/sequence/sequence_test.go +git commit -m "test(sequence): add renderer tests for multi-section and nested blocks" +``` + +--- + +## Task 9: Integration Test with scratch/note-syntax.mermaid + +**Step 1: Run CLI against the scratch file** + +Run: `cat scratch/note-syntax.mermaid | go run main.go` + +This file contains: +- `loop Video Stream` with messages +- `par Control Commands` with `and Events` + +Verify the output shows both blocks rendered correctly with their contents. + +**Step 2: If errors, debug and fix** + +Common issues: +- Parser errors (block not recognized) +- Missing 'end' keyword +- Incorrect divider validation + +**Step 3: Run full test suite** + +Run: `go test ./... -v` +Expected: All PASS + +**Step 4: Commit if any fixes were needed** + +```bash +git add -A +git commit -m "fix(sequence): address integration test issues" +``` + +--- + +## Task 10: Final Cleanup and Documentation + +**Step 1: Run all tests** + +Run: `go test ./... -v` +Expected: All PASS + +**Step 2: Test visual output with comprehensive example** + +```bash +cat << 'EOF' | go run main.go +sequenceDiagram + participant A + participant B + participant C + A->>B: Request + loop Retry 3 times + B->>C: Check + alt Success + C-->>B: OK + else Failure + C-->>B: Error + end + end + B-->>A: Response +EOF +``` + +**Step 3: Final commit** + +```bash +git add -A +git commit -m "feat(sequence): complete block syntax support (loop, alt, opt, par, critical, break, rect)" +``` + +--- + +## Summary + +After completing all tasks, the sequence diagram parser will support: +- `loop [label]` ... `end` +- `alt [label]` ... `else [label]` ... `end` +- `opt [label]` ... `end` +- `par [label]` ... `and [label]` ... `end` +- `critical [label]` ... `option [label]` ... `end` +- `break [label]` ... `end` +- `rect [color]` ... `end` +- Arbitrary nesting of blocks +- Blocks mixed with messages and notes diff --git a/docs/plans/2026-01-20-note-syntax-design.md b/docs/plans/2026-01-20-note-syntax-design.md new file mode 100644 index 0000000..cb84c1c --- /dev/null +++ b/docs/plans/2026-01-20-note-syntax-design.md @@ -0,0 +1,156 @@ +# Sequence Diagram Note Syntax Support + +## Overview + +Add support for Mermaid's note syntax in sequence diagrams. Notes are annotations that appear alongside the message flow to provide context or explanations. + +## Supported Syntax + +Four variants will be supported: + +```mermaid +Note over Actor: text # Note above a single actor +Note over Actor1,Actor2: text # Note spanning multiple actors +Note left of Actor: text # Note positioned left of actor +Note right of Actor: text # Note positioned right of actor +``` + +All variants are case-insensitive (`Note`, `NOTE`, `note`). + +## Visual Examples + +Given participants `Client`, `ESA`, `WS`, `Phone`: + +### Note over single actor +``` + │ │ │ │ +┌───┴───────────────┐ +│ Extract control │ +│ server │ +└───┬───────────────┘ + │ │ │ │ +``` + +### Note spanning multiple actors +``` + │ │ │ │ +┌───┴─────────────┴──────────┴──┐ +│ Spanning note │ +└───┬─────────────┬──────────┬──┘ + │ │ │ │ +``` + +### Note left of actor +``` + │ │ │ │ +┌─────────┐ +│ Left ├──────┤ +│ note │ │ +└─────────┘ │ + │ │ │ │ +``` + +### Note right of actor +``` + │ │ │ │ + ┌──────────┐ + │─────┤ Right │ + │ │ note │ + │ └──────────┘ + │ │ │ │ +``` + +## Data Model + +### New Types + +```go +type NotePosition int + +const ( + NoteOver NotePosition = iota // Note over Actor or Note over Actor1,Actor2 + NoteLeftOf // Note left of Actor + NoteRightOf // Note right of Actor +) + +type Note struct { + Position NotePosition + Actors []*Participant // 1 actor for left/right, 1-2 for "over" + Text string +} +``` + +### Modified Types + +```go +type DiagramElement interface{} // Messages and Notes both implement this + +type SequenceDiagram struct { + Participants []*Participant + Messages []*Message // Keep for backward compatibility + Elements []DiagramElement // Ordered sequence of messages + notes + Autonumber bool +} +``` + +## Parser Design + +Add regex to match all note variants: + +```go +noteRegex = regexp.MustCompile(`(?i)^\s*note\s+(over|left\s+of|right\s+of)\s+([^:]+):\s*(.*)$`) +``` + +The `parseNote` method will: +1. Match the regex +2. Determine position from capture group 1 +3. Parse actor(s) from capture group 2 (comma-separated for multi-actor) +4. Extract text from capture group 3 +5. Look up or auto-create participants +6. Append Note to Elements slice + +## Renderer Design + +### Entry Point + +```go +func renderNote(note *Note, layout *diagramLayout, chars BoxChars) []string { + switch note.Position { + case NoteOver: + return renderNoteOver(note, layout, chars) + case NoteLeftOf: + return renderNoteLeftOf(note, layout, chars) + case NoteRightOf: + return renderNoteRightOf(note, layout, chars) + } + return nil +} +``` + +### Rendering Logic + +- **NoteOver**: Calculate horizontal bounds from actor center(s), draw box with text, lifelines connect at box edges using `┴`/`┬` connectors +- **NoteLeftOf**: Draw box left of actor's lifeline, connect with horizontal line +- **NoteRightOf**: Draw box right of actor's lifeline, connect with horizontal line + +### Main Loop Change + +The `Render` function will iterate over `Elements` instead of just `Messages`, dispatching to `renderNote` or `renderMessage` as appropriate. + +## Testing Strategy + +1. **Parser tests**: Each note variant, case insensitivity, auto-participant creation +2. **Renderer tests**: Golden-file tests for each note position type +3. **Integration test**: Use `scratch/note-syntax.mermaid` as real-world validation + +## Backward Compatibility + +- Keep `Messages` field populated for any code depending on it +- Existing diagrams without notes continue to work unchanged +- Autonumber only applies to messages, not notes + +## Future Considerations + +- Multi-line note text (currently single line only) +- Note styling/theming +- Note text wrapping at configurable width diff --git a/docs/plans/2026-01-20-note-syntax-implementation.md b/docs/plans/2026-01-20-note-syntax-implementation.md new file mode 100644 index 0000000..7d7b600 --- /dev/null +++ b/docs/plans/2026-01-20-note-syntax-implementation.md @@ -0,0 +1,981 @@ +# Note Syntax Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add support for Mermaid's `Note over`, `Note left of`, and `Note right of` syntax in sequence diagrams. + +**Architecture:** Extend parser with note regex and parseNote method, add Note struct and DiagramElement interface, modify renderer to iterate Elements and dispatch to renderNote functions. + +**Tech Stack:** Go, regexp, existing BoxChars for Unicode/ASCII rendering + +--- + +## Task 1: Add Note Data Types + +**Files:** +- Modify: `internal/sequence/parser.go:28-48` + +**Step 1: Add NotePosition enum and Note struct after ArrowType** + +Add after line 54 in `internal/sequence/parser.go`: + +```go +type NotePosition int + +const ( + NoteOver NotePosition = iota + NoteLeftOf + NoteRightOf +) + +func (n NotePosition) String() string { + switch n { + case NoteOver: + return "over" + case NoteLeftOf: + return "left of" + case NoteRightOf: + return "right of" + default: + return fmt.Sprintf("NotePosition(%d)", n) + } +} + +type Note struct { + Position NotePosition + Actors []*Participant + Text string +} +``` + +**Step 2: Add DiagramElement interface and Elements field** + +Add interface before SequenceDiagram struct: + +```go +type DiagramElement interface { + isElement() +} + +func (*Message) isElement() {} +func (*Note) isElement() {} +``` + +Modify SequenceDiagram struct to add Elements field: + +```go +type SequenceDiagram struct { + Participants []*Participant + Messages []*Message + Elements []DiagramElement // Ordered messages and notes + Autonumber bool +} +``` + +**Step 3: Run build to verify no syntax errors** + +Run: `go build ./...` +Expected: Success, no errors + +**Step 4: Commit** + +```bash +git add internal/sequence/parser.go +git commit -m "feat(sequence): add Note data types and DiagramElement interface" +``` + +--- + +## Task 2: Add Note Regex and Parser Method + +**Files:** +- Modify: `internal/sequence/parser.go:17-26` (add regex) +- Modify: `internal/sequence/parser.go` (add parseNote method) + +**Step 1: Add noteRegex to var block** + +Add to the var block after autonumberRegex: + +```go +// noteRegex matches note declarations: +// Note over Actor: text +// Note over Actor1,Actor2: text +// Note left of Actor: text +// Note right of Actor: text +noteRegex = regexp.MustCompile(`(?i)^\s*note\s+(over|left\s+of|right\s+of)\s+([^:]+):\s*(.*)$`) +``` + +**Step 2: Add parseNote method** + +Add after parseMessage method: + +```go +func (sd *SequenceDiagram) parseNote(line string, participants map[string]*Participant) (bool, error) { + match := noteRegex.FindStringSubmatch(line) + if match == nil { + return false, nil + } + + posStr := strings.ToLower(match[1]) + actorsStr := strings.TrimSpace(match[2]) + text := strings.TrimSpace(match[3]) + + var position NotePosition + switch { + case posStr == "over": + position = NoteOver + case strings.Contains(posStr, "left"): + position = NoteLeftOf + case strings.Contains(posStr, "right"): + position = NoteRightOf + default: + return false, fmt.Errorf("unknown note position: %q", posStr) + } + + // Parse actor(s) - comma separated for "over" with multiple actors + actorIDs := strings.Split(actorsStr, ",") + var actors []*Participant + for _, id := range actorIDs { + id = strings.TrimSpace(id) + if id == "" { + continue + } + actors = append(actors, sd.getParticipant(id, participants)) + } + + if len(actors) == 0 { + return false, fmt.Errorf("note requires at least one actor") + } + + if position != NoteOver && len(actors) > 1 { + return false, fmt.Errorf("note %s only supports one actor", position) + } + + note := &Note{ + Position: position, + Actors: actors, + Text: text, + } + sd.Elements = append(sd.Elements, note) + return true, nil +} +``` + +**Step 3: Run build to verify no syntax errors** + +Run: `go build ./...` +Expected: Success, no errors + +**Step 4: Commit** + +```bash +git add internal/sequence/parser.go +git commit -m "feat(sequence): add noteRegex and parseNote method" +``` + +--- + +## Task 3: Integrate Note Parsing into Main Parse Loop + +**Files:** +- Modify: `internal/sequence/parser.go:103-128` (parse loop) +- Modify: `internal/sequence/parser.go:167-208` (parseMessage to also append to Elements) + +**Step 1: Update parseMessage to append to Elements** + +In parseMessage, before `return true, nil`, add: + +```go +sd.Elements = append(sd.Elements, msg) +``` + +**Step 2: Add parseNote call in main parse loop** + +In the Parse function, after the parseMessage block and before the error return, add: + +```go +if matched, err := sd.parseNote(trimmed, participantMap); err != nil { + return nil, fmt.Errorf("line %d: %w", i+2, err) +} else if matched { + continue +} +``` + +**Step 3: Run build to verify no syntax errors** + +Run: `go build ./...` +Expected: Success, no errors + +**Step 4: Commit** + +```bash +git add internal/sequence/parser.go +git commit -m "feat(sequence): integrate note parsing into main loop" +``` + +--- + +## Task 4: Write Parser Tests for Notes + +**Files:** +- Modify: `internal/sequence/sequence_test.go` + +**Step 1: Write test for Note over single actor** + +Add test function: + +```go +func TestParseNoteOverSingleActor(t *testing.T) { + input := `sequenceDiagram + participant A + Note over A: This is a note` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(sd.Elements) != 1 { + t.Fatalf("expected 1 element, got %d", len(sd.Elements)) + } + + note, ok := sd.Elements[0].(*Note) + if !ok { + t.Fatalf("expected Note, got %T", sd.Elements[0]) + } + + if note.Position != NoteOver { + t.Errorf("expected NoteOver, got %v", note.Position) + } + if len(note.Actors) != 1 || note.Actors[0].ID != "A" { + t.Errorf("expected actor A, got %v", note.Actors) + } + if note.Text != "This is a note" { + t.Errorf("expected 'This is a note', got %q", note.Text) + } +} +``` + +**Step 2: Run test to verify it passes** + +Run: `go test ./internal/sequence/... -run TestParseNoteOverSingleActor -v` +Expected: PASS + +**Step 3: Write test for Note over multiple actors** + +```go +func TestParseNoteOverMultipleActors(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + Note over A,B: Spanning note` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(sd.Elements) != 1 { + t.Fatalf("expected 1 element, got %d", len(sd.Elements)) + } + + note, ok := sd.Elements[0].(*Note) + if !ok { + t.Fatalf("expected Note, got %T", sd.Elements[0]) + } + + if note.Position != NoteOver { + t.Errorf("expected NoteOver, got %v", note.Position) + } + if len(note.Actors) != 2 { + t.Errorf("expected 2 actors, got %d", len(note.Actors)) + } + if note.Actors[0].ID != "A" || note.Actors[1].ID != "B" { + t.Errorf("expected actors A and B, got %v", note.Actors) + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/sequence/... -run TestParseNoteOverMultipleActors -v` +Expected: PASS + +**Step 5: Write test for Note left of and right of** + +```go +func TestParseNoteLeftRight(t *testing.T) { + tests := []struct { + name string + input string + position NotePosition + }{ + { + name: "left of", + input: `sequenceDiagram + participant A + Note left of A: Left note`, + position: NoteLeftOf, + }, + { + name: "right of", + input: `sequenceDiagram + participant A + Note right of A: Right note`, + position: NoteRightOf, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sd, err := Parse(tt.input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(sd.Elements) != 1 { + t.Fatalf("expected 1 element, got %d", len(sd.Elements)) + } + + note, ok := sd.Elements[0].(*Note) + if !ok { + t.Fatalf("expected Note, got %T", sd.Elements[0]) + } + + if note.Position != tt.position { + t.Errorf("expected %v, got %v", tt.position, note.Position) + } + }) + } +} +``` + +**Step 6: Run all note tests** + +Run: `go test ./internal/sequence/... -run TestParseNote -v` +Expected: All PASS + +**Step 7: Commit** + +```bash +git add internal/sequence/sequence_test.go +git commit -m "test(sequence): add parser tests for note syntax" +``` + +--- + +## Task 5: Add renderNote Stub and Update Render Loop + +**Files:** +- Modify: `internal/sequence/renderer.go:115-128` (render loop) + +**Step 1: Add renderNote stub function** + +Add after renderSelfMessage function: + +```go +func renderNote(note *Note, layout *diagramLayout, chars BoxChars) []string { + // TODO: implement note rendering + return nil +} +``` + +**Step 2: Update Render function to iterate Elements** + +Replace the message loop (lines ~115-125) with: + +```go +for _, elem := range sd.Elements { + for i := 0; i < layout.messageSpacing; i++ { + lines = append(lines, buildLifeline(layout, chars)) + } + + switch e := elem.(type) { + case *Message: + if e.From == e.To { + lines = append(lines, renderSelfMessage(e, layout, chars)...) + } else { + lines = append(lines, renderMessage(e, layout, chars)...) + } + case *Note: + noteLines := renderNote(e, layout, chars) + if noteLines != nil { + lines = append(lines, noteLines...) + } + } +} +``` + +**Step 3: Run existing tests to verify no regression** + +Run: `go test ./internal/sequence/... -v` +Expected: All existing tests PASS + +**Step 4: Commit** + +```bash +git add internal/sequence/renderer.go +git commit -m "refactor(sequence): update render loop to iterate Elements" +``` + +--- + +## Task 6: Implement renderNoteOver + +**Files:** +- Modify: `internal/sequence/renderer.go` + +**Step 1: Write test for Note over rendering** + +Add to test file: + +```go +func TestRenderNoteOver(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + Note over A: Test note` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("parse error: %v", err) + } + + output, err := Render(sd, nil) + if err != nil { + t.Fatalf("render error: %v", err) + } + + // Verify note box appears in output + if !strings.Contains(output, "Test note") { + t.Errorf("output should contain note text:\n%s", output) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/sequence/... -run TestRenderNoteOver -v` +Expected: FAIL (note text not in output) + +**Step 3: Implement renderNoteOver** + +Add helper and implementation: + +```go +func renderNoteOver(note *Note, layout *diagramLayout, chars BoxChars) []string { + var lines []string + + // Calculate horizontal bounds + leftActor := note.Actors[0] + rightActor := note.Actors[len(note.Actors)-1] + + leftCenter := layout.participantCenters[leftActor.Index] + rightCenter := layout.participantCenters[rightActor.Index] + + if leftCenter > rightCenter { + leftCenter, rightCenter = rightCenter, leftCenter + } + + // Note box width: from left actor center to right actor center, with padding + padding := 2 + boxLeft := leftCenter - padding + if boxLeft < 0 { + boxLeft = 0 + } + textWidth := runewidth.StringWidth(note.Text) + minBoxWidth := textWidth + 4 // 2 padding each side + boxWidth := rightCenter - leftCenter + padding*2 + if boxWidth < minBoxWidth { + boxWidth = minBoxWidth + } + boxRight := boxLeft + boxWidth + + // Build top border with lifeline connections + topLine := make([]rune, layout.totalWidth+boxWidth) + for i := range topLine { + topLine[i] = ' ' + } + for _, c := range layout.participantCenters { + if c < len(topLine) { + if c >= boxLeft && c <= boxRight { + topLine[c] = chars.TeeUp + } else { + topLine[c] = chars.Vertical + } + } + } + topLine[boxLeft] = chars.TopLeft + for i := boxLeft + 1; i < boxRight; i++ { + if topLine[i] != chars.TeeUp { + topLine[i] = chars.Horizontal + } + } + topLine[boxRight] = chars.TopRight + lines = append(lines, strings.TrimRight(string(topLine), " ")) + + // Build text line + textLine := make([]rune, layout.totalWidth+boxWidth) + for i := range textLine { + textLine[i] = ' ' + } + for _, c := range layout.participantCenters { + if c < len(textLine) && (c < boxLeft || c > boxRight) { + textLine[c] = chars.Vertical + } + } + textLine[boxLeft] = chars.Vertical + textLine[boxRight] = chars.Vertical + // Center text in box + textStart := boxLeft + (boxWidth-textWidth)/2 + col := textStart + for _, r := range note.Text { + if col < len(textLine) && col < boxRight { + textLine[col] = r + col++ + } + } + lines = append(lines, strings.TrimRight(string(textLine), " ")) + + // Build bottom border with lifeline connections + bottomLine := make([]rune, layout.totalWidth+boxWidth) + for i := range bottomLine { + bottomLine[i] = ' ' + } + for _, c := range layout.participantCenters { + if c < len(bottomLine) { + if c >= boxLeft && c <= boxRight { + bottomLine[c] = chars.TeeDown + } else { + bottomLine[c] = chars.Vertical + } + } + } + bottomLine[boxLeft] = chars.BottomLeft + for i := boxLeft + 1; i < boxRight; i++ { + if bottomLine[i] != chars.TeeDown { + bottomLine[i] = chars.Horizontal + } + } + bottomLine[boxRight] = chars.BottomRight + lines = append(lines, strings.TrimRight(string(bottomLine), " ")) + + return lines +} +``` + +Update renderNote to call it: + +```go +func renderNote(note *Note, layout *diagramLayout, chars BoxChars) []string { + switch note.Position { + case NoteOver: + return renderNoteOver(note, layout, chars) + case NoteLeftOf: + return renderNoteLeftOf(note, layout, chars) + case NoteRightOf: + return renderNoteRightOf(note, layout, chars) + } + return nil +} +``` + +Add stubs for left/right: + +```go +func renderNoteLeftOf(note *Note, layout *diagramLayout, chars BoxChars) []string { + // TODO: implement + return nil +} + +func renderNoteRightOf(note *Note, layout *diagramLayout, chars BoxChars) []string { + // TODO: implement + return nil +} +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/sequence/... -run TestRenderNoteOver -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/sequence/renderer.go internal/sequence/sequence_test.go +git commit -m "feat(sequence): implement renderNoteOver" +``` + +--- + +## Task 7: Implement renderNoteLeftOf + +**Files:** +- Modify: `internal/sequence/renderer.go` +- Modify: `internal/sequence/sequence_test.go` + +**Step 1: Write test for Note left of rendering** + +```go +func TestRenderNoteLeftOf(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + Note left of A: Left note` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("parse error: %v", err) + } + + output, err := Render(sd, nil) + if err != nil { + t.Fatalf("render error: %v", err) + } + + if !strings.Contains(output, "Left note") { + t.Errorf("output should contain note text:\n%s", output) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/sequence/... -run TestRenderNoteLeftOf -v` +Expected: FAIL + +**Step 3: Implement renderNoteLeftOf** + +```go +func renderNoteLeftOf(note *Note, layout *diagramLayout, chars BoxChars) []string { + var lines []string + actor := note.Actors[0] + center := layout.participantCenters[actor.Index] + + textWidth := runewidth.StringWidth(note.Text) + boxWidth := textWidth + 4 // padding + boxRight := center - 2 + boxLeft := boxRight - boxWidth + if boxLeft < 0 { + boxLeft = 0 + boxWidth = boxRight - boxLeft + } + + // Top border + topLine := make([]rune, layout.totalWidth+1) + for i := range topLine { + topLine[i] = ' ' + } + for _, c := range layout.participantCenters { + if c < len(topLine) { + topLine[c] = chars.Vertical + } + } + topLine[boxLeft] = chars.TopLeft + for i := boxLeft + 1; i < boxRight; i++ { + topLine[i] = chars.Horizontal + } + topLine[boxRight] = chars.TopRight + lines = append(lines, strings.TrimRight(string(topLine), " ")) + + // Text line with connector + textLine := make([]rune, layout.totalWidth+1) + for i := range textLine { + textLine[i] = ' ' + } + for _, c := range layout.participantCenters { + if c < len(textLine) { + textLine[c] = chars.Vertical + } + } + textLine[boxLeft] = chars.Vertical + textLine[boxRight] = chars.TeeLeft + for i := boxRight + 1; i < center; i++ { + textLine[i] = chars.Horizontal + } + textLine[center] = chars.TeeLeft + // Add text + textStart := boxLeft + 2 + col := textStart + for _, r := range note.Text { + if col < boxRight { + textLine[col] = r + col++ + } + } + lines = append(lines, strings.TrimRight(string(textLine), " ")) + + // Bottom border + bottomLine := make([]rune, layout.totalWidth+1) + for i := range bottomLine { + bottomLine[i] = ' ' + } + for _, c := range layout.participantCenters { + if c < len(bottomLine) { + bottomLine[c] = chars.Vertical + } + } + bottomLine[boxLeft] = chars.BottomLeft + for i := boxLeft + 1; i < boxRight; i++ { + bottomLine[i] = chars.Horizontal + } + bottomLine[boxRight] = chars.BottomRight + lines = append(lines, strings.TrimRight(string(bottomLine), " ")) + + return lines +} +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/sequence/... -run TestRenderNoteLeftOf -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/sequence/renderer.go internal/sequence/sequence_test.go +git commit -m "feat(sequence): implement renderNoteLeftOf" +``` + +--- + +## Task 8: Implement renderNoteRightOf + +**Files:** +- Modify: `internal/sequence/renderer.go` +- Modify: `internal/sequence/sequence_test.go` + +**Step 1: Write test for Note right of rendering** + +```go +func TestRenderNoteRightOf(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + Note right of B: Right note` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("parse error: %v", err) + } + + output, err := Render(sd, nil) + if err != nil { + t.Fatalf("render error: %v", err) + } + + if !strings.Contains(output, "Right note") { + t.Errorf("output should contain note text:\n%s", output) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/sequence/... -run TestRenderNoteRightOf -v` +Expected: FAIL + +**Step 3: Implement renderNoteRightOf** + +```go +func renderNoteRightOf(note *Note, layout *diagramLayout, chars BoxChars) []string { + var lines []string + actor := note.Actors[0] + center := layout.participantCenters[actor.Index] + + textWidth := runewidth.StringWidth(note.Text) + boxWidth := textWidth + 4 + boxLeft := center + 2 + boxRight := boxLeft + boxWidth + + ensureWidth := layout.totalWidth + if boxRight > ensureWidth { + ensureWidth = boxRight + 1 + } + + // Top border + topLine := make([]rune, ensureWidth) + for i := range topLine { + topLine[i] = ' ' + } + for _, c := range layout.participantCenters { + if c < len(topLine) { + topLine[c] = chars.Vertical + } + } + topLine[boxLeft] = chars.TopLeft + for i := boxLeft + 1; i < boxRight; i++ { + topLine[i] = chars.Horizontal + } + topLine[boxRight] = chars.TopRight + lines = append(lines, strings.TrimRight(string(topLine), " ")) + + // Text line with connector + textLine := make([]rune, ensureWidth) + for i := range textLine { + textLine[i] = ' ' + } + for _, c := range layout.participantCenters { + if c < len(textLine) { + textLine[c] = chars.Vertical + } + } + textLine[center] = chars.TeeRight + for i := center + 1; i < boxLeft; i++ { + textLine[i] = chars.Horizontal + } + textLine[boxLeft] = chars.TeeRight + textLine[boxRight] = chars.Vertical + // Add text + textStart := boxLeft + 2 + col := textStart + for _, r := range note.Text { + if col < boxRight { + textLine[col] = r + col++ + } + } + lines = append(lines, strings.TrimRight(string(textLine), " ")) + + // Bottom border + bottomLine := make([]rune, ensureWidth) + for i := range bottomLine { + bottomLine[i] = ' ' + } + for _, c := range layout.participantCenters { + if c < len(bottomLine) { + bottomLine[c] = chars.Vertical + } + } + bottomLine[boxLeft] = chars.BottomLeft + for i := boxLeft + 1; i < boxRight; i++ { + bottomLine[i] = chars.Horizontal + } + bottomLine[boxRight] = chars.BottomRight + lines = append(lines, strings.TrimRight(string(bottomLine), " ")) + + return lines +} +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/sequence/... -run TestRenderNoteRightOf -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/sequence/renderer.go internal/sequence/sequence_test.go +git commit -m "feat(sequence): implement renderNoteRightOf" +``` + +--- + +## Task 9: Integration Test with Real-World Example + +**Files:** +- Modify: `internal/sequence/sequence_test.go` + +**Step 1: Write integration test using scratch file syntax** + +```go +func TestRenderNoteIntegration(t *testing.T) { + input := `sequenceDiagram + participant Client + participant Server + Client->>Server: Request + Note over Client: Processing locally + Server-->>Client: Response + Note over Client,Server: Transaction complete` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("parse error: %v", err) + } + + if len(sd.Elements) != 4 { + t.Fatalf("expected 4 elements (2 messages + 2 notes), got %d", len(sd.Elements)) + } + + output, err := Render(sd, nil) + if err != nil { + t.Fatalf("render error: %v", err) + } + + // Verify all elements rendered + if !strings.Contains(output, "Request") { + t.Error("missing Request message") + } + if !strings.Contains(output, "Processing locally") { + t.Error("missing first note") + } + if !strings.Contains(output, "Response") { + t.Error("missing Response message") + } + if !strings.Contains(output, "Transaction complete") { + t.Error("missing second note") + } + + t.Logf("Rendered output:\n%s", output) +} +``` + +**Step 2: Run integration test** + +Run: `go test ./internal/sequence/... -run TestRenderNoteIntegration -v` +Expected: PASS + +**Step 3: Run all tests to verify no regressions** + +Run: `go test ./internal/sequence/... -v` +Expected: All PASS + +**Step 4: Commit** + +```bash +git add internal/sequence/sequence_test.go +git commit -m "test(sequence): add note integration test" +``` + +--- + +## Task 10: Test with scratch/note-syntax.mermaid + +**Step 1: Run CLI against the scratch file** + +Run: `go run main.go < scratch/note-syntax.mermaid` + +Verify the note on line 10 (`Note over Client: Extract control server from connectToken`) renders correctly. + +**Step 2: If errors, debug and fix** + +Check for: +- Parser errors (note not recognized) +- Renderer errors (layout issues) +- Visual issues (note box misaligned) + +**Step 3: Final test run** + +Run: `go test ./... -v` +Expected: All PASS + +**Step 4: Final commit** + +```bash +git add -A +git commit -m "feat(sequence): complete note syntax support" +``` + +--- + +## Summary + +After completing all tasks, the sequence diagram parser will support: +- `Note over Actor: text` +- `Note over Actor1,Actor2: text` +- `Note left of Actor: text` +- `Note right of Actor: text` + +All existing functionality remains backward compatible. diff --git a/internal/diagram/config.go b/internal/diagram/config.go index 5c9b2f9..ce81d1f 100644 --- a/internal/diagram/config.go +++ b/internal/diagram/config.go @@ -1,3 +1,7 @@ +// Copyright (c) 2023 Alexander Grooff +// Copyright (c) 2026 Gregory R. Warnes +// MaxWidth configuration parameter added by Gregory R. Warnes + package diagram import "fmt" @@ -32,6 +36,46 @@ type Config struct { // This controls whether graphs use colored output (html) or plain text (cli) StyleType string + // LabelWrapWidth wraps graph node labels to this width. Zero disables wrapping. + LabelWrapWidth int + + // EdgeLabelPolicy controls how graph edge labels are handled. + // Use "full" to keep labels, "ellipsis" to truncate, or "drop" to remove them. + EdgeLabelPolicy string + + // EdgeLabelMaxWidth is the maximum width for edge labels. Zero disables trimming. + EdgeLabelMaxWidth int + + // MaxWidth constrains output width in characters. Zero disables fitting. + MaxWidth int + + // FitPolicy controls how the renderer fits diagrams to MaxWidth. + // Use FitPolicyNone to disable fitting and FitPolicyAuto for heuristics. + FitPolicy string + + // CenterMultiLineLabels controls whether multi-line node labels are centered as a block. + // When true, each line is centered individually. + // When false, all lines are padded to the same width before centering, so that they will + // be left justified relative to each other, but centered within the block. + // For example: + // CenterMultiLineLabels: true: + // +----------------+ + // | | + // | ┌─ TIMER TEXT | + // | ├─> Step 1 | + // | └─> Step 2 | + // | | + // +----------------+ + // CenterMultiLineLabels: false: + // +----------------+ + // | | + // | ┌─ TIMER TEXT | + // | ├─> Step 1 | + // | └─> Step 2 | + // | | + // +----------------+ + CenterMultiLineLabels bool + // --- Sequence diagram-specific configuration --- // SequenceParticipantSpacing is the horizontal space between participants @@ -52,11 +96,17 @@ func DefaultConfig() *Config { ShowCoords: false, Verbose: false, // Graph defaults - BoxBorderPadding: 1, - PaddingBetweenX: 5, - PaddingBetweenY: 5, - GraphDirection: "LR", - StyleType: "cli", + BoxBorderPadding: 1, + PaddingBetweenX: 5, + PaddingBetweenY: 5, + GraphDirection: "", + StyleType: "cli", + LabelWrapWidth: 0, + EdgeLabelPolicy: EdgeLabelPolicyFull, + EdgeLabelMaxWidth: 0, + MaxWidth: 0, + FitPolicy: FitPolicyNone, + CenterMultiLineLabels: false, // Sequence diagram defaults SequenceParticipantSpacing: 5, SequenceMessageSpacing: 1, @@ -77,6 +127,11 @@ func NewConfig(useAscii bool, graphDirection, styleType string) (*Config, error) PaddingBetweenY: 5, GraphDirection: graphDirection, StyleType: styleType, + LabelWrapWidth: 0, + EdgeLabelPolicy: EdgeLabelPolicyFull, + EdgeLabelMaxWidth: 0, + MaxWidth: 0, + FitPolicy: FitPolicyNone, SequenceParticipantSpacing: 5, SequenceMessageSpacing: 1, SequenceSelfMessageWidth: 4, @@ -89,7 +144,7 @@ func NewConfig(useAscii bool, graphDirection, styleType string) (*Config, error) return config, nil } -func NewCLIConfig(useAscii, showCoords, verbose bool, boxBorderPadding, paddingX, paddingY int, graphDirection string) (*Config, error) { +func NewCLIConfig(useAscii, showCoords, verbose bool, boxBorderPadding, paddingX, paddingY, maxWidth int, graphDirection string, centerMultiLineLabels bool) (*Config, error) { defaults := DefaultConfig() config := &Config{ UseAscii: useAscii, @@ -100,6 +155,12 @@ func NewCLIConfig(useAscii, showCoords, verbose bool, boxBorderPadding, paddingX PaddingBetweenY: paddingY, GraphDirection: graphDirection, StyleType: "cli", + LabelWrapWidth: defaults.LabelWrapWidth, + EdgeLabelPolicy: defaults.EdgeLabelPolicy, + EdgeLabelMaxWidth: defaults.EdgeLabelMaxWidth, + MaxWidth: maxWidth, + FitPolicy: defaults.FitPolicy, + CenterMultiLineLabels: centerMultiLineLabels, SequenceParticipantSpacing: defaults.SequenceParticipantSpacing, SequenceMessageSpacing: defaults.SequenceMessageSpacing, SequenceSelfMessageWidth: defaults.SequenceSelfMessageWidth, @@ -121,8 +182,13 @@ func NewWebConfig(useAscii bool, boxBorderPadding, paddingX, paddingY int) (*Con BoxBorderPadding: boxBorderPadding, PaddingBetweenX: paddingX, PaddingBetweenY: paddingY, - GraphDirection: "LR", + GraphDirection: "", StyleType: "html", + LabelWrapWidth: defaults.LabelWrapWidth, + EdgeLabelPolicy: defaults.EdgeLabelPolicy, + EdgeLabelMaxWidth: defaults.EdgeLabelMaxWidth, + MaxWidth: defaults.MaxWidth, + FitPolicy: defaults.FitPolicy, SequenceParticipantSpacing: defaults.SequenceParticipantSpacing, SequenceMessageSpacing: defaults.SequenceMessageSpacing, SequenceSelfMessageWidth: defaults.SequenceSelfMessageWidth, @@ -147,6 +213,13 @@ func NewTestConfig(useAscii bool, styleType string) *Config { // Validate checks if the configuration values are valid. // Returns an error if any values are invalid or would cause rendering issues. func (c *Config) Validate() error { + if c.MaxWidth < 0 { + return &ConfigError{Field: "MaxWidth", Value: c.MaxWidth, Message: "must be non-negative"} + } + if c.FitPolicy != "" && c.FitPolicy != FitPolicyNone && c.FitPolicy != FitPolicyAuto { + return &ConfigError{Field: "FitPolicy", Value: c.FitPolicy, Message: "must be \"none\" or \"auto\""} + } + // Validate graph configuration if c.BoxBorderPadding < 0 { return &ConfigError{Field: "BoxBorderPadding", Value: c.BoxBorderPadding, Message: "must be non-negative"} @@ -157,7 +230,19 @@ func (c *Config) Validate() error { if c.PaddingBetweenY < 0 { return &ConfigError{Field: "PaddingBetweenY", Value: c.PaddingBetweenY, Message: "must be non-negative"} } - if c.GraphDirection != "LR" && c.GraphDirection != "TD" { + if c.LabelWrapWidth < 0 { + return &ConfigError{Field: "LabelWrapWidth", Value: c.LabelWrapWidth, Message: "must be non-negative"} + } + if c.EdgeLabelMaxWidth < 0 { + return &ConfigError{Field: "EdgeLabelMaxWidth", Value: c.EdgeLabelMaxWidth, Message: "must be non-negative"} + } + if c.EdgeLabelPolicy != "" && + c.EdgeLabelPolicy != EdgeLabelPolicyFull && + c.EdgeLabelPolicy != EdgeLabelPolicyEllipsis && + c.EdgeLabelPolicy != EdgeLabelPolicyDrop { + return &ConfigError{Field: "EdgeLabelPolicy", Value: c.EdgeLabelPolicy, Message: "must be \"full\", \"ellipsis\", or \"drop\""} + } + if c.GraphDirection != "" && c.GraphDirection != "LR" && c.GraphDirection != "TD" { return &ConfigError{Field: "GraphDirection", Value: c.GraphDirection, Message: "must be \"LR\" or \"TD\""} } if c.StyleType != "cli" && c.StyleType != "html" { diff --git a/internal/diagram/config_maxwidth_test.go b/internal/diagram/config_maxwidth_test.go new file mode 100644 index 0000000..639f948 --- /dev/null +++ b/internal/diagram/config_maxwidth_test.go @@ -0,0 +1,80 @@ +package diagram + +import ( +"testing" +) + +// TestNewCLIConfigWithMaxWidth verifies that maxWidth parameter is properly passed through +func TestNewCLIConfigWithMaxWidth(t *testing.T) { +tests := []struct { +name string +maxWidth int +expectedMaxWidth int +}{ +{ +name: "unlimited_width", +maxWidth: 0, +expectedMaxWidth: 0, +}, +{ +name: "width_100", +maxWidth: 100, +expectedMaxWidth: 100, +}, +{ +name: "width_80", +maxWidth: 80, +expectedMaxWidth: 80, +}, +{ +name: "width_200", +maxWidth: 200, +expectedMaxWidth: 200, +}, +} + +for _, tt := range tests { +t.Run(tt.name, func(t *testing.T) { +// Create config with specific maxWidth +cfg, err := NewCLIConfig( +true, // useAscii +false, // showCoords +false, // verbose +1, // boxBorderPadding +5, // paddingX +5, // paddingY +tt.maxWidth, +"LR", // graphDirection +false, // centerMultiLineLabels +) + +if err != nil { +t.Fatalf("NewCLIConfig failed: %v", err) +} + +if cfg.MaxWidth != tt.expectedMaxWidth { +t.Errorf("expected MaxWidth=%d, got MaxWidth=%d", tt.expectedMaxWidth, cfg.MaxWidth) +} +}) +} +} + +// TestNewWebConfigUsesDefaultMaxWidth verifies that NewWebConfig uses defaults +func TestNewWebConfigUsesDefaultMaxWidth(t *testing.T) { +cfg, err := NewWebConfig( +true, // useAscii +1, // boxBorderPadding +5, // paddingX +5, // paddingY +) + +if err != nil { +t.Fatalf("NewWebConfig failed: %v", err) +} + +defaults := DefaultConfig() +if cfg.MaxWidth != defaults.MaxWidth { +t.Errorf("expected NewWebConfig to use default MaxWidth=%d, got MaxWidth=%d", +defaults.MaxWidth, cfg.MaxWidth) +} +} diff --git a/internal/diagram/config_test.go b/internal/diagram/config_test.go new file mode 100644 index 0000000..503a951 --- /dev/null +++ b/internal/diagram/config_test.go @@ -0,0 +1,19 @@ +package diagram + +import "testing" + +func TestConfigMaxWidthValidation(t *testing.T) { + cfg := DefaultConfig() + cfg.MaxWidth = -1 + if err := cfg.Validate(); err == nil { + t.Fatalf("expected MaxWidth validation error") + } +} + +func TestConfigFitPolicyValidation(t *testing.T) { + cfg := DefaultConfig() + cfg.FitPolicy = "bogus" + if err := cfg.Validate(); err == nil { + t.Fatalf("expected FitPolicy validation error") + } +} diff --git a/internal/diagram/edge_label.go b/internal/diagram/edge_label.go new file mode 100644 index 0000000..09f9f8a --- /dev/null +++ b/internal/diagram/edge_label.go @@ -0,0 +1,7 @@ +package diagram + +const ( + EdgeLabelPolicyFull = "full" + EdgeLabelPolicyEllipsis = "ellipsis" + EdgeLabelPolicyDrop = "drop" +) diff --git a/internal/diagram/fit.go b/internal/diagram/fit.go new file mode 100644 index 0000000..d9ad4d7 --- /dev/null +++ b/internal/diagram/fit.go @@ -0,0 +1,6 @@ +package diagram + +const ( + FitPolicyNone = "none" + FitPolicyAuto = "auto" +) diff --git a/internal/sequence/charset.go b/internal/sequence/charset.go index d073832..ec27e78 100644 --- a/internal/sequence/charset.go +++ b/internal/sequence/charset.go @@ -8,6 +8,7 @@ type BoxChars struct { BottomRight rune Horizontal rune Vertical rune + TeeUp rune TeeDown rune TeeRight rune TeeLeft rune @@ -18,6 +19,33 @@ type BoxChars struct { DottedLine rune SelfTopRight rune SelfBottom rune + + RoundedTopLeft rune + RoundedTopRight rune + RoundedBottomLeft rune + RoundedBottomRight rune + + DottedHorizontal rune + DottedVertical rune + + DoubleHorizontal rune + DoubleVertical rune + DoubleTopLeft rune + DoubleTopRight rune + DoubleBottomLeft rune + DoubleBottomRight rune + DoubleTeeRight rune + DoubleTeeLeft rune + DoubleTeeUp rune + DoubleTeeDown rune + DoubleCross rune + + MixedTopLeft rune + MixedTopRight rune + MixedBottomLeft rune + MixedBottomRight rune + MixedTeeRight rune + MixedTeeLeft rune } var ASCII = BoxChars{ @@ -27,6 +55,7 @@ var ASCII = BoxChars{ BottomRight: '+', Horizontal: '-', Vertical: '|', + TeeUp: '+', TeeDown: '+', TeeRight: '+', TeeLeft: '+', @@ -37,6 +66,33 @@ var ASCII = BoxChars{ DottedLine: '.', SelfTopRight: '+', SelfBottom: '+', + + RoundedTopLeft: '+', + RoundedTopRight: '+', + RoundedBottomLeft: '+', + RoundedBottomRight: '+', + + DottedHorizontal: '.', + DottedVertical: ':', + + DoubleHorizontal: '=', + DoubleVertical: '#', + DoubleTopLeft: '#', + DoubleTopRight: '#', + DoubleBottomLeft: '#', + DoubleBottomRight: '#', + DoubleTeeRight: '#', + DoubleTeeLeft: '#', + DoubleTeeUp: '#', + DoubleTeeDown: '#', + DoubleCross: '#', + + MixedTopLeft: '+', + MixedTopRight: '+', + MixedBottomLeft: '+', + MixedBottomRight: '+', + MixedTeeRight: '+', + MixedTeeLeft: '+', } var Unicode = BoxChars{ @@ -46,6 +102,7 @@ var Unicode = BoxChars{ BottomRight: '┘', Horizontal: '─', Vertical: '│', + TeeUp: '┴', TeeDown: '┬', TeeRight: '├', TeeLeft: '┤', @@ -56,4 +113,120 @@ var Unicode = BoxChars{ DottedLine: '┈', SelfTopRight: '┐', SelfBottom: '┘', + + RoundedTopLeft: '╭', + RoundedTopRight: '╮', + RoundedBottomLeft: '╰', + RoundedBottomRight: '╯', + + DottedHorizontal: '┄', + DottedVertical: '┆', + + DoubleHorizontal: '═', + DoubleVertical: '║', + DoubleTopLeft: '╔', + DoubleTopRight: '╗', + DoubleBottomLeft: '╚', + DoubleBottomRight: '╝', + DoubleTeeRight: '╠', + DoubleTeeLeft: '╣', + DoubleTeeUp: '╩', + DoubleTeeDown: '╦', + DoubleCross: '╬', + + MixedTopLeft: '╓', + MixedTopRight: '╖', + MixedBottomLeft: '╙', + MixedBottomRight: '╜', + MixedTeeRight: '╟', + MixedTeeLeft: '╢', +} + +type BlockBoxChars struct { + TopLeft rune + TopRight rune + BottomLeft rune + BottomRight rune + Horizontal rune + Vertical rune + TeeRight rune + TeeLeft rune + TeeUp rune + TeeDown rune + Cross rune +} + +func GetBlockChars(blockType BlockType, base BoxChars) BlockBoxChars { + switch blockType { + case BlockLoop: + return BlockBoxChars{ + TopLeft: base.RoundedTopLeft, + TopRight: base.RoundedTopRight, + BottomLeft: base.RoundedBottomLeft, + BottomRight: base.RoundedBottomRight, + Horizontal: base.Horizontal, + Vertical: base.Vertical, + TeeRight: base.TeeRight, + TeeLeft: base.TeeLeft, + TeeUp: base.TeeUp, + TeeDown: base.TeeDown, + Cross: base.Cross, + } + case BlockAlt, BlockOpt: + return BlockBoxChars{ + TopLeft: base.TopLeft, + TopRight: base.TopRight, + BottomLeft: base.BottomLeft, + BottomRight: base.BottomRight, + Horizontal: base.DottedHorizontal, + Vertical: base.DottedVertical, + TeeRight: base.TeeRight, + TeeLeft: base.TeeLeft, + TeeUp: base.TeeUp, + TeeDown: base.TeeDown, + Cross: base.Cross, + } + case BlockPar: + return BlockBoxChars{ + TopLeft: base.MixedTopLeft, + TopRight: base.MixedTopRight, + BottomLeft: base.MixedBottomLeft, + BottomRight: base.MixedBottomRight, + Horizontal: base.Horizontal, + Vertical: base.DoubleVertical, + TeeRight: base.MixedTeeRight, + TeeLeft: base.MixedTeeLeft, + TeeUp: base.TeeUp, + TeeDown: base.TeeDown, + Cross: base.Cross, + } + case BlockCritical, BlockBreak: + return BlockBoxChars{ + TopLeft: base.DoubleTopLeft, + TopRight: base.DoubleTopRight, + BottomLeft: base.DoubleBottomLeft, + BottomRight: base.DoubleBottomRight, + Horizontal: base.DoubleHorizontal, + Vertical: base.DoubleVertical, + TeeRight: base.DoubleTeeRight, + TeeLeft: base.DoubleTeeLeft, + TeeUp: base.DoubleTeeUp, + TeeDown: base.DoubleTeeDown, + Cross: base.DoubleCross, + } + default: + return BlockBoxChars{ + TopLeft: base.TopLeft, + TopRight: base.TopRight, + BottomLeft: base.BottomLeft, + BottomRight: base.BottomRight, + Horizontal: base.Horizontal, + Vertical: base.Vertical, + TeeRight: base.TeeRight, + TeeLeft: base.TeeLeft, + TeeUp: base.TeeUp, + TeeDown: base.TeeDown, + Cross: base.Cross, + } + } } diff --git a/internal/sequence/fit.go b/internal/sequence/fit.go new file mode 100644 index 0000000..fad45f3 --- /dev/null +++ b/internal/sequence/fit.go @@ -0,0 +1,318 @@ +package sequence + +import ( + "fmt" + "strings" + + "github.com/AlexanderGrooff/mermaid-ascii/internal/diagram" + "github.com/mattn/go-runewidth" +) + +type sequenceFitPlan struct { + participantSpacing int + selfMessageWidth int + labelPolicy string + participantLabelMax int + messageLabelMax int + noteLabelMax int + blockLabelMax int +} + +func fitSequenceToWidth(sd *SequenceDiagram, config *diagram.Config) (string, error) { + basePlan := sequenceFitPlan{ + participantSpacing: config.SequenceParticipantSpacing, + selfMessageWidth: config.SequenceSelfMessageWidth, + labelPolicy: diagram.EdgeLabelPolicyFull, + participantLabelMax: 0, + messageLabelMax: 0, + noteLabelMax: 0, + blockLabelMax: 0, + } + + plans := sequenceFitPlans(sd, basePlan, config.MaxWidth) + bestOutput := "" + bestWidth := 0 + for idx, plan := range plans { + adjustedConfig := *config + adjustedConfig.SequenceParticipantSpacing = plan.participantSpacing + adjustedConfig.SequenceSelfMessageWidth = plan.selfMessageWidth + + adjustedDiagram := applySequenceFitPlan(sd, plan) + output, err := renderSequenceBase(adjustedDiagram, &adjustedConfig) + if err != nil { + return "", err + } + width := maxOutputLineWidth(output) + if idx == 0 || width < bestWidth { + bestWidth = width + bestOutput = output + } + if width <= config.MaxWidth { + return output, nil + } + } + + return bestOutput, nil +} + +func sequenceFitPlans(sd *SequenceDiagram, base sequenceFitPlan, maxWidth int) []sequenceFitPlan { + plans := []sequenceFitPlan{} + seen := map[string]struct{}{} + addPlan := func(plan sequenceFitPlan) { + normalized := normalizeSequencePlan(plan, maxWidth, sd) + key := sequenceFitPlanKey(normalized) + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + plans = append(plans, normalized) + } + + addPlan(base) + + compact := base + compact.participantSpacing = min(defaultParticipantSpacing, 2) + compact.selfMessageWidth = min(defaultSelfMessageWidth, 3) + addPlan(compact) + + tight := base + tight.participantSpacing = 1 + tight.selfMessageWidth = 2 + addPlan(tight) + + ellipsis := base + ellipsis.labelPolicy = diagram.EdgeLabelPolicyEllipsis + addPlan(ellipsis) + + ellipsisCompact := compact + ellipsisCompact.labelPolicy = diagram.EdgeLabelPolicyEllipsis + addPlan(ellipsisCompact) + + ellipsisTight := tight + ellipsisTight.labelPolicy = diagram.EdgeLabelPolicyEllipsis + addPlan(ellipsisTight) + + drop := tight + drop.labelPolicy = diagram.EdgeLabelPolicyDrop + addPlan(drop) + + return plans +} + +func normalizeSequencePlan(plan sequenceFitPlan, maxWidth int, sd *SequenceDiagram) sequenceFitPlan { + if plan.participantSpacing <= 0 { + plan.participantSpacing = defaultParticipantSpacing + } + if plan.selfMessageWidth <= 0 { + plan.selfMessageWidth = defaultSelfMessageWidth + } + if plan.selfMessageWidth < 2 { + plan.selfMessageWidth = 2 + } + if plan.labelPolicy == "" { + plan.labelPolicy = diagram.EdgeLabelPolicyFull + } + if plan.labelPolicy != diagram.EdgeLabelPolicyFull { + plan.participantLabelMax = participantLabelMaxWidthFor(sd, plan, maxWidth) + plan.messageLabelMax = messageLabelMaxWidthFor(maxWidth) + plan.noteLabelMax = noteLabelMaxWidthFor(maxWidth) + plan.blockLabelMax = blockLabelMaxWidthFor(maxWidth) + } + return plan +} + +func participantLabelMaxWidthFor(sd *SequenceDiagram, plan sequenceFitPlan, maxWidth int) int { + if maxWidth <= 0 || sd == nil || len(sd.Participants) == 0 { + return 0 + } + count := len(sd.Participants) + totalSpacing := plan.participantSpacing * (count - 1) + overhead := (boxPaddingLeftRight + boxBorderWidth) * count + available := maxWidth - totalSpacing - overhead + if available < count { + return 1 + } + return available / count +} + +func messageLabelMaxWidthFor(maxWidth int) int { + if maxWidth <= 0 { + return 0 + } + available := maxWidth - labelBufferSpace - labelLeftMargin - 4 + if available < 1 { + return 1 + } + return available +} + +func noteLabelMaxWidthFor(maxWidth int) int { + if maxWidth <= 0 { + return 0 + } + available := maxWidth - 4 + if available < 1 { + return 1 + } + return available +} + +func blockLabelMaxWidthFor(maxWidth int) int { + return noteLabelMaxWidthFor(maxWidth) +} + +func sequenceFitPlanKey(plan sequenceFitPlan) string { + return fmt.Sprintf("%d:%d:%s:%d:%d:%d:%d", + plan.participantSpacing, + plan.selfMessageWidth, + plan.labelPolicy, + plan.participantLabelMax, + plan.messageLabelMax, + plan.noteLabelMax, + plan.blockLabelMax, + ) +} + +func applySequenceFitPlan(sd *SequenceDiagram, plan sequenceFitPlan) *SequenceDiagram { + if sd == nil { + return nil + } + participants := make([]*Participant, len(sd.Participants)) + participantMap := make(map[*Participant]*Participant, len(sd.Participants)) + for i, p := range sd.Participants { + label := applyLabelPolicy(p.Label, plan.labelPolicy, plan.participantLabelMax) + cp := &Participant{ + ID: p.ID, + Label: label, + Index: p.Index, + } + participants[i] = cp + participantMap[p] = cp + } + + var messages []*Message + var cloneElement func(elem DiagramElement) DiagramElement + cloneElement = func(elem DiagramElement) DiagramElement { + switch e := elem.(type) { + case *Message: + label := applyLabelPolicy(e.Label, plan.labelPolicy, plan.messageLabelMax) + msg := &Message{ + From: participantMap[e.From], + To: participantMap[e.To], + Label: label, + ArrowType: e.ArrowType, + Number: e.Number, + } + messages = append(messages, msg) + return msg + case *Note: + text := applyLabelPolicy(e.Text, plan.labelPolicy, plan.noteLabelMax) + actors := make([]*Participant, len(e.Actors)) + for i, actor := range e.Actors { + actors[i] = participantMap[actor] + } + return &Note{ + Position: e.Position, + Actors: actors, + Text: text, + } + case *Block: + return cloneBlock(e, plan, participantMap, cloneElement) + default: + return nil + } + } + + elements := make([]DiagramElement, len(sd.Elements)) + for i, elem := range sd.Elements { + elements[i] = cloneElement(elem) + } + + return &SequenceDiagram{ + Participants: participants, + Messages: messages, + Elements: elements, + Autonumber: sd.Autonumber, + } +} + +func cloneBlock(block *Block, plan sequenceFitPlan, participantMap map[*Participant]*Participant, cloneElement func(DiagramElement) DiagramElement) *Block { + if block == nil { + return nil + } + sections := make([]*BlockSection, len(block.Sections)) + for i, section := range block.Sections { + label := applyLabelPolicy(section.Label, plan.labelPolicy, plan.blockLabelMax) + sectionElements := make([]DiagramElement, len(section.Elements)) + for j, elem := range section.Elements { + sectionElements[j] = cloneElement(elem) + } + sections[i] = &BlockSection{ + Label: label, + Elements: sectionElements, + } + } + label := applyLabelPolicy(block.Label, plan.labelPolicy, plan.blockLabelMax) + return &Block{ + Type: block.Type, + Label: label, + Sections: sections, + } +} + +func applyLabelPolicy(label, policy string, maxWidth int) string { + if label == "" { + return "" + } + switch policy { + case diagram.EdgeLabelPolicyDrop: + return "" + case diagram.EdgeLabelPolicyEllipsis: + return ellipsisLabel(label, maxWidth) + default: + return label + } +} + +func ellipsisLabel(label string, maxWidth int) string { + if maxWidth <= 0 { + return label + } + if runewidth.StringWidth(label) <= maxWidth { + return label + } + if maxWidth <= 3 { + return strings.Repeat(".", maxWidth) + } + trimmed := truncateToWidth(label, maxWidth-3) + return trimmed + "..." +} + +func truncateToWidth(label string, width int) string { + if width <= 0 { + return "" + } + var sb strings.Builder + currentWidth := 0 + for _, r := range label { + rw := runewidth.RuneWidth(r) + if currentWidth+rw > width { + break + } + sb.WriteRune(r) + currentWidth += rw + } + return sb.String() +} + +func maxOutputLineWidth(output string) int { + lines := strings.Split(output, "\n") + maxWidth := 0 + for _, line := range lines { + width := runewidth.StringWidth(line) + if width > maxWidth { + maxWidth = width + } + } + return maxWidth +} diff --git a/internal/sequence/fit_test.go b/internal/sequence/fit_test.go new file mode 100644 index 0000000..8a1a619 --- /dev/null +++ b/internal/sequence/fit_test.go @@ -0,0 +1,27 @@ +package sequence + +import ( + "testing" + + "github.com/AlexanderGrooff/mermaid-ascii/internal/diagram" +) + +func TestSequenceFitRespectsMaxWidth(t *testing.T) { + input := "sequenceDiagram\nAlice->>Bob: This is a very long message label\n" + cfg := diagram.DefaultConfig() + cfg.UseAscii = true + cfg.MaxWidth = 40 + cfg.FitPolicy = diagram.FitPolicyAuto + + parsed, err := Parse(input) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + out, err := Render(parsed, cfg) + if err != nil { + t.Fatalf("render failed: %v", err) + } + if maxOutputLineWidth(out) > cfg.MaxWidth { + t.Fatalf("expected width <= %d, got %d", cfg.MaxWidth, maxOutputLineWidth(out)) + } +} diff --git a/internal/sequence/parser.go b/internal/sequence/parser.go index 185ab37..f64b6cb 100644 --- a/internal/sequence/parser.go +++ b/internal/sequence/parser.go @@ -23,12 +23,29 @@ var ( // autonumberRegex matches the autonumber directive autonumberRegex = regexp.MustCompile(`^\s*autonumber\s*$`) + + // noteRegex matches note declarations: + // Note over Actor: text + // Note over Actor1,Actor2: text + // Note left of Actor: text + // Note right of Actor: text + noteRegex = regexp.MustCompile(`(?i)^\s*note\s+(over|left\s+of|right\s+of)\s+([^:]+):\s*(.*)$`) + + // blockStartRegex matches block start: loop, alt, opt, par, critical, break, rect + blockStartRegex = regexp.MustCompile(`(?i)^\s*(loop|alt|opt|par|critical|break|rect)\s*(.*)$`) + + // blockDividerRegex matches block dividers: else, and, option + blockDividerRegex = regexp.MustCompile(`(?i)^\s*(else|and|option)\s*(.*)$`) + + // blockEndRegex matches block end + blockEndRegex = regexp.MustCompile(`(?i)^\s*end\s*$`) ) // SequenceDiagram represents a parsed sequence diagram. type SequenceDiagram struct { Participants []*Participant Messages []*Message + Elements []DiagramElement // Ordered messages and notes Autonumber bool } @@ -64,6 +81,121 @@ func (a ArrowType) String() string { } } +type NotePosition int + +const ( + NoteOver NotePosition = iota + NoteLeftOf + NoteRightOf +) + +func (n NotePosition) String() string { + switch n { + case NoteOver: + return "over" + case NoteLeftOf: + return "left of" + case NoteRightOf: + return "right of" + default: + return fmt.Sprintf("NotePosition(%d)", n) + } +} + +type Note struct { + Position NotePosition + Actors []*Participant + Text string +} + +type BlockType int + +const ( + BlockLoop BlockType = iota + BlockAlt + BlockOpt + BlockPar + BlockCritical + BlockBreak + BlockRect +) + +func (b BlockType) String() string { + switch b { + case BlockLoop: + return "loop" + case BlockAlt: + return "alt" + case BlockOpt: + return "opt" + case BlockPar: + return "par" + case BlockCritical: + return "critical" + case BlockBreak: + return "break" + case BlockRect: + return "rect" + default: + return fmt.Sprintf("BlockType(%d)", b) + } +} + +type BlockSection struct { + Label string + Elements []DiagramElement +} + +type Block struct { + Type BlockType + Label string + Sections []*BlockSection +} + +func (*Block) isElement() {} + +type DiagramElement interface { + isElement() +} + +func parseBlockType(keyword string) (BlockType, error) { + switch strings.ToLower(keyword) { + case "loop": + return BlockLoop, nil + case "alt": + return BlockAlt, nil + case "opt": + return BlockOpt, nil + case "par": + return BlockPar, nil + case "critical": + return BlockCritical, nil + case "break": + return BlockBreak, nil + case "rect": + return BlockRect, nil + default: + return 0, fmt.Errorf("unknown block type: %q", keyword) + } +} + +func isValidDivider(blockType BlockType, divider string) bool { + divider = strings.ToLower(divider) + switch blockType { + case BlockAlt: + return divider == "else" + case BlockPar: + return divider == "and" + case BlockCritical: + return divider == "option" + default: + return false + } +} + +func (*Message) isElement() {} +func (*Note) isElement() {} + func IsSequenceDiagram(input string) bool { lines := strings.Split(input, "\n") for _, line := range lines { @@ -91,40 +223,68 @@ func Parse(input string) (*SequenceDiagram, error) { if !strings.HasPrefix(strings.TrimSpace(lines[0]), SequenceDiagramKeyword) { return nil, fmt.Errorf("expected %q keyword", SequenceDiagramKeyword) } - lines = lines[1:] sd := &SequenceDiagram{ Participants: []*Participant{}, Messages: []*Message{}, + Elements: []DiagramElement{}, Autonumber: false, } participantMap := make(map[string]*Participant) - for i, line := range lines { + idx := 1 + for idx < len(lines) { + line := lines[idx] trimmed := strings.TrimSpace(line) + if trimmed == "" { + idx++ continue } - // Check for autonumber directive + // Check for autonumber if autonumberRegex.MatchString(trimmed) { sd.Autonumber = true + idx++ continue } + // Check for participant if matched, err := sd.parseParticipant(trimmed, participantMap); err != nil { - return nil, fmt.Errorf("line %d: %w", i+2, err) + return nil, fmt.Errorf("line %d: %w", idx+1, err) } else if matched { + idx++ + continue + } + + // Check for block start + if blockStartRegex.MatchString(trimmed) { + block, nextIdx, err := sd.parseBlock(lines, idx, idx, participantMap) + if err != nil { + return nil, err + } + sd.Elements = append(sd.Elements, block) + idx = nextIdx continue } + // Check for message if matched, err := sd.parseMessage(trimmed, participantMap); err != nil { - return nil, fmt.Errorf("line %d: %w", i+2, err) + return nil, fmt.Errorf("line %d: %w", idx+1, err) } else if matched { + idx++ continue } - return nil, fmt.Errorf("line %d: invalid syntax: %q", i+2, trimmed) + // Check for note + if matched, err := sd.parseNote(trimmed, participantMap); err != nil { + return nil, fmt.Errorf("line %d: %w", idx+1, err) + } else if matched { + idx++ + continue + } + + return nil, fmt.Errorf("line %d: invalid syntax: %q", idx+1, trimmed) } if len(sd.Participants) == 0 { @@ -164,10 +324,10 @@ func (sd *SequenceDiagram) parseParticipant(line string, participants map[string return true, nil } -func (sd *SequenceDiagram) parseMessage(line string, participants map[string]*Participant) (bool, error) { +func (sd *SequenceDiagram) parseMessageElement(line string, participants map[string]*Participant) (*Message, bool, error) { match := messageRegex.FindStringSubmatch(line) if match == nil { - return false, nil + return nil, false, nil } fromID := match[2] @@ -205,6 +365,15 @@ func (sd *SequenceDiagram) parseMessage(line string, participants map[string]*Pa Number: msgNumber, } sd.Messages = append(sd.Messages, msg) + return msg, true, nil +} + +func (sd *SequenceDiagram) parseMessage(line string, participants map[string]*Participant) (bool, error) { + msg, matched, err := sd.parseMessageElement(line, participants) + if err != nil || !matched { + return matched, err + } + sd.Elements = append(sd.Elements, msg) return true, nil } @@ -222,3 +391,159 @@ func (sd *SequenceDiagram) getParticipant(id string, participants map[string]*Pa participants[id] = p return p } + +func (sd *SequenceDiagram) parseNoteElement(line string, participants map[string]*Participant) (*Note, bool, error) { + match := noteRegex.FindStringSubmatch(line) + if match == nil { + return nil, false, nil + } + + posStr := strings.ToLower(match[1]) + actorsStr := strings.TrimSpace(match[2]) + text := strings.TrimSpace(match[3]) + + var position NotePosition + switch { + case posStr == "over": + position = NoteOver + case strings.Contains(posStr, "left"): + position = NoteLeftOf + case strings.Contains(posStr, "right"): + position = NoteRightOf + default: + return nil, false, fmt.Errorf("unknown note position: %q", posStr) + } + + // Parse actor(s) - comma separated for "over" with multiple actors + actorIDs := strings.Split(actorsStr, ",") + var actors []*Participant + for _, id := range actorIDs { + id = strings.TrimSpace(id) + if id == "" { + continue + } + // Remove surrounding quotes if present (e.g., "My Service" -> My Service) + if len(id) >= 2 && id[0] == '"' && id[len(id)-1] == '"' { + id = id[1 : len(id)-1] + } + actors = append(actors, sd.getParticipant(id, participants)) + } + + if len(actors) == 0 { + return nil, false, fmt.Errorf("note requires at least one actor") + } + + if position != NoteOver && len(actors) > 1 { + return nil, false, fmt.Errorf("note %s only supports one actor", position) + } + + note := &Note{ + Position: position, + Actors: actors, + Text: text, + } + return note, true, nil +} + +func (sd *SequenceDiagram) parseNote(line string, participants map[string]*Participant) (bool, error) { + note, matched, err := sd.parseNoteElement(line, participants) + if err != nil || !matched { + return matched, err + } + sd.Elements = append(sd.Elements, note) + return true, nil +} + +func (sd *SequenceDiagram) parseBlock(lines []string, startIdx int, startLine int, participants map[string]*Participant) (*Block, int, error) { + if startIdx >= len(lines) { + return nil, startIdx, fmt.Errorf("line %d: unexpected end of input", startLine+1) + } + + match := blockStartRegex.FindStringSubmatch(lines[startIdx]) + if match == nil { + return nil, startIdx, fmt.Errorf("line %d: expected block start", startLine+1) + } + + blockType, err := parseBlockType(match[1]) + if err != nil { + return nil, startIdx, fmt.Errorf("line %d: %w", startLine+1, err) + } + + block := &Block{ + Type: blockType, + Label: strings.TrimSpace(match[2]), + Sections: []*BlockSection{ + {Label: "", Elements: []DiagramElement{}}, + }, + } + + currentSection := block.Sections[0] + idx := startIdx + 1 + lineOffset := startLine + 1 + + for idx < len(lines) { + line := lines[idx] + trimmed := strings.TrimSpace(line) + + if trimmed == "" { + idx++ + lineOffset++ + continue + } + + if blockEndRegex.MatchString(trimmed) { + return block, idx + 1, nil + } + + if divMatch := blockDividerRegex.FindStringSubmatch(trimmed); divMatch != nil { + divider := divMatch[1] + if !isValidDivider(block.Type, divider) { + return nil, idx, fmt.Errorf("line %d: invalid divider %q for block type %s", lineOffset+1, divider, block.Type) + } + if len(currentSection.Elements) == 0 && len(block.Sections) == 1 { + return nil, idx, fmt.Errorf("line %d: divider %q cannot be first content in block", lineOffset+1, divider) + } + currentSection = &BlockSection{ + Label: strings.TrimSpace(divMatch[2]), + Elements: []DiagramElement{}, + } + block.Sections = append(block.Sections, currentSection) + idx++ + lineOffset++ + continue + } + + if blockStartRegex.MatchString(trimmed) { + nestedBlock, nextIdx, err := sd.parseBlock(lines, idx, lineOffset, participants) + if err != nil { + return nil, idx, fmt.Errorf("line %d: nested block: %w", lineOffset+1, err) + } + currentSection.Elements = append(currentSection.Elements, nestedBlock) + lineOffset += nextIdx - idx + idx = nextIdx + continue + } + + if note, matched, err := sd.parseNoteElement(trimmed, participants); err != nil { + return nil, idx, fmt.Errorf("line %d: %w", lineOffset+1, err) + } else if matched { + currentSection.Elements = append(currentSection.Elements, note) + idx++ + lineOffset++ + continue + } + + if msg, matched, err := sd.parseMessageElement(trimmed, participants); err != nil { + return nil, idx, fmt.Errorf("line %d: %w", lineOffset+1, err) + } else if matched { + currentSection.Elements = append(currentSection.Elements, msg) + idx++ + lineOffset++ + continue + } + + return nil, idx, fmt.Errorf("line %d: invalid syntax in block: %q", lineOffset+1, trimmed) + } + + return nil, startIdx, fmt.Errorf("block starting at line %d has no 'end'", startLine+1) +} diff --git a/internal/sequence/renderer.go b/internal/sequence/renderer.go index 31b0461..68324da 100644 --- a/internal/sequence/renderer.go +++ b/internal/sequence/renderer.go @@ -85,6 +85,14 @@ func Render(sd *SequenceDiagram, config *diagram.Config) (string, error) { config = diagram.DefaultConfig() } + if config.FitPolicy == diagram.FitPolicyAuto && config.MaxWidth > 0 { + return fitSequenceToWidth(sd, config) + } + + return renderSequenceBase(sd, config) +} + +func renderSequenceBase(sd *SequenceDiagram, config *diagram.Config) (string, error) { chars := Unicode if config.UseAscii { chars = ASCII @@ -112,15 +120,28 @@ func Render(sd *SequenceDiagram, config *diagram.Config) (string, error) { string(chars.BottomRight) })) - for _, msg := range sd.Messages { + for _, elem := range sd.Elements { for i := 0; i < layout.messageSpacing; i++ { lines = append(lines, buildLifeline(layout, chars)) } - if msg.From == msg.To { - lines = append(lines, renderSelfMessage(msg, layout, chars)...) - } else { - lines = append(lines, renderMessage(msg, layout, chars)...) + switch e := elem.(type) { + case *Message: + if e.From == e.To { + lines = append(lines, renderSelfMessage(e, layout, chars)...) + } else { + lines = append(lines, renderMessage(e, layout, chars)...) + } + case *Note: + noteLines := renderNote(e, layout, chars) + if noteLines != nil { + lines = append(lines, noteLines...) + } + case *Block: + blockLines := renderBlock(e, layout, chars, 0, layout.messageSpacing) + if blockLines != nil { + lines = append(lines, blockLines...) + } } } @@ -130,15 +151,19 @@ func Render(sd *SequenceDiagram, config *diagram.Config) (string, error) { func buildLine(participants []*Participant, layout *diagramLayout, draw func(int) string) string { var sb strings.Builder + currentWidth := 0 for i := range participants { boxWidth := layout.participantWidths[i] + boxBorderWidth left := layout.participantCenters[i] - boxWidth/2 - needed := left - runewidth.StringWidth(sb.String()) + needed := left - currentWidth if needed > 0 { sb.WriteString(strings.Repeat(" ", needed)) + currentWidth += needed } - sb.WriteString(draw(i)) + content := draw(i) + sb.WriteString(content) + currentWidth += runewidth.StringWidth(content) } return sb.String() } @@ -213,6 +238,582 @@ func renderMessage(msg *Message, layout *diagramLayout, chars BoxChars) []string return lines } +func renderNote(note *Note, layout *diagramLayout, chars BoxChars) []string { + switch note.Position { + case NoteOver: + return renderNoteOver(note, layout, chars) + case NoteLeftOf: + return renderNoteLeftOf(note, layout, chars) + case NoteRightOf: + return renderNoteRightOf(note, layout, chars) + } + return nil +} + +func renderNoteOver(note *Note, layout *diagramLayout, chars BoxChars) []string { + var lines []string + + leftActor := note.Actors[0] + rightActor := note.Actors[len(note.Actors)-1] + + leftCenter := layout.participantCenters[leftActor.Index] + rightCenter := layout.participantCenters[rightActor.Index] + + if leftCenter > rightCenter { + leftCenter, rightCenter = rightCenter, leftCenter + } + + padding := 2 + textWidth := runewidth.StringWidth(note.Text) + minBoxWidth := textWidth + 4 + spanWidth := rightCenter - leftCenter + padding*2 + boxWidth := spanWidth + if boxWidth < minBoxWidth { + boxWidth = minBoxWidth + } + + spanCenter := (leftCenter + rightCenter) / 2 + boxLeft := spanCenter - boxWidth/2 + if boxLeft < 0 { + boxLeft = 0 + } + boxRight := boxLeft + boxWidth + + topLine := make([]rune, layout.totalWidth+boxWidth) + for i := range topLine { + topLine[i] = ' ' + } + for _, c := range layout.participantCenters { + if c < len(topLine) { + if c >= boxLeft && c <= boxRight { + topLine[c] = chars.TeeUp + } else { + topLine[c] = chars.Vertical + } + } + } + topLine[boxLeft] = chars.TopLeft + for i := boxLeft + 1; i < boxRight; i++ { + if topLine[i] != chars.TeeUp { + topLine[i] = chars.Horizontal + } + } + topLine[boxRight] = chars.TopRight + lines = append(lines, strings.TrimRight(string(topLine), " ")) + + textLine := make([]rune, layout.totalWidth+boxWidth) + for i := range textLine { + textLine[i] = ' ' + } + for _, c := range layout.participantCenters { + if c < len(textLine) && (c < boxLeft || c > boxRight) { + textLine[c] = chars.Vertical + } + } + textLine[boxLeft] = chars.Vertical + textLine[boxRight] = chars.Vertical + textStart := boxLeft + (boxWidth-textWidth)/2 + col := textStart + for _, r := range note.Text { + if col < len(textLine) && col < boxRight { + textLine[col] = r + col++ + } + } + lines = append(lines, strings.TrimRight(string(textLine), " ")) + + bottomLine := make([]rune, layout.totalWidth+boxWidth) + for i := range bottomLine { + bottomLine[i] = ' ' + } + for _, c := range layout.participantCenters { + if c < len(bottomLine) { + if c >= boxLeft && c <= boxRight { + bottomLine[c] = chars.TeeDown + } else { + bottomLine[c] = chars.Vertical + } + } + } + bottomLine[boxLeft] = chars.BottomLeft + for i := boxLeft + 1; i < boxRight; i++ { + if bottomLine[i] != chars.TeeDown { + bottomLine[i] = chars.Horizontal + } + } + bottomLine[boxRight] = chars.BottomRight + lines = append(lines, strings.TrimRight(string(bottomLine), " ")) + + return lines +} + +func renderNoteLeftOf(note *Note, layout *diagramLayout, chars BoxChars) []string { + var lines []string + actor := note.Actors[0] + center := layout.participantCenters[actor.Index] + + textWidth := runewidth.StringWidth(note.Text) + boxWidth := textWidth + 4 + boxRight := center - 2 + boxLeft := boxRight - boxWidth + + if boxLeft < 0 { + boxLeft = 0 + boxRight = boxWidth + } + + ensureWidth := layout.totalWidth + 1 + if boxRight >= ensureWidth { + ensureWidth = boxRight + 1 + } + + topLine := make([]rune, ensureWidth) + for i := range topLine { + topLine[i] = ' ' + } + for _, c := range layout.participantCenters { + if c < len(topLine) && (c < boxLeft || c > boxRight) { + topLine[c] = chars.Vertical + } + } + topLine[boxLeft] = chars.TopLeft + for i := boxLeft + 1; i < boxRight; i++ { + topLine[i] = chars.Horizontal + } + topLine[boxRight] = chars.TopRight + lines = append(lines, strings.TrimRight(string(topLine), " ")) + + textLine := make([]rune, ensureWidth) + for i := range textLine { + textLine[i] = ' ' + } + for _, c := range layout.participantCenters { + if c < len(textLine) && (c < boxLeft || c > boxRight) { + textLine[c] = chars.Vertical + } + } + textLine[boxLeft] = chars.Vertical + if boxRight < center { + textLine[boxRight] = chars.TeeLeft + for i := boxRight + 1; i < center; i++ { + textLine[i] = chars.Horizontal + } + textLine[center] = chars.TeeLeft + } else { + textLine[boxRight] = chars.Vertical + } + textStart := boxLeft + 2 + col := textStart + for _, r := range note.Text { + if col < boxRight { + textLine[col] = r + col++ + } + } + lines = append(lines, strings.TrimRight(string(textLine), " ")) + + bottomLine := make([]rune, ensureWidth) + for i := range bottomLine { + bottomLine[i] = ' ' + } + for _, c := range layout.participantCenters { + if c < len(bottomLine) && (c < boxLeft || c > boxRight) { + bottomLine[c] = chars.Vertical + } + } + bottomLine[boxLeft] = chars.BottomLeft + for i := boxLeft + 1; i < boxRight; i++ { + bottomLine[i] = chars.Horizontal + } + bottomLine[boxRight] = chars.BottomRight + lines = append(lines, strings.TrimRight(string(bottomLine), " ")) + + return lines +} + +func renderNoteRightOf(note *Note, layout *diagramLayout, chars BoxChars) []string { + var lines []string + actor := note.Actors[0] + center := layout.participantCenters[actor.Index] + + textWidth := runewidth.StringWidth(note.Text) + boxWidth := textWidth + 4 + boxLeft := center + 2 + boxRight := boxLeft + boxWidth + + ensureWidth := layout.totalWidth + if boxRight >= ensureWidth { + ensureWidth = boxRight + 1 + } + + // Top border + topLine := make([]rune, ensureWidth) + for i := range topLine { + topLine[i] = ' ' + } + for _, c := range layout.participantCenters { + if c < len(topLine) { + topLine[c] = chars.Vertical + } + } + topLine[boxLeft] = chars.TopLeft + for i := boxLeft + 1; i < boxRight; i++ { + topLine[i] = chars.Horizontal + } + topLine[boxRight] = chars.TopRight + lines = append(lines, strings.TrimRight(string(topLine), " ")) + + // Text line with connector + textLine := make([]rune, ensureWidth) + for i := range textLine { + textLine[i] = ' ' + } + for _, c := range layout.participantCenters { + if c < len(textLine) { + textLine[c] = chars.Vertical + } + } + textLine[center] = chars.TeeRight + for i := center + 1; i < boxLeft; i++ { + textLine[i] = chars.Horizontal + } + textLine[boxLeft] = chars.TeeRight + textLine[boxRight] = chars.Vertical + // Add text + textStart := boxLeft + 2 + col := textStart + for _, r := range note.Text { + if col < boxRight { + textLine[col] = r + col++ + } + } + lines = append(lines, strings.TrimRight(string(textLine), " ")) + + // Bottom border + bottomLine := make([]rune, ensureWidth) + for i := range bottomLine { + bottomLine[i] = ' ' + } + for _, c := range layout.participantCenters { + if c < len(bottomLine) { + bottomLine[c] = chars.Vertical + } + } + bottomLine[boxLeft] = chars.BottomLeft + for i := boxLeft + 1; i < boxRight; i++ { + bottomLine[i] = chars.Horizontal + } + bottomLine[boxRight] = chars.BottomRight + lines = append(lines, strings.TrimRight(string(bottomLine), " ")) + + return lines +} + +// calculateBlockRightEdge returns the minimum rightmost column index needed to +// contain all content in the block. This accounts for message labels, note texts, +// section labels, and nested blocks. +func calculateBlockRightEdge(block *Block, layout *diagramLayout) int { + maxRightEdge := 0 + + var checkElements func(elements []DiagramElement) + checkElements = func(elements []DiagramElement) { + for _, elem := range elements { + switch e := elem.(type) { + case *Message: + label := e.Label + if e.Number > 0 { + label = fmt.Sprintf("%d. %s", e.Number, e.Label) + } + if label != "" { + from := layout.participantCenters[e.From.Index] + to := layout.participantCenters[e.To.Index] + start := min(from, to) + labelLeftMargin + labelWidth := runewidth.StringWidth(label) + rightEdge := start + labelWidth + 2 + if rightEdge > maxRightEdge { + maxRightEdge = rightEdge + } + } + case *Note: + textWidth := runewidth.StringWidth(e.Text) + var noteRight int + switch e.Position { + case NoteOver: + leftCenter := layout.participantCenters[e.Actors[0].Index] + rightCenter := layout.participantCenters[e.Actors[len(e.Actors)-1].Index] + if leftCenter > rightCenter { + leftCenter, rightCenter = rightCenter, leftCenter + } + spanCenter := (leftCenter + rightCenter) / 2 + minBoxWidth := textWidth + 4 + spanWidth := rightCenter - leftCenter + 4 + boxWidth := spanWidth + if boxWidth < minBoxWidth { + boxWidth = minBoxWidth + } + noteRight = spanCenter + boxWidth/2 + 1 + case NoteRightOf: + center := layout.participantCenters[e.Actors[0].Index] + noteRight = center + 2 + textWidth + 4 + case NoteLeftOf: + noteRight = layout.participantCenters[e.Actors[0].Index] + } + if noteRight > maxRightEdge { + maxRightEdge = noteRight + } + case *Block: + nestedRightEdge := calculateBlockRightEdge(e, layout) + if nestedRightEdge > maxRightEdge { + maxRightEdge = nestedRightEdge + } + } + } + } + + for _, section := range block.Sections { + sectionLabelWidth := runewidth.StringWidth(section.Label) + if sectionLabelWidth+4 > maxRightEdge { + maxRightEdge = sectionLabelWidth + 4 + } + checkElements(section.Elements) + } + + return maxRightEdge +} + +func findBlockParticipantRange(block *Block) (minIdx, maxIdx int) { + minIdx = -1 + maxIdx = -1 + + var findInElements func(elements []DiagramElement) + findInElements = func(elements []DiagramElement) { + for _, elem := range elements { + switch e := elem.(type) { + case *Message: + if minIdx == -1 || e.From.Index < minIdx { + minIdx = e.From.Index + } + if minIdx == -1 || e.To.Index < minIdx { + minIdx = e.To.Index + } + if e.From.Index > maxIdx { + maxIdx = e.From.Index + } + if e.To.Index > maxIdx { + maxIdx = e.To.Index + } + case *Note: + for _, actor := range e.Actors { + if minIdx == -1 || actor.Index < minIdx { + minIdx = actor.Index + } + if actor.Index > maxIdx { + maxIdx = actor.Index + } + } + case *Block: + nestedMin, nestedMax := findBlockParticipantRange(e) + if nestedMin != -1 { + if minIdx == -1 || nestedMin < minIdx { + minIdx = nestedMin + } + if nestedMax > maxIdx { + maxIdx = nestedMax + } + } + } + } + } + + for _, section := range block.Sections { + findInElements(section.Elements) + } + + return minIdx, maxIdx +} + +func renderBlock(block *Block, layout *diagramLayout, chars BoxChars, depth int, messageSpacing int) []string { + var lines []string + + minIdx, maxIdx := findBlockParticipantRange(block) + if minIdx == -1 || maxIdx == -1 { + minIdx = 0 + maxIdx = len(layout.participantCenters) - 1 + } + + indent := depth * 2 + leftCenter := layout.participantCenters[minIdx] + rightCenter := layout.participantCenters[maxIdx] + + boxLeft := leftCenter - 3 + indent + if boxLeft < 0 { + boxLeft = 0 + } + boxRight := rightCenter + 3 - indent + + headerLabel := fmt.Sprintf("%s %s", block.Type, block.Label) + labelWidth := runewidth.StringWidth(headerLabel) + if boxRight-boxLeft < labelWidth+4 { + boxRight = boxLeft + labelWidth + 4 + } + + contentRightEdge := calculateBlockRightEdge(block, layout) + if contentRightEdge > boxRight { + boxRight = contentRightEdge + } + + ensureWidth := boxRight + 1 + if ensureWidth < layout.totalWidth { + ensureWidth = layout.totalWidth + } + + bc := GetBlockChars(block.Type, chars) + + makeLine := func() []rune { + line := make([]rune, ensureWidth+1) + for i := range line { + line[i] = ' ' + } + for _, c := range layout.participantCenters { + if c < len(line) { + line[c] = chars.Vertical + } + } + return line + } + + topLine := makeLine() + topLine[boxLeft] = bc.TopLeft + for i := boxLeft + 1; i < boxRight; i++ { + if topLine[i] == chars.Vertical { + topLine[i] = bc.TeeUp + } else { + topLine[i] = bc.Horizontal + } + } + topLine[boxRight] = bc.TopRight + lines = append(lines, strings.TrimRight(string(topLine), " ")) + + headerLine := makeLine() + headerLine[boxLeft] = bc.Vertical + headerLine[boxRight] = bc.Vertical + col := boxLeft + 2 + for _, r := range headerLabel { + if col < boxRight { + headerLine[col] = r + col++ + } + } + lines = append(lines, strings.TrimRight(string(headerLine), " ")) + + sepLine := makeLine() + sepLine[boxLeft] = bc.TeeRight + for i := boxLeft + 1; i < boxRight; i++ { + if sepLine[i] == chars.Vertical { + sepLine[i] = bc.Cross + } else { + sepLine[i] = bc.Horizontal + } + } + sepLine[boxRight] = bc.TeeLeft + lines = append(lines, strings.TrimRight(string(sepLine), " ")) + + for sectionIdx, section := range block.Sections { + if sectionIdx > 0 { + divLine := makeLine() + divLine[boxLeft] = bc.TeeRight + for i := boxLeft + 1; i < boxRight; i++ { + if divLine[i] == chars.Vertical { + divLine[i] = bc.Cross + } else { + divLine[i] = bc.Horizontal + } + } + divLine[boxRight] = bc.TeeLeft + lines = append(lines, strings.TrimRight(string(divLine), " ")) + + if section.Label != "" { + labelLine := makeLine() + labelLine[boxLeft] = bc.Vertical + labelLine[boxRight] = bc.Vertical + col := boxLeft + 2 + for _, r := range section.Label { + if col < boxRight { + labelLine[col] = r + col++ + } + } + lines = append(lines, strings.TrimRight(string(labelLine), " ")) + } + } + + for _, elem := range section.Elements { + for i := 0; i < messageSpacing; i++ { + spaceLine := makeLine() + spaceLine[boxLeft] = bc.Vertical + spaceLine[boxRight] = bc.Vertical + lines = append(lines, strings.TrimRight(string(spaceLine), " ")) + } + + switch e := elem.(type) { + case *Message: + msgLines := renderMessage(e, layout, chars) + for _, ml := range msgLines { + mlRunes := []rune(ml) + for len(mlRunes) <= ensureWidth { + mlRunes = append(mlRunes, ' ') + } + mlRunes[boxLeft] = bc.Vertical + mlRunes[boxRight] = bc.Vertical + lines = append(lines, strings.TrimRight(string(mlRunes), " ")) + } + case *Note: + noteLines := renderNote(e, layout, chars) + for _, nl := range noteLines { + nlRunes := []rune(nl) + for len(nlRunes) <= ensureWidth { + nlRunes = append(nlRunes, ' ') + } + nlRunes[boxLeft] = bc.Vertical + nlRunes[boxRight] = bc.Vertical + lines = append(lines, strings.TrimRight(string(nlRunes), " ")) + } + case *Block: + nestedLines := renderBlock(e, layout, chars, depth+1, messageSpacing) + for _, nl := range nestedLines { + nlRunes := []rune(nl) + for len(nlRunes) <= ensureWidth { + nlRunes = append(nlRunes, ' ') + } + nlRunes[boxLeft] = bc.Vertical + nlRunes[boxRight] = bc.Vertical + lines = append(lines, strings.TrimRight(string(nlRunes), " ")) + } + } + } + } + + spaceLine := makeLine() + spaceLine[boxLeft] = bc.Vertical + spaceLine[boxRight] = bc.Vertical + lines = append(lines, strings.TrimRight(string(spaceLine), " ")) + + bottomLine := makeLine() + bottomLine[boxLeft] = bc.BottomLeft + for i := boxLeft + 1; i < boxRight; i++ { + if bottomLine[i] == chars.Vertical { + bottomLine[i] = bc.TeeDown + } else { + bottomLine[i] = bc.Horizontal + } + } + bottomLine[boxRight] = bc.BottomRight + lines = append(lines, strings.TrimRight(string(bottomLine), " ")) + + return lines +} + func renderSelfMessage(msg *Message, layout *diagramLayout, chars BoxChars) []string { var lines []string center := layout.participantCenters[msg.From.Index] diff --git a/internal/sequence/renderer_test.go b/internal/sequence/renderer_test.go index 9e62d5e..f9a7ffe 100644 --- a/internal/sequence/renderer_test.go +++ b/internal/sequence/renderer_test.go @@ -27,6 +27,8 @@ func TestSequenceDiagramRendering(t *testing.T) { "adjacent_participants_communication.txt", "autonumber.txt", "bidirectional_messages.txt", + "block_extra_long_message.txt", + "block_long_message.txt", "dotted_arrows_only.txt", "four_participants.txt", "long_participant_names.txt", @@ -74,6 +76,8 @@ func TestSequenceDiagramRendering_ASCIISmokeTest(t *testing.T) { "adjacent_participants_communication.txt", "autonumber.txt", "bidirectional_messages.txt", + "block_extra_long_message.txt", + "block_long_message.txt", "dotted_arrows_only.txt", "four_participants.txt", "long_participant_names.txt", diff --git a/internal/sequence/sequence_test.go b/internal/sequence/sequence_test.go index 7a449e8..d58fe50 100644 --- a/internal/sequence/sequence_test.go +++ b/internal/sequence/sequence_test.go @@ -231,6 +231,703 @@ func TestArrowTypeString(t *testing.T) { } } +func TestParseNoteQuotedActorSameAsMessage(t *testing.T) { + input := `sequenceDiagram + "My Service"->>B: Hello + Note over "My Service": This is a note` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(sd.Participants) != 2 { + t.Fatalf("expected 2 participants, got %d: %v", len(sd.Participants), sd.Participants) + } + + if sd.Participants[0].ID != "My Service" { + t.Errorf("expected first participant ID to be 'My Service', got %q", sd.Participants[0].ID) + } + + if len(sd.Elements) != 2 { + t.Fatalf("expected 2 elements, got %d", len(sd.Elements)) + } + + msg, ok := sd.Elements[0].(*Message) + if !ok { + t.Fatalf("expected Message, got %T", sd.Elements[0]) + } + + note, ok := sd.Elements[1].(*Note) + if !ok { + t.Fatalf("expected Note, got %T", sd.Elements[1]) + } + + if msg.From != note.Actors[0] { + t.Errorf("message From participant (%p, ID=%q) should be same as note actor (%p, ID=%q)", + msg.From, msg.From.ID, note.Actors[0], note.Actors[0].ID) + } +} + +func TestParseNoteOverSingleActor(t *testing.T) { + input := `sequenceDiagram + participant A + Note over A: This is a note` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(sd.Elements) != 1 { + t.Fatalf("expected 1 element, got %d", len(sd.Elements)) + } + + note, ok := sd.Elements[0].(*Note) + if !ok { + t.Fatalf("expected Note, got %T", sd.Elements[0]) + } + + if note.Position != NoteOver { + t.Errorf("expected NoteOver, got %v", note.Position) + } + if len(note.Actors) != 1 || note.Actors[0].ID != "A" { + t.Errorf("expected 1 actor with ID 'A', got %v", note.Actors) + } + if note.Text != "This is a note" { + t.Errorf("expected text 'This is a note', got %q", note.Text) + } +} + +func TestParseNoteOverMultipleActors(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + Note over A,B: Spanning note` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(sd.Elements) != 1 { + t.Fatalf("expected 1 element, got %d", len(sd.Elements)) + } + + note, ok := sd.Elements[0].(*Note) + if !ok { + t.Fatalf("expected Note, got %T", sd.Elements[0]) + } + + if note.Position != NoteOver { + t.Errorf("expected NoteOver, got %v", note.Position) + } + if len(note.Actors) != 2 { + t.Fatalf("expected 2 actors, got %d", len(note.Actors)) + } + if note.Actors[0].ID != "A" || note.Actors[1].ID != "B" { + t.Errorf("expected actors A and B, got %v and %v", note.Actors[0].ID, note.Actors[1].ID) + } + if note.Text != "Spanning note" { + t.Errorf("expected text 'Spanning note', got %q", note.Text) + } +} + +func TestRenderNoteOver(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + Note over A: Test note` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("parse error: %v", err) + } + + output, err := Render(sd, nil) + if err != nil { + t.Fatalf("render error: %v", err) + } + + if !strings.Contains(output, "Test note") { + t.Errorf("output should contain note text:\n%s", output) + } +} + +func TestRenderNoteOverLongText(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + Note over A: This is a very long note text that should expand the box` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("parse error: %v", err) + } + + output, err := Render(sd, nil) + if err != nil { + t.Fatalf("render error: %v", err) + } + + longText := "This is a very long note text that should expand the box" + if !strings.Contains(output, longText) { + t.Errorf("output should contain full note text:\n%s", output) + } + + lines := strings.Split(output, "\n") + for _, line := range lines { + if strings.Contains(line, "TopLeft") || strings.Contains(line, "TopRight") { + continue + } + for i, r := range line { + if r == '│' || r == '|' { + if i > 0 && i < len(line)-1 { + prev := rune(line[i-1]) + next := rune(line[i+1]) + if (prev >= 'a' && prev <= 'z') || (prev >= 'A' && prev <= 'Z') { + t.Errorf("border character at position %d may be overwriting text: %s", i, line) + } + if (next >= 'a' && next <= 'z') || (next >= 'A' && next <= 'Z') { + if next != 'T' { + t.Errorf("border character at position %d may be adjacent to truncated text: %s", i, line) + } + } + } + } + } + } +} + +func TestParseNoteLeftRight(t *testing.T) { + tests := []struct { + name string + input string + wantPosition NotePosition + wantActorID string + wantText string + }{ + { + name: "note left of", + input: `sequenceDiagram + participant A + Note left of A: Left note`, + wantPosition: NoteLeftOf, + wantActorID: "A", + wantText: "Left note", + }, + { + name: "note right of", + input: `sequenceDiagram + participant B + Note right of B: Right note`, + wantPosition: NoteRightOf, + wantActorID: "B", + wantText: "Right note", + }, + { + name: "note left of case insensitive", + input: `sequenceDiagram + participant C + NOTE LEFT OF C: Case test`, + wantPosition: NoteLeftOf, + wantActorID: "C", + wantText: "Case test", + }, + { + name: "note right of case insensitive", + input: `sequenceDiagram + participant D + note RIGHT OF D: Mixed case`, + wantPosition: NoteRightOf, + wantActorID: "D", + wantText: "Mixed case", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sd, err := Parse(tt.input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(sd.Elements) != 1 { + t.Fatalf("expected 1 element, got %d", len(sd.Elements)) + } + + note, ok := sd.Elements[0].(*Note) + if !ok { + t.Fatalf("expected Note, got %T", sd.Elements[0]) + } + + if note.Position != tt.wantPosition { + t.Errorf("expected position %v, got %v", tt.wantPosition, note.Position) + } + if len(note.Actors) != 1 || note.Actors[0].ID != tt.wantActorID { + t.Errorf("expected actor %q, got %v", tt.wantActorID, note.Actors) + } + if note.Text != tt.wantText { + t.Errorf("expected text %q, got %q", tt.wantText, note.Text) + } + }) + } +} + +func TestRenderNoteRightOf(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + Note right of B: Right note` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("parse error: %v", err) + } + + output, err := Render(sd, nil) + if err != nil { + t.Fatalf("render error: %v", err) + } + + if !strings.Contains(output, "Right note") { + t.Errorf("output should contain note text:\n%s", output) + } +} + +func TestRenderNoteRightOfEdgeBoundary(t *testing.T) { + input := `sequenceDiagram + participant A + Note right of A: Hi` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("parse error: %v", err) + } + + output, err := Render(sd, nil) + if err != nil { + t.Fatalf("render error: %v", err) + } + + if !strings.Contains(output, "Hi") { + t.Errorf("output should contain note text:\n%s", output) + } +} + +func TestRenderNoteLeftOf(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + Note left of A: Left note` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("parse error: %v", err) + } + + output, err := Render(sd, nil) + if err != nil { + t.Fatalf("render error: %v", err) + } + + if !strings.Contains(output, "Left note") { + t.Errorf("output should contain note text:\n%s", output) + } +} + +func TestParseBlockLoop(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + loop Every minute + A->>B: Ping + B-->>A: Pong + end` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(sd.Elements) != 1 { + t.Fatalf("expected 1 element, got %d", len(sd.Elements)) + } + + block, ok := sd.Elements[0].(*Block) + if !ok { + t.Fatalf("expected Block, got %T", sd.Elements[0]) + } + + if block.Type != BlockLoop { + t.Errorf("expected BlockLoop, got %v", block.Type) + } + if block.Label != "Every minute" { + t.Errorf("expected label 'Every minute', got %q", block.Label) + } + if len(block.Sections) != 1 { + t.Errorf("expected 1 section, got %d", len(block.Sections)) + } + if len(block.Sections[0].Elements) != 2 { + t.Errorf("expected 2 elements in section, got %d", len(block.Sections[0].Elements)) + } +} + +func TestParseBlockAltElse(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + alt Success + A->>B: 200 OK + else Failure + A->>B: 500 Error + end` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + block, ok := sd.Elements[0].(*Block) + if !ok { + t.Fatalf("expected Block, got %T", sd.Elements[0]) + } + + if block.Type != BlockAlt { + t.Errorf("expected BlockAlt, got %v", block.Type) + } + if len(block.Sections) != 2 { + t.Errorf("expected 2 sections, got %d", len(block.Sections)) + } + if block.Sections[1].Label != "Failure" { + t.Errorf("expected section label 'Failure', got %q", block.Sections[1].Label) + } +} + +func TestParseBlockParAnd(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + participant C + par Task 1 + A->>B: Do X + and Task 2 + A->>C: Do Y + end` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + block, ok := sd.Elements[0].(*Block) + if !ok { + t.Fatalf("expected Block, got %T", sd.Elements[0]) + } + + if block.Type != BlockPar { + t.Errorf("expected BlockPar, got %v", block.Type) + } + if len(block.Sections) != 2 { + t.Errorf("expected 2 sections, got %d", len(block.Sections)) + } +} + +func TestParseBlockNested(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + loop Outer + alt Check + A->>B: Request + else Skip + A->>B: Skip + end + end` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + outerBlock, ok := sd.Elements[0].(*Block) + if !ok { + t.Fatalf("expected Block, got %T", sd.Elements[0]) + } + + if outerBlock.Type != BlockLoop { + t.Errorf("expected BlockLoop, got %v", outerBlock.Type) + } + + innerBlock, ok := outerBlock.Sections[0].Elements[0].(*Block) + if !ok { + t.Fatalf("expected nested Block, got %T", outerBlock.Sections[0].Elements[0]) + } + + if innerBlock.Type != BlockAlt { + t.Errorf("expected nested BlockAlt, got %v", innerBlock.Type) + } +} + +func TestParseBlockDividerAsFirstContent(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + alt + else something + A->>B: message + end` + + _, err := Parse(input) + if err == nil { + t.Fatal("expected error for divider as first content") + } + if !strings.Contains(err.Error(), "divider") || !strings.Contains(err.Error(), "cannot be first content") { + t.Errorf("expected error about divider as first content, got: %v", err) + } +} + +func TestRenderBlockLoop(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + loop Every minute + A->>B: Ping + end` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("parse error: %v", err) + } + + output, err := Render(sd, nil) + if err != nil { + t.Fatalf("render error: %v", err) + } + + if !strings.Contains(output, "loop") { + t.Errorf("output should contain 'loop':\n%s", output) + } + if !strings.Contains(output, "Every minute") { + t.Errorf("output should contain label:\n%s", output) + } + if !strings.Contains(output, "Ping") { + t.Errorf("output should contain message:\n%s", output) + } +} + +func TestRenderBlockAltElse(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + alt Success + A->>B: OK + else Error + A->>B: Fail + end` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("parse error: %v", err) + } + + output, err := Render(sd, nil) + if err != nil { + t.Fatalf("render error: %v", err) + } + + if !strings.Contains(output, "alt") { + t.Errorf("output should contain 'alt':\n%s", output) + } + if !strings.Contains(output, "Error") { + t.Errorf("output should contain 'Error' divider:\n%s", output) + } +} + +func TestRenderBlockNested(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + loop Retry + opt Check + A->>B: Verify + end + end` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("parse error: %v", err) + } + + output, err := Render(sd, nil) + if err != nil { + t.Fatalf("render error: %v", err) + } + + if !strings.Contains(output, "loop") { + t.Errorf("output should contain 'loop':\n%s", output) + } + if !strings.Contains(output, "opt") { + t.Errorf("output should contain nested 'opt':\n%s", output) + } +} + +func TestRenderBlockNestedIndentation(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + loop Outer + opt Inner + A->>B: Message + end + end` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("parse error: %v", err) + } + + output, err := Render(sd, nil) + if err != nil { + t.Fatalf("render error: %v", err) + } + + lines := strings.Split(output, "\n") + + var outerTopLineIdx, innerTopLineIdx int + outerTopLineIdx, innerTopLineIdx = -1, -1 + + for i, line := range lines { + if strings.Contains(line, "loop Outer") { + outerTopLineIdx = i + } + if strings.Contains(line, "opt Inner") { + innerTopLineIdx = i + } + } + + if outerTopLineIdx == -1 || innerTopLineIdx == -1 { + t.Fatalf("could not find block header lines in output:\n%s", output) + } + + isTopLeftCorner := func(ch rune) bool { + return ch == '┌' || ch == '╭' || ch == '╔' || ch == '╓' + } + isTopRightCorner := func(ch rune) bool { + return ch == '┐' || ch == '╮' || ch == '╗' || ch == '╖' + } + + findTopLeftCorner := func(lineIdx int) int { + for i := lineIdx; i >= 0; i-- { + runes := []rune(lines[i]) + for j, ch := range runes { + if isTopLeftCorner(ch) { + return j + } + } + } + return -1 + } + + findTopRightCorner := func(lineIdx int) int { + for i := lineIdx; i >= 0; i-- { + runes := []rune(lines[i]) + for j := len(runes) - 1; j >= 0; j-- { + if isTopRightCorner(runes[j]) { + return j + } + } + } + return -1 + } + + outerLeft := findTopLeftCorner(outerTopLineIdx) + innerLeft := findTopLeftCorner(innerTopLineIdx) + + outerStartLine := -1 + for i := outerTopLineIdx; i >= 0; i-- { + runes := []rune(lines[i]) + hasLeft, hasRight := false, false + for _, ch := range runes { + if isTopLeftCorner(ch) { + hasLeft = true + } + if isTopRightCorner(ch) { + hasRight = true + } + } + if hasLeft && hasRight { + outerStartLine = i + break + } + } + outerRight := findTopRightCorner(outerStartLine + 1) + + innerStartLine := -1 + for i := innerTopLineIdx; i >= 0; i-- { + runes := []rune(lines[i]) + count := 0 + for _, ch := range runes { + if isTopLeftCorner(ch) { + count++ + } + } + if count >= 1 { + innerStartLine = i + break + } + } + runes := []rune(lines[innerStartLine]) + innerRight := -1 + for j := len(runes) - 1; j >= 0; j-- { + if isTopRightCorner(runes[j]) { + innerRight = j + break + } + } + + if outerLeft == -1 || innerLeft == -1 || outerRight == -1 || innerRight == -1 { + t.Fatalf("could not find block boundaries (outer: %d-%d, inner: %d-%d) in output:\n%s", + outerLeft, outerRight, innerLeft, innerRight, output) + } + + if innerLeft <= outerLeft { + t.Errorf("inner block left edge (%d) should be greater than outer block left edge (%d) - inner block should be indented inward.\nOutput:\n%s", innerLeft, outerLeft, output) + } + + if innerRight >= outerRight { + t.Errorf("inner block right edge (%d) should be less than outer block right edge (%d) - inner block should be contained within outer.\nOutput:\n%s", innerRight, outerRight, output) + } +} + +func TestRenderBlockEmpty(t *testing.T) { + input := `sequenceDiagram + participant A + participant B + loop Empty + end` + + sd, err := Parse(input) + if err != nil { + t.Fatalf("parse error: %v", err) + } + + output, err := Render(sd, nil) + if err != nil { + t.Fatalf("render error: %v", err) + } + + if !strings.Contains(output, "loop Empty") { + t.Errorf("output should contain 'loop Empty' label:\n%s", output) + } + if !strings.Contains(output, "┌") || !strings.Contains(output, "┐") { + t.Errorf("output should contain block box corners:\n%s", output) + } + if !strings.Contains(output, "└") || !strings.Contains(output, "┘") { + t.Errorf("output should contain block box bottom corners:\n%s", output) + } +} + func FuzzParseSequenceDiagram(f *testing.F) { f.Add("sequenceDiagram\nA->>B: Hello") f.Add("sequenceDiagram\nparticipant Alice\nAlice->>Bob: Hi")