Skip to content

Rendering

Turning a scanned structure into terminal output: the tree view, the side-by-side comparison, and the colors and icons both use.

Tree Rendering

recursivist.tree

Terminal tree rendering.

Builds and prints a rich tree from a scanned structure, with extension colors, optional metric annotations, and Git status markers. The structure comes from get_directory_structure, which applies all filtering.

build_tree

build_tree(structure: Directory, tree: Tree, color_map: dict[str, str], spec: DisplayOptions, icon_style: str = 'emoji') -> None

Populate a rich tree from a scanned directory structure.

Recursively adds each file and subdirectory of structure to tree, with filenames colored by extension. Each file is labeled with its stored path, which already holds the full path when the scan requested one. Files are ordered by spec.sort_key via recursivist.sorting.sort_files_by_type. A subtree that hit the depth limit is simply left unexpanded; its folder icon still shows whether anything was cut off.

The resolved spec controls the annotations appended to each entry:

  • spec.metrics: the ordered lines-of-code, size, and modification-time metrics to append (in the exact order requested).
  • spec.show_git_status: append a colored marker to each file — [U] untracked (grey), [M] modified (yellow), [A] added (green), [D] deleted (red). The marker always trails the metric parenthetical, and deleted files no longer on disk are also struck through.

Parameters:

Name Type Description Default
structure Directory

Directory to render.

required
tree Tree

rich tree to add nodes to. Modified in place.

required
color_map dict[str, str]

Mapping of lowercase file extension to hex color.

required
spec DisplayOptions

Resolved sorting and annotation directives.

required
icon_style str

Icon style to use, either "emoji" or "nerd".

'emoji'
Source code in recursivist/tree.py
def build_tree(
    structure: Directory,
    tree: Tree,
    color_map: dict[str, str],
    spec: DisplayOptions,
    icon_style: str = "emoji",
) -> None:
    """Populate a ``rich`` tree from a scanned directory structure.

    Recursively adds each file and subdirectory of *structure* to *tree*, with filenames
    colored by extension. Each file is labeled with its stored ``path``, which already
    holds the full path when the scan requested one. Files are ordered by
    ``spec.sort_key`` via
    [`recursivist.sorting.sort_files_by_type`][recursivist.sorting.sort_files_by_type].
    A subtree that hit the depth limit is simply left unexpanded; its folder icon still
    shows whether anything was cut off.

    The resolved *spec* controls the annotations appended to each entry:

    - ``spec.metrics``: the ordered lines-of-code, size, and modification-time metrics
      to append (in the exact order requested).
    - ``spec.show_git_status``: append a colored marker to each file — ``[U]`` untracked
      (grey), ``[M]`` modified (yellow), ``[A]`` added (green), ``[D]`` deleted (red).
      The marker always trails the metric parenthetical, and deleted files no longer on
      disk are also struck through.

    Args:
        structure: Directory to render.
        tree: ``rich`` tree to add nodes to. Modified in place.
        color_map: Mapping of lowercase file extension to hex color.
        spec: Resolved sorting and annotation directives.
        icon_style: Icon style to use, either ``"emoji"`` or ``"nerd"``.
    """
    need_git = spec.show_git_status or spec.sort_key == METRIC_GIT
    git_markers_dict: dict[str, str] = structure.git_markers if need_git else {}
    for entry in sort_files_by_type(structure.files, spec.sort_key, git_markers_dict):
        ext = os.path.splitext(entry.name)[1].lower()
        color = color_map.get(ext, "#FFFFFF")

        git_marker = git_markers_dict.get(entry.name, "")
        is_deleted = git_marker == "D"

        name_style = f"{color} strike" if is_deleted else color

        colored_text = Text()
        icon = get_icon(entry.name, is_dir=False, style=icon_style)
        colored_text.append(f"{icon} ", style=color)
        colored_text.append(
            entry.path
            + format_metrics_suffix(entry.loc, entry.size, entry.mtime, spec.metrics),
            style=name_style,
        )

        if spec.show_git_status and git_marker:
            marker_style, badge = _GIT_MARKER_STYLES.get(
                git_marker, ("dim", f"[{git_marker}]")
            )
            colored_text.append(f" {badge}", style=marker_style)

        tree.add(colored_text)
    for folder, content in iter_subdirectories(structure):
        folder_icon = get_icon(
            folder,
            is_dir=True,
            style=icon_style,
            is_empty=not has_contents(content),
        )
        metrics = format_dir_metrics(content, spec.metrics)
        folder_display = f"{folder_icon} {folder}{metrics}"
        subtree = tree.add(Text(folder_display))
        if content.symlink_loop:
            subtree.add(Text("↩ (symlink loop)", style="dim"))
        elif not content.max_depth_reached:
            build_tree(content, subtree, color_map, spec, icon_style)

display_tree

display_tree(structure: Directory, extensions: set[str], root_name: str, spec: DisplayOptions | None = None, icon_style: str = 'emoji') -> None

Render a scanned directory structure as a tree in the terminal.

Builds a color map from extensions, populates a rich tree from structure with build_tree, and prints it. structure is rendered exactly as given: exclusions, depth limits, full paths, metrics, and Git status are all determined by the scan that produced it.

Parameters:

Name Type Description Default
structure Directory

Scanned directory structure to render, as returned by get_directory_structure.

required
extensions set[str]

Set of file extensions found in structure, as returned alongside it by the scan.

required
root_name str

