Source code for click_extra.highlight
# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Help-screen keyword highlighting and the colorized help formatter.
Hosts the engine that collects highlightable keywords from a Click context
(:class:`HelpKeywords`, ``_HelpColorsMixin``) and renders them with the
active theme: :class:`HelpFormatter` styles ``--help`` output and
:func:`highlight` applies a styling function to arbitrary matches. Split out of
:mod:`click_extra.color`, which now focuses on ``--color``/``--no-color``
resolution.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field, fields
from enum import Enum
from functools import lru_cache
import click
import click.formatting
import cloup
from click._compat import term_len
from cloup._util import identity
from . import theme as _theme
from .theme import HelpTheme, ThemeChoice
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
from typing import ClassVar
from cloup.styling import IStyle
[docs]
@dataclass
class HelpKeywords:
"""Structured collection of keywords extracted from a Click context for
help screen highlighting.
Each field corresponds to a semantic category with its own styling.
"""
cli_names: set[str] = field(default_factory=set)
subcommands: set[str] = field(default_factory=set)
command_aliases: set[str] = field(default_factory=set)
arguments: set[str] = field(default_factory=set)
long_options: set[str] = field(default_factory=set)
short_options: set[str] = field(default_factory=set)
choices: set[str] = field(default_factory=set)
choice_metavars: set[str] = field(default_factory=set)
metavars: set[str] = field(default_factory=set)
envvars: set[str] = field(default_factory=set)
defaults: set[str] = field(default_factory=set)
[docs]
def merge(self, other: HelpKeywords) -> None:
"""Merge another ``HelpKeywords`` into this one.
Each set field is updated with the corresponding set from ``other``.
"""
for f in fields(self):
getattr(self, f.name).update(getattr(other, f.name))
[docs]
def subtract(self, other: HelpKeywords) -> None:
"""Remove keywords found in ``other`` from this instance.
Each set field is difference-updated with the corresponding set from
``other``. Mirror of :meth:`merge`.
"""
for f in fields(self):
getattr(self, f.name).difference_update(getattr(other, f.name))
class _HelpColorsMixin:
"""Adds extra-keywords highlighting to Click commands.
This mixin for ``click.Command``-like classes intercepts the top-level helper-
generation method to initialize the formatter with dynamic settings. This is
implemented at this stage so we have access to the global context.
"""
#: Extra keywords to merge into the auto-collected set. Consumers can set
#: this attribute on a command instance to inject additional keywords for
#: help screen highlighting (like placeholder option names such as
#: ``--<manager-id>`` that appear in prose but are not real parameters).
extra_keywords: HelpKeywords | None = None
#: Keywords to remove from the auto-collected set. Mirror of
#: :attr:`extra_keywords`: any string listed here will not be highlighted
#: even if it was collected from the Click context.
excluded_keywords: HelpKeywords | None = None
def collect_keywords(self, ctx: click.Context) -> HelpKeywords:
"""Parse click context to collect option names, choices and metavar keywords.
Override this method to customize keyword collection. Call ``super()`` and
mutate the returned ``HelpKeywords`` to extend the default set.
"""
kw = HelpKeywords()
subcommand_objs: set[click.Command] = set()
# Includes the full command path and each ancestor name, so that
# individual components are highlighted even when interleaved with
# options (like "repomatic --table-format github sync-uv-lock").
if ctx.command_path:
kw.cli_names.add(ctx.command_path)
ancestor: click.Context | None = ctx
while ancestor:
if ancestor.info_name:
kw.cli_names.add(ancestor.info_name)
ancestor = ancestor.parent
command = ctx.command
# Will fetch command's metavar (the "[OPTIONS]" after the CLI name in
# "Usage:") and dig into subcommands to get subcommand_metavar:
# ("COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]...").
kw.metavars.update(command.collect_usage_pieces(ctx))
# Get subcommands and their aliases. Process in listed order for stable
# and predictable loading, which is important on lazy-loading.
if isinstance(command, click.Group):
for sub_id in command.list_commands(ctx):
subcommand = command.get_command(ctx, sub_id)
if not subcommand:
raise RuntimeError(f"Subcommand {sub_id!r} not found.")
kw.subcommands.add(sub_id)
kw.command_aliases.update(getattr(subcommand, "aliases", []))
# Keep reference to subcommand object for deprecated message
# collection below.
subcommand_objs.add(subcommand)
# Collect options, choices, metavars, envvars, defaults from current
# command parameters. User-defined help options (like -h, --help) are
# seeded into the options set.
options: set[str] = set(ctx.help_option_names)
# Static methods are qualified with the class name (not ``self``) so
# ``collect_keywords`` can be called on commands that don't inherit the
# mixin (used by ``cli_wrapper.patch_click`` for third-party CLIs).
_HelpColorsMixin._collect_params(
command.get_params(ctx),
ctx,
kw,
options,
)
# Collect option names and choices from parent groups. Subcommand
# docstrings often reference parent options in usage examples (like
# "myapp --table-format github sub").
parent_ctx = ctx.parent
while parent_ctx:
for param in parent_ctx.command.get_params(parent_ctx):
if isinstance(param, click.Option) and not param.hidden:
options.update(param.opts)
options.update(param.secondary_opts)
if isinstance(param.type, (click.Choice, ThemeChoice)):
_HelpColorsMixin._collect_choice_keywords(
param,
parent_ctx,
kw,
)
elif type_choices := getattr(param.type, "choices", None):
kw.choices.update(type_choices)
kw.choice_metavars.add(param.make_metavar(ctx=parent_ctx))
parent_ctx = parent_ctx.parent
# Split options into short and long by length heuristic. Short options
# are no longer than 2 characters like "-D", "/d", "/?", "+w", "-w".
# XXX We cannot reuse the _short_opts and _long_opts attributes from
# Click's parser because their values are not passed when the context
# is updated. So we rely on simple heuristics to guess the category.
for name in options:
if len(name) <= 2:
kw.short_options.add(name)
else:
kw.long_options.add(name)
# Merge consumer-provided extra keywords. Uses ``getattr`` so the
# method works on commands that don't inherit the mixin.
extra_kw = getattr(self, "extra_keywords", None)
if extra_kw is not None:
kw.merge(extra_kw)
# Note: excluded_keywords is NOT applied here. It is applied later
# in highlight_extra_keywords(), after choice metavars have been
# placeholdered, so that exclusions only affect cross-ref passes.
return kw
@staticmethod
def _collect_choice_keywords(
param: click.Parameter,
ctx: click.Context,
kw: HelpKeywords,
) -> None:
"""Collect choice keywords from a ``click.Choice`` parameter.
When a custom metavar (like ``LEVEL``) replaces the standard
``[choice1|choice2]`` rendering, original-case choice strings are
collected to match developer-written prose (such as "Either CRITICAL,
ERROR, ...") without producing false-positive highlights for common
English words like "error" and "info".
"""
assert isinstance(param.type, (click.Choice, ThemeChoice))
if isinstance(param, click.Option) and param.metavar:
# Custom metavar hides the normalized choice list. Collect
# original-case values. This is the first step of Click's own
# ``normalize_choice()`` before case folding is applied.
kw.choices.update(
c.name if isinstance(c, Enum) else str(c) for c in param.type.choices
)
else:
# Standard metavar: collect the normalized forms that
# match what Click renders in ``[choice1|choice2]``.
kw.choices.update(
param.type.normalize_choice(c, ctx) for c in param.type.choices
)
# Also collect the rendered metavar string (like
# ``[json|xml|csv]``) so it can be styled and placeholdered
# before cross-ref highlighting. This protects choices that
# appear in ``excluded_keywords`` from losing their
# highlight inside their own metavar.
kw.choice_metavars.add(param.make_metavar(ctx=ctx))
@staticmethod
def _collect_params(
params: list[click.Parameter],
ctx: click.Context,
kw: HelpKeywords,
options: set[str],
) -> None:
"""Extract keywords from a list of parameters into ``kw`` and ``options``."""
for param in params:
# Ignore hidden options that are not meant to be displayed.
if isinstance(param, click.Option) and param.hidden:
continue
# Only collect option names from actual Option parameters, not from
# Arguments. An Argument's opts contains the bare parameter name
# (like "keys") which would pollute the option keywords and
# interfere with highlighting of real options like "--list-keys".
if isinstance(param, click.Option):
options.update(param.opts)
options.update(param.secondary_opts)
elif isinstance(param, click.Argument):
# Collect argument metavars (like "MY_ARG") as a distinct
# category from option metavars.
kw.arguments.add(param.make_metavar(ctx=ctx))
# Only Choice and DateTime types produce their own structured
# metavar (with delimiters like brackets and pipes). All other
# types fall back to a plain uppercased name (like TEXT, INTEGER).
if isinstance(param.type, (click.Choice, ThemeChoice)):
_HelpColorsMixin._collect_choice_keywords(param, ctx, kw)
elif isinstance(param.type, click.DateTime):
# Highlight each datetime format string as a choice.
kw.choices.update(param.type.formats)
elif type_choices := getattr(param.type, "choices", None):
# Duck-typed choice-like ``click.ParamType`` (such as
# :class:`click_extra.types.MultiChoice` and its subclasses):
# each accepted value is worth highlighting individually, and
# the rendered ``[a,b,c]`` metavar protects the brackets +
# separators from later passes. ``click.Choice`` subclasses are
# already handled by the branch above.
kw.choices.update(type_choices)
kw.choice_metavars.add(param.make_metavar(ctx=ctx))
elif not isinstance(param, click.Argument):
# Argument metavars are collected in the arguments set.
kw.metavars.add(param.make_metavar(ctx=ctx))
# A user-provided metavar (like ``metavar="LEVEL"``) is always
# worth highlighting, even for Choice/DateTime types.
if param.metavar and not isinstance(param, click.Argument):
kw.metavars.add(param.metavar)
if param.envvar:
if isinstance(param.envvar, str):
kw.envvars.add(param.envvar)
else:
kw.envvars.update(param.envvar)
if isinstance(param, click.Option):
default_string = param.get_help_extra(ctx).get("default")
if default_string:
kw.defaults.add(default_string)
def get_help(self, ctx: click.Context) -> str:
"""Replace default formatter by our own."""
ctx.formatter_class = HelpFormatter
return super().get_help(ctx) # type: ignore[no-any-return,misc]
@staticmethod
def _collect_excluded_keywords(ctx: click.Context) -> HelpKeywords | None:
"""Merge ``excluded_keywords`` from the current command and all ancestors.
Mirrors the parent-context traversal that collects parent choices in
:meth:`collect_keywords`. Returns a fresh :class:`HelpKeywords` so that
no command's original ``excluded_keywords`` is mutated.
"""
excluded: HelpKeywords | None = None
cmd_ctx: click.Context | None = ctx
while cmd_ctx:
cmd_excluded = getattr(cmd_ctx.command, "excluded_keywords", None)
if cmd_excluded is not None:
if excluded is None:
excluded = HelpKeywords()
excluded.merge(cmd_excluded)
cmd_ctx = cmd_ctx.parent
return excluded
def format_help(self, ctx: click.Context, formatter: HelpFormatter) -> None:
"""Feed our custom formatter instance with the keywords to highlight."""
formatter.keywords = self.collect_keywords(ctx)
formatter.excluded_keywords = self._collect_excluded_keywords(ctx)
super().format_help(ctx, formatter) # type: ignore[misc]
@lru_cache(maxsize=512)
def _escape_for_help_screen(text: str) -> str:
"""Prepares a string to be used in a regular expression for matches in help screen.
Applies `re.escape <https://docs.python.org/3/library/re.html#re.escape>`_, then
accounts for long strings being wrapped on multiple lines and padded with spaces to
fit the columnar layout.
It allows for:
- additional number of optional blank characters (line-returns, spaces, tabs, ...)
after a dash, as the help renderer is free to wrap strings after a dash.
- a space to be replaced by any number of blank characters.
"""
return re.escape(text).replace("-", "-\\s*").replace("\\ ", "\\s+")
[docs]
class HelpFormatter(cloup.HelpFormatter):
"""Extends Cloup's custom HelpFormatter to highlights options, choices, metavars and
default values.
This is being discussed for upstream integration at:
- https://github.com/janluke/cloup/issues/97
- https://github.com/click-contrib/click-help-colors/issues/17
- https://github.com/janluke/cloup/issues/95
"""
theme: HelpTheme
def __init__(self, *args, **kwargs) -> None:
"""Forces theme to the active one for the current Click context.
Also transform Cloup's standard ``HelpTheme`` to our own ``HelpTheme``.
Resolves the active theme via :func:`click_extra.theme.get_current_theme`,
which reads the per-invocation pick from the Click context (set by
:class:`~click_extra.theme.ThemeOption`) and falls back to the module-level
default when no context is active.
"""
active_theme = _theme.get_current_theme()
theme = kwargs.get("theme", active_theme)
if not isinstance(theme, HelpTheme):
theme = active_theme.with_(**theme._asdict())
kwargs["theme"] = theme
super().__init__(*args, **kwargs)
[docs]
def write_usage(
self,
prog: str,
args: str = "",
prefix: str | None = None,
) -> None:
"""ANSI-aware override of ``cloup.HelpFormatter.write_usage``.
On Click ``8.3.x``, ``click.formatting.wrap_text`` measures line length
with raw :func:`len`, counting every byte of the ANSI escape sequences
embedded in ``initial_indent`` (the styled ``Usage:`` heading +
invoked-command name). With 24-bit RGB themes (like Solarized Dark,
Dracula, Nord, Monokai), each styled token carries 17+ extra
bytes of escape, which inflates the measured line beyond the width
budget and causes premature wraps mid-token: ``[OPTIONS\\n ]``.
Cloup styles ``prefix`` and ``prog`` then delegates to click's
:meth:`HelpFormatter.write_usage`, inheriting the bug. This
override re-applies the same styling, then bypasses ``wrap_text``
whenever the visible content fits on a single line: the common case
for short usage strings where wrapping is unnecessary. Lines that
genuinely overflow the visible width fall back to click's
implementation: the wrap point may still be sub-optimal but the
output stays syntactically valid.
.. note::
Click ``8.4.0`` (PR `pallets/click#3420
<https://github.com/pallets/click/pull/3420>`_) made
``click.formatting.TextWrapper`` ANSI-aware by counting
visible width instead of raw bytes, so this override is a no-op
fast path on Click ``>= 8.4.0`` and only fixes wrapping on the
Click ``8.3.x`` releases click-extra still supports.
.. todo:: Drop this override once the minimum supported Click rises to
``8.4.0`` (which includes ``pallets/click#3420``). The
``term_len``-based visible-width check below becomes redundant
once Click's own wrapper counts visible width.
"""
if prefix is None:
prefix = "Usage:"
styled_prefix = self.theme.heading(prefix) + " "
styled_prog = self.theme.invoked_command(prog)
usage_prefix = f"{styled_prefix:>{self.current_indent}}{styled_prog} "
text_width = self.width - self.current_indent
visible_width = term_len(usage_prefix) + term_len(args)
if visible_width <= text_width:
# Fits on one visible line: skip click's wrap_text, which would
# count the ANSI escape bytes toward line length and split
# mid-token for 24-bit RGB themes.
self.write(f"{usage_prefix}{args}\n")
return
# Visibly too wide for one line. Fall back to click's parent
# implementation for multi-line wrapping. Bypass cloup's wrapper to
# avoid double-styling ``prefix`` and ``prog``.
click.formatting.HelpFormatter.write_usage(
self,
styled_prog,
args,
styled_prefix,
)
keywords: HelpKeywords = HelpKeywords()
excluded_keywords: HelpKeywords | None = None
#: Matches range expressions like ``0<=x<=9``, ``x>=1024``, ``0<=x<100``.
#:
#: Bounds use ``[^\]\s]+`` (not ``\S+``) so a bound can't absorb the closing
#: ``]`` of its own field: with ``\S+``, ``[x>=1]`` matches ``1]`` and the
#: enclosing bracket regex then runs on to the next ``]`` on screen. Keep this
#: exclusion in sync with the range branch embedded in ``_bracket_re``.
_range_re: ClassVar[re.Pattern] = re.compile(
r"(?:[^\]\s]+(?:<|<=))?x(?:<|<=|>|>=)[^\]\s]+"
)
_bracket_re: ClassVar[re.Pattern] = re.compile(
r"( )" # 2 spaces (column or description spacing).
r"\[" # Opening bracket.
r"(" # Capture the bracket content.
r"(?:env\s+var:|default:|required" # Must start with a recognized label
r"|(?:[^\]\s]+(?:<|<=))?x(?:<|<=|>|>=)[^\]\s]+)" # or a range (see _range_re).
r"[^\]]*" # Followed by any non-] characters.
r")"
r"\]", # Closing bracket.
re.DOTALL,
)
_sep_re: ClassVar[re.Pattern] = re.compile(r";\s+")
_envvar_re: ClassVar[re.Pattern] = re.compile(r"(env\s+var:\s+)(.*)", re.DOTALL)
_default_re: ClassVar[re.Pattern] = re.compile(r"(default:\s+)(.*)", re.DOTALL)
#: Matches ``(DEPRECATED)`` and ``(DEPRECATED: reason)`` markers, regardless
#: of casing. The canonical upstream format is produced by Click's shared
#: ``_format_deprecated_label`` helper; the case-insensitive flag also
#: catches manually-written variants in custom help strings.
_deprecated_re: ClassVar[re.Pattern] = re.compile(
r"\(deprecated(?::\s[^)]+)?\)",
re.IGNORECASE,
)
def _bracket_or(self, slot_name: str) -> IStyle:
"""Return ``theme.<slot_name>`` or fall back to ``theme.bracket``.
When a theme leaves an inner bracket-field slot (``envvar``,
``default``, ``required``, ``range_label``) at
:func:`identity <cloup._util.identity>`, value tokens inside the
bracket block default to the ``bracket`` styling rather than
rendering plain. This lets a theme set only ``bracket`` and get a
uniformly dim bracket field for free; richer themes layer specific
styles on top by setting the inner slots.
"""
slot: IStyle = getattr(self.theme, slot_name)
if slot is identity:
return self.theme.bracket
return slot
def _style_bracket_fields(self, match: re.Match) -> str:
"""Style a trailing ``[env var: ...; default: ...; ...]`` block.
Parses the bracket content by splitting on ``;`` separators and
matching each field by its label prefix. Applied post-wrapping because
Click's text wrapper splits lines after ``get_help_record()`` returns,
which would break pre-styled ANSI codes.
Inner-slot fallback: when a theme leaves ``envvar`` / ``default`` /
``required`` / ``range_label`` at :func:`identity <cloup._util.identity>`,
the value token inherits the ``bracket`` styling via
:py:meth:`_bracket_or`. The bracket slot acts as the structural
default for the whole field; the other four slots override
piecemeal.
"""
prefix = match.group(1)
content = match.group(2)
# Split on semicolons, keeping the separators.
parts = re.split(r"(;\s+)", content)
styled: list[str] = []
for part in parts:
# Separator between fields.
if self._sep_re.fullmatch(part):
styled.append(self.theme.bracket(part))
# Environment variable field.
elif m := self._envvar_re.match(part):
styled.append(
self.theme.bracket(m.group(1))
+ self._bracket_or("envvar")(m.group(2))
)
# Default value field.
elif m := self._default_re.match(part):
styled.append(
self.theme.bracket(m.group(1))
+ self._bracket_or("default")(m.group(2))
)
# Required label.
elif part == "required":
styled.append(self._bracket_or("required")(part))
# Range expression.
elif self._range_re.fullmatch(part):
styled.append(self._bracket_or("range_label")(part))
# Fallback: style as generic bracket content.
else:
styled.append(self.theme.bracket(part))
return ( # type: ignore[no-any-return]
prefix + self.theme.bracket("[") + "".join(styled) + self.theme.bracket("]")
)
def _style_choice_metavar(self, metavar: str, choices: set[str]) -> str | None:
"""Style individual choices inside a choice metavar string.
Takes a rendered metavar like ``[json|xml|csv]`` (Click ``Choice``-style)
or ``[id,spec,value]`` (Click Extra ``MultiChoice``-style) and returns a
styled version where each known choice is wrapped with ``theme.choice``.
A part that is not a known choice is a type placeholder (like the
``INTEGER`` in a hybrid ``[auto|max|INTEGER]`` metavar) and is styled
with ``theme.metavar`` instead. Returns ``None`` if ``metavar`` does not
look like a choice list.
"""
# Strip the surrounding brackets.
if not (metavar.startswith("[") and metavar.endswith("]")):
return None
inner = metavar[1:-1]
# Detect the separator from the metavar itself: pipe for pick-one
# ``click.Choice``, comma for multi-pick ``MultiChoice``.
sep = "|" if "|" in inner else ","
parts = inner.split(sep)
styled_parts = [
self.theme.choice(part) if part in choices else self.theme.metavar(part)
for part in parts
]
return "[" + sep.join(styled_parts) + "]"
@staticmethod
def _add_placeholder(styled: str, store: dict[str, str]) -> str:
"""Register a styled fragment as a null-byte placeholder.
Returns the placeholder key. Used to protect already-styled regions
from subsequent regex passes.
"""
key = f"\x00B{len(store)}\x00"
store[key] = styled
return key
[docs]
def highlight_extra_keywords(self, help_text: str) -> str:
"""Highlight extra keywords in help screens based on the theme.
Uses the ``highlight()`` function for all keyword categories. Each
category is processed as a batch of regex patterns with a single styling
function, which handles overlapping matches and prevents double-styling.
"""
kw = self.keywords
# Highlight deprecated messages. Uses a case-insensitive regex to catch
# both Click-native "(DEPRECATED)" markers and manually-added variants
# like "(Deprecated)" in help strings.
help_text = highlight(help_text, [self._deprecated_re], self.theme.deprecated)
# Highlight subcommand names. Requires 2-space indentation as a
# leading boundary.
if kw.subcommands:
help_text = highlight(
help_text,
(
re.compile(rf"(?<= ){re.escape(name)}(?=\s)")
for name in sorted(kw.subcommands, key=len, reverse=True)
),
self.theme.subcommand,
)
# Highlight command aliases inside parenthetical groups like
# "(lock, freeze, snapshot)". Aliases are preceded by "(" or ", "
# and followed by "," or ")".
if kw.command_aliases:
help_text = highlight(
help_text,
(
re.compile(rf"(?<=[(, ]){re.escape(name)}(?=[,)])")
for name in sorted(kw.command_aliases, key=len, reverse=True)
),
self.theme.subcommand,
)
# Style trailing bracket fields [env var: ...; default: ...; ...].
# This must happen post-wrapping because Click's text wrapper splits
# lines after get_help_record() returns, which would break pre-styled
# ANSI codes.
#
# To prevent cross-reference highlighting from restyling keywords that
# appear inside bracket field content (such as a choice value like
# "outline" within a default value "rounded-outline"), we replace each
# styled bracket field with a null-byte placeholder, run all cross-ref
# passes on the placeholder text, then restore the styled fields.
bracket_placeholders: dict[str, str] = {}
def _bracket_to_placeholder(match: re.Match) -> str:
return self._add_placeholder(
self._style_bracket_fields(match), bracket_placeholders
)
help_text = self._bracket_re.sub(_bracket_to_placeholder, help_text)
# Style and placeholder choice metavars (like ``[json|xml|csv]``)
# before applying excluded_keywords and running cross-ref passes.
# This ensures that choices excluded from cross-ref highlighting
# (like "version") are still highlighted inside their own metavar.
for metavar_str in kw.choice_metavars:
styled = self._style_choice_metavar(metavar_str, kw.choices)
if styled is None:
continue
pattern = re.compile(_escape_for_help_screen(metavar_str))
help_text = pattern.sub(
lambda m, s=styled: self._add_placeholder(s, bracket_placeholders), # type: ignore[misc]
help_text,
)
# Apply excluded_keywords after metavar placeholdering so that
# exclusions only affect the cross-ref passes below.
if self.excluded_keywords is not None:
kw.subtract(self.excluded_keywords)
# The remaining passes search free-form text (descriptions, docstrings)
# for option names, choices, arguments, metavars and CLI names.
# Cross-reference highlighting can be disabled via the theme to avoid
# over-interpretation in help text that references external identifiers.
if self.theme.cross_ref_highlight:
# Highlight CLI names and commands.
if kw.cli_names:
help_text = highlight(
help_text,
(
re.compile(rf"(?<=\s){re.escape(name)}(?=\s)")
for name in sorted(kw.cli_names, key=len, reverse=True)
),
self.theme.invoked_command,
)
# Highlight options (long and short combined). Per-keyword lookbehind
# excludes the option's own leading symbol to prevent matching repeated
# prefixes (for example, "---debug" should not match "--debug").
all_options = sorted(
kw.long_options | kw.short_options, key=len, reverse=True
)
if all_options:
help_text = highlight(
help_text,
(
re.compile(
rf"(?<=[^\w{re.escape(kw[0])}])"
rf"{_escape_for_help_screen(kw)}"
rf"(?=[^\w\-])"
)
for kw in all_options
),
self.theme.option,
)
# Highlight other keywords, which are expected to be separated by
# any character but word characters.
for keywords, style_func in (
# Arguments before metavars: argument names like MY_ARG are a
# subset of metavars, so highlighting them first with a distinct
# style takes priority.
(kw.arguments, self.theme.argument),
# Choices are already featured in metavars, so we process them
# before metavars to avoid double-highlighting.
(kw.choices, self.theme.choice),
(kw.metavars, self.theme.metavar),
):
if keywords:
# Transform keywords into regex patterns.
patterns = (
# Negative lookbehind rejects matches preceded by:
# - a word character (\w),
# - a dot: "pyproject.toml" (\.),
# - a hyphen: "rounded-outline" (\-),
# - a slash: "https://github.com" (\/),
# - an exclamation mark: "[!WARNING]" (!),
# - an ANSI escape: already-styled text (\x1b).
# Negative lookahead rejects matches followed by:
# - a word character (\w),
# - a hyphen: "github-actions" (\-).
re.compile(
rf"(?<![\w\.\x1b\-/!])"
rf"{_escape_for_help_screen(keyword)}"
rf"(?![\w\-])"
)
for keyword in sorted(keywords, reverse=True)
)
help_text = highlight(
content=help_text,
patterns=patterns,
styling_func=style_func,
)
# Restore styled bracket fields.
for key, styled in bracket_placeholders.items():
help_text = help_text.replace(key, styled)
return help_text
[docs]
def getvalue(self) -> str:
"""Wrap original `Click.HelpFormatter.getvalue()` to force extra-colorization on
rendering."""
help_text = super().getvalue()
return self.highlight_extra_keywords(help_text)
[docs]
def highlight(
content: str,
patterns: Iterable[str | re.Pattern] | str | re.Pattern,
styling_func: Callable,
ignore_case: bool = False,
) -> str:
"""Highlights parts of the ``content`` that matches ``patterns``.
Takes care of overlapping parts within the ``content``, so that the styling function
is applied only once to each contiguous range of matching characters.
.. todo::
Support case-foldeing, so we can have the ``Straße`` string matching the
``Strasse`` content.
This could be tricky as it messes with string length and characters index, which
our logic relies on.
.. danger::
Roundtrip through lower-casing/upper-casing is a can of worms, because some
characters change length when their case is changed:
- `Unicode roundtrip-unsafe characters
<https://gist.github.com/rendello/4d8266b7c52bf0e98eab2073b38829d9>`_
- `Unicode codepoints expanding or contracting on case changes
<https://gist.github.com/rendello/d37552507a389656e248f3255a618127>`_
"""
# Normalize input to a set of patterns.
if isinstance(patterns, (str, re.Pattern)):
pattern_list = {patterns}
else:
pattern_list = set(patterns)
# Set of character indices flagged for highlighting.
matched_indices: set[int] = set()
# Normalize patterns into regular expressions and find matches.
for pattern in pattern_list:
# Pattern is already a compiled regex.
if isinstance(pattern, re.Pattern):
regex = pattern
# Treat as literal string and escape for regex.
elif isinstance(pattern, str):
regex = re.compile(re.escape(pattern), re.IGNORECASE if ignore_case else 0)
else:
raise TypeError(f"Unsupported pattern type: {pattern!r}")
# Force IGNORECASE flag if not already compiled with it.
if ignore_case and not (regex.flags & re.IGNORECASE):
regex = re.compile(regex.pattern, regex.flags | re.IGNORECASE)
# Find all matches, including overlapping ones. Because re.search()
# returns only the first match, we skip ahead one character past the
# start of each match to find overlapping occurrences.
start_pos = 0
while start_pos < len(content):
match = regex.search(content, start_pos)
if not match:
break
start_idx = match.start()
end_idx = match.end()
# Skip zero-length matches (like those from pure lookbehind/lookahead).
if start_idx >= end_idx:
start_pos = start_idx + 1
continue
matched_indices.update(range(start_idx, end_idx))
start_pos = start_idx + 1
if not matched_indices:
return content
# Build the styled string in one pass: contiguous runs of matched or
# unmatched characters are grouped, and only matched runs are styled.
parts: list[str] = []
in_match = 0 in matched_indices
run_start = 0
for i in range(1, len(content) + 1):
current_in_match = i in matched_indices if i < len(content) else not in_match
if current_in_match != in_match:
segment = content[run_start:i]
parts.append(styling_func(segment) if in_match else segment)
run_start = i
in_match = current_in_match
# Flush the last run.
if run_start < len(content):
segment = content[run_start:]
parts.append(styling_func(segment) if in_match else segment)
return "".join(parts)