Skip to content

Diagnostics

diagnostics

Pure, bounded diagnostics for anchor mismatches.

When an anchor fails to match, the caller needs to know why: a tab where spaces were expected, a trailing space run, a non-breaking space, an em dash instead of a hyphen, or simply the wrong line. This module answers that question with side-effect-free helpers: no filesystem, no subprocess, no network. Inputs are already-read lines plus the anchor string.

Every output is bounded (MAX_SNIPPET_CHARS, MAX_DIAGNOSTIC_CHARS) and every scan is bounded (MAX_CANDIDATE_LINES) so a pathological input can neither blow up the message nor the runtime.

Candidate dataclass

A near-miss window found in the scanned lines.

Attributes:

Name Type Description
line int

1-based line number where the window starts.

ratio float

similarity ratio against the anchor, in [0.0, 1.0].

text str

the raw window text, terminators excluded.

Source code in packages/axm-edit/src/axm_edit/core/diagnostics.py
Python
@dataclass(frozen=True, slots=True)
class Candidate:
    """A near-miss window found in the scanned lines.

    Attributes:
        line: 1-based line number where the window starts.
        ratio: similarity ratio against the anchor, in ``[0.0, 1.0]``.
        text: the raw window text, terminators excluded.
    """

    line: int
    ratio: float
    text: str

NearMiss dataclass

A rendered explanation for an anchor that matched nothing.

Attributes:

Name Type Description
candidate Candidate | None

the closest window found, None when none is similar enough; exposed exactly as :func:closest_candidate produced it, so text stays raw and unrendered.

message str

a single bounded line naming the difference, every invisible or non-ASCII character replaced by its marker.

Source code in packages/axm-edit/src/axm_edit/core/diagnostics.py
Python
@dataclass(frozen=True, slots=True)
class NearMiss:
    """A rendered explanation for an anchor that matched nothing.

    Attributes:
        candidate: the closest window found, ``None`` when none is similar
            enough; exposed exactly as :func:`closest_candidate` produced it,
            so ``text`` stays raw and unrendered.
        message: a single bounded line naming the difference, every invisible
            or non-ASCII character replaced by its marker.
    """

    candidate: Candidate | None
    message: str

closest_candidate(lines, old)

Return the line window closest to the old anchor, or None.

The scan slides a window of the anchor's line count over the first MAX_CANDIDATE_LINES lines and keeps the best similarity ratio. A window below SIMILARITY_THRESHOLD is never reported: no best-effort guess. Ties are resolved deterministically in favour of the lowest 1-based line number, thanks to the strictly-greater comparison.

Source code in packages/axm-edit/src/axm_edit/core/diagnostics.py
Python
def closest_candidate(lines: Sequence[str], old: str) -> Candidate | None:
    """Return the line window closest to the ``old`` anchor, or ``None``.

    The scan slides a window of the anchor's line count over the first
    ``MAX_CANDIDATE_LINES`` lines and keeps the best similarity ratio. A window
    below ``SIMILARITY_THRESHOLD`` is never reported: no best-effort guess.
    Ties are resolved deterministically in favour of the lowest 1-based line
    number, thanks to the strictly-greater comparison.
    """
    anchor_lines = old.splitlines() or [old]
    span = len(anchor_lines)
    scanned = lines[:MAX_CANDIDATE_LINES]
    if span == 0 or len(scanned) < span:
        return None

    matcher: difflib.SequenceMatcher[str] = difflib.SequenceMatcher(autojunk=False)
    matcher.set_seq2("\n".join(anchor_lines))
    best: Candidate | None = None
    for start in range(len(scanned) - span + 1):
        window = "\n".join(scanned[start : start + span])
        matcher.set_seq1(window)
        if matcher.real_quick_ratio() < SIMILARITY_THRESHOLD:
            continue
        if matcher.quick_ratio() < SIMILARITY_THRESHOLD:
            continue
        ratio = matcher.ratio()
        if ratio < SIMILARITY_THRESHOLD:
            continue
        if best is None or ratio > best.ratio:
            best = Candidate(line=start + 1, ratio=ratio, text=window)
    return best

explain_difference(expected, actual)

Explain, on a single bounded line, how actual differs from expected.

The message names the 1-based column of the first difference, the two offending characters (non-ASCII punctuation is routed through the Unicode naming helper) and both sides rendered by :func:render_invisibles. The result never exceeds MAX_DIAGNOSTIC_CHARS.

Source code in packages/axm-edit/src/axm_edit/core/diagnostics.py
Python
def explain_difference(expected: str, actual: str) -> str:
    """Explain, on a single bounded line, how ``actual`` differs from ``expected``.

    The message names the 1-based column of the first difference, the two
    offending characters (non-ASCII punctuation is routed through the Unicode
    naming helper) and both sides rendered by :func:`render_invisibles`. The
    result never exceeds ``MAX_DIAGNOSTIC_CHARS``.
    """
    column = _first_difference(expected, actual)
    if column is None:
        return "no difference: both sides are identical"
    message = (
        f"first difference at column {column + 1}: "
        f"expected {_describe_char(expected, column)} "
        f"vs actual {_describe_char(actual, column)} "
        f"| expected {render_invisibles(expected)} "
        f"| actual {render_invisibles(actual)}"
    )
    return _truncate(message, MAX_DIAGNOSTIC_CHARS)

