diff --git a/configconverter/validators.py b/configconverter/validators.py new file mode 100644 index 0000000..2030b97 --- /dev/null +++ b/configconverter/validators.py @@ -0,0 +1,135 @@ +"""Validation module for config-converter-cli.""" + +from typing import Optional, Tuple + +from rich.console import Console +from rich.syntax import Syntax +from rich.text import Text + +from configconverter.converters import Converter +from configconverter.exceptions import ParseError, ValidationError + + +class Validator: + """Validates configuration files for syntax errors.""" + + def __init__(self): + self.converter = Converter() + self.console = Console() + + def validate( + self, content: str, format: Optional[str] = None + ) -> Tuple[bool, Optional[ParseError]]: + """Validate content for syntax errors. + + Args: + content: The content to validate + format: Optional format hint (json, yaml, toml) + + Returns: + Tuple of (is_valid, error) + """ + try: + if format: + detected_format = format + else: + detected_format = self.converter.detect_format(content) + + data = self.converter._parse(content, detected_format) + return True, None + except ParseError as e: + return False, e + except Exception as e: + return False, ParseError(f"Validation failed: {e}") + + def validate_file( + self, file_path: str, format: Optional[str] = None + ) -> Tuple[bool, Optional[ParseError]]: + """Validate a file for syntax errors. + + Args: + file_path: Path to the file + format: Optional format hint (json, yaml, toml) + + Returns: + Tuple of (is_valid, error) + """ + try: + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + return self.validate(content, format) + except FileNotFoundError: + return False, ParseError(f"File not found: {file_path}") + except Exception as e: + return False, ParseError(f"Failed to read file: {e}") + + def format_error_message( + self, error: ParseError, content: str + ) -> str: + """Format a parse error with syntax highlighting. + + Args: + error: The parse error + content: The original content + + Returns: + Formatted error message + """ + lines = content.split("\n") + + if error.line_number is not None: + line_idx = error.line_number - 1 + if 0 <= line_idx < len(lines): + context_lines = 2 + start = max(0, line_idx - context_lines) + end = min(len(lines), line_idx + context_lines + 1) + + snippet_lines = [] + for i in range(start, end): + prefix = ">>> " if i == line_idx else " " + snippet_lines.append(f"{prefix}{i + 1:4d} | {lines[i]}") + + snippet = "\n".join(snippet_lines) + return f"Parse Error at line {error.line_number}:\n{snippet}\n\n{error.message}" + + return f"Parse Error: {error.message}" + + def print_error( + self, error: ParseError, content: str, file_path: Optional[str] = None + ) -> None: + """Print a validation error with colored output. + + Args: + error: The parse error + content: The original content + file_path: Optional file path for header + """ + header = f"[bold red]Error[/bold red]" + if file_path: + header += f" in [cyan]{file_path}[/cyan]" + + self.console.print(header) + + if error.line_number is not None: + lines = content.split("\n") + line_idx = error.line_number - 1 + + if 0 <= line_idx < len(lines): + self.console.print( + f"Line {error.line_number}: [red]{error.message}[/red]" + ) + self.console.print() + + syntax = Syntax( + lines[line_idx], + "python", + line_numbers=True, + start_line=error.line_number, + highlight_lines={error.line_number}, + ) + self.console.print(syntax) + else: + self.console.print(f"[red]{error.message}[/red]") + + +validator = Validator()