Document CLI search; highlight matches black-on-yellow, restoring prior styling

This commit is contained in:
LeoVasanko committed 2026-09-28 05:28:11 +00:00
1 parent 2c7487de73
commit e296859383
5 files changed
+71 -18

No files matched your search

+11
View File
@@ -86,6 +86,17 @@ Like repeated `--grep` patterns (which must all match), repeated ranges are rest
The JSON dump (`-o`) and migrations operate on the state after the **last printed record**, whatever filtering produced the printout — so a plain `-r 2:5` dump ends at the 5th filtered record, and `--grep` or `--context` shift the dump to their last printed record as well. When nothing is printed (an empty filter result or `-q`), the dump falls back to the state at the end of the unit-spec window (or the end of the file).
## CLI Search
`--grep PATTERN` (repeatable) prints only records matching the pattern, structurally and case-insensitively; matched regions get a yellow background, and a matched change record is printed in full, not just its matching line. Patterns match the record's structure, never rendered text:
- A bare pattern matches the action or user as a substring, a dotted path by element (each element in full unless it uses shell wildcards: `users` matches `users` anywhere but not `foousers`; `us*` does; elements match as a contiguous sequence, so `users.*.email` works), or a value (strings by substring, other scalars in full: `true` matches a boolean, `tru` does not).
- The `path=value` form requires both sides to match within the same change line; `=value` and `path=` match values or paths only.
- Repeated patterns are ANDed within the same record, but different patterns may match different lines of it.
- With `-k`, logfmt-prettified values, users, and path elements match alongside the raw ones, and the displayed (prettified) text is what gets highlighted.
Snapshots are matched against their flattened state with the same rules. A matched snapshot shows the matched records of its state as compact `path.to.record = {value}` lines under the indicator — normal path coloring, logfmt-prettified elements, matched regions highlighted — while unmatched parts of the state stay hidden. In the filter pipeline, grep applies after unit `-r` windows and before plain-number slicing and `--context`.
## Meta Format Migrations
The snapshot `k` field carries an integer meta format version (`kanta.structs.META_FORMAT`, currently 1), bumped manually whenever the record-level format around the payloads changes. k1 introduced revision numbers.
+3 -1
View File
@@ -265,7 +265,9 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"Print only change records matching PATTERN, structurally and"
" case-insensitively; matched regions get a yellow background,"
" and on a match the whole record is printed, not just the"
" matching line. Repeatable: every pattern must match somewhere"
" matching line. Snapshots are matched against their state and"
" show the matched records of it as compact lines. Repeatable:"
" every pattern must match somewhere"
" in the same record, but different patterns may match different"
" lines of it. A bare pattern matches the action or user as a"
" substring, a dotted path by element (each element in full,"
+50 -12
View File
@@ -31,6 +31,7 @@ from __future__ import annotations
import dataclasses
import fnmatch
import json
import re
from collections.abc import Iterator
from typing import Any
@@ -43,23 +44,47 @@ from kanta.logging import (
)
from kanta.serialization.base import unmarshal
from kanta.structs import ChangeRecord
from kanta.tty import ANSI_RE, ESC, Line, strip_ansi
from kanta.tty import (
ANSI_RE,
Line,
_parse_sgr,
_sgr_transition,
strip_ansi,
)
_GLOB_CHARS = frozenset("*?[")
_MARK_BG = f"{ESC}48;5;220m" # yellow background (xterm256 #ffd700) for matches
_UNMARK_BG = f"{ESC}49m" # back to the default background, foreground untouched
# Marked regions: xterm256 black text (16, not theme-dependent "black") on
# xterm256 yellow (220 ~ #ffd700).
_MARK_FG = "38;5;16"
_MARK_BG = "48;5;220"
_SGR_RE = re.compile(r"\x1b\[([0-9;]*)m")
def _apply_sgr(active: dict[str, str], escape: str) -> None:
"""Fold one escape sequence into the active SGR state; non-SGR ignored."""
match = _SGR_RE.fullmatch(escape)
if not match:
return
spec = match.group(1)
if not spec:
active.clear()
return
if "0" in spec.split(";"):
active.clear()
active.update(_parse_sgr(spec))
def mark_spans(styled: str, spans: list[tuple[int, int]]) -> str:
"""Wrap the given visible-text spans of a styled string with the mark color.
"""Wrap the given visible-text spans of a styled string with the mark colors.
``spans`` are ``(start, end)`` offsets into the visible text of
*styled*; ANSI sequences are not counted. Overlapping and adjacent
spans are merged first, so overlapping matches from different patterns
produce one continuous highlight. The set/clear codes are inserted at
the mapped positions in *styled*; only the background attribute is
touched, leaving foreground colors intact.
produce one continuous highlight. Marked regions get black text on a
yellow background; the active SGR state is tracked through *styled* so
the original styling — foreground included — is restored exactly where
each mark ends.
"""
spans = _merge_spans(spans)
if not spans:
@@ -67,12 +92,16 @@ def mark_spans(styled: str, spans: list[tuple[int, int]]) -> str:
out: list[str] = []
plain_pos = 0
prev_end = 0
active: dict[str, str] = {}
for match in ANSI_RE.finditer(styled):
out.append(_wrap_run(styled[prev_end : match.start()], plain_pos, spans))
out.append(
_wrap_run(styled[prev_end : match.start()], plain_pos, spans, active)
)
plain_pos += match.start() - prev_end
out.append(match.group(0))
_apply_sgr(active, match.group(0))
prev_end = match.end()
out.append(_wrap_run(styled[prev_end:], plain_pos, spans))
out.append(_wrap_run(styled[prev_end:], plain_pos, spans, active))
return "".join(out)
@@ -89,10 +118,17 @@ def _merge_spans(spans: list[tuple[int, int]]) -> list[tuple[int, int]]:
return [(start, end) for start, end in merged]
def _wrap_run(run: str, plain_start: int, spans: list[tuple[int, int]]) -> str:
"""Wrap the intersections of *spans* with one escape-free text run."""
def _wrap_run(
run: str, plain_start: int, spans: list[tuple[int, int]], active: dict[str, str]
) -> str:
"""Wrap the intersections of *spans* with one escape-free text run.
*active* is the SGR state in effect at this run; each mark sets the
black-on-yellow mark state and each unmark restores *active* exactly.
"""
if not run:
return run
mark_state = {**active, "fg": _MARK_FG, "bg": _MARK_BG}
out: list[str] = []
pos = 0
for start, end in spans:
@@ -101,7 +137,9 @@ def _wrap_run(run: str, plain_start: int, spans: list[tuple[int, int]]) -> str:
if s >= e or e <= pos:
continue
out.append(run[pos:s])
out.append(f"{_MARK_BG}{run[s:e]}{_UNMARK_BG}")
out.append(_sgr_transition(active, mark_state))
out.append(run[s:e])
out.append(_sgr_transition(mark_state, active))
pos = e
out.append(run[pos:])
return "".join(out)
+1
View File
@@ -31,6 +31,7 @@ bin = [
[project.urls]
Homepage = "https://vasanko.com/coders/kanta"
Repository = "https://git.zi.fi/LeoVasanko/kanta"
Issues = "https://github.com/LeoVasanko/kanta"
[dependency-groups]
dev = [
+6 -5
View File
@@ -23,8 +23,8 @@ from kanta.tty import strip_ansi
TS = datetime(2026, 1, 1, tzinfo=UTC)
MARK = "\x1b[48;5;220m"
UNMARK = "\x1b[49m"
MARK = "\x1b[38;5;16;48;5;220m" # xterm256 black text on yellow background
UNMARK = "\x1b[0m" # full reset (unstyled context); styled runs restore their style
def test_match_path_whole_elements_anywhere():
@@ -76,13 +76,14 @@ def test_mark_spans_merges_overlaps_and_adjacents():
def test_mark_spans_offsets_into_styled_text():
styled = "\x1b[32mal\x1b[0mlice"
restore_green = "\x1b[0;32m" # the mark restores the active style exactly
# A span crossing an escape sequence wraps each escape-free run.
assert mark_spans(styled, [(0, 3)]) == (
f"\x1b[32m{MARK}al{UNMARK}\x1b[0m{MARK}l{UNMARK}ice"
f"\x1b[32m{MARK}al{restore_green}\x1b[0m{MARK}l{UNMARK}ice"
)
# A span covering everything wraps each escape-free run separately.
assert mark_spans(styled, [(0, 6)]) == (
f"\x1b[32m{MARK}al{UNMARK}\x1b[0m{MARK}lice{UNMARK}"
f"\x1b[32m{MARK}al{restore_green}\x1b[0m{MARK}lice{UNMARK}"
)
assert mark_spans(styled, []) == styled
@@ -493,7 +494,7 @@ def test_cli_grep_snapshot_prints_matched_state(tmp_path, capsys, monkeypatch):
state=state,
color=True,
)
assert "\x1b[48;5;220malice@example.com\x1b[49m" in err
assert f"{MARK}alice@example.com{UNMARK}" in err
def test_cli_grep_snapshot_selection_shortcut(tmp_path, capsys, monkeypatch):