diff --git a/.agents/skills/mike/SKILL.md b/.agents/skills/mike/SKILL.md index 48f403e..5efc0d2 100644 --- a/.agents/skills/mike/SKILL.md +++ b/.agents/skills/mike/SKILL.md @@ -129,3 +129,22 @@ The deployment logic is automated in [.github/workflows/ci.yaml](.github/workflo ### 404 for `versions.json` - If you see a 404 for `/versions.json` but `https://.github.io//versions.json` exists, the switcher is looking at the domain root instead of the project root. Verify `site_url` includes the repository name and has a trailing slash. + +## Post-Mortem & Known Issues + +> [!CAUTION] +> **Current Status**: Documentation versioning is currently **BROKEN** on the live site (`kilo59.github.io/ruff-sync`). + +### Failed Repair History +The following fixes have been attempted and **FAILED** to resolve the issue: +1. **Lowercasing `site_url`**: Normalizing the repository name in the URL (e.g., `ruff-sync` instead of `Ruff-Sync`) did not fix the 404s for `versions.json`. +2. **Removing `theme.version`**: Removing the redundant Material 9.x config did not restore the switcher. +3. **Adding `canonical_version: stable`**: Adding this to the `mike` plugin in `mkdocs.yml` was intended to fix path resolution but has not fixed the root page 404. +4. **CI Restoration Logic**: Adding `mike alias --push stable stable` to the CI to manually repair `versions.json` hasn't restored the picker on the root page. + +### Root Cause Suspicions +- **GitHub Pages Subfolder Pathing**: The site is served from a subfolder (`/ruff-sync/`). `mike`'s JavaScript for the version switcher frequently struggles with calculating relative paths to `versions.json` when served from a subfolder if `site_url` or base paths are not perfectly aligned with the deployment environment. +- **`versions.json` Drift**: The `versions.json` file on the `gh-pages` branch frequently becomes desynchronized or loses the `stable` entry, which triggers `mkdocs-material` to hide the switcher entirely. + +### Guidance for Future Agents +Before attempting another "fix," you **MUST** verify the current state of `versions.json` on the `gh-pages` branch and check the browser console on the live site for 404 paths. Do not assume standard configurations will work without manual verification of the deployed assets. diff --git a/.agents/skills/textual/references/widgets.md b/.agents/skills/textual/references/widgets.md index d56c334..2585d84 100644 --- a/.agents/skills/textual/references/widgets.md +++ b/.agents/skills/textual/references/widgets.md @@ -57,3 +57,29 @@ def on_key(self, event: events.Key) -> None: > [!TIP] > Use `BINDINGS` in your `App` or `Screen` class for most navigation tasks. Textual manages the labels and shortcuts in the `Footer` for you. + +### `ModalScreen` & Overlays +Modals are screens with a transparent or dim background that overlay the main app. +```python +from textual.screen import ModalScreen +from textual.app import App + +class OmniboxScreen(ModalScreen[str]): + # A modal screen that returns a `str` when dismissed. + def compose(self) -> ComposeResult: + # yield your input/search widgets here + yield Input(placeholder="Search...") + + # Dismiss the screen and return data + def on_input_submitted(self, event: Input.Submitted) -> None: + self.dismiss(event.value) + +# In the main App or Screen: +def on_key(self, event: events.Key) -> None: + if event.key == "ctrl+p": + self.push_screen(OmniboxScreen(), self.handle_omnibox_result) + +def handle_omnibox_result(self, result: str | None) -> None: + if result: + print(f"Selected: {result}") +``` diff --git a/.agents/tui_design.md b/.agents/tui_design.md index d20fddb..2f28c58 100644 --- a/.agents/tui_design.md +++ b/.agents/tui_design.md @@ -12,7 +12,10 @@ Before we build the TUI feature itself, some of the existing codebase should be Currently, `ruff-sync` is primarily concerned with TOML serialization. The TUI introduces a requirement to execute the system `ruff` binary (e.g. `ruff rule `). **Refactoring Task:** - Create a generalized, safe abstraction for interacting with the `ruff` executable. -- Provide a typed function `async def get_ruff_rule_markdown(rule_code: str) -> str | None` that uses `asyncio.create_subprocess_exec` (or threads with `subprocess`) to fetch and return the rule text. This prevents raw `subprocess` sprawl across TUI widgets. +- Provide typed async functions: + - `async def get_ruff_rule_markdown(rule_code: str) -> str | None` + - `async def get_ruff_config_markdown(setting_path: str) -> str | None` +- These should use `asyncio.create_subprocess_exec` to fetch and return the text. This prevents raw `subprocess` sprawl across TUI widgets. ### B. Configuration Reader Extraction (`src/ruff_sync/config_io.py` or similar) Right now, discovering and extracting the local `pyproject.toml` is slightly coupled to the lifecycle of pulling from upstreams (`pull()` / `check()` in `core.py`). @@ -59,16 +62,21 @@ Right now, discovering and extracting the local `pyproject.toml` is slightly cou ### [NEW] src/ruff_sync/tui/app.py - Define `RuffSyncApp` subclassing `textual.app.App`. - **CSS**: Define embedded TCSS. - - Layout: `Horizontal` split with `Tree` (left) sized `1fr`, and a `Vertical` container (right) sized `2fr`. Left side for Navigation, right side for Content & Inspector. + - Layout: `Horizontal` split with `Tree` (left) sized `1fr`, and a `Vertical` container (`#content-pane`) (right) sized `2fr`. + - **Dynamic Layout**: + - `#category-table` takes **40% height** by default. + - `#inspector` (Markdown) takes **60% height** by default. + - When the table is hidden, the `#inspector` uses a `.full-height` class (**100% height**) to fill the content pane. + - Both widgets utilize `overflow-y: auto` for vertical scrolling only when needed. - **Compose Method**: ```python def compose(self) -> ComposeResult: yield Header() with Horizontal(): yield ConfigTree(id="config-tree") - with Vertical(): + with Vertical(id="content-pane"): yield CategoryTable(id="category-table") - yield RuleInspector(id="inspector", classes="hidden") + yield RuleInspector(id="inspector") yield Footer() ``` - **Lifecycle (`on_mount`)**: @@ -87,9 +95,9 @@ Contains all custom Textual widgets required for this view. - Automatically clears and updates columns/rows when a tree node is highlighted. **3. `RuleInspector` (inherits `textual.widgets.Markdown`)**: -- Displays `ruff rule ` output. -- Features an `async def fetch_and_display(self, rule_code: str)` method. -- **Background Worker**: Uses Textual `@work(thread=True)` to execute our refactored `get_ruff_rule_markdown()` function. +- Displays documentation for both rules (`ruff rule `) and settings (`ruff config `). +- Features an `async def fetch_and_display(self, target: str, is_rule: bool = True)` method. +- **Background Worker**: Uses Textual `@work(thread=True)` to execute the appropriate refactored `get_ruff_*_markdown()` function. --- @@ -98,10 +106,11 @@ Contains all custom Textual widgets required for this view. ### [MODIFY] src/ruff_sync/tui/app.py (Reactivity) - Tie widgets together using Textual's event routing (`@on`): - `@on(Tree.NodeSelected)`: - - If the node is a configuration section (e.g. `lint.isort`), hide the Inspector and populate the `CategoryTable` with settings. - - If the node represents a list of rules (unwrapped `lint.select`), display the active rule list in the table. + - If the node is a configuration section (e.g. `lint.isort`), ensure the `CategoryTable` is visible (remove "hidden" class), remove the `.full-height` class from the inspector, and populate it with settings. It also triggers `inspector.fetch_and_display(section_path, is_rule=False)` to show section-level docs if available. + - If the node represents a rule code, add the "hidden" class to `CategoryTable` to maximize vertical space, add the `.full-height` class to the `RuleInspector`, and display the rule via `fetch_and_display(code, is_rule=True)`. - `@on(DataTable.RowSelected)`: - - If the focused row represents a Ruff Rule Code (e.g., `RUF012`), reveal the `RuleInspector` widget and call `inspector.fetch_and_display("RUF012")`. + - If the focused row represents a Ruff Rule Code (e.g., `RUF012`), hide the `CategoryTable`, reveal the `RuleInspector` widget, apply `.full-height`, and call `inspector.fetch_and_display("RUF012", is_rule=True)`. + - If the focused row represents a configuration setting, reveal the `RuleInspector` (at default height if table is shown) and call `inspector.fetch_and_display(setting_key, is_rule=False)`. - Expose related context natively based on selections. --- diff --git a/.agents/tui_requirements.md b/.agents/tui_requirements.md index 079de64..53940c0 100644 --- a/.agents/tui_requirements.md +++ b/.agents/tui_requirements.md @@ -21,15 +21,19 @@ - Provide visual grouping for rule categories (e.g., `E` for pycodestyle, `F` for Pyflakes, `TC` for flake8-type-checking). 3. **Contextual Inspector & Documentation** - When a user highlights a specific rule (e.g., `RUF012`), asynchronously execute `ruff rule ` to fetch and render the official documentation as Markdown. - - Surface related context depending on the selection: highlighting a rule exposes its documentation AND related configuration settings; highlighting a config setting might expose its structural definition or the specific rules it governs. -4. **Fuzzy Search** + - When a user highlights a configuration setting (e.g., `lint.isort.combine-as-imports`), asynchronously execute `ruff config ` to fetch its documentation, default value, and type information. + - Surface related context depending on the selection: highlighting a rule exposes its documentation AND related configuration settings; highlighting a config setting (in the Tree or CategoryTable) exposes its definition and usage examples. +4. **Rich Metadata Rendering** + - The Inspector should distinguish between Rule documentation and Setting documentation. + - Setting documentation should clearly call out **Default Value** and **Type** in a dedicated header or sidebar within the inspector. +5. **Fuzzy Search** - A search bar to quickly locate a specific configuration key or rule code without manual scrolling. ### 2.2 UX / UI Layout Concept - **Header:** Application title and current local repository path. - **Left Sidebar (Navigation):** `Tree` widget for configuration categories (`Global`, `Linting`, `Formatting`, `Rule Index`). -- **Center Area (Main Content):** `DataTable` or `ListView` showing the keys and values for the selected category. -- **Right/Bottom Panel (Inspector):** Context-aware inspector. Because `ruff rule ` output contains proper Markdown, this panel MUST robustly render Markdown (e.g., utilizing Textual's `Markdown` widget) while dynamically adjoining related setting/rule cross-references around it. +- **Center Area (Main Content):** `DataTable` or `ListView` showing the keys and values for the selected category. This panel is dynamically hidden when inspecting dense documentation to maximize reading space. +- **Right/Bottom Panel (Inspector):** Context-aware scrollable inspector (`overflow-y: auto`). Because `ruff rule ` output contains proper Markdown, this panel MUST robustly render Markdown (e.g., utilizing Textual's `Markdown` widget) while dynamically adjoining related setting/rule cross-references around it. - **Footer:** Action key bindings (`q` to Quit, `/` to Search, `?` for Help). --- diff --git a/.agents/tui_rule_browsing.md b/.agents/tui_rule_browsing.md new file mode 100644 index 0000000..2c209f9 --- /dev/null +++ b/.agents/tui_rule_browsing.md @@ -0,0 +1,44 @@ +# Ruff-Sync TUI: Rule Browsing & Discovery Proposals + +This document outlines additional convenience features and alternative navigation paradigms for browsing and exploring specific Ruff rules and groups outside the standard TOML hierarchy. + +**Related Documents:** +- [TUI Requirements](tui_requirements.md) +- [TUI Technical Design](tui_design.md) +- [Rule Browsing Detailed Design](tui_rule_browsing_design.md) + +## Background +While the TOML hierarchy (as established in the core requirements) provides an exact structural representation (e.g., exposing `tool.ruff.lint.select`), it is not always the most intuitive way to discover or understand the net set of rules actively evaluating the project. + +The following proposals are designed to augment the current "Read-Only" TUI mode to support rapid interrogation, global searching, and global discovery of Ruff rules. + +--- + +## 1. The "Effective Rules" Flat Table +Instead of exclusively browsing via the TOML tree (`tool.ruff.lint.select`), we introduce a top-level **"Effective Rules"** dashboard. +- **How it works:** A single, sortable `DataTable` that flattens all configuration vectors (`select`, `extend-select`, `ignore`, `per-file-ignores`) into a definitive list. +- **Columns:** `Code` (e.g., F401), `Name` (e.g., unused-import), `Category` (e.g., Pyflakes), and `Status` (Enabled/Ignored). +- **Benefit:** Gives the user a complete, "at-a-glance" ledger of exactly what the linter is checking without having to manually perform the mental math of `select` minus `ignore`. + +## 2. Category / Linter Prefix Grouping +Ruff is conceptually built around "Linters" (e.g., `Pyflakes`, `pycodestyle`, `flake8-bugbear`). The TOML configuration often just specifies a prefix like `select = ["E", "F", "B"]`. +- **How it works:** Add a new root node in the `Tree` called **"Linters & Categories"**. +- **Interaction:** Navigating to "flake8-bugbear (B)" displays a table of all `B` rules, instantly showing which ones are locally enabled, ignored, or inactive. +- **Benefit:** Maps directly to how developers usually think about adding new rulesets to their projects. + +## 3. Global Fuzzy Command Palette ("Omnibox") +Navigating a tree or a table can be tedious if the user knows exactly what they are looking for. +- **How it works:** A global keybind (e.g., `Ctrl+P` or `/`) that opens a fuzzy search overlay modal. +- **Interaction:** The user types "unused" and the palette immediately surfaces `F401 (unused-import)`, `F841 (unused-variable)`, etc. Hitting Enter drops them directly into the `RuleInspector` for that specific rule, completely bypassing the Tree hierarchy. +- **Benefit:** The fastest possible way to interrogate a specific rule. + +## 4. Quick-Filter State Toggles +When inside any view displaying rules (like the flattened table or the prefix grouping), give the user hotkeys to rapidly shift perspectives. +- **How it works:** Add toggles (e.g., `1: All`, `2: Enabled`, `3: Ignored`). +- **Benefit:** If a user is looking at a massive category like `E` (pycodestyle), they can quickly press `3` to filter the table down to ONLY the rules they explicitly ignored, providing an instant audit trail. + +## 5. "Rule Registry" / Discovery Mode +Currently, the user only sees rules they explicitly mention in their `pyproject.toml` or have inherited. How do they discover new rules to adopt? +- **How it works:** By executing `ruff rule --all` under the hood, the TUI could populate a "Discovery" tab. This lists every single rule Ruff supports. +- **Visuals:** Rules that are currently enabled in the local project are highlighted or checked off. +- **Benefit:** Huge value-add for configuration exploration. Users can browse new rules they might want to adopt directly inside the TUI without referring back to the Ruff website. diff --git a/.agents/tui_rule_browsing_design.md b/.agents/tui_rule_browsing_design.md new file mode 100644 index 0000000..2438c47 --- /dev/null +++ b/.agents/tui_rule_browsing_design.md @@ -0,0 +1,66 @@ +# Detailed Implementation Plan: Effective Rules & Omnibox + +This document provides exhaustive technical instructions to implement Features 1 (Effective Rules Flat Table) and 3 (Global Fuzzy Command Palette) from the [Rule Browsing Proposals](tui_rule_browsing.md). + +--- + +## 1. Data Access & Subprocess Upgrades +To present a flat list of rules or allow fuzzy searching across all rules, the TUI must have structural knowledge of every rule Ruff supports. + +### A. `get_all_ruff_rules() -> list[dict]` +- In `src/ruff_sync/system.py` (or the equivalent subprocess wrapper module), implement an async function that executes `uv run ruff rule --all --output-format json` (or `ruff rule --all --output-format json` depending on the environment context). +- This returns a JSON array of objects representing all rules (fields include `name`, `code`, `linter`, `summary`, etc.). + +### B. `get_ruff_linters() -> list[dict]` +- Implement an async function that executes `ruff linter --output-format json`. Returns categories/prefixes (fields include `prefix`, `name`, `categories`). + +### C. Active Rule Evaluation Logic +- Create a pure utility function `compute_effective_rules(all_rules: list[dict], toml_config: dict) -> list[dict]`. +- `toml_config` is the unwrapped TOML dictionary. +- The function iterates over `all_rules`. For each rule, it checks the rule's `code` (e.g. `F401`) against the `[tool.ruff.lint]` keys `select`, `ignore`, `extend-select`. +- **Heuristic**: Length-based prefix matching. If `select = ["F"]` and `ignore = ["F401"]`, `F401` matches both. Since `F401` (len 4) is a longer and more specific prefix match than `F` (len 1), `ignore` wins. The rule is marked with `status="Ignored"`. If it was only partially matched in `select` it gets `status="Enabled"`. +- Return an enriched list of dictionaries mimicking the original rules but adding a `status` key. + +--- + +## 2. The "Effective Rules" Flat Table UI + +### A. Tree Hierarchy Updates (`src/ruff_sync/tui/widgets.py`) +- Modify the `ConfigTree` (which parses the `pyproject.toml` hierarchy) to inject a synthetic root node at the very top: **"Effective Active Rules"**. + +### B. Table Integration (`src/ruff_sync/tui/app.py` & `widgets.py`) +- When the user selects the "Effective Active Rules" tree node, intercept the event in `@on(Tree.NodeSelected)`. +- Make the `CategoryTable` visible and hide the `RuleInspector` initially. +- Clear existing columns and add `["Code", "Name", "Linter", "Status"]`. +- Fetch the enriched rules list from `compute_effective_rules`. +- Iterate the enriched rules, adding them to the `CategoryTable`. Use Textual Rich markup (e.g., `[green]Enabled[/green]`, `[red]Ignored[/red]`) for the Status column. + +### C. Row Selection Linkage +- Ensure `@on(DataTable.RowSelected)` correctly parses the Row Data to extract the "Rule Code" (e.g. `F401`). +- Hiding the Table and revealing the `RuleInspector` should fire seamlessly by calling `self.query_one("#inspector").fetch_and_display(rule_code, is_rule=True)`. + +--- + +## 3. The Global Fuzzy "Omnibox" UI + +### A. App-Level Keybind (`src/ruff_sync/tui/app.py`) +- In `RuffSyncApp.BINDINGS`, add `("/", "search", "Search Rules")`. +- Add `def action_search(self) -> None:` to intercept the hotkey. + +### B. `OmniboxScreen` Widget (`src/ruff_sync/tui/screens.py`) +- Create a module `screens.py` and define `class OmniboxScreen(ModalScreen[str]):`. +- `compose()` should yield an `Input(placeholder="Search rules (e.g. F401, unused)...")` and optionally an initially-empty `OptionList` or `ListView` beneath it for results. +- **TCSS**: Center the `OmniboxScreen` contents vertically and horizontally. Give the main container a distinct background color and border to pop out over the main App. + +### C. Fuzzy Search Logic (`@on(Input.Changed)`) +- Read `all_rules` from the background fetching mechanism. +- For every keystroke (`Input.Changed`), perform a simple substring or fuzzy match against both `rule["code"]` and `rule["name"]`. +- Populate the `ListView`/`OptionList` with the top 10-15 matches. + +### D. Submission (`@on(Input.Submitted)` or `OptionList.OptionSelected`) +- When the user hits enter on a search result, call `self.dismiss(result_rule_code)`. + +### E. App Callback Integration +- In `action_search`, execute `self.push_screen(OmniboxScreen(), self.handle_omnibox_result)`. +- `def handle_omnibox_result(self, rule_code: str | None) -> None:` +- If `rule_code` is provided, act exactly as if a row was selected in the `CategoryTable`: Hide the table, show the inspector via `self.query_one("#inspector").fetch_and_display(rule_code, is_rule=True)`. diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e223741..65aa5ad 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -84,6 +84,25 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} slug: Kilo59/ruff-sync + test-no-optional-deps: + name: Test without optional dependencies + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install ruff-sync (no extras) + # We install only the base package without 'dev' or 'tui' groups. + run: pip install . + + - name: Run optional dependency validation script + run: bash tests/test_minimal_imports.sh + pre-publish: name: Test package installation needs: [static-analysis, tests] diff --git a/pyproject.toml b/pyproject.toml index 7a8ef51..d873485 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ruff-sync" -version = "0.1.5.dev2" +version = "0.1.5.dev3" description = "Synchronize Ruff linter configuration across projects" keywords = ["ruff", "linter", "config", "synchronize", "python", "linting", "automation", "tomlkit", "pre-commit"] authors = [ @@ -41,6 +41,7 @@ Changelog = "https://github.com/Kilo59/ruff-sync/releases" [project.scripts] ruff-sync = "ruff_sync:main" +ruff-inspect = "ruff_sync:inspect" [dependency-groups] dev = [ @@ -57,6 +58,7 @@ dev = [ "respx>=0.21.1", "ruamel-yaml>=0.18.6", "ruff>=0.15.0", + "textual>=8.2.2", "wily>=1.25.0", ] docs = [ diff --git a/src/ruff_sync/__init__.py b/src/ruff_sync/__init__.py index 911897e..28f1ca2 100644 --- a/src/ruff_sync/__init__.py +++ b/src/ruff_sync/__init__.py @@ -9,6 +9,7 @@ Arguments, __version__, get_config, + inspect, main, ) from .config_io import ( @@ -46,6 +47,7 @@ "get_formatter", "get_ruff_config", "get_ruff_tool_table", + "inspect", "is_ruff_toml_file", "load_local_ruff_config", "main", diff --git a/src/ruff_sync/cli.py b/src/ruff_sync/cli.py index 6523630..d95a827 100644 --- a/src/ruff_sync/cli.py +++ b/src/ruff_sync/cli.py @@ -42,6 +42,7 @@ pull, resolve_raw_url, ) +from ruff_sync.dependencies import DependencyError if TYPE_CHECKING: from collections.abc import Iterable, Mapping @@ -378,6 +379,9 @@ def _resolve_upstream(args: CLIArguments, config: Config) -> tuple[URL, ...]: f"got {type(config_upstream).__name__}" ) + if args.command == "inspect": + return () + PARSER.error( "❌ the following arguments are required: upstream " f"(or define it in [tool.ruff-sync] in {RuffConfigFileName.PYPROJECT_TOML}) 💥" @@ -599,25 +603,37 @@ def main() -> int: try: if exec_args.command == "inspect": - from ruff_sync.dependencies import require_dependency # noqa: PLC0415 - - try: - require_dependency("textual", extra_name="tui") - except ImportError as e: - LOGGER.error(f"❌ {e}") # noqa: TRY400 - return 1 + from ruff_sync.tui import get_tui_app # noqa: PLC0415 - LOGGER.error("❌ The Terminal UI (inspect) is not yet implemented.") - return 1 + app_class = get_tui_app() + return app_class(exec_args).run() or 0 if exec_args.command == "check": return asyncio.run(check(exec_args)) return asyncio.run(pull(exec_args)) + except DependencyError as e: + LOGGER.error(f"❌ {e}") # noqa: TRY400 + return 1 except UpstreamError as e: for url, err in e.errors: LOGGER.error(f"❌ Failed to fetch {url}: {err}") # noqa: TRY400 return 4 +def inspect() -> int: + """Entry point for the ruff-inspect console script.""" + # Handle optional subcommands/args if user passed any to ruff-inspect + # but primarily ensure "inspect" is the command. + if len(sys.argv) > 1 and sys.argv[1] not in ("-h", "--help", "--version"): + # If they passed args but no command, insert 'inspect' + if sys.argv[1] not in ("pull", "check", "inspect"): + sys.argv.insert(1, "inspect") + else: + # Default to 'inspect' if no args or just flags + sys.argv.insert(1, "inspect") + + return main() + + if __name__ == "__main__": sys.exit(main()) diff --git a/src/ruff_sync/dependencies.py b/src/ruff_sync/dependencies.py index 9e1cc83..b6dd4d8 100644 --- a/src/ruff_sync/dependencies.py +++ b/src/ruff_sync/dependencies.py @@ -2,10 +2,19 @@ from __future__ import annotations +import importlib import importlib.util -from typing import Final +from typing import TYPE_CHECKING, Final -__all__: Final[list[str]] = ["is_installed", "require_dependency"] +if TYPE_CHECKING: + from collections.abc import Callable + from typing import Any + +__all__: Final[list[str]] = ["DependencyError", "is_installed", "require_dependency"] + + +class DependencyError(Exception): + """Raised when a required optional dependency is missing or broken.""" def is_installed(package_name: str) -> bool: @@ -20,19 +29,36 @@ def is_installed(package_name: str) -> bool: return importlib.util.find_spec(package_name) is not None -def require_dependency(package_name: str, extra_name: str) -> None: - """Ensure a dependency is installed, or raise a helpful ImportError. +def require_dependency( + package_name: str, + extra_name: str, + *, + _is_installed: Callable[[str], bool] = is_installed, + _import_module: Callable[[str], Any] = importlib.import_module, +) -> None: + """Ensure a dependency is installed and importable, or raise DependencyError. Args: package_name: The name of the required package. extra_name: The name of the ruff-sync extra that provides this package. + _is_installed: Internal use only for testing. Function to check if a package is installed. + _import_module: Internal use only for testing. Function to import a module. Raises: - ImportError: If the package is not installed. + DependencyError: If the package is not installed or raises an error during import. """ - if not is_installed(package_name): - msg = ( - f"The '{package_name}' package is required for this feature. " - f"Install it with: pip install 'ruff-sync[{extra_name}]'" - ) - raise ImportError(msg) + msg = ( + f"The '{package_name}' package is required for this feature. " + f"Install it with: pip install 'ruff-sync[{extra_name}]'" + ) + + # 1. Fast check (dry) to see if it exists at all + if not _is_installed(package_name): + raise DependencyError(msg) + + # 2. Wet check (real import) to ensure it's functional + try: + _import_module(package_name) + except (ImportError, ModuleNotFoundError) as e: + # If it failed here, it's installed but BROKEN (e.g. missing sub-dependencies) + raise DependencyError(msg) from e diff --git a/src/ruff_sync/system.py b/src/ruff_sync/system.py index a70b4cc..3b57eee 100644 --- a/src/ruff_sync/system.py +++ b/src/ruff_sync/system.py @@ -3,8 +3,12 @@ from __future__ import annotations import asyncio +import json import logging -from typing import Final +from typing import TYPE_CHECKING, Any, Final + +if TYPE_CHECKING: + from collections.abc import Mapping LOGGER = logging.getLogger(__name__) @@ -20,6 +24,132 @@ async def get_ruff_rule_markdown(rule_code: str) -> str | None: or the rule is not found. """ cmd: Final[list[str]] = ["ruff", "rule", rule_code] + return await _run_ruff_command(cmd, f"ruff rule {rule_code}") + + +async def get_ruff_config_markdown(setting_path: str) -> str | None: + """Execute `ruff config ` and return the Markdown documentation. + + Args: + setting_path: The Ruff configuration setting path (e.g., 'lint.select'). + + Returns: + The Markdown documentation for the setting, or None if the execution fails. + """ + # Strip 'tool.ruff.' prefix if present as 'ruff config' expects relative paths + clean_path = setting_path.removeprefix("tool.ruff.") + if not clean_path or clean_path == "tool.ruff": + return None + cmd: Final[list[str]] = ["ruff", "config", clean_path] + return await _run_ruff_command(cmd, f"ruff config {clean_path}") + + +async def get_all_ruff_rules() -> list[dict[str, Any]]: + """Execute `ruff rule --all --output-format json` and return the parsed rules. + + Returns: + A list of dictionaries representing all supported Ruff rules. + """ + cmd: Final[list[str]] = ["ruff", "rule", "--all", "--output-format", "json"] + output = await _run_ruff_command(cmd, "ruff rule --all") + if not output: + return [] + try: + data = json.loads(output) + except json.JSONDecodeError: + LOGGER.exception("Failed to parse Ruff rules JSON.") + return [] + + if isinstance(data, list): + return data + return [] + + +async def get_ruff_linters() -> list[dict[str, Any]]: + """Execute `ruff linter --output-format json` and return the parsed linters. + + Returns: + A list of dictionaries representing Ruff linter categories. + """ + cmd: Final[list[str]] = ["ruff", "linter", "--output-format", "json"] + output = await _run_ruff_command(cmd, "ruff linter") + if not output: + return [] + try: + data = json.loads(output) + except json.JSONDecodeError: + LOGGER.exception("Failed to parse Ruff linters JSON.") + return [] + + if isinstance(data, list): + return data + return [] + + +def compute_effective_rules( + all_rules: list[dict[str, Any]], toml_config: Mapping[str, Any] +) -> list[dict[str, Any]]: + """Determine the status (Enabled, Ignored, Disabled) for each rule. + + Args: + all_rules: The list of all supported rules. + toml_config: The local configuration dictionary. + + Returns: + The list of rules enriched with a 'status' key. + """ + # The config may be "wrapped" (top-level 'tool' key) or + # "unwrapped" (direct Ruff config as returned by load_local_ruff_config). + lint = toml_config.get("lint") + if lint is None: + lint = toml_config.get("tool", {}).get("ruff", {}).get("lint", {}) + + select = set(lint.get("select", [])) | set(lint.get("extend-select", [])) + ignore = set(lint.get("ignore", [])) | set(lint.get("extend-ignore", [])) + + # If no select/extend-select is provided, Ruff defaults to E and F + if not lint.get("select") and not lint.get("extend-select"): + select.update(["E", "F"]) + + enriched: list[dict[str, Any]] = [] + for rule in all_rules: + code = rule["code"] + + # Find longest matching select prefix + best_select_len = -1 + for s in select: + if code.startswith(s): + best_select_len = max(best_select_len, len(s)) + + # Find longest matching ignore prefix + best_ignore_len = -1 + for i in ignore: + if code.startswith(i): + best_ignore_len = max(best_ignore_len, len(i)) + + status = "Disabled" + if best_select_len > best_ignore_len: + status = "Enabled" + elif best_ignore_len >= best_select_len and best_ignore_len != -1: + status = "Ignored" + + rule_with_status = dict(rule) + rule_with_status["status"] = status + enriched.append(rule_with_status) + + return enriched + + +async def _run_ruff_command(cmd: list[str], description: str) -> str | None: + """Execute a ruff command and return the decoded output. + + Args: + cmd: The command to execute. + description: A human-readable description for logging. + + Returns: + The decoded stdout, or None if the command fails. + """ LOGGER.debug(f"Executing system command: {' '.join(cmd)}") try: @@ -32,14 +162,15 @@ async def get_ruff_rule_markdown(rule_code: str) -> str | None: if process.returncode != 0: msg = stderr.decode().strip() - LOGGER.warning(f"Ruff command failed with exit code {process.returncode}: {msg}") + LOGGER.warning(f"Command '{description}' failed with code {process.returncode}: {msg}") return None - return stdout.decode().strip() - + output = stdout.decode().strip() except FileNotFoundError: LOGGER.exception("Ruff executable not found in PATH.") return None except Exception: - LOGGER.exception(f"Unexpected error executing ruff rule {rule_code}") + LOGGER.exception(f"Unexpected error executing '{description}'") return None + else: + return output or None diff --git a/src/ruff_sync/tui/__init__.py b/src/ruff_sync/tui/__init__.py new file mode 100644 index 0000000..015dbe3 --- /dev/null +++ b/src/ruff_sync/tui/__init__.py @@ -0,0 +1,26 @@ +"""TUI package for ruff-sync.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ruff_sync.tui.app import RuffSyncApp + + +def get_tui_app() -> type[RuffSyncApp]: + """Lazy-load the TUI and return the RuffSyncApp class. + + Returns: + The RuffSyncApp class. + + Raises: + DependencyError: If 'textual' is not installed or the TUI cannot be loaded. + """ + from ruff_sync.dependencies import require_dependency # noqa: PLC0415 + + require_dependency("textual", extra_name="tui") + + from ruff_sync.tui.app import RuffSyncApp # noqa: PLC0415 + + return RuffSyncApp diff --git a/src/ruff_sync/tui/app.py b/src/ruff_sync/tui/app.py new file mode 100644 index 0000000..850e685 --- /dev/null +++ b/src/ruff_sync/tui/app.py @@ -0,0 +1,304 @@ +"""Main application logic for the Ruff-Sync Terminal User Interface.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, ClassVar, Final + +from textual import on, work +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical +from textual.widgets import DataTable, Footer, Header, Tree +from typing_extensions import override + +from ruff_sync.config_io import load_local_ruff_config +from ruff_sync.system import compute_effective_rules, get_all_ruff_rules, get_ruff_linters +from ruff_sync.tui.constants import RULE_PATTERN +from ruff_sync.tui.screens import LegendScreen, OmniboxScreen +from ruff_sync.tui.themes import AMBER_EMBER +from ruff_sync.tui.widgets import CategoryTable, ConfigTree, RuleInspector + +if TYPE_CHECKING: + from ruff_sync.cli import Arguments + + +LOGGER = logging.getLogger(__name__) + +MIN_RULE_COLUMNS: Final = 4 + + +class RuffSyncApp(App[None]): + """Ruff-Sync Terminal User Interface.""" + + CSS = """ + Screen { + background: $surface; + } + + #config-tree { + width: 1fr; + max-width: 42; + height: 100%; + border-right: solid $primary-darken-2; + } + + #content-pane { + width: 1fr; + height: 100%; + } + + #category-table { + height: 40%; + border-bottom: solid $primary-darken-2; + } + + #inspector { + height: 60%; + padding: 1; + background: $surface-darken-1; + overflow-y: auto; + } + """ + + BINDINGS: ClassVar[list[Any]] = [ + ("q", "quit", "Quit"), + ("/", "search", "Search Rules"), + ("?", "show_legend", "Show Legend"), + ("l", "show_legend", "Show Legend"), + ("c", "copy_content", "Copy Content"), + ] + + def __init__(self, args: Arguments, **kwargs: Any) -> None: + """Initialize the application. + + Args: + args: The CLI arguments. + **kwargs: Additional keyword arguments. + """ + super().__init__(**kwargs) + self.args = args + self.config: dict[str, Any] = {} + self.all_rules: list[dict[str, Any]] = [] + self.effective_rules: list[dict[str, Any]] = [] + self.linters: list[dict[str, Any]] = [] + + @override + def compose(self) -> ComposeResult: + """Compose the user interface elements.""" + yield Header() + with Horizontal(): + yield ConfigTree("Local Configuration", id="config-tree") + with Vertical(id="content-pane"): + yield CategoryTable(id="category-table") + yield RuleInspector(id="inspector") + yield Footer() + + async def on_mount(self) -> None: + """Load the configuration and populate the tree.""" + try: + self.config = load_local_ruff_config(self.args.to) + except Exception: + LOGGER.exception("Failed to load Ruff configuration.") + self.notify("Failed to load Ruff configuration.", severity="error") + self.config = {} + + tree = self.query_one(ConfigTree) + tree.populate(self.config, has_rules=True) + tree.focus() + + # Register and set the default theme + self.register_theme(AMBER_EMBER) + self.theme = "amber-ember" + + # Prime the caches in the background + self._prime_caches() + + @work + async def _prime_caches(self) -> None: + """Fetch rules and compute effectiveness in the background.""" + self.all_rules = await get_all_ruff_rules() + self.linters = await get_ruff_linters() + if self.config: + self.effective_rules = compute_effective_rules(self.all_rules, self.config) + + # Refresh the tree once metadata is loaded to show linter groups + # We pass effective_rules so the tree can filter out entire groups that are disabled + tree = self.query_one(ConfigTree) + tree.populate( + self.config, + has_rules=True, + linters=self.linters, + effective_rules=self.effective_rules, + ) + + @work + async def _display_effective_rules(self) -> None: + """Populate the table with the effective rules list.""" + if not self.all_rules: + self.all_rules = await get_all_ruff_rules() + self.effective_rules = compute_effective_rules(self.all_rules, self.config) + + # Filter for only Enabled or Ignored rules as per the "Effective Rules" proposal + effective_only = [r for r in self.effective_rules if r["status"] != "Disabled"] + + table = self.query_one(CategoryTable) + table.update_rules(effective_only) + + inspector = self.query_one(RuleInspector) + inspector.update( + "## Effective Rule Status\n\nThis table shows rules that are actively being used " + "or have been explicitly ignored in your configuration." + ) + + @on(Tree.NodeSelected) + def handle_node_selected(self, event: Tree.NodeSelected[Any]) -> None: + """Handle tree node selection. + + Args: + event: The tree node selected event. + """ + data = event.node.data + label = event.node.label + label_text = str(label.plain) if hasattr(label, "plain") else str(label) + + table = self.query_one(CategoryTable) + inspector = self.query_one(RuleInspector) + + if data == "__rules__": + self._display_effective_rules() + return + + if isinstance(data, dict) and data.get("type") == "linter": + prefix = data.get("prefix", "") + name = data.get("name", "Unknown Linter") + + # Filter effective rules by prefix + # We show all rules in the group (even Disabled) as per the Discovery proposal + filtered_rules = [r for r in self.effective_rules if r["code"].startswith(prefix)] + + table.update_rules(filtered_rules) + msg = f"## {name} ({prefix or 'No Prefix'})\n\nShowing rules for the {name} category." + inspector.update(msg) + return + + # Build full path for context + full_path = self._get_node_path(event.node) + + # Check if the node label or path matches a ruff rule + if isinstance(label_text, str) and RULE_PATTERN.match(label_text): + self._inspect_rule(label_text) + elif isinstance(data, (dict, list)): + table.update_content(data) + # Fetch config documentation for the section if possible (skip root) + if full_path != "tool.ruff": + inspector.fetch_and_display(full_path, is_rule=False) + else: + inspector.show_placeholder() + else: + table.update_content(data) + inspector.fetch_and_display(full_path, is_rule=False) + + @on(DataTable.RowSelected) + def handle_row_selected(self, event: DataTable.RowSelected) -> None: + """Handle data table row selection. + + Args: + event: The data table row selected event. + """ + table = self.query_one(CategoryTable) + row = table.get_row_at(event.cursor_row) + + # Handle multi-column rules view vs key-value view + if len(row) >= MIN_RULE_COLUMNS: + # Use row_key for stable rule code extraction (avoids markup) + rule_code = str(event.row_key.value) + self._inspect_rule(rule_code) + return + + key, value = row + # Check if the value or key looks like a rule code + rule_code_from_kv = None + if RULE_PATTERN.match(str(key)): + rule_code_from_kv = str(key) + elif RULE_PATTERN.match(str(value)): + rule_code_from_kv = str(value) + + if rule_code_from_kv: + self._inspect_rule(rule_code_from_kv) + else: + # It's a configuration key, show its documentation + inspector = self.query_one(RuleInspector) + full_path = f"{self._get_node_path(self.query_one(ConfigTree).cursor_node)}.{key}" + inspector.fetch_and_display(full_path, is_rule=False) + + def _inspect_rule(self, rule_code: str) -> None: + """Centralized helper for rule inspection with metadata enrichment. + + Args: + rule_code: The Ruff rule code to inspect. + """ + # Fetch metadata for enrichment + rule_data = next((r for r in self.effective_rules if r["code"] == rule_code), None) + name = rule_data.get("name") if rule_data else None + status = rule_data.get("status") if rule_data else "Disabled" + explanation = rule_data.get("explanation") if rule_data else None + + inspector = self.query_one(RuleInspector) + inspector.fetch_and_display( + rule_code, + is_rule=True, + cached_content=explanation, + rule_name=name, + rule_status=status, + ) + + def _get_node_path(self, node: Any) -> str: + """Construct the full configuration path to a tree node. + + Args: + node: The tree node. + + Returns: + The dot-separated configuration path. + """ + path: list[str] = [] + current = node + tree = self.query_one(ConfigTree) + while current and current != tree.root: + label = current.label + label_text = str(label.plain) if hasattr(label, "plain") else str(label) + path.append(label_text) + current = current.parent + + if not path: + return "tool.ruff" + return "tool.ruff." + ".".join(reversed(path)) + + def action_search(self) -> None: + """Launch the global fuzzy search Omnibox.""" + if not self.all_rules: + self.notify("Still fetching rule metadata...", severity="warning") + # Even if empty, we push; the screen handles empty list + self.push_screen(OmniboxScreen(self.all_rules), self.handle_omnibox_result) + + def handle_omnibox_result(self, rule_code: str | None) -> None: + """Handle the result from the Omnibox search. + + Args: + rule_code: The selected rule code, or None if cancelled. + """ + if rule_code: + self._inspect_rule(rule_code) + + def action_show_legend(self) -> None: + """Display the TUI legend modal.""" + self.push_screen(LegendScreen()) + + def action_copy_content(self) -> None: + """Copy the current inspector content to the clipboard.""" + inspector = self.query_one(RuleInspector) + if inspector.source: + self.copy_to_clipboard(str(inspector.source)) + self.notify("Copied content to clipboard", title="Clipboard") + else: + self.notify("No content to copy", severity="warning") diff --git a/src/ruff_sync/tui/constants.py b/src/ruff_sync/tui/constants.py new file mode 100644 index 0000000..1404db3 --- /dev/null +++ b/src/ruff_sync/tui/constants.py @@ -0,0 +1,9 @@ +"""Constants for the Ruff-Sync Terminal User Interface.""" + +from __future__ import annotations + +import re +from typing import Final + +# Regex pattern for matching Ruff rule codes (e.g., E501, RUF012) +RULE_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[A-Z]+[0-9]+$") diff --git a/src/ruff_sync/tui/screens.py b/src/ruff_sync/tui/screens.py new file mode 100644 index 0000000..9486892 --- /dev/null +++ b/src/ruff_sync/tui/screens.py @@ -0,0 +1,185 @@ +"""Screens for the Ruff-Sync Terminal User Interface.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar, Final + +from textual import on +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import Input, OptionList, Static +from textual.widgets.option_list import Option +from typing_extensions import override + +if TYPE_CHECKING: + from textual.app import ComposeResult + +MAX_SEARCH_RESULTS: Final = 15 + + +class OmniboxScreen(ModalScreen[str]): + """A modal search screen for quickly finding Ruff rules.""" + + CSS = """ + OmniboxScreen { + align: center middle; + } + + #omnibox-container { + width: 60; + height: auto; + max-height: 20; + background: $boost; + border: thick $primary; + padding: 1; + } + + #omnibox-input { + margin-bottom: 1; + } + + #omnibox-results { + height: auto; + max-height: 12; + border: none; + background: $surface; + } + """ + + def __init__(self, all_rules: list[dict[str, Any]], **kwargs: Any) -> None: + """Initialize the search screen. + + Args: + all_rules: The list of all rules to search through. + **kwargs: Additional keyword arguments. + """ + super().__init__(**kwargs) + self.all_rules = all_rules + + @override + def compose(self) -> ComposeResult: + """Compose the search interface.""" + with Vertical(id="omnibox-container"): + yield Static("[b]Search Ruff Rules[/b] (e.g. F401, unused)", id="omnibox-title") + yield Input(placeholder="Start typing...", id="omnibox-input") + yield OptionList(id="omnibox-results") + + def on_mount(self) -> None: + """Focus the input on mount.""" + self.query_one(Input).focus() + + @on(Input.Changed) + def handle_input_changed(self, event: Input.Changed) -> None: + """Filter rules based on search input. + + Args: + event: The input changed event. + """ + search_query = event.value.strip().lower() + results_list = self.query_one(OptionList) + results_list.clear_options() + + if not search_query: + return + + matches = [] + for rule in self.all_rules: + code = rule["code"].lower() + name = rule["name"].lower() + if search_query in code or search_query in name: + matches.append(rule) + if len(matches) >= MAX_SEARCH_RESULTS: # Limit results + break + + for match in matches: + results_list.add_option( + Option(f"[b]{match['code']}[/b] - {match['name']}", id=match["code"]) + ) + + @on(Input.Submitted) + def handle_input_submitted(self) -> None: + """Handle enter key in the input.""" + results_list = self.query_one(OptionList) + if results_list.option_count > 0: + # If there's a selected option, use it. Otherwise use the first matching one. + index = results_list.highlighted if results_list.highlighted is not None else 0 + option = results_list.get_option_at_index(index) + if option.id: + self.dismiss(str(option.id)) + + @on(OptionList.OptionSelected) + def handle_option_selected(self, event: OptionList.OptionSelected) -> None: + """Handle selection from the results list.""" + if event.option.id: + self.dismiss(str(event.option.id)) + + def action_cancel(self) -> None: + """Close the screen without selection.""" + self.dismiss() + + +class LegendScreen(ModalScreen[None]): + """A toggleable legend explaining TUI status colors and icons.""" + + BINDINGS: ClassVar[list[Any]] = [ + ("escape,l,?", "dismiss", "Close Legend"), + ] + + CSS = """ + LegendScreen { + align: center middle; + background: black 50%; + } + + #legend-container { + width: 50; + height: auto; + background: $boost; + border: heavy $accent; + padding: 1 2; + } + + .legend-title { + text-align: center; + text-style: bold; + margin-bottom: 1; + color: $accent; + } + + .legend-section { + text-style: underline; + margin-top: 1; + margin-bottom: 1; + } + + .legend-row { + margin-left: 2; + } + + #legend-footer { + text-align: center; + margin-top: 1; + color: $text-disabled; + } + """ + + @override + def compose(self) -> ComposeResult: + """Compose the legend content.""" + with Vertical(id="legend-container"): + yield Static("Ruff-Sync TUI Legend", id="legend-title", classes="legend-title") + + yield Static("Rule Status", classes="legend-section") + yield Static( + "🟢 [green]Enabled[/green] - Active in configuration", classes="legend-row" + ) + yield Static("🟡 [yellow]Ignored[/yellow] - Explicitly ignored", classes="legend-row") + yield Static("⚪ [dim]Disabled[/dim] - Category not selected", classes="legend-row") + + yield Static("Fix Availability", classes="legend-section") + yield Static("[cyan]Always[/cyan] - Automatic fix available", classes="legend-row") + yield Static( + "[yellow]Sometimes[/yellow] - Conditional/enforced fix", classes="legend-row" + ) + + yield Static("Press [b]? [/b] or [b]Esc[/b] to close", id="legend-footer") diff --git a/src/ruff_sync/tui/themes.py b/src/ruff_sync/tui/themes.py new file mode 100644 index 0000000..80a8ea8 --- /dev/null +++ b/src/ruff_sync/tui/themes.py @@ -0,0 +1,21 @@ +"""Custom themes for the ruff-sync TUI.""" + +from __future__ import annotations + +from textual.theme import Theme + +# AMBER_EMBER +# High-contrast, warm dark theme with vibrant gold primary colors. +AMBER_EMBER = Theme( + name="amber-ember", + primary="#FFB300", # Darker, punchy Amber + secondary="#FFD54F", # Lighter Amber + accent="#D81B60", # Material Magenta + warning="#FFB300", # Amber 600 + error="#E91E63", # Pink 500 + success="#81C784", # Light Green 300 + background="#121212", # Material Dark + surface="#1E1E1E", + panel="#2C2C2C", + boost="#2C2C2C", +) diff --git a/src/ruff_sync/tui/widgets.py b/src/ruff_sync/tui/widgets.py new file mode 100644 index 0000000..da45dac --- /dev/null +++ b/src/ruff_sync/tui/widgets.py @@ -0,0 +1,293 @@ +"""Widgets for the Ruff-Sync Terminal User Interface.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar + +from textual import work +from textual.widgets import DataTable, Markdown, Tree +from typing_extensions import override + +from ruff_sync.system import get_ruff_config_markdown, get_ruff_rule_markdown + +if TYPE_CHECKING: + from textual.widgets.tree import TreeNode + + +class ConfigTree(Tree[Any]): + """A tree widget for navigating Ruff configuration.""" + + def populate( + self, + config: dict[str, Any], + has_rules: bool = False, + linters: list[dict[str, Any]] | None = None, + effective_rules: list[dict[str, Any]] | None = None, + ) -> None: + """Populate the tree with configuration sections. + + Args: + config: The unwrapped dictionary of Ruff configuration. + has_rules: Whether to inject the 'Effective Rule Status' node. + linters: Optional linter metadata for expanding the rules node. + effective_rules: List of rules with status for filtering. + """ + self.clear() + self.root.expand() + if has_rules: + rules_node = self.root.add("Effective Rule Status", data="__rules__") + if linters and effective_rules: + self._populate_linter_nodes(rules_node, linters, effective_rules) + self._populate_node(self.root, config) + + # Auto-expand up to 2 levels if it fits in the current view + self._expand_if_fits() + + def _is_linter_active( + self, linter: dict[str, Any], effective_rules: list[dict[str, Any]] + ) -> bool: + """Check if a linter (or any of its categories) has active/ignored rules.""" + prefix = linter.get("prefix") + if prefix and any( + r["code"].startswith(prefix) and r["status"] != "Disabled" for r in effective_rules + ): + return True + + if "categories" in linter: + return any(self._is_linter_active(c, effective_rules) for c in linter["categories"]) + + return False + + def _populate_linter_nodes( + self, + parent: TreeNode[Any], + linters: list[dict[str, Any]], + effective_rules: list[dict[str, Any]], + ) -> None: + """Recursively add linter category nodes to the tree.""" + # Sort linters by name for better navigation + for linter in sorted(linters, key=lambda x: x["name"]): + # Filter: only show if this linter or any category has active rules + if not self._is_linter_active(linter, effective_rules): + continue + + name = linter["name"] + prefix = linter.get("prefix") + label = f"{name} ({prefix})" if prefix else name + + # Use 'linter' structure in data for easy detection + node_data = {"type": "linter", "prefix": prefix, "name": name} + node = parent.add(label, data=node_data) + + # Recursive call for sub-categories (e.g. Pylint, pycodestyle) + if "categories" in linter: + self._populate_linter_nodes(node, linter["categories"], effective_rules) + + def _populate_node(self, parent: TreeNode[Any], data: Any) -> None: + """Recursively add nodes to the tree. + + Args: + parent: The parent tree node. + data: The data to add to the tree. + """ + if isinstance(data, dict): + for key, value in sorted(data.items()): + node = parent.add(key, data=value) + if isinstance(value, (dict, list)): + self._populate_node(node, value) + elif isinstance(data, list): + for i, item in enumerate(data): + label = str(item) if not isinstance(item, (dict, list)) else f"[{i}]" + node = parent.add(label, data=item) + if isinstance(item, (dict, list)): + self._populate_node(node, item) + + def _expand_if_fits(self) -> None: + """Expand the first few levels of the tree if they fit in the vertical space.""" + # We target depth 2 expansion (Root -> Categories -> Items) + target_depth = 2 + + # Use widget height if available, otherwise fallback to a common terminal height + # Subtract some margin for header/footer + limit = (self.size.height or 24) - 2 + + # Count visible nodes if we were to expand + to_expand: list[TreeNode[Any]] = [] + visible_count = 1 # Start with the root + + def collect_nodes(node: TreeNode[Any], depth: int) -> int: + nonlocal visible_count + if depth >= target_depth: + return visible_count + + children = list(node.children) + if not children: + return visible_count + + # If adding these children exceeds the limit, stop + if visible_count + len(children) > limit: + return visible_count + + # Mark for expansion and continue + to_expand.append(node) + visible_count += len(children) + + for child in children: + collect_nodes(child, depth + 1) + return visible_count + + collect_nodes(self.root, 0) + + # Apply the expansions + for node in to_expand: + node.expand() + + +class CategoryTable(DataTable[Any]): + """A table widget for displaying configuration keys and values.""" + + @override + def on_mount(self) -> None: + """Initialize the table columns.""" + self.cursor_type = "row" + self.add_columns("Key", "Value") + + def update_content(self, data: Any) -> None: + """Update the table rows based on the selected data. + + Args: + data: The data to display in the table. + """ + self.clear(columns=True) + self.add_columns("Key", "Value") + if isinstance(data, dict): + for key, value in sorted(data.items()): + self.add_row(key, str(value)) + elif isinstance(data, list): + for i, item in enumerate(data): + self.add_row(str(i), str(item)) + else: + self.add_row("Value", str(data)) + + def update_rules(self, rules: list[dict[str, Any]]) -> None: + """Update the table with a list of rules using row-level highlighting. + + Args: + rules: The enriched rules list to display. + """ + self.clear(columns=True) + # Status is now indicated by row highlighting (colors), so column is removed + self.add_columns("Code", "Name", "Linter", "Fix") + for rule in rules: + status = rule.get("status", "Unknown") + + # Determine row color based on status + color = "" + if status == "Enabled": + color = "success" + elif status == "Ignored": + color = "warning" + elif status == "Disabled": + color = ( + "dim" # Keep dim as it's a standard Rich style that works across backgrounds + ) + + # Rich uses [/] to close the nearest open tag + code_markup = f"[{color}]{rule['code']}[/]" if color else rule["code"] + name_markup = f"[{color}]{rule['name']}[/]" if color else rule["name"] + + fix = rule.get("fix_availability", "None") + fix_markup = fix + if fix == "Always": + fix_markup = f"[accent]{fix}[/]" + elif fix in ("Sometimes", "Enforced"): + fix_markup = f"[warning]{fix}[/]" + + # Note: We still keep linter as-is for clarity + self.add_row(code_markup, name_markup, rule["linter"], fix_markup, key=rule["code"]) + + +class RuleInspector(Markdown): + """A markdown widget for inspecting Ruff rules and settings.""" + + _current_meta: ClassVar[dict[str, str]] = {} + + def on_mount(self) -> None: + """Set initial placeholder content.""" + self.show_placeholder() + + @work(exclusive=True, group="inspector_update") + async def show_placeholder(self) -> None: + """Display a placeholder message.""" + self.update( + "## Selection Details\n\nSelect a configuration key in the tree or a rule " + "code in the table to view documentation or additional context." + ) + + def show_context(self, path: str, value: Any) -> None: + """Display general context for a configuration setting. + + Args: + path: The full configuration path (e.g., 'tool.ruff.lint.select'). + value: The value of the setting. + """ + # Show a summary for complex types, or the raw value for simple ones + if isinstance(value, dict): + summary = f"Table with {len(value)} keys" + elif isinstance(value, list): + summary = f"List with {len(value)} items" + else: + summary = f"`{value}`" + + self.update(f"### Configuration Context\n\n**Path**: `{path}`\n\n**Value**: {summary}") + + @work(exclusive=True, group="inspector_update") + async def fetch_and_display( + self, + target: str, + is_rule: bool = True, + cached_content: str | None = None, + rule_name: str | None = None, + rule_status: str | None = None, + ) -> None: + """Fetch and display the documentation for a rule or setting. + + Args: + target: The Ruff rule code or configuration path. + is_rule: True if fetching a rule, False if fetching a config setting. + cached_content: Optional pre-fetched documentation. + rule_name: Optional rule name for display. + rule_status: Optional rule status (Enabled, Ignored, Disabled). + """ + if target == "tool.ruff": + self.show_placeholder() + return + + content: str | None = None + if cached_content: + content = cached_content + else: + # Set a loading message + desc = "rule" if is_rule else "config" + self.update( + f"## Inspecting {target}...\n\nFetching documentation from `ruff {desc}`..." + ) + + if is_rule: + content = await get_ruff_rule_markdown(target) + else: + content = await get_ruff_config_markdown(target) + + if content: + # Prepend header if it's a rule + header = "" + if is_rule: + status_icons = {"Enabled": "🟢", "Ignored": "🟡", "Disabled": "⚪"} + icon = status_icons.get(rule_status or "Disabled", "⚪") + name = rule_name or "Unknown Rule" + header = f"# {icon} {target}: {name}\n\n---\n\n" + + self.update(header + content.strip()) + else: + desc = "rule" if is_rule else "config" + self.update(f"## Error\n\nCould not fetch documentation for {desc} `{target}`.") diff --git a/tests/conftest.py b/tests/conftest.py index 8778369..ba006f8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ import logging import sys +from typing import Literal, Protocol, runtime_checkable import pytest from typing_extensions import override @@ -62,3 +63,61 @@ def clear_ruff_sync_caches(): """Clear all lru_caches in ruff_sync.""" ruff_sync.get_config.cache_clear() ruff_sync.Arguments.fields.cache_clear() + + +@runtime_checkable +class CLIRunner(Protocol): + """Protocol for the cli_run fixture.""" + + def __call__( + self, + args: list[str], + entry_point: Literal["ruff-sync", "ruff-inspect"] = "ruff-sync", + ) -> tuple[int, str, str]: + """Run a CLI command with the given arguments.""" + ... + + +@pytest.fixture +def cli_run(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> CLIRunner: + """Fixture to run the ruff-sync CLI or entry points and capture output.""" + + def _run( + args: list[str], + entry_point: Literal["ruff-sync", "ruff-inspect"] = "ruff-sync", + ) -> tuple[int, str, str]: + """Run a CLI command with the given arguments. + + Args: + args: The list of CLI arguments (excluding the program name). + entry_point: The name of the entry point to run ('ruff-sync' or 'ruff-inspect'). + + Returns: + A tuple of (exit_code, stdout, stderr). + """ + # Reset sys.argv for each run + monkeypatch.setattr(sys, "argv", [entry_point, *args]) + + exit_code = 0 + try: + if entry_point == "ruff-inspect": + from ruff_sync.cli import inspect + + exit_code = inspect() + else: + from ruff_sync.cli import main + + exit_code = main() + except SystemExit as e: + # Handle sys.exit calls from argparse or main + if isinstance(e.code, int): + exit_code = e.code + elif e.code is None: + exit_code = 0 + else: + exit_code = 1 + + captured = capsys.readouterr() + return exit_code, captured.out, captured.err + + return _run diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index 68432af..827d474 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -1,35 +1,59 @@ -"""Tests for the dependencies module.""" - from __future__ import annotations -from unittest.mock import patch +from typing import Any import pytest -from ruff_sync.dependencies import is_installed, require_dependency +from ruff_sync.dependencies import DependencyError, is_installed, require_dependency def test_is_installed_true() -> None: - with patch("importlib.util.find_spec", return_value=True): - assert is_installed("some_package") is True + # 'sys' is always installed + assert is_installed("sys") is True def test_is_installed_false() -> None: - with patch("importlib.util.find_spec", return_value=None): - assert is_installed("nonexistent_package") is False + assert is_installed("nonexistent_package_12345") is False def test_require_dependency_success() -> None: - with patch("ruff_sync.dependencies.is_installed", return_value=True): - # Should not raise - require_dependency("some_package", "some_extra") - - -def test_require_dependency_failure() -> None: - with patch("ruff_sync.dependencies.is_installed", return_value=False): - with pytest.raises(ImportError) as exc_info: - require_dependency("textual", "tui") - - msg = str(exc_info.value) - assert "textual" in msg - assert "pip install 'ruff-sync[tui]'" in msg + # No error should be raised when both check and import succeed + require_dependency( + "some_package", + "some_extra", + _is_installed=lambda _: True, + _import_module=lambda _: None, + ) + + +def test_require_dependency_not_installed() -> None: + # Should raise DependencyError if not installed + with pytest.raises(DependencyError) as exc_info: + require_dependency( + "textual", + "tui", + _is_installed=lambda _: False, + ) + + msg = str(exc_info.value) + assert "textual" in msg + assert "pip install 'ruff-sync[tui]'" in msg + + +def test_require_dependency_broken_import() -> None: + # Should raise DependencyError if installed but import fails (broken) + def broken_import(_name: str) -> Any: + msg = "Something went wrong during import" + raise ImportError(msg) + + with pytest.raises(DependencyError) as exc_info: + require_dependency( + "broken_pkg", + "broken_extra", + _is_installed=lambda _: True, + _import_module=broken_import, + ) + + msg = str(exc_info.value) + assert "broken_pkg" in msg + assert "pip install 'ruff-sync[broken_extra]'" in msg diff --git a/tests/test_minimal_imports.sh b/tests/test_minimal_imports.sh new file mode 100755 index 0000000..1674f3b --- /dev/null +++ b/tests/test_minimal_imports.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +# This script verifies that ruff-sync works correctly without optional dependencies. +# It is intended to be run in a clean environment where only base dependencies are installed. + +echo "🔍 Verifying absence of optional dependencies..." +if pip show textual > /dev/null 2>&1; then + echo "❌ Error: 'textual' is installed, but it should be absent for this test." + exit 1 +fi +echo "✅ Optional dependencies are absent." + +echo "🚀 Testing 'ruff-sync --help'..." +ruff-sync --version +ruff-sync --help > /dev/null +echo "✅ 'ruff-sync --help' passed." + +echo "🚀 Testing 'ruff-sync pull --help'..." +ruff-sync pull --help > /dev/null +echo "✅ 'ruff-sync pull --help' passed." + +echo "🚀 Testing 'ruff-sync inspect --help'..." +ruff-sync inspect --help > /dev/null +echo "✅ 'ruff-sync inspect --help' passed." + +echo "🚀 Testing 'ruff-inspect --help'..." +ruff-inspect --help > /dev/null +echo "✅ 'ruff-inspect --help' passed." + +echo "🚀 Testing 'ruff-sync check' (dogfooding)..." +# Use the current project's repo for a real-world check that should pass. +ruff-sync check https://github.com/Kilo59/ruff-sync +echo "✅ 'ruff-sync check' passed." + +echo "🚀 Testing 'ruff-sync inspect' graceful failure..." +# Capture output and check for the expected error message. +if ruff-sync inspect 2> inspect_error.log; then + echo "❌ Error: 'ruff-sync inspect' should have failed without 'textual'." + exit 1 +fi + +ERROR_MSG=$(cat inspect_error.log) +echo "Captured error: $ERROR_MSG" + +if [[ "$ERROR_MSG" == *"textual"* ]] && [[ "$ERROR_MSG" == *"ruff-sync[tui]"* ]]; then + echo "✅ 'ruff-sync inspect' failed gracefully with correct message." +else + echo "❌ Error: 'ruff-sync inspect' failed with unexpected message." + exit 1 +fi + +echo "✨ All optional dependency validation tests passed!" +rm inspect_error.log diff --git a/tests/test_rule_logic.py b/tests/test_rule_logic.py new file mode 100644 index 0000000..4dcc7c4 --- /dev/null +++ b/tests/test_rule_logic.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from typing import Any + +from ruff_sync.system import compute_effective_rules + + +def test_compute_effective_rules_basic(): + """Test basic select/ignore logic.""" + all_rules = [ + {"code": "F401", "name": "unused-import", "linter": "Pyflakes"}, + {"code": "E501", "name": "line-too-long", "linter": "pycodestyle"}, + {"code": "UP001", "name": "useless-metaclass", "linter": "pyupgrade"}, + ] + toml_config = { + "tool": { + "ruff": { + "lint": { + "select": ["F"], + "ignore": ["F401"], + } + } + } + } + + enriched = compute_effective_rules(all_rules, toml_config) + + # F401: select matches "F" (len 1), ignore matches "F401" (len 4). Ignore wins. + f401 = next(r for r in enriched if r["code"] == "F401") + assert f401["status"] == "Ignored" + + # E501: no match in select/ignore. Disabled (since select is explicitly "F"). + e501 = next(r for r in enriched if r["code"] == "E501") + assert e501["status"] == "Disabled" + + +def test_compute_effective_rules_defaults(): + """Test that it falls back to Ruff defaults (E, F) when select is empty.""" + all_rules = [ + {"code": "F401", "name": "unused-import", "linter": "Pyflakes"}, + {"code": "E501", "name": "line-too-long", "linter": "pycodestyle"}, + {"code": "UP001", "name": "useless-metaclass", "linter": "pyupgrade"}, + ] + toml_config: dict[str, Any] = {} # Empty config + + enriched = compute_effective_rules(all_rules, toml_config) + + # Defaults are E and F + f401 = next(r for r in enriched if r["code"] == "F401") + assert f401["status"] == "Enabled" + + e501 = next(r for r in enriched if r["code"] == "E501") + assert e501["status"] == "Enabled" + + up001 = next(r for r in enriched if r["code"] == "UP001") + assert up001["status"] == "Disabled" + + +def test_compute_effective_rules_specificity(): + """Test that the longest prefix match wins.""" + all_rules = [{"code": "PLR0912", "name": "too-many-branches", "linter": "Pylint"}] + + # Select more specific than ignore + toml_config_1 = {"tool": {"ruff": {"lint": {"select": ["PLR0912"], "ignore": ["PLR"]}}}} + enriched_1 = compute_effective_rules(all_rules, toml_config_1) + assert enriched_1[0]["status"] == "Enabled" + + # Ignore more specific than select + toml_config_2 = {"tool": {"ruff": {"lint": {"select": ["PLR"], "ignore": ["PLR0912"]}}}} + enriched_2 = compute_effective_rules(all_rules, toml_config_2) + assert enriched_2[0]["status"] == "Ignored" + + +def test_compute_effective_rules_extend(): + """Test that extend-select and extend-ignore are respected.""" + all_rules = [ + {"code": "F401", "name": "unused-import", "linter": "Pyflakes"}, + {"code": "I001", "name": "unsorted-imports", "linter": "isort"}, + ] + toml_config = { + "tool": { + "ruff": { + "lint": { + "extend-select": ["I"], + "extend-ignore": ["F401"], + } + } + } + } + + enriched = compute_effective_rules(all_rules, toml_config) + + # F401: Default "F" select matched, but extend-ignore "F401" is longer. + f401 = next(r for r in enriched if r["code"] == "F401") + assert f401["status"] == "Ignored" + + # I001: Selected via extend-select + i001 = next(r for r in enriched if r["code"] == "I001") + assert i001["status"] == "Enabled" diff --git a/tests/test_themes.py b/tests/test_themes.py new file mode 100644 index 0000000..c6ae6c6 --- /dev/null +++ b/tests/test_themes.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +if TYPE_CHECKING: + import pathlib + +import pytest + +from ruff_sync.cli import Arguments +from ruff_sync.tui.app import RuffSyncApp + + +@pytest.fixture +def mock_args(tmp_path: pathlib.Path) -> Arguments: + return Arguments( + command="inspect", + upstream=(), + to=tmp_path, + exclude=(), + verbose=0, + ) + + +@pytest.mark.asyncio +async def test_themes_registered(mock_args: Arguments) -> None: + # Create mock config so on_mount doesn't fail + pyproject = mock_args.to / "pyproject.toml" + pyproject.write_text("[tool.ruff]\n", encoding="utf-8") + + app = RuffSyncApp(mock_args) + async with app.run_test(): + # Check that 'amber-ember' is registered and active + assert "amber-ember" in app.available_themes + assert app.theme == "amber-ember" + + # Check that removed custom themes are NOT there + assert "ruff-sync-slate" not in app.available_themes + assert "material-ghost" not in app.available_themes + + # Check the actual theme object values (smoke test) + theme = cast("Any", app.get_theme("amber-ember")) + assert str(theme.primary).upper() == "#FFB300" + assert str(theme.background).upper() == "#121212" diff --git a/tests/test_tui.py b/tests/test_tui.py new file mode 100644 index 0000000..cad0758 --- /dev/null +++ b/tests/test_tui.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any, cast +from unittest.mock import patch + +import pytest +from textual.widgets import DataTable, Tree + +from ruff_sync.cli import Arguments +from ruff_sync.tui.app import RuffSyncApp +from ruff_sync.tui.screens import LegendScreen +from ruff_sync.tui.widgets import RuleInspector + +if TYPE_CHECKING: + import pathlib + + from ruff_sync.tui.widgets import ConfigTree + + from .conftest import CLIRunner + + +@pytest.fixture +def mock_args(tmp_path: pathlib.Path) -> Arguments: + return Arguments( + command="inspect", + upstream=(), + to=tmp_path, + exclude=(), + verbose=0, + ) + + +def test_ruff_sync_app_init(mock_args: Arguments) -> None: + app = RuffSyncApp(mock_args) + assert app.args == mock_args + assert app.config == {} + + +@pytest.mark.asyncio +async def test_ruff_sync_app_mount_load_config_failure( + mock_args: Arguments, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """on_mount should handle a failure to load the Ruff config gracefully.""" + + # Force load_local_ruff_config to fail to exercise the error-handling path. + def mock_fail(_: Any) -> Any: + msg = "boom" + raise RuntimeError(msg) + + monkeypatch.setattr( + "ruff_sync.tui.app.load_local_ruff_config", + mock_fail, + ) + + app = RuffSyncApp(mock_args) + + # Run the app so that on_mount is invoked. + async with app.run_test() as pilot: + # Let the app process startup/mount events. + await pilot.pause() + + # When config loading fails, the app should keep the default (empty) config. + assert app.config == {} + tree = cast("ConfigTree", app.query_one("#config-tree")) + # Root only has "Effective Rule Status" node + assert len(tree.root.children) == 1 + label = tree.root.children[0].label + label_text = label.plain if hasattr(label, "plain") else label + assert str(label_text) == "Effective Rule Status" + + +@pytest.mark.asyncio +async def test_ruff_sync_app_mount(mock_args: Arguments, tmp_path: pathlib.Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """ +[tool.ruff] +line-length = 88 +[tool.ruff.lint] +select = ["E", "F"] +""", + encoding="utf-8", + ) + + app = RuffSyncApp(mock_args) + async with app.run_test(): + assert app.config == {"line-length": 88, "lint": {"select": ["E", "F"]}} + tree = app.query_one(Tree) + assert tree.root.label is not None + root_label = tree.root.label + root_label_text = root_label.plain if hasattr(root_label, "plain") else root_label + assert str(root_label_text) == "Local Configuration" + # Check some children + assert any( + str(n.label.plain if hasattr(n.label, "plain") else n.label) == "line-length" + for n in tree.root.children + ) + assert any( + str(n.label.plain if hasattr(n.label, "plain") else n.label) == "lint" + for n in tree.root.children + ) + + +@pytest.mark.asyncio +async def test_ruff_sync_app_node_selection(mock_args: Arguments, tmp_path: pathlib.Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """ +[tool.ruff] +line-length = 88 +""", + encoding="utf-8", + ) + + app = RuffSyncApp(mock_args) + async with app.run_test() as pilot: + tree = app.query_one(Tree) + # Select "line-length" + node = next( + n + for n in tree.root.children + if str(n.label.plain if hasattr(n.label, "plain") else n.label) == "line-length" + ) + tree.select_node(node) + await pilot.pause() + + table = app.query_one(DataTable) + # For a simple value, CategoryTable.update_content(88) adds row ("Value", "88") + assert table.row_count == 1 + row = table.get_row_at(0) + assert [str(cell) for cell in row] == ["Value", "88"] + + +@pytest.mark.asyncio +async def test_ruff_sync_app_rule_selection(mock_args: Arguments, tmp_path: pathlib.Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """ +[tool.ruff.lint] +select = ["RUF012"] +""", + encoding="utf-8", + ) + + app = RuffSyncApp(mock_args) + mock_markdown = "## RUF012 Documentation\n\nDetailed info here." + + with patch("ruff_sync.tui.widgets.get_ruff_rule_markdown", return_value=mock_markdown): + async with app.run_test() as pilot: + # Wait for background worker and tree repopulation (priming) + while not app.effective_rules: + await asyncio.sleep(0.1) + await pilot.pause() + + tree = app.query_one(Tree) + # Find and select RUF012 node in the now-stable tree + # It's inside tool.ruff -> lint -> select -> RUF012 + lint_node = next( + n + for n in tree.root.children + if str(n.label.plain if hasattr(n.label, "plain") else n.label) == "lint" + ) + lint_node.expand() + await pilot.pause() + + select_node = next( + n + for n in lint_node.children + if str(n.label.plain if hasattr(n.label, "plain") else n.label) == "select" + ) + select_node.expand() + await pilot.pause() + + rule_node = next( + n + for n in select_node.children + if str(n.label.plain if hasattr(n.label, "plain") else n.label) == "RUF012" + ) + tree.focus() + tree.select_node(rule_node) + await pilot.press("enter") + + inspector = app.query_one("#inspector", RuleInspector) + # Wait for background worker and UI update + for _ in range(20): + await pilot.pause(0.2) + if "RUF012" in str(inspector.source): + break + + # Verify Markdown content (simplified check) + assert "RUF012" in str(inspector.source) + + +def test_cli_inspect_subcommand( + cli_run: CLIRunner, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test that 'ruff-sync inspect' attempts to run the app.""" + # Mock load_local_ruff_config where it's used in RuffSyncApp.on_mount + monkeypatch.setattr("ruff_sync.tui.app.load_local_ruff_config", lambda _: {}) + + # Use patch to prevent the App from actually running (which would block/fail in CI) + # and just verify it was instantiated and run() was called. + with patch("ruff_sync.tui.app.RuffSyncApp.run", return_value=0) as mock_run: + exit_code, _out, _err = cli_run(["inspect", "--to", str(tmp_path)]) + assert exit_code == 0 + mock_run.assert_called_once() + + +@pytest.mark.parametrize( + "args, expected_command", + [ + ([], "inspect"), + (["--help"], "inspect"), + (["check", "--to", "."], "check"), # Should NOT be rewritten to 'inspect' + (["--to", "."], "inspect"), # Should be rewritten since '--to' is not a command + ], +) +def test_cli_ruff_inspect_entry_point_variations( + args: list[str], + expected_command: str, + cli_run: CLIRunner, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that the 'ruff-inspect' entry point correctly handles and rewrites arguments.""" + # Mock load_local_ruff_config where it's used in RuffSyncApp.on_mount + monkeypatch.setattr("ruff_sync.tui.app.load_local_ruff_config", lambda _: {}) + + # We need to simulate the program name 'ruff-inspect' + # The 'to' arg is added to ensure we have a valid target if needed + final_args = args + if "--to" not in args and "--help" not in args: + final_args = [*args, "--to", str(tmp_path)] + + # 1. Test running (using patched run() to avoid TUI execution) + with patch("ruff_sync.tui.app.RuffSyncApp.run", return_value=0) as mock_run: + exit_code, _out, _err = cli_run(final_args, entry_point="ruff-inspect") + + # If --help was passed, argparse will exit 0 and not call run() + if "--help" in args: + assert exit_code == 0 + mock_run.assert_not_called() + elif expected_command == "inspect": + assert exit_code == 0 + mock_run.assert_called_once() + else: + # For 'check', asyncio.run(check()) is called, not RuffSyncApp.run() + assert exit_code == 0 + mock_run.assert_not_called() + + # 2. Test instantiation (to verify the command was correctly resolved) + with ( + patch("ruff_sync.tui.app.RuffSyncApp.__init__", return_value=None) as mock_init, + patch("ruff_sync.cli.asyncio.run", return_value=0), + patch("ruff_sync.tui.app.RuffSyncApp.run", return_value=0), + ): + cli_run(final_args, entry_point="ruff-inspect") + + if "--help" not in args: + if expected_command == "inspect": + mock_init.assert_called_once() + exec_args = mock_init.call_args[0][0] + assert exec_args.command == "inspect" + else: + mock_init.assert_not_called() + + +@pytest.mark.asyncio +async def test_ruff_sync_app_show_legend(mock_args: Arguments) -> None: + """The legend screen should be pushed when '?' is pressed.""" + app = RuffSyncApp(mock_args) + async with app.run_test() as pilot: + await pilot.press("?") + await pilot.pause() + assert isinstance(app.screen, LegendScreen) + + await pilot.press("escape") + await pilot.pause() + assert not isinstance(app.screen, LegendScreen) + + +@pytest.mark.asyncio +async def test_ruff_sync_app_copy_content(mock_args: Arguments) -> None: + """The inspector content should be copied to the clipboard when 'c' is pressed.""" + app = RuffSyncApp(mock_args) + # Mock copy_to_clipboard on the app instance + with patch.object(RuffSyncApp, "copy_to_clipboard") as mock_copy: + async with app.run_test() as pilot: + # Manually update inspector to simulate a selected rule/config + inspector = app.query_one(RuleInspector) + inspector.update("Copied Content Test") + await pilot.pause() + + await pilot.press("c") + await pilot.pause() + + mock_copy.assert_called_once_with("Copied Content Test") diff --git a/uv.lock b/uv.lock index bd8529a..eb024de 100644 --- a/uv.lock +++ b/uv.lock @@ -1594,7 +1594,7 @@ wheels = [ [[package]] name = "ruff-sync" -version = "0.1.5.dev2" +version = "0.1.5.dev3" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -1622,6 +1622,7 @@ dev = [ { name = "respx" }, { name = "ruamel-yaml" }, { name = "ruff" }, + { name = "textual" }, { name = "wily" }, ] docs = [ @@ -1657,6 +1658,7 @@ dev = [ { name = "respx", specifier = ">=0.21.1" }, { name = "ruamel-yaml", specifier = ">=0.18.6" }, { name = "ruff", specifier = ">=0.15.0" }, + { name = "textual", specifier = ">=8.2.2" }, { name = "wily", specifier = ">=1.25.0" }, ] docs = [