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). 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 ## 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. 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" "Print only change records matching PATTERN, structurally and"
" case-insensitively; matched regions get a yellow background," " case-insensitively; matched regions get a yellow background,"
" and on a match the whole record is printed, not just the" " 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" " in the same record, but different patterns may match different"
" lines of it. A bare pattern matches the action or user as a" " lines of it. A bare pattern matches the action or user as a"
" substring, a dotted path by element (each element in full," " 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 dataclasses
import fnmatch import fnmatch
import json import json
import re
from collections.abc import Iterator from collections.abc import Iterator
from typing import Any from typing import Any
@@ -43,23 +44,47 @@ from kanta.logging import (
) )
from kanta.serialization.base import unmarshal from kanta.serialization.base import unmarshal
from kanta.structs import ChangeRecord 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("*?[") _GLOB_CHARS = frozenset("*?[")
_MARK_BG = f"{ESC}48;5;220m" # yellow background (xterm256 #ffd700) for matches # Marked regions: xterm256 black text (16, not theme-dependent "black") on
_UNMARK_BG = f"{ESC}49m" # back to the default background, foreground untouched # 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: 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 ``spans`` are ``(start, end)`` offsets into the visible text of
*styled*; ANSI sequences are not counted. Overlapping and adjacent *styled*; ANSI sequences are not counted. Overlapping and adjacent
spans are merged first, so overlapping matches from different patterns spans are merged first, so overlapping matches from different patterns
produce one continuous highlight. The set/clear codes are inserted at produce one continuous highlight. Marked regions get black text on a
the mapped positions in *styled*; only the background attribute is yellow background; the active SGR state is tracked through *styled* so
touched, leaving foreground colors intact. the original styling — foreground included — is restored exactly where
each mark ends.
""" """
spans = _merge_spans(spans) spans = _merge_spans(spans)
if not spans: if not spans:
@@ -67,12 +92,16 @@ def mark_spans(styled: str, spans: list[tuple[int, int]]) -> str:
out: list[str] = [] out: list[str] = []
plain_pos = 0 plain_pos = 0
prev_end = 0 prev_end = 0
active: dict[str, str] = {}
for match in ANSI_RE.finditer(styled): 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 plain_pos += match.start() - prev_end
out.append(match.group(0)) out.append(match.group(0))
_apply_sgr(active, match.group(0))
prev_end = match.end() 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) 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] return [(start, end) for start, end in merged]
def _wrap_run(run: str, plain_start: int, spans: list[tuple[int, int]]) -> str: def _wrap_run(
"""Wrap the intersections of *spans* with one escape-free text 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: if not run:
return run return run
mark_state = {**active, "fg": _MARK_FG, "bg": _MARK_BG}
out: list[str] = [] out: list[str] = []
pos = 0 pos = 0
for start, end in spans: 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: if s >= e or e <= pos:
continue continue
out.append(run[pos:s]) 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 pos = e
out.append(run[pos:]) out.append(run[pos:])
return "".join(out) return "".join(out)
+1
View File
@@ -31,6 +31,7 @@ bin = [
[project.urls] [project.urls]
Homepage = "https://vasanko.com/coders/kanta" Homepage = "https://vasanko.com/coders/kanta"
Repository = "https://git.zi.fi/LeoVasanko/kanta" Repository = "https://git.zi.fi/LeoVasanko/kanta"
Issues = "https://github.com/LeoVasanko/kanta"
[dependency-groups] [dependency-groups]
dev = [ dev = [
+6 -5
View File
@@ -23,8 +23,8 @@ from kanta.tty import strip_ansi
TS = datetime(2026, 1, 1, tzinfo=UTC) TS = datetime(2026, 1, 1, tzinfo=UTC)
MARK = "\x1b[48;5;220m" MARK = "\x1b[38;5;16;48;5;220m" # xterm256 black text on yellow background
UNMARK = "\x1b[49m" UNMARK = "\x1b[0m" # full reset (unstyled context); styled runs restore their style
def test_match_path_whole_elements_anywhere(): 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(): def test_mark_spans_offsets_into_styled_text():
styled = "\x1b[32mal\x1b[0mlice" 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. # A span crossing an escape sequence wraps each escape-free run.
assert mark_spans(styled, [(0, 3)]) == ( 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. # A span covering everything wraps each escape-free run separately.
assert mark_spans(styled, [(0, 6)]) == ( 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 assert mark_spans(styled, []) == styled
@@ -493,7 +494,7 @@ def test_cli_grep_snapshot_prints_matched_state(tmp_path, capsys, monkeypatch):
state=state, state=state,
color=True, 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): def test_cli_grep_snapshot_selection_shortcut(tmp_path, capsys, monkeypatch):