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")