explain_near_miss(lines, old)

Assemble the near-miss report for an old anchor that matched nothing.

Three branches, in order: the anchor swallowed a line break (the message names the first of the two joined lines and carries the <LF> marker); a similar window exists (the message names its 1-based line and contrasts both sides through :func:render_invisibles, so a tab, a trailing space run, a non-breaking space or a Unicode punctuation swap is named instead of being dumped raw); or nothing is similar enough, in which case the candidate is None and the message says so explicitly.

The returned candidate is the one :func:closest_candidate produced, unchanged, and the message never exceeds MAX_DIAGNOSTIC_CHARS.

Source code in packages/axm-edit/src/axm_edit/core/diagnostics.py
Python
def explain_near_miss(lines: Sequence[str], old: str) -> NearMiss:
    """Assemble the near-miss report for an ``old`` anchor that matched nothing.

    Three branches, in order: the anchor swallowed a line break (the message
    names the first of the two joined lines and carries the ``<LF>`` marker);
    a similar window exists (the message names its 1-based line and contrasts
    both sides through :func:`render_invisibles`, so a tab, a trailing space
    run, a non-breaking space or a Unicode punctuation swap is named instead
    of being dumped raw); or nothing is similar enough, in which case the
    candidate is ``None`` and the message says so explicitly.

    The returned candidate is the one :func:`closest_candidate` produced,
    unchanged, and the message never exceeds ``MAX_DIAGNOSTIC_CHARS``.
    """
    candidate = closest_candidate(lines, old)
    boundary = _boundary_line(lines, old)
    if boundary is not None:
        bounded = _truncate(_boundary_message(lines, boundary), MAX_DIAGNOSTIC_CHARS)
        return NearMiss(candidate=candidate, message=bounded)
    if candidate is None:
        return NearMiss(candidate=None, message=_NO_CANDIDATE_MESSAGE)
    bounded = _truncate(_candidate_message(candidate, old), MAX_DIAGNOSTIC_CHARS)
    return NearMiss(candidate=candidate, message=bounded)

format_match_lines(match_lines, limit=MAX_LISTED_MATCH_LINES)

Render match line numbers, keeping at most limit of them.

The kept numbers are comma-joined in their original order; whatever the sequence holds beyond limit is summarised by a trailing (+N more) suffix instead of being dumped. A sequence of limit numbers or fewer renders as the plain comma-joined list, with no suffix.

Pure by construction: it reads nothing but its arguments, so an anchor repeated hundreds of times still yields a bounded, actionable list.

Source code in packages/axm-edit/src/axm_edit/core/diagnostics.py
Python
def format_match_lines(
    match_lines: Sequence[int],
    limit: int = MAX_LISTED_MATCH_LINES,
) -> str:
    """Render match line numbers, keeping at most ``limit`` of them.

    The kept numbers are comma-joined in their original order; whatever the
    sequence holds beyond ``limit`` is summarised by a trailing
    ``(+N more)`` suffix instead of being dumped. A sequence of ``limit``
    numbers or fewer renders as the plain comma-joined list, with no suffix.

    Pure by construction: it reads nothing but its arguments, so an anchor
    repeated hundreds of times still yields a bounded, actionable list.
    """
    listed = list(match_lines[:limit])
    joined = ", ".join(str(number) for number in listed)
    remaining = len(match_lines) - len(listed)
    if remaining <= 0:
        return joined
    return f"{joined} (+{remaining} more)"

render_invisibles(text)

Render text with every invisible or non-ASCII character named.

Tabs become <TAB>, a trailing space run becomes one <SP> marker per space, U+00A0 becomes <NBSP>, line terminators become <CR>/<LF> and any other non-ASCII character becomes its Unicode name (or the <U+XXXX> fallback). Ordinary printable ASCII is passed through untouched. The result never exceeds MAX_SNIPPET_CHARS.

Source code in packages/axm-edit/src/axm_edit/core/diagnostics.py
Python
def render_invisibles(text: str) -> str:
    """Render ``text`` with every invisible or non-ASCII character named.

    Tabs become ``<TAB>``, a trailing space run becomes one ``<SP>`` marker per
    space, U+00A0 becomes ``<NBSP>``, line terminators become ``<CR>``/``<LF>``
    and any other non-ASCII character becomes its Unicode name (or the
    ``<U+XXXX>`` fallback). Ordinary printable ASCII is passed through
    untouched. The result never exceeds ``MAX_SNIPPET_CHARS``.
    """
    rendered: list[str] = []
    for piece in text.splitlines(keepends=True):
        body = piece.rstrip("\r\n")
        terminator = piece[len(body) :]
        rendered.append(_render_body(body))
        rendered.extend(_render_char(char) for char in terminator)
    return _truncate("".join(rendered), MAX_SNIPPET_CHARS)