diff --git a/Makefile b/Makefile index 4f9e9aa..66bbfd3 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # CLI Patterns Makefile # Development and testing automation -.PHONY: help install test test-unit test-integration test-coverage test-parser test-executor test-design test-fast test-components lint type-check format clean clean-docker all quality format-check ci-setup ci-native ci-docker verify-sync benchmark test-all ci-summary +.PHONY: help install test test-unit test-integration test-coverage test-parser test-executor test-design test-fast test-components lint lint-fix type-check format clean clean-docker all quality format-check ci-setup ci-native ci-docker verify-sync benchmark test-all ci-summary # Default target help: @@ -18,6 +18,7 @@ help: @echo "make test-fast - Run non-slow tests only" @echo "make test-components - Run all component tests (parser, executor, design)" @echo "make lint - Run ruff linter" + @echo "make lint-fix - Run ruff linter and auto-fix issues" @echo "make type-check - Run mypy type checking" @echo "make format - Format code with black" @echo "make clean - Remove build artifacts and cache" @@ -79,6 +80,14 @@ lint: ruff check src/ tests/; \ fi +# Lint code and auto-fix issues +lint-fix: + @if command -v uv > /dev/null 2>&1; then \ + uv run ruff check src/ tests/ --fix; \ + else \ + ruff check src/ tests/ --fix; \ + fi + # Type check with mypy type-check: @if command -v uv > /dev/null 2>&1; then \ diff --git a/src/cli_patterns/ui/parser/types.py b/src/cli_patterns/ui/parser/types.py index 7e9df44..6d5acb0 100644 --- a/src/cli_patterns/ui/parser/types.py +++ b/src/cli_patterns/ui/parser/types.py @@ -5,6 +5,12 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Optional +from rich.console import Group, RenderableType +from rich.text import Text + +from cli_patterns.ui.design.registry import theme_registry +from cli_patterns.ui.design.tokens import HierarchyToken, StatusToken + if TYPE_CHECKING: pass @@ -87,6 +93,9 @@ def has_flag(self, flag: str) -> bool: class ParseError(Exception): """Exception raised during command parsing. + This class implements the Rich __rich__() protocol for automatic themed display + when printed to a Rich console. Use console.print(error) for best results. + Attributes: message: Human-readable error message error_type: Type of parsing error @@ -115,6 +124,90 @@ def __str__(self) -> str: """String representation of the error.""" return f"{self.error_type}: {self.message}" + def __rich__(self) -> RenderableType: + """Rich rendering protocol implementation for automatic themed display. + + Uses the global theme_registry to resolve design tokens to themed styles, + ensuring consistency with the application's design system. + + Returns: + RenderableType (Group) containing styled error message and suggestions + """ + # Map error_type to StatusToken + status_token = self._get_status_token() + + # Create styled error message using theme registry + error_text = Text() + error_text.append( + f"{self.error_type}: ", style=theme_registry.resolve(status_token) + ) + error_text.append(self.message) + + # Create suggestions list with hierarchy styling (limit to 3) + renderables: list[RenderableType] = [error_text] + + if self.suggestions: + # Add "Did you mean:" prompt using theme registry + prompt_text = Text( + "\n\nDid you mean:", style=theme_registry.resolve(StatusToken.INFO) + ) + renderables.append(prompt_text) + + # Add up to 3 suggestions with hierarchy styling + for idx, suggestion in enumerate(self.suggestions[:3]): + hierarchy = self._get_suggestion_hierarchy(idx) + suggestion_text = Text() + suggestion_text.append( + f"\n • {suggestion}", style=theme_registry.resolve(hierarchy) + ) + renderables.append(suggestion_text) + + return Group(*renderables) + + def _get_status_token(self) -> StatusToken: + """Map error_type to appropriate StatusToken. + + Returns: + StatusToken based on error_type + """ + error_type_lower = self.error_type.lower() + + if "syntax" in error_type_lower: + return StatusToken.ERROR + elif ( + "unknown_command" in error_type_lower + or "command_not_found" in error_type_lower + ): + return StatusToken.WARNING + elif ( + "invalid_args" in error_type_lower or "invalid_argument" in error_type_lower + ): + return StatusToken.ERROR + elif "deprecated" in error_type_lower: + return StatusToken.WARNING + else: + # Default to ERROR for unknown error types + return StatusToken.ERROR + + def _get_suggestion_hierarchy(self, index: int) -> HierarchyToken: + """Get hierarchy token for suggestion based on ranking. + + The first suggestion is PRIMARY (best match), second is SECONDARY (good match), + and third is TERTIARY (possible match). + + Args: + index: Position in suggestions list (0-based) + + Returns: + HierarchyToken indicating visual importance + """ + if index == 0: + return HierarchyToken.PRIMARY # Best match + elif index == 1: + return HierarchyToken.SECONDARY # Good match + else: + return HierarchyToken.TERTIARY # Possible match + @dataclass class Context: diff --git a/tests/unit/ui/parser/test_types.py b/tests/unit/ui/parser/test_types.py index 7008824..01e629b 100644 --- a/tests/unit/ui/parser/test_types.py +++ b/tests/unit/ui/parser/test_types.py @@ -5,12 +5,14 @@ from typing import Any import pytest +from rich.console import Console, Group from cli_patterns.ui.design.tokens import ( CategoryToken, DisplayMetadata, EmphasisToken, HierarchyToken, + StatusToken, ) from cli_patterns.ui.parser.types import ( CommandArgs, @@ -545,6 +547,334 @@ def test_error_serialization_with_metadata(self) -> None: assert error.display_metadata.hierarchy == HierarchyToken.SECONDARY +class TestParseErrorRichRendering: + """Test ParseError's Rich protocol implementation (__rich__ method).""" + + def test_rich_method_returns_group(self) -> None: + """Test that __rich__() returns a Rich Group.""" + error = ParseError( + error_type="TEST_ERROR", + message="Test error message", + suggestions=["Suggestion 1"], + ) + + result = error.__rich__() + assert isinstance(result, Group) + + def test_rich_rendering_without_suggestions(self) -> None: + """Test Rich rendering of error without suggestions.""" + error = ParseError( + error_type="NO_SUGGESTIONS_ERROR", + message="Error without any suggestions", + suggestions=[], + ) + + result = error.__rich__() + assert isinstance(result, Group) + + # Convert to text to inspect content + console = Console() + with console.capture() as capture: + console.print(result) + output = capture.get() + + assert "NO_SUGGESTIONS_ERROR" in output + assert "Error without any suggestions" in output + assert "Did you mean:" not in output + + def test_rich_rendering_with_suggestions(self) -> None: + """Test Rich rendering of error with suggestions.""" + error = ParseError( + error_type="WITH_SUGGESTIONS", + message="Error with suggestions", + suggestions=["First suggestion", "Second suggestion"], + ) + + result = error.__rich__() + assert isinstance(result, Group) + + # Convert to text to inspect content + console = Console() + with console.capture() as capture: + console.print(result) + output = capture.get() + + assert "WITH_SUGGESTIONS" in output + assert "Error with suggestions" in output + assert "Did you mean:" in output + assert "First suggestion" in output + assert "Second suggestion" in output + + def test_rich_rendering_limits_suggestions_to_three(self) -> None: + """Test that Rich rendering limits suggestions to max 3.""" + error = ParseError( + error_type="MANY_SUGGESTIONS", + message="Error with many suggestions", + suggestions=[ + "Suggestion 1", + "Suggestion 2", + "Suggestion 3", + "Suggestion 4", + "Suggestion 5", + ], + ) + + result = error.__rich__() + console = Console() + with console.capture() as capture: + console.print(result) + output = capture.get() + + # Should include first 3 suggestions + assert "Suggestion 1" in output + assert "Suggestion 2" in output + assert "Suggestion 3" in output + + # Should NOT include 4th and 5th suggestions + assert "Suggestion 4" not in output + assert "Suggestion 5" not in output + + def test_error_type_to_status_token_mapping_syntax(self) -> None: + """Test that syntax errors map to ERROR status.""" + error = ParseError( + error_type="SYNTAX_ERROR", + message="Syntax error in command", + suggestions=[], + ) + + status = error._get_status_token() + assert status == StatusToken.ERROR + + def test_error_type_to_status_token_mapping_unknown_command(self) -> None: + """Test that unknown command errors map to WARNING status.""" + error = ParseError( + error_type="UNKNOWN_COMMAND", + message="Command not found", + suggestions=[], + ) + + status = error._get_status_token() + assert status == StatusToken.WARNING + + def test_error_type_to_status_token_mapping_invalid_args(self) -> None: + """Test that invalid args errors map to ERROR status.""" + error = ParseError( + error_type="INVALID_ARGS", + message="Invalid arguments provided", + suggestions=[], + ) + + status = error._get_status_token() + assert status == StatusToken.ERROR + + def test_error_type_to_status_token_mapping_deprecated(self) -> None: + """Test that deprecated errors map to WARNING status.""" + error = ParseError( + error_type="DEPRECATED_COMMAND", + message="This command is deprecated", + suggestions=[], + ) + + status = error._get_status_token() + assert status == StatusToken.WARNING + + def test_error_type_to_status_token_mapping_default(self) -> None: + """Test that unknown error types default to ERROR status.""" + error = ParseError( + error_type="RANDOM_ERROR_TYPE", + message="Some unknown error", + suggestions=[], + ) + + status = error._get_status_token() + assert status == StatusToken.ERROR + + def test_suggestion_hierarchy_first_is_primary(self) -> None: + """Test that first suggestion gets PRIMARY hierarchy.""" + error = ParseError( + error_type="TEST", + message="Test", + suggestions=["First"], + ) + + hierarchy = error._get_suggestion_hierarchy(0) + assert hierarchy == HierarchyToken.PRIMARY + + def test_suggestion_hierarchy_second_is_secondary(self) -> None: + """Test that second suggestion gets SECONDARY hierarchy.""" + error = ParseError( + error_type="TEST", + message="Test", + suggestions=["First", "Second"], + ) + + hierarchy = error._get_suggestion_hierarchy(1) + assert hierarchy == HierarchyToken.SECONDARY + + def test_suggestion_hierarchy_third_is_tertiary(self) -> None: + """Test that third suggestion gets TERTIARY hierarchy.""" + error = ParseError( + error_type="TEST", + message="Test", + suggestions=["First", "Second", "Third"], + ) + + hierarchy = error._get_suggestion_hierarchy(2) + assert hierarchy == HierarchyToken.TERTIARY + + def test_theme_registry_integration_status_tokens(self) -> None: + """Test that __rich__() uses theme_registry for StatusToken styling.""" + from cli_patterns.ui.design.registry import theme_registry + + error = ParseError( + error_type="SYNTAX_ERROR", + message="Test theme integration", + suggestions=[], + ) + + # Get the status token and verify it can be resolved + status = error._get_status_token() + style = theme_registry.resolve(status) + + # Verify theme registry returns a valid style string + assert isinstance(style, str) + assert len(style) > 0 + + def test_theme_registry_integration_hierarchy_tokens(self) -> None: + """Test that __rich__() uses theme_registry for HierarchyToken styling.""" + from cli_patterns.ui.design.registry import theme_registry + + error = ParseError( + error_type="TEST", + message="Test hierarchy styling", + suggestions=["First", "Second", "Third"], + ) + + # Get hierarchy tokens and verify they can be resolved + for idx in range(3): + hierarchy = error._get_suggestion_hierarchy(idx) + style = theme_registry.resolve(hierarchy) + + # Verify theme registry returns a valid style string + assert isinstance(style, str) + assert len(style) > 0 + + def test_theme_registry_integration_in_rich_output(self) -> None: + """Test that Rich rendering uses theme_registry resolved styles.""" + error = ParseError( + error_type="THEME_TEST", + message="Testing theme registry integration", + suggestions=["Suggestion 1"], + ) + + # Render with Rich + result = error.__rich__() + assert isinstance(result, Group) + + # Verify the error can be printed (theme resolution doesn't throw) + console = Console() + with console.capture() as capture: + console.print(error) + output = capture.get() + + assert "THEME_TEST" in output + assert "Testing theme registry integration" in output + + def test_rich_rendering_with_console_print(self) -> None: + """Test that ParseError can be directly printed to Rich console.""" + error = ParseError( + error_type="CONSOLE_PRINT_TEST", + message="Testing console.print() integration", + suggestions=["Use console.print()", "Automatic styling works"], + ) + + console = Console() + with console.capture() as capture: + console.print(error) + output = capture.get() + + assert "CONSOLE_PRINT_TEST" in output + assert "Testing console.print() integration" in output + assert "Did you mean:" in output + assert "Use console.print()" in output + + def test_rich_rendering_preserves_multiline_messages(self) -> None: + """Test that multiline error messages are preserved in Rich rendering.""" + error = ParseError( + error_type="MULTILINE_ERROR", + message="First line of error\nSecond line of error\nThird line", + suggestions=["Fix the error"], + ) + + console = Console() + with console.capture() as capture: + console.print(error) + output = capture.get() + + assert "First line of error" in output + assert "Second line of error" in output + assert "Third line" in output + + def test_rich_rendering_handles_unicode(self) -> None: + """Test that Rich rendering handles Unicode characters correctly.""" + error = ParseError( + error_type="UNICODE_ERROR", + message="Error with Unicode: üñíçødé symbols", + suggestions=["Check encoding"], + ) + + console = Console() + with console.capture() as capture: + console.print(error) + output = capture.get() + + assert "üñíçødé" in output + + def test_rich_rendering_backward_compatible_with_str(self) -> None: + """Test that __str__() still works alongside __rich__().""" + error = ParseError( + error_type="BACKWARD_COMPAT", + message="Test backward compatibility", + suggestions=["Maintain compatibility"], + ) + + # __str__() should still work + str_output = str(error) + assert "BACKWARD_COMPAT" in str_output + assert "Test backward compatibility" in str_output + + # __rich__() should also work + result = error.__rich__() + assert isinstance(result, Group) + + @pytest.mark.parametrize( + "error_type,expected_in_output", + [ + ("syntax_error", "syntax_error"), + ("UNKNOWN_COMMAND_ERROR", "UNKNOWN_COMMAND_ERROR"), + ("invalid_args_provided", "invalid_args_provided"), + ("deprecated_feature", "deprecated_feature"), + ], + ) + def test_rich_rendering_various_error_types( + self, error_type: str, expected_in_output: str + ) -> None: + """Test Rich rendering with various error type patterns.""" + error = ParseError( + error_type=error_type, + message=f"Message for {error_type}", + suggestions=["Fix it"], + ) + + console = Console() + with console.capture() as capture: + console.print(error) + output = capture.get() + + assert expected_in_output in output + assert f"Message for {error_type}" in output + + class TestContext: """Test Context class for parser state management."""