diff --git a/PHASE2.md b/PHASE2.md new file mode 100644 index 0000000..e6c2549 --- /dev/null +++ b/PHASE2.md @@ -0,0 +1,293 @@ +# Phase 2 Features - Bidirectional Conversion & Multiple Formats + +Phase 2 adds bidirectional conversion capabilities and support for multiple diagram formats: PlantUML, D2, and Graphviz. + +## New Features + +### 1. Mermaid Parser (Bidirectional Conversion) + +Parse Mermaid diagrams back into DiagramIR, enabling true bidirectional editing. + +```python +from ij.parsers import MermaidParser + +mermaid_text = """ +flowchart TD + n1([Start]) + n2[Process] + n3([End]) + n1 --> n2 + n2 --> n3 +""" + +parser = MermaidParser() +diagram = parser.parse(mermaid_text) + +# Now you can manipulate the diagram or convert to other formats +``` + +**Roundtrip Conversion:** + +```python +from ij.renderers import MermaidRenderer +from ij.parsers import MermaidParser + +# Mermaid -> DiagramIR -> Mermaid +parser = MermaidParser() +diagram = parser.parse(original_mermaid) + +renderer = MermaidRenderer() +regenerated = renderer.render(diagram) +``` + +### 2. PlantUML Renderer + +Export diagrams to PlantUML format, the enterprise standard for comprehensive UML. + +```python +from ij.renderers import PlantUMLRenderer + +renderer = PlantUMLRenderer(use_skinparam=True) +plantuml_output = renderer.render(diagram) +print(plantuml_output) +``` + +**Output:** +```plantuml +@startuml +title My Process + +skinparam ActivityFontSize 14 +skinparam ActivityBorderColor #2C3E50 +skinparam ActivityBackgroundColor #ECF0F1 + +start +:Process step; +stop +@enduml +``` + +### 3. D2 Renderer + +Export to D2 (Terrastruct), a modern diagram language with excellent aesthetics. + +```python +from ij.renderers import D2Renderer + +renderer = D2Renderer(layout="dagre") +d2_output = renderer.render(diagram) +``` + +**Output:** +```d2 +# My Process + +direction: down + +n1: "Start" { + shape: oval + style.fill: '#90EE90' + style.stroke: '#228B22' +} +n2: "Process" { + shape: rectangle +} +n1 -> n2 +``` + +**Features:** +- Multiple layout engines (dagre, elk, tala) +- Beautiful default styling +- Direction support +- Shape customization + +### 4. Graphviz/DOT Renderer + +Export to Graphviz DOT format, the 30-year-old foundation of graph visualization. + +```python +from ij.renderers import GraphvizRenderer + +renderer = GraphvizRenderer(layout="dot") +dot_output = renderer.render(diagram) + +# Or render directly to image +renderer.render_to_image(diagram, "output", format="png") +``` + +**Output:** +```dot +digraph G { + layout="dot"; + rankdir="TB"; + + n1 [label="Start", shape=oval, style=filled, fillcolor=lightgreen]; + n2 [label="Process", shape=box, style=filled, fillcolor=lightgray]; + n1 -> n2; +} +``` + +**Supported layouts:** +- `dot`: Hierarchical layouts +- `neato`: Spring model layouts +- `fdp`: Force-directed placement +- `circo`: Circular layouts +- `twopi`: Radial layouts + +### 5. Enhanced Text Converter + +More sophisticated natural language processing with support for conditionals, parallel flows, and loops. + +#### Conditional Branches + +```python +from ij.converters import EnhancedTextConverter + +converter = EnhancedTextConverter() +text = "Start -> Check user. If authenticated: Show dashboard, else: Show login" +diagram = converter.convert(text) +``` + +Creates a decision node with Yes/No branches automatically. + +#### Parallel Flows + +```python +text = "Start -> [parallel: Send email, Update database, Log event] -> End" +diagram = converter.convert(text) +``` + +Creates parallel execution paths that converge. + +#### Loops + +```python +text = "Start while data available: Process item" +diagram = converter.convert(text) +``` + +Creates a loop with decision point and back edge. + +## Multi-Format Workflow + +Convert diagrams between any supported format: + +```python +from ij.parsers import MermaidParser +from ij.renderers import PlantUMLRenderer, D2Renderer, GraphvizRenderer + +# Start with Mermaid +mermaid_source = """ +flowchart TD + A[Input] --> B[Process] + B --> C[Output] +""" + +# Parse +parser = MermaidParser() +diagram = parser.parse(mermaid_source) + +# Export to multiple formats +plantuml = PlantUMLRenderer().render(diagram) +d2 = D2Renderer().render(diagram) +graphviz = GraphvizRenderer().render(diagram) + +# Save all formats +renderers = { + "diagram.mmd": MermaidRenderer(), + "diagram.puml": PlantUMLRenderer(), + "diagram.d2": D2Renderer(), + "diagram.dot": GraphvizRenderer(), +} + +for filename, renderer in renderers.items(): + renderer.render_to_file(diagram, filename) +``` + +## Format Comparison + +| Format | Strengths | Best For | Output | +|--------|-----------|----------|--------| +| **Mermaid** | GitHub native, simple syntax, 84K stars | Documentation, README files | SVG, PNG | +| **PlantUML** | Comprehensive UML, 25+ types, enterprise | Detailed UML diagrams | SVG, PNG | +| **D2** | Modern, beautiful, bidirectional editing | Presentations, architecture | SVG, PNG, PPT | +| **Graphviz** | Foundation, maximum control, 30 years | Complex graphs, custom layouts | Any format | + +## Complete Example + +```python +from ij import ( + SimpleTextConverter, + EnhancedTextConverter, + MermaidParser, + MermaidRenderer, + PlantUMLRenderer, + D2Renderer, + GraphvizRenderer, +) + +# Method 1: Simple text conversion +converter = SimpleTextConverter() +diagram = converter.convert("Start -> Process -> End") + +# Method 2: Enhanced text with conditionals +enhanced = EnhancedTextConverter() +diagram = enhanced.convert( + "Start -> Check data. If valid: Process, else: Reject" +) + +# Method 3: Parse existing Mermaid +parser = MermaidParser() +diagram = parser.parse(existing_mermaid_diagram) + +# Render to any format +mermaid = MermaidRenderer().render(diagram) +plantuml = PlantUMLRenderer().render(diagram) +d2 = D2Renderer().render(diagram) +graphviz = GraphvizRenderer().render(diagram) + +# Save to files +MermaidRenderer().render_to_file(diagram, "output.mmd") +PlantUMLRenderer().render_to_file(diagram, "output.puml") +D2Renderer().render_to_file(diagram, "output.d2") +GraphvizRenderer().render_to_file(diagram, "output.dot") +``` + +## Running Examples + +```bash +# Phase 2 examples +python examples/phase2_features.py + +# Specific examples +python -c "from examples.phase2_features import example1_bidirectional_conversion; example1_bidirectional_conversion()" +``` + +## Testing + +All Phase 2 features are fully tested: + +```bash +pytest tests/test_parsers.py -v # Parser tests +pytest tests/test_new_renderers.py -v # New renderer tests +pytest tests/test_enhanced_converter.py -v # Enhanced converter tests +``` + +**Test Coverage:** +- 52 tests total (26 from Phase 1 + 26 from Phase 2) +- 100% pass rate +- Roundtrip conversion validated + +## Next Steps (Phase 3) + +- AI/LLM integration for natural language understanding +- Visual editor integration +- Real-time collaboration (CRDT-based) +- Code-to-diagram reverse engineering +- Multiple diagram views from single source + +## See Also + +- [Main README](README.md) - Overview and Phase 1 features +- [examples/phase2_features.py](examples/phase2_features.py) - Complete examples +- [Research Document](misc/REASEARCH.md) - Background research diff --git a/PHASE3.md b/PHASE3.md new file mode 100644 index 0000000..ac5b4cd --- /dev/null +++ b/PHASE3.md @@ -0,0 +1,471 @@ +## Phase 3 Features - AI/LLM Integration & Code Analysis + +Phase 3 adds AI-powered diagram generation and Python code reverse engineering capabilities, completing the research-recommended feature set for modern diagramming systems. + +## New Features + +### 1. AI/LLM-Powered Diagram Generation + +Convert natural language descriptions to diagrams using OpenAI's API. Achieves **10-20x faster** diagram creation as documented in research. + +```python +from ij.converters import LLMConverter + +converter = LLMConverter(model="gpt-4o-mini", temperature=0.3) + +diagram = converter.convert(""" +A user logs into the system. If authentication succeeds, they see +the dashboard. Otherwise, they see an error message. +""") +``` + +**Features:** +- Natural language understanding +- Automatic node type inference +- Clean, well-structured output +- Iterative refinement support +- Uses cheap, effective models (gpt-4o-mini: ~$0.00015 per diagram) + +**Installation:** +```bash +pip install ij[ai] # Installs openai package +export OPENAI_API_KEY=your-key-here +``` + +### 2. Iterative Diagram Refinement + +Refine diagrams conversationally using AI feedback: + +```python +from ij.renderers import MermaidRenderer + +# Generate initial diagram +diagram = converter.convert("User login process") +mermaid = MermaidRenderer().render(diagram) + +# Refine with feedback +refined = converter.refine( + diagram, + "Add a password reset option if login fails", + mermaid +) +``` + +This enables: +- Conversational diagram improvement +- Adding missing details +- Restructuring flows +- Fixing errors + +### 3. Python Code Analysis + +Reverse engineer diagrams from Python code using AST parsing. + +#### Function Flowcharts + +```python +from ij.analyzers import PythonCodeAnalyzer + +code = """ +def process_order(order): + if order.is_valid(): + if order.in_stock(): + charge_customer(order) + ship_order(order) + else: + backorder(order) + else: + reject_order(order) +""" + +analyzer = PythonCodeAnalyzer() +diagram = analyzer.analyze_function(code) +``` + +Generates a flowchart showing: +- Control flow (if/else, while, for) +- Function calls +- Return statements +- Decision points + +#### Call Graphs + +```python +code = """ +def main(): + config = load_config() + db = setup_database(config) + run_app(db) + +def load_config(): + return read_file("config.json") +""" + +diagram = analyzer.analyze_module_calls(code) +``` + +Shows function dependencies and call relationships. + +#### Class Diagrams + +```python +code = """ +class UserService(BaseService): + def __init__(self): + self.users = [] + + def add_user(self, user): + self.users.append(user) + + def get_user(self, id): + return find_by_id(self.users, id) +""" + +diagram = analyzer.analyze_class(code, class_name="UserService") +``` + +Shows: +- Class structure +- Methods and attributes +- Inheritance relationships + +### 4. Hybrid Workflows + +Combine code analysis with AI enhancement: + +```python +# Step 1: Analyze existing code +analyzer = PythonCodeAnalyzer() +diagram = analyzer.analyze_function(code) + +# Step 2: Enhance with AI +converter = LLMConverter() +enhanced = converter.refine( + diagram, + "Add error handling for edge cases", + MermaidRenderer().render(diagram) +) +``` + +## AI Model Configuration + +### Recommended Models + +**gpt-4o-mini** (default): +- Cost: ~$0.00015 per diagram +- Speed: ~1-2 seconds +- Quality: Excellent for flowcharts +- Best for: Most use cases + +**gpt-4o**: +- Cost: ~$0.0015 per diagram (10x more) +- Speed: ~2-3 seconds +- Quality: Slightly better for complex diagrams +- Best for: Production-critical diagrams + +### Configuration Options + +```python +converter = LLMConverter( + api_key="your-key", # Or use OPENAI_API_KEY env var + model="gpt-4o-mini", # Model choice + temperature=0.3 # Lower = more deterministic +) +``` + +**Temperature guide:** +- 0.1-0.3: Consistent, predictable output (recommended) +- 0.4-0.7: More creative variations +- 0.8-1.0: Maximum creativity (less consistent) + +## Cost Estimation + +Based on typical usage with gpt-4o-mini: + +- Simple diagram (3-5 nodes): $0.00010 - $0.00015 +- Medium diagram (6-10 nodes): $0.00015 - $0.00020 +- Complex diagram (11+ nodes): $0.00020 - $0.00030 +- Refinement iteration: $0.00015 - $0.00025 + +**Monthly estimates:** +- Light use (10 diagrams/day): ~$1-2/month +- Medium use (50 diagrams/day): ~$5-10/month +- Heavy use (200 diagrams/day): ~$20-40/month + +## Python Code Analysis Capabilities + +### Supported Constructs + +✅ **Control Flow:** +- if/elif/else statements +- while loops +- for loops +- Nested conditions + +✅ **Functions:** +- Function definitions +- Function calls +- Return statements +- Parameters + +✅ **Classes:** +- Class definitions +- Methods +- Attributes +- Inheritance + +### Limitations + +⚠️ **Not Yet Supported:** +- Try/except blocks (shown as generic statements) +- Async/await +- Decorators (not visualized) +- Complex comprehensions +- Multi-file analysis + +## Testing + +### Mock Tests (Always Run) + +Tests use mocks by default - no API key needed: + +```bash +pytest tests/test_llm_converter.py -v -k "not real_api" +pytest tests/test_python_analyzer.py -v +``` + +**71 total tests** including: +- 12 Python analyzer tests +- 7 LLM converter mock tests +- 2 optional real API tests (require `OPENAI_API_KEY`) + +### Real API Tests (Optional) + +Optional integration tests with real OpenAI API: + +```bash +export OPENAI_API_KEY=your-key-here +pytest tests/test_llm_converter.py -v # Runs all tests including real API +``` + +These tests: +- Only run if `OPENAI_API_KEY` is set +- Use gpt-4o-mini (cheap) +- Cost ~$0.0003 per test run +- Validate end-to-end functionality + +## Complete Examples + +### Example 1: Code → Diagram → Multiple Formats + +```python +from ij.analyzers import PythonCodeAnalyzer +from ij.renderers import MermaidRenderer, PlantUMLRenderer, D2Renderer + +code = """ +def validate(data): + if not data: + return False + if check_format(data): + save(data) + return True + return False +""" + +analyzer = PythonCodeAnalyzer() +diagram = analyzer.analyze_function(code) + +# Export to multiple formats +mermaid = MermaidRenderer().render(diagram) +plantuml = PlantUMLRenderer().render(diagram) +d2 = D2Renderer().render(diagram) +``` + +### Example 2: Natural Language → AI → Diagram + +```python +from ij.converters import LLMConverter +from ij.renderers import MermaidRenderer + +converter = LLMConverter() + +diagram = converter.convert(""" +Create a diagram for an online shopping checkout process. The user +adds items to cart, proceeds to checkout, enters shipping info, +chooses payment method, and confirms the order. Include error handling. +""", title="E-commerce Checkout") + +mermaid = MermaidRenderer().render(diagram) +print(mermaid) +``` + +### Example 3: Few-Shot Learning + +Provide examples to guide AI output: + +```python +examples = [ + { + "description": "Simple login", + "mermaid": """flowchart TD + A([Start]) --> B[Enter credentials] + B --> C{Valid?} + C -->|Yes| D([Success]) + C -->|No| E([Error])""" + } +] + +diagram = converter.convert_with_examples( + "User registration process", + examples=examples +) +``` + +### Example 4: Hybrid Workflow + +```python +# 1. Analyze existing code +analyzer = PythonCodeAnalyzer() +code_diagram = analyzer.analyze_function(existing_code) + +# 2. Get initial Mermaid +initial = MermaidRenderer().render(code_diagram) + +# 3. Use AI to add missing pieces +converter = LLMConverter() +enhanced = converter.refine( + code_diagram, + "Add input validation and error handling steps", + initial +) + +# 4. Export final version +final = MermaidRenderer().render(enhanced) +``` + +## Running Examples + +```bash +# Phase 3 examples (AI examples skip if no API key) +python examples/phase3_features.py + +# Individual examples +python -c "from examples.phase3_features import example1_python_code_analysis; example1_python_code_analysis()" +``` + +## CI/CD Integration + +The OpenAI API key is configured in CI for integration testing: + +```yaml +# .github/workflows/ci.yml +- name: Run Tests + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: pytest +``` + +Tests automatically: +- Run mock tests (always) +- Run real API tests (if key available) +- Skip gracefully (if key missing) + +## Best Practices + +### AI-Powered Generation + +**DO:** +- Use clear, specific descriptions +- Include context about the domain +- Start with simpler diagrams and refine +- Use temperature 0.1-0.3 for consistency +- Cache and reuse diagrams when possible + +**DON'T:** +- Use vague or ambiguous language +- Expect perfect output on first try +- Generate diagrams for well-known patterns (use templates instead) +- Use high temperature for production diagrams + +### Code Analysis + +**DO:** +- Analyze one function/class at a time +- Use meaningful variable names in code +- Document complex logic +- Review generated diagrams for accuracy + +**DON'T:** +- Analyze overly complex functions (refactor first) +- Expect diagram to show implementation details +- Rely solely on generated diagrams (human review needed) + +## Future Enhancements (Phase 4+) + +Planned for future releases: +- [ ] Java code analysis +- [ ] JavaScript/TypeScript analysis +- [ ] Sequence diagram generation from traces +- [ ] PlantUML parser (completing bidirectional support) +- [ ] Local LLM support (Ollama, etc.) +- [ ] Diagram optimization suggestions +- [ ] Multi-language code analysis +- [ ] Visual diff for diagram changes + +## Troubleshooting + +### ImportError: No module named 'openai' + +Install AI dependencies: +```bash +pip install ij[ai] +``` + +### ValueError: OpenAI API key required + +Set your API key: +```bash +export OPENAI_API_KEY=your-key-here +``` + +Or pass directly: +```python +converter = LLMConverter(api_key="your-key") +``` + +### API Rate Limits + +If you hit rate limits: +- Add delays between requests +- Use caching for repeated diagrams +- Consider batch processing +- Upgrade OpenAI tier if needed + +### Quality Issues + +If AI output is poor quality: +- Lower temperature (0.1-0.2) +- Provide more specific descriptions +- Use few-shot examples +- Try gpt-4o instead of gpt-4o-mini +- Break complex diagrams into smaller parts + +## Resources + +- [OpenAI API Documentation](https://platform.openai.com/docs) +- [OpenAI Pricing](https://openai.com/pricing) +- [Python AST Documentation](https://docs.python.org/3/library/ast.html) +- [Phase 1 Features](README.md#features) +- [Phase 2 Features](PHASE2.md) + +--- + +**Phase 3 Status:** ✅ Complete + +**Test Coverage:** 71 tests (100% pass rate) + +**Key Metrics:** +- AI generation: 10-20x faster than manual +- Code analysis: Instant diagram generation +- Cost: ~$0.00015 per AI-generated diagram +- Quality: Production-ready with human review diff --git a/PHASE4.md b/PHASE4.md new file mode 100644 index 0000000..00403a8 --- /dev/null +++ b/PHASE4.md @@ -0,0 +1,419 @@ +# Phase 4: Advanced Diagramming Features + +Phase 4 completes the bidirectional diagramming system with advanced features for diagram manipulation, multi-format support, and sequence diagram generation. + +## New Features + +### 1. Bidirectional D2 Support + +Complete bidirectional conversion for D2 (Terrastruct) diagrams. + +**D2 Parser (`ij.parsers.D2Parser`)**: +- Parse D2 syntax to DiagramIR +- Support for all node shapes (oval, rectangle, diamond, cylinder) +- Edge types (direct, conditional, bidirectional) +- Direction metadata +- Automatic START/END node inference + +**Example**: +```python +from ij import D2Parser, D2Renderer + +# Parse D2 to IR +d2_code = """ +start: "Begin" { + shape: oval +} +process: "Process Data" { + shape: rectangle +} +start -> process +""" + +parser = D2Parser() +diagram = parser.parse(d2_code) + +# Render back to D2 +renderer = D2Renderer() +d2_output = renderer.render(diagram) +``` + +### 2. Sequence Diagram Generation + +Generate Mermaid sequence diagrams for showing interactions and message flows. + +**SequenceDiagramRenderer**: +- Render DiagramIR as Mermaid sequence diagrams +- Support for sync (solid arrows) and async (dashed arrows) messages +- Notes and activations support + +**InteractionAnalyzer**: +- Analyze Python code to extract interaction patterns +- Parse natural language descriptions +- Automatic participant detection + +**Examples**: + +**From DiagramIR**: +```python +from ij import DiagramIR, Node, Edge, SequenceDiagramRenderer + +diagram = DiagramIR() +diagram.add_node(Node(id="user", label="User")) +diagram.add_node(Node(id="api", label="API")) +diagram.add_edge(Edge(source="user", target="api", label="Request")) + +renderer = SequenceDiagramRenderer() +print(renderer.render(diagram)) +``` + +**From Code**: +```python +from ij import InteractionAnalyzer, SequenceDiagramRenderer + +code = """ +api.authenticate(user) +db.query(user_id) +cache.store(result) +""" + +analyzer = InteractionAnalyzer() +diagram = analyzer.analyze_function_calls("app", code) + +renderer = SequenceDiagramRenderer() +print(renderer.render(diagram)) +``` + +**From Natural Language**: +```python +text = """ +User sends request to API. +API queries Database. +Database returns data to API. +""" + +analyzer = InteractionAnalyzer() +diagram = analyzer.from_text_description(text) +``` + +### 3. Diagram Transformations + +Powerful utilities for manipulating and optimizing diagrams. + +**DiagramTransforms Class**: + +**Simplification**: +```python +from ij import DiagramTransforms + +# Remove isolated nodes and duplicate edges +simplified = DiagramTransforms.simplify(diagram, remove_isolated=True) +``` + +**Filtering**: +```python +# Keep only PROCESS nodes +filtered = DiagramTransforms.filter_by_node_type( + diagram, [NodeType.PROCESS], keep=True +) + +# Custom filtering with predicates +error_nodes = DiagramTransforms.apply_node_filter( + diagram, lambda n: "error" in n.label.lower() +) +``` + +**Subgraph Extraction**: +```python +# Extract subgraph starting from a node +subgraph = DiagramTransforms.extract_subgraph( + diagram, root_node_id="start", max_depth=3 +) +``` + +**Merging Diagrams**: +```python +# Merge multiple diagrams +merged = DiagramTransforms.merge_diagrams( + [diagram1, diagram2], title="Combined Flow" +) +``` + +**Cycle Detection**: +```python +# Find all cycles in the diagram +cycles = DiagramTransforms.find_cycles(diagram) +if cycles: + print(f"Found {len(cycles)} cycle(s)") +``` + +**Statistics**: +```python +# Get comprehensive diagram statistics +stats = DiagramTransforms.get_statistics(diagram) +print(f"Nodes: {stats['node_count']}") +print(f"Edges: {stats['edge_count']}") +print(f"Has cycles: {stats['has_cycles']}") +print(f"Node types: {stats['node_types']}") +``` + +**Other Transformations**: +- `reverse_edges()` - Reverse all edge directions +- `merge_sequential_nodes()` - Combine linear sequences + +### 4. Multi-Format Workflows + +Seamlessly convert between all supported formats. + +**Supported Formats**: +- **Mermaid**: GitHub/GitLab native, excellent browser support +- **PlantUML**: Enterprise standard, extensive features +- **D2**: Modern, beautiful diagrams with scripting +- **Graphviz/DOT**: Classic graph visualization, 30+ year foundation + +**Example Workflow**: +```python +from ij import ( + MermaidParser, + D2Renderer, + PlantUMLRenderer, + GraphvizRenderer +) + +# Start with Mermaid +mermaid_code = "flowchart TD\n A --> B" +diagram = MermaidParser().parse(mermaid_code) + +# Convert to any format +d2_output = D2Renderer().render(diagram) +plantuml_output = PlantUMLRenderer().render(diagram) +dot_output = GraphvizRenderer().render(diagram) +``` + +## API Reference + +### D2Parser + +```python +class D2Parser: + def parse(self, d2_text: str) -> DiagramIR: + """Parse D2 syntax to DiagramIR.""" + + def parse_file(self, filename: str) -> DiagramIR: + """Parse D2 file to DiagramIR.""" +``` + +### SequenceDiagramRenderer + +```python +class SequenceDiagramRenderer: + def render(self, diagram: DiagramIR) -> str: + """Render DiagramIR as Mermaid sequence diagram.""" + + def render_with_notes( + self, diagram: DiagramIR, notes: Dict[str, List[str]] + ) -> str: + """Render with participant notes.""" + + def render_with_activations( + self, diagram: DiagramIR, activations: List[tuple] + ) -> str: + """Render with participant activations.""" +``` + +### InteractionAnalyzer + +```python +class InteractionAnalyzer: + def analyze_function_calls(self, caller: str, code: str) -> DiagramIR: + """Analyze code to create sequence diagram.""" + + def from_text_description(self, text: str) -> DiagramIR: + """Create sequence diagram from text description.""" +``` + +### DiagramTransforms + +```python +class DiagramTransforms: + @staticmethod + def simplify(diagram: DiagramIR, remove_isolated: bool = True) -> DiagramIR: + """Simplify diagram by removing redundant elements.""" + + @staticmethod + def filter_by_node_type( + diagram: DiagramIR, + node_types: List[NodeType], + keep: bool = True + ) -> DiagramIR: + """Filter diagram by node types.""" + + @staticmethod + def extract_subgraph( + diagram: DiagramIR, + root_node_id: str, + max_depth: Optional[int] = None + ) -> DiagramIR: + """Extract subgraph from root node.""" + + @staticmethod + def merge_diagrams( + diagrams: List[DiagramIR], + title: Optional[str] = None + ) -> DiagramIR: + """Merge multiple diagrams.""" + + @staticmethod + def find_cycles(diagram: DiagramIR) -> List[List[str]]: + """Find all cycles in the diagram.""" + + @staticmethod + def get_statistics(diagram: DiagramIR) -> dict: + """Get comprehensive diagram statistics.""" + + @staticmethod + def apply_node_filter( + diagram: DiagramIR, + predicate: Callable[[Node], bool] + ) -> DiagramIR: + """Filter nodes using custom predicate.""" + + @staticmethod + def reverse_edges(diagram: DiagramIR) -> DiagramIR: + """Reverse all edge directions.""" + + @staticmethod + def merge_sequential_nodes( + diagram: DiagramIR, + separator: str = " → " + ) -> DiagramIR: + """Merge sequential nodes into single nodes.""" +``` + +## Examples + +See `examples/phase4_features.py` for comprehensive examples including: + +1. **Bidirectional D2 Conversion** - Parse and render D2 diagrams +2. **Sequence Diagrams** - Generate interaction diagrams +3. **Code to Sequence** - Analyze Python code for interactions +4. **Text to Sequence** - Parse natural language descriptions +5. **Diagram Transformations** - Simplify and optimize diagrams +6. **Filtering & Extraction** - Extract relevant subgraphs +7. **Merging Diagrams** - Combine multiple flows +8. **Cycle Detection** - Find circular dependencies +9. **Multi-Format Workflows** - Convert between formats +10. **Custom Filtering** - Advanced filtering with predicates + +## Testing + +Phase 4 includes 51 new tests: +- **16 tests** for D2 parser +- **15 tests** for sequence diagrams +- **20 tests** for diagram transformations + +Run tests: +```bash +pytest tests/test_d2_parser.py -v +pytest tests/test_sequence.py -v +pytest tests/test_transforms.py -v +``` + +Total test count: **124 tests** (122 passing, 2 optional AI tests) + +## Use Cases + +### 1. Documentation Generation +- Parse existing D2/Mermaid diagrams +- Transform and optimize +- Export to multiple formats for different audiences + +### 2. Code Documentation +- Analyze Python code for interactions +- Generate sequence diagrams automatically +- Visualize system architecture + +### 3. Diagram Optimization +- Simplify complex diagrams +- Remove redundant elements +- Extract relevant portions + +### 4. System Analysis +- Detect circular dependencies +- Calculate complexity metrics +- Filter by component type + +### 5. Multi-Team Collaboration +- Convert between team-preferred formats +- Merge partial diagrams from different teams +- Maintain consistency across formats + +## Performance + +All transformations operate on DiagramIR in-memory: +- **Parsing**: O(n) where n is input size +- **Simplification**: O(n + m) where m is edges +- **Filtering**: O(n) +- **Subgraph extraction**: O(n + m) with BFS +- **Cycle detection**: O(n + m) with DFS +- **Statistics**: O(n + m) + +## Limitations + +1. **D2 Parser**: Supports basic D2 syntax; advanced styling not fully supported +2. **Sequence Diagrams**: No support for alt/opt/loop blocks yet +3. **Merge Sequential**: Basic implementation, may not handle all edge cases +4. **Text Parsing**: Simple regex-based, may miss complex sentence structures + +## Future Enhancements + +Potential Phase 5 features: +- Interactive diagram editing +- Real-time collaboration +- Advanced layout algorithms +- Diagram diff and merge +- Version control integration +- Web-based editor +- Diagram animation +- Export to image formats (PNG, SVG) + +## Migration from Phase 3 + +Phase 4 is fully backward compatible with Phase 3. No breaking changes. + +New imports: +```python +from ij import ( + # New in Phase 4 + D2Parser, + SequenceDiagramRenderer, + InteractionAnalyzer, + DiagramTransforms, + + # Existing from previous phases + DiagramIR, + MermaidParser, + MermaidRenderer, + # ... etc +) +``` + +## Contributing + +To add new transformations: + +1. Add method to `DiagramTransforms` class in `ij/transforms.py` +2. Add tests in `tests/test_transforms.py` +3. Add example in `examples/phase4_features.py` +4. Update this documentation + +To add new diagram types: + +1. Create parser in `ij/parsers/` +2. Create renderer in `ij/renderers/` +3. Add tests +4. Update examples + +## License + +MIT License - see LICENSE file for details diff --git a/README.md b/README.md index aa542df..49fab4a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,344 @@ -# ij -Idea Junction - Connect of vague ideas and evolve them to fully functional systems +# ij - Idea Junction + +**Connect vague ideas and evolve them to fully functional systems** + +Idea Junction (ij) is a bidirectional diagramming system that enables seamless movement between natural language, visual diagrams, and code. Built on research into modern diagramming tools and best practices, it provides a foundation for transforming ideas into structured, analyzable diagrams. + +## Features + +### Phase 1 (Core) ✅ +- **Text-to-Diagram Conversion**: Convert simple text descriptions into diagrams +- **Intermediate Representation (IR)**: AST-like structure for bidirectional conversion +- **Graph Analysis**: Powered by NetworkX for path finding, cycle detection, and graph manipulation +- **Mermaid Support**: Full rendering support for GitHub-native diagrams +- **CLI & Python API**: Use as a command-line tool or integrate into your Python projects +- **Type-Safe**: Full type hints and validation + +### Phase 2 (Bidirectional & Multi-Format) ✅ +- **Mermaid Parser**: Parse Mermaid diagrams back to IR (true bidirectional conversion) +- **PlantUML Renderer**: Export to PlantUML for enterprise UML diagrams +- **D2 Renderer**: Export to D2 (Terrastruct) for modern, beautiful diagrams +- **Graphviz Renderer**: Export to DOT format with multiple layout engines +- **Enhanced Text Converter**: Support for conditionals, parallel flows, and loops +- **Format Conversion**: Convert between Mermaid, PlantUML, D2, and Graphviz + +### Phase 3 (AI & Code Analysis) ✅ +- **AI/LLM Integration**: Natural language to diagrams using OpenAI API (10-20x faster) +- **Python Code Analysis**: Reverse engineer flowcharts from Python functions +- **Call Graph Generation**: Visualize function dependencies +- **Class Diagrams**: Generate from Python classes with inheritance +- **Iterative Refinement**: Conversational diagram improvement with AI +- **Hybrid Workflows**: Combine code analysis with AI enhancement + +### Phase 4 (Advanced Features) ✅ +- **Bidirectional D2**: Parse and render D2 diagrams (complete format support) +- **Sequence Diagrams**: Generate Mermaid sequence diagrams for interactions +- **Interaction Analysis**: Extract sequence diagrams from code and text +- **Diagram Transformations**: Simplify, filter, merge, and optimize diagrams +- **Cycle Detection**: Find circular dependencies and loops +- **Subgraph Extraction**: Extract relevant portions of large diagrams +- **Multi-Format Workflows**: Seamlessly convert between all supported formats +- **Statistics & Analysis**: Comprehensive diagram metrics and insights + +## Installation + +```bash +pip install ij +``` + +Or install from source: + +```bash +git clone https://github.com/i2mint/ij +cd ij +pip install -e . +``` + +## Quick Start + +### Command Line + +```bash +# Convert text to Mermaid diagram +ij "Start -> Process data -> Make decision -> End" + +# Save to file +ij "Step 1 -> Step 2 -> Step 3" -o diagram.mmd + +# Specify direction +ij "A -> B -> C" -d LR -o horizontal.mmd + +# Read from file +ij -f process.txt -o output.mmd +``` + +### Python API + +```python +from ij import text_to_mermaid + +# Simple conversion +mermaid = text_to_mermaid("Start -> Process -> End") +print(mermaid) +``` + +Output: +```mermaid +flowchart TD + n0([Start]) + n1[Process] + n2([End]) + n0 --> n1 + n1 --> n2 +``` + +### Manual Diagram Creation + +```python +from ij import DiagramIR, Node, Edge, NodeType, MermaidRenderer + +# Create diagram programmatically +diagram = DiagramIR(metadata={"title": "My Process"}) + +diagram.add_node(Node(id="start", label="Start", node_type=NodeType.START)) +diagram.add_node(Node(id="process", label="Do work", node_type=NodeType.PROCESS)) +diagram.add_node(Node(id="end", label="End", node_type=NodeType.END)) + +diagram.add_edge(Edge(source="start", target="process")) +diagram.add_edge(Edge(source="process", target="end")) + +# Render to Mermaid +renderer = MermaidRenderer(direction="LR") +print(renderer.render(diagram)) +``` + +### Graph Analysis + +```python +from ij import DiagramIR, Node, Edge +from ij.graph_ops import GraphOperations + +# Create a workflow +diagram = DiagramIR() +diagram.add_node(Node(id="a", label="Start")) +diagram.add_node(Node(id="b", label="Task 1")) +diagram.add_node(Node(id="c", label="Task 2")) +diagram.add_node(Node(id="d", label="End")) + +diagram.add_edge(Edge(source="a", target="b")) +diagram.add_edge(Edge(source="b", target="c")) +diagram.add_edge(Edge(source="c", target="d")) +diagram.add_edge(Edge(source="a", target="d")) # Shortcut path + +# Find all paths +paths = GraphOperations.find_paths(diagram, "a", "d") +print(f"Found {len(paths)} paths") # Output: Found 2 paths + +# Get topological order +order = GraphOperations.topological_sort(diagram) +print(order) # Output: ['a', 'b', 'c', 'd'] + +# Simplify (remove redundant edges) +simplified = GraphOperations.simplify_diagram(diagram) +``` + +## Architecture + +Based on comprehensive research into bidirectional diagramming systems, ij uses: + +- **AST-based IR**: Core data structure for diagram representation +- **Graph Model**: NetworkX for analysis and transformation +- **Extensible Renderers**: Pluggable output formats (Mermaid, PlantUML, D2, etc.) +- **Text-based DSL**: Git-friendly, version-controllable diagram source + +``` +Natural Language → DiagramIR → Mermaid/PlantUML/D2 + ↓ + NetworkX Graph + ↓ + Analysis & Transformation +``` + +## Node Types + +ij supports several node types with automatic inference: + +- `START`: Beginning of a process (stadium shape in Mermaid) +- `END`: End of a process (stadium shape) +- `PROCESS`: Processing step (rectangle) +- `DECISION`: Decision point (diamond) +- `DATA`: Data storage (cylinder) +- `SUBPROCESS`: Sub-process (double rectangle) + +Keywords like "Start", "End", "decide", "database" automatically set the correct type. + +## Examples + +See the [examples/](examples/) directory for comprehensive usage examples: + +```bash +# Phase 1: Basic features +python examples/basic_usage.py + +# Phase 2: Bidirectional conversion and multiple formats +python examples/phase2_features.py +``` + +**Phase 1 Examples:** +- Simple text-to-diagram conversion +- Manual diagram creation +- Graph analysis (path finding, topological sort) +- Different diagram directions +- Natural language processing +- Saving diagrams to files + +**Phase 2 Examples:** +- Bidirectional conversion (Mermaid ↔ IR) +- Multi-format rendering (Mermaid, PlantUML, D2, Graphviz) +- Enhanced text conversion with conditionals +- Parallel flows and loops +- Format conversion workflows +- Complex workflow examples + +**Phase 3 Examples:** +- Python code → flowchart diagrams +- Call graph generation +- Class diagram generation +- AI-powered natural language → diagram +- Iterative refinement with AI +- Hybrid code + AI workflows + +See [PHASE2.md](PHASE2.md) and [PHASE3.md](PHASE3.md) for complete documentation. + +## Viewing Diagrams + +Generated Mermaid diagrams can be viewed in: + +1. **GitHub/GitLab** - Paste in markdown files (native support) +2. **Mermaid Live Editor** - https://mermaid.live/ +3. **VS Code** - Install Mermaid preview extension +4. **Command line** - Use `mmdc` (Mermaid CLI) + +Example in markdown: + +````markdown +```mermaid +flowchart TD + A[Start] --> B{Decision?} + B -->|Yes| C[Process] + B -->|No| D[Skip] + C --> E[End] + D --> E +``` +```` + +## Development + +### Running Tests + +```bash +pip install -e ".[dev]" +pytest tests/ -v +``` + +All tests should pass: +``` +71 passed in 1.41s +``` + +Test breakdown: +- Phase 1: 26 tests (core, renderers, converters, graph operations) +- Phase 2: 26 tests (parsers, new renderers, enhanced converter) +- Phase 3: 19 tests (AI mocks, Python analyzer, +2 optional real API tests) + +**Note:** AI tests use mocks by default. For real OpenAI API tests: +```bash +export OPENAI_API_KEY=your-key +pytest tests/test_llm_converter.py -v # Includes real API tests +``` + +### Code Quality + +```bash +# Run linter +ruff check ij/ + +# Format code +ruff format ij/ +``` + +## Roadmap + +### Phase 1 (Core Foundation) ✅ +- [x] Core DiagramIR architecture +- [x] Mermaid renderer +- [x] NetworkX integration +- [x] CLI interface +- [x] Basic text-to-diagram conversion +- [x] Comprehensive tests (26 tests) + +### Phase 2 (Bidirectional & Multi-Format) ✅ +- [x] Mermaid parser (Mermaid → IR bidirectional) +- [x] PlantUML renderer +- [x] D2 renderer +- [x] Graphviz/DOT renderer +- [x] Enhanced text converter (conditionals, parallel, loops) +- [x] Comprehensive tests (52 total tests) +- [x] Multi-format examples + +### Phase 3 (AI & Code Analysis) ✅ +- [x] AI/LLM integration with OpenAI API +- [x] Python code-to-diagram reverse engineering +- [x] Function flowchart generation +- [x] Call graph visualization +- [x] Class diagram generation +- [x] Iterative refinement with AI +- [x] Comprehensive tests (71 total tests, including AI mocks) +- [x] Optional real API tests + +### Phase 4 (Future) +- [ ] Visual editor integration +- [ ] Real-time collaboration (CRDT-based) +- [ ] Java/JavaScript code analysis +- [ ] Additional parsers (PlantUML, D2 → IR) +- [ ] Web-based interactive editor +- [ ] Local LLM support (Ollama, etc.) +- [ ] Sequence diagram generation + +## Research Foundation + +This project is built on comprehensive research into: + +- Diagram-as-code languages (Mermaid, PlantUML, D2, Graphviz) +- Python libraries (NetworkX, diagrams, graphviz) +- JavaScript frameworks (React Flow, Cytoscape.js) +- Bidirectional editing patterns +- AI-powered generation +- Technical architecture patterns + +See [misc/REASEARCH.md](misc/REASEARCH.md) for the full research report. + +## Contributing + +Contributions are welcome! Please: + +1. Fork the repository +2. Create a feature branch +3. Add tests for new functionality +4. Ensure all tests pass +5. Submit a pull request + +## License + +MIT License - see LICENSE file for details + +## Links + +- **Repository**: https://github.com/i2mint/ij +- **Issues**: https://github.com/i2mint/ij/issues +- **Mermaid Docs**: https://mermaid.js.org +- **NetworkX Docs**: https://networkx.org + +--- + +**Idea Junction** - From vague ideas to fully functional systems diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..a4ab7a1 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,44 @@ +# Idea Junction Examples + +This directory contains examples demonstrating various features of Idea Junction. + +## Running the Examples + +```bash +# Install the package first +pip install -e . + +# Run the basic usage examples +python examples/basic_usage.py + +# Or run individual functions +python -c "from examples.basic_usage import example1_simple_conversion; example1_simple_conversion()" +``` + +## Examples Overview + +### basic_usage.py + +Demonstrates the core functionality: + +1. **Simple text-to-diagram conversion** - Quick conversion from text to Mermaid +2. **Manual diagram creation** - Building diagrams programmatically +3. **Graph analysis** - Finding paths and analyzing diagram structure +4. **Different directions** - TD, LR, BT, RL layouts +5. **Natural language** - Converting ideas to diagrams +6. **Saving to file** - Exporting diagrams + +## Viewing the Diagrams + +The generated Mermaid diagrams can be viewed in several ways: + +1. **GitHub/GitLab** - Paste in markdown files (native support) +2. **Mermaid Live Editor** - https://mermaid.live/ +3. **VS Code** - Use the Mermaid extension +4. **Command line** - Use `mmdc` (Mermaid CLI) + +Example: Viewing in Mermaid Live Editor: +1. Copy the generated Mermaid code +2. Go to https://mermaid.live/ +3. Paste the code +4. Export as PNG/SVG if needed diff --git a/examples/basic_usage.py b/examples/basic_usage.py new file mode 100644 index 0000000..1c9f069 --- /dev/null +++ b/examples/basic_usage.py @@ -0,0 +1,176 @@ +"""Basic usage examples for Idea Junction.""" + +from ij import ( + DiagramIR, + Edge, + EdgeType, + MermaidRenderer, + Node, + NodeType, + SimpleTextConverter, + text_to_mermaid, +) + + +def example1_simple_conversion(): + """Example 1: Simple text-to-diagram conversion.""" + print("=" * 60) + print("Example 1: Simple text-to-diagram conversion") + print("=" * 60) + + text = "Start -> Process data -> Make decision -> End" + mermaid = text_to_mermaid(text) + + print(f"Input: {text}") + print("\nGenerated Mermaid diagram:") + print(mermaid) + print() + + +def example2_manual_diagram_creation(): + """Example 2: Manually create a diagram using DiagramIR.""" + print("=" * 60) + print("Example 2: Manual diagram creation") + print("=" * 60) + + # Create diagram + diagram = DiagramIR(metadata={"title": "User Registration Flow"}) + + # Add nodes + diagram.add_node(Node(id="start", label="User visits site", node_type=NodeType.START)) + diagram.add_node( + Node(id="check", label="Has account?", node_type=NodeType.DECISION) + ) + diagram.add_node(Node(id="login", label="Login", node_type=NodeType.PROCESS)) + diagram.add_node(Node(id="register", label="Register", node_type=NodeType.PROCESS)) + diagram.add_node( + Node(id="save", label="Save to database", node_type=NodeType.DATA) + ) + diagram.add_node(Node(id="end", label="Welcome page", node_type=NodeType.END)) + + # Add edges + diagram.add_edge(Edge(source="start", target="check")) + diagram.add_edge(Edge(source="check", target="login", label="Yes")) + diagram.add_edge(Edge(source="check", target="register", label="No")) + diagram.add_edge(Edge(source="register", target="save")) + diagram.add_edge(Edge(source="login", target="end")) + diagram.add_edge(Edge(source="save", target="end")) + + # Render to Mermaid + renderer = MermaidRenderer(direction="TD") + mermaid = renderer.render(diagram) + + print("Generated Mermaid diagram:") + print(mermaid) + print() + + +def example3_graph_analysis(): + """Example 3: Graph analysis using NetworkX integration.""" + print("=" * 60) + print("Example 3: Graph analysis") + print("=" * 60) + + from ij.graph_ops import GraphOperations + + # Create a workflow diagram + diagram = DiagramIR() + diagram.add_node(Node(id="a", label="Start")) + diagram.add_node(Node(id="b", label="Step 1")) + diagram.add_node(Node(id="c", label="Step 2")) + diagram.add_node(Node(id="d", label="Step 3")) + diagram.add_node(Node(id="e", label="End")) + + diagram.add_edge(Edge(source="a", target="b")) + diagram.add_edge(Edge(source="b", target="c")) + diagram.add_edge(Edge(source="b", target="d")) + diagram.add_edge(Edge(source="c", target="e")) + diagram.add_edge(Edge(source="d", target="e")) + + # Analyze the diagram + paths = GraphOperations.find_paths(diagram, "a", "e") + print(f"All paths from Start to End:") + for i, path in enumerate(paths, 1): + path_labels = [diagram.get_node(nid).label for nid in path] + print(f" Path {i}: {' → '.join(path_labels)}") + + topo_order = GraphOperations.topological_sort(diagram) + if topo_order: + print(f"\nTopological order: {' → '.join(topo_order)}") + print() + + +def example4_different_directions(): + """Example 4: Different diagram directions.""" + print("=" * 60) + print("Example 4: Different diagram directions") + print("=" * 60) + + text = "Input -> Process -> Output" + + for direction in ["TD", "LR", "BT", "RL"]: + mermaid = text_to_mermaid(text, direction=direction) + print(f"\nDirection: {direction}") + print(mermaid) + + +def example5_from_natural_language(): + """Example 5: Converting natural language ideas to diagrams.""" + print("=" * 60) + print("Example 5: Natural language to diagrams") + print("=" * 60) + + ideas = [ + "Start the application", + "Load configuration file", + "Check if database is ready", + "Initialize services", + "Start web server", + "End setup", + ] + + # Join with arrows + text = " -> ".join(ideas) + + converter = SimpleTextConverter() + diagram = converter.convert(text, title="Application Startup") + + renderer = MermaidRenderer() + mermaid = renderer.render(diagram) + + print("Ideas:") + for i, idea in enumerate(ideas, 1): + print(f" {i}. {idea}") + + print("\nGenerated diagram:") + print(mermaid) + print() + + +def example6_saving_to_file(): + """Example 6: Save diagram to file.""" + print("=" * 60) + print("Example 6: Saving to file") + print("=" * 60) + + text = "Design -> Implement -> Test -> Deploy" + converter = SimpleTextConverter() + diagram = converter.convert(text, title="Software Development Cycle") + + renderer = MermaidRenderer() + output_file = "/tmp/diagram.mmd" + renderer.render_to_file(diagram, output_file) + + print(f"Diagram saved to: {output_file}") + print("\nFile contents:") + with open(output_file) as f: + print(f.read()) + + +if __name__ == "__main__": + example1_simple_conversion() + example2_manual_diagram_creation() + example3_graph_analysis() + example4_different_directions() + example5_from_natural_language() + example6_saving_to_file() diff --git a/examples/phase2_features.py b/examples/phase2_features.py new file mode 100644 index 0000000..fad9c4b --- /dev/null +++ b/examples/phase2_features.py @@ -0,0 +1,227 @@ +"""Phase 2 feature examples: Bidirectional conversion and multiple formats.""" + +from ij import DiagramIR, Node, Edge, NodeType +from ij.renderers import MermaidRenderer, PlantUMLRenderer, D2Renderer, GraphvizRenderer +from ij.parsers import MermaidParser +from ij.converters import EnhancedTextConverter + + +def example1_bidirectional_conversion(): + """Example 1: Bidirectional conversion - Mermaid to IR and back.""" + print("=" * 60) + print("Example 1: Bidirectional Conversion") + print("=" * 60) + + # Original Mermaid diagram + original_mermaid = """ +flowchart TD + n1([Start]) + n2[Process Data] + n3{Valid?} + n4[Save] + n5([End]) + n1 --> n2 + n2 --> n3 + n3 -->|Yes| n4 + n3 -->|No| n5 + n4 --> n5 +""" + + print("Original Mermaid:") + print(original_mermaid) + + # Parse to IR + parser = MermaidParser() + diagram = parser.parse(original_mermaid) + + print(f"\nParsed to DiagramIR:") + print(f" Nodes: {len(diagram.nodes)}") + print(f" Edges: {len(diagram.edges)}") + + # Render back to Mermaid + renderer = MermaidRenderer() + regenerated = renderer.render(diagram) + + print(f"\nRegenerated Mermaid:") + print(regenerated) + print() + + +def example2_multi_format_rendering(): + """Example 2: Render the same diagram in multiple formats.""" + print("=" * 60) + print("Example 2: Multi-Format Rendering") + print("=" * 60) + + # Create diagram + diagram = DiagramIR(metadata={"title": "User Login Flow"}) + diagram.add_node(Node(id="start", label="User visits site", node_type=NodeType.START)) + diagram.add_node(Node(id="check", label="Has account?", node_type=NodeType.DECISION)) + diagram.add_node(Node(id="login", label="Login", node_type=NodeType.PROCESS)) + diagram.add_node(Node(id="register", label="Register", node_type=NodeType.PROCESS)) + diagram.add_node(Node(id="end", label="Dashboard", node_type=NodeType.END)) + + diagram.add_edge(Edge(source="start", target="check")) + diagram.add_edge(Edge(source="check", target="login", label="Yes")) + diagram.add_edge(Edge(source="check", target="register", label="No")) + diagram.add_edge(Edge(source="login", target="end")) + diagram.add_edge(Edge(source="register", target="end")) + + # Render in all formats + formats = { + "Mermaid": MermaidRenderer(), + "PlantUML": PlantUMLRenderer(), + "D2": D2Renderer(), + "Graphviz": GraphvizRenderer(), + } + + for format_name, renderer in formats.items(): + output = renderer.render(diagram) + print(f"\n{format_name} Output:") + print("-" * 40) + print(output[:300] + "..." if len(output) > 300 else output) + + print() + + +def example3_enhanced_text_conversion(): + """Example 3: Enhanced text converter with conditionals.""" + print("=" * 60) + print("Example 3: Enhanced Text Conversion") + print("=" * 60) + + converter = EnhancedTextConverter() + + # Conditional example + text1 = "Start -> Check inventory. If available: Process order, else: Notify customer" + print(f"Input: {text1}") + + diagram1 = converter.convert(text1, title="Order Processing") + renderer = MermaidRenderer() + print(f"\nMermaid Output:") + print(renderer.render(diagram1)) + + # Parallel example + text2 = "Start -> [parallel: Send email, Update database, Log event] -> End" + print(f"\nInput: {text2}") + + diagram2 = converter.convert(text2, title="Parallel Tasks") + print(f"\nMermaid Output:") + print(renderer.render(diagram2)) + print() + + +def example4_format_conversion(): + """Example 4: Convert between different diagram formats.""" + print("=" * 60) + print("Example 4: Format Conversion") + print("=" * 60) + + # Start with Mermaid + mermaid_input = """ +flowchart LR + A[Input] --> B[Transform] + B --> C[Validate] + C --> D[Output] +""" + + print("Original Mermaid:") + print(mermaid_input) + + # Parse + parser = MermaidParser() + diagram = parser.parse(mermaid_input) + + # Convert to D2 + d2_renderer = D2Renderer() + d2_output = d2_renderer.render(diagram) + + print("\nConverted to D2:") + print(d2_output) + + # Convert to PlantUML + plantuml_renderer = PlantUMLRenderer() + plantuml_output = plantuml_renderer.render(diagram) + + print("\nConverted to PlantUML:") + print(plantuml_output) + print() + + +def example5_save_multiple_formats(): + """Example 5: Save diagram in multiple formats.""" + print("=" * 60) + print("Example 5: Save in Multiple Formats") + print("=" * 60) + + # Create diagram + from ij.converters import SimpleTextConverter + + converter = SimpleTextConverter() + diagram = converter.convert( + "Start -> Validate -> Process -> Store -> End", + title="Data Pipeline" + ) + + # Save in multiple formats + files = { + "/tmp/diagram.mmd": MermaidRenderer(), + "/tmp/diagram.puml": PlantUMLRenderer(), + "/tmp/diagram.d2": D2Renderer(), + "/tmp/diagram.dot": GraphvizRenderer(), + } + + for filename, renderer in files.items(): + renderer.render_to_file(diagram, filename) + print(f"Saved: {filename}") + + print("\nAll formats saved to /tmp/") + print() + + +def example6_complex_workflow(): + """Example 6: Complex workflow with enhanced converter.""" + print("=" * 60) + print("Example 6: Complex Workflow") + print("=" * 60) + + converter = EnhancedTextConverter() + + # Complex workflow with loop + text = """ + Start application. + Load configuration. + Initialize services. + while requests pending: Process request. + Shutdown gracefully. + End application + """ + + print(f"Input:") + print(text) + + diagram = converter.convert(text, title="Application Lifecycle") + + # Render in Mermaid + mermaid_renderer = MermaidRenderer() + mermaid_output = mermaid_renderer.render(diagram) + + print(f"\nMermaid Output:") + print(mermaid_output) + + # Also render in D2 for comparison + d2_renderer = D2Renderer() + d2_output = d2_renderer.render(diagram) + + print(f"\nD2 Output:") + print(d2_output) + print() + + +if __name__ == "__main__": + example1_bidirectional_conversion() + example2_multi_format_rendering() + example3_enhanced_text_conversion() + example4_format_conversion() + example5_save_multiple_formats() + example6_complex_workflow() diff --git a/examples/phase3_features.py b/examples/phase3_features.py new file mode 100644 index 0000000..fb3e488 --- /dev/null +++ b/examples/phase3_features.py @@ -0,0 +1,360 @@ +"""Phase 3 feature examples: AI/LLM Integration and Code Analysis. + +NOTE: LLM examples require OPENAI_API_KEY environment variable and the openai package: + pip install ij[ai] + export OPENAI_API_KEY=your-key-here +""" + +import os + + +def example1_python_code_analysis(): + """Example 1: Analyze Python function to create flowchart.""" + print("=" * 60) + print("Example 1: Python Code Analysis - Function Flowchart") + print("=" * 60) + + from ij.analyzers import PythonCodeAnalyzer + from ij.renderers import MermaidRenderer + + code = """ +def process_order(order): + if order.is_valid(): + if order.in_stock(): + charge_customer(order) + ship_order(order) + send_confirmation(order) + else: + send_backorder_notice(order) + else: + send_error_notification(order) + return order.status +""" + + print("Python Code:") + print(code) + + analyzer = PythonCodeAnalyzer() + diagram = analyzer.analyze_function(code) + + renderer = MermaidRenderer() + mermaid = renderer.render(diagram) + + print("\nGenerated Flowchart (Mermaid):") + print(mermaid) + print() + + +def example2_call_graph(): + """Example 2: Generate call graph from Python module.""" + print("=" * 60) + print("Example 2: Python Module Call Graph") + print("=" * 60) + + from ij.analyzers import PythonCodeAnalyzer + from ij.renderers import D2Renderer + + code = """ +def load_config(): + return read_file("config.json") + +def initialize(): + config = load_config() + setup_database(config) + setup_logging(config) + +def main(): + initialize() + run_app() +""" + + print("Python Code:") + print(code) + + analyzer = PythonCodeAnalyzer() + diagram = analyzer.analyze_module_calls(code) + + # Render as D2 for nice visualization + renderer = D2Renderer() + d2 = renderer.render(diagram) + + print("\nGenerated Call Graph (D2):") + print(d2) + print() + + +def example3_class_diagram(): + """Example 3: Generate class diagram from Python class.""" + print("=" * 60) + print("Example 3: Python Class Diagram") + print("=" * 60) + + from ij.analyzers import PythonCodeAnalyzer + from ij.renderers import MermaidRenderer + + code = """ +class BaseHandler: + def handle(self): + pass + +class AuthHandler(BaseHandler): + def __init__(self): + self.auth_service = None + + def handle(self): + return self.authenticate() + + def authenticate(self): + pass +""" + + print("Python Code:") + print(code) + + analyzer = PythonCodeAnalyzer() + diagram = analyzer.analyze_class(code, class_name="AuthHandler") + + renderer = MermaidRenderer() + mermaid = renderer.render(diagram) + + print("\nGenerated Class Diagram (Mermaid):") + print(mermaid) + print() + + +def example4_llm_conversion(): + """Example 4: AI-powered natural language to diagram (requires OpenAI API key).""" + print("=" * 60) + print("Example 4: AI/LLM-Powered Diagram Generation") + print("=" * 60) + + if not os.environ.get("OPENAI_API_KEY"): + print("⚠️ Skipping - OPENAI_API_KEY not set") + print( + "To run this example, set your OpenAI API key:\n" + " export OPENAI_API_KEY=your-key-here\n" + ) + return + + try: + from ij.converters import LLMConverter + from ij.renderers import MermaidRenderer + except ImportError: + print("⚠️ Skipping - OpenAI package not installed") + print("Install with: pip install ij[ai]\n") + return + + description = """ + A user visits an e-commerce website. They browse products and add items to + their cart. When ready to checkout, the system checks if they're logged in. + If not, they must login or create an account. After authentication, they + proceed to payment. If payment succeeds, the order is confirmed and items + are shipped. If payment fails, an error is shown. + """ + + print(f"Natural Language Description:{description}") + + print("\nGenerating diagram with AI (using gpt-4o-mini)...") + + try: + converter = LLMConverter(model="gpt-4o-mini", temperature=0.3) + diagram = converter.convert(description, title="E-commerce Checkout") + + renderer = MermaidRenderer() + mermaid = renderer.render(diagram) + + print("\nAI-Generated Diagram (Mermaid):") + print(mermaid) + + print("\n✅ Success! The AI understood the process and created a diagram.") + print( + f" Generated {len(diagram.nodes)} nodes and {len(diagram.edges)} edges." + ) + except Exception as e: + print(f"\n❌ Error: {e}") + + print() + + +def example5_llm_refinement(): + """Example 5: Iterative diagram refinement with AI.""" + print("=" * 60) + print("Example 5: AI-Powered Diagram Refinement") + print("=" * 60) + + if not os.environ.get("OPENAI_API_KEY"): + print("⚠️ Skipping - OPENAI_API_KEY not set\n") + return + + try: + from ij.converters import LLMConverter + from ij.renderers import MermaidRenderer + except ImportError: + print("⚠️ Skipping - OpenAI package not installed\n") + return + + print("Initial description: User login process") + + try: + converter = LLMConverter(model="gpt-4o-mini", temperature=0.3) + diagram = converter.convert("User login process") + + renderer = MermaidRenderer() + initial_mermaid = renderer.render(diagram) + + print("\nInitial Diagram:") + print(initial_mermaid) + + print("\nRefining with feedback: 'Add password reset option'...") + + refined = converter.refine( + diagram, + "Add a password reset option if login fails", + initial_mermaid, + ) + + refined_mermaid = renderer.render(refined) + + print("\nRefined Diagram:") + print(refined_mermaid) + + print("\n✅ Diagram updated based on feedback!") + except Exception as e: + print(f"\n❌ Error: {e}") + + print() + + +def example6_code_to_multiple_formats(): + """Example 6: Analyze code and export to multiple diagram formats.""" + print("=" * 60) + print("Example 6: Code Analysis → Multiple Formats") + print("=" * 60) + + from ij.analyzers import PythonCodeAnalyzer + from ij.renderers import MermaidRenderer, PlantUMLRenderer, D2Renderer + + code = """ +def validate_user(username, password): + if not username: + return False + if not password: + return False + if check_credentials(username, password): + log_success(username) + return True + else: + log_failure(username) + return False +""" + + print("Python Code:") + print(code) + + analyzer = PythonCodeAnalyzer() + diagram = analyzer.analyze_function(code) + + print("\nGenerated diagrams in multiple formats:\n") + + # Mermaid + mermaid = MermaidRenderer().render(diagram) + print("1. Mermaid (for GitHub/GitLab):") + print(mermaid[:200] + "...\n") + + # PlantUML + plantuml = PlantUMLRenderer().render(diagram) + print("2. PlantUML (for enterprise):") + print(plantuml[:200] + "...\n") + + # D2 + d2 = D2Renderer().render(diagram) + print("3. D2 (modern/beautiful):") + print(d2[:200] + "...\n") + + print( + "✅ Same diagram, three formats! Use whichever fits your workflow best." + ) + print() + + +def example7_combined_workflow(): + """Example 7: Combined workflow - Code analysis + AI enhancement.""" + print("=" * 60) + print("Example 7: Hybrid Workflow (Code + AI)") + print("=" * 60) + + from ij.analyzers import PythonCodeAnalyzer + from ij.renderers import MermaidRenderer + + # Step 1: Analyze existing code + code = """ +def checkout(): + cart = get_cart() + total = calculate_total(cart) + process_payment(total) +""" + + print("Step 1: Analyze existing code") + print(code) + + analyzer = PythonCodeAnalyzer() + diagram = analyzer.analyze_function(code) + + renderer = MermaidRenderer() + mermaid = renderer.render(diagram) + + print("\nGenerated diagram:") + print(mermaid) + + # Step 2: Use AI to enhance if available + if os.environ.get("OPENAI_API_KEY"): + try: + from ij.converters import LLMConverter + + print( + "\nStep 2: Enhance with AI - adding error handling..." + ) + + converter = LLMConverter(model="gpt-4o-mini") + enhanced = converter.refine( + diagram, + "Add error handling for payment failures and empty cart scenarios", + mermaid, + ) + + enhanced_mermaid = renderer.render(enhanced) + print("\nAI-Enhanced Diagram:") + print(enhanced_mermaid) + except ImportError: + print("\nStep 2: Install openai package to enable AI enhancement") + else: + print("\nStep 2: Set OPENAI_API_KEY to enable AI enhancement") + + print() + + +if __name__ == "__main__": + # Code analysis examples (always work) + example1_python_code_analysis() + example2_call_graph() + example3_class_diagram() + example6_code_to_multiple_formats() + example7_combined_workflow() + + # AI examples (require API key) + example4_llm_conversion() + example5_llm_refinement() + + print("=" * 60) + print("Phase 3 Examples Complete!") + print("=" * 60) + print("\nKey Features Demonstrated:") + print("✅ Python code → flowchart diagrams") + print("✅ Call graph generation") + print("✅ Class diagram generation") + print("✅ AI-powered natural language → diagram") + print("✅ Iterative refinement with AI") + print("✅ Multi-format export") + print("✅ Hybrid code + AI workflows") + print("\nFor AI features, install: pip install ij[ai]") + print("And set: export OPENAI_API_KEY=your-key-here") diff --git a/examples/phase4_features.py b/examples/phase4_features.py new file mode 100644 index 0000000..b8c2a20 --- /dev/null +++ b/examples/phase4_features.py @@ -0,0 +1,425 @@ +"""Phase 4 feature examples: Advanced Diagramming Features. + +Demonstrates: +- Bidirectional D2 conversion +- Sequence diagram generation +- Diagram transformations and optimization +- Multi-format workflows +""" + + +def example1_d2_bidirectional(): + """Example 1: Bidirectional D2 conversion.""" + print("=" * 60) + print("Example 1: Bidirectional D2 Conversion") + print("=" * 60) + + from ij import D2Parser, D2Renderer, MermaidRenderer + + # Start with D2 code + d2_code = """ +direction: right + +start: "Begin" { + shape: oval +} + +validate: "Validate Input" { + shape: diamond +} + +process: "Process Data" { + shape: rectangle +} + +db: "Save to Database" { + shape: cylinder +} + +end: "Complete" { + shape: oval +} + +start -> validate +validate -> process: "Valid" +validate -> end: "Invalid" +process -> db +db -> end +""" + + print("Original D2 Code:") + print(d2_code) + + # Parse D2 to DiagramIR + parser = D2Parser() + diagram = parser.parse(d2_code) + + print(f"\nParsed DiagramIR: {len(diagram.nodes)} nodes, {len(diagram.edges)} edges") + + # Render to Mermaid + mermaid_renderer = MermaidRenderer() + mermaid_code = mermaid_renderer.render(diagram) + + print("\nConverted to Mermaid:") + print(mermaid_code) + + # Render back to D2 + d2_renderer = D2Renderer() + d2_roundtrip = d2_renderer.render(diagram) + + print("\nRoundtrip back to D2:") + print(d2_roundtrip) + print() + + +def example2_sequence_diagrams(): + """Example 2: Sequence diagram generation.""" + print("=" * 60) + print("Example 2: Sequence Diagram Generation") + print("=" * 60) + + from ij import DiagramIR, Edge, EdgeType, Node, SequenceDiagramRenderer + + # Create a sequence diagram for an API call + diagram = DiagramIR(metadata={"title": "User Authentication Flow"}) + + # Add participants + diagram.add_node(Node(id="user", label="User")) + diagram.add_node(Node(id="frontend", label="Frontend")) + diagram.add_node(Node(id="api", label="API")) + diagram.add_node(Node(id="db", label="Database")) + + # Add interactions + diagram.add_edge(Edge(source="user", target="frontend", label="Enter credentials")) + diagram.add_edge(Edge(source="frontend", target="api", label="POST /login")) + diagram.add_edge(Edge(source="api", target="db", label="Check credentials")) + diagram.add_edge( + Edge(source="db", target="api", label="User found", edge_type=EdgeType.CONDITIONAL) + ) + diagram.add_edge( + Edge(source="api", target="frontend", label="200 OK + token", edge_type=EdgeType.CONDITIONAL) + ) + diagram.add_edge( + Edge(source="frontend", target="user", label="Welcome!", edge_type=EdgeType.CONDITIONAL) + ) + + # Render as Mermaid sequence diagram + renderer = SequenceDiagramRenderer() + mermaid = renderer.render(diagram) + + print("Generated Sequence Diagram (Mermaid):") + print(mermaid) + print() + + +def example3_interaction_from_code(): + """Example 3: Generate sequence diagram from code.""" + print("=" * 60) + print("Example 3: Code to Sequence Diagram") + print("=" * 60) + + from ij import InteractionAnalyzer, SequenceDiagramRenderer + + # Python code with method calls + code = """ +user.authenticate(credentials) +session.create(user_id) +cache.store(session_token) +logger.log("User logged in", user_id) +""" + + print("Python Code:") + print(code) + + # Analyze to create sequence diagram + analyzer = InteractionAnalyzer() + diagram = analyzer.analyze_function_calls("app", code) + + # Render + renderer = SequenceDiagramRenderer() + mermaid = renderer.render(diagram) + + print("\nGenerated Sequence Diagram:") + print(mermaid) + print() + + +def example4_interaction_from_text(): + """Example 4: Generate sequence diagram from natural language.""" + print("=" * 60) + print("Example 4: Text to Sequence Diagram") + print("=" * 60) + + from ij import InteractionAnalyzer, SequenceDiagramRenderer + + text = """ + Customer sends order to WebShop. + WebShop queries Inventory. + Inventory returns availability to WebShop. + WebShop requests PaymentGateway. + PaymentGateway responds to WebShop. + WebShop confirms to Customer. + """ + + print("Natural Language Description:") + print(text) + + # Parse text to sequence diagram + analyzer = InteractionAnalyzer() + diagram = analyzer.from_text_description(text) + + # Render + renderer = SequenceDiagramRenderer() + mermaid = renderer.render(diagram) + + print("\nGenerated Sequence Diagram:") + print(mermaid) + print() + + +def example5_diagram_transformations(): + """Example 5: Diagram transformation and optimization.""" + print("=" * 60) + print("Example 5: Diagram Transformations") + print("=" * 60) + + from ij import DiagramIR, DiagramTransforms, Edge, Node, NodeType + + # Create a diagram with some issues + diagram = DiagramIR(metadata={"title": "Original Diagram"}) + diagram.add_node(Node(id="start", label="Start", node_type=NodeType.START)) + diagram.add_node(Node(id="step1", label="Step 1")) + diagram.add_node(Node(id="step2", label="Step 2")) + diagram.add_node(Node(id="isolated", label="Isolated Node")) + diagram.add_node(Node(id="end", label="End", node_type=NodeType.END)) + + diagram.add_edge(Edge(source="start", target="step1")) + diagram.add_edge(Edge(source="step1", target="step2")) + diagram.add_edge(Edge(source="step2", target="end")) + diagram.add_edge(Edge(source="start", target="step1")) # Duplicate + + print(f"Original: {len(diagram.nodes)} nodes, {len(diagram.edges)} edges") + + # Simplify: remove isolated nodes and duplicate edges + simplified = DiagramTransforms.simplify(diagram) + print(f"After simplify: {len(simplified.nodes)} nodes, {len(simplified.edges)} edges") + + # Get statistics + stats = DiagramTransforms.get_statistics(simplified) + print(f"\nDiagram Statistics:") + print(f" - Node count: {stats['node_count']}") + print(f" - Edge count: {stats['edge_count']}") + print(f" - Isolated nodes: {stats['isolated_nodes']}") + print(f" - Has cycles: {stats['has_cycles']}") + print(f" - Node types: {stats['node_types']}") + print() + + +def example6_filter_and_extract(): + """Example 6: Filtering and extracting subgraphs.""" + print("=" * 60) + print("Example 6: Filter and Extract Subgraphs") + print("=" * 60) + + from ij import DiagramIR, DiagramTransforms, Edge, Node, NodeType + + # Create a larger diagram + diagram = DiagramIR() + diagram.add_node(Node(id="start", label="Start", node_type=NodeType.START)) + diagram.add_node(Node(id="process1", label="Process 1", node_type=NodeType.PROCESS)) + diagram.add_node(Node(id="decision", label="Decision", node_type=NodeType.DECISION)) + diagram.add_node(Node(id="process2", label="Process 2", node_type=NodeType.PROCESS)) + diagram.add_node(Node(id="data", label="Database", node_type=NodeType.DATA)) + diagram.add_node(Node(id="end", label="End", node_type=NodeType.END)) + + diagram.add_edge(Edge(source="start", target="process1")) + diagram.add_edge(Edge(source="process1", target="decision")) + diagram.add_edge(Edge(source="decision", target="process2")) + diagram.add_edge(Edge(source="process2", target="data")) + diagram.add_edge(Edge(source="data", target="end")) + + print(f"Original diagram: {len(diagram.nodes)} nodes") + + # Filter: keep only PROCESS nodes + process_only = DiagramTransforms.filter_by_node_type( + diagram, [NodeType.PROCESS], keep=True + ) + print(f"After filtering to PROCESS nodes: {len(process_only.nodes)} nodes") + + # Extract subgraph from decision node with depth 2 + subgraph = DiagramTransforms.extract_subgraph(diagram, "decision", max_depth=2) + print(f"Subgraph from 'decision' (depth 2): {len(subgraph.nodes)} nodes") + print() + + +def example7_merge_diagrams(): + """Example 7: Merging multiple diagrams.""" + print("=" * 60) + print("Example 7: Merge Multiple Diagrams") + print("=" * 60) + + from ij import DiagramIR, DiagramTransforms, Edge, Node + + # Create diagram 1: User flow + user_flow = DiagramIR(metadata={"title": "User Flow"}) + user_flow.add_node(Node(id="login", label="Login")) + user_flow.add_node(Node(id="dashboard", label="Dashboard")) + user_flow.add_edge(Edge(source="login", target="dashboard")) + + # Create diagram 2: Admin flow + admin_flow = DiagramIR(metadata={"title": "Admin Flow"}) + admin_flow.add_node(Node(id="admin_login", label="Admin Login")) + admin_flow.add_node(Node(id="admin_panel", label="Admin Panel")) + admin_flow.add_edge(Edge(source="admin_login", target="admin_panel")) + + # Merge both diagrams + merged = DiagramTransforms.merge_diagrams( + [user_flow, admin_flow], title="Complete Application Flow" + ) + + print(f"User flow: {len(user_flow.nodes)} nodes") + print(f"Admin flow: {len(admin_flow.nodes)} nodes") + print(f"Merged: {len(merged.nodes)} nodes, {len(merged.edges)} edges") + print(f"Merged title: {merged.metadata.get('title')}") + print() + + +def example8_find_cycles(): + """Example 8: Detecting cycles in diagrams.""" + print("=" * 60) + print("Example 8: Cycle Detection") + print("=" * 60) + + from ij import DiagramIR, DiagramTransforms, Edge, Node + + # Create a diagram with a cycle + diagram = DiagramIR() + diagram.add_node(Node(id="a", label="A")) + diagram.add_node(Node(id="b", label="B")) + diagram.add_node(Node(id="c", label="C")) + + diagram.add_edge(Edge(source="a", target="b")) + diagram.add_edge(Edge(source="b", target="c")) + diagram.add_edge(Edge(source="c", target="a")) # Creates cycle + + # Detect cycles + cycles = DiagramTransforms.find_cycles(diagram) + + print(f"Found {len(cycles)} cycle(s)") + for i, cycle in enumerate(cycles, 1): + print(f" Cycle {i}: {' -> '.join(cycle)}") + print() + + +def example9_multi_format_workflow(): + """Example 9: Multi-format conversion workflow.""" + print("=" * 60) + print("Example 9: Multi-Format Conversion Workflow") + print("=" * 60) + + from ij import ( + D2Renderer, + DiagramIR, + Edge, + GraphvizRenderer, + MermaidRenderer, + Node, + PlantUMLRenderer, + ) + + # Create a simple diagram + diagram = DiagramIR(metadata={"title": "Multi-Format Demo"}) + diagram.add_node(Node(id="start", label="Start")) + diagram.add_node(Node(id="process", label="Process")) + diagram.add_node(Node(id="end", label="End")) + + diagram.add_edge(Edge(source="start", target="process")) + diagram.add_edge(Edge(source="process", target="end")) + + print("Converting diagram to multiple formats:\n") + + # Render to Mermaid + mermaid = MermaidRenderer().render(diagram) + print("1. Mermaid (GitHub/GitLab):") + print(mermaid[:150] + "...\n") + + # Render to PlantUML + plantuml = PlantUMLRenderer().render(diagram) + print("2. PlantUML (Enterprise docs):") + print(plantuml[:150] + "...\n") + + # Render to D2 + d2 = D2Renderer().render(diagram) + print("3. D2 (Modern/Beautiful):") + print(d2[:150] + "...\n") + + # Render to Graphviz + graphviz = GraphvizRenderer().render(diagram) + print("4. Graphviz/DOT (Classic):") + print(graphviz[:150] + "...\n") + + print("✅ Same diagram, four different formats!") + print() + + +def example10_custom_filtering(): + """Example 10: Custom filtering with predicates.""" + print("=" * 60) + print("Example 10: Custom Node Filtering") + print("=" * 60) + + from ij import DiagramIR, DiagramTransforms, Edge, Node + + # Create diagram + diagram = DiagramIR() + diagram.add_node(Node(id="n1", label="Handle error")) + diagram.add_node(Node(id="n2", label="Process data")) + diagram.add_node(Node(id="n3", label="Log error")) + diagram.add_node(Node(id="n4", label="Save result")) + + diagram.add_edge(Edge(source="n1", target="n3")) + diagram.add_edge(Edge(source="n2", target="n4")) + + print(f"Original: {len(diagram.nodes)} nodes") + + # Filter: keep only error-related nodes + error_nodes = DiagramTransforms.apply_node_filter( + diagram, lambda n: "error" in n.label.lower() + ) + + print(f"Error-related nodes only: {len(error_nodes.nodes)} nodes") + for node in error_nodes.nodes: + print(f" - {node.label}") + print() + + +if __name__ == "__main__": + # D2 and format conversion examples + example1_d2_bidirectional() + example9_multi_format_workflow() + + # Sequence diagram examples + example2_sequence_diagrams() + example3_interaction_from_code() + example4_interaction_from_text() + + # Transformation examples + example5_diagram_transformations() + example6_filter_and_extract() + example7_merge_diagrams() + example8_find_cycles() + example10_custom_filtering() + + print("=" * 60) + print("Phase 4 Examples Complete!") + print("=" * 60) + print("\nKey Features Demonstrated:") + print("✅ Bidirectional D2 conversion (parse & render)") + print("✅ Sequence diagram generation") + print("✅ Interaction analysis from code and text") + print("✅ Diagram transformations (simplify, filter, extract)") + print("✅ Multi-diagram merging") + print("✅ Cycle detection") + print("✅ Custom filtering with predicates") + print("✅ Multi-format export (Mermaid, PlantUML, D2, Graphviz)") + print("\nPhase 4 brings advanced diagram manipulation and multi-format support!") diff --git a/ij/__init__.py b/ij/__init__.py index e69de29..3b66a3d 100644 --- a/ij/__init__.py +++ b/ij/__init__.py @@ -0,0 +1,80 @@ +"""Idea Junction - Connect vague ideas and evolve them to fully functional systems. + +A bidirectional diagramming system that enables seamless movement between +natural language, visual diagrams, and code. +""" + +from .analyzers import PythonCodeAnalyzer +from .core import DiagramIR, Edge, EdgeType, Node, NodeType +from .converters import EnhancedTextConverter, SimpleTextConverter +from .graph_ops import GraphOperations +from .parsers import D2Parser, MermaidParser +from .transforms import DiagramTransforms +from .renderers import ( + D2Renderer, + GraphvizRenderer, + InteractionAnalyzer, + MermaidRenderer, + PlantUMLRenderer, + SequenceDiagramRenderer, +) + +# LLMConverter is optional (requires openai package) +try: + from .converters import LLMConverter + _has_llm = True +except ImportError: + _has_llm = False + +__version__ = "0.1.1" + +__all__ = [ + "DiagramIR", + "Node", + "Edge", + "NodeType", + "EdgeType", + "SimpleTextConverter", + "EnhancedTextConverter", + "MermaidRenderer", + "PlantUMLRenderer", + "D2Renderer", + "GraphvizRenderer", + "SequenceDiagramRenderer", + "InteractionAnalyzer", + "MermaidParser", + "D2Parser", + "GraphOperations", + "PythonCodeAnalyzer", + "DiagramTransforms", +] + +if _has_llm: + __all__.append("LLMConverter") + + +def text_to_mermaid(text: str, title: str = None, direction: str = "TD") -> str: + """Convert text description to Mermaid diagram (convenience function). + + Args: + text: Text description of the process/flow + title: Optional diagram title + direction: Flow direction (TD, LR, etc.) + + Returns: + Mermaid syntax string + + Example: + >>> mermaid = text_to_mermaid("Start -> Process data -> End") + >>> print(mermaid) + flowchart TD + n0([Start]) + n1[Process data] + n2([End]) + n0 --> n1 + n1 --> n2 + """ + converter = SimpleTextConverter() + diagram = converter.convert(text, title=title) + renderer = MermaidRenderer(direction=direction) + return renderer.render(diagram) diff --git a/ij/analyzers/__init__.py b/ij/analyzers/__init__.py new file mode 100644 index 0000000..30e63b2 --- /dev/null +++ b/ij/analyzers/__init__.py @@ -0,0 +1,5 @@ +"""Code analyzers for reverse engineering diagrams from code.""" + +from .python_analyzer import PythonCodeAnalyzer + +__all__ = ["PythonCodeAnalyzer"] diff --git a/ij/analyzers/python_analyzer.py b/ij/analyzers/python_analyzer.py new file mode 100644 index 0000000..b6a99d4 --- /dev/null +++ b/ij/analyzers/python_analyzer.py @@ -0,0 +1,373 @@ +"""Python code analyzer for reverse engineering diagrams. + +Analyzes Python code to generate flowcharts, call graphs, and class diagrams +following research recommendations for code-to-diagram generation. +""" + +import ast +from typing import Dict, List, Optional, Set + +from ..core import DiagramIR, Edge, EdgeType, Node, NodeType + + +class PythonCodeAnalyzer: + """Analyze Python code to generate diagrams. + + Supports: + - Function call graphs + - Control flow diagrams + - Class relationship diagrams + """ + + def __init__(self): + """Initialize analyzer.""" + self.node_counter = 0 + + def analyze_function( + self, code: str, function_name: Optional[str] = None + ) -> DiagramIR: + """Analyze a Python function to create a flowchart. + + Args: + code: Python source code + function_name: Specific function to analyze (None = first function) + + Returns: + DiagramIR representation of the function's control flow + + Example: + >>> code = ''' + ... def process_order(order): + ... if order.is_valid(): + ... save_to_database(order) + ... send_confirmation(order) + ... else: + ... send_error_notification(order) + ... ''' + >>> analyzer = PythonCodeAnalyzer() + >>> diagram = analyzer.analyze_function(code) + """ + tree = ast.parse(code) + + # Find the target function + func_def = None + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef): + if function_name is None or node.name == function_name: + func_def = node + break + + if func_def is None: + raise ValueError( + f"Function {function_name or 'not found'} in provided code" + ) + + diagram = DiagramIR( + metadata={"title": f"Control Flow: {func_def.name}", "type": "function"} + ) + + # Create start node + start_node = self._create_node(f"Start {func_def.name}", NodeType.START) + diagram.add_node(start_node) + + # Analyze function body + last_node = start_node + for stmt in func_def.body: + last_node = self._analyze_statement(stmt, last_node, diagram) + + # Create end node + end_node = self._create_node(f"End {func_def.name}", NodeType.END) + diagram.add_node(end_node) + if last_node: + diagram.add_edge(Edge(source=last_node.id, target=end_node.id)) + + return diagram + + def analyze_class(self, code: str, class_name: Optional[str] = None) -> DiagramIR: + """Analyze a Python class to create a class diagram. + + Args: + code: Python source code + class_name: Specific class to analyze (None = first class) + + Returns: + DiagramIR representation of the class structure + """ + tree = ast.parse(code) + + # Find the target class + class_def = None + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + if class_name is None or node.name == class_name: + class_def = node + break + + if class_def is None: + raise ValueError(f"Class {class_name or 'not found'} in provided code") + + diagram = DiagramIR( + metadata={"title": f"Class: {class_def.name}", "type": "class"} + ) + + # Class node + methods = [] + attributes = [] + + for item in class_def.body: + if isinstance(item, ast.FunctionDef): + methods.append(item.name) + elif isinstance(item, ast.Assign): + for target in item.targets: + if isinstance(target, ast.Name): + attributes.append(target.id) + + class_label = f"{class_def.name}\\n" + if attributes: + class_label += f"Attributes: {', '.join(attributes)}\\n" + if methods: + class_label += f"Methods: {', '.join(methods[:5])}" + if len(methods) > 5: + class_label += "..." + + class_node = self._create_node(class_label, NodeType.PROCESS) + diagram.add_node(class_node) + + # Add inheritance relationships + for base in class_def.bases: + if isinstance(base, ast.Name): + base_node = self._create_node(base.id, NodeType.PROCESS) + diagram.add_node(base_node) + diagram.add_edge( + Edge( + source=base_node.id, + target=class_node.id, + label="inherits", + ) + ) + + return diagram + + def analyze_module_calls(self, code: str) -> DiagramIR: + """Analyze function calls in a module to create a call graph. + + Args: + code: Python source code + + Returns: + DiagramIR representation of function call relationships + """ + tree = ast.parse(code) + + diagram = DiagramIR(metadata={"title": "Call Graph", "type": "calls"}) + + # Extract all function definitions + functions = {} + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef): + functions[node.name] = node + + # Analyze calls in each function + for func_name, func_def in functions.items(): + caller_node = self._get_or_create_function_node( + func_name, diagram + ) + + # Find all function calls + for node in ast.walk(func_def): + if isinstance(node, ast.Call): + if isinstance(node.func, ast.Name): + callee_name = node.func.id # Use .id for ast.Name + callee_node = self._get_or_create_function_node( + callee_name, diagram + ) + # Add edge if not already present + edge_exists = any( + e.source == caller_node.id and e.target == callee_node.id + for e in diagram.edges + ) + if not edge_exists: + diagram.add_edge( + Edge(source=caller_node.id, target=callee_node.id) + ) + + return diagram + + def _analyze_statement( + self, stmt: ast.stmt, prev_node: Node, diagram: DiagramIR + ) -> Node: + """Analyze a single statement and add to diagram. + + Returns the last node created. + """ + if isinstance(stmt, ast.If): + return self._analyze_if(stmt, prev_node, diagram) + elif isinstance(stmt, ast.While): + return self._analyze_while(stmt, prev_node, diagram) + elif isinstance(stmt, ast.For): + return self._analyze_for(stmt, prev_node, diagram) + elif isinstance(stmt, ast.Return): + return self._analyze_return(stmt, prev_node, diagram) + elif isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call): + return self._analyze_call(stmt.value, prev_node, diagram) + else: + # Generic statement + node = self._create_node( + self._stmt_to_string(stmt), NodeType.PROCESS + ) + diagram.add_node(node) + diagram.add_edge(Edge(source=prev_node.id, target=node.id)) + return node + + def _analyze_if(self, stmt: ast.If, prev_node: Node, diagram: DiagramIR) -> Node: + """Analyze an if statement.""" + # Decision node + condition = self._expr_to_string(stmt.test) + decision_node = self._create_node(condition, NodeType.DECISION) + diagram.add_node(decision_node) + diagram.add_edge(Edge(source=prev_node.id, target=decision_node.id)) + + # True branch + last_true = decision_node + for body_stmt in stmt.body: + last_true = self._analyze_statement(body_stmt, last_true, diagram) + + # Update edge to decision to have "Yes" label + if last_true != decision_node: + # Find and update the first edge from decision + for edge in diagram.edges: + if edge.source == decision_node.id and edge.target == last_true.id: + edge.label = "Yes" + break + + # False branch + last_false = decision_node + if stmt.orelse: + for else_stmt in stmt.orelse: + last_false = self._analyze_statement(else_stmt, last_false, diagram) + # Update edge to have "No" label + if last_false != decision_node: + for edge in diagram.edges: + if edge.source == decision_node.id and edge.target == last_false.id: + edge.label = "No" + break + + # For simplicity, return the last node from true branch + # In a real implementation, you might want to merge branches + return last_true if last_true != decision_node else last_false + + def _analyze_while(self, stmt: ast.While, prev_node: Node, diagram: DiagramIR) -> Node: + """Analyze a while loop.""" + condition = self._expr_to_string(stmt.test) + decision_node = self._create_node(condition, NodeType.DECISION) + diagram.add_node(decision_node) + diagram.add_edge(Edge(source=prev_node.id, target=decision_node.id)) + + # Loop body + last_body = decision_node + for body_stmt in stmt.body: + last_body = self._analyze_statement(body_stmt, last_body, diagram) + + # Loop back + diagram.add_edge( + Edge( + source=last_body.id, + target=decision_node.id, + edge_type=EdgeType.CONDITIONAL, + label="Continue", + ) + ) + + return decision_node + + def _analyze_for(self, stmt: ast.For, prev_node: Node, diagram: DiagramIR) -> Node: + """Analyze a for loop.""" + target = self._expr_to_string(stmt.target) + iter_expr = self._expr_to_string(stmt.iter) + loop_label = f"For {target} in {iter_expr}" + + loop_node = self._create_node(loop_label, NodeType.DECISION) + diagram.add_node(loop_node) + diagram.add_edge(Edge(source=prev_node.id, target=loop_node.id)) + + # Loop body + last_body = loop_node + for body_stmt in stmt.body: + last_body = self._analyze_statement(body_stmt, last_body, diagram) + + # Loop back + diagram.add_edge( + Edge( + source=last_body.id, + target=loop_node.id, + edge_type=EdgeType.CONDITIONAL, + ) + ) + + return loop_node + + def _analyze_return(self, stmt: ast.Return, prev_node: Node, diagram: DiagramIR) -> Node: + """Analyze a return statement.""" + if stmt.value: + label = f"Return {self._expr_to_string(stmt.value)}" + else: + label = "Return" + + node = self._create_node(label, NodeType.PROCESS) + diagram.add_node(node) + diagram.add_edge(Edge(source=prev_node.id, target=node.id)) + return node + + def _analyze_call(self, call: ast.Call, prev_node: Node, diagram: DiagramIR) -> Node: + """Analyze a function call.""" + func_name = self._expr_to_string(call.func) + args = [self._expr_to_string(arg) for arg in call.args] + label = f"{func_name}({', '.join(args) if args else ''})" + + node = self._create_node(label, NodeType.PROCESS) + diagram.add_node(node) + diagram.add_edge(Edge(source=prev_node.id, target=node.id)) + return node + + def _create_node(self, label: str, node_type: NodeType = NodeType.PROCESS) -> Node: + """Create a node with unique ID.""" + node_id = f"n{self.node_counter}" + self.node_counter += 1 + return Node(id=node_id, label=label, node_type=node_type) + + def _get_or_create_function_node(self, func_name: str, diagram: DiagramIR) -> Node: + """Get existing function node or create new one.""" + # Check if node exists + for node in diagram.nodes: + if node.label == func_name: + return node + + # Create new node + node = self._create_node(func_name, NodeType.SUBPROCESS) + diagram.add_node(node) + return node + + def _expr_to_string(self, expr: ast.expr) -> str: + """Convert AST expression to string.""" + try: + return ast.unparse(expr) + except AttributeError: + # Python < 3.9 fallback + if isinstance(expr, ast.Name): + return expr.id + elif isinstance(expr, ast.Constant): + return str(expr.value) + elif isinstance(expr, ast.Attribute): + return f"{self._expr_to_string(expr.value)}.{expr.attr}" + elif isinstance(expr, ast.Call): + func = self._expr_to_string(expr.func) + return f"{func}(...)" + else: + return "" + + def _stmt_to_string(self, stmt: ast.stmt) -> str: + """Convert AST statement to string.""" + try: + return ast.unparse(stmt)[:50] + except AttributeError: + return f"{stmt.__class__.__name__}" diff --git a/ij/cli.py b/ij/cli.py new file mode 100644 index 0000000..d5b1f39 --- /dev/null +++ b/ij/cli.py @@ -0,0 +1,81 @@ +"""Command-line interface for Idea Junction.""" + +import argparse +import sys +from pathlib import Path + +from . import SimpleTextConverter, MermaidRenderer + + +def main(): + """Main CLI entry point.""" + parser = argparse.ArgumentParser( + description="Idea Junction - Convert ideas to diagrams", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Convert text to Mermaid diagram + ij "Start -> Process data -> Make decision -> End" + + # Save to file + ij "Start -> Process -> End" -o diagram.mmd + + # Read from file + ij -f input.txt -o output.mmd + + # Specify direction + ij "Step 1 -> Step 2 -> Step 3" -d LR + """, + ) + + parser.add_argument( + "text", nargs="?", help="Text description of the process/flow (or use -f)" + ) + parser.add_argument("-f", "--file", help="Read text from file") + parser.add_argument("-o", "--output", help="Output file (default: stdout)") + parser.add_argument( + "-d", + "--direction", + default="TD", + choices=["TD", "LR", "BT", "RL"], + help="Diagram direction (TD=top-down, LR=left-right, etc.)", + ) + parser.add_argument("-t", "--title", help="Diagram title") + + args = parser.parse_args() + + # Get input text + if args.file: + try: + text = Path(args.file).read_text() + except FileNotFoundError: + print(f"Error: File '{args.file}' not found", file=sys.stderr) + sys.exit(1) + elif args.text: + text = args.text + else: + parser.print_help() + sys.exit(1) + + # Convert text to diagram + try: + converter = SimpleTextConverter() + diagram = converter.convert(text, title=args.title) + + renderer = MermaidRenderer(direction=args.direction) + mermaid_output = renderer.render(diagram) + + # Output + if args.output: + Path(args.output).write_text(mermaid_output) + print(f"Diagram saved to {args.output}") + else: + print(mermaid_output) + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/ij/converters/__init__.py b/ij/converters/__init__.py new file mode 100644 index 0000000..12a9579 --- /dev/null +++ b/ij/converters/__init__.py @@ -0,0 +1,12 @@ +"""Converters between different representations.""" + +from .enhanced_text import EnhancedTextConverter +from .text_to_ir import SimpleTextConverter + +try: + from .llm_converter import LLMConverter + + __all__ = ["SimpleTextConverter", "EnhancedTextConverter", "LLMConverter"] +except ImportError: + # OpenAI not installed + __all__ = ["SimpleTextConverter", "EnhancedTextConverter"] diff --git a/ij/converters/enhanced_text.py b/ij/converters/enhanced_text.py new file mode 100644 index 0000000..33e5430 --- /dev/null +++ b/ij/converters/enhanced_text.py @@ -0,0 +1,309 @@ +"""Enhanced text to DiagramIR converter with better NLP. + +Provides more sophisticated text parsing with support for: +- Conditional branches +- Parallel flows +- Loop detection +- Better natural language understanding +""" + +import re +from typing import Dict, List, Optional, Tuple +from ..core import DiagramIR, Edge, EdgeType, Node, NodeType + + +class EnhancedTextConverter: + """Enhanced text-to-diagram converter with NLP capabilities. + + Supports: + - Conditional branches (if/else) + - Parallel flows (parallel keyword) + - Loops (while, repeat) + - Better keyword detection + - Multiple sentence formats + """ + + def __init__(self): + """Initialize converter.""" + self.node_counter = 0 + self.nodes_created = {} + + def convert(self, text: str, title: Optional[str] = None) -> DiagramIR: + """Convert enhanced text to DiagramIR. + + Args: + text: Input text describing a process/flow + title: Optional diagram title + + Returns: + DiagramIR representation + + Examples: + >>> converter = EnhancedTextConverter() + >>> # Conditional + >>> diagram = converter.convert("Start -> Check user. If authenticated: Show dashboard, else: Show login") + >>> # Parallel + >>> diagram = converter.convert("Start -> [parallel: Process A, Process B] -> End") + """ + diagram = DiagramIR() + if title: + diagram.metadata["title"] = title + + self.node_counter = 0 + self.nodes_created = {} + + # Detect and handle different patterns + if self._contains_conditional(text): + self._parse_conditional(text, diagram) + elif self._contains_parallel(text): + self._parse_parallel(text, diagram) + elif self._contains_loop(text): + self._parse_loop(text, diagram) + else: + # Use simple parsing + self._parse_simple(text, diagram) + + return diagram + + def _contains_conditional(self, text: str) -> bool: + """Check if text contains conditional logic.""" + return bool( + re.search( + r"\b(if|when|unless|whether)\b.*\b(then|else|otherwise)\b", + text, + re.IGNORECASE, + ) + ) + + def _contains_parallel(self, text: str) -> bool: + """Check if text contains parallel flows.""" + return bool(re.search(r"\[parallel:", text, re.IGNORECASE)) + + def _contains_loop(self, text: str) -> bool: + """Check if text contains loops.""" + return bool(re.search(r"\b(while|repeat|loop|until)\b", text, re.IGNORECASE)) + + def _parse_conditional(self, text: str, diagram: DiagramIR) -> None: + """Parse text with conditional branches.""" + # Pattern: "... If condition: action1, else: action2 ..." + pattern = r"(.+?)\s+[Ii]f\s+(.+?):\s*([^,]+),?\s*else:\s*(.+?)(?:\s*->|\s*\.|$)" + match = re.search(pattern, text) + + if match: + before = match.group(1).strip() + condition = match.group(2).strip() + true_action = match.group(3).strip() + false_action = match.group(4).strip() + + # Parse before steps + if before and before.lower() not in ["start", "begin"]: + for step in re.split(r"\s*->\s*", before): + step = step.strip() + if step: + node = self._create_node(step) + diagram.add_node(node) + + # Create decision node + decision_node = self._create_node(condition, NodeType.DECISION) + diagram.add_node(decision_node) + + # Create branches + true_node = self._create_node(true_action) + false_node = self._create_node(false_action) + diagram.add_node(true_node) + diagram.add_node(false_node) + + # Connect decision to branches + diagram.add_edge( + Edge(source=decision_node.id, target=true_node.id, label="Yes") + ) + diagram.add_edge( + Edge(source=decision_node.id, target=false_node.id, label="No") + ) + + # Connect previous nodes + if len(diagram.nodes) > 3: + prev_node = diagram.nodes[-4] # Node before decision + diagram.add_edge(Edge(source=prev_node.id, target=decision_node.id)) + + def _parse_parallel(self, text: str, diagram: DiagramIR) -> None: + """Parse text with parallel flows.""" + # Pattern: "[parallel: A, B, C]" + pattern = r"\[parallel:\s*([^\]]+)\]" + match = re.search(pattern, text) + + if match: + # Get steps before and after parallel + before = text[: match.start()].strip() + after = text[match.end() :].strip() + parallel_steps = [s.strip() for s in match.group(1).split(",")] + + # Parse before + prev_node = None + for step in re.split(r"\s*->\s*", before): + step = step.strip() + if step: + node = self._create_node(step) + diagram.add_node(node) + if prev_node: + diagram.add_edge(Edge(source=prev_node.id, target=node.id)) + prev_node = node + + # Create parallel nodes + parallel_nodes = [] + for step in parallel_steps: + node = self._create_node(step) + diagram.add_node(node) + parallel_nodes.append(node) + + # Connect from previous + if prev_node: + diagram.add_edge(Edge(source=prev_node.id, target=node.id)) + + # Parse after + if after: + after = after.lstrip("->").strip() + for step in re.split(r"\s*->\s*", after): + step = step.strip() + if step: + node = self._create_node(step) + diagram.add_node(node) + + # Connect all parallel nodes to this + for pnode in parallel_nodes: + diagram.add_edge(Edge(source=pnode.id, target=node.id)) + + prev_node = node + + def _parse_loop(self, text: str, diagram: DiagramIR) -> None: + """Parse text with loops.""" + # Pattern: "... while condition: action ..." + pattern = r"(.+?)\s+(while|repeat|loop)\s+(.+?):\s*(.+?)(?:\s*->|\s*\.|$)" + match = re.search(pattern, text, re.IGNORECASE) + + if match: + before = match.group(1).strip() + loop_type = match.group(2).lower() + condition = match.group(3).strip() + action = match.group(4).strip() + + # Parse before steps + prev_node = None + if before and before.lower() not in ["start", "begin"]: + for step in re.split(r"\s*->\s*", before): + step = step.strip() + if step: + node = self._create_node(step) + diagram.add_node(node) + if prev_node: + diagram.add_edge(Edge(source=prev_node.id, target=node.id)) + prev_node = node + + # Create loop decision + decision_node = self._create_node(condition, NodeType.DECISION) + diagram.add_node(decision_node) + + if prev_node: + diagram.add_edge(Edge(source=prev_node.id, target=decision_node.id)) + + # Create loop action + action_node = self._create_node(action) + diagram.add_node(action_node) + + # Create loop edges + diagram.add_edge( + Edge(source=decision_node.id, target=action_node.id, label="Continue") + ) + diagram.add_edge( + Edge( + source=action_node.id, + target=decision_node.id, + edge_type=EdgeType.CONDITIONAL, + ) + ) + + def _parse_simple(self, text: str, diagram: DiagramIR) -> None: + """Parse simple linear flow.""" + # Split by arrows or line breaks + steps = [] + parts = re.split(r"\s*->\s*|\s*→\s*|\n|\.(?:\s|$)", text) + + for part in parts: + part = part.strip() + if part: + steps.append(part) + + # Create nodes and edges + prev_node = None + for i, step in enumerate(steps): + node = self._create_node(step, self._infer_type(step, i, len(steps))) + diagram.add_node(node) + + if prev_node: + diagram.add_edge(Edge(source=prev_node.id, target=node.id)) + + prev_node = node + + def _create_node( + self, label: str, node_type: Optional[NodeType] = None + ) -> Node: + """Create a node with unique ID.""" + # Check if we've already created this node + if label in self.nodes_created: + return self.nodes_created[label] + + node_id = f"n{self.node_counter}" + self.node_counter += 1 + + if node_type is None: + node_type = self._infer_type(label, 0, 1) + + node = Node(id=node_id, label=label, node_type=node_type) + self.nodes_created[label] = node + return node + + def _infer_type(self, text: str, index: int, total: int) -> NodeType: + """Infer node type from text content.""" + text_lower = text.lower() + + # Check for explicit keywords (order matters) + if any( + word in text_lower + for word in ["start", "begin", "initialize", "open", "launch"] + ): + return NodeType.START + elif any( + word in text_lower + for word in ["end", "finish", "complete", "close", "done"] + ): + return NodeType.END + elif any( + word in text_lower + for word in [ + "if", + "decide", + "check", + "verify", + "validate", + "whether", + "when", + "?", + ] + ): + return NodeType.DECISION + elif any( + word in text_lower for word in ["database", "store", "save", "persist"] + ): + return NodeType.DATA + elif any( + word in text_lower for word in ["subprocess", "subroutine", "call"] + ): + return NodeType.SUBPROCESS + + # Use position as hint + if index == 0 and total > 1: + return NodeType.START + elif index == total - 1 and total > 1: + return NodeType.END + + return NodeType.PROCESS diff --git a/ij/converters/llm_converter.py b/ij/converters/llm_converter.py new file mode 100644 index 0000000..d696a6e --- /dev/null +++ b/ij/converters/llm_converter.py @@ -0,0 +1,226 @@ +"""AI/LLM-based text to diagram converter. + +Uses OpenAI API for intelligent natural language understanding and +diagram generation. Follows research recommendations for AI-powered +diagram generation (10-20x faster initial drafts). +""" + +import json +import os +from typing import Dict, List, Optional + +try: + import openai +except ImportError: + openai = None + +from ..core import DiagramIR, Edge, EdgeType, Node, NodeType +from ..parsers import MermaidParser + + +class LLMConverter: + """AI-powered text to diagram converter using LLMs. + + Uses OpenAI API with carefully crafted prompts to generate diagrams + from natural language descriptions. Supports iterative refinement. + """ + + SYSTEM_PROMPT = """You are an expert at converting natural language descriptions into Mermaid flowchart diagrams. + +When given a description, create a clear, well-structured Mermaid flowchart that accurately represents the process or system described. + +Guidelines: +1. Use appropriate node types: + - ([...]) for start/end nodes + - [...] for process steps + - {...} for decisions + - [(...)] for data/storage +2. Use meaningful node IDs (e.g., 'start', 'check_user', 'save_data') +3. Add clear labels to decision edges (e.g., |Yes|, |No|) +4. Keep the diagram focused and avoid unnecessary complexity +5. Use flowchart TD (top-down) or LR (left-right) as appropriate + +Output ONLY the Mermaid code, no explanations or markdown code blocks.""" + + def __init__( + self, + api_key: Optional[str] = None, + model: str = "gpt-4o-mini", + temperature: float = 0.3, + ): + """Initialize LLM converter. + + Args: + api_key: OpenAI API key (defaults to OPENAI_API_KEY env var) + model: Model to use (gpt-4o-mini is cheap and effective) + temperature: Sampling temperature (lower = more deterministic) + """ + self.api_key = api_key or os.environ.get("OPENAI_API_KEY") + self.model = model + self.temperature = temperature + + if not self.api_key: + raise ValueError( + "OpenAI API key required. Set OPENAI_API_KEY environment variable " + "or pass api_key parameter." + ) + + if openai is None: + raise ImportError( + "OpenAI library required for LLM conversion. " + "Install with: pip install openai" + ) + + self.client = openai.OpenAI(api_key=self.api_key) + + def convert( + self, text: str, title: Optional[str] = None, direction: str = "TD" + ) -> DiagramIR: + """Convert natural language to DiagramIR using LLM. + + Args: + text: Natural language description + title: Optional diagram title + direction: Diagram direction (TD, LR, etc.) + + Returns: + DiagramIR representation + + Example: + >>> converter = LLMConverter() + >>> diagram = converter.convert( + ... "A user logs into the system. If authentication succeeds, " + ... "they see the dashboard. Otherwise, they see an error message." + ... ) + """ + # Build user prompt + user_prompt = f"Create a Mermaid flowchart for:\n\n{text}" + if title: + user_prompt += f"\n\nTitle: {title}" + if direction != "TD": + user_prompt += f"\n\nUse direction: {direction}" + + # Call OpenAI API + mermaid_code = self._generate_mermaid(user_prompt) + + # Parse the generated Mermaid to DiagramIR + parser = MermaidParser() + diagram = parser.parse(mermaid_code) + + # Add title if provided + if title and "title" not in diagram.metadata: + diagram.metadata["title"] = title + + return diagram + + def refine( + self, diagram: DiagramIR, feedback: str, current_mermaid: str + ) -> DiagramIR: + """Refine an existing diagram based on feedback. + + Args: + diagram: Current diagram + feedback: User feedback/instructions + current_mermaid: Current Mermaid representation + + Returns: + Refined DiagramIR + + Example: + >>> diagram = converter.convert("User login process") + >>> from ij.renderers import MermaidRenderer + >>> mermaid = MermaidRenderer().render(diagram) + >>> refined = converter.refine( + ... diagram, + ... "Add a step for password reset if login fails", + ... mermaid + ... ) + """ + user_prompt = f"""Current diagram: +```mermaid +{current_mermaid} +``` + +Please modify it based on this feedback: +{feedback} + +Output the updated Mermaid code.""" + + # Generate refined version + mermaid_code = self._generate_mermaid(user_prompt) + + # Parse back to DiagramIR + parser = MermaidParser() + refined_diagram = parser.parse(mermaid_code) + + # Preserve metadata + refined_diagram.metadata.update(diagram.metadata) + + return refined_diagram + + def _generate_mermaid(self, user_prompt: str) -> str: + """Call OpenAI API to generate Mermaid code. + + Args: + user_prompt: User's prompt + + Returns: + Generated Mermaid code + """ + response = self.client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": self.SYSTEM_PROMPT}, + {"role": "user", "content": user_prompt}, + ], + temperature=self.temperature, + max_tokens=1000, + ) + + mermaid_code = response.choices[0].message.content.strip() + + # Clean up if wrapped in code blocks + if mermaid_code.startswith("```"): + lines = mermaid_code.split("\n") + # Remove first line (```mermaid or ```) and last line (```) + mermaid_code = "\n".join(lines[1:-1]) + + return mermaid_code + + def convert_with_examples( + self, text: str, examples: List[Dict[str, str]], title: Optional[str] = None + ) -> DiagramIR: + """Convert with few-shot examples for better quality. + + Args: + text: Natural language description + examples: List of {"description": "...", "mermaid": "..."} examples + title: Optional diagram title + + Returns: + DiagramIR representation + """ + # Build few-shot prompt + examples_text = "\n\n".join( + [ + f"Example {i+1}:\nDescription: {ex['description']}\n\nMermaid:\n{ex['mermaid']}" + for i, ex in enumerate(examples) + ] + ) + + user_prompt = f"""{examples_text} + +Now create a diagram for: +{text}""" + + if title: + user_prompt += f"\n\nTitle: {title}" + + mermaid_code = self._generate_mermaid(user_prompt) + parser = MermaidParser() + diagram = parser.parse(mermaid_code) + + if title: + diagram.metadata["title"] = title + + return diagram diff --git a/ij/converters/text_to_ir.py b/ij/converters/text_to_ir.py new file mode 100644 index 0000000..a0109a5 --- /dev/null +++ b/ij/converters/text_to_ir.py @@ -0,0 +1,131 @@ +"""Text to DiagramIR converters. + +This module provides simple rule-based conversion from structured text +to DiagramIR. Future versions can integrate AI/LLM-based conversion. +""" + +import re +from typing import List, Tuple +from ..core import DiagramIR, Edge, EdgeType, Node, NodeType + + +class SimpleTextConverter: + """Simple rule-based text to diagram converter. + + Parses structured text like: + - "Start -> Process A -> Decision B" + - "If condition: Process C, else: Process D" + """ + + def __init__(self): + """Initialize converter.""" + self.node_counter = 0 + + def convert(self, text: str, title: str = None) -> DiagramIR: + """Convert structured text to DiagramIR. + + Args: + text: Input text describing a process/flow + title: Optional diagram title + + Returns: + DiagramIR representation + """ + diagram = DiagramIR() + if title: + diagram.metadata["title"] = title + + # Parse the text into steps + steps = self._parse_steps(text) + + # Convert steps to nodes and edges + previous_node_id = None + for step_text, step_type in steps: + node_id = f"n{self.node_counter}" + self.node_counter += 1 + + node = Node(id=node_id, label=step_text, node_type=step_type) + diagram.add_node(node) + + # Connect to previous node + if previous_node_id: + edge = Edge(source=previous_node_id, target=node_id) + diagram.add_edge(edge) + + previous_node_id = node_id + + return diagram + + def _parse_steps(self, text: str) -> List[Tuple[str, NodeType]]: + """Parse text into a list of (step_text, node_type) tuples. + + Args: + text: Input text + + Returns: + List of tuples (step_text, node_type) + """ + steps = [] + + # Split by common delimiters + parts = re.split(r"\s*->\s*|\s*→\s*|\n", text) + + for i, part in enumerate(parts): + part = part.strip() + if not part: + continue + + # Determine node type based on keywords + node_type = self._infer_node_type(part, i, len(parts)) + steps.append((part, node_type)) + + return steps + + def _infer_node_type(self, text: str, index: int, total: int) -> NodeType: + """Infer node type from text content and position. + + Args: + text: Node text + index: Position in sequence + total: Total number of nodes + + Returns: + Inferred NodeType + """ + text_lower = text.lower() + + # Check for explicit keywords + if any( + word in text_lower for word in ["start", "begin", "initialize", "open"] + ): + return NodeType.START + elif any(word in text_lower for word in ["end", "finish", "complete", "close"]): + return NodeType.END + elif any( + word in text_lower + for word in ["if", "decide", "check", "verify", "validate", "?"] + ): + return NodeType.DECISION + elif any(word in text_lower for word in ["database", "store", "save"]): + return NodeType.DATA + + # Use position as hint + if index == 0: + return NodeType.START + elif index == total - 1: + return NodeType.END + + return NodeType.PROCESS + + +class StructuredTextConverter: + """More advanced converter supporting branching and conditions. + + Planned for future implementation to handle: + - Conditional branches + - Parallel flows + - Subprocesses + - Loops + """ + + pass diff --git a/ij/core.py b/ij/core.py new file mode 100644 index 0000000..0de1e84 --- /dev/null +++ b/ij/core.py @@ -0,0 +1,123 @@ +"""Core data structures for Idea Junction. + +This module provides the intermediate representation (IR) for diagrams, +following the recommendation from research to use AST-like structures +for bidirectional conversion between text, diagrams, and code. +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Dict, List, Optional + + +class NodeType(Enum): + """Types of nodes in a diagram.""" + + PROCESS = "process" + DECISION = "decision" + START = "start" + END = "end" + DATA = "data" + SUBPROCESS = "subprocess" + CUSTOM = "custom" + + +class EdgeType(Enum): + """Types of edges connecting nodes.""" + + DIRECT = "direct" + CONDITIONAL = "conditional" + BIDIRECTIONAL = "bidirectional" + + +@dataclass +class Node: + """Represents a node in the diagram IR. + + Attributes: + id: Unique identifier for the node + label: Display label/text + node_type: Type of node (process, decision, etc.) + metadata: Additional properties for extensibility + """ + + id: str + label: str + node_type: NodeType = NodeType.PROCESS + metadata: Dict[str, Any] = field(default_factory=dict) + + def __hash__(self): + return hash(self.id) + + +@dataclass +class Edge: + """Represents an edge in the diagram IR. + + Attributes: + source: Source node ID + target: Target node ID + label: Optional edge label + edge_type: Type of edge + metadata: Additional properties for extensibility + """ + + source: str + target: str + label: Optional[str] = None + edge_type: EdgeType = EdgeType.DIRECT + metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class DiagramIR: + """Intermediate Representation for diagrams. + + This serves as the central data structure that can be: + - Generated from natural language + - Converted to various diagram formats (Mermaid, PlantUML, D2) + - Manipulated programmatically + - Parsed back from diagram syntax + + Attributes: + nodes: List of nodes in the diagram + edges: List of edges connecting nodes + metadata: Diagram-level properties (title, description, etc.) + """ + + nodes: List[Node] = field(default_factory=list) + edges: List[Edge] = field(default_factory=list) + metadata: Dict[str, Any] = field(default_factory=dict) + + def add_node(self, node: Node) -> None: + """Add a node to the diagram.""" + self.nodes.append(node) + + def add_edge(self, edge: Edge) -> None: + """Add an edge to the diagram.""" + self.edges.append(edge) + + def get_node(self, node_id: str) -> Optional[Node]: + """Get a node by ID.""" + for node in self.nodes: + if node.id == node_id: + return node + return None + + def validate(self) -> bool: + """Validate the diagram structure. + + Returns: + True if the diagram is valid, False otherwise + """ + # Check for unique node IDs + node_ids = [node.id for node in self.nodes] + if len(node_ids) != len(set(node_ids)): + return False + + # Check that edges reference existing nodes + for edge in self.edges: + if edge.source not in node_ids or edge.target not in node_ids: + return False + + return True diff --git a/ij/graph_ops.py b/ij/graph_ops.py new file mode 100644 index 0000000..5fe7bd9 --- /dev/null +++ b/ij/graph_ops.py @@ -0,0 +1,189 @@ +"""Graph operations using NetworkX. + +Provides graph manipulation, analysis, and transformation capabilities +following the research recommendation to use NetworkX as the foundation +for graph-based operations. +""" + +import networkx as nx +from typing import Dict, List, Optional, Set +from .core import DiagramIR, Edge, Node + + +class GraphOperations: + """Graph manipulation and analysis using NetworkX.""" + + @staticmethod + def to_networkx(diagram: DiagramIR) -> nx.DiGraph: + """Convert DiagramIR to NetworkX directed graph. + + Args: + diagram: DiagramIR to convert + + Returns: + NetworkX DiGraph + """ + G = nx.DiGraph() + + # Add nodes with attributes + for node in diagram.nodes: + G.add_node( + node.id, + label=node.label, + node_type=node.node_type.value, + **node.metadata, + ) + + # Add edges with attributes + for edge in diagram.edges: + G.add_edge( + edge.source, + edge.target, + label=edge.label, + edge_type=edge.edge_type.value, + **edge.metadata, + ) + + return G + + @staticmethod + def from_networkx(G: nx.DiGraph) -> DiagramIR: + """Convert NetworkX graph to DiagramIR. + + Args: + G: NetworkX directed graph + + Returns: + DiagramIR representation + """ + from .core import NodeType, EdgeType + + diagram = DiagramIR() + + # Convert nodes + for node_id, attrs in G.nodes(data=True): + node_type = NodeType(attrs.get("node_type", "process")) + label = attrs.get("label", str(node_id)) + + # Filter out known attributes + metadata = { + k: v for k, v in attrs.items() if k not in ["label", "node_type"] + } + + node = Node(id=node_id, label=label, node_type=node_type, metadata=metadata) + diagram.add_node(node) + + # Convert edges + for source, target, attrs in G.edges(data=True): + edge_type = EdgeType(attrs.get("edge_type", "direct")) + label = attrs.get("label") + + # Filter out known attributes + metadata = { + k: v for k, v in attrs.items() if k not in ["label", "edge_type"] + } + + edge = Edge( + source=source, + target=target, + label=label, + edge_type=edge_type, + metadata=metadata, + ) + diagram.add_edge(edge) + + return diagram + + @staticmethod + def find_paths(diagram: DiagramIR, source: str, target: str) -> List[List[str]]: + """Find all paths between two nodes. + + Args: + diagram: DiagramIR to analyze + source: Source node ID + target: Target node ID + + Returns: + List of paths (each path is a list of node IDs) + """ + G = GraphOperations.to_networkx(diagram) + try: + paths = list(nx.all_simple_paths(G, source, target)) + return paths + except (nx.NodeNotFound, nx.NetworkXNoPath): + return [] + + @staticmethod + def find_cycles(diagram: DiagramIR) -> List[List[str]]: + """Find all cycles in the diagram. + + Args: + diagram: DiagramIR to analyze + + Returns: + List of cycles (each cycle is a list of node IDs) + """ + G = GraphOperations.to_networkx(diagram) + try: + cycles = list(nx.simple_cycles(G)) + return cycles + except nx.NetworkXNoCycle: + return [] + + @staticmethod + def topological_sort(diagram: DiagramIR) -> Optional[List[str]]: + """Get topological ordering of nodes (for DAGs). + + Args: + diagram: DiagramIR to sort + + Returns: + List of node IDs in topological order, or None if graph has cycles + """ + G = GraphOperations.to_networkx(diagram) + try: + return list(nx.topological_sort(G)) + except (nx.NetworkXError, nx.NetworkXUnfeasible): + # Graph has cycles or other issues + return None + + @staticmethod + def find_critical_nodes(diagram: DiagramIR) -> Set[str]: + """Find nodes whose removal would disconnect the graph. + + Args: + diagram: DiagramIR to analyze + + Returns: + Set of critical node IDs + """ + G = GraphOperations.to_networkx(diagram) + return set(nx.articulation_points(G.to_undirected())) + + @staticmethod + def simplify_diagram(diagram: DiagramIR, remove_redundant: bool = True) -> DiagramIR: + """Simplify diagram by removing redundant edges and nodes. + + Args: + diagram: DiagramIR to simplify + remove_redundant: If True, remove transitive edges + + Returns: + Simplified DiagramIR + """ + G = GraphOperations.to_networkx(diagram) + + if remove_redundant: + # Compute transitive reduction to remove redundant edges + G_reduced = nx.transitive_reduction(G) + # Copy node and edge attributes + for node in G.nodes(): + for attr, value in G.nodes[node].items(): + G_reduced.nodes[node][attr] = value + for u, v in G_reduced.edges(): + if G.has_edge(u, v): + for attr, value in G.edges[u, v].items(): + G_reduced.edges[u, v][attr] = value + G = G_reduced + + return GraphOperations.from_networkx(G) diff --git a/ij/parsers/__init__.py b/ij/parsers/__init__.py new file mode 100644 index 0000000..071f55b --- /dev/null +++ b/ij/parsers/__init__.py @@ -0,0 +1,6 @@ +"""Diagram parsers for various formats.""" + +from .d2 import D2Parser +from .mermaid import MermaidParser + +__all__ = ["MermaidParser", "D2Parser"] diff --git a/ij/parsers/d2.py b/ij/parsers/d2.py new file mode 100644 index 0000000..f9ef3ba --- /dev/null +++ b/ij/parsers/d2.py @@ -0,0 +1,245 @@ +"""D2 diagram parser. + +Parses D2 (Terrastruct) syntax to DiagramIR, completing bidirectional +support for the modern D2 format. +""" + +import re +from typing import Dict, List, Optional, Tuple + +from ..core import DiagramIR, Edge, EdgeType, Node, NodeType + + +class D2Parser: + """Parse D2 syntax to DiagramIR. + + Supports basic D2 syntax including: + - Node definitions with shapes and labels + - Edge connections with labels + - Direction metadata + """ + + # Shape to NodeType mapping + SHAPE_TO_TYPE = { + "oval": NodeType.START, # Could be START or END, we'll infer + "rectangle": NodeType.PROCESS, + "diamond": NodeType.DECISION, + "cylinder": NodeType.DATA, + } + + def __init__(self): + """Initialize parser.""" + self.nodes: Dict[str, Node] = {} + self.edges: List[Edge] = [] + self.metadata: Dict = {} + + def parse(self, d2_text: str) -> DiagramIR: + """Parse D2 syntax to DiagramIR. + + Args: + d2_text: D2 diagram source + + Returns: + DiagramIR representation + + Example: + >>> parser = D2Parser() + >>> d2_code = ''' + ... n1: "Start" { + ... shape: oval + ... } + ... n2: "Process" { + ... shape: rectangle + ... } + ... n1 -> n2 + ... ''' + >>> diagram = parser.parse(d2_code) + """ + self.nodes = {} + self.edges = [] + self.metadata = {} + + lines = d2_text.strip().split("\n") + i = 0 + + while i < len(lines): + line = lines[i].strip() + + # Skip empty lines and comments + if not line or line.startswith("#"): + # Check for title comment + if line.startswith("#") and not self.metadata.get("title"): + title = line[1:].strip() + if title and not title.startswith(" "): + self.metadata["title"] = title + i += 1 + continue + + # Check for direction + if line.startswith("direction:"): + direction = line.split(":", 1)[1].strip() + self.metadata["direction"] = self._convert_direction(direction) + i += 1 + continue + + # Check for node definition with block + node_match = re.match(r'(\w+):\s*"([^"]+)"\s*\{', line) + if node_match: + node_id = node_match.group(1) + label = node_match.group(2) + i += 1 + + # Parse node properties + shape = "rectangle" # default + while i < len(lines): + prop_line = lines[i].strip() + if prop_line == "}": + i += 1 + break + if prop_line.startswith("shape:"): + shape = prop_line.split(":", 1)[1].strip() + i += 1 + + node_type = self.SHAPE_TO_TYPE.get(shape, NodeType.PROCESS) + self.nodes[node_id] = Node( + id=node_id, label=label, node_type=node_type + ) + continue + + # Check for simple node + simple_node = re.match(r'(\w+):\s*"([^"]+)"', line) + if simple_node: + node_id = simple_node.group(1) + label = simple_node.group(2) + if node_id not in self.nodes: + self.nodes[node_id] = Node( + id=node_id, label=label, node_type=NodeType.PROCESS + ) + i += 1 + continue + + # Check for edge + edge_match = self._parse_edge_line(line) + if edge_match: + source, target, label, edge_type = edge_match + # Ensure nodes exist + if source not in self.nodes: + self.nodes[source] = Node( + id=source, label=source, node_type=NodeType.PROCESS + ) + if target not in self.nodes: + self.nodes[target] = Node( + id=target, label=target, node_type=NodeType.PROCESS + ) + + self.edges.append( + Edge(source=source, target=target, label=label, edge_type=edge_type) + ) + i += 1 + continue + + i += 1 + + # Infer START/END for oval nodes + self._infer_start_end() + + # Build diagram + diagram = DiagramIR(metadata=self.metadata) + for node in self.nodes.values(): + diagram.add_node(node) + for edge in self.edges: + diagram.add_edge(edge) + + return diagram + + def _parse_edge_line(self, line: str) -> Optional[Tuple[str, str, Optional[str], EdgeType]]: + """Parse an edge line. + + Returns: (source, target, label, edge_type) or None + """ + # Edge with label: n1 -> n2: "label" + match = re.match(r'(\w+)\s*->\s*(\w+):\s*"([^"]+)"', line) + if match: + return ( + match.group(1), + match.group(2), + match.group(3), + EdgeType.DIRECT, + ) + + # Edge with dashed style: n1 -> n2 {style.stroke-dash: 3} + match = re.match( + r'(\w+)\s*->\s*(\w+)\s*\{[^}]*stroke-dash[^}]*\}', line + ) + if match: + return ( + match.group(1), + match.group(2), + None, + EdgeType.CONDITIONAL, + ) + + # Simple edge: n1 -> n2 + match = re.match(r'(\w+)\s*->\s*(\w+)', line) + if match: + return ( + match.group(1), + match.group(2), + None, + EdgeType.DIRECT, + ) + + # Bidirectional: n1 <-> n2 + match = re.match(r'(\w+)\s*<->\s*(\w+)', line) + if match: + return ( + match.group(1), + match.group(2), + None, + EdgeType.BIDIRECTIONAL, + ) + + return None + + def _convert_direction(self, d2_direction: str) -> str: + """Convert D2 direction to Mermaid direction.""" + direction_map = { + "down": "TD", + "up": "BT", + "right": "LR", + "left": "RL", + } + return direction_map.get(d2_direction, "TD") + + def _infer_start_end(self): + """Infer which oval nodes are START vs END based on edges.""" + # Nodes with no incoming edges are likely START + # Nodes with no outgoing edges are likely END + incoming = set() + outgoing = set() + + for edge in self.edges: + outgoing.add(edge.source) + incoming.add(edge.target) + + for node_id, node in self.nodes.items(): + if node.node_type == NodeType.START: # oval nodes + if node_id in outgoing and node_id not in incoming: + node.node_type = NodeType.START + elif node_id in incoming and node_id not in outgoing: + node.node_type = NodeType.END + elif node_id not in outgoing and node_id not in incoming: + # Isolated oval, default to START + node.node_type = NodeType.START + + def parse_file(self, filename: str) -> DiagramIR: + """Parse D2 file to DiagramIR. + + Args: + filename: Path to D2 file + + Returns: + DiagramIR representation + """ + with open(filename, "r") as f: + return self.parse(f.read()) diff --git a/ij/parsers/mermaid.py b/ij/parsers/mermaid.py new file mode 100644 index 0000000..ff5c2bb --- /dev/null +++ b/ij/parsers/mermaid.py @@ -0,0 +1,198 @@ +"""Mermaid diagram parser. + +Converts Mermaid syntax back to DiagramIR, enabling bidirectional conversion. +""" + +import re +from typing import Dict, List, Optional, Tuple +from ..core import DiagramIR, Edge, EdgeType, Node, NodeType + + +class MermaidParser: + """Parses Mermaid flowchart syntax to DiagramIR. + + Supports basic flowchart syntax including: + - Node definitions with various shapes + - Edge connections with labels + - Title metadata + """ + + # Pattern for flowchart declaration + FLOWCHART_PATTERN = re.compile(r"flowchart\s+(TD|LR|BT|RL)") + + # Pattern for title + TITLE_PATTERN = re.compile(r"title:\s*(.+)") + + # Pattern for node with shape + NODE_PATTERNS = [ + # Stadium shape: n1([Label]) + (re.compile(r"(\w+)\(\[(.+?)\]\)"), NodeType.START), + # Rhombus: n1{Label} + (re.compile(r"(\w+)\{(.+?)\}"), NodeType.DECISION), + # Cylindrical: n1[(Label)] + (re.compile(r"(\w+)\[\((.+?)\)\]"), NodeType.DATA), + # Subroutine: n1[[Label]] + (re.compile(r"(\w+)\[\[(.+?)\]\]"), NodeType.SUBPROCESS), + # Rectangle: n1[Label] + (re.compile(r"(\w+)\[(.+?)\]"), NodeType.PROCESS), + ] + + # Pattern for edges + EDGE_PATTERNS = [ + # With label: n1 -->|label| n2 + ( + re.compile(r"(\w+)\s+(-->|<-->|-\.->)\s*\|([^|]+)\|\s*(\w+)"), + "labeled", + ), + # Simple: n1 --> n2 + (re.compile(r"(\w+)\s+(-->|<-->|-\.->)\s+(\w+)"), "simple"), + ] + + ARROW_TO_EDGE_TYPE = { + "-->": EdgeType.DIRECT, + "-.->": EdgeType.CONDITIONAL, + "<-->": EdgeType.BIDIRECTIONAL, + } + + def __init__(self): + """Initialize parser.""" + self.nodes: Dict[str, Node] = {} + self.edges: List[Edge] = [] + self.metadata: Dict = {} + + def parse(self, mermaid_text: str) -> DiagramIR: + """Parse Mermaid syntax to DiagramIR. + + Args: + mermaid_text: Mermaid flowchart syntax + + Returns: + DiagramIR representation + + Raises: + ValueError: If the syntax is invalid + """ + self.nodes = {} + self.edges = [] + self.metadata = {} + + lines = mermaid_text.strip().split("\n") + + for line in lines: + line = line.strip() + if not line or line.startswith("---"): + continue + + # Check for title + title_match = self.TITLE_PATTERN.match(line) + if title_match: + self.metadata["title"] = title_match.group(1).strip() + continue + + # Check for flowchart declaration + flowchart_match = self.FLOWCHART_PATTERN.match(line) + if flowchart_match: + self.metadata["direction"] = flowchart_match.group(1) + continue + + # Try to parse as edge + if self._parse_edge(line): + continue + + # Try to parse as node + self._parse_node(line) + + # Build DiagramIR + diagram = DiagramIR(metadata=self.metadata) + for node in self.nodes.values(): + diagram.add_node(node) + for edge in self.edges: + diagram.add_edge(edge) + + return diagram + + def _parse_node(self, line: str) -> bool: + """Parse a node definition. + + Args: + line: Line containing node definition + + Returns: + True if successfully parsed, False otherwise + """ + for pattern, node_type in self.NODE_PATTERNS: + match = pattern.search(line) + if match: + node_id = match.group(1) + label = match.group(2) + + # Determine if it's START or END based on label + if node_type == NodeType.START: + # Check if label suggests END + if any( + word in label.lower() + for word in ["end", "finish", "complete", "done"] + ): + node_type = NodeType.END + + if node_id not in self.nodes: + self.nodes[node_id] = Node( + id=node_id, label=label, node_type=node_type + ) + return True + + return False + + def _parse_edge(self, line: str) -> bool: + """Parse an edge definition. + + Args: + line: Line containing edge definition + + Returns: + True if successfully parsed, False otherwise + """ + # Try labeled edge first + for pattern, edge_kind in self.EDGE_PATTERNS: + match = pattern.match(line) + if match: + if edge_kind == "labeled": + source = match.group(1) + arrow = match.group(2) + label = match.group(3).strip() + target = match.group(4) + else: # simple + source = match.group(1) + arrow = match.group(2) + target = match.group(3) + label = None + + # Ensure nodes exist + if source not in self.nodes: + self.nodes[source] = Node( + id=source, label=source, node_type=NodeType.PROCESS + ) + if target not in self.nodes: + self.nodes[target] = Node( + id=target, label=target, node_type=NodeType.PROCESS + ) + + edge_type = self.ARROW_TO_EDGE_TYPE.get(arrow, EdgeType.DIRECT) + self.edges.append( + Edge(source=source, target=target, label=label, edge_type=edge_type) + ) + return True + + return False + + def parse_file(self, filename: str) -> DiagramIR: + """Parse Mermaid file to DiagramIR. + + Args: + filename: Path to Mermaid file + + Returns: + DiagramIR representation + """ + with open(filename, "r") as f: + return self.parse(f.read()) diff --git a/ij/renderers/__init__.py b/ij/renderers/__init__.py new file mode 100644 index 0000000..18de588 --- /dev/null +++ b/ij/renderers/__init__.py @@ -0,0 +1,16 @@ +"""Diagram renderers for various formats.""" + +from .mermaid import MermaidRenderer +from .plantuml import PlantUMLRenderer +from .d2 import D2Renderer +from .graphviz import GraphvizRenderer +from .sequence import SequenceDiagramRenderer, InteractionAnalyzer + +__all__ = [ + "MermaidRenderer", + "PlantUMLRenderer", + "D2Renderer", + "GraphvizRenderer", + "SequenceDiagramRenderer", + "InteractionAnalyzer", +] diff --git a/ij/renderers/d2.py b/ij/renderers/d2.py new file mode 100644 index 0000000..437ac23 --- /dev/null +++ b/ij/renderers/d2.py @@ -0,0 +1,138 @@ +"""D2 diagram renderer. + +Converts DiagramIR to D2 (Terrastruct) syntax, a modern diagram-as-code +language with excellent aesthetics and bidirectional editing support. +""" + +from typing import Dict +from ..core import DiagramIR, EdgeType, NodeType + + +class D2Renderer: + """Renders DiagramIR to D2 syntax. + + D2 is a modern diagram language with clean syntax, multiple layout engines, + and PowerPoint export capabilities. + """ + + # Map node types to D2 shapes + NODE_SHAPES: Dict[NodeType, str] = { + NodeType.START: "oval", + NodeType.END: "oval", + NodeType.PROCESS: "rectangle", + NodeType.DECISION: "diamond", + NodeType.DATA: "cylinder", + NodeType.SUBPROCESS: "rectangle", + NodeType.CUSTOM: "rectangle", + } + + def __init__(self, layout: str = "dagre"): + """Initialize renderer. + + Args: + layout: Layout engine (dagre, elk, tala) + """ + self.layout = layout + + def render(self, diagram: DiagramIR) -> str: + """Render a DiagramIR to D2 syntax. + + Args: + diagram: The diagram to render + + Returns: + D2 syntax as a string + """ + if not diagram.validate(): + raise ValueError("Invalid diagram structure") + + lines = [] + + # Add title as a comment and direction + if "title" in diagram.metadata: + lines.append(f"# {diagram.metadata['title']}") + lines.append("") + + # Set direction based on metadata + direction = diagram.metadata.get("direction", "down") + d2_direction = self._convert_direction(direction) + if d2_direction: + lines.append(f"direction: {d2_direction}") + lines.append("") + + # Render nodes + for node in diagram.nodes: + node_def = self._render_node(node) + lines.append(node_def) + + if diagram.nodes: + lines.append("") + + # Render edges + for edge in diagram.edges: + edge_def = self._render_edge(edge) + lines.append(edge_def) + + return "\n".join(lines) + + def _convert_direction(self, mermaid_direction: str) -> str: + """Convert Mermaid direction to D2 direction.""" + direction_map = { + "TD": "down", + "BT": "up", + "LR": "right", + "RL": "left", + } + return direction_map.get(mermaid_direction, "down") + + def _render_node(self, node) -> str: + """Render a single node to D2 syntax.""" + # Sanitize label for D2 + label = node.label.replace('"', '\\"') + + # Get shape + shape = self.NODE_SHAPES.get(node.node_type, "rectangle") + + # Style start/end nodes differently + if node.node_type in [NodeType.START, NodeType.END]: + style = " {style.fill: '#90EE90'; style.stroke: '#228B22'}" + elif node.node_type == NodeType.DECISION: + style = " {style.fill: '#FFE4B5'; style.stroke: '#FF8C00'}" + elif node.node_type == NodeType.DATA: + style = " {style.fill: '#B0E0E6'; style.stroke: '#4682B4'}" + else: + style = "" + + return f'{node.id}: "{label}" {{\n shape: {shape}{style}\n}}' + + def _render_edge(self, edge) -> str: + """Render a single edge to D2 syntax.""" + # Determine arrow style + if edge.edge_type == EdgeType.BIDIRECTIONAL: + arrow = "<->" + elif edge.edge_type == EdgeType.CONDITIONAL: + arrow = "-> {style.stroke-dash: 3}" + else: + arrow = "->" + + if edge.label: + # Edge with label + label = edge.label.replace('"', '\\"') + return f'{edge.source} {arrow} {edge.target}: "{label}"' + else: + # Simple edge + if edge.edge_type == EdgeType.CONDITIONAL: + return f"{edge.source} -> {edge.target} {{style.stroke-dash: 3}}" + else: + return f"{edge.source} {arrow} {edge.target}" + + def render_to_file(self, diagram: DiagramIR, filename: str) -> None: + """Render diagram and save to file. + + Args: + diagram: The diagram to render + filename: Path to output file + """ + content = self.render(diagram) + with open(filename, "w") as f: + f.write(content) diff --git a/ij/renderers/graphviz.py b/ij/renderers/graphviz.py new file mode 100644 index 0000000..f58fd42 --- /dev/null +++ b/ij/renderers/graphviz.py @@ -0,0 +1,176 @@ +"""Graphviz/DOT diagram renderer. + +Converts DiagramIR to DOT (Graphviz) syntax, the foundational graph +visualization language. +""" + +from typing import Dict +from ..core import DiagramIR, EdgeType, NodeType + + +class GraphvizRenderer: + """Renders DiagramIR to Graphviz DOT syntax. + + Graphviz is the 30-year-old foundation that underpins PlantUML, + Structurizr, and many other tools. + """ + + # Map node types to Graphviz shapes + NODE_SHAPES: Dict[NodeType, str] = { + NodeType.START: "oval", + NodeType.END: "oval", + NodeType.PROCESS: "box", + NodeType.DECISION: "diamond", + NodeType.DATA: "cylinder", + NodeType.SUBPROCESS: "box3d", + NodeType.CUSTOM: "box", + } + + def __init__(self, layout: str = "dot", graph_type: str = "digraph"): + """Initialize renderer. + + Args: + layout: Layout algorithm (dot, neato, fdp, sfdp, twopi, circo) + graph_type: Graph type (digraph for directed, graph for undirected) + """ + self.layout = layout + self.graph_type = graph_type + + def render(self, diagram: DiagramIR) -> str: + """Render a DiagramIR to DOT syntax. + + Args: + diagram: The diagram to render + + Returns: + DOT syntax as a string + """ + if not diagram.validate(): + raise ValueError("Invalid diagram structure") + + lines = [f"{self.graph_type} G {{"] + + # Add graph attributes + lines.append(f' layout="{self.layout}";') + lines.append(' rankdir="TB";') # Top to bottom by default + lines.append(' node [fontname="Arial", fontsize=12];') + lines.append(' edge [fontname="Arial", fontsize=10];') + + # Add title as label if present + if "title" in diagram.metadata: + title = diagram.metadata["title"].replace('"', '\\"') + lines.append(f' label="{title}";') + lines.append(' labelloc="t";') + + # Adjust rankdir based on direction + if "direction" in diagram.metadata: + rankdir = self._convert_direction(diagram.metadata["direction"]) + lines.append(f' rankdir="{rankdir}";') + + lines.append("") + + # Render nodes + for node in diagram.nodes: + node_def = self._render_node(node) + lines.append(f" {node_def}") + + if diagram.nodes: + lines.append("") + + # Render edges + edge_op = "->" if self.graph_type == "digraph" else "--" + for edge in diagram.edges: + edge_def = self._render_edge(edge, edge_op) + lines.append(f" {edge_def}") + + lines.append("}") + return "\n".join(lines) + + def _convert_direction(self, mermaid_direction: str) -> str: + """Convert Mermaid direction to Graphviz rankdir.""" + direction_map = { + "TD": "TB", # Top to bottom + "BT": "BT", # Bottom to top + "LR": "LR", # Left to right + "RL": "RL", # Right to left + } + return direction_map.get(mermaid_direction, "TB") + + def _render_node(self, node) -> str: + """Render a single node to DOT syntax.""" + # Sanitize label for DOT + label = node.label.replace('"', '\\"') + + # Get shape and style + shape = self.NODE_SHAPES.get(node.node_type, "box") + + # Style based on node type + if node.node_type == NodeType.START: + style = 'style=filled, fillcolor=lightgreen' + elif node.node_type == NodeType.END: + style = 'style=filled, fillcolor=lightcoral' + elif node.node_type == NodeType.DECISION: + style = 'style=filled, fillcolor=lightyellow' + elif node.node_type == NodeType.DATA: + style = 'style=filled, fillcolor=lightblue' + else: + style = 'style=filled, fillcolor=lightgray' + + return f'{node.id} [label="{label}", shape={shape}, {style}];' + + def _render_edge(self, edge, edge_op: str) -> str: + """Render a single edge to DOT syntax.""" + # Determine edge style + if edge.edge_type == EdgeType.BIDIRECTIONAL: + edge_attrs = 'dir=both' + elif edge.edge_type == EdgeType.CONDITIONAL: + edge_attrs = 'style=dashed' + else: + edge_attrs = '' + + if edge.label: + # Edge with label + label = edge.label.replace('"', '\\"') + if edge_attrs: + return f'{edge.source} {edge_op} {edge.target} [label="{label}", {edge_attrs}];' + else: + return f'{edge.source} {edge_op} {edge.target} [label="{label}"];' + else: + # Simple edge + if edge_attrs: + return f'{edge.source} {edge_op} {edge.target} [{edge_attrs}];' + else: + return f'{edge.source} {edge_op} {edge.target};' + + def render_to_file(self, diagram: DiagramIR, filename: str) -> None: + """Render diagram and save to file. + + Args: + diagram: The diagram to render + filename: Path to output file + """ + content = self.render(diagram) + with open(filename, "w") as f: + f.write(content) + + def render_to_image( + self, diagram: DiagramIR, filename: str, format: str = "png" + ) -> None: + """Render diagram directly to image using graphviz library. + + Args: + diagram: The diagram to render + filename: Path to output file (without extension) + format: Output format (png, svg, pdf, etc.) + """ + try: + import graphviz + + dot_source = self.render(diagram) + graph = graphviz.Source(dot_source, format=format) + graph.render(filename, cleanup=True) + except ImportError: + raise ImportError( + "graphviz library required for image rendering. " + "Install with: pip install graphviz" + ) diff --git a/ij/renderers/mermaid.py b/ij/renderers/mermaid.py new file mode 100644 index 0000000..f475c59 --- /dev/null +++ b/ij/renderers/mermaid.py @@ -0,0 +1,101 @@ +"""Mermaid diagram renderer. + +Converts DiagramIR to Mermaid syntax, following the research recommendation +to use Mermaid for its GitHub integration and simplicity. +""" + +from typing import Dict +from ..core import DiagramIR, EdgeType, NodeType + + +class MermaidRenderer: + """Renders DiagramIR to Mermaid syntax. + + Supports flowchart diagrams with various node shapes and edge types. + """ + + # Map node types to Mermaid shapes + NODE_SHAPES: Dict[NodeType, tuple] = { + NodeType.PROCESS: ("[", "]"), # Rectangle + NodeType.DECISION: ("{", "}"), # Rhombus + NodeType.START: ("([", "])"), # Stadium + NodeType.END: ("([", "])"), # Stadium + NodeType.DATA: ("[(", ")]"), # Cylindrical + NodeType.SUBPROCESS: ("[[", "]]"), # Subroutine + NodeType.CUSTOM: ("[", "]"), # Default rectangle + } + + # Map edge types to Mermaid arrows + EDGE_ARROWS: Dict[EdgeType, str] = { + EdgeType.DIRECT: "-->", + EdgeType.CONDITIONAL: "-.->", + EdgeType.BIDIRECTIONAL: "<-->", + } + + def __init__(self, direction: str = "TD"): + """Initialize renderer. + + Args: + direction: Flow direction (TD=top-down, LR=left-right, etc.) + """ + self.direction = direction + + def render(self, diagram: DiagramIR) -> str: + """Render a DiagramIR to Mermaid syntax. + + Args: + diagram: The diagram to render + + Returns: + Mermaid syntax as a string + """ + if not diagram.validate(): + raise ValueError("Invalid diagram structure") + + lines = [f"flowchart {self.direction}"] + + # Add title if present + if "title" in diagram.metadata: + lines.insert(0, f"---\ntitle: {diagram.metadata['title']}\n---") + + # Render nodes + for node in diagram.nodes: + node_def = self._render_node(node) + lines.append(f" {node_def}") + + # Render edges + for edge in diagram.edges: + edge_def = self._render_edge(edge) + lines.append(f" {edge_def}") + + return "\n".join(lines) + + def _render_node(self, node) -> str: + """Render a single node to Mermaid syntax.""" + shape_start, shape_end = self.NODE_SHAPES[node.node_type] + # Sanitize label for Mermaid + label = node.label.replace('"', "'") + return f"{node.id}{shape_start}{label}{shape_end}" + + def _render_edge(self, edge) -> str: + """Render a single edge to Mermaid syntax.""" + arrow = self.EDGE_ARROWS[edge.edge_type] + + if edge.label: + # Edge with label + label = edge.label.replace('"', "'") + return f"{edge.source} {arrow}|{label}| {edge.target}" + else: + # Simple edge + return f"{edge.source} {arrow} {edge.target}" + + def render_to_file(self, diagram: DiagramIR, filename: str) -> None: + """Render diagram and save to file. + + Args: + diagram: The diagram to render + filename: Path to output file + """ + content = self.render(diagram) + with open(filename, "w") as f: + f.write(content) diff --git a/ij/renderers/plantuml.py b/ij/renderers/plantuml.py new file mode 100644 index 0000000..0342bc7 --- /dev/null +++ b/ij/renderers/plantuml.py @@ -0,0 +1,152 @@ +"""PlantUML diagram renderer. + +Converts DiagramIR to PlantUML syntax, supporting activity diagrams +which are ideal for process flows. +""" + +from typing import Dict +from ..core import DiagramIR, EdgeType, NodeType + + +class PlantUMLRenderer: + """Renders DiagramIR to PlantUML activity diagram syntax. + + PlantUML is the enterprise standard for comprehensive UML diagrams + with support for 25+ diagram types. + """ + + # Map node types to PlantUML syntax + NODE_SYNTAX: Dict[NodeType, str] = { + NodeType.START: "start", + NodeType.END: "stop", + NodeType.PROCESS: ":", + NodeType.DECISION: "if", + NodeType.DATA: ":", # Use note for data + NodeType.SUBPROCESS: ":", + NodeType.CUSTOM: ":", + } + + def __init__(self, use_skinparam: bool = True): + """Initialize renderer. + + Args: + use_skinparam: Whether to include modern styling + """ + self.use_skinparam = use_skinparam + + def render(self, diagram: DiagramIR) -> str: + """Render a DiagramIR to PlantUML syntax. + + Args: + diagram: The diagram to render + + Returns: + PlantUML syntax as a string + """ + if not diagram.validate(): + raise ValueError("Invalid diagram structure") + + lines = ["@startuml"] + + # Add title if present + if "title" in diagram.metadata: + lines.append(f"title {diagram.metadata['title']}") + lines.append("") + + # Add modern styling + if self.use_skinparam: + lines.extend( + [ + "skinparam ActivityFontSize 14", + "skinparam ActivityBorderColor #2C3E50", + "skinparam ActivityBackgroundColor #ECF0F1", + "", + ] + ) + + # Build a graph structure to understand flow + node_map = {node.id: node for node in diagram.nodes} + edges_from = {} + for edge in diagram.edges: + if edge.source not in edges_from: + edges_from[edge.source] = [] + edges_from[edge.source].append(edge) + + # Track rendered nodes to avoid duplicates + rendered = set() + + # Render in topological order if possible + from ..graph_ops import GraphOperations + + order = GraphOperations.topological_sort(diagram) + if not order: + # If there are cycles, just use node order + order = [node.id for node in diagram.nodes] + + for node_id in order: + if node_id in rendered: + continue + + node = node_map[node_id] + node_syntax = self._render_node(node) + if node_syntax: + lines.append(node_syntax) + rendered.add(node_id) + + # Render edges from this node + if node_id in edges_from: + for edge in edges_from[node_id]: + edge_syntax = self._render_edge(edge, node_map) + if edge_syntax: + lines.append(edge_syntax) + + lines.append("@enduml") + return "\n".join(lines) + + def _render_node(self, node) -> str: + """Render a single node to PlantUML syntax.""" + if node.node_type == NodeType.START: + return "start" + elif node.node_type == NodeType.END: + return "stop" + elif node.node_type == NodeType.DECISION: + # Decision nodes are rendered with edges + return None + elif node.node_type == NodeType.DATA: + # Render as activity with note + sanitized_label = node.label.replace(":", "\\:") + return f":{sanitized_label};\nnote right: Data storage" + else: + # Regular activity + sanitized_label = node.label.replace(":", "\\:") + return f":{sanitized_label};" + + def _render_edge(self, edge, node_map) -> str: + """Render an edge (primarily for decisions).""" + source_node = node_map.get(edge.source) + target_node = node_map.get(edge.target) + + if source_node and source_node.node_type == NodeType.DECISION: + # This is a decision branch + label = edge.label if edge.label else "" + target_label = ( + target_node.label.replace(":", "\\:") if target_node else edge.target + ) + + if label: + return f"if ({source_node.label}) then ({label})\n :{target_label};\nendif" + else: + return None + + return None + + def render_to_file(self, diagram: DiagramIR, filename: str) -> None: + """Render diagram and save to file. + + Args: + diagram: The diagram to render + filename: Path to output file + """ + content = self.render(diagram) + with open(filename, "w") as f: + f.write(content) diff --git a/ij/renderers/sequence.py b/ij/renderers/sequence.py new file mode 100644 index 0000000..213410e --- /dev/null +++ b/ij/renderers/sequence.py @@ -0,0 +1,304 @@ +"""Sequence diagram renderer for Mermaid format. + +Renders DiagramIR as Mermaid sequence diagrams for showing interactions +and message flows between participants over time. +""" + +import ast +import re +from typing import Dict, List, Set + +from ..core import DiagramIR, Edge, EdgeType, Node + + +class SequenceDiagramRenderer: + """Render DiagramIR as Mermaid sequence diagram. + + Maps DiagramIR concepts to sequence diagrams: + - Nodes -> Participants + - Edges -> Messages between participants + - Edge labels -> Message content + - EdgeType.DIRECT -> Solid arrows (synchronous) + - EdgeType.CONDITIONAL -> Dashed arrows (asynchronous/return) + - EdgeType.BIDIRECTIONAL -> Bidirectional arrows + """ + + def __init__(self): + """Initialize renderer.""" + pass + + def render(self, diagram: DiagramIR) -> str: + """Render DiagramIR as Mermaid sequence diagram. + + Args: + diagram: DiagramIR to render + + Returns: + Mermaid sequence diagram syntax + + Example: + >>> from ij import DiagramIR, Node, Edge + >>> diagram = DiagramIR() + >>> diagram.add_node(Node(id="user", label="User")) + >>> diagram.add_node(Node(id="api", label="API")) + >>> diagram.add_edge(Edge(source="user", target="api", label="Request")) + >>> renderer = SequenceDiagramRenderer() + >>> print(renderer.render(diagram)) + sequenceDiagram + participant user as User + participant api as API + user->>api: Request + """ + if not diagram.validate(): + raise ValueError("Invalid diagram structure") + + lines = ["sequenceDiagram"] + + # Add title if present + if "title" in diagram.metadata: + lines.append(f" title {diagram.metadata['title']}") + + # Collect all participants (nodes) + participants = self._collect_participants(diagram) + + # Declare participants + for node in diagram.nodes: + if node.id in participants: + # Use label if different from id, otherwise just id + if node.label and node.label != node.id: + lines.append(f" participant {node.id} as {node.label}") + else: + lines.append(f" participant {node.id}") + + # Add messages (edges) + for edge in diagram.edges: + arrow = self._get_arrow_type(edge.edge_type) + message = edge.label if edge.label else "" + lines.append(f" {edge.source}{arrow}{edge.target}: {message}") + + return "\n".join(lines) + + def _collect_participants(self, diagram: DiagramIR) -> Set[str]: + """Collect all participant IDs from nodes and edges. + + Args: + diagram: DiagramIR to analyze + + Returns: + Set of participant IDs + """ + participants = set() + + # Add all nodes + for node in diagram.nodes: + participants.add(node.id) + + # Add any participants referenced in edges that aren't in nodes + for edge in diagram.edges: + participants.add(edge.source) + participants.add(edge.target) + + return participants + + def _get_arrow_type(self, edge_type: EdgeType) -> str: + """Get Mermaid arrow type for edge type. + + Args: + edge_type: EdgeType to convert + + Returns: + Mermaid arrow syntax + """ + arrow_map = { + EdgeType.DIRECT: "->>", # Solid arrow (synchronous) + EdgeType.CONDITIONAL: "-->>", # Dashed arrow (asynchronous/return) + EdgeType.BIDIRECTIONAL: "<<->>", # Bidirectional + } + return arrow_map.get(edge_type, "->>") + + def render_with_notes( + self, diagram: DiagramIR, notes: Dict[str, List[str]] + ) -> str: + """Render sequence diagram with notes. + + Args: + diagram: DiagramIR to render + notes: Dict mapping participant IDs to list of notes + + Returns: + Mermaid sequence diagram with notes + + Example: + >>> notes = {"user": ["Note about user"], "api": ["API note"]} + >>> renderer.render_with_notes(diagram, notes) + """ + lines = self.render(diagram).split("\n") + + # Insert notes after participant declarations + insert_index = len([l for l in lines if l.strip().startswith("participant")]) + if "title" in diagram.metadata: + insert_index += 1 # Account for title line + insert_index += 1 # Account for sequenceDiagram line + + note_lines = [] + for participant_id, participant_notes in notes.items(): + for note in participant_notes: + note_lines.append(f" Note over {participant_id}: {note}") + + # Insert notes + lines = lines[:insert_index] + note_lines + lines[insert_index:] + + return "\n".join(lines) + + def render_with_activations( + self, diagram: DiagramIR, activations: List[tuple] + ) -> str: + """Render sequence diagram with participant activations. + + Args: + diagram: DiagramIR to render + activations: List of (participant_id, "activate"/"deactivate") tuples + + Returns: + Mermaid sequence diagram with activations + + Example: + >>> activations = [("api", "activate"), ("api", "deactivate")] + >>> renderer.render_with_activations(diagram, activations) + """ + base = self.render(diagram) + lines = base.split("\n") + + # Add activations at the end + for participant_id, action in activations: + if action == "activate": + lines.append(f" activate {participant_id}") + elif action == "deactivate": + lines.append(f" deactivate {participant_id}") + + return "\n".join(lines) + + +class InteractionAnalyzer: + """Analyze code or text to identify interaction patterns for sequence diagrams.""" + + def __init__(self): + """Initialize analyzer.""" + pass + + def analyze_function_calls( + self, caller: str, code: str + ) -> DiagramIR: + """Analyze function calls to create a sequence diagram. + + Args: + caller: The calling function/component name + code: Python code to analyze + + Returns: + DiagramIR representing the call sequence + + Example: + >>> analyzer = InteractionAnalyzer() + >>> code = ''' + ... api.authenticate(user) + ... db.query(user_id) + ... cache.store(result) + ... ''' + >>> diagram = analyzer.analyze_function_calls("client", code) + """ + from ..core import Edge, Node + + diagram = DiagramIR(metadata={"type": "sequence"}) + + # Add caller as first participant + diagram.add_node(Node(id=caller, label=caller)) + + # Parse the code + try: + tree = ast.parse(code) + except SyntaxError: + # If it's not valid Python, return empty diagram + return diagram + + # Find all function calls of the form object.method() + for node in ast.walk(tree): + if isinstance(node, ast.Call): + if isinstance(node.func, ast.Attribute): + # This is a method call like obj.method() + obj_name = self._get_object_name(node.func.value) + method_name = node.func.attr + + # Add participant if not exists + if not any(n.id == obj_name for n in diagram.nodes): + diagram.add_node(Node(id=obj_name, label=obj_name)) + + # Add message + message = f"{method_name}()" + diagram.add_edge( + Edge(source=caller, target=obj_name, label=message) + ) + + return diagram + + def _get_object_name(self, node: ast.expr) -> str: + """Extract object name from AST node.""" + if isinstance(node, ast.Name): + return node.id + elif isinstance(node, ast.Attribute): + return self._get_object_name(node.value) + "." + node.attr + else: + return "unknown" + + def from_text_description(self, text: str) -> DiagramIR: + """Create sequence diagram from text description. + + Args: + text: Natural language description of interactions + + Returns: + DiagramIR representing the interaction sequence + + Example: + >>> analyzer = InteractionAnalyzer() + >>> text = "User sends request to API. API queries Database. Database returns data to API. API responds to User." + >>> diagram = analyzer.from_text_description(text) + """ + from ..core import Edge, Node + + diagram = DiagramIR(metadata={"type": "sequence"}) + + # Simple pattern matching for "A sends/queries/calls/requests B" + # Pattern: "Subject verb Object" or "Subject verb message to Object" + patterns = [ + r"(\w+)\s+(?:sends?|queries?|calls?|requests?|asks?)\s+(?:to\s+)?(\w+)(?:\s*:\s*(.+?)(?:\.|$))?", + r"(\w+)\s+(?:responds?|returns?|replies?)\s+(?:to\s+)?(\w+)(?:\s*:\s*(.+?)(?:\.|$))?", + ] + + sentences = text.split(".") + + for sentence in sentences: + sentence = sentence.strip() + if not sentence: + continue + + for pattern in patterns: + match = re.search(pattern, sentence, re.IGNORECASE) + if match: + source = match.group(1) + target = match.group(2) + message = match.group(3) if len(match.groups()) > 2 and match.group(3) else "" + + # Add participants if not exists + if not any(n.id == source for n in diagram.nodes): + diagram.add_node(Node(id=source, label=source)) + if not any(n.id == target for n in diagram.nodes): + diagram.add_node(Node(id=target, label=target)) + + # Add message + diagram.add_edge( + Edge(source=source, target=target, label=message.strip()) + ) + break + + return diagram diff --git a/ij/transforms.py b/ij/transforms.py new file mode 100644 index 0000000..5eb489d --- /dev/null +++ b/ij/transforms.py @@ -0,0 +1,451 @@ +"""Diagram transformation and optimization utilities. + +Provides operations for manipulating, simplifying, and optimizing diagrams. +""" + +from typing import Callable, List, Optional, Set + +from .core import DiagramIR, Edge, EdgeType, Node, NodeType + + +class DiagramTransforms: + """Transform and optimize diagram structures.""" + + @staticmethod + def simplify(diagram: DiagramIR, remove_isolated: bool = True) -> DiagramIR: + """Simplify diagram by removing redundant nodes and edges. + + Args: + diagram: DiagramIR to simplify + remove_isolated: Remove nodes with no connections + + Returns: + Simplified DiagramIR + + Example: + >>> from ij import DiagramIR, Node, Edge + >>> diagram = DiagramIR() + >>> diagram.add_node(Node(id="a", label="A")) + >>> diagram.add_node(Node(id="isolated", label="Isolated")) + >>> diagram.add_edge(Edge(source="a", target="b")) + >>> simplified = DiagramTransforms.simplify(diagram) + >>> len(simplified.nodes) # isolated node removed + 2 + """ + new_diagram = DiagramIR(metadata=diagram.metadata.copy()) + + # Find connected nodes + connected_nodes = set() + for edge in diagram.edges: + connected_nodes.add(edge.source) + connected_nodes.add(edge.target) + + # Add nodes + for node in diagram.nodes: + if remove_isolated: + # Only add if connected or is START/END + if node.id in connected_nodes or node.node_type in [ + NodeType.START, + NodeType.END, + ]: + new_diagram.add_node(node) + else: + new_diagram.add_node(node) + + # Remove duplicate edges + added_edges = set() + for edge in diagram.edges: + edge_sig = (edge.source, edge.target, edge.label, edge.edge_type) + if edge_sig not in added_edges: + new_diagram.add_edge(edge) + added_edges.add(edge_sig) + + return new_diagram + + @staticmethod + def merge_sequential_nodes(diagram: DiagramIR, separator: str = " → ") -> DiagramIR: + """Merge sequential nodes into single nodes. + + Combines nodes that form a linear sequence with no branching. + + Args: + diagram: DiagramIR to transform + separator: String to join labels + + Returns: + DiagramIR with merged nodes + """ + from collections import defaultdict + + # Build adjacency lists + outgoing = defaultdict(list) + incoming = defaultdict(list) + + for edge in diagram.edges: + outgoing[edge.source].append(edge.target) + incoming[edge.target].append(edge.source) + + # Find mergeable chains: nodes with exactly 1 incoming and 1 outgoing edge + merged = set() + new_diagram = DiagramIR(metadata=diagram.metadata.copy()) + + node_map = {node.id: node for node in diagram.nodes} + + for node in diagram.nodes: + if node.id in merged: + continue + + # Start a chain if this node has 1 outgoing edge + if len(outgoing.get(node.id, [])) == 1: + chain = [node.id] + current = outgoing[node.id][0] + + # Extend chain while next node has 1 in and 1 out + while ( + current in node_map + and len(incoming.get(current, [])) == 1 + and len(outgoing.get(current, [])) == 1 + and current not in merged + ): + chain.append(current) + merged.add(current) + current = outgoing[current][0] + + # If we have a chain of at least 2 nodes, merge them + if len(chain) >= 2: + # Create merged node + labels = [node_map[nid].label for nid in chain] + merged_label = separator.join(labels) + merged_node = Node( + id=chain[0], + label=merged_label, + node_type=node_map[chain[0]].node_type, + ) + new_diagram.add_node(merged_node) + + # Mark all in chain as merged + for nid in chain: + merged.add(nid) + + # Add edge to next node after chain + if current in node_map: + new_diagram.add_edge(Edge(source=chain[0], target=current)) + else: + # Single node, add as-is + if node.id not in merged: + new_diagram.add_node(node) + else: + # Node not part of chain, add as-is + if node.id not in merged: + new_diagram.add_node(node) + + # Add edges that don't involve merged nodes + for edge in diagram.edges: + if edge.source not in merged and edge.target not in merged: + new_diagram.add_edge(edge) + + return new_diagram + + @staticmethod + def filter_by_node_type( + diagram: DiagramIR, node_types: List[NodeType], keep: bool = True + ) -> DiagramIR: + """Filter diagram by node types. + + Args: + diagram: DiagramIR to filter + node_types: List of NodeTypes to filter + keep: If True, keep only these types; if False, remove these types + + Returns: + Filtered DiagramIR + + Example: + >>> # Keep only PROCESS nodes + >>> filtered = DiagramTransforms.filter_by_node_type( + ... diagram, [NodeType.PROCESS], keep=True + ... ) + """ + new_diagram = DiagramIR(metadata=diagram.metadata.copy()) + + # Filter nodes + kept_node_ids = set() + for node in diagram.nodes: + should_keep = (node.node_type in node_types) == keep + if should_keep: + new_diagram.add_node(node) + kept_node_ids.add(node.id) + + # Add edges between kept nodes + for edge in diagram.edges: + if edge.source in kept_node_ids and edge.target in kept_node_ids: + new_diagram.add_edge(edge) + + return new_diagram + + @staticmethod + def extract_subgraph( + diagram: DiagramIR, root_node_id: str, max_depth: Optional[int] = None + ) -> DiagramIR: + """Extract subgraph starting from a root node. + + Args: + diagram: Source DiagramIR + root_node_id: ID of root node + max_depth: Maximum depth to traverse (None = unlimited) + + Returns: + DiagramIR containing subgraph + + Example: + >>> # Extract subgraph from node 'start' with depth 2 + >>> subgraph = DiagramTransforms.extract_subgraph(diagram, "start", max_depth=2) + """ + from collections import deque + + # Find root node + root_node = None + for node in diagram.nodes: + if node.id == root_node_id: + root_node = node + break + + if root_node is None: + raise ValueError(f"Root node '{root_node_id}' not found") + + new_diagram = DiagramIR(metadata=diagram.metadata.copy()) + + # BFS to find reachable nodes + visited = set() + queue = deque([(root_node_id, 0)]) + visited.add(root_node_id) + + # Build edge map + edge_map = {} + for edge in diagram.edges: + if edge.source not in edge_map: + edge_map[edge.source] = [] + edge_map[edge.source].append(edge) + + # Traverse + while queue: + node_id, depth = queue.popleft() + + # Add node + for node in diagram.nodes: + if node.id == node_id: + new_diagram.add_node(node) + break + + # Check depth limit + if max_depth is not None and depth >= max_depth: + continue + + # Add outgoing edges and visit neighbors + if node_id in edge_map: + for edge in edge_map[node_id]: + new_diagram.add_edge(edge) + if edge.target not in visited: + visited.add(edge.target) + queue.append((edge.target, depth + 1)) + + return new_diagram + + @staticmethod + def merge_diagrams(diagrams: List[DiagramIR], title: Optional[str] = None) -> DiagramIR: + """Merge multiple diagrams into one. + + Args: + diagrams: List of DiagramIR to merge + title: Optional title for merged diagram + + Returns: + Merged DiagramIR + + Example: + >>> diagram1 = DiagramIR() + >>> diagram2 = DiagramIR() + >>> merged = DiagramTransforms.merge_diagrams([diagram1, diagram2]) + """ + metadata = {"merged": True} + if title: + metadata["title"] = title + + merged = DiagramIR(metadata=metadata) + + # Track node IDs to avoid duplicates + added_node_ids = set() + + for diagram in diagrams: + # Add nodes + for node in diagram.nodes: + if node.id not in added_node_ids: + merged.add_node(node) + added_node_ids.add(node.id) + + # Add edges + for edge in diagram.edges: + merged.add_edge(edge) + + return merged + + @staticmethod + def reverse_edges(diagram: DiagramIR) -> DiagramIR: + """Reverse all edge directions in the diagram. + + Args: + diagram: DiagramIR to reverse + + Returns: + DiagramIR with reversed edges + + Example: + >>> reversed_diagram = DiagramTransforms.reverse_edges(diagram) + """ + new_diagram = DiagramIR(metadata=diagram.metadata.copy()) + + # Add all nodes + for node in diagram.nodes: + new_diagram.add_node(node) + + # Reverse edges + for edge in diagram.edges: + reversed_edge = Edge( + source=edge.target, + target=edge.source, + label=edge.label, + edge_type=edge.edge_type, + ) + new_diagram.add_edge(reversed_edge) + + return new_diagram + + @staticmethod + def apply_node_filter( + diagram: DiagramIR, predicate: Callable[[Node], bool] + ) -> DiagramIR: + """Filter nodes using a custom predicate function. + + Args: + diagram: DiagramIR to filter + predicate: Function that returns True for nodes to keep + + Returns: + Filtered DiagramIR + + Example: + >>> # Keep only nodes with labels containing "error" + >>> filtered = DiagramTransforms.apply_node_filter( + ... diagram, lambda n: "error" in n.label.lower() + ... ) + """ + new_diagram = DiagramIR(metadata=diagram.metadata.copy()) + + # Filter nodes + kept_node_ids = set() + for node in diagram.nodes: + if predicate(node): + new_diagram.add_node(node) + kept_node_ids.add(node.id) + + # Add edges between kept nodes + for edge in diagram.edges: + if edge.source in kept_node_ids and edge.target in kept_node_ids: + new_diagram.add_edge(edge) + + return new_diagram + + @staticmethod + def find_cycles(diagram: DiagramIR) -> List[List[str]]: + """Find all cycles in the diagram. + + Args: + diagram: DiagramIR to analyze + + Returns: + List of cycles, where each cycle is a list of node IDs + + Example: + >>> cycles = DiagramTransforms.find_cycles(diagram) + >>> if cycles: + ... print(f"Found {len(cycles)} cycles") + """ + from collections import defaultdict + + # Build adjacency list + graph = defaultdict(list) + for edge in diagram.edges: + graph[edge.source].append(edge.target) + + cycles = [] + visited = set() + rec_stack = set() + path = [] + + def dfs(node): + visited.add(node) + rec_stack.add(node) + path.append(node) + + for neighbor in graph.get(node, []): + if neighbor not in visited: + dfs(neighbor) + elif neighbor in rec_stack: + # Found a cycle + cycle_start = path.index(neighbor) + cycle = path[cycle_start:] + cycles.append(cycle.copy()) + + path.pop() + rec_stack.remove(node) + + # Check all nodes + for node in diagram.nodes: + if node.id not in visited: + dfs(node.id) + + return cycles + + @staticmethod + def get_statistics(diagram: DiagramIR) -> dict: + """Get statistics about the diagram. + + Args: + diagram: DiagramIR to analyze + + Returns: + Dictionary containing diagram statistics + + Example: + >>> stats = DiagramTransforms.get_statistics(diagram) + >>> print(f"Nodes: {stats['node_count']}, Edges: {stats['edge_count']}") + """ + from collections import Counter + + node_type_counts = Counter(node.node_type for node in diagram.nodes) + edge_type_counts = Counter(edge.edge_type for edge in diagram.edges) + + # Find nodes by degree + incoming_degree = Counter() + outgoing_degree = Counter() + + for edge in diagram.edges: + outgoing_degree[edge.source] += 1 + incoming_degree[edge.target] += 1 + + return { + "node_count": len(diagram.nodes), + "edge_count": len(diagram.edges), + "node_types": dict(node_type_counts), + "edge_types": dict(edge_type_counts), + "max_incoming_degree": max(incoming_degree.values()) if incoming_degree else 0, + "max_outgoing_degree": max(outgoing_degree.values()) if outgoing_degree else 0, + "isolated_nodes": len( + [ + n + for n in diagram.nodes + if incoming_degree[n.id] == 0 and outgoing_degree[n.id] == 0 + ] + ), + "has_cycles": len(DiagramTransforms.find_cycles(diagram)) > 0, + } diff --git a/pyproject.toml b/pyproject.toml index c2ea478..ddff69a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,10 @@ readme = "README.md" requires-python = ">=3.10" keywords = [] authors = [] -dependencies = [] +dependencies = [ + "networkx>=3.0", + "graphviz>=0.20", +] [project.license] text = "mit" @@ -21,6 +24,9 @@ text = "mit" Homepage = "https://github.com/i2mint/ij" Repository = "https://github.com/i2mint/ij" +[project.scripts] +ij = "ij.cli:main" + [project.optional-dependencies] dev = [ "pytest>=7.0", @@ -31,6 +37,9 @@ docs = [ "sphinx>=6.0", "sphinx-rtd-theme>=1.0", ] +ai = [ + "openai>=1.0.0", +] [tool.ruff] line-length = 88 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..07ed1aa --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for Idea Junction.""" diff --git a/tests/test_converters.py b/tests/test_converters.py new file mode 100644 index 0000000..92555e5 --- /dev/null +++ b/tests/test_converters.py @@ -0,0 +1,77 @@ +"""Tests for text converters.""" + +import pytest +from ij.converters import SimpleTextConverter +from ij.core import NodeType + + +def test_simple_text_converter(): + """Test basic text to diagram conversion.""" + converter = SimpleTextConverter() + diagram = converter.convert("Start -> Process -> End") + + assert len(diagram.nodes) == 3 + assert len(diagram.edges) == 2 + assert diagram.validate() + + +def test_converter_with_title(): + """Test conversion with title.""" + converter = SimpleTextConverter() + diagram = converter.convert("Step 1 -> Step 2", title="My Process") + + assert diagram.metadata["title"] == "My Process" + + +def test_converter_node_type_inference(): + """Test that node types are inferred correctly.""" + converter = SimpleTextConverter() + diagram = converter.convert("Start -> Decide if ready -> Process data -> End") + + # Check inferred types + nodes = {node.label: node.node_type for node in diagram.nodes} + assert nodes["Start"] == NodeType.START + assert nodes["Decide if ready"] == NodeType.DECISION + assert nodes["Process data"] == NodeType.PROCESS + assert nodes["End"] == NodeType.END + + +def test_converter_with_newlines(): + """Test conversion with newline-separated steps.""" + text = """Start +Process data +Make decision +End""" + converter = SimpleTextConverter() + diagram = converter.convert(text) + + assert len(diagram.nodes) == 4 + assert len(diagram.edges) == 3 + + +def test_converter_with_arrows(): + """Test different arrow styles.""" + converter = SimpleTextConverter() + + # Test -> arrows + diagram1 = converter.convert("A -> B -> C") + assert len(diagram1.nodes) == 3 + + # Test → unicode arrows + diagram2 = converter.convert("A → B → C") + assert len(diagram2.nodes) == 3 + + +def test_converter_keywords(): + """Test keyword-based node type inference.""" + test_cases = [ + ("Start process", NodeType.START), + ("Finish task", NodeType.END), + ("Check if valid", NodeType.DECISION), + ("Store in database", NodeType.DATA), + ] + + converter = SimpleTextConverter() + for text, expected_type in test_cases: + diagram = converter.convert(text) + assert diagram.nodes[0].node_type == expected_type diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..5922309 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,92 @@ +"""Tests for core data structures.""" + +import pytest +from ij.core import DiagramIR, Edge, EdgeType, Node, NodeType + + +def test_node_creation(): + """Test creating nodes.""" + node = Node(id="n1", label="Test Node", node_type=NodeType.PROCESS) + assert node.id == "n1" + assert node.label == "Test Node" + assert node.node_type == NodeType.PROCESS + + +def test_edge_creation(): + """Test creating edges.""" + edge = Edge(source="n1", target="n2", label="test", edge_type=EdgeType.DIRECT) + assert edge.source == "n1" + assert edge.target == "n2" + assert edge.label == "test" + + +def test_diagram_ir(): + """Test DiagramIR operations.""" + diagram = DiagramIR() + + # Add nodes + n1 = Node(id="n1", label="Start", node_type=NodeType.START) + n2 = Node(id="n2", label="End", node_type=NodeType.END) + diagram.add_node(n1) + diagram.add_node(n2) + + # Add edge + e1 = Edge(source="n1", target="n2") + diagram.add_edge(e1) + + assert len(diagram.nodes) == 2 + assert len(diagram.edges) == 1 + assert diagram.get_node("n1") == n1 + assert diagram.validate() + + +def test_diagram_validation(): + """Test diagram validation.""" + diagram = DiagramIR() + + # Valid diagram + n1 = Node(id="n1", label="A") + n2 = Node(id="n2", label="B") + diagram.add_node(n1) + diagram.add_node(n2) + diagram.add_edge(Edge(source="n1", target="n2")) + assert diagram.validate() + + # Invalid: duplicate node IDs + diagram.add_node(Node(id="n1", label="Duplicate")) + assert not diagram.validate() + + # Invalid: edge references non-existent node + diagram2 = DiagramIR() + diagram2.add_node(Node(id="n1", label="A")) + diagram2.add_edge(Edge(source="n1", target="n999")) + assert not diagram2.validate() + + +def test_node_types(): + """Test all node types.""" + for node_type in NodeType: + node = Node(id="test", label="Test", node_type=node_type) + assert node.node_type == node_type + + +def test_edge_types(): + """Test all edge types.""" + for edge_type in EdgeType: + edge = Edge(source="n1", target="n2", edge_type=edge_type) + assert edge.edge_type == edge_type + + +def test_metadata(): + """Test metadata storage.""" + node = Node( + id="n1", label="Test", metadata={"color": "blue", "custom": "value"} + ) + assert node.metadata["color"] == "blue" + assert node.metadata["custom"] == "value" + + edge = Edge(source="n1", target="n2", metadata={"weight": 5}) + assert edge.metadata["weight"] == 5 + + diagram = DiagramIR(metadata={"title": "Test Diagram"}) + assert diagram.metadata["title"] == "Test Diagram" diff --git a/tests/test_d2_parser.py b/tests/test_d2_parser.py new file mode 100644 index 0000000..0e4a4e0 --- /dev/null +++ b/tests/test_d2_parser.py @@ -0,0 +1,346 @@ +"""Tests for D2 parser.""" + +import pytest + +from ij import D2Parser +from ij.core import DiagramIR, EdgeType, NodeType + + +def test_d2_parser_simple_nodes(): + """Test parsing simple D2 nodes.""" + d2_code = """ +n1: "Start" { + shape: oval +} +n2: "Process" { + shape: rectangle +} +n3: "End" { + shape: oval +} +n1 -> n2 +n2 -> n3 +""" + parser = D2Parser() + diagram = parser.parse(d2_code) + + assert len(diagram.nodes) == 3 + assert len(diagram.edges) == 2 + + # Check node types + assert diagram.nodes[0].label == "Start" + assert diagram.nodes[0].node_type == NodeType.START + + assert diagram.nodes[1].label == "Process" + assert diagram.nodes[1].node_type == NodeType.PROCESS + + assert diagram.nodes[2].label == "End" + assert diagram.nodes[2].node_type == NodeType.END + + +def test_d2_parser_decision_nodes(): + """Test parsing decision/diamond nodes.""" + d2_code = """ +n1: "Check status" { + shape: diamond +} +n2: "Success" { + shape: rectangle +} +n3: "Failure" { + shape: rectangle +} +n1 -> n2: "Yes" +n1 -> n3: "No" +""" + parser = D2Parser() + diagram = parser.parse(d2_code) + + assert len(diagram.nodes) == 3 + assert diagram.nodes[0].node_type == NodeType.DECISION + assert diagram.nodes[0].label == "Check status" + + # Check edge labels + assert diagram.edges[0].label == "Yes" + assert diagram.edges[1].label == "No" + + +def test_d2_parser_data_nodes(): + """Test parsing data/cylinder nodes.""" + d2_code = """ +n1: "Database" { + shape: cylinder +} +n2: "Save data" { + shape: rectangle +} +n2 -> n1 +""" + parser = D2Parser() + diagram = parser.parse(d2_code) + + assert len(diagram.nodes) == 2 + assert diagram.nodes[0].node_type == NodeType.DATA + assert diagram.nodes[0].label == "Database" + + +def test_d2_parser_direction(): + """Test parsing direction metadata.""" + d2_code = """ +direction: right +n1: "A" +n2: "B" +n1 -> n2 +""" + parser = D2Parser() + diagram = parser.parse(d2_code) + + assert diagram.metadata.get("direction") == "LR" + + +def test_d2_parser_all_directions(): + """Test parsing all direction options.""" + directions = { + "down": "TD", + "up": "BT", + "right": "LR", + "left": "RL", + } + + for d2_dir, expected in directions.items(): + d2_code = f"direction: {d2_dir}\nn1: \"A\"\nn2: \"B\"\nn1 -> n2" + parser = D2Parser() + diagram = parser.parse(d2_code) + assert diagram.metadata.get("direction") == expected + + +def test_d2_parser_simple_nodes_without_block(): + """Test parsing simple nodes without property blocks.""" + d2_code = """ +n1: "First step" +n2: "Second step" +n1 -> n2 +""" + parser = D2Parser() + diagram = parser.parse(d2_code) + + assert len(diagram.nodes) == 2 + assert diagram.nodes[0].label == "First step" + assert diagram.nodes[0].node_type == NodeType.PROCESS + assert diagram.nodes[1].label == "Second step" + + +def test_d2_parser_edges_with_labels(): + """Test parsing edges with labels.""" + d2_code = """ +n1: "A" +n2: "B" +n3: "C" +n1 -> n2: "step 1" +n2 -> n3: "step 2" +""" + parser = D2Parser() + diagram = parser.parse(d2_code) + + assert len(diagram.edges) == 2 + assert diagram.edges[0].label == "step 1" + assert diagram.edges[1].label == "step 2" + + +def test_d2_parser_conditional_edges(): + """Test parsing conditional/dashed edges.""" + d2_code = """ +n1: "A" +n2: "B" +n1 -> n2 {style.stroke-dash: 3} +""" + parser = D2Parser() + diagram = parser.parse(d2_code) + + assert len(diagram.edges) == 1 + assert diagram.edges[0].edge_type == EdgeType.CONDITIONAL + + +def test_d2_parser_bidirectional_edges(): + """Test parsing bidirectional edges.""" + d2_code = """ +n1: "A" +n2: "B" +n1 <-> n2 +""" + parser = D2Parser() + diagram = parser.parse(d2_code) + + assert len(diagram.edges) == 1 + assert diagram.edges[0].edge_type == EdgeType.BIDIRECTIONAL + + +def test_d2_parser_auto_create_nodes(): + """Test that nodes are auto-created if referenced in edges.""" + d2_code = """ +n1 -> n2 +n2 -> n3 +""" + parser = D2Parser() + diagram = parser.parse(d2_code) + + assert len(diagram.nodes) == 3 + # Auto-created nodes should have their ID as label + assert diagram.nodes[0].label == "n1" + assert diagram.nodes[1].label == "n2" + assert diagram.nodes[2].label == "n3" + + +def test_d2_parser_start_end_inference(): + """Test inference of START/END nodes based on edges.""" + d2_code = """ +n1: "First" { + shape: oval +} +n2: "Middle" { + shape: rectangle +} +n3: "Last" { + shape: oval +} +n1 -> n2 +n2 -> n3 +""" + parser = D2Parser() + diagram = parser.parse(d2_code) + + # n1 has no incoming edges -> START + # n3 has no outgoing edges -> END + assert diagram.nodes[0].node_type == NodeType.START + assert diagram.nodes[2].node_type == NodeType.END + + +def test_d2_parser_empty_input(): + """Test parsing empty D2 input.""" + parser = D2Parser() + diagram = parser.parse("") + + assert len(diagram.nodes) == 0 + assert len(diagram.edges) == 0 + + +def test_d2_parser_comments(): + """Test parsing D2 with comments.""" + d2_code = """ +# This is a comment +n1: "Start" +# Another comment +n2: "End" +n1 -> n2 +""" + parser = D2Parser() + diagram = parser.parse(d2_code) + + assert len(diagram.nodes) == 2 + assert len(diagram.edges) == 1 + + +def test_d2_parser_complex_flowchart(): + """Test parsing a complex D2 flowchart.""" + d2_code = """ +direction: down + +start: "Begin Process" { + shape: oval +} + +check: "Validate Input" { + shape: diamond +} + +process: "Process Data" { + shape: rectangle +} + +save: "Save to DB" { + shape: cylinder +} + +end: "Complete" { + shape: oval +} + +start -> check +check -> process: "Valid" +check -> end: "Invalid" +process -> save +save -> end +""" + parser = D2Parser() + diagram = parser.parse(d2_code) + + assert len(diagram.nodes) == 5 + assert len(diagram.edges) == 5 + assert diagram.metadata.get("direction") == "TD" + + # Check specific node types + node_map = {node.label: node for node in diagram.nodes} + assert node_map["Begin Process"].node_type == NodeType.START + assert node_map["Validate Input"].node_type == NodeType.DECISION + assert node_map["Process Data"].node_type == NodeType.PROCESS + assert node_map["Save to DB"].node_type == NodeType.DATA + assert node_map["Complete"].node_type == NodeType.END + + +def test_d2_parser_roundtrip_with_renderer(): + """Test roundtrip: Mermaid -> IR -> D2 -> IR.""" + from ij import D2Renderer, MermaidParser + + mermaid_code = """ +flowchart TD + start([Start]) + process[Process Data] + decision{Check Status} + end_node([End]) + + start --> process + process --> decision + decision -->|Success| end_node + decision -->|Failure| process +""" + + # Parse Mermaid + mermaid_parser = MermaidParser() + diagram1 = mermaid_parser.parse(mermaid_code) + + # Render to D2 + d2_renderer = D2Renderer() + d2_code = d2_renderer.render(diagram1) + + # Parse D2 back + d2_parser = D2Parser() + diagram2 = d2_parser.parse(d2_code) + + # Should have same structure + assert len(diagram1.nodes) == len(diagram2.nodes) + assert len(diagram1.edges) == len(diagram2.edges) + + +def test_d2_parser_file(): + """Test parsing D2 from file.""" + import tempfile + + d2_code = """ +n1: "Start" { + shape: oval +} +n2: "End" { + shape: oval +} +n1 -> n2 +""" + + with tempfile.NamedTemporaryFile(mode="w", suffix=".d2", delete=False) as f: + f.write(d2_code) + f.flush() + + parser = D2Parser() + diagram = parser.parse_file(f.name) + + assert len(diagram.nodes) == 2 + assert len(diagram.edges) == 1 diff --git a/tests/test_enhanced_converter.py b/tests/test_enhanced_converter.py new file mode 100644 index 0000000..0246bb9 --- /dev/null +++ b/tests/test_enhanced_converter.py @@ -0,0 +1,104 @@ +"""Tests for enhanced text converter.""" + +import pytest +from ij.converters import EnhancedTextConverter +from ij.core import NodeType + + +def test_enhanced_simple_flow(): + """Test basic flow conversion.""" + converter = EnhancedTextConverter() + diagram = converter.convert("Start -> Process -> End") + + assert len(diagram.nodes) == 3 + assert diagram.validate() + + +def test_enhanced_conditional(): + """Test conditional branch parsing.""" + converter = EnhancedTextConverter() + text = "Start -> Check user. If authenticated: Show dashboard, else: Show login" + diagram = converter.convert(text) + + # Should have: Start, Check user (decision), Show dashboard, Show login + assert len(diagram.nodes) >= 3 + assert diagram.validate() + + # Find decision node + decision_nodes = [n for n in diagram.nodes if n.node_type == NodeType.DECISION] + assert len(decision_nodes) > 0 + + # Check for Yes/No edges + edge_labels = [e.label for e in diagram.edges if e.label] + assert "Yes" in edge_labels or "No" in edge_labels + + +def test_enhanced_parallel(): + """Test parallel flow parsing.""" + converter = EnhancedTextConverter() + text = "Start -> [parallel: Process A, Process B, Process C] -> End" + diagram = converter.convert(text) + + assert len(diagram.nodes) >= 5 # Start + 3 parallel + End + assert diagram.validate() + + +def test_enhanced_loop(): + """Test loop parsing.""" + converter = EnhancedTextConverter() + text = "Start while data available: Process item" + diagram = converter.convert(text) + + # Should have decision and loop-back edge + assert diagram.validate() + + # Find conditional edges (loop back) + from ij.core import EdgeType + + conditional_edges = [e for e in diagram.edges if e.edge_type == EdgeType.CONDITIONAL] + assert len(conditional_edges) > 0 + + +def test_enhanced_keywords(): + """Test keyword-based type inference.""" + converter = EnhancedTextConverter() + + test_cases = [ + ("Launch application", NodeType.START), + ("Complete task", NodeType.END), + ("Verify credentials", NodeType.DECISION), + ("Persist to database", NodeType.DATA), + ("Call subprocess", NodeType.SUBPROCESS), + ] + + for text, expected_type in test_cases: + diagram = converter.convert(text) + assert diagram.nodes[0].node_type == expected_type + + +def test_enhanced_with_title(): + """Test conversion with title.""" + converter = EnhancedTextConverter() + diagram = converter.convert("Step 1 -> Step 2", title="My Flow") + + assert diagram.metadata["title"] == "My Flow" + + +def test_enhanced_sentence_parsing(): + """Test parsing sentence-style descriptions.""" + converter = EnhancedTextConverter() + text = "Begin process. Load data. Validate input. Save results. End process" + diagram = converter.convert(text) + + assert len(diagram.nodes) >= 5 + assert diagram.validate() + + +def test_enhanced_mixed_separators(): + """Test handling mixed separators.""" + converter = EnhancedTextConverter() + text = "Start -> Step 1. Step 2 → Step 3 -> End" + diagram = converter.convert(text) + + assert len(diagram.nodes) >= 3 + assert diagram.validate() diff --git a/tests/test_graph_ops.py b/tests/test_graph_ops.py new file mode 100644 index 0000000..ea346dc --- /dev/null +++ b/tests/test_graph_ops.py @@ -0,0 +1,115 @@ +"""Tests for graph operations.""" + +import pytest +from ij.core import DiagramIR, Edge, Node, NodeType +from ij.graph_ops import GraphOperations + + +def test_to_networkx(): + """Test conversion to NetworkX.""" + diagram = DiagramIR() + diagram.add_node(Node(id="n1", label="A")) + diagram.add_node(Node(id="n2", label="B")) + diagram.add_edge(Edge(source="n1", target="n2")) + + G = GraphOperations.to_networkx(diagram) + + assert G.number_of_nodes() == 2 + assert G.number_of_edges() == 1 + assert "n1" in G.nodes + assert "n2" in G.nodes + assert G.has_edge("n1", "n2") + + +def test_from_networkx(): + """Test conversion from NetworkX.""" + diagram = DiagramIR() + diagram.add_node(Node(id="n1", label="A", node_type=NodeType.START)) + diagram.add_node(Node(id="n2", label="B", node_type=NodeType.END)) + diagram.add_edge(Edge(source="n1", target="n2", label="go")) + + # Convert to NetworkX and back + G = GraphOperations.to_networkx(diagram) + diagram2 = GraphOperations.from_networkx(G) + + assert len(diagram2.nodes) == 2 + assert len(diagram2.edges) == 1 + assert diagram2.get_node("n1").label == "A" + assert diagram2.get_node("n1").node_type == NodeType.START + + +def test_find_paths(): + """Test path finding.""" + diagram = DiagramIR() + diagram.add_node(Node(id="n1", label="A")) + diagram.add_node(Node(id="n2", label="B")) + diagram.add_node(Node(id="n3", label="C")) + diagram.add_edge(Edge(source="n1", target="n2")) + diagram.add_edge(Edge(source="n2", target="n3")) + diagram.add_edge(Edge(source="n1", target="n3")) # Direct path + + paths = GraphOperations.find_paths(diagram, "n1", "n3") + + assert len(paths) == 2 # Two paths: direct and via n2 + assert ["n1", "n3"] in paths + assert ["n1", "n2", "n3"] in paths + + +def test_find_cycles(): + """Test cycle detection.""" + diagram = DiagramIR() + diagram.add_node(Node(id="n1", label="A")) + diagram.add_node(Node(id="n2", label="B")) + diagram.add_node(Node(id="n3", label="C")) + diagram.add_edge(Edge(source="n1", target="n2")) + diagram.add_edge(Edge(source="n2", target="n3")) + diagram.add_edge(Edge(source="n3", target="n1")) # Creates cycle + + cycles = GraphOperations.find_cycles(diagram) + + assert len(cycles) > 0 + + +def test_topological_sort(): + """Test topological sorting.""" + diagram = DiagramIR() + diagram.add_node(Node(id="n1", label="A")) + diagram.add_node(Node(id="n2", label="B")) + diagram.add_node(Node(id="n3", label="C")) + diagram.add_edge(Edge(source="n1", target="n2")) + diagram.add_edge(Edge(source="n2", target="n3")) + + order = GraphOperations.topological_sort(diagram) + + assert order is not None + assert order.index("n1") < order.index("n2") + assert order.index("n2") < order.index("n3") + + +def test_topological_sort_with_cycle(): + """Test topological sort fails with cycles.""" + diagram = DiagramIR() + diagram.add_node(Node(id="n1", label="A")) + diagram.add_node(Node(id="n2", label="B")) + diagram.add_edge(Edge(source="n1", target="n2")) + diagram.add_edge(Edge(source="n2", target="n1")) # Cycle + + order = GraphOperations.topological_sort(diagram) + + assert order is None # Should fail due to cycle + + +def test_simplify_diagram(): + """Test diagram simplification.""" + diagram = DiagramIR() + diagram.add_node(Node(id="n1", label="A")) + diagram.add_node(Node(id="n2", label="B")) + diagram.add_node(Node(id="n3", label="C")) + diagram.add_edge(Edge(source="n1", target="n2")) + diagram.add_edge(Edge(source="n2", target="n3")) + diagram.add_edge(Edge(source="n1", target="n3")) # Redundant edge + + simplified = GraphOperations.simplify_diagram(diagram) + + # Should remove the redundant direct edge from n1 to n3 + assert len(simplified.edges) == 2 diff --git a/tests/test_llm_converter.py b/tests/test_llm_converter.py new file mode 100644 index 0000000..c8cd3a9 --- /dev/null +++ b/tests/test_llm_converter.py @@ -0,0 +1,198 @@ +"""Tests for LLM-based converter. + +Tests use mocks by default, with optional real API tests when OPENAI_API_KEY is set. +""" + +import os +from unittest.mock import MagicMock, patch + +import pytest + +# Skip all tests in this file if openai is not installed +pytest.importorskip("openai") + +from ij.converters.llm_converter import LLMConverter +from ij.core import DiagramIR + + +@pytest.fixture +def mock_openai_client(): + """Mock OpenAI client for testing.""" + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = """flowchart TD + start([Start]) + check{Authenticated?} + dashboard[Show Dashboard] + login[Show Login] + start --> check + check -->|Yes| dashboard + check -->|No| login""" + + mock_client = MagicMock() + mock_client.chat.completions.create.return_value = mock_response + + return mock_client + + +def test_llm_converter_init_without_key(): + """Test initialization fails without API key.""" + # Clear env var if present + old_key = os.environ.pop("OPENAI_API_KEY", None) + + try: + with pytest.raises(ValueError, match="OpenAI API key required"): + LLMConverter() + finally: + if old_key: + os.environ["OPENAI_API_KEY"] = old_key + + +def test_llm_converter_init_with_key(): + """Test initialization with API key.""" + converter = LLMConverter(api_key="test-key") + assert converter.api_key == "test-key" + assert converter.model == "gpt-4o-mini" + assert converter.temperature == 0.3 + + +@patch("ij.converters.llm_converter.openai") +def test_llm_converter_convert_mock(mock_openai, mock_openai_client): + """Test conversion with mocked OpenAI API.""" + mock_openai.OpenAI.return_value = mock_openai_client + + converter = LLMConverter(api_key="test-key") + diagram = converter.convert("User authentication process") + + # Verify API was called + assert mock_openai_client.chat.completions.create.called + + # Verify diagram was created + assert isinstance(diagram, DiagramIR) + assert len(diagram.nodes) > 0 + assert len(diagram.edges) > 0 + assert diagram.validate() + + +@patch("ij.converters.llm_converter.openai") +def test_llm_converter_with_title(mock_openai, mock_openai_client): + """Test conversion with title.""" + mock_openai.OpenAI.return_value = mock_openai_client + + converter = LLMConverter(api_key="test-key") + diagram = converter.convert("User login", title="Login Flow") + + assert diagram.metadata.get("title") == "Login Flow" + + +@patch("ij.converters.llm_converter.openai") +def test_llm_converter_refine(mock_openai, mock_openai_client): + """Test diagram refinement.""" + mock_openai.OpenAI.return_value = mock_openai_client + + converter = LLMConverter(api_key="test-key") + diagram = converter.convert("Simple process") + + # Mock refined response + mock_openai_client.chat.completions.create.return_value.choices[ + 0 + ].message.content = """flowchart TD + start([Start]) + process[Process] + extra[Extra Step] + end([End]) + start --> process + process --> extra + extra --> end""" + + from ij.renderers import MermaidRenderer + + mermaid = MermaidRenderer().render(diagram) + refined = converter.refine(diagram, "Add an extra step", mermaid) + + assert isinstance(refined, DiagramIR) + assert refined.validate() + + +@patch("ij.converters.llm_converter.openai") +def test_llm_converter_code_block_cleanup(mock_openai): + """Test that code blocks are properly cleaned up.""" + # Mock response with code blocks + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = """```mermaid +flowchart TD + A[Start] --> B[End] +```""" + + mock_client = MagicMock() + mock_client.chat.completions.create.return_value = mock_response + mock_openai.OpenAI.return_value = mock_client + + converter = LLMConverter(api_key="test-key") + diagram = converter.convert("Simple flow") + + assert isinstance(diagram, DiagramIR) + assert diagram.validate() + + +@patch("ij.converters.llm_converter.openai") +def test_llm_converter_with_examples(mock_openai, mock_openai_client): + """Test few-shot learning with examples.""" + mock_openai.OpenAI.return_value = mock_openai_client + + converter = LLMConverter(api_key="test-key") + examples = [ + { + "description": "Login process", + "mermaid": "flowchart TD\n A[Start] --> B[Login]", + } + ] + + diagram = converter.convert_with_examples("Signup process", examples) + + assert isinstance(diagram, DiagramIR) + assert diagram.validate() + + +# Optional real API tests - only run if OPENAI_API_KEY is set +@pytest.mark.skipif( + not os.environ.get("OPENAI_API_KEY"), + reason="OPENAI_API_KEY not set - skipping real API tests", +) +def test_llm_converter_real_api_simple(): + """Test with real OpenAI API - simple case. + + This test only runs if OPENAI_API_KEY environment variable is set. + Uses gpt-4o-mini which is cheap (~$0.00015 per request). + """ + converter = LLMConverter(model="gpt-4o-mini", temperature=0.1) + diagram = converter.convert("A simple two-step process") + + # Verify basic structure + assert isinstance(diagram, DiagramIR) + assert len(diagram.nodes) >= 2 + assert len(diagram.edges) >= 1 + assert diagram.validate() + + +@pytest.mark.skipif( + not os.environ.get("OPENAI_API_KEY"), + reason="OPENAI_API_KEY not set - skipping real API tests", +) +def test_llm_converter_real_api_with_decision(): + """Test with real OpenAI API - decision logic. + + This test only runs if OPENAI_API_KEY environment variable is set. + """ + converter = LLMConverter(model="gpt-4o-mini", temperature=0.1) + diagram = converter.convert( + "Check if user is authenticated. If yes, show dashboard. If no, show login." + ) + + # Should have decision node + from ij.core import NodeType + + decision_nodes = [n for n in diagram.nodes if n.node_type == NodeType.DECISION] + assert len(decision_nodes) >= 1 + assert diagram.validate() diff --git a/tests/test_new_renderers.py b/tests/test_new_renderers.py new file mode 100644 index 0000000..fcbd56d --- /dev/null +++ b/tests/test_new_renderers.py @@ -0,0 +1,171 @@ +"""Tests for PlantUML, D2, and Graphviz renderers.""" + +import pytest +from ij.core import DiagramIR, Edge, Node, NodeType +from ij.renderers import PlantUMLRenderer, D2Renderer, GraphvizRenderer + + +def create_sample_diagram(): + """Create a sample diagram for testing.""" + diagram = DiagramIR(metadata={"title": "Test Process"}) + diagram.add_node(Node(id="n1", label="Start", node_type=NodeType.START)) + diagram.add_node(Node(id="n2", label="Process", node_type=NodeType.PROCESS)) + diagram.add_node(Node(id="n3", label="End", node_type=NodeType.END)) + diagram.add_edge(Edge(source="n1", target="n2")) + diagram.add_edge(Edge(source="n2", target="n3")) + return diagram + + +def test_plantuml_simple_diagram(): + """Test PlantUML rendering.""" + diagram = create_sample_diagram() + renderer = PlantUMLRenderer() + output = renderer.render(diagram) + + assert "@startuml" in output + assert "@enduml" in output + assert "title Test Process" in output + assert "start" in output + assert "stop" in output + assert ":Process;" in output + + +def test_plantuml_without_skinparam(): + """Test PlantUML without styling.""" + diagram = create_sample_diagram() + renderer = PlantUMLRenderer(use_skinparam=False) + output = renderer.render(diagram) + + assert "skinparam" not in output + assert "@startuml" in output + + +def test_d2_simple_diagram(): + """Test D2 rendering.""" + diagram = create_sample_diagram() + renderer = D2Renderer() + output = renderer.render(diagram) + + assert "# Test Process" in output + assert 'n1: "Start"' in output + assert 'shape: oval' in output + assert 'n2: "Process"' in output + assert 'n3: "End"' in output + assert "n1 -> n2" in output + assert "n2 -> n3" in output + + +def test_d2_with_decision(): + """Test D2 rendering with decision nodes.""" + diagram = DiagramIR() + diagram.add_node(Node(id="n1", label="Check", node_type=NodeType.DECISION)) + diagram.add_node(Node(id="n2", label="Yes path")) + diagram.add_edge(Edge(source="n1", target="n2", label="Yes")) + + renderer = D2Renderer() + output = renderer.render(diagram) + + assert "shape: diamond" in output + assert 'n1 -> n2: "Yes"' in output + + +def test_d2_direction(): + """Test D2 direction conversion.""" + diagram = DiagramIR(metadata={"direction": "LR"}) + diagram.add_node(Node(id="n1", label="A")) + + renderer = D2Renderer() + output = renderer.render(diagram) + + assert "direction: right" in output + + +def test_graphviz_simple_diagram(): + """Test Graphviz rendering.""" + diagram = create_sample_diagram() + renderer = GraphvizRenderer() + output = renderer.render(diagram) + + assert "digraph G {" in output + assert "}" in output + assert 'label="Test Process"' in output + assert 'n1 [label="Start"' in output + assert 'n2 [label="Process"' in output + assert 'n3 [label="End"' in output + assert "n1 -> n2" in output + assert "n2 -> n3" in output + + +def test_graphviz_node_shapes(): + """Test Graphviz node shapes.""" + diagram = DiagramIR() + diagram.add_node(Node(id="n1", label="Start", node_type=NodeType.START)) + diagram.add_node(Node(id="n2", label="Decide", node_type=NodeType.DECISION)) + diagram.add_node(Node(id="n3", label="Data", node_type=NodeType.DATA)) + + renderer = GraphvizRenderer() + output = renderer.render(diagram) + + assert "shape=oval" in output # START + assert "shape=diamond" in output # DECISION + assert "shape=cylinder" in output # DATA + + +def test_graphviz_edge_types(): + """Test Graphviz edge types.""" + from ij.core import EdgeType + + diagram = DiagramIR() + diagram.add_node(Node(id="n1", label="A")) + diagram.add_node(Node(id="n2", label="B")) + diagram.add_node(Node(id="n3", label="C")) + + diagram.add_edge(Edge(source="n1", target="n2", edge_type=EdgeType.CONDITIONAL)) + diagram.add_edge( + Edge(source="n2", target="n3", edge_type=EdgeType.BIDIRECTIONAL) + ) + + renderer = GraphvizRenderer() + output = renderer.render(diagram) + + assert "style=dashed" in output # CONDITIONAL + assert "dir=both" in output # BIDIRECTIONAL + + +def test_graphviz_with_labels(): + """Test Graphviz with edge labels.""" + diagram = DiagramIR() + diagram.add_node(Node(id="n1", label="A")) + diagram.add_node(Node(id="n2", label="B")) + diagram.add_edge(Edge(source="n1", target="n2", label="Yes")) + + renderer = GraphvizRenderer() + output = renderer.render(diagram) + + assert 'label="Yes"' in output + + +def test_graphviz_layout(): + """Test Graphviz layout engine selection.""" + diagram = create_sample_diagram() + + for layout in ["dot", "neato", "fdp", "circo"]: + renderer = GraphvizRenderer(layout=layout) + output = renderer.render(diagram) + assert f'layout="{layout}"' in output + + +def test_all_renderers_produce_output(): + """Test that all renderers produce non-empty output.""" + diagram = create_sample_diagram() + + renderers = [ + PlantUMLRenderer(), + D2Renderer(), + GraphvizRenderer(), + ] + + for renderer in renderers: + output = renderer.render(diagram) + assert len(output) > 0 + assert isinstance(output, str) diff --git a/tests/test_parsers.py b/tests/test_parsers.py new file mode 100644 index 0000000..dc27e51 --- /dev/null +++ b/tests/test_parsers.py @@ -0,0 +1,144 @@ +"""Tests for diagram parsers.""" + +import pytest +from ij.parsers import MermaidParser +from ij.core import NodeType, EdgeType + + +def test_mermaid_parser_simple(): + """Test parsing simple Mermaid diagram.""" + mermaid_text = """ + flowchart TD + n1([Start]) + n2[Process] + n3([End]) + n1 --> n2 + n2 --> n3 + """ + + parser = MermaidParser() + diagram = parser.parse(mermaid_text) + + assert len(diagram.nodes) == 3 + assert len(diagram.edges) == 2 + assert diagram.validate() + + +def test_mermaid_parser_with_title(): + """Test parsing diagram with title.""" + mermaid_text = """ + --- + title: My Process + --- + flowchart TD + n1[Step 1] + """ + + parser = MermaidParser() + diagram = parser.parse(mermaid_text) + + assert diagram.metadata["title"] == "My Process" + + +def test_mermaid_parser_node_shapes(): + """Test parsing different node shapes.""" + mermaid_text = """ + flowchart TD + n1([Start]) + n2{Decision?} + n3[(Database)] + n4[[Subprocess]] + n5[Process] + """ + + parser = MermaidParser() + diagram = parser.parse(mermaid_text) + + nodes = {node.id: node for node in diagram.nodes} + + assert nodes["n1"].node_type == NodeType.START + assert nodes["n2"].node_type == NodeType.DECISION + assert nodes["n3"].node_type == NodeType.DATA + assert nodes["n4"].node_type == NodeType.SUBPROCESS + assert nodes["n5"].node_type == NodeType.PROCESS + + +def test_mermaid_parser_edges_with_labels(): + """Test parsing edges with labels.""" + mermaid_text = """ + flowchart TD + n1[A] + n2[B] + n1 -->|Yes| n2 + """ + + parser = MermaidParser() + diagram = parser.parse(mermaid_text) + + assert len(diagram.edges) == 1 + edge = diagram.edges[0] + assert edge.label == "Yes" + assert edge.source == "n1" + assert edge.target == "n2" + + +def test_mermaid_parser_edge_types(): + """Test parsing different edge types.""" + mermaid_text = """ + flowchart TD + n1[A] + n2[B] + n3[C] + n4[D] + n1 --> n2 + n2 -.-> n3 + n3 <--> n4 + """ + + parser = MermaidParser() + diagram = parser.parse(mermaid_text) + + edges = diagram.edges + assert edges[0].edge_type == EdgeType.DIRECT + assert edges[1].edge_type == EdgeType.CONDITIONAL + assert edges[2].edge_type == EdgeType.BIDIRECTIONAL + + +def test_mermaid_parser_direction(): + """Test parsing diagram direction.""" + mermaid_text = """ + flowchart LR + n1[A] --> n2[B] + """ + + parser = MermaidParser() + diagram = parser.parse(mermaid_text) + + assert diagram.metadata["direction"] == "LR" + + +def test_mermaid_parser_roundtrip(): + """Test roundtrip conversion: IR -> Mermaid -> IR.""" + from ij import DiagramIR, Node, Edge, NodeType + from ij.renderers import MermaidRenderer + + # Create original diagram + original = DiagramIR() + original.add_node(Node(id="n1", label="Start", node_type=NodeType.START)) + original.add_node(Node(id="n2", label="Process", node_type=NodeType.PROCESS)) + original.add_node(Node(id="n3", label="End", node_type=NodeType.END)) + original.add_edge(Edge(source="n1", target="n2")) + original.add_edge(Edge(source="n2", target="n3")) + + # Convert to Mermaid + renderer = MermaidRenderer() + mermaid_text = renderer.render(original) + + # Parse back + parser = MermaidParser() + parsed = parser.parse(mermaid_text) + + # Verify + assert len(parsed.nodes) == len(original.nodes) + assert len(parsed.edges) == len(original.edges) + assert parsed.validate() diff --git a/tests/test_python_analyzer.py b/tests/test_python_analyzer.py new file mode 100644 index 0000000..ac94b4c --- /dev/null +++ b/tests/test_python_analyzer.py @@ -0,0 +1,240 @@ +"""Tests for Python code analyzer.""" + +import pytest +from ij.analyzers import PythonCodeAnalyzer +from ij.core import NodeType + + +def test_analyze_simple_function(): + """Test analyzing a simple function.""" + code = """ +def hello(): + print("Hello") + return "done" +""" + + analyzer = PythonCodeAnalyzer() + diagram = analyzer.analyze_function(code) + + assert diagram.validate() + assert len(diagram.nodes) >= 3 # start, print, return, end + assert diagram.metadata["title"] == "Control Flow: hello" + + +def test_analyze_function_with_if(): + """Test analyzing function with if statement.""" + code = """ +def check_value(x): + if x > 0: + print("Positive") + else: + print("Negative") + return x +""" + + analyzer = PythonCodeAnalyzer() + diagram = analyzer.analyze_function(code) + + assert diagram.validate() + + # Should have decision node + decision_nodes = [n for n in diagram.nodes if n.node_type == NodeType.DECISION] + assert len(decision_nodes) >= 1 + + # Should have edges with labels + labeled_edges = [e for e in diagram.edges if e.label] + assert len(labeled_edges) >= 0 # May have Yes/No labels + + +def test_analyze_function_with_while(): + """Test analyzing function with while loop.""" + code = """ +def count_down(n): + while n > 0: + print(n) + n = n - 1 + return "done" +""" + + analyzer = PythonCodeAnalyzer() + diagram = analyzer.analyze_function(code) + + assert diagram.validate() + + # Should have decision node for while condition + decision_nodes = [n for n in diagram.nodes if n.node_type == NodeType.DECISION] + assert len(decision_nodes) >= 1 + + # Should have conditional edges (loop back) + from ij.core import EdgeType + + conditional_edges = [e for e in diagram.edges if e.edge_type == EdgeType.CONDITIONAL] + assert len(conditional_edges) >= 1 + + +def test_analyze_function_with_for(): + """Test analyzing function with for loop.""" + code = """ +def process_items(items): + for item in items: + print(item) + return len(items) +""" + + analyzer = PythonCodeAnalyzer() + diagram = analyzer.analyze_function(code) + + assert diagram.validate() + # Should have loop structure + assert len(diagram.nodes) >= 3 + + +def test_analyze_specific_function(): + """Test analyzing a specific function by name.""" + code = """ +def first(): + return 1 + +def second(): + return 2 +""" + + analyzer = PythonCodeAnalyzer() + diagram = analyzer.analyze_function(code, function_name="second") + + assert diagram.metadata["title"] == "Control Flow: second" + + +def test_analyze_function_with_calls(): + """Test analyzing function with function calls.""" + code = """ +def process(): + data = load_data() + result = transform(data) + save(result) +""" + + analyzer = PythonCodeAnalyzer() + diagram = analyzer.analyze_function(code) + + assert diagram.validate() + # Should have nodes for each call + assert len(diagram.nodes) >= 4 # start, load_data, transform, save, end + + +def test_analyze_class(): + """Test analyzing a class.""" + code = """ +class MyClass: + def __init__(self): + self.value = 0 + + def increment(self): + self.value += 1 + + def decrement(self): + self.value -= 1 +""" + + analyzer = PythonCodeAnalyzer() + diagram = analyzer.analyze_class(code) + + assert diagram.validate() + assert diagram.metadata["title"] == "Class: MyClass" + assert len(diagram.nodes) >= 1 + + +def test_analyze_class_with_inheritance(): + """Test analyzing class with inheritance.""" + code = """ +class Base: + pass + +class Derived(Base): + def method(self): + pass +""" + + analyzer = PythonCodeAnalyzer() + diagram = analyzer.analyze_class(code, class_name="Derived") + + assert diagram.validate() + # Should have nodes for both Base and Derived + assert len(diagram.nodes) >= 2 + # Should have inheritance edge + assert len(diagram.edges) >= 1 + + +def test_analyze_module_calls(): + """Test analyzing function calls in a module.""" + code = """ +def helper(): + return "data" + +def processor(): + data = helper() + return data + +def main(): + result = processor() + print(result) +""" + + analyzer = PythonCodeAnalyzer() + diagram = analyzer.analyze_module_calls(code) + + assert diagram.validate() + assert diagram.metadata["title"] == "Call Graph" + # Should have nodes for all functions + assert len(diagram.nodes) >= 3 + # Should have call edges + assert len(diagram.edges) >= 2 + + +def test_analyze_complex_function(): + """Test analyzing a more complex function.""" + code = """ +def process_order(order): + if order.is_valid(): + if order.in_stock(): + charge_customer(order) + ship_order(order) + send_confirmation(order) + else: + send_backorder_notice(order) + else: + send_error_notification(order) + return order.status +""" + + analyzer = PythonCodeAnalyzer() + diagram = analyzer.analyze_function(code) + + assert diagram.validate() + # Should have multiple decision nodes for nested ifs + decision_nodes = [n for n in diagram.nodes if n.node_type == NodeType.DECISION] + assert len(decision_nodes) >= 2 + + +def test_analyze_function_not_found(): + """Test error when function not found.""" + code = """ +def existing(): + pass +""" + + analyzer = PythonCodeAnalyzer() + with pytest.raises(ValueError, match="Function nonexistent"): + analyzer.analyze_function(code, function_name="nonexistent") + + +def test_analyze_class_not_found(): + """Test error when class not found.""" + code = """ +class Existing: + pass +""" + + analyzer = PythonCodeAnalyzer() + with pytest.raises(ValueError, match="Class NonExistent"): + analyzer.analyze_class(code, class_name="NonExistent") diff --git a/tests/test_renderers.py b/tests/test_renderers.py new file mode 100644 index 0000000..60fd04a --- /dev/null +++ b/tests/test_renderers.py @@ -0,0 +1,95 @@ +"""Tests for diagram renderers.""" + +import pytest +from ij.core import DiagramIR, Edge, Node, NodeType +from ij.renderers import MermaidRenderer + + +def test_mermaid_simple_diagram(): + """Test rendering a simple diagram to Mermaid.""" + diagram = DiagramIR() + diagram.add_node(Node(id="n1", label="Start", node_type=NodeType.START)) + diagram.add_node(Node(id="n2", label="Process", node_type=NodeType.PROCESS)) + diagram.add_node(Node(id="n3", label="End", node_type=NodeType.END)) + diagram.add_edge(Edge(source="n1", target="n2")) + diagram.add_edge(Edge(source="n2", target="n3")) + + renderer = MermaidRenderer() + output = renderer.render(diagram) + + assert "flowchart TD" in output + assert "n1([Start])" in output + assert "n2[Process]" in output + assert "n3([End])" in output + assert "n1 --> n2" in output + assert "n2 --> n3" in output + + +def test_mermaid_with_title(): + """Test rendering with title.""" + diagram = DiagramIR(metadata={"title": "My Process"}) + diagram.add_node(Node(id="n1", label="Step 1")) + + renderer = MermaidRenderer() + output = renderer.render(diagram) + + assert "title: My Process" in output + + +def test_mermaid_node_shapes(): + """Test different node shapes.""" + diagram = DiagramIR() + + # Test different node types + test_cases = [ + (NodeType.PROCESS, "[", "]"), + (NodeType.DECISION, "{", "}"), + (NodeType.START, "([", "])"), + (NodeType.DATA, "[(", ")]"), + ] + + for i, (node_type, start, end) in enumerate(test_cases): + node = Node(id=f"n{i}", label=f"Test {i}", node_type=node_type) + diagram.add_node(node) + + renderer = MermaidRenderer() + output = renderer.render(diagram) + + for i, (_, start, end) in enumerate(test_cases): + assert f"n{i}{start}Test {i}{end}" in output + + +def test_mermaid_edge_with_label(): + """Test rendering edges with labels.""" + diagram = DiagramIR() + diagram.add_node(Node(id="n1", label="A")) + diagram.add_node(Node(id="n2", label="B")) + diagram.add_edge(Edge(source="n1", target="n2", label="Yes")) + + renderer = MermaidRenderer() + output = renderer.render(diagram) + + assert "n1 -->|Yes| n2" in output + + +def test_mermaid_direction(): + """Test different diagram directions.""" + diagram = DiagramIR() + diagram.add_node(Node(id="n1", label="A")) + + # Test different directions + for direction in ["TD", "LR", "BT", "RL"]: + renderer = MermaidRenderer(direction=direction) + output = renderer.render(diagram) + assert f"flowchart {direction}" in output + + +def test_mermaid_invalid_diagram(): + """Test error handling for invalid diagrams.""" + diagram = DiagramIR() + diagram.add_node(Node(id="n1", label="A")) + diagram.add_edge(Edge(source="n1", target="n999")) # Invalid edge + + renderer = MermaidRenderer() + with pytest.raises(ValueError): + renderer.render(diagram) diff --git a/tests/test_sequence.py b/tests/test_sequence.py new file mode 100644 index 0000000..08f35cc --- /dev/null +++ b/tests/test_sequence.py @@ -0,0 +1,325 @@ +"""Tests for sequence diagram renderer and interaction analyzer.""" + +import pytest + +from ij import DiagramIR, Edge, EdgeType, InteractionAnalyzer, Node, SequenceDiagramRenderer + + +def test_sequence_renderer_simple(): + """Test rendering a simple sequence diagram.""" + diagram = DiagramIR() + diagram.add_node(Node(id="user", label="User")) + diagram.add_node(Node(id="api", label="API")) + diagram.add_edge(Edge(source="user", target="api", label="Request")) + + renderer = SequenceDiagramRenderer() + result = renderer.render(diagram) + + assert "sequenceDiagram" in result + assert "participant user as User" in result + assert "participant api as API" in result + assert "user->>api: Request" in result + + +def test_sequence_renderer_with_title(): + """Test rendering sequence diagram with title.""" + diagram = DiagramIR(metadata={"title": "User Login Flow"}) + diagram.add_node(Node(id="user", label="User")) + diagram.add_node(Node(id="auth", label="Auth Service")) + diagram.add_edge(Edge(source="user", target="auth", label="Login")) + + renderer = SequenceDiagramRenderer() + result = renderer.render(diagram) + + assert "title User Login Flow" in result + + +def test_sequence_renderer_arrow_types(): + """Test different arrow types in sequence diagrams.""" + diagram = DiagramIR() + diagram.add_node(Node(id="a", label="A")) + diagram.add_node(Node(id="b", label="B")) + + # Direct/synchronous + diagram.add_edge( + Edge(source="a", target="b", label="sync", edge_type=EdgeType.DIRECT) + ) + + # Conditional/asynchronous + diagram.add_edge( + Edge(source="b", target="a", label="async", edge_type=EdgeType.CONDITIONAL) + ) + + renderer = SequenceDiagramRenderer() + result = renderer.render(diagram) + + # Direct should use solid arrow ->> + assert "a->>b: sync" in result + # Conditional should use dashed arrow -->> + assert "b-->>a: async" in result + + +def test_sequence_renderer_multiple_messages(): + """Test rendering multiple messages between participants.""" + diagram = DiagramIR() + diagram.add_node(Node(id="client", label="Client")) + diagram.add_node(Node(id="server", label="Server")) + diagram.add_node(Node(id="db", label="Database")) + + diagram.add_edge(Edge(source="client", target="server", label="GET /data")) + diagram.add_edge(Edge(source="server", target="db", label="SELECT *")) + diagram.add_edge( + Edge(source="db", target="server", label="Results", edge_type=EdgeType.CONDITIONAL) + ) + diagram.add_edge( + Edge(source="server", target="client", label="200 OK", edge_type=EdgeType.CONDITIONAL) + ) + + renderer = SequenceDiagramRenderer() + result = renderer.render(diagram) + + assert "client->>server: GET /data" in result + assert "server->>db: SELECT *" in result + assert "db-->>server: Results" in result + assert "server-->>client: 200 OK" in result + + +def test_sequence_renderer_with_notes(): + """Test rendering sequence diagram with notes.""" + diagram = DiagramIR() + diagram.add_node(Node(id="user", label="User")) + diagram.add_node(Node(id="api", label="API")) + diagram.add_edge(Edge(source="user", target="api", label="Request")) + + renderer = SequenceDiagramRenderer() + notes = { + "user": ["This is the end user"], + "api": ["REST API endpoint"], + } + result = renderer.render_with_notes(diagram, notes) + + assert "Note over user: This is the end user" in result + assert "Note over api: REST API endpoint" in result + + +def test_sequence_renderer_with_activations(): + """Test rendering sequence diagram with activations.""" + diagram = DiagramIR() + diagram.add_node(Node(id="client", label="Client")) + diagram.add_node(Node(id="server", label="Server")) + diagram.add_edge(Edge(source="client", target="server", label="Request")) + + renderer = SequenceDiagramRenderer() + activations = [ + ("server", "activate"), + ("server", "deactivate"), + ] + result = renderer.render_with_activations(diagram, activations) + + assert "activate server" in result + assert "deactivate server" in result + + +def test_sequence_renderer_no_label(): + """Test rendering messages without labels.""" + diagram = DiagramIR() + diagram.add_node(Node(id="a", label="A")) + diagram.add_node(Node(id="b", label="B")) + diagram.add_edge(Edge(source="a", target="b")) # No label + + renderer = SequenceDiagramRenderer() + result = renderer.render(diagram) + + assert "a->>b:" in result + + +def test_interaction_analyzer_function_calls(): + """Test analyzing function calls to create sequence diagram.""" + code = """ +api.authenticate(user) +db.query(user_id) +cache.store(result) +""" + analyzer = InteractionAnalyzer() + diagram = analyzer.analyze_function_calls("client", code) + + # Should have client, api, db, cache as participants + assert len(diagram.nodes) == 4 + participant_ids = {node.id for node in diagram.nodes} + assert "client" in participant_ids + assert "api" in participant_ids + assert "db" in participant_ids + assert "cache" in participant_ids + + # Should have 3 messages + assert len(diagram.edges) == 3 + assert any( + e.source == "client" and e.target == "api" and "authenticate" in e.label + for e in diagram.edges + ) + assert any( + e.source == "client" and e.target == "db" and "query" in e.label + for e in diagram.edges + ) + assert any( + e.source == "client" and e.target == "cache" and "store" in e.label + for e in diagram.edges + ) + + +def test_interaction_analyzer_invalid_code(): + """Test handling invalid Python code.""" + code = "this is not valid python code @#$%" + analyzer = InteractionAnalyzer() + diagram = analyzer.analyze_function_calls("client", code) + + # Should return diagram with just the caller + assert len(diagram.nodes) == 1 + assert diagram.nodes[0].id == "client" + assert len(diagram.edges) == 0 + + +def test_interaction_analyzer_from_text(): + """Test creating sequence diagram from text description.""" + text = """ + User sends request to API. + API queries Database. + Database returns data to API. + API responds to User. + """ + analyzer = InteractionAnalyzer() + diagram = analyzer.from_text_description(text) + + # Should identify User, API, Database as participants + participant_ids = {node.id for node in diagram.nodes} + assert "User" in participant_ids + assert "API" in participant_ids + assert "Database" in participant_ids + + # Should have multiple interactions + assert len(diagram.edges) >= 3 + + +def test_interaction_analyzer_text_variations(): + """Test different text patterns for interactions.""" + texts = [ + "Alice calls Bob", + "Client requests Server", + "User asks API", + "Service queries Database", + ] + + analyzer = InteractionAnalyzer() + + for text in texts: + diagram = analyzer.from_text_description(text) + # Each should create at least one interaction + assert len(diagram.nodes) >= 2 + assert len(diagram.edges) >= 1 + + +def test_interaction_analyzer_empty_text(): + """Test handling empty text input.""" + analyzer = InteractionAnalyzer() + diagram = analyzer.from_text_description("") + + assert len(diagram.nodes) == 0 + assert len(diagram.edges) == 0 + + +def test_sequence_integration(): + """Test full integration: analyze code -> render sequence diagram.""" + code = """ +user.login(credentials) +auth.validate(credentials) +db.check_password(username) +""" + analyzer = InteractionAnalyzer() + diagram = analyzer.analyze_function_calls("app", code) + + renderer = SequenceDiagramRenderer() + result = renderer.render(diagram) + + # Should produce valid Mermaid sequence diagram + assert "sequenceDiagram" in result + assert "participant app" in result + assert "participant user" in result + assert "participant auth" in result + assert "participant db" in result + + +def test_sequence_text_to_diagram(): + """Test text description to sequence diagram.""" + text = """ + Browser sends HTTP request to WebServer. + WebServer calls AppServer. + AppServer queries Database. + Database returns results to AppServer. + AppServer responds to WebServer. + WebServer sends response to Browser. + """ + + analyzer = InteractionAnalyzer() + diagram = analyzer.from_text_description(text) + + renderer = SequenceDiagramRenderer() + result = renderer.render(diagram) + + assert "sequenceDiagram" in result + assert "Browser" in result + assert "WebServer" in result + assert "AppServer" in result + assert "Database" in result + + +def test_sequence_complex_scenario(): + """Test complex multi-participant sequence diagram.""" + diagram = DiagramIR(metadata={"title": "E-commerce Checkout"}) + + # Add participants + participants = ["User", "Frontend", "API", "PaymentGateway", "Database"] + for p in participants: + diagram.add_node(Node(id=p, label=p)) + + # Add interactions + diagram.add_edge(Edge(source="User", target="Frontend", label="Click Checkout")) + diagram.add_edge(Edge(source="Frontend", target="API", label="POST /checkout")) + diagram.add_edge(Edge(source="API", target="Database", label="Validate cart")) + diagram.add_edge( + Edge(source="Database", target="API", label="OK", edge_type=EdgeType.CONDITIONAL) + ) + diagram.add_edge(Edge(source="API", target="PaymentGateway", label="Process payment")) + diagram.add_edge( + Edge( + source="PaymentGateway", + target="API", + label="Success", + edge_type=EdgeType.CONDITIONAL, + ) + ) + diagram.add_edge(Edge(source="API", target="Database", label="Save order")) + diagram.add_edge( + Edge( + source="API", + target="Frontend", + label="200 OK", + edge_type=EdgeType.CONDITIONAL, + ) + ) + diagram.add_edge( + Edge( + source="Frontend", + target="User", + label="Show confirmation", + edge_type=EdgeType.CONDITIONAL, + ) + ) + + renderer = SequenceDiagramRenderer() + result = renderer.render(diagram) + + assert "title E-commerce Checkout" in result + assert len(diagram.edges) == 9 + assert "User->>Frontend" in result + assert "Frontend->>API" in result + assert "API->>PaymentGateway" in result diff --git a/tests/test_transforms.py b/tests/test_transforms.py new file mode 100644 index 0000000..a0cba59 --- /dev/null +++ b/tests/test_transforms.py @@ -0,0 +1,313 @@ +"""Tests for diagram transformation utilities.""" + +import pytest + +from ij import DiagramIR, DiagramTransforms, Edge, EdgeType, Node, NodeType + + +def test_simplify_removes_isolated_nodes(): + """Test that simplify removes isolated nodes.""" + diagram = DiagramIR() + diagram.add_node(Node(id="a", label="A")) + diagram.add_node(Node(id="b", label="B")) + diagram.add_node(Node(id="isolated", label="Isolated")) + diagram.add_edge(Edge(source="a", target="b")) + + simplified = DiagramTransforms.simplify(diagram, remove_isolated=True) + + assert len(simplified.nodes) == 2 + node_ids = {n.id for n in simplified.nodes} + assert "isolated" not in node_ids + + +def test_simplify_keeps_start_end_nodes(): + """Test that simplify keeps START/END nodes even if isolated.""" + diagram = DiagramIR() + diagram.add_node(Node(id="start", label="Start", node_type=NodeType.START)) + diagram.add_node(Node(id="end", label="End", node_type=NodeType.END)) + + simplified = DiagramTransforms.simplify(diagram, remove_isolated=True) + + assert len(simplified.nodes) == 2 + + +def test_simplify_removes_duplicate_edges(): + """Test that simplify removes duplicate edges.""" + diagram = DiagramIR() + diagram.add_node(Node(id="a", label="A")) + diagram.add_node(Node(id="b", label="B")) + diagram.add_edge(Edge(source="a", target="b", label="same")) + diagram.add_edge(Edge(source="a", target="b", label="same")) # Duplicate + + simplified = DiagramTransforms.simplify(diagram) + + assert len(simplified.edges) == 1 + + +def test_filter_by_node_type_keep(): + """Test filtering to keep only specific node types.""" + diagram = DiagramIR() + diagram.add_node(Node(id="n1", label="N1", node_type=NodeType.PROCESS)) + diagram.add_node(Node(id="n2", label="N2", node_type=NodeType.DECISION)) + diagram.add_node(Node(id="n3", label="N3", node_type=NodeType.PROCESS)) + + filtered = DiagramTransforms.filter_by_node_type( + diagram, [NodeType.PROCESS], keep=True + ) + + assert len(filtered.nodes) == 2 + for node in filtered.nodes: + assert node.node_type == NodeType.PROCESS + + +def test_filter_by_node_type_remove(): + """Test filtering to remove specific node types.""" + diagram = DiagramIR() + diagram.add_node(Node(id="n1", label="N1", node_type=NodeType.PROCESS)) + diagram.add_node(Node(id="n2", label="N2", node_type=NodeType.DECISION)) + + filtered = DiagramTransforms.filter_by_node_type( + diagram, [NodeType.DECISION], keep=False + ) + + assert len(filtered.nodes) == 1 + assert filtered.nodes[0].node_type == NodeType.PROCESS + + +def test_extract_subgraph(): + """Test extracting a subgraph from a root node.""" + diagram = DiagramIR() + diagram.add_node(Node(id="root", label="Root")) + diagram.add_node(Node(id="child1", label="Child1")) + diagram.add_node(Node(id="child2", label="Child2")) + diagram.add_node(Node(id="unrelated", label="Unrelated")) + + diagram.add_edge(Edge(source="root", target="child1")) + diagram.add_edge(Edge(source="root", target="child2")) + + subgraph = DiagramTransforms.extract_subgraph(diagram, "root") + + node_ids = {n.id for n in subgraph.nodes} + assert "root" in node_ids + assert "child1" in node_ids + assert "child2" in node_ids + assert "unrelated" not in node_ids + + +def test_extract_subgraph_with_depth(): + """Test extracting subgraph with max depth.""" + diagram = DiagramIR() + nodes = ["n0", "n1", "n2", "n3"] + for n in nodes: + diagram.add_node(Node(id=n, label=n)) + + diagram.add_edge(Edge(source="n0", target="n1")) + diagram.add_edge(Edge(source="n1", target="n2")) + diagram.add_edge(Edge(source="n2", target="n3")) + + subgraph = DiagramTransforms.extract_subgraph(diagram, "n0", max_depth=2) + + node_ids = {n.id for n in subgraph.nodes} + assert "n0" in node_ids + assert "n1" in node_ids + assert "n2" in node_ids + assert "n3" not in node_ids # Beyond depth 2 + + +def test_merge_diagrams(): + """Test merging multiple diagrams.""" + diagram1 = DiagramIR() + diagram1.add_node(Node(id="a", label="A")) + diagram1.add_edge(Edge(source="a", target="b")) + + diagram2 = DiagramIR() + diagram2.add_node(Node(id="c", label="C")) + diagram2.add_edge(Edge(source="c", target="d")) + + merged = DiagramTransforms.merge_diagrams([diagram1, diagram2]) + + assert len(merged.nodes) >= 2 + assert len(merged.edges) == 2 + + +def test_merge_diagrams_with_duplicates(): + """Test that merging handles duplicate node IDs.""" + diagram1 = DiagramIR() + diagram1.add_node(Node(id="a", label="A")) + + diagram2 = DiagramIR() + diagram2.add_node(Node(id="a", label="A")) # Same ID + + merged = DiagramTransforms.merge_diagrams([diagram1, diagram2]) + + # Should only have one node 'a' + assert len([n for n in merged.nodes if n.id == "a"]) == 1 + + +def test_reverse_edges(): + """Test reversing edge directions.""" + diagram = DiagramIR() + diagram.add_node(Node(id="a", label="A")) + diagram.add_node(Node(id="b", label="B")) + diagram.add_edge(Edge(source="a", target="b", label="forward")) + + reversed_diagram = DiagramTransforms.reverse_edges(diagram) + + assert len(reversed_diagram.edges) == 1 + edge = reversed_diagram.edges[0] + assert edge.source == "b" + assert edge.target == "a" + assert edge.label == "forward" + + +def test_apply_node_filter(): + """Test custom node filter predicate.""" + diagram = DiagramIR() + diagram.add_node(Node(id="n1", label="Error handler")) + diagram.add_node(Node(id="n2", label="Success handler")) + diagram.add_node(Node(id="n3", label="Error logger")) + + # Keep only nodes with "error" in label + filtered = DiagramTransforms.apply_node_filter( + diagram, lambda n: "error" in n.label.lower() + ) + + assert len(filtered.nodes) == 2 + for node in filtered.nodes: + assert "error" in node.label.lower() + + +def test_find_cycles_no_cycle(): + """Test cycle detection on acyclic graph.""" + diagram = DiagramIR() + diagram.add_node(Node(id="a", label="A")) + diagram.add_node(Node(id="b", label="B")) + diagram.add_edge(Edge(source="a", target="b")) + + cycles = DiagramTransforms.find_cycles(diagram) + + assert len(cycles) == 0 + + +def test_find_cycles_with_cycle(): + """Test cycle detection on cyclic graph.""" + diagram = DiagramIR() + diagram.add_node(Node(id="a", label="A")) + diagram.add_node(Node(id="b", label="B")) + diagram.add_node(Node(id="c", label="C")) + + diagram.add_edge(Edge(source="a", target="b")) + diagram.add_edge(Edge(source="b", target="c")) + diagram.add_edge(Edge(source="c", target="a")) # Creates cycle + + cycles = DiagramTransforms.find_cycles(diagram) + + assert len(cycles) > 0 + + +def test_get_statistics(): + """Test diagram statistics calculation.""" + diagram = DiagramIR() + diagram.add_node(Node(id="n1", label="N1", node_type=NodeType.START)) + diagram.add_node(Node(id="n2", label="N2", node_type=NodeType.PROCESS)) + diagram.add_node(Node(id="n3", label="N3", node_type=NodeType.END)) + diagram.add_node(Node(id="isolated", label="Isolated", node_type=NodeType.DATA)) + + diagram.add_edge(Edge(source="n1", target="n2")) + diagram.add_edge(Edge(source="n2", target="n3", edge_type=EdgeType.CONDITIONAL)) + + stats = DiagramTransforms.get_statistics(diagram) + + assert stats["node_count"] == 4 + assert stats["edge_count"] == 2 + assert stats["node_types"][NodeType.START] == 1 + assert stats["node_types"][NodeType.PROCESS] == 1 + assert stats["node_types"][NodeType.END] == 1 + assert stats["node_types"][NodeType.DATA] == 1 + assert stats["isolated_nodes"] == 1 + assert not stats["has_cycles"] + + +def test_get_statistics_with_cycles(): + """Test statistics include cycle detection.""" + diagram = DiagramIR() + diagram.add_node(Node(id="a", label="A")) + diagram.add_node(Node(id="b", label="B")) + diagram.add_edge(Edge(source="a", target="b")) + diagram.add_edge(Edge(source="b", target="a")) # Cycle + + stats = DiagramTransforms.get_statistics(diagram) + + assert stats["has_cycles"] + + +def test_extract_subgraph_invalid_root(): + """Test extracting subgraph with invalid root node.""" + diagram = DiagramIR() + diagram.add_node(Node(id="a", label="A")) + + with pytest.raises(ValueError, match="Root node .* not found"): + DiagramTransforms.extract_subgraph(diagram, "nonexistent") + + +def test_merge_sequential_nodes_simple(): + """Test merging sequential nodes.""" + diagram = DiagramIR() + diagram.add_node(Node(id="a", label="Step A")) + diagram.add_node(Node(id="b", label="Step B")) + diagram.add_node(Node(id="c", label="Step C")) + + diagram.add_edge(Edge(source="a", target="b")) + diagram.add_edge(Edge(source="b", target="c")) + + merged = DiagramTransforms.merge_sequential_nodes(diagram) + + # Should merge a->b into single node since b has 1 in and 1 out + # Result should have fewer nodes + assert len(merged.nodes) <= len(diagram.nodes) + + +def test_edge_types_preserved(): + """Test that edge types are preserved in transformations.""" + diagram = DiagramIR() + diagram.add_node(Node(id="a", label="A")) + diagram.add_node(Node(id="b", label="B")) + diagram.add_edge( + Edge(source="a", target="b", edge_type=EdgeType.CONDITIONAL) + ) + + reversed_diagram = DiagramTransforms.reverse_edges(diagram) + + assert reversed_diagram.edges[0].edge_type == EdgeType.CONDITIONAL + + +def test_metadata_preserved(): + """Test that metadata is preserved in transformations.""" + diagram = DiagramIR(metadata={"title": "Test Diagram", "version": "1.0"}) + diagram.add_node(Node(id="a", label="A")) + + simplified = DiagramTransforms.simplify(diagram) + + assert "title" in simplified.metadata + assert simplified.metadata["title"] == "Test Diagram" + + +def test_filter_preserves_edges(): + """Test that filtering preserves edges between kept nodes.""" + diagram = DiagramIR() + diagram.add_node(Node(id="a", label="A", node_type=NodeType.PROCESS)) + diagram.add_node(Node(id="b", label="B", node_type=NodeType.PROCESS)) + diagram.add_node(Node(id="c", label="C", node_type=NodeType.DECISION)) + + diagram.add_edge(Edge(source="a", target="b")) + diagram.add_edge(Edge(source="b", target="c")) + + # Keep only PROCESS nodes + filtered = DiagramTransforms.filter_by_node_type( + diagram, [NodeType.PROCESS], keep=True + ) + + # Should have edge a->b but not b->c + assert len(filtered.edges) == 1 + assert filtered.edges[0].source == "a" + assert filtered.edges[0].target == "b"