feat: Enhanced PlantUML validation with dual-method checking

Now validates using two methods for better error detection:
1. plantuml -syntax: Fast check with line numbers
2. plantuml -tsvg: Render check that catches errors via stderr
   and embedded error indicators in SVG output

Also extracts error line numbers from rendered SVG when available.

Note: PlantUML only catches syntax errors, not semantic issues
(e.g., nesting rectangles in components produces valid syntax
but incorrect rendering).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2025-12-30 23:20:36 -04:00
parent 1923053f6a
commit e83177d248
1 changed files with 48 additions and 17 deletions

View File

@ -263,15 +263,24 @@ class AIThread(QThread):
return True, None # No validation available return True, None # No validation available
def _validate_plantuml(self, code: str) -> tuple: def _validate_plantuml(self, code: str) -> tuple:
"""Validate PlantUML code using plantuml -syntax command.""" """Validate PlantUML code using plantuml -syntax and render check.
Uses two validation methods:
1. plantuml -syntax: Fast syntax check with line numbers
2. plantuml -tsvg: Render check that catches additional errors via stderr
Note: PlantUML only catches syntax errors, not semantic issues
(e.g., nesting elements in unsupported ways).
"""
import shutil import shutil
import re
plantuml_cmd = shutil.which('plantuml') plantuml_cmd = shutil.which('plantuml')
if not plantuml_cmd: if not plantuml_cmd:
# PlantUML not installed, skip validation return True, None # PlantUML not installed, skip validation
return True, None
try: try:
# Method 1: Syntax check (fast, gives line numbers)
result = subprocess.run( result = subprocess.run(
[plantuml_cmd, '-syntax', '-pipe'], [plantuml_cmd, '-syntax', '-pipe'],
input=code, input=code,
@ -280,28 +289,50 @@ class AIThread(QThread):
timeout=30 timeout=30
) )
# Exit code 0 = valid, anything else = error # Parse error from syntax check
if result.returncode == 0: if result.returncode != 0 or 'ERROR' in result.stdout:
return True, None return self._parse_plantuml_error(result.stdout)
# Parse error message from stdout # Method 2: Render check (catches some errors -syntax misses)
# Format: "ERROR\n<line_number>\nSyntax Error?\n<description>" render_result = subprocess.run(
error_lines = result.stdout.strip().split('\n') [plantuml_cmd, '-tsvg', '-pipe'],
error_msg = "PlantUML syntax error" input=code,
capture_output=True,
text=True,
timeout=30
)
if len(error_lines) >= 2 and error_lines[0] == 'ERROR': # Check stderr for error indicators
line_num = error_lines[1] if 'ERROR' in render_result.stderr or 'Syntax Error' in render_result.stderr:
error_msg = f"PlantUML syntax error on line {line_num}" return self._parse_plantuml_error(render_result.stderr)
if len(error_lines) >= 4:
error_msg += f": {error_lines[3]}"
return False, error_msg # Check SVG output for embedded error (red error text)
if 'fill="#FF0000"' in render_result.stdout and 'Syntax Error' in render_result.stdout:
line_match = re.search(r'\[From string \(line (\d+)\)', render_result.stdout)
if line_match:
return False, f"PlantUML error on line {line_match.group(1)}"
return False, "PlantUML syntax error (see rendered output)"
return True, None
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return True, None # Skip validation on timeout return True, None # Skip validation on timeout
except Exception as e: except Exception:
return True, None # Skip validation on error return True, None # Skip validation on error
def _parse_plantuml_error(self, output: str) -> tuple:
"""Parse PlantUML error output and return (False, error_message)."""
error_lines = output.strip().split('\n')
error_msg = "PlantUML syntax error"
if len(error_lines) >= 2 and error_lines[0] == 'ERROR':
line_num = error_lines[1]
error_msg = f"PlantUML syntax error on line {line_num}"
if len(error_lines) >= 4:
error_msg += f": {error_lines[3]}"
return False, error_msg
def _call_ai(self, instruction: str, current_code: str) -> tuple: def _call_ai(self, instruction: str, current_code: str) -> tuple:
"""Call artifact-ai SmartTool and return (success, response_or_error). """Call artifact-ai SmartTool and return (success, response_or_error).