Display name for the root node (e.g. the directory's basename, or a repository name for a GitHub input).

required
spec DisplayOptions | None

Resolved sorting and annotation directives. Defaults to a plain DisplayOptions (no sorting, no annotations). Metrics and Git status are only shown if the scan collected them.

None
icon_style str

Icon style to use, either "emoji" or "nerd".

'emoji'
Source code in recursivist/tree.py
def display_tree(
    structure: Directory,
    extensions: set[str],
    root_name: str,
    spec: DisplayOptions | None = None,
    icon_style: str = "emoji",
) -> None:
    """Render a scanned directory structure as a tree in the terminal.

    Builds a color map from *extensions*, populates a ``rich`` tree from *structure*
    with [`build_tree`][recursivist.tree.build_tree], and prints it. *structure* is
    rendered exactly as given: exclusions, depth limits, full paths, metrics, and Git
    status are all determined by the scan that produced it.

    Args:
        structure: Scanned directory structure to render, as returned by
            [`get_directory_structure`][recursivist.scanner.get_directory_structure].
        extensions: Set of file extensions found in *structure*, as returned alongside
            it by the scan.
        root_name: Display name for the root node (e.g. the directory's basename, or a
            repository name for a GitHub input).
        spec: Resolved sorting and annotation directives. Defaults to a plain
            [`DisplayOptions`][recursivist.flags.DisplayOptions] (no sorting, no
            annotations). Metrics and Git status are only shown if the scan collected
            them.
        icon_style: Icon style to use, either ``"emoji"`` or ``"nerd"``.
    """
    if spec is None:
        spec = DisplayOptions()

    color_map = build_color_map(extensions)
    root_icon = get_icon(
        root_name,
        is_dir=True,
        style=icon_style,
        is_empty=not has_contents(structure),
    )
    root_label = f"{root_icon} {root_name}" + format_dir_metrics(
        structure, spec.metrics
    )
    tree = Tree(Text(root_label))
    build_tree(structure, tree, color_map, spec, icon_style=icon_style)
    Console().print(tree)

Compare

recursivist.compare

Side-by-side directory comparison.

Builds the structures for two directories with identical filtering and renders them next to each other, highlighting entries unique to either side. Supports the same filtering and metric options as the single-tree renderer, with terminal output for interactive use and HTML export for sharing.

build_comparison_tree

build_comparison_tree(structure: Directory, other_structure: Directory, tree: Tree, spec: DisplayOptions, icon_style: str = 'emoji', identity_spec: DisplayOptions | None = None, *, this_is_remote: bool = False, other_is_remote: bool = False) -> None

Populate a rich tree, highlighting differences against another tree.

Recursively adds the entries of structure to tree, comparing each against other_structure: items present in both are shown normally, items unique to structure are highlighted in green, and items unique to other_structure are highlighted in red. File names are rendered without file-type-specific colors so the green/red difference highlighting stands out. Files are ordered by spec.sort_key and metric annotations are appended in spec.metrics order.

When spec.show_git_status is set, each file is followed by a plain Git-status badge — [U] untracked, [M] modified, [A] added, [D] deleted — read from the git_markers stored on structure (and on other_structure for entries unique to it). The badge is not color-coded; it trails the metric parenthetical, and deleted files are struck through.

Two identically named files count as the same entry only when their displayed annotations also match (see _comparison_identity), so a differing metric or Git status marks them as unique to their side. identity_spec controls which annotations that match considers: it defaults to spec, but a caller comparing a local directory against a hosted repository passes spec.without_remote_unsupported() so that annotations a remote side cannot provide (modification time, Git status) are excluded from the identity — those are still displayed per spec, but they do not split otherwise-matching files across the two sides.

The traversal is shared with the HTML export (see _ComparisonWalker), so both views always agree on ordering, badges and highlighting.

Parameters:

Name Type Description Default
structure Directory

The directory being rendered.

required
other_structure Directory

The directory being compared against.

required
tree Tree

rich tree to add nodes to. Modified in place.

required
spec DisplayOptions

Resolved sorting and annotation directives.

required
icon_style str

Icon style to use, either "emoji" or "nerd".

'emoji'
identity_spec DisplayOptions | None

Directives governing which annotations contribute to cross-side file identity. Defaults to spec.

None
this_is_remote bool

Whether the primary structure originates from a hosted repository.

False
other_is_remote bool

Whether the compared structure originates from a hosted repository.

False
Source code in recursivist/compare.py
def build_comparison_tree(
    structure: Directory,
    other_structure: Directory,
    tree: Tree,
    spec: DisplayOptions,
    icon_style: str = "emoji",
    identity_spec: DisplayOptions | None = None,
    *,
    this_is_remote: bool = False,
    other_is_remote: bool = False,
) -> None:
    """Populate a ``rich`` tree, highlighting differences against another tree.

    Recursively adds the entries of *structure* to *tree*, comparing each against
    *other_structure*: items present in both are shown normally, items unique to
    *structure* are highlighted in green, and items unique to *other_structure* are
    highlighted in red. File names are rendered without file-type-specific colors so the
    green/red difference highlighting stands out. Files are ordered by ``spec.sort_key``
    and metric annotations are appended in ``spec.metrics`` order.

    When ``spec.show_git_status`` is set, each file is followed by a plain Git-status
    badge — ``[U]`` untracked, ``[M]`` modified, ``[A]`` added, ``[D]`` deleted — read
    from the ``git_markers`` stored on *structure* (and on *other_structure* for
    entries unique to it). The badge is not color-coded; it trails the metric
    parenthetical, and deleted files are struck through.

    Two identically named files count as the same entry only when their *displayed*
    annotations also match (see `_comparison_identity`), so a differing metric or Git
    status marks them as unique to their side. *identity_spec* controls which
    annotations that match considers: it defaults to *spec*, but a caller comparing a
    local directory against a hosted repository passes
    ``spec.without_remote_unsupported()`` so that annotations a remote side cannot
    provide (modification time, Git status) are excluded from the identity — those are
    still *displayed* per *spec*, but they do not split otherwise-matching files across
    the two sides.

    The traversal is shared with the HTML export (see `_ComparisonWalker`), so both
    views always agree on ordering, badges and highlighting.

    Args:
        structure: The directory being rendered.
        other_structure: The directory being compared against.
        tree: ``rich`` tree to add nodes to. Modified in place.
        spec: Resolved sorting and annotation directives.
        icon_style: Icon style to use, either ``"emoji"`` or ``"nerd"``.
        identity_spec: Directives governing which annotations contribute to cross-side
            file identity. Defaults to *spec*.
        this_is_remote: Whether the primary structure originates from a hosted
            repository.
        other_is_remote: Whether the compared structure originates from a hosted
            repository.
    """
    walker = _ComparisonWalker.for_sides(
        spec, identity_spec, this_is_remote, other_is_remote, icon_style
    )
    _render_rich_nodes(walker.walk(structure, other_structure), tree)

display_comparison

display_comparison(dir1: str, dir2: str, exclude_dirs: list[str] | None = None, ignore_file: str | None = None, exclude_extensions: set[str] | None = None, exclude_patterns: list[str] | None = None, include_patterns: list[str] | None = None, use_regex: bool = False, max_depth: int = 0, show_full_path: bool = False, spec: DisplayOptions | None = None, icon_style: str = 'emoji', *, targets: _Targets | None = None) -> None

Render two directory trees side by side in the terminal.

Scans both directories with identical options and prints them as two labeled, color-highlighted panels: entries unique to dir1 and dir2 are highlighted in contrasting colors, shared entries are shown normally, and a legend explains the scheme.

Parameters:

Name Type Description Default
dir1 str

Path to the first directory.

required
dir2 str

Path to the second directory.

required
exclude_dirs list[str] | None

Directory names to skip entirely.

None
ignore_file str | None

Name of an ignore file to honor (e.g. .gitignore).

None
exclude_extensions set[str] | None

File extensions to exclude. Normalized to a lowercase, dot-prefixed form before scanning.

None
exclude_patterns list[str] | None

Glob or regex patterns to exclude.

None
include_patterns list[str] | None

Glob or regex patterns to include. When given, only files whose names match one are kept, and a match overrides ignore-file rules for that file. They do not override exclude_dirs, exclude_extensions, or exclude_patterns.

None
use_regex bool

Whether to treat the patterns as regular expressions instead of glob patterns.

False
max_depth int

Maximum depth to display, or 0 for unlimited.

0
show_full_path bool

Whether to display absolute paths instead of bare filenames.

False
spec DisplayOptions | None

Resolved sorting and annotation directives. Defaults to a plain DisplayOptions.

None
icon_style str

Icon style to use, either "emoji" or "nerd".

'emoji'
targets _Targets | None

The (target1, target2) pair already parsed from dir1 and dir2 with parse_github_url (None for a local side), for callers that have it. Left as None, the inputs are parsed here.

None
Source code in recursivist/compare.py
def display_comparison(
    dir1: str,
    dir2: str,
    exclude_dirs: list[str] | None = None,
    ignore_file: str | None = None,
    exclude_extensions: set[str] | None = None,
    exclude_patterns: list[str] | None = None,
    include_patterns: list[str] | None = None,
    use_regex: bool = False,
    max_depth: int = 0,
    show_full_path: bool = False,
    spec: DisplayOptions | None = None,
    icon_style: str = "emoji",
    *,
    targets: _Targets | None = None,
) -> None:
    """Render two directory trees side by side in the terminal.

    Scans both directories with identical options and prints them as two labeled,
    color-highlighted panels: entries unique to *dir1* and *dir2* are highlighted in
    contrasting colors, shared entries are shown normally, and a legend explains the
    scheme.

    Args:
        dir1: Path to the first directory.
        dir2: Path to the second directory.
        exclude_dirs: Directory names to skip entirely.
        ignore_file: Name of an ignore file to honor (e.g. ``.gitignore``).
        exclude_extensions: File extensions to exclude. Normalized to a lowercase,
            dot-prefixed form before scanning.
        exclude_patterns: Glob or regex patterns to exclude.
        include_patterns: Glob or regex patterns to include. When given, only files
            whose names match one are kept, and a match overrides ignore-file rules for
            that file. They do not override *exclude_dirs*, *exclude_extensions*, or
            *exclude_patterns*.
        use_regex: Whether to treat the patterns as regular expressions instead of glob
            patterns.
        max_depth: Maximum depth to display, or ``0`` for unlimited.
        show_full_path: Whether to display absolute paths instead of bare filenames.
        spec: Resolved sorting and annotation directives. Defaults to a plain
            [`DisplayOptions`][recursivist.flags.DisplayOptions].
        icon_style: Icon style to use, either ``"emoji"`` or ``"nerd"``.
        targets: The ``(target1, target2)`` pair already parsed from *dir1* and *dir2*
            with [`parse_github_url`][recursivist.github.parse_github_url] (``None`` for
            a local side), for callers that have it. Left as ``None``, the inputs are
            parsed here.
    """
    if spec is None:
        spec = DisplayOptions()
    if exclude_dirs is None:
        exclude_dirs = []
    if exclude_extensions is None:
        exclude_extensions = set()
    if exclude_patterns is None:
        exclude_patterns = []
    if include_patterns is None:
        include_patterns = []
    exclude_extensions = normalize_extensions(exclude_extensions)
    compiled_exclude = compile_regex_patterns(exclude_patterns, use_regex)
    compiled_include = compile_regex_patterns(include_patterns, use_regex)
    targets = _resolve_targets(dir1, dir2, targets)
    structure1, structure2, (target1, target2) = _scan_sides(
        dir1,
        dir2,
        exclude_dirs,
        ignore_file,
        exclude_extensions,
        exclude_patterns=compiled_exclude,
        include_patterns=compiled_include,
        max_depth=max_depth,
        show_full_path=show_full_path,
        spec=spec,
        targets=targets,
    )
    console = Console()

    identity_spec = _identity_spec_for(target1, target2, spec)

    is_remote1 = target1 is not None
    is_remote2 = target2 is not None

    dir1_metrics = _side_metrics(spec.metrics, is_remote1)
    dir2_metrics = _side_metrics(spec.metrics, is_remote2)

    root_base1 = _side_display_name(dir1, target1)
    root_base2 = _side_display_name(dir2, target2)
    root_icon1 = get_icon(
        root_base1,
        is_dir=True,
        style=icon_style,
        is_empty=not has_contents(structure1),
    )
    root_icon2 = get_icon(
        root_base2,
        is_dir=True,
        style=icon_style,
        is_empty=not has_contents(structure2),
    )

    tree1 = Tree(
        Text(
            f"{root_icon1} {root_base1}" + format_dir_metrics(structure1, dir1_metrics),
            style="bold",
        )
    )

    tree2 = Tree(
        Text(
            f"{root_icon2} {root_base2}" + format_dir_metrics(structure2, dir2_metrics),
            style="bold",
        )
    )

    build_comparison_tree(
        structure1,
        structure2,
        tree1,
        spec,
        icon_style=icon_style,
        identity_spec=identity_spec,
        this_is_remote=is_remote1,
        other_is_remote=is_remote2,
    )
    build_comparison_tree(
        structure2,
        structure1,
        tree2,
        spec,
        icon_style=icon_style,
        identity_spec=identity_spec,
        this_is_remote=is_remote2,
        other_is_remote=is_remote1,
    )
    legend_text = Text()
    legend_text.append("Legend: ", style="bold")
    legend_text.append("Green", style="on green")
    legend_text.append(" = In this directory, ")
    legend_text.append("Red", style="on red")
    legend_text.append(" = In the other directory")
    if "loc" in spec.metrics:
        legend_text.append("\n")
        legend_text.append("LOC counts shown in parentheses")
    if "size" in spec.metrics:
        legend_text.append("\n")
        legend_text.append("File sizes shown in parentheses")
    if "mtime" in spec.metrics:
        legend_text.append("\n")
        legend_text.append("Modification times shown in parentheses")
    if spec.show_git_status:
        legend_text.append("\n")
        legend_text.append(
            "Git status markers: [U] untracked, [M] modified, [A] added, [D] deleted"
        )
    _sort_note = {
        "loc": "Files sorted by line count",
        "size": "Files sorted by size",
        "mtime": "Files sorted by modification time (newest first)",
        "git_status": "Files sorted by Git status",
        "similarity": "Files grouped by name similarity",
    }.get(spec.sort_key or "")
    if _sort_note:
        legend_text.append("\n")
        legend_text.append(_sort_note)
    if max_depth > 0:
        level_word = "level" if max_depth == 1 else "levels"
        legend_text.append("\n")
        legend_text.append(f"Directory tree is limited to {max_depth} {level_word}")
    if show_full_path:
        legend_text.append("\n")
        legend_text.append("Full file paths are shown instead of just filenames")
    if exclude_patterns or include_patterns:
        pattern_info = []
        if exclude_patterns:
            pattern_type = "Regex" if use_regex else "Glob"
            pattern_info.append(
                f"{pattern_type} exclusion patterns: "
                f"{', '.join(str(p) for p in exclude_patterns)}"
            )
        if include_patterns:
            pattern_type = "Regex" if use_regex else "Glob"
            pattern_info.append(
                f"{pattern_type} inclusion patterns: "
                f"{', '.join(str(p) for p in include_patterns)}"
            )
        if pattern_info:
            pattern_panel = Panel(
                Text("\n".join(pattern_info)),
                title="Applied Patterns",
                border_style="blue",
            )
            console.print(pattern_panel)
    legend_panel = Panel(legend_text, border_style="dim")
    console.print(legend_panel)
    console.print(
        _render_side_by_side(
            console,
            Panel(
                tree1,
                title=Text(f"Directory 1: {root_base1}"),
                border_style="blue",
            ),
            Panel(
                tree2,
                title=Text(f"Directory 2: {root_base2}"),
                border_style="green",
            ),
        )
    )

export_comparison

export_comparison(dir1: str, dir2: str, format_type: str, output_path: str, exclude_dirs: list[str] | None = None, ignore_file: str | None = None, exclude_extensions: set[str] | None = None, exclude_patterns: list[str] | None = None, include_patterns: list[str] | None = None, use_regex: bool = False, max_depth: int = 0, show_full_path: bool = False, spec: DisplayOptions | None = None, icon_style: str = 'emoji', *, targets: _Targets | None = None) -> None

Export a side-by-side directory comparison to an HTML file.

Scans both directories with identical options and writes a standalone, responsive HTML document containing the highlighted comparison, a legend, and a summary of the settings used. Only HTML output is supported.

Parameters:

Name Type Description Default
dir1 str

Path to the first directory.

required
dir2 str

Path to the second directory.

required
format_type str

Export format. Only "html" is supported.

required
output_path str

Path the HTML file is written to.

required
exclude_dirs list[str] | None

Directory names to skip entirely.

None
ignore_file str | None

Name of an ignore file to honor (e.g. .gitignore).

None
exclude_extensions set[str] | None

File extensions to exclude. Normalized to a lowercase, dot-prefixed form before scanning.

None
exclude_patterns list[str] | None

Glob or regex patterns to exclude.

None
include_patterns list[str] | None

Glob or regex patterns to include. When given, only files whose names match one are kept, and a match overrides ignore-file rules for that file. They do not override exclude_dirs, exclude_extensions, or exclude_patterns.

None
use_regex bool

Whether to treat the patterns as regular expressions instead of glob patterns.

False
max_depth int

Maximum depth to include, or 0 for unlimited.

0
show_full_path bool

Whether to write absolute paths instead of bare filenames.

False
spec DisplayOptions | None

Resolved sorting and annotation directives. Defaults to a plain DisplayOptions.

None
icon_style str

Icon style to use, either "emoji" or "nerd".

'emoji'
targets _Targets | None

The (target1, target2) pair already parsed from dir1 and dir2 with parse_github_url (None for a local side), for callers that have it. Left as None, the inputs are parsed here.

None

Raises:

Type Description
ValueError

If format_type is not "html".

Source code in recursivist/compare.py
def export_comparison(
    dir1: str,
    dir2: str,
    format_type: str,
    output_path: str,
    exclude_dirs: list[str] | None = None,
    ignore_file: str | None = None,
    exclude_extensions: set[str] | None = None,
    exclude_patterns: list[str] | None = None,
    include_patterns: list[str] | None = None,
    use_regex: bool = False,
    max_depth: int = 0,
    show_full_path: bool = False,
    spec: DisplayOptions | None = None,
    icon_style: str = "emoji",
    *,
    targets: _Targets | None = None,
) -> None:
    """Export a side-by-side directory comparison to an HTML file.

    Scans both directories with identical options and writes a standalone, responsive
    HTML document containing the highlighted comparison, a legend, and a summary of the
    settings used. Only HTML output is supported.

    Args:
        dir1: Path to the first directory.
        dir2: Path to the second directory.
        format_type: Export format. Only ``"html"`` is supported.
        output_path: Path the HTML file is written to.
        exclude_dirs: Directory names to skip entirely.
        ignore_file: Name of an ignore file to honor (e.g. ``.gitignore``).
        exclude_extensions: File extensions to exclude. Normalized to a lowercase,
            dot-prefixed form before scanning.
        exclude_patterns: Glob or regex patterns to exclude.
        include_patterns: Glob or regex patterns to include. When given, only files
            whose names match one are kept, and a match overrides ignore-file rules for
            that file. They do not override *exclude_dirs*, *exclude_extensions*, or
            *exclude_patterns*.
        use_regex: Whether to treat the patterns as regular expressions instead of glob
            patterns.
        max_depth: Maximum depth to include, or ``0`` for unlimited.
        show_full_path: Whether to write absolute paths instead of bare filenames.
        spec: Resolved sorting and annotation directives. Defaults to a plain
            [`DisplayOptions`][recursivist.flags.DisplayOptions].
        icon_style: Icon style to use, either ``"emoji"`` or ``"nerd"``.
        targets: The ``(target1, target2)`` pair already parsed from *dir1* and *dir2*
            with [`parse_github_url`][recursivist.github.parse_github_url] (``None`` for
            a local side), for callers that have it. Left as ``None``, the inputs are
            parsed here.

    Raises:
        ValueError: If *format_type* is not ``"html"``.
    """
    if format_type != "html":
        raise ValueError("Only HTML format is supported for comparison export")
    if spec is None:
        spec = DisplayOptions()
    if exclude_dirs is None:
        exclude_dirs = []
    if exclude_extensions is None:
        exclude_extensions = set()
    if exclude_patterns is None:
        exclude_patterns = []
    if include_patterns is None:
        include_patterns = []
    exclude_extensions = normalize_extensions(exclude_extensions)
    compiled_exclude = compile_regex_patterns(exclude_patterns, use_regex)
    compiled_include = compile_regex_patterns(include_patterns, use_regex)
    targets = _resolve_targets(dir1, dir2, targets)
    structure1, structure2, (target1, target2) = _scan_sides(
        dir1,
        dir2,
        exclude_dirs,
        ignore_file,
        exclude_extensions,
        exclude_patterns=compiled_exclude,
        include_patterns=compiled_include,
        max_depth=max_depth,
        show_full_path=show_full_path,
        spec=spec,
        targets=targets,
    )
    _export_comparison_to_html(
        structure1,
        structure2,
        output_path,
        name1=_side_display_name(dir1, target1),
        name2=_side_display_name(dir2, target2),
        is_remote1=target1 is not None,
        is_remote2=target2 is not None,
        spec=spec,
        identity_spec=_identity_spec_for(target1, target2, spec),
        exclude_patterns=exclude_patterns,
        include_patterns=include_patterns,
        use_regex=use_regex,
        max_depth=max_depth,
        show_full_path=show_full_path,
        icon_style=icon_style,
    )

Colors

recursivist.colors

Deterministic color assignment for file extensions.

Derives a hex color for each file extension from a hash of the extension, then nudges it away from the colors already assigned so distinct extensions stay visually separable. build_color_map colors a whole set of extensions at once, in sorted order, so a given set always produces the same mapping. Also provides WCAG 2.1 contrast helpers used by renderers that draw onto a known background (such as the HTML exporter) to guarantee legible text. Pure standard library.

WCAG_AA_NORMAL_TEXT module-attribute

WCAG_AA_NORMAL_TEXT = 4.5

WCAG 2.1 level AA minimum contrast ratio for normal-sized body text.

WCAG_AAA_NORMAL_TEXT module-attribute

WCAG_AAA_NORMAL_TEXT = 7.0

WCAG 2.1 level AAA minimum contrast ratio for normal-sized body text.

color_distance

color_distance(color1: tuple[int, int, int], color2: tuple[int, int, int]) -> float

Calculate the perceptual distance between two RGB colors.

Uses a weighted Euclidean distance formula that approximates human color perception by emphasising the green channel over red and blue.

Parameters:

Name Type Description Default
color1 tuple[int, int, int]

First color as an (r, g, b) tuple with component values in the range 0-255.

required
color2 tuple[int, int, int]

Second color as an (r, g, b) tuple with component values in the range 0-255.

required

Returns:

Type Description
float

A non-negative float representing the perceptual distance; 0.0 means the

float

colors are identical and larger values indicate greater visual difference.

Source code in recursivist/colors.py
def color_distance(color1: tuple[int, int, int], color2: tuple[int, int, int]) -> float:
    """Calculate the perceptual distance between two RGB colors.

    Uses a weighted Euclidean distance formula that approximates human color perception
    by emphasising the green channel over red and blue.

    Args:
        color1: First color as an ``(r, g, b)`` tuple with component values in the range
            ``0``-``255``.
        color2: Second color as an ``(r, g, b)`` tuple with component values in the
            range ``0``-``255``.

    Returns:
        A non-negative float representing the perceptual distance; ``0.0`` means the
        colors are identical and larger values indicate greater visual difference.
    """
    r1, g1, b1 = [x / 255 for x in color1]
    r2, g2, b2 = [x / 255 for x in color2]
    r_weight, g_weight, b_weight = 0.3, 0.59, 0.11
    dist = math.sqrt(
        r_weight * (r1 - r2) ** 2
        + g_weight * (g1 - g2) ** 2
        + b_weight * (b1 - b2) ** 2
    )
    return dist

hex_to_rgb

hex_to_rgb(hex_color: str) -> tuple[int, int, int]

Convert a CSS hex color string to an (r, g, b) tuple.

Parameters:

Name Type Description Default
hex_color str

Six-digit hex color string, optionally prefixed with '#' (e.g., "#FF5733" or "FF5733").

required

Returns:

Type Description
tuple[int, int, int]

A three-tuple of integers (red, green, blue) in the range 0-255.

Source code in recursivist/colors.py
def hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
    """Convert a CSS hex color string to an ``(r, g, b)`` tuple.

    Args:
        hex_color: Six-digit hex color string, optionally prefixed with ``'#'`` (e.g.,
            ``"#FF5733"`` or ``"FF5733"``).

    Returns:
        A three-tuple of integers ``(red, green, blue)`` in the range ``0``-``255``.
    """
    hex_color = hex_color.lstrip("#")
    return cast(
        tuple[int, int, int], tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
    )

rgb_to_hex

rgb_to_hex(color: tuple[int, int, int]) -> str

Convert an (r, g, b) tuple to a CSS hex color string.

Each component is an integer in the range 0-255. The result is lowercase, six digits long, and prefixed with '#'.

Source code in recursivist/colors.py
def rgb_to_hex(color: tuple[int, int, int]) -> str:
    """Convert an ``(r, g, b)`` tuple to a CSS hex color string.

    Each component is an integer in the range ``0``-``255``. The result is lowercase,
    six digits long, and prefixed with ``'#'``.
    """
    return _HEX_FORMAT.format(*color)

relative_luminance

relative_luminance(color: tuple[int, int, int]) -> float

Calculate the WCAG relative luminance of an sRGB color.

Implements the definition given in WCAG 2.1: each channel is normalised to 0-1, linearised to remove the sRGB transfer function, and then combined with the standard luminance coefficients.

Parameters:

Name Type Description Default
color tuple[int, int, int]

Color as an (r, g, b) tuple with component values in the range 0-255.

required

Returns:

Type Description
float

The relative luminance, from 0.0 (black) to 1.0 (white).

Source code in recursivist/colors.py
def relative_luminance(color: tuple[int, int, int]) -> float:
    """Calculate the WCAG relative luminance of an sRGB color.

    Implements the definition given in WCAG 2.1: each channel is normalised to
    ``0``-``1``, linearised to remove the sRGB transfer function, and then combined with
    the standard luminance coefficients.

    Args:
        color: Color as an ``(r, g, b)`` tuple with component values in the range
            ``0``-``255``.

    Returns:
        The relative luminance, from ``0.0`` (black) to ``1.0`` (white).
    """
    linear = []
    for component in color:
        channel = component / 255
        if channel <= 0.03928:
            linear.append(channel / 12.92)
        else:
            linear.append(((channel + 0.055) / 1.055) ** 2.4)
    red, green, blue = linear
    return 0.2126 * red + 0.7152 * green + 0.0722 * blue

contrast_ratio

contrast_ratio(color1: tuple[int, int, int], color2: tuple[int, int, int]) -> float

Calculate the WCAG contrast ratio between two colors.

Parameters:

Name Type Description Default
color1 tuple[int, int, int]

First color as an (r, g, b) tuple with component values in the range 0-255.

required
color2 tuple[int, int, int]

Second color as an (r, g, b) tuple with component values in the range 0-255.

required

Returns:

Type Description
float

The contrast ratio, from 1.0 (identical luminance) to 21.0 (black

float

against white). WCAG 2.1 requires at least 4.5 for normal body text at level

float

AA and 3.0 for large text.

Source code in recursivist/colors.py
def contrast_ratio(color1: tuple[int, int, int], color2: tuple[int, int, int]) -> float:
    """Calculate the WCAG contrast ratio between two colors.

    Args:
        color1: First color as an ``(r, g, b)`` tuple with component values in the range
            ``0``-``255``.
        color2: Second color as an ``(r, g, b)`` tuple with component values in the
            range ``0``-``255``.

    Returns:
        The contrast ratio, from ``1.0`` (identical luminance) to ``21.0`` (black
        against white). WCAG 2.1 requires at least ``4.5`` for normal body text at level
        AA and ``3.0`` for large text.
    """
    luminance1 = relative_luminance(color1)
    luminance2 = relative_luminance(color2)
    lighter = max(luminance1, luminance2)
    darker = min(luminance1, luminance2)
    return (lighter + 0.05) / (darker + 0.05)

ensure_contrast cached

ensure_contrast(hex_color: str, background: str = '#ffffff', min_ratio: float = WCAG_AA_NORMAL_TEXT) -> str

Adjust a color until it meets a WCAG contrast ratio against background.

The hue is preserved so extensions stay recognisable and mutually distinguishable; only brightness (and, if brightness alone is not enough, saturation) is changed. Colors that already meet min_ratio are returned unchanged, so this is a no-op for compliant input.

Colors are darkened against light backgrounds and lightened against dark ones, whichever direction can reach the required ratio.

Parameters:

Name Type Description Default
hex_color str

Foreground color as a hex string, with or without a leading '#'.

required
background str

Background color the text is drawn on, as a hex string.

'#ffffff'
min_ratio float

Minimum acceptable contrast ratio. Defaults to 4.5, the WCAG 2.1 level AA threshold for normal-sized text.

WCAG_AA_NORMAL_TEXT

Returns:

Type Description
str

A CSS hex color string that meets min_ratio against background, or (if no

str

adjustment of this hue can reach the ratio) the closest achievable color.

Source code in recursivist/colors.py
@lru_cache(maxsize=512)
def ensure_contrast(
    hex_color: str,
    background: str = "#ffffff",
    min_ratio: float = WCAG_AA_NORMAL_TEXT,
) -> str:
    """Adjust a color until it meets a WCAG contrast ratio against *background*.

    The hue is preserved so extensions stay recognisable and mutually distinguishable;
    only brightness (and, if brightness alone is not enough, saturation) is changed.
    Colors that already meet *min_ratio* are returned unchanged, so this is a no-op for
    compliant input.

    Colors are darkened against light backgrounds and lightened against dark ones,
    whichever direction can reach the required ratio.

    Args:
        hex_color: Foreground color as a hex string, with or without a leading ``'#'``.
        background: Background color the text is drawn on, as a hex string.
        min_ratio: Minimum acceptable contrast ratio. Defaults to ``4.5``, the WCAG 2.1
            level AA threshold for normal-sized text.

    Returns:
        A CSS hex color string that meets *min_ratio* against *background*, or (if no
        adjustment of this hue can reach the ratio) the closest achievable color.
    """
    foreground = hex_to_rgb(hex_color)
    background_rgb = hex_to_rgb(background)
    if contrast_ratio(foreground, background_rgb) >= min_ratio:
        return hex_color
    hue, saturation, value = colorsys.rgb_to_hsv(*[c / 255 for c in foreground])
    darken = contrast_ratio((0, 0, 0), background_rgb) >= contrast_ratio(
        (255, 255, 255), background_rgb
    )
    steps = 128
    best_color = foreground
    best_ratio = contrast_ratio(foreground, background_rgb)
    candidates = []
    for step in range(1, steps + 1):
        fraction = step / steps
        if darken:
            candidates.append((hue, saturation, value * (1.0 - fraction)))
        else:
            candidates.append((hue, saturation, value + (1.0 - value) * fraction))
    if not darken:
        for step in range(1, steps + 1):
            fraction = step / steps
            candidates.append((hue, saturation * (1.0 - fraction), 1.0))
    for candidate_hsv in candidates:
        candidate = _hsv_to_rgb255(*candidate_hsv)
        ratio = contrast_ratio(candidate, background_rgb)
        if ratio >= min_ratio:
            return rgb_to_hex(candidate)
        if ratio > best_ratio:
            best_ratio = ratio
            best_color = candidate
    return rgb_to_hex(best_color)

build_color_map

build_color_map(extensions: Iterable[str]) -> dict[str, str]

Assign a visually distinct color to every extension in extensions.

Extensions are colored in sorted order, starting from an empty set of assigned colors, so the result is a pure function of the set of extensions: it depends neither on the order extensions is iterated in nor on any colors generated earlier. The same set therefore always yields the same mapping, across runs and across renderers.

Each color starts from a hash of its extension and is nudged away from the colors of the extensions sorted before it. An extension's color can consequently differ between two sets that contain different extensions.

Parameters:

Name Type Description Default
extensions Iterable[str]

File extensions to color, each as it should be keyed in the result (e.g. ".py"). Duplicates are ignored.

required

Returns:

Type Description
dict[str, str]

A mapping of each extension to a CSS hex color string. An empty extension maps

dict[str, str]

to white.

Source code in recursivist/colors.py
def build_color_map(extensions: Iterable[str]) -> dict[str, str]:
    """Assign a visually distinct color to every extension in *extensions*.

    Extensions are colored in sorted order, starting from an empty set of assigned
    colors, so the result is a pure function of the set of extensions: it depends
    neither on the order *extensions* is iterated in nor on any colors generated
    earlier. The same set therefore always yields the same mapping, across runs and
    across renderers.

    Each color starts from a hash of its extension and is nudged away from the colors of
    the extensions sorted before it. An extension's color can consequently differ
    between two sets that contain different extensions.

    Args:
        extensions: File extensions to color, each as it should be keyed in the result
            (e.g. ``".py"``). Duplicates are ignored.

    Returns:
        A mapping of each extension to a CSS hex color string. An empty extension maps
        to white.
    """
    assigned: dict[str, str] = {}
    return {ext: _assign_color(ext, assigned) for ext in sorted(set(extensions))}

Icons

recursivist.icons

Nerd Font icon mappings for files and directories.

This module provides icon lookup utilities based on Nerd Font glyph codes. Icons are resolved in priority order:

  1. Exact filename match (e.g., Dockerfile, package.json)
  2. File extension match (e.g., .py, .ts)
  3. Named folder match (e.g., node_modules, .git)
  4. Generic fallback icons for unknown files and directories

get_icon

get_icon(filename: str, is_dir: bool = False, style: str = 'emoji', is_empty: bool = False) -> str

Return the icon for a file or directory in the requested style.

With the "emoji" style, a single generic file emoji is returned for files and an open or closed folder emoji for directories. With the "nerd" style, a Nerd Font glyph is resolved in priority order.

For files:

  1. Exact filename match in EXACT_MATCH_ICONS (case-insensitive).
  2. File-extension match in EXTENSION_ICONS.
  3. The DEFAULT_NERD_FILE fallback.

For directories, FOLDER_ICONS is consulted first (so well-known folders keep their distinctive glyph regardless of their contents), falling back to the open or closed generic folder glyph depending on is_empty.

Parameters:

Name Type Description Default
filename str

Name of the file or directory (basename only, not a full path). Matched case-insensitively.

required
is_dir bool

When True, treat filename as a directory name and look up folder icons instead of file icons.

False
style str

Icon style to use, either "emoji" or "nerd".

'emoji'
is_empty bool

Only meaningful when is_dir is set. When True, the directory holds nothing that is being displayed and the closed folder icon is used; otherwise the open folder icon is used.

False

Returns:

Type Description
str

A single Unicode character containing the matching glyph.

Source code in recursivist/icons.py
def get_icon(
    filename: str,
    is_dir: bool = False,
    style: str = "emoji",
    is_empty: bool = False,
) -> str:
    """Return the icon for a file or directory in the requested style.

    With the ``"emoji"`` style, a single generic file emoji is returned for files and
    an open or closed folder emoji for directories. With the ``"nerd"`` style, a Nerd
    Font glyph is resolved in priority order.

    For files:

    1. Exact filename match in ``EXACT_MATCH_ICONS`` (case-insensitive).
    2. File-extension match in ``EXTENSION_ICONS``.
    3. The ``DEFAULT_NERD_FILE`` fallback.

    For directories, ``FOLDER_ICONS`` is consulted first (so well-known folders keep
    their distinctive glyph regardless of their contents), falling back to the open or
    closed generic folder glyph depending on *is_empty*.

    Args:
        filename: Name of the file or directory (basename only, not a full path).
            Matched case-insensitively.
        is_dir: When ``True``, treat *filename* as a directory name and look up folder
            icons instead of file icons.
        style: Icon style to use, either ``"emoji"`` or ``"nerd"``.
        is_empty: Only meaningful when *is_dir* is set. When ``True``, the directory
            holds nothing that is being displayed and the closed folder icon is used;
            otherwise the open folder icon is used.

    Returns:
        A single Unicode character containing the matching glyph.
    """
    if style == "emoji":
        if is_dir:
            return DEFAULT_EMOJI_FOLDER if is_empty else DEFAULT_EMOJI_FOLDER_OPEN
        return DEFAULT_EMOJI_FILE

    filename_lower = filename.lower()

    if is_dir:
        default_folder = DEFAULT_NERD_FOLDER if is_empty else DEFAULT_NERD_FOLDER_OPEN
        return FOLDER_ICONS.get(filename_lower, default_folder)

    if filename_lower in EXACT_MATCH_ICONS:
        return EXACT_MATCH_ICONS[filename_lower]

    _, ext = os.path.splitext(filename_lower)
    return EXTENSION_ICONS.get(ext, DEFAULT_NERD_FILE)