#!/usr/bin/env python3 """ Convert legacy # [DEF:...] / # [/DEF:...] annotations to #region/#endregion format. ONLY modifies comment lines (starting with '#'). NEVER touches code, strings, imports, or non-comment lines. Preserves original line endings (CRLF vs LF). """ from pathlib import Path import re SRC_DIR = Path(__file__).resolve().parent / "src" def has_crlf(content: bytes) -> bool: """Check if content uses CRLF line endings.""" return b'\r\n' in content def process_file(filepath: Path) -> tuple[int, int]: """Process a single file. Returns (blocks_converted, metadata_merged).""" # Read as bytes to detect original line endings with open(filepath, "rb") as f: raw_bytes = f.read() original_crlf = has_crlf(raw_bytes) # Decode text (Python converts CRLF to LF automatically on decode in text mode, # but we decoded from bytes, so handle it manually) text = raw_bytes.decode('utf-8') if original_crlf: text = text.replace('\r\n', '\n') lines = text.split('\n') result = [] blocks_converted = 0 metadata_merged = 0 i = 0 n = len(lines) while i < n: line = lines[i] stripped = line.rstrip('\r') # Check for [SECTION: ...] or [/SECTION] lines if re.match(r'^#\s*\[SECTION:\s*.*\]\s*$', stripped): i += 1 continue if re.match(r'^#\s*\[/SECTION\]\s*$', stripped): i += 1 continue # Check for [/DEF:Name] or [/DEF:Name:Type] m = re.match(r'^#\s*\[/DEF:(\w+(?:\.\w+)*)(?::\w+)?\]\s*$', stripped) if m: name = m.group(1) result.append(f"# #endregion {name}") blocks_converted += 1 i += 1 continue # Check for [DEF:Name:Type] m = re.match(r'^#\s*\[DEF:(\w+(?:\.\w+)*):(\w+)\]\s*$', stripped) if m: name = m.group(1) typ = m.group(2) # Collect subsequent metadata lines complexity = None semantics = None metadata_lines = [] # other metadata lines to preserve j = i + 1 while j < n: nxt = lines[j].rstrip('\r') # Stop if not a comment if not nxt.startswith('#'): break # Stop if it's another DEF or SECTION if re.match(r'^#\s*\[', nxt): break # Skip blank comment lines (just '#') but continue collecting if re.match(r'^#\s*$', nxt): metadata_lines.append(lines[j]) j += 1 continue # Check for @COMPLEXITY cm = re.match(r'^#\s*@COMPLEXITY:\s*(\d+)\s*$', nxt) if cm: complexity = int(cm.group(1)) j += 1 continue # Check for @SEMANTICS sm = re.match(r'^#\s*@SEMANTICS:\s*(.+?)\s*$', nxt) if sm: semantics = sm.group(1).strip() j += 1 continue # Skip @PARAM, @RETURN, @THROW if re.match(r'^#\s*@(PARAM|RETURN|THROW)\b', nxt): j += 1 continue # Convert @PURPOSE -> @BRIEF pm = re.match(r'^#\s*@PURPOSE:\s*(.*?)\s*$', nxt) if pm: metadata_lines.append(f"# @BRIEF {pm.group(1).strip()}") j += 1 continue # Normalize @RELATION rm = re.match(r'^#\s*@RELATION:\s*(.+?)\s*$', nxt) if rm: rel_text = rm.group(1).strip() # Remove brackets ONLY from predicate (word before ->) rel_text = re.sub(r'\[(\w+)\]\s*->', r'\1 ->', rel_text) # Normalize whitespace around -> rel_text = re.sub(r'\s*->\s*', ' -> ', rel_text) metadata_lines.append(f"# @RELATION {rel_text}") j += 1 continue # Pass through any other tags as-is if re.match(r'^#\s*@\w+', nxt): metadata_lines.append(lines[j]) j += 1 continue # If it doesn't match any known pattern, keep it but stop collecting break # Build the header header_parts = [f"# #region {name}"] if complexity is not None: header_parts.append(f"[C:{complexity}]") metadata_merged += 1 header_parts.append(f"[TYPE {typ}]") if semantics is not None: header_parts.append(f"[SEMANTICS {semantics}]") metadata_merged += 1 result.append(" ".join(header_parts)) # Add preserved metadata lines result.extend(metadata_lines) blocks_converted += 1 i = j continue # Check for standalone @PURPOSE (not in a DEF block) pm = re.match(r'^#\s*@PURPOSE:\s*(.*?)\s*$', stripped) if pm: result.append(f"# @BRIEF {pm.group(1).strip()}") i += 1 continue # Check for standalone @RELATION normalization rm = re.match(r'^#\s*@RELATION:\s*(.+?)\s*$', stripped) if rm: rel_text = rm.group(1).strip() # Remove brackets ONLY from predicate (word before ->) rel_text = re.sub(r'\[(\w+)\]\s*->', r'\1 ->', rel_text) # Normalize whitespace around -> rel_text = re.sub(r'\s*->\s*', ' -> ', rel_text) result.append(f"# @RELATION {rel_text}") i += 1 continue # Skip @PARAM, @RETURN, @THROW lines (standalone) if re.match(r'^#\s*@(PARAM|RETURN|THROW)\b', stripped): i += 1 continue # Pass through everything else unchanged result.append(line) i += 1 # Build new content with preserved line endings new_text = '\n'.join(result) if new_text == text: return 0, 0 # Convert back to CRLF if original had it if original_crlf: new_raw = new_text.replace('\n', '\r\n').encode('utf-8') else: new_raw = new_text.encode('utf-8') with open(filepath, "wb") as f: f.write(new_raw) return blocks_converted, metadata_merged def main(): total_blocks = 0 total_merged = 0 total_files = 0 # Collect all Python files py_files = sorted(SRC_DIR.rglob("*.py")) for filepath in py_files: # Skip __pycache__, .venv, tests rel = filepath.relative_to(SRC_DIR) parts = list(rel.parts) if "__pycache__" in parts or ".venv" in parts: continue if any(p.startswith("__tests__") or p == "tests" for p in parts): continue try: blocks, merged = process_file(filepath) if blocks > 0 or merged > 0: total_files += 1 total_blocks += blocks total_merged += merged print(f" {rel}: {blocks} blocks, {merged} metadata merged") except Exception as e: print(f" ERROR processing {rel}: {e}") print(f"\nTotal: {total_files} files processed, {total_blocks} DEF blocks converted, {total_merged} metadata merged") if __name__ == "__main__": main()