diff --git a/AGENTS.md b/AGENTS.md index 4e31e0e6..0806bbc8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,9 +126,13 @@ Example format: ### Imports +**For standard library modules:** - Use namespace imports: `import enum` instead of `from enum import Enum` - For typing, use `import typing as t` and access via namespace: `t.NamedTuple`, etc. -- Use `from __future__ import annotations` at the top of all Python files + +**For third-party packages:** Use idiomatic import styles for each library (e.g., `from pygments.token import Token` is fine). + +**Always:** Use `from __future__ import annotations` at the top of all Python files. ### Docstrings diff --git a/docs/_ext/__init__.py b/docs/_ext/__init__.py new file mode 100644 index 00000000..7a9e6898 --- /dev/null +++ b/docs/_ext/__init__.py @@ -0,0 +1,3 @@ +"""Sphinx extensions for vcspull documentation.""" + +from __future__ import annotations diff --git a/docs/_ext/cli_usage_lexer.py b/docs/_ext/cli_usage_lexer.py new file mode 100644 index 00000000..40170e31 --- /dev/null +++ b/docs/_ext/cli_usage_lexer.py @@ -0,0 +1,115 @@ +"""Pygments lexer for CLI usage/help output. + +This module provides a custom Pygments lexer for highlighting command-line +usage text typically generated by argparse, getopt, or similar libraries. +""" + +from __future__ import annotations + +from pygments.lexer import RegexLexer, bygroups, include +from pygments.token import Generic, Name, Operator, Punctuation, Text, Whitespace + + +class CLIUsageLexer(RegexLexer): + """Lexer for CLI usage/help text (argparse, etc.). + + Highlights usage patterns including options, arguments, and meta-variables. + + Examples + -------- + >>> from pygments.token import Token + >>> lexer = CLIUsageLexer() + >>> tokens = list(lexer.get_tokens("usage: cmd [-h]")) + >>> tokens[0] + (Token.Generic.Heading, 'usage:') + >>> tokens[2] + (Token.Name.Label, 'cmd') + """ + + name = "CLI Usage" + aliases = ["cli-usage", "usage"] # noqa: RUF012 + filenames: list[str] = [] # noqa: RUF012 + mimetypes = ["text/x-cli-usage"] # noqa: RUF012 + + tokens = { # noqa: RUF012 + "root": [ + # "usage:" at start of line + (r"^(usage:)(\s+)", bygroups(Generic.Heading, Whitespace)), # type: ignore[no-untyped-call] + # Continuation lines (leading whitespace for wrapped usage) + (r"^(\s+)(?=\S)", Whitespace), + include("inline"), + ], + "inline": [ + # Whitespace + (r"\s+", Whitespace), + # Long options with = value (e.g., --log-level=VALUE) + ( + r"(--[a-zA-Z0-9][-a-zA-Z0-9]*)(=)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9]*)", + bygroups(Name.Tag, Operator, Name.Variable), # type: ignore[no-untyped-call] + ), + # Long options standalone + (r"--[a-zA-Z0-9][-a-zA-Z0-9]*", Name.Tag), + # Short options with space-separated value (e.g., -S socket-path) + ( + r"(-[a-zA-Z0-9])(\s+)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9]*)", + bygroups(Name.Attribute, Whitespace, Name.Variable), # type: ignore[no-untyped-call] + ), + # Short options standalone + (r"-[a-zA-Z0-9]", Name.Attribute), + # UPPERCASE meta-variables (COMMAND, FILE, PATH) + (r"\b[A-Z][A-Z0-9_]+\b", Name.Constant), + # Opening bracket - enter optional state + (r"\[", Punctuation, "optional"), + # Closing bracket (fallback for unmatched) + (r"\]", Punctuation), + # Choice separator (pipe) + (r"\|", Operator), + # Parentheses for grouping + (r"[()]", Punctuation), + # Positional/command names (lowercase with dashes) + (r"\b[a-z][-a-z0-9]*\b", Name.Label), + # Catch-all for any other text + (r"[^\s\[\]|()]+", Text), + ], + "optional": [ + # Nested optional bracket + (r"\[", Punctuation, "#push"), + # End optional + (r"\]", Punctuation, "#pop"), + # Contents use inline rules + include("inline"), + ], + } + + +def tokenize_usage(text: str) -> list[tuple[str, str]]: + """Tokenize usage text and return list of (token_type, value) tuples. + + Parameters + ---------- + text : str + CLI usage text to tokenize. + + Returns + ------- + list[tuple[str, str]] + List of (token_type_name, text_value) tuples. + + Examples + -------- + >>> result = tokenize_usage("usage: cmd [-h]") + >>> result[0] + ('Token.Generic.Heading', 'usage:') + >>> result[2] + ('Token.Name.Label', 'cmd') + >>> result[4] + ('Token.Punctuation', '[') + >>> result[5] + ('Token.Name.Attribute', '-h') + >>> result[6] + ('Token.Punctuation', ']') + """ + lexer = CLIUsageLexer() + return [ + (str(tok_type), tok_value) for tok_type, tok_value in lexer.get_tokens(text) + ] diff --git a/docs/_ext/pretty_argparse.py b/docs/_ext/pretty_argparse.py new file mode 100644 index 00000000..5815e116 --- /dev/null +++ b/docs/_ext/pretty_argparse.py @@ -0,0 +1,776 @@ +"""Enhanced sphinx-argparse output formatting. + +This extension wraps sphinx-argparse's directive to: +1. Remove ANSI escape codes that may be present when FORCE_COLOR is set +2. Convert "examples:" definition lists into proper documentation sections +3. Nest category-specific examples under a parent Examples section +4. Apply cli-usage syntax highlighting to usage blocks +5. Reorder sections so usage appears before examples +""" + +from __future__ import annotations + +import re +import typing as t + +from docutils import nodes +from sphinxarg.ext import ArgParseDirective + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + +_ANSI_RE = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") + +# Match asterisks that would trigger RST emphasis (preceded by delimiter like +# - or space) but NOT asterisks already escaped or in code/literal contexts +_RST_EMPHASIS_RE = re.compile(r"(?<=[^\s\\])-\*(?=[^\s*]|$)") + + +def escape_rst_emphasis(text: str) -> str: + r"""Escape asterisks that would trigger RST inline emphasis. + + In reStructuredText, ``*text*`` creates emphasis. When argparse help text + contains patterns like ``django-*``, the dash (a delimiter character) followed + by asterisk triggers emphasis detection, causing warnings like: + "Inline emphasis start-string without end-string." + + This function escapes such asterisks with a backslash so they render literally. + + Parameters + ---------- + text : str + Text potentially containing problematic asterisks. + + Returns + ------- + str + Text with asterisks escaped where needed. + + Examples + -------- + >>> escape_rst_emphasis('vcspull list "django-*"') + 'vcspull list "django-\\*"' + >>> escape_rst_emphasis("plain text") + 'plain text' + >>> escape_rst_emphasis("already \\* escaped") + 'already \\* escaped' + >>> escape_rst_emphasis("*emphasis* is ok") + '*emphasis* is ok' + """ + return _RST_EMPHASIS_RE.sub(r"-\*", text) + + +def strip_ansi(text: str) -> str: + r"""Remove ANSI escape codes from text. + + Parameters + ---------- + text : str + Text potentially containing ANSI codes. + + Returns + ------- + str + Text with ANSI codes removed. + + Examples + -------- + >>> strip_ansi("plain text") + 'plain text' + >>> strip_ansi("\033[32mgreen\033[0m") + 'green' + >>> strip_ansi("\033[1;34mbold blue\033[0m") + 'bold blue' + """ + return _ANSI_RE.sub("", text) + + +def is_examples_term(term_text: str) -> bool: + """Check if a definition term is an examples header. + + Parameters + ---------- + term_text : str + The text content of a definition term. + + Returns + ------- + bool + True if this is an examples header. + + Examples + -------- + >>> is_examples_term("examples:") + True + >>> is_examples_term("Machine-readable output examples:") + True + >>> is_examples_term("Usage:") + False + """ + return term_text.lower().rstrip(":").endswith("examples") + + +def is_base_examples_term(term_text: str) -> bool: + """Check if a definition term is a base "examples:" header (no prefix). + + Parameters + ---------- + term_text : str + The text content of a definition term. + + Returns + ------- + bool + True if this is just "examples:" with no category prefix. + + Examples + -------- + >>> is_base_examples_term("examples:") + True + >>> is_base_examples_term("Examples") + True + >>> is_base_examples_term("Field-scoped examples:") + False + """ + return term_text.lower().rstrip(":").strip() == "examples" + + +def make_section_id( + term_text: str, + counter: int = 0, + *, + is_subsection: bool = False, + page_prefix: str = "", +) -> str: + """Generate a section ID from an examples term. + + Parameters + ---------- + term_text : str + The examples term text (e.g., "Machine-readable output: examples:") + counter : int + Counter for uniqueness if multiple examples sections exist. + is_subsection : bool + If True, omit "-examples" suffix for cleaner nested IDs. + page_prefix : str + Optional prefix from the page name (e.g., "sync", "add") to ensure + uniqueness across different documentation pages. + + Returns + ------- + str + A normalized section ID. + + Examples + -------- + >>> make_section_id("examples:") + 'examples' + >>> make_section_id("examples:", page_prefix="sync") + 'sync-examples' + >>> make_section_id("Machine-readable output examples:") + 'machine-readable-output-examples' + >>> make_section_id("Field-scoped examples:", is_subsection=True) + 'field-scoped' + >>> make_section_id("examples:", counter=1) + 'examples-1' + """ + # Extract prefix before "examples" (e.g., "Machine-readable output") + lower_text = term_text.lower().rstrip(":") + if "examples" in lower_text: + prefix = lower_text.rsplit("examples", 1)[0].strip() + # Remove trailing colon from prefix (handles ": examples" pattern) + prefix = prefix.rstrip(":").strip() + if prefix: + normalized_prefix = prefix.replace(" ", "-") + # Subsections don't need "-examples" suffix + if is_subsection: + section_id = normalized_prefix + else: + section_id = f"{normalized_prefix}-examples" + else: + # Plain "examples" - add page prefix if provided for uniqueness + section_id = f"{page_prefix}-examples" if page_prefix else "examples" + else: + section_id = "examples" + + # Add counter suffix for uniqueness + if counter > 0: + section_id = f"{section_id}-{counter}" + + return section_id + + +def make_section_title(term_text: str, *, is_subsection: bool = False) -> str: + """Generate a section title from an examples term. + + Parameters + ---------- + term_text : str + The examples term text (e.g., "Machine-readable output: examples:") + is_subsection : bool + If True, omit "Examples" suffix for cleaner nested titles. + + Returns + ------- + str + A proper title (e.g., "Machine-readable Output Examples" or just + "Machine-Readable Output" if is_subsection=True). + + Examples + -------- + >>> make_section_title("examples:") + 'Examples' + >>> make_section_title("Machine-readable output examples:") + 'Machine-Readable Output Examples' + >>> make_section_title("Field-scoped examples:", is_subsection=True) + 'Field-Scoped' + """ + # Remove trailing colon and normalize + text = term_text.rstrip(":").strip() + # Handle base "examples:" case + if text.lower() == "examples": + return "Examples" + + # Extract the prefix (category name) before "examples" + lower = text.lower() + if lower.endswith(": examples"): + prefix = text[: -len(": examples")] + elif lower.endswith(" examples"): + prefix = text[: -len(" examples")] + else: + prefix = text + + # Title case the prefix + titled_prefix = prefix.title() + + # For subsections, just use the prefix (cleaner nested titles) + if is_subsection: + return titled_prefix + + # For top-level sections, append "Examples" + return f"{titled_prefix} Examples" + + +def _create_example_section( + term_text: str, + def_node: nodes.definition, + *, + is_subsection: bool = False, + page_prefix: str = "", +) -> nodes.section: + """Create a section node for an examples item. + + Parameters + ---------- + term_text : str + The examples term text. + def_node : nodes.definition + The definition node containing example commands. + is_subsection : bool + If True, create a subsection with simpler title/id. + page_prefix : str + Optional prefix from the page name for unique section IDs. + + Returns + ------- + nodes.section + A section node with title and code blocks. + """ + section_id = make_section_id( + term_text, is_subsection=is_subsection, page_prefix=page_prefix + ) + section_title = make_section_title(term_text, is_subsection=is_subsection) + + section = nodes.section() + section["ids"] = [section_id] + section["names"] = [nodes.fully_normalize_name(section_title)] + + title = nodes.title(text=section_title) + section += title + + # Extract commands from definition and create separate code blocks + def_text = strip_ansi(def_node.astext()) + for line in def_text.split("\n"): + line = line.strip() + if line: + code_block = nodes.literal_block( + text=f"$ {line}", + classes=["highlight-console"], + ) + code_block["language"] = "console" + section += code_block + + return section + + +def transform_definition_list( + dl_node: nodes.definition_list, *, page_prefix: str = "" +) -> list[nodes.Node]: + """Transform a definition list, converting examples items to code blocks. + + If there's a base "examples:" item followed by category-specific examples + (e.g., "Field-scoped: examples:"), the categories are nested under the + parent Examples section for cleaner ToC structure. + + Parameters + ---------- + dl_node : nodes.definition_list + A definition list node. + page_prefix : str + Optional prefix from the page name for unique section IDs. + + Returns + ------- + list[nodes.Node] + Transformed nodes - code blocks for examples, original for others. + """ + # First pass: collect examples and non-examples items separately + example_items: list[tuple[str, nodes.definition]] = [] # (term_text, def_node) + non_example_items: list[nodes.Node] = [] + base_examples_index: int | None = None + + for item in dl_node.children: + if not isinstance(item, nodes.definition_list_item): + continue + + # Get the term and definition + term_node = None + def_node = None + for child in item.children: + if isinstance(child, nodes.term): + term_node = child + elif isinstance(child, nodes.definition): + def_node = child + + if term_node is None or def_node is None: + non_example_items.append(item) + continue + + term_text = strip_ansi(term_node.astext()) + + if is_examples_term(term_text): + if is_base_examples_term(term_text): + base_examples_index = len(example_items) + example_items.append((term_text, def_node)) + else: + non_example_items.append(item) + + # Build result nodes + result_nodes: list[nodes.Node] = [] + + # Flush non-example items first (if any appeared before examples) + if non_example_items: + new_dl = nodes.definition_list() + new_dl.extend(non_example_items) + result_nodes.append(new_dl) + + # Determine nesting strategy + # Nest if: there's a base "examples:" AND at least one other example category + should_nest = base_examples_index is not None and len(example_items) > 1 + + if should_nest and base_examples_index is not None: + # Create parent "Examples" section + base_term, base_def = example_items[base_examples_index] + parent_section = _create_example_section( + base_term, base_def, is_subsection=False, page_prefix=page_prefix + ) + + # Add other examples as nested subsections + for i, (term_text, def_node) in enumerate(example_items): + if i == base_examples_index: + continue # Skip the base (already used as parent) + subsection = _create_example_section( + term_text, def_node, is_subsection=True, page_prefix=page_prefix + ) + parent_section += subsection + + result_nodes.append(parent_section) + else: + # No nesting - create flat sections (backwards compatible) + for term_text, def_node in example_items: + section = _create_example_section( + term_text, def_node, is_subsection=False, page_prefix=page_prefix + ) + result_nodes.append(section) + + return result_nodes + + +def process_node( + node: nodes.Node, *, page_prefix: str = "" +) -> nodes.Node | list[nodes.Node]: + """Process a node: strip ANSI codes and transform examples. + + Parameters + ---------- + node : nodes.Node + A docutils node to process. + page_prefix : str + Optional prefix from the page name for unique section IDs. + + Returns + ------- + nodes.Node | list[nodes.Node] + The processed node(s). + """ + # Handle text nodes - strip ANSI + if isinstance(node, nodes.Text): + cleaned = strip_ansi(node.astext()) + if cleaned != node.astext(): + return nodes.Text(cleaned) + return node + + # Handle definition lists - transform examples + if isinstance(node, nodes.definition_list): + # Check if any items are examples + has_examples = False + for item in node.children: + if isinstance(item, nodes.definition_list_item): + for child in item.children: + if isinstance(child, nodes.term) and is_examples_term( + strip_ansi(child.astext()) + ): + has_examples = True + break + if has_examples: + break + + if has_examples: + return transform_definition_list(node, page_prefix=page_prefix) + + # Handle literal_block nodes - strip ANSI and apply usage highlighting + if isinstance(node, nodes.literal_block): + text = strip_ansi(node.astext()) + needs_update = text != node.astext() + + # Check if this is a usage block (starts with "usage:") + is_usage_block = text.lstrip().lower().startswith("usage:") + + if needs_update or is_usage_block: + new_block = nodes.literal_block(text=text) + # Preserve attributes + for attr in ("language", "classes"): + if attr in node: + new_block[attr] = node[attr] + # Apply cli-usage language to usage blocks + if is_usage_block: + new_block["language"] = "cli-usage" + return new_block + return node + + # Handle paragraph nodes - strip ANSI and lift sections out + if isinstance(node, nodes.paragraph): + # Process children and check if any become sections + processed_children: list[nodes.Node] = [] + changed = False + has_sections = False + + for child in node.children: + if isinstance(child, nodes.Text): + cleaned = strip_ansi(child.astext()) + if cleaned != child.astext(): + processed_children.append(nodes.Text(cleaned)) + changed = True + else: + processed_children.append(child) + else: + result = process_node(child, page_prefix=page_prefix) + if isinstance(result, list): + processed_children.extend(result) + changed = True + # Check if any results are sections + if any(isinstance(r, nodes.section) for r in result): + has_sections = True + elif result is not child: + processed_children.append(result) + changed = True + if isinstance(result, nodes.section): + has_sections = True + else: + processed_children.append(child) + + if not changed: + return node + + # If no sections, return a normal paragraph + if not has_sections: + new_para = nodes.paragraph() + new_para.extend(processed_children) + return new_para + + # Sections found - lift them out of the paragraph + # Return a list: [para_before, section1, section2, ..., para_after] + result_nodes: list[nodes.Node] = [] + current_para_children: list[nodes.Node] = [] + + for child in processed_children: + if isinstance(child, nodes.section): + # Flush current paragraph content + if current_para_children: + para = nodes.paragraph() + para.extend(current_para_children) + result_nodes.append(para) + current_para_children = [] + # Add section as a sibling + result_nodes.append(child) + else: + current_para_children.append(child) + + # Flush remaining paragraph content + if current_para_children: + para = nodes.paragraph() + para.extend(current_para_children) + result_nodes.append(para) + + return result_nodes + + # Recursively process children for other node types + if hasattr(node, "children"): + new_children: list[nodes.Node] = [] + children_changed = False + for child in node.children: + result = process_node(child, page_prefix=page_prefix) + if isinstance(result, list): + new_children.extend(result) + children_changed = True + elif result is not child: + new_children.append(result) + children_changed = True + else: + new_children.append(child) + if children_changed: + node.children = new_children + + return node + + +def _is_usage_block(node: nodes.Node) -> bool: + """Check if a node is a usage literal block. + + Parameters + ---------- + node : nodes.Node + A docutils node to check. + + Returns + ------- + bool + True if this is a usage block (literal_block starting with "usage:"). + + Examples + -------- + >>> from docutils import nodes + >>> _is_usage_block(nodes.literal_block(text="usage: cmd [-h]")) + True + >>> _is_usage_block(nodes.literal_block(text="Usage: vcspull sync")) + True + >>> _is_usage_block(nodes.literal_block(text=" usage: cmd")) + True + >>> _is_usage_block(nodes.literal_block(text="some other text")) + False + >>> _is_usage_block(nodes.paragraph(text="usage: cmd")) + False + >>> _is_usage_block(nodes.section()) + False + """ + if not isinstance(node, nodes.literal_block): + return False + text = node.astext() + return text.lstrip().lower().startswith("usage:") + + +def _is_examples_section(node: nodes.Node) -> bool: + """Check if a node is an examples section. + + Parameters + ---------- + node : nodes.Node + A docutils node to check. + + Returns + ------- + bool + True if this is an examples section (section with "examples" in its ID). + + Examples + -------- + >>> from docutils import nodes + >>> section = nodes.section() + >>> section["ids"] = ["examples"] + >>> _is_examples_section(section) + True + >>> section2 = nodes.section() + >>> section2["ids"] = ["machine-readable-output-examples"] + >>> _is_examples_section(section2) + True + >>> section3 = nodes.section() + >>> section3["ids"] = ["positional-arguments"] + >>> _is_examples_section(section3) + False + >>> _is_examples_section(nodes.paragraph()) + False + >>> _is_examples_section(nodes.literal_block(text="examples")) + False + """ + if not isinstance(node, nodes.section): + return False + ids: list[str] = node.get("ids", []) + return any("examples" in id_str.lower() for id_str in ids) + + +def _reorder_nodes(processed: list[nodes.Node]) -> list[nodes.Node]: + """Reorder nodes so usage blocks appear before examples sections. + + This ensures the CLI usage synopsis appears above examples in the + documentation, making it easier to understand command syntax before + seeing example invocations. + + Parameters + ---------- + processed : list[nodes.Node] + List of processed docutils nodes. + + Returns + ------- + list[nodes.Node] + Reordered nodes with usage before examples. + + Examples + -------- + >>> from docutils import nodes + + Create test nodes: + + >>> desc = nodes.paragraph(text="Description") + >>> examples = nodes.section() + >>> examples["ids"] = ["examples"] + >>> usage = nodes.literal_block(text="usage: cmd [-h]") + >>> args = nodes.section() + >>> args["ids"] = ["arguments"] + + When usage appears after examples, it gets moved before: + + >>> result = _reorder_nodes([desc, examples, usage, args]) + >>> [type(n).__name__ for n in result] + ['paragraph', 'literal_block', 'section', 'section'] + + When no examples exist, order is unchanged: + + >>> result = _reorder_nodes([desc, usage, args]) + >>> [type(n).__name__ for n in result] + ['paragraph', 'literal_block', 'section'] + + When usage already before examples, order is preserved: + + >>> result = _reorder_nodes([desc, usage, examples, args]) + >>> [type(n).__name__ for n in result] + ['paragraph', 'literal_block', 'section', 'section'] + + Empty list returns empty: + + >>> _reorder_nodes([]) + [] + """ + # First pass: check if there are any examples sections + has_examples = any(_is_examples_section(node) for node in processed) + if not has_examples: + # No examples, preserve original order + return processed + + usage_blocks: list[nodes.Node] = [] + examples_sections: list[nodes.Node] = [] + other_before_examples: list[nodes.Node] = [] + other_after_examples: list[nodes.Node] = [] + + seen_examples = False + for node in processed: + if _is_usage_block(node): + usage_blocks.append(node) + elif _is_examples_section(node): + examples_sections.append(node) + seen_examples = True + elif not seen_examples: + other_before_examples.append(node) + else: + other_after_examples.append(node) + + # Order: before_examples → usage → examples → after_examples + return ( + other_before_examples + usage_blocks + examples_sections + other_after_examples + ) + + +class CleanArgParseDirective(ArgParseDirective): # type: ignore[misc] + """ArgParse directive that strips ANSI codes and formats examples.""" + + def _nested_parse_paragraph(self, text: str) -> nodes.Node: + """Parse text as RST, escaping problematic characters first. + + Overrides the parent class to escape asterisks in patterns like + ``django-*`` that would otherwise trigger RST emphasis warnings. + """ + escaped_text = escape_rst_emphasis(text) + result: nodes.Node = super()._nested_parse_paragraph(escaped_text) + return result + + def run(self) -> list[nodes.Node]: + """Run the directive, clean output, format examples, and reorder.""" + result = super().run() + + # Extract page name for unique section IDs across different CLI pages + page_prefix = "" + if hasattr(self.state, "document"): + settings = self.state.document.settings + if hasattr(settings, "env") and hasattr(settings.env, "docname"): + # docname is like "cli/sync" - extract "sync" + docname = settings.env.docname + page_prefix = docname.split("/")[-1] + + processed: list[nodes.Node] = [] + for node in result: + processed_node = process_node(node, page_prefix=page_prefix) + if isinstance(processed_node, list): + processed.extend(processed_node) + else: + processed.append(processed_node) + + # Reorder: usage blocks before examples sections + return _reorder_nodes(processed) + + +def setup(app: Sphinx) -> dict[str, t.Any]: + """Register the clean argparse directive and CLI usage lexer. + + Parameters + ---------- + app : Sphinx + The Sphinx application object. + + Returns + ------- + dict + Extension metadata. + """ + # Override the default argparse directive + app.add_directive("argparse", CleanArgParseDirective, override=True) + + # Register CLI usage lexer for usage block highlighting + from cli_usage_lexer import CLIUsageLexer + + app.add_lexer("cli-usage", CLIUsageLexer) + + # Register vcspull output lexer for command output highlighting + from vcspull_output_lexer import ( # type: ignore[import-not-found] + VcspullOutputLexer, + ) + + app.add_lexer("vcspull-output", VcspullOutputLexer) + + # Register vcspull console lexer for session highlighting + from vcspull_console_lexer import ( # type: ignore[import-not-found] + VcspullConsoleLexer, + ) + + app.add_lexer("vcspull-console", VcspullConsoleLexer) + + return {"version": "2.0", "parallel_read_safe": True} diff --git a/docs/_ext/vcspull_console_lexer.py b/docs/_ext/vcspull_console_lexer.py new file mode 100644 index 00000000..9c10d987 --- /dev/null +++ b/docs/_ext/vcspull_console_lexer.py @@ -0,0 +1,119 @@ +"""Pygments lexer for vcspull CLI sessions (command + output). + +This module provides a custom Pygments lexer for highlighting vcspull command +sessions, combining shell command highlighting with semantic output highlighting. +""" + +from __future__ import annotations + +import re + +from pygments.lexer import Lexer, do_insertions, line_re # type: ignore[attr-defined] +from pygments.lexers.shell import BashLexer +from pygments.token import Generic, Text +from vcspull_output_lexer import ( # type: ignore[import-not-found] + VcspullOutputLexer, +) + + +class VcspullConsoleLexer(Lexer): + r"""Lexer for vcspull CLI sessions with semantic output highlighting. + + Extends BashSessionLexer pattern but delegates output lines to + VcspullOutputLexer for semantic coloring of vcspull command output. + + Examples + -------- + >>> from pygments.token import Token + >>> lexer = VcspullConsoleLexer() + >>> text = "$ vcspull list\\n• flask → ~/code/flask\\n" + >>> tokens = list(lexer.get_tokens(text)) + >>> any(t == Token.Generic.Prompt for t, v in tokens) + True + >>> any(t == Token.Name.Function for t, v in tokens) + True + """ + + name = "Vcspull Console" + aliases = ["vcspull-console"] # noqa: RUF012 + filenames: list[str] = [] # noqa: RUF012 + mimetypes = ["text/x-vcspull-console"] # noqa: RUF012 + + _venv = re.compile(r"^(\([^)]*\))(\s*)") + _ps1rgx = re.compile( + r"^((?:(?:\[.*?\])|(?:\(\S+\))?(?:| |sh\S*?|\w+\S+[@:]\S+(?:\s+\S+)" + r"?|\[\S+[@:][^\n]+\].+))\s*[$#%]\s*)(.*\n?)" + ) + _ps2 = "> " + + def get_tokens_unprocessed( # type: ignore[no-untyped-def] + self, + text: str, + ): + """Tokenize text with shell commands and vcspull output. + + Parameters + ---------- + text : str + The text to tokenize. + + Yields + ------ + tuple[int, TokenType, str] + Tuples of (index, token_type, value). + """ + innerlexer = BashLexer(**self.options) + outputlexer = VcspullOutputLexer(**self.options) + + pos = 0 + curcode = "" + insertions = [] + backslash_continuation = False + + for match in line_re.finditer(text): + line = match.group() + + venv_match = self._venv.match(line) + if venv_match: + venv = venv_match.group(1) + venv_whitespace = venv_match.group(2) + insertions.append( + (len(curcode), [(0, Generic.Prompt.VirtualEnv, venv)]) + ) + if venv_whitespace: + insertions.append((len(curcode), [(0, Text, venv_whitespace)])) + line = line[venv_match.end() :] + + m = self._ps1rgx.match(line) + if m: + if not insertions: + pos = match.start() + + insertions.append((len(curcode), [(0, Generic.Prompt, m.group(1))])) + curcode += m.group(2) + backslash_continuation = curcode.endswith("\\\n") + elif backslash_continuation: + if line.startswith(self._ps2): + insertions.append( + (len(curcode), [(0, Generic.Prompt, line[: len(self._ps2)])]) + ) + curcode += line[len(self._ps2) :] + else: + curcode += line + backslash_continuation = curcode.endswith("\\\n") + else: + if insertions: + toks = innerlexer.get_tokens_unprocessed(curcode) + for i, t, v in do_insertions(insertions, toks): + yield pos + i, t, v + # Use VcspullOutputLexer for output lines + for i, t, v in outputlexer.get_tokens_unprocessed(line): + yield match.start() + i, t, v + insertions = [] + curcode = "" + + if insertions: + for i, t, v in do_insertions( + insertions, innerlexer.get_tokens_unprocessed(curcode) + ): + yield pos + i, t, v diff --git a/docs/_ext/vcspull_output_lexer.py b/docs/_ext/vcspull_output_lexer.py new file mode 100644 index 00000000..9e54a593 --- /dev/null +++ b/docs/_ext/vcspull_output_lexer.py @@ -0,0 +1,176 @@ +"""Pygments lexer for vcspull CLI output. + +This module provides a custom Pygments lexer for highlighting vcspull command +output (list, status, sync, search) with semantic colors matching the CLI. +""" + +from __future__ import annotations + +from pygments.lexer import RegexLexer, bygroups +from pygments.token import ( + Comment, + Generic, + Name, + Number, + Punctuation, + Text, + Whitespace, +) + + +class VcspullOutputLexer(RegexLexer): + """Lexer for vcspull CLI output. + + Highlights vcspull command output including list, status, sync, and search + results with semantic coloring. + + Token mapping to vcspull semantic colors: + - SUCCESS (green): Generic.Inserted - checkmarks, "up to date", "synced" + - WARNING (yellow): Name.Exception - warning symbols, "dirty", "behind" + - ERROR (red): Generic.Error - error symbols, "missing", "error" + - INFO (cyan): Name.Function - repository names + - HIGHLIGHT (magenta): Generic.Subheading - workspace headers + - MUTED (blue/gray): Comment - bullets, arrows, labels + + Examples + -------- + >>> from pygments.token import Token + >>> lexer = VcspullOutputLexer() + >>> tokens = list(lexer.get_tokens("• flask → ~/code/flask")) + >>> tokens[0] + (Token.Comment, '•') + >>> tokens[2] + (Token.Name.Function, 'flask') + """ + + name = "vcspull Output" + aliases = ["vcspull-output", "vcspull"] # noqa: RUF012 + filenames: list[str] = [] # noqa: RUF012 + mimetypes = ["text/x-vcspull-output"] # noqa: RUF012 + + tokens = { # noqa: RUF012 + "root": [ + # Newlines + (r"\n", Whitespace), + # Workspace header - path ending with / at start of line or after newline + # Matched by looking for ~/path/ or /path/ pattern as a whole line + (r"(~?/[-a-zA-Z0-9_.~/+]+/)(?=\s*$|\s*\n)", Generic.Subheading), + # Success symbol with repo name (green) - for sync output like "✓ repo" + ( + r"(✓)(\s+)([a-zA-Z][-a-zA-Z0-9_.]+)(?=\s+[~/]|:|\s*$)", + bygroups(Generic.Inserted, Whitespace, Name.Function), # type: ignore[no-untyped-call] + ), + # Success symbol standalone (green) + (r"✓", Generic.Inserted), + # Warning symbol with repo name (yellow) + ( + r"(⚠)(\s+)([a-zA-Z][-a-zA-Z0-9_.]+)(?=\s+[~/]|:|\s*$)", + bygroups(Name.Exception, Whitespace, Name.Function), # type: ignore[no-untyped-call] + ), + # Warning symbol standalone (yellow) + (r"⚠", Name.Exception), + # Error symbol with repo name (red) + ( + r"(✗)(\s+)([a-zA-Z][-a-zA-Z0-9_.]+)(?=\s+[~/]|:|\s*$)", + bygroups(Generic.Error, Whitespace, Name.Function), # type: ignore[no-untyped-call] + ), + # Error symbol standalone (red) + (r"✗", Generic.Error), + # Clone/add symbol with repo name (green) + ( + r"(\+)(\s+)([a-zA-Z][-a-zA-Z0-9_.]+)", + bygroups(Generic.Inserted, Whitespace, Name.Function), # type: ignore[no-untyped-call] + ), + # Update/change symbol with repo name (yellow) + ( + r"(~)(\s+)([a-zA-Z][-a-zA-Z0-9_.]+)", + bygroups(Name.Exception, Whitespace, Name.Function), # type: ignore[no-untyped-call] + ), + # Bullet (muted) + (r"•", Comment), + # Arrow (muted) + (r"→", Comment), + # Status messages - success (green) - must be at word boundary + (r"\bup to date\b", Generic.Inserted), + (r"\bsynced\b", Generic.Inserted), + (r"\bexists?\b", Generic.Inserted), + (r"\bahead by \d+\b", Generic.Inserted), + # Status messages - warning (yellow) + (r"\bdirty\b", Name.Exception), + (r"\bbehind(?: by \d+)?\b", Name.Exception), + (r"\bdiverged\b", Name.Exception), + (r"\bnot a git repo\b", Name.Exception), + # Status messages - error (red) + (r"(?<=: )missing\b", Generic.Error), # "missing" after colon + (r"\berror\b", Generic.Error), + (r"\bfailed\b", Generic.Error), + # Labels (muted) - common vcspull output labels + ( + r"(Summary:|Progress:|Path:|Branch:|url:|workspace:|Ahead/Behind:|" + r"Remote:|Repository:|Note:|Usage:)", + Generic.Heading, + ), + # vcspull command and subcommands (for pretty docs) + (r"\bvcspull\b", Name.Builtin), + (r"\b(sync|list|add|status|search|discover|fmt)\b(?=\s|$)", Name.Builtin), + # Git URLs (with git+ prefix) + (r"git\+https?://[^\s]+", Name.Tag), + # Plain HTTPS/HTTP URLs (without git+ prefix) + (r"https?://[^\s()]+", Name.Tag), + # Interactive prompt options like [y/N], [Y/n] + (r"\[[yYnN]/[yYnN]\]", Comment), + # Question mark prompt indicator + (r"\?", Generic.Prompt), + # Paths with ~/ - include + for c++ directories + (r"~?/[-a-zA-Z0-9_.~/+]+(?![\w/+])", Name.Variable), + # Repository names followed by arrow (muted arrow) + # Only match repo name when followed by arrow - avoids false positives + ( + r"([a-zA-Z][-a-zA-Z0-9_.]+)(\s*)(→)", + bygroups(Name.Function, Whitespace, Comment), # type: ignore[no-untyped-call] + ), + # Note: Removed generic "name:" pattern as it caused false positives + # (matching "add:" in "Would add:", "complete:" in "Dry run complete:") + # Repo names are matched via symbol-prefixed patterns (✓, ✗, ⚠, etc.) + # Count labels in summaries + ( + r"(\d+)(\s+)(repositories|repos|exist|missing|synced|failed|blocked|errors)", + bygroups(Number.Integer, Whitespace, Name.Label), # type: ignore[no-untyped-call] + ), + # Numbers + (r"\d+", Number.Integer), + # Whitespace + (r"[ \t]+", Whitespace), + # Punctuation + (r"[,():]", Punctuation), + # Fallback - any other text + (r"[^\s•→✓✗⚠+~:,()]+", Text), + ], + } + + +def tokenize_output(text: str) -> list[tuple[str, str]]: + """Tokenize vcspull output and return list of (token_type, value) tuples. + + Parameters + ---------- + text : str + vcspull CLI output text to tokenize. + + Returns + ------- + list[tuple[str, str]] + List of (token_type_name, text_value) tuples. + + Examples + -------- + >>> result = tokenize_output("• flask → ~/code/flask") + >>> result[0] + ('Token.Comment', '•') + >>> result[2] + ('Token.Name.Function', 'flask') + """ + lexer = VcspullOutputLexer() + return [ + (str(tok_type), tok_value) for tok_type, tok_value in lexer.get_tokens(text) + ] diff --git a/docs/cli/add.md b/docs/cli/add.md index e6211c24..df96dc0f 100644 --- a/docs/cli/add.md +++ b/docs/cli/add.md @@ -20,14 +20,13 @@ For bulk scanning of existing repositories, see {ref}`cli-discover`. :func: create_parser :prog: vcspull :path: add - :nodescription: ``` ## Basic usage Point to an existing checkout to add it under its parent workspace: -```console +```vcspull-console $ vcspull add ~/study/python/pytest-docker Found new repository to import: + pytest-docker (https://github.com/avast/pytest-docker) diff --git a/docs/cli/discover.md b/docs/cli/discover.md index 4e7ee35d..18a50e5f 100644 --- a/docs/cli/discover.md +++ b/docs/cli/discover.md @@ -14,14 +14,13 @@ workspaces or migrating from other tools. :func: create_parser :prog: vcspull :path: discover - :nodescription: ``` ## Basic usage Scan a directory for Git repositories: -```console +```vcspull-console $ vcspull discover ~/code Found 2 repositories in ~/code @@ -67,7 +66,7 @@ This scans all subdirectories for Git repositories, making it ideal for: Skip prompts and add all repositories with `--yes` or `-y`: -```console +```vcspull-console $ vcspull discover ~/code --recursive --yes Found 15 repositories in ~/code Added 15 repositories to ~/.vcspull.yaml @@ -88,7 +87,7 @@ $ vcspull discover ~/code --dry-run Output shows: -```console +```vcspull-output Would add: vcspull (~/code/) Remote: git+https://github.com/vcs-python/vcspull.git @@ -149,7 +148,7 @@ For each repository found: Repositories without an `origin` remote are detected but logged as a warning: -```console +```vcspull-console $ vcspull discover ~/code WARNING: Could not determine remote URL for ~/code/local-project (no origin remote) Skipping local-project @@ -211,7 +210,7 @@ After discovering repositories, consider: If a repository already exists in your configuration, vcspull will detect it: -```console +```vcspull-console Repository: flask Path: ~/code/flask Remote: git+https://github.com/pallets/flask.git diff --git a/docs/cli/fmt.md b/docs/cli/fmt.md index ef12e69d..f0d3e718 100644 --- a/docs/cli/fmt.md +++ b/docs/cli/fmt.md @@ -19,7 +19,6 @@ place while still showing a warning. :func: create_parser :prog: vcspull :path: fmt - :nodescription: ``` ## What gets formatted diff --git a/docs/cli/index.md b/docs/cli/index.md index 69cf1611..97748371 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -34,7 +34,6 @@ completion :func: create_parser :prog: vcspull :nosubcommands: - :nodescription: subparser_name : @replace See :ref:`cli-sync`, :ref:`cli-add`, :ref:`cli-discover`, :ref:`cli-list`, :ref:`cli-search`, :ref:`cli-status`, :ref:`cli-fmt` diff --git a/docs/cli/list.md b/docs/cli/list.md index 66d76e01..ad69646e 100644 --- a/docs/cli/list.md +++ b/docs/cli/list.md @@ -14,14 +14,13 @@ filter repositories by patterns, and export structured data for automation. :func: create_parser :prog: vcspull :path: list - :nodescription: ``` ## Basic usage List all configured repositories: -```console +```vcspull-console $ vcspull list • tiktoken → ~/study/ai/tiktoken • GeographicLib → ~/study/c++/GeographicLib @@ -32,7 +31,7 @@ $ vcspull list Filter repositories using fnmatch-style patterns: -```console +```vcspull-console $ vcspull list 'flask*' • flask → ~/code/flask • flask-sqlalchemy → ~/code/flask-sqlalchemy @@ -48,7 +47,7 @@ $ vcspull list django flask Group repositories by workspace root with `--tree`: -```console +```vcspull-console $ vcspull list --tree ~/study/ai/ @@ -132,7 +131,7 @@ $ vcspull list -f ~/projects/.vcspull.yaml Filter repositories by workspace root with `-w/--workspace/--workspace-root`: -```console +```vcspull-console $ vcspull list -w ~/code/ • flask → ~/code/flask • requests → ~/code/requests diff --git a/docs/cli/search.md b/docs/cli/search.md index 6bd61706..b7c186ff 100644 --- a/docs/cli/search.md +++ b/docs/cli/search.md @@ -14,14 +14,13 @@ scope to specific fields, and can emit structured JSON for automation. :func: create_parser :prog: vcspull :path: search - :nodescription: ``` ## Basic usage Search all fields (name, path, url, workspace) with regex: -```console +```vcspull-console $ vcspull search django • django → ~/code/django ``` @@ -30,7 +29,7 @@ $ vcspull search django Target specific fields with prefixes: -```console +```vcspull-console $ vcspull search name:django url:github • django → ~/code/django url: git+https://github.com/django/django.git diff --git a/docs/cli/status.md b/docs/cli/status.md index e8a352bd..82bd1fe5 100644 --- a/docs/cli/status.md +++ b/docs/cli/status.md @@ -14,14 +14,13 @@ This introspection command helps verify your local workspace matches your config :func: create_parser :prog: vcspull :path: status - :nodescription: ``` ## Basic usage Check the status of all configured repositories: -```console +```vcspull-console $ vcspull status ✗ tiktoken: missing ✓ flask: up to date @@ -40,7 +39,7 @@ The command shows: Filter repositories using fnmatch-style patterns: -```console +```vcspull-console $ vcspull status 'django*' • django → ~/code/django (exists, clean) • django-extensions → ~/code/django-extensions (missing) @@ -56,7 +55,7 @@ $ vcspull status django flask requests Show additional information with `--detailed` or `-d`: -```console +```vcspull-console $ vcspull status --detailed ✓ flask: up to date Path: ~/code/flask diff --git a/docs/cli/sync.md b/docs/cli/sync.md index 2e219e52..f5636bbd 100644 --- a/docs/cli/sync.md +++ b/docs/cli/sync.md @@ -16,14 +16,13 @@ synchronized with remote repositories. :func: create_parser :prog: vcspull :path: sync - :nodescription: ``` ## Dry run mode Preview what would be synchronized without making changes: -```console +```vcspull-console $ vcspull sync --dry-run '*' Would sync flask at ~/code/flask Would sync django at ~/code/django @@ -169,17 +168,17 @@ $ vcspull sync 'django-anymail' 'django-guardian' As of 1.13.x, if you enter a repo term (or terms) that aren't found throughout your configurations, it will show a warning: -```console +```vcspull-console $ vcspull sync non_existent_repo No repo found in config(s) for "non_existent_repo" ``` -```console +```vcspull-console $ vcspull sync non_existent_repo existing_repo No repo found in config(s) for "non_existent_repo" ``` -```console +```vcspull-console $ vcspull sync non_existent_repo existing_repo another_repo_not_in_config No repo found in config(s) for "non_existent_repo" No repo found in config(s) for "another_repo_not_in_config" diff --git a/docs/conf.py b/docs/conf.py index 779888b9..233dfa1f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -35,6 +35,7 @@ "sphinx.ext.napoleon", "sphinx.ext.linkcode", "sphinxarg.ext", # sphinx-argparse + "pretty_argparse", # Enhanced sphinx-argparse with ANSI stripping "sphinx_inline_tabs", "sphinx_copybutton", "sphinxext.opengraph", diff --git a/pyproject.toml b/pyproject.toml index 9ad98b05..9c938890 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,6 +98,8 @@ dev = [ "ruff", "mypy", # Annotations + "types-docutils", + "types-Pygments", "types-requests", "types-PyYAML", "types-colorama" @@ -136,6 +138,8 @@ lint = [ "mypy", ] typings = [ + "types-docutils", + "types-Pygments", "types-requests", "types-PyYAML", "types-colorama" @@ -157,6 +161,12 @@ strict = true [[tool.mypy.overrides]] module = [ "shtab", + "sphinxarg.*", + "cli_usage_lexer", + "docutils", + "docutils.*", + "pygments", + "pygments.*", ] ignore_missing_imports = true @@ -167,6 +177,7 @@ omit = [ "*/_*", "*/_compat.py", "docs/conf.py", + "docs/_ext/*", "tests/*", ] @@ -239,13 +250,13 @@ required-imports = [ "*/__init__.py" = ["F401"] [tool.pytest.ini_options] -addopts = "--tb=short --no-header --showlocals" +addopts = "--tb=short --no-header --showlocals --doctest-modules" +doctest_optionflags = "ELLIPSIS NORMALIZE_WHITESPACE" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" testpaths = [ "src/vcspull", "tests", - "docs", ] filterwarnings = [ "ignore:The frontend.Option(Parser)? class.*:DeprecationWarning::", diff --git a/src/vcspull/_internal/config_reader.py b/src/vcspull/_internal/config_reader.py index 4313d793..b6798ab6 100644 --- a/src/vcspull/_internal/config_reader.py +++ b/src/vcspull/_internal/config_reader.py @@ -53,13 +53,13 @@ def load(cls, fmt: FormatLiteral, content: str) -> ConfigReader: >>> cfg = ConfigReader.load("json", '{ "session_name": "my session" }') >>> cfg - + >>> cfg.content {'session_name': 'my session'} >>> cfg = ConfigReader.load("yaml", 'session_name: my session') >>> cfg - + >>> cfg.content {'session_name': 'my session'} """ @@ -146,7 +146,7 @@ def from_file(cls, path: pathlib.Path) -> ConfigReader: >>> cfg = ConfigReader.from_file(yaml_file) >>> cfg - + >>> cfg.content {'session_name': 'my session'} @@ -163,7 +163,7 @@ def from_file(cls, path: pathlib.Path) -> ConfigReader: >>> cfg = ConfigReader.from_file(json_file) >>> cfg - + >>> cfg.content {'session_name': 'my session'} diff --git a/tests/docs/__init__.py b/tests/docs/__init__.py new file mode 100644 index 00000000..b6723bfd --- /dev/null +++ b/tests/docs/__init__.py @@ -0,0 +1,3 @@ +"""Tests for documentation extensions.""" + +from __future__ import annotations diff --git a/tests/docs/_ext/__init__.py b/tests/docs/_ext/__init__.py new file mode 100644 index 00000000..56548488 --- /dev/null +++ b/tests/docs/_ext/__init__.py @@ -0,0 +1,3 @@ +"""Tests for docs/_ext Sphinx extensions.""" + +from __future__ import annotations diff --git a/tests/docs/_ext/conftest.py b/tests/docs/_ext/conftest.py new file mode 100644 index 00000000..bb2cf99b --- /dev/null +++ b/tests/docs/_ext/conftest.py @@ -0,0 +1,11 @@ +"""Fixtures and configuration for docs extension tests.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Add docs/_ext to path so we can import the extension module +docs_ext_path = Path(__file__).parent.parent.parent.parent / "docs" / "_ext" +if str(docs_ext_path) not in sys.path: + sys.path.insert(0, str(docs_ext_path)) diff --git a/tests/docs/_ext/test_cli_usage_lexer.py b/tests/docs/_ext/test_cli_usage_lexer.py new file mode 100644 index 00000000..3c32ebac --- /dev/null +++ b/tests/docs/_ext/test_cli_usage_lexer.py @@ -0,0 +1,358 @@ +"""Tests for cli_usage_lexer Pygments extension.""" + +from __future__ import annotations + +import typing as t + +import pytest +from cli_usage_lexer import ( + CLIUsageLexer, + tokenize_usage, +) + +# --- Helper to extract token type names --- + + +def get_tokens(text: str) -> list[tuple[str, str]]: + """Get tokens as (type_name, value) tuples.""" + lexer = CLIUsageLexer() + return [ + (str(tok_type), tok_value) for tok_type, tok_value in lexer.get_tokens(text) + ] + + +# --- Token type fixtures --- + + +class TokenTypeFixture(t.NamedTuple): + """Test fixture for verifying specific token types.""" + + test_id: str + input_text: str + expected_token_type: str + expected_value: str + + +TOKEN_TYPE_FIXTURES: list[TokenTypeFixture] = [ + TokenTypeFixture( + test_id="usage_heading", + input_text="usage:", + expected_token_type="Token.Generic.Heading", + expected_value="usage:", + ), + TokenTypeFixture( + test_id="short_option", + input_text="-h", + expected_token_type="Token.Name.Attribute", + expected_value="-h", + ), + TokenTypeFixture( + test_id="long_option", + input_text="--verbose", + expected_token_type="Token.Name.Tag", + expected_value="--verbose", + ), + TokenTypeFixture( + test_id="long_option_with_dashes", + input_text="--no-color", + expected_token_type="Token.Name.Tag", + expected_value="--no-color", + ), + TokenTypeFixture( + test_id="uppercase_metavar", + input_text="COMMAND", + expected_token_type="Token.Name.Constant", + expected_value="COMMAND", + ), + TokenTypeFixture( + test_id="uppercase_metavar_with_underscore", + input_text="FILE_PATH", + expected_token_type="Token.Name.Constant", + expected_value="FILE_PATH", + ), + TokenTypeFixture( + test_id="positional_arg", + input_text="repo-name", + expected_token_type="Token.Name.Label", + expected_value="repo-name", + ), + TokenTypeFixture( + test_id="command_name", + input_text="vcspull", + expected_token_type="Token.Name.Label", + expected_value="vcspull", + ), + TokenTypeFixture( + test_id="open_bracket", + input_text="[", + expected_token_type="Token.Punctuation", + expected_value="[", + ), + TokenTypeFixture( + test_id="close_bracket", + input_text="]", + expected_token_type="Token.Punctuation", + expected_value="]", + ), + TokenTypeFixture( + test_id="pipe_operator", + input_text="|", + expected_token_type="Token.Operator", + expected_value="|", + ), +] + + +@pytest.mark.parametrize( + TokenTypeFixture._fields, + TOKEN_TYPE_FIXTURES, + ids=[f.test_id for f in TOKEN_TYPE_FIXTURES], +) +def test_token_type( + test_id: str, + input_text: str, + expected_token_type: str, + expected_value: str, +) -> None: + """Test individual token type detection.""" + tokens = get_tokens(input_text) + # Find the expected token (skip whitespace) + non_ws_tokens = [(t, v) for t, v in tokens if "Whitespace" not in t and v.strip()] + assert len(non_ws_tokens) >= 1, f"No non-whitespace tokens found for '{input_text}'" + token_type, token_value = non_ws_tokens[0] + assert token_type == expected_token_type, ( + f"Expected {expected_token_type}, got {token_type}" + ) + assert token_value == expected_value + + +# --- Short option with value fixtures --- + + +class ShortOptionValueFixture(t.NamedTuple): + """Test fixture for short options with values.""" + + test_id: str + input_text: str + option: str + value: str + + +SHORT_OPTION_VALUE_FIXTURES: list[ShortOptionValueFixture] = [ + ShortOptionValueFixture( + test_id="lowercase_value", + input_text="-c config-path", + option="-c", + value="config-path", + ), + ShortOptionValueFixture( + test_id="uppercase_value", + input_text="-d DIRECTORY", + option="-d", + value="DIRECTORY", + ), + ShortOptionValueFixture( + test_id="simple_value", + input_text="-r name", + option="-r", + value="name", + ), +] + + +@pytest.mark.parametrize( + ShortOptionValueFixture._fields, + SHORT_OPTION_VALUE_FIXTURES, + ids=[f.test_id for f in SHORT_OPTION_VALUE_FIXTURES], +) +def test_short_option_with_value( + test_id: str, + input_text: str, + option: str, + value: str, +) -> None: + """Test short option followed by value tokenization.""" + tokens = get_tokens(input_text) + non_ws_tokens = [(t, v) for t, v in tokens if "Whitespace" not in t] + + assert len(non_ws_tokens) >= 2 + assert non_ws_tokens[0] == ("Token.Name.Attribute", option) + # Value could be Name.Variable or Name.Constant depending on case + assert non_ws_tokens[1][1] == value + + +# --- Long option with value fixtures --- + + +class LongOptionValueFixture(t.NamedTuple): + """Test fixture for long options with = values.""" + + test_id: str + input_text: str + option: str + value: str + + +LONG_OPTION_VALUE_FIXTURES: list[LongOptionValueFixture] = [ + LongOptionValueFixture( + test_id="uppercase_value", + input_text="--config=FILE", + option="--config", + value="FILE", + ), + LongOptionValueFixture( + test_id="lowercase_value", + input_text="--output=path", + option="--output", + value="path", + ), +] + + +@pytest.mark.parametrize( + LongOptionValueFixture._fields, + LONG_OPTION_VALUE_FIXTURES, + ids=[f.test_id for f in LONG_OPTION_VALUE_FIXTURES], +) +def test_long_option_with_value( + test_id: str, + input_text: str, + option: str, + value: str, +) -> None: + """Test long option with = value tokenization.""" + tokens = get_tokens(input_text) + non_ws_tokens = [(t, v) for t, v in tokens if "Whitespace" not in t] + + assert len(non_ws_tokens) >= 3 + assert non_ws_tokens[0] == ("Token.Name.Tag", option) + assert non_ws_tokens[1] == ("Token.Operator", "=") + assert non_ws_tokens[2][1] == value + + +# --- Full usage string fixtures --- + + +class UsageStringFixture(t.NamedTuple): + """Test fixture for full usage string tokenization.""" + + test_id: str + input_text: str + expected_contains: list[tuple[str, str]] + + +USAGE_STRING_FIXTURES: list[UsageStringFixture] = [ + UsageStringFixture( + test_id="simple_usage", + input_text="usage: cmd [-h]", + expected_contains=[ + ("Token.Generic.Heading", "usage:"), + ("Token.Name.Label", "cmd"), + ("Token.Punctuation", "["), + ("Token.Name.Attribute", "-h"), + ("Token.Punctuation", "]"), + ], + ), + UsageStringFixture( + test_id="mutually_exclusive", + input_text="[--json | --ndjson | --table]", + expected_contains=[ + ("Token.Name.Tag", "--json"), + ("Token.Operator", "|"), + ("Token.Name.Tag", "--ndjson"), + ("Token.Operator", "|"), + ("Token.Name.Tag", "--table"), + ], + ), + UsageStringFixture( + test_id="subcommand", + input_text="usage: vcspull sync", + expected_contains=[ + ("Token.Generic.Heading", "usage:"), + ("Token.Name.Label", "vcspull"), + ("Token.Name.Label", "sync"), + ], + ), + UsageStringFixture( + test_id="positional_args", + input_text="[repo-name] [path]", + expected_contains=[ + ("Token.Punctuation", "["), + ("Token.Name.Label", "repo-name"), + ("Token.Punctuation", "]"), + ("Token.Punctuation", "["), + ("Token.Name.Label", "path"), + ("Token.Punctuation", "]"), + ], + ), +] + + +@pytest.mark.parametrize( + UsageStringFixture._fields, + USAGE_STRING_FIXTURES, + ids=[f.test_id for f in USAGE_STRING_FIXTURES], +) +def test_usage_string( + test_id: str, + input_text: str, + expected_contains: list[tuple[str, str]], +) -> None: + """Test full usage string tokenization contains expected tokens.""" + tokens = get_tokens(input_text) + for expected_type, expected_value in expected_contains: + assert (expected_type, expected_value) in tokens, ( + f"Expected ({expected_type}, {expected_value!r}) not found in tokens" + ) + + +# --- Real vcspull usage output test --- + + +def test_vcspull_sync_usage() -> None: + """Test real vcspull sync usage output tokenization.""" + usage_text = """\ +usage: vcspull sync [-h] [-c CONFIG] [-d DIRECTORY] + [--json | --ndjson | --table] [--color {auto,always,never}] + [--no-progress] [--verbose] + [repo-name] [path]""" + + tokens = get_tokens(usage_text) + + # Check key elements are present + # Note: DIRECTORY after -d is Name.Variable (option value), not Name.Constant + expected = [ + ("Token.Generic.Heading", "usage:"), + ("Token.Name.Label", "vcspull"), + ("Token.Name.Label", "sync"), + ("Token.Name.Attribute", "-h"), + ("Token.Name.Attribute", "-c"), + ("Token.Name.Variable", "CONFIG"), # Option value, not standalone metavar + ("Token.Name.Attribute", "-d"), + ("Token.Name.Variable", "DIRECTORY"), # Option value, not standalone metavar + ("Token.Name.Tag", "--json"), + ("Token.Name.Tag", "--ndjson"), + ("Token.Name.Tag", "--table"), + ("Token.Name.Tag", "--color"), + ("Token.Name.Tag", "--no-progress"), + ("Token.Name.Tag", "--verbose"), + ("Token.Name.Label", "repo-name"), + ("Token.Name.Label", "path"), + ] + + for expected_type, expected_value in expected: + assert (expected_type, expected_value) in tokens, ( + f"Expected ({expected_type}, {expected_value!r}) not in tokens" + ) + + +# --- tokenize_usage helper function test --- + + +def test_tokenize_usage_helper() -> None: + """Test the tokenize_usage helper function.""" + result = tokenize_usage("usage: cmd [-h]") + + assert result[0] == ("Token.Generic.Heading", "usage:") + assert ("Token.Name.Label", "cmd") in result + assert ("Token.Name.Attribute", "-h") in result diff --git a/tests/docs/_ext/test_pretty_argparse.py b/tests/docs/_ext/test_pretty_argparse.py new file mode 100644 index 00000000..1d186205 --- /dev/null +++ b/tests/docs/_ext/test_pretty_argparse.py @@ -0,0 +1,967 @@ +"""Tests for pretty_argparse sphinx extension.""" + +from __future__ import annotations + +import typing as t + +import pytest +from docutils import nodes +from pretty_argparse import ( # type: ignore[import-not-found] + _is_examples_section, + _is_usage_block, + _reorder_nodes, + escape_rst_emphasis, + is_base_examples_term, + is_examples_term, + make_section_id, + make_section_title, + strip_ansi, + transform_definition_list, +) + +# --- strip_ansi tests --- + + +class StripAnsiFixture(t.NamedTuple): + """Test fixture for strip_ansi function.""" + + test_id: str + input_text: str + expected: str + + +STRIP_ANSI_FIXTURES: list[StripAnsiFixture] = [ + StripAnsiFixture( + test_id="plain_text", + input_text="hello", + expected="hello", + ), + StripAnsiFixture( + test_id="green_color", + input_text="\033[32mgreen\033[0m", + expected="green", + ), + StripAnsiFixture( + test_id="bold_blue", + input_text="\033[1;34mbold\033[0m", + expected="bold", + ), + StripAnsiFixture( + test_id="multiple_codes", + input_text="\033[1m\033[32mtest\033[0m", + expected="test", + ), + StripAnsiFixture( + test_id="empty_string", + input_text="", + expected="", + ), + StripAnsiFixture( + test_id="mixed_content", + input_text="pre\033[31mred\033[0mpost", + expected="preredpost", + ), + StripAnsiFixture( + test_id="reset_only", + input_text="\033[0m", + expected="", + ), + StripAnsiFixture( + test_id="sgr_params", + input_text="\033[38;5;196mred256\033[0m", + expected="red256", + ), +] + + +@pytest.mark.parametrize( + StripAnsiFixture._fields, + STRIP_ANSI_FIXTURES, + ids=[f.test_id for f in STRIP_ANSI_FIXTURES], +) +def test_strip_ansi(test_id: str, input_text: str, expected: str) -> None: + """Test ANSI escape code stripping.""" + assert strip_ansi(input_text) == expected + + +# --- escape_rst_emphasis tests --- + + +class EscapeRstEmphasisFixture(t.NamedTuple): + """Test fixture for escape_rst_emphasis function.""" + + test_id: str + input_text: str + expected: str + + +ESCAPE_RST_EMPHASIS_FIXTURES: list[EscapeRstEmphasisFixture] = [ + EscapeRstEmphasisFixture( + test_id="plain_text_unchanged", + input_text="plain text", + expected="plain text", + ), + EscapeRstEmphasisFixture( + test_id="glob_pattern_escaped", + input_text='vcspull list "django-*"', + expected='vcspull list "django-\\*"', + ), + EscapeRstEmphasisFixture( + test_id="multiple_glob_patterns", + input_text='vcspull sync "flask-*" "django-*"', + expected='vcspull sync "flask-\\*" "django-\\*"', + ), + EscapeRstEmphasisFixture( + test_id="asterisk_at_end", + input_text="pattern-*", + expected="pattern-\\*", + ), + EscapeRstEmphasisFixture( + test_id="already_escaped_unchanged", + input_text="already-\\* escaped", + expected="already-\\* escaped", + ), + EscapeRstEmphasisFixture( + test_id="valid_emphasis_unchanged", + input_text="*emphasis* is ok", + expected="*emphasis* is ok", + ), + EscapeRstEmphasisFixture( + test_id="strong_emphasis_unchanged", + input_text="**strong** text", + expected="**strong** text", + ), + EscapeRstEmphasisFixture( + test_id="space_before_asterisk_unchanged", + input_text="space * asterisk", + expected="space * asterisk", + ), + EscapeRstEmphasisFixture( + test_id="asterisk_after_dot_unchanged", + input_text="regex.*pattern", + expected="regex.*pattern", + ), + EscapeRstEmphasisFixture( + test_id="single_asterisk_unchanged", + input_text="vcspull sync '*'", + expected="vcspull sync '*'", + ), + EscapeRstEmphasisFixture( + test_id="empty_string", + input_text="", + expected="", + ), + EscapeRstEmphasisFixture( + test_id="underscore_asterisk_unchanged", + input_text="name_*pattern", + expected="name_*pattern", + ), + EscapeRstEmphasisFixture( + test_id="dash_asterisk_with_following_char", + input_text="repo-*-suffix", + expected="repo-\\*-suffix", + ), +] + + +@pytest.mark.parametrize( + EscapeRstEmphasisFixture._fields, + ESCAPE_RST_EMPHASIS_FIXTURES, + ids=[f.test_id for f in ESCAPE_RST_EMPHASIS_FIXTURES], +) +def test_escape_rst_emphasis(test_id: str, input_text: str, expected: str) -> None: + """Test RST emphasis escaping for argparse patterns.""" + assert escape_rst_emphasis(input_text) == expected + + +# --- is_examples_term tests --- + + +class IsExamplesTermFixture(t.NamedTuple): + """Test fixture for is_examples_term function.""" + + test_id: str + term_text: str + expected: bool + + +IS_EXAMPLES_TERM_FIXTURES: list[IsExamplesTermFixture] = [ + IsExamplesTermFixture( + test_id="base_examples_colon", + term_text="examples:", + expected=True, + ), + IsExamplesTermFixture( + test_id="base_examples_no_colon", + term_text="examples", + expected=True, + ), + IsExamplesTermFixture( + test_id="prefixed_machine_readable", + term_text="Machine-readable output examples:", + expected=True, + ), + IsExamplesTermFixture( + test_id="prefixed_field_scoped", + term_text="Field-scoped search examples:", + expected=True, + ), + IsExamplesTermFixture( + test_id="colon_pattern", + term_text="Machine-readable output: examples:", + expected=True, + ), + IsExamplesTermFixture( + test_id="usage_not_examples", + term_text="Usage:", + expected=False, + ), + IsExamplesTermFixture( + test_id="arguments_not_examples", + term_text="Named Arguments:", + expected=False, + ), + IsExamplesTermFixture( + test_id="case_insensitive_upper", + term_text="EXAMPLES:", + expected=True, + ), + IsExamplesTermFixture( + test_id="case_insensitive_mixed", + term_text="Examples:", + expected=True, + ), +] + + +@pytest.mark.parametrize( + IsExamplesTermFixture._fields, + IS_EXAMPLES_TERM_FIXTURES, + ids=[f.test_id for f in IS_EXAMPLES_TERM_FIXTURES], +) +def test_is_examples_term(test_id: str, term_text: str, expected: bool) -> None: + """Test examples term detection.""" + assert is_examples_term(term_text) == expected + + +# --- is_base_examples_term tests --- + + +class IsBaseExamplesTermFixture(t.NamedTuple): + """Test fixture for is_base_examples_term function.""" + + test_id: str + term_text: str + expected: bool + + +IS_BASE_EXAMPLES_TERM_FIXTURES: list[IsBaseExamplesTermFixture] = [ + IsBaseExamplesTermFixture( + test_id="base_with_colon", + term_text="examples:", + expected=True, + ), + IsBaseExamplesTermFixture( + test_id="base_no_colon", + term_text="examples", + expected=True, + ), + IsBaseExamplesTermFixture( + test_id="uppercase", + term_text="EXAMPLES", + expected=True, + ), + IsBaseExamplesTermFixture( + test_id="mixed_case", + term_text="Examples:", + expected=True, + ), + IsBaseExamplesTermFixture( + test_id="prefixed_not_base", + term_text="Field-scoped examples:", + expected=False, + ), + IsBaseExamplesTermFixture( + test_id="output_examples_not_base", + term_text="Machine-readable output examples:", + expected=False, + ), + IsBaseExamplesTermFixture( + test_id="colon_pattern_not_base", + term_text="Output: examples:", + expected=False, + ), +] + + +@pytest.mark.parametrize( + IsBaseExamplesTermFixture._fields, + IS_BASE_EXAMPLES_TERM_FIXTURES, + ids=[f.test_id for f in IS_BASE_EXAMPLES_TERM_FIXTURES], +) +def test_is_base_examples_term(test_id: str, term_text: str, expected: bool) -> None: + """Test base examples term detection.""" + assert is_base_examples_term(term_text) == expected + + +# --- make_section_id tests --- + + +class MakeSectionIdFixture(t.NamedTuple): + """Test fixture for make_section_id function.""" + + test_id: str + term_text: str + counter: int + is_subsection: bool + expected: str + + +MAKE_SECTION_ID_FIXTURES: list[MakeSectionIdFixture] = [ + MakeSectionIdFixture( + test_id="base_examples", + term_text="examples:", + counter=0, + is_subsection=False, + expected="examples", + ), + MakeSectionIdFixture( + test_id="prefixed_standard", + term_text="Machine-readable output examples:", + counter=0, + is_subsection=False, + expected="machine-readable-output-examples", + ), + MakeSectionIdFixture( + test_id="subsection_omits_suffix", + term_text="Field-scoped examples:", + counter=0, + is_subsection=True, + expected="field-scoped", + ), + MakeSectionIdFixture( + test_id="with_counter", + term_text="examples:", + counter=2, + is_subsection=False, + expected="examples-2", + ), + MakeSectionIdFixture( + test_id="counter_zero_no_suffix", + term_text="examples:", + counter=0, + is_subsection=False, + expected="examples", + ), + MakeSectionIdFixture( + test_id="colon_pattern", + term_text="Machine-readable output: examples:", + counter=0, + is_subsection=False, + expected="machine-readable-output-examples", + ), + MakeSectionIdFixture( + test_id="subsection_with_counter", + term_text="Field-scoped examples:", + counter=1, + is_subsection=True, + expected="field-scoped-1", + ), +] + + +@pytest.mark.parametrize( + MakeSectionIdFixture._fields, + MAKE_SECTION_ID_FIXTURES, + ids=[f.test_id for f in MAKE_SECTION_ID_FIXTURES], +) +def test_make_section_id( + test_id: str, + term_text: str, + counter: int, + is_subsection: bool, + expected: str, +) -> None: + """Test section ID generation.""" + assert make_section_id(term_text, counter, is_subsection=is_subsection) == expected + + +def test_make_section_id_with_page_prefix() -> None: + """Test section ID generation with page_prefix for cross-page uniqueness.""" + # Base "examples:" with page_prefix becomes "sync-examples" + assert make_section_id("examples:", page_prefix="sync") == "sync-examples" + assert make_section_id("examples:", page_prefix="add") == "add-examples" + + # Prefixed examples already unique - page_prefix not added + assert ( + make_section_id("Machine-readable output examples:", page_prefix="sync") + == "machine-readable-output-examples" + ) + + # Subsection with page_prefix + result = make_section_id( + "Field-scoped examples:", is_subsection=True, page_prefix="sync" + ) + assert result == "field-scoped" + + # Empty page_prefix behaves like before + assert make_section_id("examples:", page_prefix="") == "examples" + + +# --- make_section_title tests --- + + +class MakeSectionTitleFixture(t.NamedTuple): + """Test fixture for make_section_title function.""" + + test_id: str + term_text: str + is_subsection: bool + expected: str + + +MAKE_SECTION_TITLE_FIXTURES: list[MakeSectionTitleFixture] = [ + MakeSectionTitleFixture( + test_id="base_examples", + term_text="examples:", + is_subsection=False, + expected="Examples", + ), + MakeSectionTitleFixture( + test_id="prefixed_with_examples_suffix", + term_text="Machine-readable output examples:", + is_subsection=False, + expected="Machine-Readable Output Examples", + ), + MakeSectionTitleFixture( + test_id="subsection_omits_examples", + term_text="Field-scoped examples:", + is_subsection=True, + expected="Field-Scoped", + ), + MakeSectionTitleFixture( + test_id="colon_pattern", + term_text="Machine-readable output: examples:", + is_subsection=False, + expected="Machine-Readable Output Examples", + ), + MakeSectionTitleFixture( + test_id="subsection_colon_pattern", + term_text="Machine-readable output: examples:", + is_subsection=True, + expected="Machine-Readable Output", + ), + MakeSectionTitleFixture( + test_id="base_examples_no_colon", + term_text="examples", + is_subsection=False, + expected="Examples", + ), +] + + +@pytest.mark.parametrize( + MakeSectionTitleFixture._fields, + MAKE_SECTION_TITLE_FIXTURES, + ids=[f.test_id for f in MAKE_SECTION_TITLE_FIXTURES], +) +def test_make_section_title( + test_id: str, + term_text: str, + is_subsection: bool, + expected: str, +) -> None: + """Test section title generation.""" + assert make_section_title(term_text, is_subsection=is_subsection) == expected + + +# --- transform_definition_list integration tests --- + + +def _make_dl_item(term: str, definition: str) -> nodes.definition_list_item: + """Create a definition list item for testing. + + Parameters + ---------- + term : str + The definition term text. + definition : str + The definition content text. + + Returns + ------- + nodes.definition_list_item + A definition list item with term and definition. + """ + item = nodes.definition_list_item() + term_node = nodes.term(text=term) + def_node = nodes.definition() + def_node += nodes.paragraph(text=definition) + item += term_node + item += def_node + return item + + +def test_transform_definition_list_single_examples() -> None: + """Single examples section creates one section node.""" + dl = nodes.definition_list() + dl += _make_dl_item("examples:", "vcspull ls") + + result = transform_definition_list(dl) + + assert len(result) == 1 + assert isinstance(result[0], nodes.section) + assert result[0]["ids"] == ["examples"] + + +def test_transform_definition_list_nested_examples() -> None: + """Base examples with category creates nested sections.""" + dl = nodes.definition_list() + dl += _make_dl_item("examples:", "vcspull ls") + dl += _make_dl_item("Machine-readable output examples:", "vcspull ls --json") + + result = transform_definition_list(dl) + + # Should have single parent section containing nested subsection + assert len(result) == 1 + parent = result[0] + assert isinstance(parent, nodes.section) + assert parent["ids"] == ["examples"] + + # Find nested subsection + subsections = [c for c in parent.children if isinstance(c, nodes.section)] + assert len(subsections) == 1 + assert subsections[0]["ids"] == ["machine-readable-output"] + + +def test_transform_definition_list_multiple_categories() -> None: + """Multiple example categories all nest under parent.""" + dl = nodes.definition_list() + dl += _make_dl_item("examples:", "vcspull ls") + dl += _make_dl_item("Field-scoped examples:", "vcspull ls --field name") + dl += _make_dl_item("Machine-readable output examples:", "vcspull ls --json") + + result = transform_definition_list(dl) + + assert len(result) == 1 + parent = result[0] + assert isinstance(parent, nodes.section) + + subsections = [c for c in parent.children if isinstance(c, nodes.section)] + assert len(subsections) == 2 + + +def test_transform_definition_list_preserves_non_examples() -> None: + """Non-example items preserved as definition list.""" + dl = nodes.definition_list() + dl += _make_dl_item("Usage:", "How to use this command") + dl += _make_dl_item("examples:", "vcspull ls") + + result = transform_definition_list(dl) + + # Should have both definition list (non-examples) and section (examples) + has_dl = any(isinstance(n, nodes.definition_list) for n in result) + has_section = any(isinstance(n, nodes.section) for n in result) + assert has_dl, "Non-example items should be preserved as definition list" + assert has_section, "Example items should become sections" + + +def test_transform_definition_list_no_examples() -> None: + """Definition list without examples returns empty list.""" + dl = nodes.definition_list() + dl += _make_dl_item("Usage:", "How to use") + dl += _make_dl_item("Options:", "Available options") + + result = transform_definition_list(dl) + + # All items are non-examples, should return definition list + assert len(result) == 1 + assert isinstance(result[0], nodes.definition_list) + + +def test_transform_definition_list_only_category_no_base() -> None: + """Single category example without base examples stays flat.""" + dl = nodes.definition_list() + dl += _make_dl_item("Machine-readable output examples:", "vcspull ls --json") + + result = transform_definition_list(dl) + + # Without base "examples:", no nesting - just single section + assert len(result) == 1 + assert isinstance(result[0], nodes.section) + # Should have full title since it's not nested + assert result[0]["ids"] == ["machine-readable-output-examples"] + + +def test_transform_definition_list_code_blocks_created() -> None: + """Each command line becomes a separate code block.""" + dl = nodes.definition_list() + dl += _make_dl_item("examples:", "cmd1\ncmd2\ncmd3") + + result = transform_definition_list(dl) + + section = result[0] + code_blocks = [c for c in section.children if isinstance(c, nodes.literal_block)] + assert len(code_blocks) == 3 + assert code_blocks[0].astext() == "$ cmd1" + assert code_blocks[1].astext() == "$ cmd2" + assert code_blocks[2].astext() == "$ cmd3" + + +# --- _is_usage_block tests --- + + +class IsUsageBlockFixture(t.NamedTuple): + """Test fixture for _is_usage_block function.""" + + test_id: str + node_type: str + node_text: str + expected: bool + + +IS_USAGE_BLOCK_FIXTURES: list[IsUsageBlockFixture] = [ + IsUsageBlockFixture( + test_id="literal_block_usage_lowercase", + node_type="literal_block", + node_text="usage: cmd [-h]", + expected=True, + ), + IsUsageBlockFixture( + test_id="literal_block_usage_uppercase", + node_type="literal_block", + node_text="Usage: vcspull sync", + expected=True, + ), + IsUsageBlockFixture( + test_id="literal_block_usage_leading_space", + node_type="literal_block", + node_text=" usage: cmd", + expected=True, + ), + IsUsageBlockFixture( + test_id="literal_block_not_usage", + node_type="literal_block", + node_text="some other text", + expected=False, + ), + IsUsageBlockFixture( + test_id="literal_block_usage_in_middle", + node_type="literal_block", + node_text="see usage: for more", + expected=False, + ), + IsUsageBlockFixture( + test_id="paragraph_with_usage", + node_type="paragraph", + node_text="usage: cmd", + expected=False, + ), + IsUsageBlockFixture( + test_id="section_node", + node_type="section", + node_text="", + expected=False, + ), +] + + +def _make_test_node(node_type: str, node_text: str) -> nodes.Node: + """Create a test node of the specified type. + + Parameters + ---------- + node_type : str + Type of node to create ("literal_block", "paragraph", "section"). + node_text : str + Text content for the node. + + Returns + ------- + nodes.Node + The created node. + """ + if node_type == "literal_block": + return nodes.literal_block(text=node_text) + if node_type == "paragraph": + return nodes.paragraph(text=node_text) + if node_type == "section": + return nodes.section() + msg = f"Unknown node type: {node_type}" + raise ValueError(msg) + + +@pytest.mark.parametrize( + IsUsageBlockFixture._fields, + IS_USAGE_BLOCK_FIXTURES, + ids=[f.test_id for f in IS_USAGE_BLOCK_FIXTURES], +) +def test_is_usage_block( + test_id: str, + node_type: str, + node_text: str, + expected: bool, +) -> None: + """Test usage block detection.""" + node = _make_test_node(node_type, node_text) + assert _is_usage_block(node) == expected + + +# --- _is_examples_section tests --- + + +class IsExamplesSectionFixture(t.NamedTuple): + """Test fixture for _is_examples_section function.""" + + test_id: str + node_type: str + section_ids: list[str] + expected: bool + + +IS_EXAMPLES_SECTION_FIXTURES: list[IsExamplesSectionFixture] = [ + IsExamplesSectionFixture( + test_id="section_with_examples_id", + node_type="section", + section_ids=["examples"], + expected=True, + ), + IsExamplesSectionFixture( + test_id="section_with_prefixed_examples", + node_type="section", + section_ids=["machine-readable-output-examples"], + expected=True, + ), + IsExamplesSectionFixture( + test_id="section_with_uppercase_examples", + node_type="section", + section_ids=["EXAMPLES"], + expected=True, + ), + IsExamplesSectionFixture( + test_id="section_without_examples", + node_type="section", + section_ids=["positional-arguments"], + expected=False, + ), + IsExamplesSectionFixture( + test_id="section_with_multiple_ids", + node_type="section", + section_ids=["main-id", "examples-alias"], + expected=True, + ), + IsExamplesSectionFixture( + test_id="section_empty_ids", + node_type="section", + section_ids=[], + expected=False, + ), + IsExamplesSectionFixture( + test_id="paragraph_node", + node_type="paragraph", + section_ids=[], + expected=False, + ), + IsExamplesSectionFixture( + test_id="literal_block_node", + node_type="literal_block", + section_ids=[], + expected=False, + ), +] + + +def _make_section_node(node_type: str, section_ids: list[str]) -> nodes.Node: + """Create a test node with optional section IDs. + + Parameters + ---------- + node_type : str + Type of node to create. + section_ids : list[str] + IDs to assign if creating a section. + + Returns + ------- + nodes.Node + The created node. + """ + if node_type == "section": + section = nodes.section() + section["ids"] = section_ids + return section + if node_type == "paragraph": + return nodes.paragraph() + if node_type == "literal_block": + return nodes.literal_block(text="examples") + msg = f"Unknown node type: {node_type}" + raise ValueError(msg) + + +@pytest.mark.parametrize( + IsExamplesSectionFixture._fields, + IS_EXAMPLES_SECTION_FIXTURES, + ids=[f.test_id for f in IS_EXAMPLES_SECTION_FIXTURES], +) +def test_is_examples_section( + test_id: str, + node_type: str, + section_ids: list[str], + expected: bool, +) -> None: + """Test examples section detection.""" + node = _make_section_node(node_type, section_ids) + assert _is_examples_section(node) == expected + + +# --- _reorder_nodes tests --- + + +def _make_usage_node(text: str = "usage: cmd [-h]") -> nodes.literal_block: + """Create a usage block node. + + Parameters + ---------- + text : str + Text content for the usage block. + + Returns + ------- + nodes.literal_block + A literal block node with usage text. + """ + return nodes.literal_block(text=text) + + +def _make_examples_section(section_id: str = "examples") -> nodes.section: + """Create an examples section node. + + Parameters + ---------- + section_id : str + The ID for the section. + + Returns + ------- + nodes.section + A section node with the specified ID. + """ + section = nodes.section() + section["ids"] = [section_id] + return section + + +def test_reorder_nodes_usage_after_examples() -> None: + """Usage block after examples gets moved before examples.""" + desc = nodes.paragraph(text="Description") + examples = _make_examples_section() + usage = _make_usage_node() + + # Create a non-examples section + args_section = nodes.section() + args_section["ids"] = ["arguments"] + + result = _reorder_nodes([desc, examples, usage, args_section]) + + # Should be: desc, usage, examples, args + assert len(result) == 4 + assert isinstance(result[0], nodes.paragraph) + assert isinstance(result[1], nodes.literal_block) + assert isinstance(result[2], nodes.section) + assert result[2]["ids"] == ["examples"] + assert isinstance(result[3], nodes.section) + assert result[3]["ids"] == ["arguments"] + + +def test_reorder_nodes_no_examples() -> None: + """Without examples, original order is preserved.""" + desc = nodes.paragraph(text="Description") + usage = _make_usage_node() + args = nodes.section() + args["ids"] = ["arguments"] + + result = _reorder_nodes([desc, usage, args]) + + # Order unchanged: desc, usage, args + assert len(result) == 3 + assert isinstance(result[0], nodes.paragraph) + assert isinstance(result[1], nodes.literal_block) + assert isinstance(result[2], nodes.section) + + +def test_reorder_nodes_usage_already_before_examples() -> None: + """When usage is already before examples, order is preserved.""" + desc = nodes.paragraph(text="Description") + usage = _make_usage_node() + examples = _make_examples_section() + args = nodes.section() + args["ids"] = ["arguments"] + + result = _reorder_nodes([desc, usage, examples, args]) + + # Order should be: desc, usage, examples, args + assert len(result) == 4 + assert isinstance(result[0], nodes.paragraph) + assert isinstance(result[1], nodes.literal_block) + assert isinstance(result[2], nodes.section) + assert result[2]["ids"] == ["examples"] + + +def test_reorder_nodes_empty_list() -> None: + """Empty input returns empty output.""" + result = _reorder_nodes([]) + assert result == [] + + +def test_reorder_nodes_multiple_usage_blocks() -> None: + """Multiple usage blocks are all moved before examples.""" + desc = nodes.paragraph(text="Description") + examples = _make_examples_section() + usage1 = _make_usage_node("usage: cmd1 [-h]") + usage2 = _make_usage_node("usage: cmd2 [-v]") + + result = _reorder_nodes([desc, examples, usage1, usage2]) + + # Should be: desc, usage1, usage2, examples + assert len(result) == 4 + assert isinstance(result[0], nodes.paragraph) + assert isinstance(result[1], nodes.literal_block) + assert isinstance(result[2], nodes.literal_block) + assert isinstance(result[3], nodes.section) + + +def test_reorder_nodes_multiple_examples_sections() -> None: + """Multiple examples sections are grouped together.""" + desc = nodes.paragraph(text="Description") + examples1 = _make_examples_section("examples") + usage = _make_usage_node() + examples2 = _make_examples_section("machine-readable-output-examples") + args = nodes.section() + args["ids"] = ["arguments"] + + result = _reorder_nodes([desc, examples1, usage, examples2, args]) + + # Should be: desc, usage, examples1, examples2, args + assert len(result) == 5 + assert isinstance(result[0], nodes.paragraph) + assert isinstance(result[1], nodes.literal_block) + assert result[2]["ids"] == ["examples"] + assert result[3]["ids"] == ["machine-readable-output-examples"] + assert result[4]["ids"] == ["arguments"] + + +def test_reorder_nodes_preserves_non_examples_after() -> None: + """Non-examples nodes after examples stay at the end.""" + desc = nodes.paragraph(text="Description") + examples = _make_examples_section() + usage = _make_usage_node() + epilog = nodes.paragraph(text="Epilog") + + result = _reorder_nodes([desc, examples, usage, epilog]) + + # Should be: desc, usage, examples, epilog + assert len(result) == 4 + assert result[0].astext() == "Description" + assert isinstance(result[1], nodes.literal_block) + assert isinstance(result[2], nodes.section) + assert result[3].astext() == "Epilog" diff --git a/tests/docs/_ext/test_vcspull_console_lexer.py b/tests/docs/_ext/test_vcspull_console_lexer.py new file mode 100644 index 00000000..8ec101eb --- /dev/null +++ b/tests/docs/_ext/test_vcspull_console_lexer.py @@ -0,0 +1,158 @@ +"""Tests for vcspull_console_lexer Pygments extension.""" + +from __future__ import annotations + +import typing as t + +import pytest +from pygments.token import Token +from vcspull_console_lexer import ( # type: ignore[import-not-found] + VcspullConsoleLexer, +) + +# --- Console session tests --- + + +class ConsoleSessionFixture(t.NamedTuple): + """Test fixture for console session patterns.""" + + test_id: str + input_text: str + expected_tokens: list[tuple[t.Any, str]] + + +CONSOLE_SESSION_FIXTURES: list[ConsoleSessionFixture] = [ + ConsoleSessionFixture( + test_id="command_with_list_output", + input_text="$ vcspull list\n• flask → ~/code/flask\n", + expected_tokens=[ + (Token.Generic.Prompt, "$ "), + (Token.Text, "vcspull"), # BashLexer tokenizes as Text + (Token.Comment, "•"), + (Token.Name.Function, "flask"), + (Token.Comment, "→"), + (Token.Name.Variable, "~/code/flask"), + ], + ), + ConsoleSessionFixture( + test_id="command_with_status_output", + input_text="$ vcspull status\n✓ flask: up to date\n", + expected_tokens=[ + (Token.Generic.Prompt, "$ "), + (Token.Text, "vcspull"), # BashLexer tokenizes as Text + (Token.Generic.Inserted, "✓"), + (Token.Name.Function, "flask"), + (Token.Punctuation, ":"), + (Token.Generic.Inserted, "up to date"), + ], + ), + ConsoleSessionFixture( + test_id="command_with_sync_output", + input_text="$ vcspull sync\n+ new-repo ~/code/new-repo\n", + expected_tokens=[ + (Token.Generic.Prompt, "$ "), + (Token.Text, "vcspull"), # BashLexer tokenizes as Text + (Token.Generic.Inserted, "+"), + (Token.Name.Function, "new-repo"), + (Token.Name.Variable, "~/code/new-repo"), + ], + ), + ConsoleSessionFixture( + test_id="tree_view_with_workspace_header", + input_text="$ vcspull list --tree\n~/code/\n • flask → ~/code/flask\n", + expected_tokens=[ + (Token.Generic.Prompt, "$ "), + (Token.Text, "vcspull"), # BashLexer tokenizes as Text + (Token.Generic.Subheading, "~/code/"), + (Token.Comment, "•"), + (Token.Name.Function, "flask"), + (Token.Comment, "→"), + (Token.Name.Variable, "~/code/flask"), + ], + ), +] + + +@pytest.mark.parametrize( + ConsoleSessionFixture._fields, + CONSOLE_SESSION_FIXTURES, + ids=[f.test_id for f in CONSOLE_SESSION_FIXTURES], +) +def test_console_session( + test_id: str, + input_text: str, + expected_tokens: list[tuple[t.Any, str]], +) -> None: + """Test console session tokenization.""" + lexer = VcspullConsoleLexer() + tokens = [(t, v) for t, v in lexer.get_tokens(input_text) if v.strip()] + for expected_token, expected_value in expected_tokens: + assert (expected_token, expected_value) in tokens, ( + f"Expected ({expected_token}, {expected_value!r}) not found in tokens" + ) + + +# --- Prompt handling tests --- + + +def test_prompt_detection() -> None: + """Test that shell prompts are detected and tokenized.""" + lexer = VcspullConsoleLexer() + text = "$ vcspull list\n• flask → ~/code/flask\n" + tokens = list(lexer.get_tokens(text)) + + # Check that prompt is detected + prompt_tokens = [(t, v) for t, v in tokens if t == Token.Generic.Prompt] + assert len(prompt_tokens) == 1 + assert prompt_tokens[0][1] == "$ " + + +def test_multiline_output() -> None: + """Test multiline vcspull output tokenization.""" + text = """$ vcspull list --tree +~/work/python/ + • flask → ~/work/python/flask + • requests → ~/work/python/requests +""" + lexer = VcspullConsoleLexer() + tokens = [(t, v) for t, v in lexer.get_tokens(text) if v.strip()] + + # Check key tokens + assert (Token.Generic.Prompt, "$ ") in tokens + assert (Token.Generic.Subheading, "~/work/python/") in tokens + assert (Token.Name.Function, "flask") in tokens + assert (Token.Name.Function, "requests") in tokens + + +def test_warning_and_error_output() -> None: + """Test warning and error symbols in output.""" + text = """$ vcspull status +✓ good-repo: up to date +⚠ dirty-repo: dirty +✗ missing-repo: missing +""" + lexer = VcspullConsoleLexer() + tokens = [(t, v) for t, v in lexer.get_tokens(text) if v.strip()] + + # Check success + assert (Token.Generic.Inserted, "✓") in tokens + assert (Token.Generic.Inserted, "up to date") in tokens + + # Check warning + assert (Token.Name.Exception, "⚠") in tokens + assert (Token.Name.Exception, "dirty") in tokens + + # Check error + assert (Token.Generic.Error, "✗") in tokens + assert (Token.Generic.Error, "missing") in tokens + + +def test_command_only_no_output() -> None: + """Test command without output.""" + text = "$ vcspull list django flask\n" + lexer = VcspullConsoleLexer() + tokens = [(t, v) for t, v in lexer.get_tokens(text) if v.strip()] + + # Should have prompt and command tokens + assert (Token.Generic.Prompt, "$ ") in tokens + assert (Token.Text, "vcspull") in tokens # BashLexer tokenizes as Text diff --git a/tests/docs/_ext/test_vcspull_output_lexer.py b/tests/docs/_ext/test_vcspull_output_lexer.py new file mode 100644 index 00000000..1b4f159d --- /dev/null +++ b/tests/docs/_ext/test_vcspull_output_lexer.py @@ -0,0 +1,400 @@ +"""Tests for vcspull_output_lexer Pygments extension.""" + +from __future__ import annotations + +import typing as t + +import pytest +from pygments.token import Token +from vcspull_output_lexer import ( # type: ignore[import-not-found] + VcspullOutputLexer, + tokenize_output, +) + +# --- List output tests --- + + +class ListOutputFixture(t.NamedTuple): + """Test fixture for list output patterns.""" + + test_id: str + input_text: str + expected_tokens: list[tuple[t.Any, str]] + + +LIST_OUTPUT_FIXTURES: list[ListOutputFixture] = [ + ListOutputFixture( + test_id="basic_list_item", + input_text="• flask → ~/code/flask", + expected_tokens=[ + (Token.Comment, "•"), + (Token.Name.Function, "flask"), + (Token.Comment, "→"), + (Token.Name.Variable, "~/code/flask"), + ], + ), + ListOutputFixture( + test_id="path_with_plus", + input_text="• GeographicLib → ~/study/c++/GeographicLib", + expected_tokens=[ + (Token.Comment, "•"), + (Token.Name.Function, "GeographicLib"), + (Token.Comment, "→"), + (Token.Name.Variable, "~/study/c++/GeographicLib"), + ], + ), + ListOutputFixture( + test_id="repo_with_dots", + input_text="• pytest-django → ~/code/pytest-django", + expected_tokens=[ + (Token.Comment, "•"), + (Token.Name.Function, "pytest-django"), + (Token.Comment, "→"), + (Token.Name.Variable, "~/code/pytest-django"), + ], + ), +] + + +@pytest.mark.parametrize( + ListOutputFixture._fields, + LIST_OUTPUT_FIXTURES, + ids=[f.test_id for f in LIST_OUTPUT_FIXTURES], +) +def test_list_output( + test_id: str, + input_text: str, + expected_tokens: list[tuple[t.Any, str]], +) -> None: + """Test list command output tokenization.""" + lexer = VcspullOutputLexer() + tokens = [(t, v) for t, v in lexer.get_tokens(input_text) if v.strip()] + assert tokens == expected_tokens + + +# --- Status output tests --- + + +class StatusOutputFixture(t.NamedTuple): + """Test fixture for status output patterns.""" + + test_id: str + input_text: str + expected_tokens: list[tuple[t.Any, str]] + + +STATUS_OUTPUT_FIXTURES: list[StatusOutputFixture] = [ + StatusOutputFixture( + test_id="success_up_to_date", + input_text="✓ flask: up to date", + expected_tokens=[ + (Token.Generic.Inserted, "✓"), + (Token.Name.Function, "flask"), + (Token.Punctuation, ":"), + (Token.Generic.Inserted, "up to date"), + ], + ), + StatusOutputFixture( + test_id="error_missing", + input_text="✗ missing-repo: missing", + expected_tokens=[ + (Token.Generic.Error, "✗"), + (Token.Name.Function, "missing-repo"), + (Token.Punctuation, ":"), + (Token.Generic.Error, "missing"), + ], + ), + StatusOutputFixture( + test_id="warning_dirty", + input_text="⚠ dirty-repo: dirty", + expected_tokens=[ + (Token.Name.Exception, "⚠"), + (Token.Name.Function, "dirty-repo"), + (Token.Punctuation, ":"), + (Token.Name.Exception, "dirty"), + ], + ), + StatusOutputFixture( + test_id="warning_behind", + input_text="⚠ behind-repo: behind by 5", + expected_tokens=[ + (Token.Name.Exception, "⚠"), + (Token.Name.Function, "behind-repo"), + (Token.Punctuation, ":"), + (Token.Name.Exception, "behind by 5"), + ], + ), +] + + +@pytest.mark.parametrize( + StatusOutputFixture._fields, + STATUS_OUTPUT_FIXTURES, + ids=[f.test_id for f in STATUS_OUTPUT_FIXTURES], +) +def test_status_output( + test_id: str, + input_text: str, + expected_tokens: list[tuple[t.Any, str]], +) -> None: + """Test status command output tokenization.""" + lexer = VcspullOutputLexer() + tokens = [(t, v) for t, v in lexer.get_tokens(input_text) if v.strip()] + assert tokens == expected_tokens + + +# --- Sync output tests --- + + +class SyncOutputFixture(t.NamedTuple): + """Test fixture for sync output patterns.""" + + test_id: str + input_text: str + expected_tokens: list[tuple[t.Any, str]] + + +SYNC_OUTPUT_FIXTURES: list[SyncOutputFixture] = [ + SyncOutputFixture( + test_id="clone_with_url", + input_text="+ new-repo ~/code/new-repo git+https://github.com/user/repo", + expected_tokens=[ + (Token.Generic.Inserted, "+"), + (Token.Name.Function, "new-repo"), + (Token.Name.Variable, "~/code/new-repo"), + (Token.Name.Tag, "git+https://github.com/user/repo"), + ], + ), + SyncOutputFixture( + test_id="update_repo", + input_text="~ old-repo ~/code/old-repo", + expected_tokens=[ + (Token.Name.Exception, "~"), + (Token.Name.Function, "old-repo"), + (Token.Name.Variable, "~/code/old-repo"), + ], + ), + SyncOutputFixture( + test_id="unchanged_repo", + input_text="✓ stable ~/code/stable", + expected_tokens=[ + (Token.Generic.Inserted, "✓"), + (Token.Name.Function, "stable"), + (Token.Name.Variable, "~/code/stable"), + ], + ), +] + + +@pytest.mark.parametrize( + SyncOutputFixture._fields, + SYNC_OUTPUT_FIXTURES, + ids=[f.test_id for f in SYNC_OUTPUT_FIXTURES], +) +def test_sync_output( + test_id: str, + input_text: str, + expected_tokens: list[tuple[t.Any, str]], +) -> None: + """Test sync command output tokenization.""" + lexer = VcspullOutputLexer() + tokens = [(t, v) for t, v in lexer.get_tokens(input_text) if v.strip()] + assert tokens == expected_tokens + + +# --- Summary output tests --- + + +class SummaryOutputFixture(t.NamedTuple): + """Test fixture for summary output patterns.""" + + test_id: str + input_text: str + expected_tokens: list[tuple[t.Any, str]] + + +SUMMARY_OUTPUT_FIXTURES: list[SummaryOutputFixture] = [ + SummaryOutputFixture( + test_id="basic_summary", + input_text="Summary: 10 repositories, 8 exist, 2 missing", + expected_tokens=[ + (Token.Generic.Heading, "Summary:"), + (Token.Literal.Number.Integer, "10"), + (Token.Name.Label, "repositories"), + (Token.Punctuation, ","), + (Token.Literal.Number.Integer, "8"), + (Token.Name.Label, "exist"), + (Token.Punctuation, ","), + (Token.Literal.Number.Integer, "2"), + (Token.Name.Label, "missing"), + ], + ), +] + + +@pytest.mark.parametrize( + SummaryOutputFixture._fields, + SUMMARY_OUTPUT_FIXTURES, + ids=[f.test_id for f in SUMMARY_OUTPUT_FIXTURES], +) +def test_summary_output( + test_id: str, + input_text: str, + expected_tokens: list[tuple[t.Any, str]], +) -> None: + """Test summary line tokenization.""" + lexer = VcspullOutputLexer() + tokens = [(t, v) for t, v in lexer.get_tokens(input_text) if v.strip()] + assert tokens == expected_tokens + + +# --- Workspace header tests --- + + +class WorkspaceHeaderFixture(t.NamedTuple): + """Test fixture for workspace header patterns.""" + + test_id: str + input_text: str + expected_tokens: list[tuple[t.Any, str]] + + +WORKSPACE_HEADER_FIXTURES: list[WorkspaceHeaderFixture] = [ + WorkspaceHeaderFixture( + test_id="home_relative_path", + input_text="~/work/python/", + expected_tokens=[ + (Token.Generic.Subheading, "~/work/python/"), + ], + ), + WorkspaceHeaderFixture( + test_id="absolute_path", + input_text="/home/user/code/", + expected_tokens=[ + (Token.Generic.Subheading, "/home/user/code/"), + ], + ), +] + + +@pytest.mark.parametrize( + WorkspaceHeaderFixture._fields, + WORKSPACE_HEADER_FIXTURES, + ids=[f.test_id for f in WORKSPACE_HEADER_FIXTURES], +) +def test_workspace_header( + test_id: str, + input_text: str, + expected_tokens: list[tuple[t.Any, str]], +) -> None: + """Test workspace header tokenization.""" + lexer = VcspullOutputLexer() + tokens = [(t, v) for t, v in lexer.get_tokens(input_text) if v.strip()] + assert tokens == expected_tokens + + +# --- Multiline tests --- + + +def test_multiline_list_output() -> None: + """Test multiline list output with workspace header.""" + text = """~/work/python/ + • flask → ~/work/python/flask + • requests → ~/work/python/requests""" + + lexer = VcspullOutputLexer() + tokens = [(t, v) for t, v in lexer.get_tokens(text) if v.strip()] + + # Check key tokens are present + assert (Token.Generic.Subheading, "~/work/python/") in tokens + assert (Token.Name.Function, "flask") in tokens + assert (Token.Name.Function, "requests") in tokens + assert (Token.Name.Variable, "~/work/python/flask") in tokens + assert (Token.Name.Variable, "~/work/python/requests") in tokens + + +def test_multiline_sync_output() -> None: + """Test multiline sync plan output.""" + text = """~/work/python/ ++ new-lib ~/work/python/new-lib git+https://github.com/user/new-lib +~ old-lib ~/work/python/old-lib +✓ stable-lib ~/work/python/stable-lib""" + + lexer = VcspullOutputLexer() + tokens = [(t, v) for t, v in lexer.get_tokens(text) if v.strip()] + + # Check symbols + assert (Token.Generic.Inserted, "+") in tokens + assert (Token.Name.Exception, "~") in tokens + assert (Token.Generic.Inserted, "✓") in tokens + + # Check repo names + assert (Token.Name.Function, "new-lib") in tokens + assert (Token.Name.Function, "old-lib") in tokens + assert (Token.Name.Function, "stable-lib") in tokens + + +# --- tokenize_output helper tests --- + + +def test_tokenize_output_basic() -> None: + """Test the tokenize_output helper function.""" + result = tokenize_output("• flask → ~/code/flask") + assert result[0] == ("Token.Comment", "•") + assert ("Token.Name.Function", "flask") in result + assert ("Token.Comment", "→") in result + assert ("Token.Name.Variable", "~/code/flask") in result + + +def test_tokenize_output_empty() -> None: + """Test tokenize_output with empty string.""" + result = tokenize_output("") + # Should only have a trailing newline token + assert len(result) == 1 + assert result[0][0] == "Token.Text.Whitespace" + + +# --- URL and prompt tests --- + + +def test_url_in_parentheses() -> None: + """Test plain HTTPS URLs in parentheses are tokenized correctly.""" + text = " + pytest-docker (https://github.com/avast/pytest-docker)" + lexer = VcspullOutputLexer() + tokens = [(t, v) for t, v in lexer.get_tokens(text) if v.strip()] + + assert (Token.Generic.Inserted, "+") in tokens + assert (Token.Name.Function, "pytest-docker") in tokens + assert (Token.Punctuation, "(") in tokens + assert (Token.Name.Tag, "https://github.com/avast/pytest-docker") in tokens + assert (Token.Punctuation, ")") in tokens + + +def test_interactive_prompt() -> None: + """Test interactive prompt [y/N] patterns.""" + text = "? Import this repository? [y/N]: y" + lexer = VcspullOutputLexer() + tokens = [(t, v) for t, v in lexer.get_tokens(text) if v.strip()] + + assert (Token.Generic.Prompt, "?") in tokens + assert (Token.Comment, "[y/N]") in tokens + + +def test_vcspull_add_output() -> None: + """Test full vcspull add output with all patterns.""" + text = """Found new repository to import: + + pytest-docker (https://github.com/avast/pytest-docker) + • workspace: ~/study/python/ +? Import this repository? [y/N]: y""" + + lexer = VcspullOutputLexer() + tokens = [(t, v) for t, v in lexer.get_tokens(text) if v.strip()] + + # Check key tokens + assert (Token.Generic.Inserted, "+") in tokens + assert (Token.Name.Function, "pytest-docker") in tokens + assert (Token.Name.Tag, "https://github.com/avast/pytest-docker") in tokens + assert (Token.Comment, "•") in tokens + assert (Token.Generic.Heading, "workspace:") in tokens + assert (Token.Generic.Prompt, "?") in tokens + assert (Token.Comment, "[y/N]") in tokens diff --git a/uv.lock b/uv.lock index 4a5749dc..2d38e216 100644 --- a/uv.lock +++ b/uv.lock @@ -1316,6 +1316,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/95/3a/44ccbbfef6235aeea84c74041dc6dfee6c17ff3ddba782a0250e41687ec7/types_colorama-0.4.15.20250801-py3-none-any.whl", hash = "sha256:b6e89bd3b250fdad13a8b6a465c933f4a5afe485ea2e2f104d739be50b13eea9", size = 10743, upload-time = "2025-08-01T03:48:21.774Z" }, ] +[[package]] +name = "types-docutils" +version = "0.22.3.20251115" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/d7/576ec24bf61a280f571e1f22284793adc321610b9bcfba1bf468cf7b334f/types_docutils-0.22.3.20251115.tar.gz", hash = "sha256:0f79ea6a7bd4d12d56c9f824a0090ffae0ea4204203eb0006392906850913e16", size = 56828, upload-time = "2025-11-15T02:59:57.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/01/61ac9eb38f1f978b47443dc6fd2e0a3b0f647c2da741ddad30771f1b2b6f/types_docutils-0.22.3.20251115-py3-none-any.whl", hash = "sha256:c6e53715b65395d00a75a3a8a74e352c669bc63959e65a207dffaa22f4a2ad6e", size = 91951, upload-time = "2025-11-15T02:59:56.413Z" }, +] + +[[package]] +name = "types-pygments" +version = "2.19.0.20251121" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-docutils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/3b/cd650700ce9e26b56bd1a6aa4af397bbbc1784e22a03971cb633cdb0b601/types_pygments-2.19.0.20251121.tar.gz", hash = "sha256:eef114fde2ef6265365522045eac0f8354978a566852f69e75c531f0553822b1", size = 18590, upload-time = "2025-11-21T03:03:46.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/8a/9244b21f1d60dcc62e261435d76b02f1853b4771663d7ec7d287e47a9ba9/types_pygments-2.19.0.20251121-py3-none-any.whl", hash = "sha256:cb3bfde34eb75b984c98fb733ce4f795213bd3378f855c32e75b49318371bb25", size = 25674, upload-time = "2025-11-21T03:03:45.72Z" }, +] + [[package]] name = "types-pyyaml" version = "6.0.12.20250915" @@ -1422,6 +1443,8 @@ dev = [ { name = "sphinxext-rediraffe" }, { name = "syrupy" }, { name = "types-colorama" }, + { name = "types-docutils" }, + { name = "types-pygments" }, { name = "types-pyyaml" }, { name = "types-requests" }, ] @@ -1457,6 +1480,8 @@ testing = [ ] typings = [ { name = "types-colorama" }, + { name = "types-docutils" }, + { name = "types-pygments" }, { name = "types-pyyaml" }, { name = "types-requests" }, ] @@ -1499,6 +1524,8 @@ dev = [ { name = "sphinxext-rediraffe" }, { name = "syrupy" }, { name = "types-colorama" }, + { name = "types-docutils" }, + { name = "types-pygments" }, { name = "types-pyyaml" }, { name = "types-requests" }, ] @@ -1531,6 +1558,8 @@ testing = [ ] typings = [ { name = "types-colorama" }, + { name = "types-docutils" }, + { name = "types-pygments" }, { name = "types-pyyaml" }, { name = "types-requests" }, ]