diff --git a/CHANGELOG.md b/CHANGELOG.md index 08dd176..9707ce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Performance**: Sorting is 2 to 5 times faster on large codebases. The lookup tables used while parsing a class are no longer read through Dynaconf, whose containers scan every key on each membership test; they live in a plain `Lookups` object that is rebuilt whenever the configuration changes + - Sort keys are cached per class name and built from a precomputed component table instead of an if/elif chain; sorted class attribute values are cached as well (bounded to 8,192 entries). The separate parse cache is no longer needed + - Splitting a class without arbitrary values uses `str.split` instead of a character loop + - A dry run over 1,650 already sorted files drops from 2.3s to 0.4s; three real projects run 2.1 to 2.6 times faster +- **Performance**: Faster startup for stdin and `--version`. Rich, wcmatch, difflib and the package metadata lookup are imported only when needed, and the console is created only in file mode (one file on stdin: 88ms to 64ms) + ### Fixed +- **Reliability**: Runs longer than 60 seconds no longer abort with a `TimeoutError`; the thread pool waited with a total timeout instead of a per-file one - **CI**: The documentation workflow no longer runs for pull requests; GitHub Pages' environment protection rejects deployments from anything but `main`, so every pull request showed a failed check ## [0.14.0] - 2026-09-29 diff --git a/tailwhip/__init__.py b/tailwhip/__init__.py index d28c57b..9ae1f34 100644 --- a/tailwhip/__init__.py +++ b/tailwhip/__init__.py @@ -2,10 +2,18 @@ from __future__ import annotations -from importlib import metadata - from tailwhip.cli import main -__version__ = metadata.version("tailwhip") __author__ = "Martin Mahner" __all__ = ["__author__", "__version__", "main"] + + +def __getattr__(name: str) -> str: + """Resolve the package version on first use; reading the metadata is slow.""" + if name == "__version__": + from importlib import metadata # noqa: PLC0415 + + return metadata.version("tailwhip") + + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) diff --git a/tailwhip/cli.py b/tailwhip/cli.py index 461119a..116b8f2 100644 --- a/tailwhip/cli.py +++ b/tailwhip/cli.py @@ -4,17 +4,15 @@ import sys import time -from importlib import metadata from pathlib import Path from typing import Annotated import typer -from rich.console import Console from tailwhip.configuration import ( - CONSOLE_THEME, VerbosityLevel, config, + create_console, get_pyproject_toml_data, update_configuration, ) @@ -25,6 +23,8 @@ def version_callback(value: bool) -> None: """Print version and exit.""" if value: + from importlib import metadata # noqa: PLC0415 + version = metadata.version("tailwhip") typer.echo(f"tailwhip {version}") raise typer.Exit @@ -149,20 +149,20 @@ def run( # noqa: PLR0913, PLR0917 } ) - # Setup helper tools --------------------------------------------------------------- + # Handle stdin mode ---------------------------------------------------------------- - config.console = Console(quiet=quiet, theme=CONSOLE_THEME) + # Without paths and with stdin being piped (not a TTY), process stdin to stdout + if not paths and not sys.stdin.isatty(): + input_text = sys.stdin.read() + output_text = process_text(input_text) + sys.stdout.write(output_text) + return - # Handle stdin mode ---------------------------------------------------------------- + # Setup helper tools --------------------------------------------------------------- - if not paths: - # Check if stdin is being piped (not a TTY) - if not sys.stdin.isatty(): - input_text = sys.stdin.read() - output_text = process_text(input_text) - sys.stdout.write(output_text) - return + config.console = create_console(quiet=quiet) + if not paths: # No paths and no piped input config.console.print( "[red]Error: No paths provided. Provide file paths or pipe content to stdin.[/red]" diff --git a/tailwhip/configuration.py b/tailwhip/configuration.py index 109ab7a..9ecec8f 100644 --- a/tailwhip/configuration.py +++ b/tailwhip/configuration.py @@ -4,29 +4,41 @@ import re import tomllib -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import IntEnum from pathlib import Path from typing import TYPE_CHECKING import dynaconf -import rich if TYPE_CHECKING: from collections.abc import Callable + import rich.console + # Path to default configuration file BASE_CONFIGURATION_FILE = Path(__file__).parent / "configuration.toml" -# Console theme for rich.Console output -CONSOLE_THEME = rich.theme.Theme( - { - "important": "white on deep_pink4", - "highlight": "yellow1", - "filename": "white", - "bold": "sky_blue1", - } -) +# Styles for the rich.Console output +CONSOLE_STYLES = { + "important": "white on deep_pink4", + "highlight": "yellow1", + "filename": "white", + "bold": "sky_blue1", +} + + +def create_console(*, quiet: bool) -> rich.console.Console: + """ + Create the console used for all output. + + Rich is imported here rather than at module level: importing it is a + noticeable part of the startup time, and stdin mode never prints through it. + """ + from rich.console import Console # noqa: PLC0415 + from rich.theme import Theme # noqa: PLC0415 + + return Console(quiet=quiet, theme=Theme(CONSOLE_STYLES)) @dataclass @@ -38,6 +50,33 @@ class Pattern: template: str +@dataclass(slots=True) +class Lookups: + """ + Plain lookup tables derived from the configuration lists. + + Values stored on the Dynaconf settings object are wrapped in node types whose + membership tests scan every key, far too slow for the sorting hot path. These + plain structures are refreshed in place by _rebuild_lookups() whenever the + configuration changes, so modules can keep a reference to the single instance. + """ + + variant_separator: str = ":" + skip_expressions: tuple[str, ...] = () + component_order: tuple[str, ...] = () + variant_index: dict[str, int] = field(default_factory=dict) + prefix_index: dict[str, int] = field(default_factory=dict) + direction_index: dict[str, int] = field(default_factory=dict) + size_index: dict[str, int] = field(default_factory=dict) + value_index: dict[str, int] = field(default_factory=dict) + color_index: dict[str, int] = field(default_factory=dict) + shade_index: dict[str, int] = field(default_factory=dict) + alpha_index: dict[str, int] = field(default_factory=dict) + + +lookups = Lookups() + + def get_pyproject_toml_data(start_path: Path) -> Path | None: """Search for pyproject.toml starting at the given path.""" pyproject_path = None @@ -87,19 +126,24 @@ def update_configuration(data: dict | Path) -> None: def _rebuild_lookups() -> None: - """Rebuild lookup dictionaries and compile patterns.""" + """Rebuild the lookup tables and compile patterns.""" + # Plain copies of the settings read for every class or attribute + lookups.variant_separator = config.variant_separator + lookups.skip_expressions = tuple(config.skip_expressions) + lookups.component_order = tuple(config.component_order) + # Build lookup dicts for O(1) index access - config.variant_index = {v: i for i, v in enumerate(config.variants)} - config.prefix_index = {p: i for i, p in enumerate(config.prefixes)} - config.direction_index = {d: i for i, d in enumerate(config.directions)} - config.size_index = {s: i for i, s in enumerate(config.sizes)} - config.value_index = {v: i for i, v in enumerate(config.numerics)} - config.shade_index = {s: i for i, s in enumerate(config.shades)} - config.alpha_index = {a: i for i, a in enumerate(config.alphas)} + lookups.variant_index = {v: i for i, v in enumerate(config.variants)} + lookups.prefix_index = {p: i for i, p in enumerate(config.prefixes)} + lookups.direction_index = {d: i for i, d in enumerate(config.directions)} + lookups.size_index = {s: i for i, s in enumerate(config.sizes)} + lookups.value_index = {v: i for i, v in enumerate(config.numerics)} + lookups.shade_index = {s: i for i, s in enumerate(config.shades)} + lookups.alpha_index = {a: i for i, a in enumerate(config.alphas)} # Combine and sort colors alphabetically all_colors_sorted = sorted({*config.colors, *config.custom_colors}) - config.color_index = {c: i for i, c in enumerate(all_colors_sorted)} + lookups.color_index = {c: i for i, c in enumerate(all_colors_sorted)} # Compile class_patterns into Pattern objects with compiled regexes config.APPLY_PATTERNS = [ @@ -111,7 +155,7 @@ def _rebuild_lookups() -> None: for pattern in config.class_patterns ] - # Cached parse results depend on the lookups above, so drop them + # Cached results derived from the configuration are stale now, so drop them for clear in _cache_clearers: clear() @@ -152,16 +196,6 @@ class TailwhipConfig(dynaconf.Dynaconf): shades: list[str] alphas: list[str] - # Lookup dictionaries (built at runtime for O(1) access) - variant_index: dict[str, int] - prefix_index: dict[str, int] - direction_index: dict[str, int] - size_index: dict[str, int] - value_index: dict[str, int] - color_index: dict[str, int] - shade_index: dict[str, int] - alpha_index: dict[str, int] - # Compiled patterns APPLY_PATTERNS: list[Pattern] diff --git a/tailwhip/files.py b/tailwhip/files.py index cce7321..2ac1086 100644 --- a/tailwhip/files.py +++ b/tailwhip/files.py @@ -2,22 +2,19 @@ from __future__ import annotations -import difflib from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING -from rich.padding import Padding -from rich.syntax import Syntax -from wcmatch import glob - from tailwhip.configuration import VerbosityLevel, config from tailwhip.process import process_text if TYPE_CHECKING: from collections.abc import Generator, Iterable + from rich.syntax import Syntax + @dataclass(slots=True) class FileResult: @@ -55,6 +52,9 @@ def find_files(*, paths: list[Path]) -> Generator[Path]: [PosixPath('home.html'), PosixPath('static/app.css')] """ + # Imported on demand: stdin mode never needs it, and it is slow to import + from wcmatch import glob # noqa: PLC0415 + seen = set() flags = glob.GLOBSTAR | glob.BRACE | glob.EXTGLOB | glob.DOTGLOB @@ -86,6 +86,11 @@ def find_files(*, paths: list[Path]) -> Generator[Path]: def get_diff(path: Path, old_text: str, new_text: str) -> Syntax: """Show a nice diff using Rich.""" + # Imported on demand: only the diff verbosity needs them + import difflib # noqa: PLC0415 + + from rich.syntax import Syntax # noqa: PLC0415 + # Create a text diff between old and new text diff = difflib.unified_diff( old_text.splitlines(), @@ -143,6 +148,8 @@ def _process_file(f: Path) -> FileResult: config.console.print(f"[dim]Would update[/dim] [filename]{f}[/filename]") if config.verbosity >= VerbosityLevel.DIFF: + from rich.padding import Padding # noqa: PLC0415 + diff = get_diff(f, old_text, new_text) config.console.print(Padding(diff, (1, 0, 1, 4))) @@ -180,7 +187,7 @@ def apply_changes(*, targets: Iterable[Path]) -> tuple[bool, int, int]: with ThreadPoolExecutor() as executor: futures = [executor.submit(_process_file, f) for f in targets] - for future in as_completed(futures, timeout=60): + for future in as_completed(futures): found_any = True result = future.result() diff --git a/tailwhip/process.py b/tailwhip/process.py index 68e652c..94d991a 100644 --- a/tailwhip/process.py +++ b/tailwhip/process.py @@ -2,9 +2,10 @@ from __future__ import annotations +from functools import lru_cache from typing import TYPE_CHECKING -from tailwhip.configuration import config +from tailwhip.configuration import config, lookups, register_cache_clearer from tailwhip.sorting import sort_classes if TYPE_CHECKING: @@ -41,7 +42,29 @@ def split_classes(s: str) -> list[str]: [] """ - return s.strip().split() + return s.split() + + +@lru_cache(maxsize=8192) +def _sort_class_string(classes_str: str) -> str | None: + """ + Sort the classes of a class attribute value, or return None to leave it as is. + + The same attribute values repeat all over a codebase, so results are cached. + Values containing a template expression, or no classes at all, yield None. + """ + # Skip if a template expression appears inside the class attribute + for skip_expr in lookups.skip_expressions: + if skip_expr in classes_str: + return None + + classes = split_classes(classes_str) + + # Skip if no classes were found + if not classes: + return None + + return " ".join(sort_classes(classes)) def process_pattern(match: re.Match[str], template: str) -> str: @@ -74,24 +97,16 @@ def process_pattern(match: re.Match[str], template: str) -> str: >>> # Output: class="text-{{ color }}-500 flex" # Unchanged """ - classes_str = match.group("classes") + sorted_classes = _sort_class_string(match.group("classes")) - # Skip if a template expression appears inside the class attribute - if any(skip_expr in classes_str for skip_expr in config.skip_expressions): + # Skip template expressions and empty attributes + if sorted_classes is None: return match.group(0) - classes = split_classes(classes_str) - - # Skip if no classes were found - if not classes: - return match.group(0) - - sorted_classes = sort_classes(classes) - # Get all named groups from the match as context context = match.groupdict() # Override 'classes' with sorted version - context["classes"] = " ".join(sorted_classes) + context["classes"] = sorted_classes # Use template to reconstruct return template.format(**context) @@ -129,3 +144,8 @@ def process_text(text: str) -> str: text, ) return text + + +# Sorted class strings depend on the configuration, so the cache must be dropped +# whenever the configuration changes. +register_cache_clearer(_sort_class_string.cache_clear) diff --git a/tailwhip/sorting.py b/tailwhip/sorting.py index 8ee19fb..71d5553 100644 --- a/tailwhip/sorting.py +++ b/tailwhip/sorting.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from functools import cache -from tailwhip.configuration import config, register_cache_clearer +from tailwhip.configuration import lookups, register_cache_clearer @dataclass(slots=True) @@ -33,12 +33,13 @@ def _extract_prefix_and_tokens(tokens: list[str]) -> tuple[str, list[str]]: prefix = tokens[0] remaining = tokens[1:] + prefix_index = lookups.prefix_index # Try to find the longest matching prefix # e.g., "inline-flex" should match as prefix, not "inline" + value "flex" for i in range(len(remaining), 0, -1): candidate = "-".join([prefix, *remaining[:i]]) - if candidate in config.prefix_index: + if candidate in prefix_index: return candidate, remaining[i:] return prefix, remaining @@ -56,15 +57,15 @@ def _parse_component_tokens( suffix_parts = [] for token in tokens: - if direction is None and token in config.direction_index: + if direction is None and token in lookups.direction_index: direction = token - elif size is None and token in config.size_index: + elif size is None and token in lookups.size_index: size = token - elif value is None and token in config.value_index: + elif value is None and token in lookups.value_index: value = token - elif color is None and token in config.color_index: + elif color is None and token in lookups.color_index: color = token - elif shade is None and token in config.shade_index: + elif shade is None and token in lookups.shade_index: shade = token else: suffix_parts.append(token) @@ -72,7 +73,6 @@ def _parse_component_tokens( return direction, size, value, color, shade, "-".join(suffix_parts) -@cache def parse_class(classname: str) -> ParsedClass: """ Parse a Tailwind CSS class into its components. @@ -95,7 +95,7 @@ def parse_class(classname: str) -> ParsedClass: # 2. Split variants from the utility. Separators inside [...] or (...) # belong to an arbitrary value, e.g. "supports-[display:grid]:grid". - parts = _split_top_level(remaining, config.variant_separator) + parts = _split_top_level(remaining, lookups.variant_separator) variants = parts[:-1] # All but the last are variants utility = parts[-1] # Last part is the utility @@ -143,6 +143,11 @@ def _split_top_level(text: str, separator: str) -> list[str]: ("supports-[display:grid]:") and CSS variable shorthands ("bg-(--brand)") contain characters that would otherwise be mistaken for separators. """ + # Without an opening bracket nothing can be nested, so a plain split is + # equivalent and much faster than walking the string character by character. + if "[" not in text and "(" not in text: + return text.split(separator) + parts: list[str] = [] current: list[str] = [] depth = 0 @@ -197,91 +202,42 @@ def _is_base_utility(parsed: ParsedClass) -> bool: _MAX_RANK = 999999 # For unknown values not in any list - -def _component_rank(component_type: str, parsed: ParsedClass) -> tuple[int, str]: - """ - Get the sort rank for a component type from a parsed class. - - Returns a tuple of (index, value) where: - - index is the position in the component's list - - value is the string value for secondary sorting - - Direction: no-direction sorts first (-1), e.g., border-1 before border-t-1. - Other components: no-value sorts last (max_rank), e.g., border-1 before border-red. - Unknown prefixes get -1 (sort first, non-Tailwind classes before Tailwind). - Unknown values (not in the list) get max_rank (sort last). - """ - # Get component data: (value, index_map, none_rank, unknown_rank) - # Using if/elif chain instead of dict lookup for performance - if component_type == "prefix": - value, index_map, none_rank, unknown_rank = ( - parsed.prefix, - config.prefix_index, - -1, - -1, - ) - elif component_type == "direction": - value, index_map, none_rank, unknown_rank = ( - parsed.direction, - config.direction_index, - -1, - _MAX_RANK, - ) - elif component_type == "size": - value, index_map, none_rank, unknown_rank = ( - parsed.size, - config.size_index, - _MAX_RANK, - _MAX_RANK, - ) - elif component_type == "value": - value, index_map, none_rank, unknown_rank = ( - parsed.value, - config.value_index, - _MAX_RANK, - _MAX_RANK, - ) - elif component_type == "color": - value, index_map, none_rank, unknown_rank = ( - parsed.color, - config.color_index, - _MAX_RANK, - _MAX_RANK, - ) - elif component_type == "shade": - value, index_map, none_rank, unknown_rank = ( - parsed.shade, - config.shade_index, - _MAX_RANK, - _MAX_RANK, - ) - elif component_type == "alpha": - value, index_map, none_rank, unknown_rank = ( - parsed.alpha, - config.alpha_index, - _MAX_RANK, - _MAX_RANK, - ) - else: - return (_MAX_RANK, "") - - if value is None: - return (none_rank, "") - - idx = index_map.get(value, unknown_rank) - return (idx, value) +# The components compared after variants and prefix, keyed by their name in the +# component_order configuration. Each entry holds the ParsedClass attribute, the +# lookup table on `lookups`, the rank used when a class has no such component and +# the rank used when the value is not in the list: +# +# - Direction: no-direction sorts first (-1), e.g., border-1 before border-t-1. +# - Other components: no-value sorts last (max rank), e.g., border-1 before border-red. +# - Unknown values (not in the list) get max rank (sort last). +_COMPONENTS: dict[str, tuple[str, str, int, int]] = { + "direction": ("direction", "direction_index", -1, _MAX_RANK), + "size": ("size", "size_index", _MAX_RANK, _MAX_RANK), + "value": ("value", "value_index", _MAX_RANK, _MAX_RANK), + "color": ("color", "color_index", _MAX_RANK, _MAX_RANK), + "shade": ("shade", "shade_index", _MAX_RANK, _MAX_RANK), + "alpha": ("alpha", "alpha_index", _MAX_RANK, _MAX_RANK), +} + +# Tables derived from the configuration by _refresh_tables(): the components to +# compare in configured order as (attribute, index, none_rank, unknown_rank), and +# the ("name-", "name[") prefixes with their rank for variants that take an +# argument, e.g. "min-[320px]". +_component_ranks: list[tuple[str, dict[str, int], int, int]] = [] +_variant_prefixes: list[tuple[tuple[str, str], int]] = [] def _get_variant_rank(variant: str) -> int: """Get the sort rank for a single variant, supporting prefix matching.""" # Try exact match first (fast path) - if variant in config.variant_index: - return config.variant_index[variant] + rank = lookups.variant_index.get(variant) + if rank is not None: + return rank # Try prefix match (e.g., "min-[320px]" matches "min") - for known_variant, idx in config.variant_index.items(): - if variant.startswith((known_variant + "-", known_variant + "[")): - return idx + for prefixes, rank in _variant_prefixes: + if variant.startswith(prefixes): + return rank return _MAX_RANK @@ -295,6 +251,7 @@ def _variant_sort_key(variants: list[str]) -> tuple: return tuple((_get_variant_rank(v), v) for v in variants) +@cache def sort_key(classname: str) -> tuple: """ Generate a sort key for a Tailwind CSS class. @@ -305,28 +262,29 @@ def sort_key(classname: str) -> tuple: 3. Base utility flag (base utilities sort first within same prefix) 4. Direction, Size, Value, Color, Shade, Alpha (by component_order) 5. Suffix (arbitrary values sort last) + + Keys are cached per class name, as the same classes appear over and over + across a codebase. The cache is dropped whenever the configuration changes. """ parsed = parse_class(classname) - # Build the sort key - key_parts: list = [] - - # 1. Variants - key_parts.append(_variant_sort_key(parsed.variants)) - - # 2. Prefix - key_parts.append(_component_rank("prefix", parsed)) - - # 3. Base utility flag (base utilities sort first within same prefix) - # This ensures "border" < "border-t" and "blur" < "blur-sm" - key_parts.append(0 if _is_base_utility(parsed) else 1) - - # 4. Components in order (skip variant and prefix) - key_parts.extend( - _component_rank(component_type, parsed) - for component_type in config.component_order - if component_type not in ("variant", "prefix") - ) + key_parts: list = [ + # 1. Variants + _variant_sort_key(parsed.variants), + # 2. Prefix. Unknown prefixes get -1, so non-Tailwind classes sort first. + (lookups.prefix_index.get(parsed.prefix, -1), parsed.prefix), + # 3. Base utility flag (base utilities sort first within same prefix) + # This ensures "border" < "border-t" and "blur" < "blur-sm" + 0 if _is_base_utility(parsed) else 1, + ] + + # 4. Components in configured order + for attribute, index, none_rank, unknown_rank in _component_ranks: + value = getattr(parsed, attribute) + if value is None: + key_parts.append((none_rank, "")) + else: + key_parts.append((index.get(value, unknown_rank), value)) # 5. Suffix (arbitrary values sort last within same component structure) key_parts.append((0 if not parsed.suffix else 1, parsed.suffix)) @@ -349,6 +307,26 @@ def sort_classes(class_list: list[str]) -> list[str]: return sorted(deduped, key=sort_key) -# Parsed classes depend on the configuration lists, so the cache must be dropped -# whenever the configuration changes. -register_cache_clearer(parse_class.cache_clear) +def _refresh_tables() -> None: + """Rebuild the sorting tables from the configuration and drop cached sort keys.""" + ranks = [] + for component in lookups.component_order: + # Unknown component names are ignored; "variant" and "prefix" always + # come first and are handled separately in sort_key() + if component in _COMPONENTS: + attribute, table, none_rank, unknown_rank = _COMPONENTS[component] + ranks.append((attribute, getattr(lookups, table), none_rank, unknown_rank)) + _component_ranks[:] = ranks + + _variant_prefixes[:] = [ + ((variant + "-", variant + "["), rank) + for variant, rank in lookups.variant_index.items() + ] + + sort_key.cache_clear() + + +# Sort keys depend on the configuration lists, so the tables and the cache must be +# rebuilt whenever the configuration changes. +register_cache_clearer(_refresh_tables) +_refresh_tables()