Skip to main content
Glama
nazufel
by nazufel
xcode_parser.py25.8 kB
""" Xcode log parser for extracting build errors, warnings, and diagnostic information. """ import os import re import json import subprocess from pathlib import Path from typing import Dict, List, Optional, NamedTuple from datetime import datetime import glob class XcodeDiagnostic(NamedTuple): """Represents a single Xcode diagnostic (error, warning, or note).""" severity: str # 'error', 'warning', 'note' message: str file_path: Optional[str] line_number: Optional[int] column_number: Optional[int] category: Optional[str] code: Optional[str] timestamp: datetime class XcodeBuildResult(NamedTuple): """Represents the result of an Xcode build.""" project_name: str scheme: str target: str configuration: str success: bool diagnostics: List[XcodeDiagnostic] build_time: datetime duration: Optional[float] class XcodeLogParser: """Parses Xcode build logs and extracts diagnostic information.""" def __init__(self): self.derived_data_path = self._get_derived_data_path() # Regex patterns for parsing Xcode logs self.error_patterns = [ # Swift compiler errors with file:line:column format r"^(.+?):(\d+):(\d+): error: (.+)$", # SwiftUI specific errors r"^(.+?):(\d+):(\d+): error: \[SwiftUI\] (.+)$", # Swift scope and syntax errors r"^(.+?):(\d+):(\d+): error: Cannot find '(.+?)' in scope$", r"^(.+?):(\d+):(\d+): error: Use of unresolved identifier '(.+?)'$", r"^(.+?):(\d+):(\d+): error: Expected '(.+?)'$", r"^(.+?):(\d+):(\d+): error: Missing argument for parameter '(.+?)'$", r"^(.+?):(\d+):(\d+): error: Generic parameter '(.+?)' could not be inferred$", r"^(.+?):(\d+):(\d+): error: Incorrect argument label in call$", r"^(.+?):(\d+):(\d+): error: Cannot convert value of type '(.+?)' to expected argument type '(.+?)'$", # Generic error pattern r"^error: (.+)$", # Build error pattern r"^\*\* BUILD FAILED \*\*$", ] self.warning_patterns = [ # Swift compiler warnings r"^(.+?):(\d+):(\d+): warning: (.+)$", # SwiftUI warnings r"^(.+?):(\d+):(\d+): warning: \[SwiftUI\] (.+)$", # Generic warning pattern r"^warning: (.+)$", ] self.note_patterns = [ # Compiler notes r"^(.+?):(\d+):(\d+): note: (.+)$", # Generic note pattern r"^note: (.+)$", ] def _get_derived_data_path(self) -> Path: """Get the path to Xcode's DerivedData directory.""" home = Path.home() derived_data = home / "Library" / "Developer" / "Xcode" / "DerivedData" return derived_data def find_recent_projects(self, limit: int = 10) -> List[str]: """Find recently built Xcode projects.""" if not self.derived_data_path.exists(): return [] projects = [] for project_dir in self.derived_data_path.iterdir(): if project_dir.is_dir() and not project_dir.name.startswith('.'): # Get modification time mod_time = project_dir.stat().st_mtime projects.append((project_dir.name, mod_time)) # Sort by modification time (most recent first) projects.sort(key=lambda x: x[1], reverse=True) return [name for name, _ in projects[:limit]] def get_latest_build_log(self, project_name: Optional[str] = None) -> Optional[Path]: """Get the path to the most recent build log.""" if project_name: project_path = self.derived_data_path / project_name else: # Find the most recently modified project recent_projects = self.find_recent_projects(1) if not recent_projects: return None project_path = self.derived_data_path / recent_projects[0] if not project_path.exists(): return None # Look for build logs in Logs/Build directory logs_path = project_path / "Logs" / "Build" if not logs_path.exists(): return None # Find the most recent .xcactivitylog file log_files = list(logs_path.glob("*.xcactivitylog")) if not log_files: return None # Return the most recently modified log file return max(log_files, key=lambda f: f.stat().st_mtime) def parse_build_log(self, log_path: Path) -> Optional[XcodeBuildResult]: """Parse an Xcode build log file.""" if not log_path.exists(): return None try: # Handle binary .xcactivitylog files properly if log_path.suffix == '.xcactivitylog': content = self._extract_text_from_xcactivitylog(log_path) else: # Try to read as text for other log formats try: content = log_path.read_text(encoding='utf-8', errors='ignore') except UnicodeDecodeError: # If it's binary, try to extract readable text with open(log_path, 'rb') as f: raw_content = f.read() content = raw_content.decode('utf-8', errors='ignore') return self._parse_log_content(content, log_path) except Exception as e: print(f"Error parsing log file {log_path}: {e}") return None def _extract_text_from_xcactivitylog(self, log_path: Path) -> str: """Extract text content from binary .xcactivitylog files.""" try: # Try using xcrun to convert the log to text result = subprocess.run( ['xcrun', 'xcactivitylog', '--log', str(log_path)], capture_output=True, text=True, timeout=30 ) if result.returncode == 0: return result.stdout else: print(f"xcrun xcactivitylog failed: {result.stderr}") except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError) as e: print(f"xcrun not available or failed: {e}") # Fallback: try to extract readable text from binary try: with open(log_path, 'rb') as f: raw_content = f.read() # Look for readable text patterns in the binary data content = raw_content.decode('utf-8', errors='ignore') # Filter out most binary noise, keep lines that look like build output lines = content.split('\n') filtered_lines = [] for line in lines: line = line.strip() if (line and (':' in line or 'error:' in line.lower() or 'warning:' in line.lower() or 'note:' in line.lower() or 'BUILD' in line.upper())): filtered_lines.append(line) return '\n'.join(filtered_lines) except Exception as e: print(f"Fallback text extraction failed: {e}") return "" def _parse_log_content(self, content: str, log_path: Path) -> XcodeBuildResult: """Parse the content of a build log.""" lines = content.split('\n') diagnostics = [] project_name = "Unknown" scheme = "Unknown" target = "Unknown" configuration = "Unknown" success = True build_time = datetime.fromtimestamp(log_path.stat().st_mtime) # Extract project info from log path or content if "DerivedData" in str(log_path): parts = str(log_path).split("/") for i, part in enumerate(parts): if part == "DerivedData" and i + 1 < len(parts): project_name = parts[i + 1].split("-")[0] break for line_num, line in enumerate(lines): line = line.strip() if not line: continue # Check for build failure if "BUILD FAILED" in line or "** BUILD FAILED **" in line: success = False # Parse diagnostics diagnostic = self._parse_diagnostic_line(line) if diagnostic: diagnostics.append(diagnostic) return XcodeBuildResult( project_name=project_name, scheme=scheme, target=target, configuration=configuration, success=success, diagnostics=diagnostics, build_time=build_time, duration=None ) def _parse_diagnostic_line(self, line: str) -> Optional[XcodeDiagnostic]: """Parse a single line for diagnostic information.""" # Try error patterns for pattern in self.error_patterns: match = re.match(pattern, line) if match: if len(match.groups()) >= 4: # Full diagnostic with file, line, column return XcodeDiagnostic( severity='error', message=match.group(4), file_path=match.group(1), line_number=int(match.group(2)), column_number=int(match.group(3)), category=None, code=None, timestamp=datetime.now() ) else: # Generic error return XcodeDiagnostic( severity='error', message=match.group(1), file_path=None, line_number=None, column_number=None, category=None, code=None, timestamp=datetime.now() ) # Try warning patterns for pattern in self.warning_patterns: match = re.match(pattern, line) if match: if len(match.groups()) >= 4: return XcodeDiagnostic( severity='warning', message=match.group(4), file_path=match.group(1), line_number=int(match.group(2)), column_number=int(match.group(3)), category=None, code=None, timestamp=datetime.now() ) else: return XcodeDiagnostic( severity='warning', message=match.group(1), file_path=None, line_number=None, column_number=None, category=None, code=None, timestamp=datetime.now() ) # Try note patterns for pattern in self.note_patterns: match = re.match(pattern, line) if match: if len(match.groups()) >= 4: return XcodeDiagnostic( severity='note', message=match.group(4), file_path=match.group(1), line_number=int(match.group(2)), column_number=int(match.group(3)), category=None, code=None, timestamp=datetime.now() ) else: return XcodeDiagnostic( severity='note', message=match.group(1), file_path=None, line_number=None, column_number=None, category=None, code=None, timestamp=datetime.now() ) return None def get_current_diagnostics(self, project_name: Optional[str] = None) -> List[XcodeDiagnostic]: """Get current diagnostics for the most recent build.""" # First try to get live editor errors from Xcode console print("Debug: Attempting to get live editor errors...") live_editor_errors = self._get_live_editor_errors(project_name) if live_editor_errors: print(f"Debug: Found {len(live_editor_errors)} live editor errors") return live_editor_errors # Then try to get live build errors from xcodebuild print("Debug: Attempting live build diagnostics...") live_diagnostics = self._get_live_build_diagnostics(project_name) if live_diagnostics: print(f"Debug: Found {len(live_diagnostics)} live diagnostics") return live_diagnostics print("Debug: No live diagnostics found, trying log files...") # Fallback to parsing log files log_path = self.get_latest_build_log(project_name) if not log_path: print("Debug: No log files found") return [] print(f"Debug: Found log file: {log_path}") result = self.parse_build_log(log_path) if not result: print("Debug: Could not parse log file") return [] print(f"Debug: Parsed {len(result.diagnostics)} diagnostics from log file") return result.diagnostics def _get_live_editor_errors(self, project_name: Optional[str] = None) -> List[XcodeDiagnostic]: """Get live editor errors by monitoring Xcode's console output.""" try: # Use log command to get recent Xcode diagnostics cmd = [ 'log', 'show', '--predicate', 'process == "Xcode" OR process == "SourceKitService" OR process == "swift"', '--last', '5m', # Last 5 minutes '--style', 'compact' ] print(f"Debug: Running log command: {' '.join(cmd)}") result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) if result.returncode != 0: print(f"Debug: log command failed with return code {result.returncode}") print(f"Debug: stderr: {result.stderr}") return [] print(f"Debug: Log output length: {len(result.stdout)}") # Parse the log output for error patterns diagnostics = [] for line in result.stdout.split('\n'): line = line.strip() if not line: continue # Look for error patterns in the log output diagnostic = self._parse_diagnostic_line(line) if diagnostic: diagnostics.append(diagnostic) print(f"Debug: Found {len(diagnostics)} diagnostics in log output") return diagnostics except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError) as e: print(f"Live editor errors failed: {e}") return [] def _get_live_build_diagnostics(self, project_name: Optional[str] = None) -> List[XcodeDiagnostic]: """Get live build diagnostics by running xcodebuild with live error checking.""" try: # Find the most recent project if not specified if not project_name: recent_projects = self.find_recent_projects(1) if not recent_projects: print("Debug: No recent projects found") return [] project_name = recent_projects[0] print(f"Debug: Using most recent project: {project_name}") # Look for .xcodeproj or .xcworkspace files in common locations project_path = self._find_project_file(project_name) if not project_path: print(f"Debug: Could not find project file for '{project_name}'") return [] print(f"Debug: Found project file: {project_path}") # Run xcodebuild to get current build status cmd = ['xcodebuild', '-project', str(project_path), '-list'] result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) if result.returncode != 0: print(f"Debug: xcodebuild -list failed with return code {result.returncode}") print(f"Debug: stderr: {result.stderr}") return [] # Get available schemes schemes = self._extract_schemes_from_list(result.stdout) if not schemes: print("Debug: No schemes found") return [] print(f"Debug: Found schemes: {schemes}") # Try to build with the first scheme to get current errors scheme = schemes[0] # Use -dry-run first to get syntax errors without full compilation build_cmd = [ 'xcodebuild', '-project', str(project_path), '-scheme', scheme, '-configuration', 'Debug', '-dry-run' # This will show syntax errors without full build ] print(f"Debug: Running dry-run command: {' '.join(build_cmd)}") build_result = subprocess.run( build_cmd, capture_output=True, text=True, timeout=30 ) print(f"Debug: Dry-run completed with return code: {build_result.returncode}") print(f"Debug: stdout length: {len(build_result.stdout)}") print(f"Debug: stderr length: {len(build_result.stderr)}") # Parse build output for diagnostics diagnostics = self._parse_build_output(build_result.stdout, build_result.stderr) print(f"Debug: Parsed {len(diagnostics)} diagnostics from dry-run output") # If dry-run didn't find errors, try a full build if not diagnostics: print("Debug: No errors from dry-run, trying full build...") build_cmd = [ 'xcodebuild', '-project', str(project_path), '-scheme', scheme, '-configuration', 'Debug', 'build' ] build_result = subprocess.run( build_cmd, capture_output=True, text=True, timeout=60 ) print(f"Debug: Full build completed with return code: {build_result.returncode}") diagnostics = self._parse_build_output(build_result.stdout, build_result.stderr) print(f"Debug: Parsed {len(diagnostics)} diagnostics from full build output") return diagnostics except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError) as e: print(f"Live build diagnostics failed: {e}") return [] def _find_project_file(self, project_name: str) -> Optional[Path]: """Find the .xcodeproj or .xcworkspace file for a project using Spotlight.""" try: # Search for .xcworkspace first command = [ 'mdfind', f"kMDItemKind == 'Xcode Workspace' && kMDItemDisplayName == '{project_name}.xcworkspace'" ] result = subprocess.run(command, capture_output=True, text=True, timeout=5) if result.returncode == 0 and result.stdout.strip(): # Take the first result workspace_path = result.stdout.strip().split('\n')[0] return Path(workspace_path) # If not found, search for .xcodeproj command = [ 'mdfind', f"kMDItemKind == 'Xcode Project' && kMDItemDisplayName == '{project_name}.xcodeproj'" ] result = subprocess.run(command, capture_output=True, text=True, timeout=5) if result.returncode == 0 and result.stdout.strip(): # Take the first result project_path = result.stdout.strip().split('\n')[0] return Path(project_path) except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError) as e: print(f"Error finding project using mdfind: {e}") # Fallback to the old method if mdfind fails return self._find_project_file_fallback(project_name) # Fallback if mdfind doesn't find anything return self._find_project_file_fallback(project_name) def _find_project_file_fallback(self, project_name: str) -> Optional[Path]: """Fallback method to find project files in common locations.""" # Common locations to search search_paths = [ Path.home() / "Desktop", Path.home() / "Documents", Path.home() / "Developer", Path("/Users") / "Shared" / "Developer", Path.cwd() # Current working directory ] for search_path in search_paths: if not search_path.exists(): continue # Look for .xcodeproj files for proj_file in search_path.rglob(f"{project_name}.xcodeproj"): return proj_file # Look for .xcworkspace files for workspace_file in search_path.rglob(f"{project_name}.xcworkspace"): return workspace_file return None def _extract_schemes_from_list(self, list_output: str) -> List[str]: """Extract scheme names from xcodebuild -list output.""" schemes = [] in_schemes_section = False for line in list_output.split('\n'): line = line.strip() if 'Schemes:' in line: in_schemes_section = True continue elif in_schemes_section: if line and not line.startswith('Info:') and not line.startswith('Build'): schemes.append(line) elif line.startswith('Info:') or line.startswith('Build'): break return schemes def _parse_build_output(self, stdout: str, stderr: str) -> List[XcodeDiagnostic]: """Parse xcodebuild output for diagnostics.""" diagnostics = [] all_output = stdout + '\n' + stderr for line in all_output.split('\n'): line = line.strip() if not line: continue diagnostic = self._parse_diagnostic_line(line) if diagnostic: diagnostics.append(diagnostic) return diagnostics def watch_for_new_builds(self, callback): """Watch for new build logs and call callback when found.""" from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler class BuildLogHandler(FileSystemEventHandler): def __init__(self, parser, callback): self.parser = parser self.callback = callback def on_created(self, event): if event.is_directory: return if event.src_path.endswith('.xcactivitylog'): # New build log created log_path = Path(event.src_path) result = self.parser.parse_build_log(log_path) if result: self.callback(result) observer = Observer() handler = BuildLogHandler(self, callback) # Watch all project directories for new logs for project_name in self.find_recent_projects(): logs_path = self.derived_data_path / project_name / "Logs" / "Build" if logs_path.exists(): observer.schedule(handler, str(logs_path), recursive=False) observer.start() return observer def main(): """Test the Xcode parser.""" parser = XcodeLogParser() print("Recent Xcode projects:") projects = parser.find_recent_projects(5) for i, project in enumerate(projects): print(f" {i+1}. {project}") print(f"\nDerivedData path: {parser.derived_data_path}") # Get diagnostics for the most recent project diagnostics = parser.get_current_diagnostics() print(f"\nFound {len(diagnostics)} diagnostics:") for diag in diagnostics: location = "" if diag.file_path and diag.line_number: location = f" at {diag.file_path}:{diag.line_number}" if diag.column_number: location += f":{diag.column_number}" print(f" [{diag.severity.upper()}]{location}: {diag.message}") if __name__ == "__main__": main()

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/nazufel/xcode-errors-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server