Document CLI search; highlight matches black-on-yellow, restoring prior styling
This commit is contained in:
1 parent
2c7487de73
commit
e296859383
5 files changed
+71
-18
No files matched your search
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
Reference in new issue
Block a user