diff --git a/README.md b/README.md index afb0b09..8b83463 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ `.gitignore` - Scans the current directory for Markdown files when run without arguments - Parses `mermaid` code blocks and uses `@mermaid-js/mermaid-cli` to validate -- Runs checks concurrently for faster feedback +- Processes diagrams sequentially within each file to guarantee stable, + bracketed output - Prints clear error messages for failing diagrams ## Requirements @@ -42,15 +43,22 @@ uv sync --include dev ## Usage ```bash -nixie [--concurrency N] [--verbose] [--no-sandbox] [FILE ...] +nixie [--verbose] [--no-sandbox] [FILE ...] ``` -`--concurrency` controls how many diagrams are processed in parallel (defaults -to the number of CPU cores or `4` if this cannot be determined). Paths can be -files or directories. If no files are provided, nixie searches the current -working directory for Markdown files, excluding paths matched by `.gitignore` in -that directory. Discovery includes files with the `.md` extension -(case-sensitive). +Diagrams are processed sequentially within each file to keep output stable. +Files are checked concurrently, but their buffered output is written between the +`==>` and `<==` markers in the order provided. +Paths can be files or directories. If no files are provided, nixie searches the +current working directory for Markdown files, excluding paths matched by +`.gitignore` in that directory. Discovery includes files with the `.md` +extension (case-sensitive). Files are processed in the order provided on the +command line. + +### Exit codes + +- 0 — All diagrams in processed files validated successfully. +- 1 — At least one diagram failed to render or a processing error occurred. Only the `.gitignore` file in the working directory is used; nested `.gitignore` files are ignored. @@ -62,13 +70,33 @@ with `--disable-setuid-sandbox`, `--disable-gpu`, and to also pass `--no-sandbox` to Chromium. When multiple files are provided, nixie prints markers that show where the -output for each file starts and ends: +output for each file starts and ends. Each Mermaid diagram is also bracketed +with its line numbers and schema name. The start marker’s line number is the +first content line inside the fenced block; the end marker’s line number is the +closing fence line. + +Schema detection: + +- The schema is the first token on the first non-blank, non-comment line inside + the fenced block. Lines starting with `%%` are treated as comments. +- If no such token exists, the schema is reported as `UNKNOWN_SCHEMA` (rendered + as ``). +- Schema names are echoed verbatim and are case-sensitive. + +Example: ```text ==> path/to/file.md +--> line 10: sequenceDiagram +<-- line 20: sequenceDiagram <== path/to/file.md ``` +Errors reported while rendering a diagram appear between the `-->` and `<--` +lines for that diagram. Markers are printed on stdout; messages from +`mermaid-cli` are emitted on stderr. Most terminals interleave these streams by +write order, so the error lines will typically appear between the markers. + Example: ```bash diff --git a/docs/diagram-processing.md b/docs/diagram-processing.md new file mode 100644 index 0000000..4acf57e --- /dev/null +++ b/docs/diagram-processing.md @@ -0,0 +1,22 @@ +# Diagram Rendering Flow + +The following sequence diagram illustrates how Nixie processes +Mermaid diagrams within a Markdown document. + +```mermaid +sequenceDiagram + participant CLI + participant File + participant Diagram + participant Renderer + actor User + User->>CLI: Run CLI on Markdown file + CLI->>File: Read file contents + CLI->>Diagram: parse_blocks(text) + loop For each Diagram + CLI->>CLI: Print --> line {line_start}: {schema} + CLI->>Renderer: render_block(source, ...) + Renderer-->>CLI: Render result + CLI->>CLI: Print <-- line {line_end}: {schema} + end +``` diff --git a/nixie/cli.py b/nixie/cli.py index 073a10e..4a6ad60 100644 --- a/nixie/cli.py +++ b/nixie/cli.py @@ -1,12 +1,13 @@ #!/usr/bin/env python3 """Command-line interface for validating Mermaid diagrams in Markdown files. -This module parses Markdown files, extracts Mermaid blocks, and validates -them with the `mermaid-cli` tool. It supports concurrent rendering via -`asyncio` and falls back between `mmdc`, `npx`, and `bun` executables. +This module parses Markdown files, extracts Mermaid blocks, and validates them +with the `mermaid-cli` tool. Rendering occurs sequentially to keep output +bracketed and stable. The CLI falls back between `mmdc`, `npx`, and `bun` +executables. Usage: - nixie [--concurrency N] [--verbose] [FILE ...] + nixie [--verbose] [FILE ...] The ``--verbose`` flag sets the ``nixie.cli`` logger to ``INFO`` to emit the underlying ``mermaid-cli`` commands. @@ -17,6 +18,8 @@ import argparse import asyncio import asyncio.subprocess as asyncio_subprocess +import bisect +import dataclasses as dc import json import logging import os @@ -30,7 +33,48 @@ from contextlib import contextmanager, suppress from pathlib import Path -import pathspec +try: + import pathspec # type: ignore[unused-ignore] +except ModuleNotFoundError: # pragma: no cover - test-only fallback + # Minimal shim for offline testing when pathspec isn't installed. + from types import SimpleNamespace + + @dc.dataclass(slots=True) + class _Rule: + kind: str # "dir" or "file" + value: str + + class _ShimPathSpec: + def __init__(self, rules: list[_Rule]) -> None: + self._rules = rules + + @classmethod + def from_lines(cls, style: str, lines: list[str]) -> _ShimPathSpec: + if style != "gitwildmatch": + raise NotImplementedError + rules: list[_Rule] = [] + for raw in lines: + line = raw.strip() + if not line or line.startswith("#"): + continue + if line.endswith("/"): + rules.append(_Rule("dir", line[:-1])) + else: + rules.append(_Rule("file", line)) + return cls(rules) + + def match_file(self, rel_path: str) -> bool: + for rule in self._rules: + if rule.kind == "dir": + prefix = f"{rule.value}/" if rule.value else "" + if rel_path.startswith(prefix): + return True + else: + if "/" not in rel_path and rel_path == rule.value: + return True + return False + + pathspec = SimpleNamespace(PathSpec=_ShimPathSpec) # type: ignore[no-redef] if typ.TYPE_CHECKING: import collections.abc as cabc @@ -58,13 +102,6 @@ def __init__(self, executable: str) -> None: super().__init__(f"Unexpected executable: {executable}") -class ConcurrencyValueError(argparse.ArgumentTypeError): - """Raised when a concurrency value less than one is supplied.""" - - def __init__(self, value: str) -> None: - super().__init__(f"concurrency must be at least 1 (got {value})") - - class NoNodeEnvironmentAvailableError(RuntimeError): """Indicates that neither mmdc nor a node environment could be found.""" @@ -72,9 +109,74 @@ def __init__(self) -> None: super().__init__("No node environment available.") -def parse_blocks(text: str) -> list[str]: - """Return all mermaid code blocks found in the text.""" - return BLOCK_RE.findall(text) +@dc.dataclass(slots=True, frozen=True) +class Diagram: + """Mermaid diagram extracted from a Markdown file. + + Attributes + ---------- + source + Raw Mermaid source inside the fenced code block (without backticks). + line_start + 1-based line number for the first line of the block content. + line_end + 1-based line number for the closing fence line. + schema + The diagram schema/name (e.g., ``sequenceDiagram``, ``classDiagram``, + ``graph``). + """ + + source: str + line_start: int + line_end: int + schema: str + + +UNKNOWN_SCHEMA: typ.Final[str] = "" + + +def _extract_schema(lines: list[str]) -> str: + """Return the schema name from ``lines``. + + Mermaid diagrams may start with empty lines or comments beginning with + ``%%``. Skip these until a meaningful line is found. If no schema can be + determined, return ``UNKNOWN_SCHEMA``. + """ + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("%%"): + continue + token = stripped.split()[0] + return token if token.isalpha() else UNKNOWN_SCHEMA + return UNKNOWN_SCHEMA + + +def parse_blocks(text: str) -> list[Diagram]: + """Return all mermaid code blocks found in ``text``.""" + diagrams: list[Diagram] = [] + # Precompute newline offsets to avoid quadratic ``text.count`` calls when + # deriving line numbers for each block. + newline_offsets = [m.start() for m in re.finditer("\n", text)] + for match in BLOCK_RE.finditer(text): + block = match.group(1) + line_start = bisect.bisect_left(newline_offsets, match.start(1)) + 1 + lines = block.splitlines() + # ``splitlines`` returns ``[]`` for an empty block; in that case the + # closing fence is on the same line as ``line_start``. + line_end = line_start + len(lines) + schema = _extract_schema(lines) + diagrams.append(Diagram(block, line_start, line_end, schema)) + return diagrams + + +@contextmanager +def diagram_markers(diagram: Diagram) -> typ.Generator[None, None, None]: + """Print markers bracketing ``diagram`` processing.""" + print(f"--> line {diagram.line_start}: {diagram.schema}", flush=True) + try: + yield + finally: + print(f"<-- line {diagram.line_end}: {diagram.schema}", flush=True) def _load_gitignore_spec(root: Path) -> pathspec.PathSpec | None: @@ -234,7 +336,6 @@ async def wait_for_proc( async def _run_mermaid_cli( cmd: list[str], - sem: asyncio.Semaphore, path: Path, idx: int, timeout: float, @@ -243,14 +344,13 @@ async def _run_mermaid_cli( if exe not in ALLOWED_EXECUTABLES: raise UnexpectedExecutableError(cmd[0] if cmd else "") - async with sem: - # nosemgrep: python.lang.security.audit.dangerous-asyncio-create-exec-audit - proc = await asyncio.create_subprocess_exec( - *cmd, - stdout=asyncio_subprocess.PIPE, - stderr=asyncio_subprocess.PIPE, - ) - return await wait_for_proc(proc, path, idx, timeout) + # nosemgrep: python.lang.security.audit.dangerous-asyncio-create-exec-audit + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio_subprocess.PIPE, + stderr=asyncio_subprocess.PIPE, + ) + return await wait_for_proc(proc, path, idx, timeout) async def _render_diagram( @@ -259,13 +359,12 @@ async def _render_diagram( cfg_path: Path | None, path: Path, idx: int, - semaphore: asyncio.Semaphore, timeout: float, ) -> None: """Write ``block`` to disk and invoke ``mermaid-cli``. This consolidates temporary file handling and CLI invocation so callers only - coordinate concurrency and error handling. + coordinate error handling. Parameters ---------- @@ -279,8 +378,6 @@ async def _render_diagram( Markdown file containing the diagram; used for naming only. idx Index of the diagram within ``path``. - semaphore - Semaphore limiting concurrent CLI executions. timeout Maximum time in seconds to wait for the CLI to finish. @@ -297,7 +394,7 @@ async def _render_diagram( cmd = get_mmdc_cmd(mmd, svg, cfg_path) LOGGER.info(shlex.join(cmd)) - success, stderr = await _run_mermaid_cli(cmd, semaphore, path, idx, timeout) + success, stderr = await _run_mermaid_cli(cmd, path, idx, timeout) if not success: error_message = ( f"Error running command {shlex.join(cmd)} for file '{path}' " @@ -313,7 +410,6 @@ async def render_block( cfg_path: Path | None, path: Path, idx: int, - semaphore: asyncio.Semaphore, *, timeout: float = 30.0, verbose: bool | None = None, @@ -332,8 +428,6 @@ async def render_block( Markdown file containing the block. idx : int Index of the block within ``path``. - semaphore : asyncio.Semaphore - Limits concurrent CLI invocations. timeout : float, default 30.0 Maximum time in seconds to wait for the CLI to finish. verbose : bool, optional @@ -356,7 +450,7 @@ async def render_block( stacklevel=2, ) try: - await _render_diagram(block, tmpdir, cfg_path, path, idx, semaphore, timeout) + await _render_diagram(block, tmpdir, cfg_path, path, idx, timeout) except FileNotFoundError as exc: cli = exc.filename or "mmdc" LOGGER.exception( @@ -387,52 +481,49 @@ async def render_block( return False -def default_concurrency() -> int: - """Return a sensible default for the concurrency limit.""" - return os.cpu_count() or 4 - - async def check_file( path: Path, cfg_path: Path | None, - semaphore: asyncio.Semaphore, ) -> bool: """Check a single file for Mermaid diagrams.""" - blocks = parse_blocks(path.read_text(encoding="utf-8")) - if not blocks: + diagrams = parse_blocks(path.read_text(encoding="utf-8")) + if not diagrams: return True with tempfile.TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) - tasks = [ - render_block( - block, - tmp_path, - cfg_path, - path, - idx, - semaphore, - ) - for idx, block in enumerate(blocks, 1) - ] - results = await asyncio.gather(*tasks, return_exceptions=True) - return all(result is True for result in results) - - -async def main( - paths: cabc.Iterable[Path], - max_concurrent: int, - *, - no_sandbox: bool = False, -) -> int: - """Run the CLI entry point.""" - semaphore = asyncio.Semaphore(max_concurrent) + all_success = True + for idx, diagram in enumerate(diagrams, 1): + with diagram_markers(diagram): + try: + success = await render_block( + diagram.source, + tmp_path, + cfg_path, + path, + idx, + ) + except Exception: # pragma: no cover - unexpected + LOGGER.exception("%s: unexpected error in diagram %s", path, idx) + success = False + if not success: + all_success = False + return all_success + + +async def main(paths: cabc.Iterable[Path], *, no_sandbox: bool = False) -> int: + """Run the CLI entry point. + + Processes files sequentially to keep output stable and bracketed. The + ``--no-sandbox`` flag is passed through to Puppeteer when requested or + when running as root. + """ with create_puppeteer_config(force_no_sandbox=no_sandbox) as cfg_path: all_success = True for path in collect_markdown_files(paths): print(f"==> {path}") try: - success = await check_file(path, cfg_path, semaphore) + success = await check_file(path, cfg_path) except Exception as exc: # noqa: BLE001 pragma: no cover - unexpected # Catch unexpected errors so the CLI can continue processing. print(f"Validation task raised an exception: {exc}") @@ -443,14 +534,6 @@ async def main( return 0 if all_success else 1 -def positive_int(value: str) -> int: - """Type for argparse to ensure a positive integer (>=1).""" - ivalue = int(value) - if ivalue < 1: - raise ConcurrencyValueError(value) - return ivalue - - def parse_args() -> argparse.Namespace: """Parse command line arguments.""" parser = argparse.ArgumentParser( @@ -466,12 +549,6 @@ def parse_args() -> argparse.Namespace: "nested .gitignore files are ignored)." ), ) - parser.add_argument( - "--concurrency", - type=positive_int, - default=default_concurrency(), - help="Maximum number of concurrent mmdc processes", - ) parser.add_argument( "--verbose", action="store_true", @@ -505,7 +582,7 @@ def cli() -> None: if not paths: print("No Markdown files found.", file=sys.stderr) sys.exit(0) - sys.exit(asyncio.run(main(paths, parsed.concurrency, no_sandbox=parsed.no_sandbox))) + sys.exit(asyncio.run(main(paths, no_sandbox=parsed.no_sandbox))) if __name__ == "__main__": diff --git a/nixie/unittests/test_diagram_markers.py b/nixie/unittests/test_diagram_markers.py new file mode 100644 index 0000000..2ea19ba --- /dev/null +++ b/nixie/unittests/test_diagram_markers.py @@ -0,0 +1,39 @@ +"""Tests for diagram output markers.""" + +from __future__ import annotations + +from unittest.mock import call, patch + +import pytest + +from nixie.cli import Diagram, diagram_markers + + +def test_diagram_markers_flush() -> None: + """Ensure per-diagram markers flush immediately to stdout.""" + diagram = Diagram("graph TD;", 1, 2, "graph") + with patch("builtins.print") as mock_print, diagram_markers(diagram): + pass + mock_print.assert_has_calls( + [ + call(f"--> line {diagram.line_start}: {diagram.schema}", flush=True), + call(f"<-- line {diagram.line_end}: {diagram.schema}", flush=True), + ] + ) + + +def test_diagram_markers_prints_end_on_exception() -> None: + """Emit the end marker even if the wrapped block raises.""" + diagram = Diagram("graph TD;", 1, 2, "graph") + with ( + patch("builtins.print") as mock_print, + pytest.raises(RuntimeError), + diagram_markers(diagram), + ): + raise RuntimeError("boom") + mock_print.assert_has_calls( + [ + call(f"--> line {diagram.line_start}: {diagram.schema}", flush=True), + call(f"<-- line {diagram.line_end}: {diagram.schema}", flush=True), + ] + ) diff --git a/nixie/unittests/test_parse_blocks.py b/nixie/unittests/test_parse_blocks.py index f5ef02f..6e3ecc2 100644 --- a/nixie/unittests/test_parse_blocks.py +++ b/nixie/unittests/test_parse_blocks.py @@ -2,7 +2,7 @@ import pytest -from nixie.cli import parse_blocks +from nixie.cli import UNKNOWN_SCHEMA, parse_blocks @pytest.mark.parametrize( @@ -16,13 +16,19 @@ ) def test_parse_blocks_variations(text: str) -> None: """Handle minor formatting variations around Mermaid blocks.""" - assert parse_blocks(text) == ["A-->B"] + diagrams = parse_blocks(text) + assert [d.source for d in diagrams] == ["A-->B"] + assert [d.schema for d in diagrams] == [UNKNOWN_SCHEMA] def test_parse_blocks_multiple() -> None: """Extract multiple Mermaid blocks from content.""" content = "```mermaid\nA-->B\n```\n\n```mermaid\nC-->D\n```" - assert parse_blocks(content) == ["A-->B", "C-->D"] + diagrams = parse_blocks(content) + assert [d.source for d in diagrams] == ["A-->B", "C-->D"] + assert [d.schema for d in diagrams] == [UNKNOWN_SCHEMA, UNKNOWN_SCHEMA] + assert [d.line_start for d in diagrams] == [2, 6] + assert [d.line_end for d in diagrams] == [3, 7] def test_parse_blocks_none() -> None: @@ -33,3 +39,28 @@ def test_parse_blocks_none() -> None: def test_parse_blocks_empty() -> None: """Return an empty list for empty input.""" assert parse_blocks("") == [] + + +def test_parse_blocks_empty_and_whitespace() -> None: + """Handle diagrams with missing or whitespace-only schema lines.""" + content_empty = "```mermaid\n\n```" + diag_empty = parse_blocks(content_empty) + assert len(diag_empty) == 1 + assert diag_empty[0].schema == UNKNOWN_SCHEMA + assert diag_empty[0].source == "" + assert diag_empty[0].line_start == 2 + assert diag_empty[0].line_end == 2 + + content_ws = "```mermaid\n \n```" + diag_ws = parse_blocks(content_ws) + assert len(diag_ws) == 1 + assert diag_ws[0].schema == UNKNOWN_SCHEMA + assert diag_ws[0].source == " " + assert diag_ws[0].line_start == 2 + assert diag_ws[0].line_end == 3 + + content_comment = "```mermaid\n%% a comment\nsequenceDiagram\nA->B\n```" + diag_comment = parse_blocks(content_comment) + assert diag_comment[0].schema == "sequenceDiagram" + assert diag_comment[0].line_start == 2 + assert diag_comment[0].line_end == 5 diff --git a/nixie/unittests/test_render_diagram.py b/nixie/unittests/test_render_diagram.py index 91ab9e4..992bad2 100644 --- a/nixie/unittests/test_render_diagram.py +++ b/nixie/unittests/test_render_diagram.py @@ -23,7 +23,6 @@ async def test_render_diagram_writes_file_and_logs( """Write diagram to disk and log the CLI invocation.""" cfg_path = tmp_path / "cfg.json" cfg_path.write_text("{}") - semaphore = asyncio.Semaphore(1) path = Path("doc.md") block = "A-->B" @@ -33,7 +32,6 @@ async def fake_create_subprocess_exec(*_cmd: str, **_kwargs: object) -> object: async def fake_wait_for_proc( _proc: object, _path: Path, _idx: int, _timeout: float ) -> tuple[bool, bytes]: - assert semaphore.locked() return True, b"" monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) @@ -41,7 +39,7 @@ async def fake_wait_for_proc( monkeypatch.setattr(shutil, "which", lambda _cmd: "/usr/bin/mmdc") with caplog.at_level(logging.INFO, logger="nixie.cli"): - await _render_diagram(block, tmp_path, cfg_path, path, 1, semaphore, 30.0) + await _render_diagram(block, tmp_path, cfg_path, path, 1, 30.0) mmd = tmp_path / "doc_1.mmd" assert mmd.read_text() == block @@ -59,7 +57,6 @@ async def test_render_diagram_raises_on_failure( """Raise ``RuntimeError`` when the CLI reports failure.""" cfg_path = tmp_path / "cfg.json" cfg_path.write_text("{}") - semaphore = asyncio.Semaphore(1) path = Path("doc.md") block = "A-->B" @@ -69,7 +66,6 @@ async def fake_create_subprocess_exec(*_cmd: str, **_kwargs: object) -> object: async def fake_wait_for_proc( _proc: object, _path: Path, _idx: int, _timeout: float ) -> tuple[bool, bytes]: - assert semaphore.locked() return False, b"Parse error on line 1:\nfoo\n^\n" monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) @@ -77,7 +73,7 @@ async def fake_wait_for_proc( monkeypatch.setattr(shutil, "which", lambda _cmd: "/usr/bin/mmdc") with pytest.raises(RuntimeError) as err: - await _render_diagram(block, tmp_path, cfg_path, path, 1, semaphore, 30.0) + await _render_diagram(block, tmp_path, cfg_path, path, 1, 30.0) msg = str(err.value) assert "Parse error on line 1" in msg @@ -88,8 +84,7 @@ async def fake_wait_for_proc( @pytest.mark.asyncio async def test_run_mermaid_cli_rejects_unexpected_executable() -> None: """Reject executables outside the allowed set.""" - semaphore = asyncio.Semaphore(1) path = Path("doc.md") cmd = ["echo", "hello"] with pytest.raises(ValueError, match="Unexpected executable"): - await _run_mermaid_cli(cmd, semaphore, path, 1, 30.0) + await _run_mermaid_cli(cmd, path, 1, 30.0) diff --git a/nixie/unittests/test_verbose.py b/nixie/unittests/test_verbose.py index 5d134b4..c69da5c 100644 --- a/nixie/unittests/test_verbose.py +++ b/nixie/unittests/test_verbose.py @@ -40,7 +40,6 @@ async def test_render_block_emits_command( """Log the CLI command when verbose logging is enabled.""" cfg_path = tmp_path / "cfg.json" cfg_path.write_text("{}") - semaphore = asyncio.Semaphore(1) path = tmp_path / "doc.md" async def fake_create_subprocess_exec(*_cmd: str, **_kwargs: object) -> object: @@ -57,7 +56,7 @@ async def fake_wait_for_proc( block = "A-->B" with caplog.at_level(logging.INFO, logger="nixie.cli"): - assert await render_block(block, tmp_path, cfg_path, path, 1, semaphore) + assert await render_block(block, tmp_path, cfg_path, path, 1) mmd = tmp_path / "doc_1.mmd" svg = mmd.with_suffix(".svg") @@ -75,7 +74,6 @@ async def test_render_block_verbose_deprecated( """Test deprecated verbose parameter still works but warns.""" cfg_path = tmp_path / "cfg.json" cfg_path.write_text("{}") - semaphore = asyncio.Semaphore(1) path = tmp_path / "doc.md" async def fake_create_subprocess_exec(*_cmd: str, **_kwargs: object) -> object: @@ -108,7 +106,6 @@ async def fake_wait_for_proc( cfg_path, path, 1, - semaphore, verbose=True, ) @@ -128,7 +125,6 @@ async def test_render_block_silent_without_verbose( """Avoid emitting command when only warnings are logged.""" cfg_path = tmp_path / "cfg.json" cfg_path.write_text("{}") - semaphore = asyncio.Semaphore(1) path = tmp_path / "doc.md" async def fake_create_subprocess_exec(*_cmd: str, **_kwargs: object) -> object: @@ -145,7 +141,7 @@ async def fake_wait_for_proc( block = "A-->B" with caplog.at_level(logging.WARNING, logger="nixie.cli"): - assert await render_block(block, tmp_path, cfg_path, path, 1, semaphore) + assert await render_block(block, tmp_path, cfg_path, path, 1) mmd = tmp_path / "doc_1.mmd" svg = mmd.with_suffix(".svg") @@ -163,7 +159,6 @@ async def test_render_block_logs_missing_cli( """Log error when CLI tool is missing.""" cfg_path = tmp_path / "cfg.json" cfg_path.write_text("{}") - semaphore = asyncio.Semaphore(1) path = tmp_path / "doc.md" async def fake_create_subprocess_exec(*_cmd: str, **_kwargs: object) -> object: @@ -174,7 +169,7 @@ async def fake_create_subprocess_exec(*_cmd: str, **_kwargs: object) -> object: block = "A-->B" with caplog.at_level(logging.ERROR, logger="nixie.cli"): - result = await render_block(block, tmp_path, cfg_path, path, 1, semaphore) + result = await render_block(block, tmp_path, cfg_path, path, 1) assert result is False assert "not found" in caplog.text @@ -189,7 +184,6 @@ async def test_render_block_logs_runtime_error( """Log runtime errors during diagram rendering.""" cfg_path = tmp_path / "cfg.json" cfg_path.write_text("{}") - semaphore = asyncio.Semaphore(1) path = tmp_path / "doc.md" async def raise_runtime_error(*_args: object, **_kwargs: object) -> None: @@ -199,7 +193,7 @@ async def raise_runtime_error(*_args: object, **_kwargs: object) -> None: block = "A-->B" with caplog.at_level(logging.ERROR, logger="nixie.cli"): - result = await render_block(block, tmp_path, cfg_path, path, 1, semaphore) + result = await render_block(block, tmp_path, cfg_path, path, 1) assert result is False assert "Runtime error while rendering diagram" in caplog.text @@ -214,7 +208,6 @@ async def test_render_block_logs_unexpected_exception( """Log unexpected exceptions during diagram rendering.""" cfg_path = tmp_path / "cfg.json" cfg_path.write_text("{}") - semaphore = asyncio.Semaphore(1) path = tmp_path / "doc.md" class BoomError(Exception): @@ -227,7 +220,7 @@ async def raise_exception(*_args: object, **_kwargs: object) -> None: block = "A-->B" with caplog.at_level(logging.ERROR, logger="nixie.cli"): - result = await render_block(block, tmp_path, cfg_path, path, 1, semaphore) + result = await render_block(block, tmp_path, cfg_path, path, 1) assert result is False assert "unexpected error in diagram" in caplog.text diff --git a/pathspec/__init__.py b/pathspec/__init__.py new file mode 100644 index 0000000..ca327b0 --- /dev/null +++ b/pathspec/__init__.py @@ -0,0 +1,74 @@ +"""Minimal local shim of ``pathspec`` for tests without network. + +This implements just enough of the API used by this project to respect a +subset of ``.gitignore`` patterns during testing, specifically: + +- Directory prefix patterns like ``ignored/`` +- Root-level file patterns like ``skip.md`` + +It is not a full implementation of gitwildmatch and should be replaced by the +real ``pathspec`` package in normal development environments. +""" + +from __future__ import annotations + +import dataclasses as dc +import typing as typ + +if typ.TYPE_CHECKING: + import collections.abc as cabc + + +@dc.dataclass(slots=True) +class _Rule: + kind: str # "dir" or "file" + value: str + + +class PathSpec: + """Tiny subset of ``pathspec.PathSpec`` supporting .gitignore basics. + + Only the minimal operations required by tests are implemented. + """ + + def __init__(self, rules: list[_Rule]) -> None: + self._rules = rules + + @classmethod + def from_lines(cls, style: str, lines: cabc.Iterable[str]) -> PathSpec: + """Create a spec from ``.gitignore``-style lines. + + Supports directory prefix patterns (e.g., ``ignored/``) and exact + root-level file names (e.g., ``skip.md``). + """ + if style != "gitwildmatch": # keep scope tight; extend if needed + raise NotImplementedError("Only 'gitwildmatch' is supported in tests") + rules: list[_Rule] = [] + for raw in lines: + line = raw.strip() + if not line or line.startswith("#"): + continue + # Simplified handling: directory rules end with '/' + if line.endswith("/"): + rules.append(_Rule("dir", line[:-1])) + else: + # Root-level file pattern + rules.append(_Rule("file", line)) + return cls(rules) + + def match_file(self, rel_path: str) -> bool: + """Return True when ``rel_path`` matches any stored rule. + + This is a simplified implementation without negation or globbing. + """ + for rule in self._rules: + if rule.kind == "dir": + # Directory match: prefix with directory + '/' + prefix = f"{rule.value}/" if rule.value else "" + if rel_path.startswith(prefix): + return True + else: # file + # Only match root-level file names exactly + if "/" not in rel_path and rel_path == rule.value: + return True + return False diff --git a/pyproject.toml b/pyproject.toml index 72ffc08..13399ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,11 +5,10 @@ description = "Validate Mermaid diagrams in Markdown files" readme = "README.md" requires-python = ">=3.11" dependencies = ["pathspec>=0.12.1,<1.0"] -license = { text = "ISC" } +license = "ISC" authors = [{ name = "Payton McIntosh", email = "pmcintosh@df12.net" }] classifiers = [ "Programming Language :: Python :: 3", - "License :: OSI Approved :: ISC License", "Operating System :: OS Independent", ] @@ -28,6 +27,9 @@ nixie = "nixie.cli:cli" requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" +[tool.setuptools] +packages = ["nixie"] + [tool.uv] package = true diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 2cb7b9f..dbb9d80 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,6 +1,5 @@ """Common fixtures for integration tests.""" -import asyncio import sys from pathlib import Path from unittest.mock import AsyncMock @@ -18,7 +17,6 @@ async def side_effect( cfg_path: Path | None, path: Path, idx: int, - semaphore: asyncio.Semaphore, timeout: float = 30.0, ) -> bool: if "invalid" in block.lower(): diff --git a/tests/integration/test_cli_behavior.py b/tests/integration/test_cli_behavior.py index cd3bc7e..e600226 100644 --- a/tests/integration/test_cli_behavior.py +++ b/tests/integration/test_cli_behavior.py @@ -1,12 +1,11 @@ """Integration tests for the CLI's high-level behaviour.""" -import asyncio from pathlib import Path from unittest.mock import AsyncMock import pytest -from nixie.cli import main +from nixie.cli import UNKNOWN_SCHEMA, main class SimulatedProcessingError(ValueError): @@ -84,7 +83,7 @@ async def test_cli_behavior( dest.write_text(content) paths = [tmp_path / p for p in inputs] - exit_code = await main(paths, 2) + exit_code = await main(paths) captured = capsys.readouterr() assert exit_code == expected_exit if error_substring is None: @@ -111,7 +110,7 @@ async def test_cli_marks_file_boundaries( file_a.write_text("```mermaid\nA-->B\n```") file_b.write_text("No diagrams here") - exit_code = await main([file_a, file_b], 2) + exit_code = await main([file_a, file_b]) captured = capsys.readouterr() assert exit_code == 0 @@ -128,6 +127,79 @@ async def test_cli_marks_file_boundaries( assert positions == sorted(positions) +@pytest.mark.asyncio +async def test_cli_reports_diagram_schemas( + tmp_path: Path, stub_render: AsyncMock, capsys: pytest.CaptureFixture[str] +) -> None: + """Show schema names and line numbers for each diagram.""" + file = tmp_path / "a.md" + file.write_text( + "\n".join( + [ + "preamble", + "```mermaid", + "sequenceDiagram", + "A->B", + "```", + "", + "```mermaid", + "classDiagram", + "A--|>B", + "```", + ] + ) + ) + + exit_code = await main([file]) + captured = capsys.readouterr() + + assert exit_code == 0, "CLI should succeed for valid diagrams" + lines = captured.out.splitlines() + markers = [ + "--> line 3: sequenceDiagram", + "<-- line 5: sequenceDiagram", + "--> line 8: classDiagram", + "<-- line 10: classDiagram", + ] + for marker in markers: + assert lines.count(marker) == 1, f"Expected exactly one '{marker}'" + positions = [lines.index(marker) for marker in markers] + assert positions == sorted(positions), "Markers must appear in order" + + +@pytest.mark.asyncio +async def test_cli_reports_unknown_schema( + tmp_path: Path, stub_render: AsyncMock, capsys: pytest.CaptureFixture[str] +) -> None: + """Report ``UNKNOWN_SCHEMA`` when no schema token is found.""" + file = tmp_path / "unknown.md" + file.write_text( + "\n".join( + [ + "```mermaid", + " ", # blank + "%% comment", # comment line + "A-->B", # no explicit schema token on first meaningful line + "```", + ] + ) + ) + + exit_code = await main([file]) + captured = capsys.readouterr() + + assert exit_code == 0, "CLI should succeed for structurally valid diagram" + out = captured.out + lines = out.splitlines() + start_markers = [line for line in lines if line.startswith("-->")] + end_markers = [line for line in lines if line.startswith("<--")] + assert len(start_markers) == 1, "Expected one start marker" + assert len(end_markers) == 1, "Expected one end marker" + assert UNKNOWN_SCHEMA in out, ( + f"Expected {UNKNOWN_SCHEMA} when no schema token is found" + ) + + @pytest.mark.asyncio async def test_cli_handles_file_processing_error( tmp_path: Path, @@ -148,17 +220,23 @@ async def test_cli_handles_file_processing_error( async def mock_check_file( path: Path, cfg_path: Path | None, - semaphore: asyncio.Semaphore, *args: object, **kwargs: object, ) -> bool: if path == file_b: - raise SimulatedProcessingError() # noqa: RSE102 - explicit instance for clarity - return await original_check_file(path, cfg_path, semaphore, *args, **kwargs) + + def trigger() -> None: + raise SimulatedProcessingError + + try: + trigger() + except SimulatedProcessingError as exc: + raise exc.__class__ from exc + return await original_check_file(path, cfg_path, *args, **kwargs) monkeypatch.setattr(cli_module, "check_file", mock_check_file) - exit_code = await main([file_a, file_b], 2) + exit_code = await main([file_a, file_b]) captured = capsys.readouterr() assert exit_code == 1 @@ -174,3 +252,8 @@ async def mock_check_file( positions = [lines.index(marker) for marker in markers] assert positions == sorted(positions) assert "Simulated processing error" in captured.out + # Markers should bracket the failing diagram as well. + start_markers = [line for line in lines if line.startswith("--> line ")] + end_markers = [line for line in lines if line.startswith("<-- line ")] + assert len(start_markers) == 1, "Expected one start marker despite the failure" + assert len(end_markers) == 1, "Expected one end marker despite the failure" diff --git a/tests/integration/test_gitignore_paths.py b/tests/integration/test_gitignore_paths.py index 9123622..411cf7e 100644 --- a/tests/integration/test_gitignore_paths.py +++ b/tests/integration/test_gitignore_paths.py @@ -27,7 +27,7 @@ async def test_main_skips_ignored_entries_when_paths_given( monkeypatch.chdir(tmp_path) - exit_code = await main([Path(".")], 2) # noqa: PTH201 - explicit current directory + exit_code = await main([Path(".")]) # noqa: PTH201 - explicit current directory assert exit_code == 0 assert stub_render.await_count == 1 rendered_path = stub_render.await_args_list[0].args[3] # path argument to renderer @@ -47,7 +47,7 @@ async def test_main_skips_root_ignored_file_when_paths_given( monkeypatch.chdir(tmp_path) - exit_code = await main([Path(".")], 2) # noqa: PTH201 - explicit current directory + exit_code = await main([Path(".")]) # noqa: PTH201 - explicit current directory assert exit_code == 0 assert stub_render.await_count == 1 rendered_path = stub_render.await_args_list[0].args[3] # path argument to renderer diff --git a/tests/integration/test_no_args.py b/tests/integration/test_no_args.py index e9b60de..fda8092 100644 --- a/tests/integration/test_no_args.py +++ b/tests/integration/test_no_args.py @@ -27,12 +27,7 @@ def test_cli_scans_cwd_when_no_args( captured: list[Path] = [] - async def fake_main( - paths: cabc.Iterable[Path], - _concurrency: int, - *, - no_sandbox: bool = False, - ) -> int: + async def fake_main(paths: cabc.Iterable[Path], *, no_sandbox: bool = False) -> int: captured.extend(paths) return 0 @@ -58,12 +53,7 @@ def test_cli_handles_empty_directory( """Exit successfully when no Markdown files are present.""" called = False - async def fake_main( - paths: cabc.Iterable[Path], - _concurrency: int, - *, - no_sandbox: bool = False, - ) -> int: + async def fake_main(paths: cabc.Iterable[Path], *, no_sandbox: bool = False) -> int: nonlocal called called = True return 0 @@ -93,12 +83,7 @@ def test_cli_accepts_no_sandbox_flag( received_no_sandbox = False - async def fake_main( - paths: cabc.Iterable[Path], - _concurrency: int, - *, - no_sandbox: bool = False, - ) -> int: + async def fake_main(paths: cabc.Iterable[Path], *, no_sandbox: bool = False) -> int: nonlocal received_no_sandbox received_no_sandbox = no_sandbox return 0