diff --git a/docs/database.md b/docs/database.md index 3cab082..24a6bfe 100644 --- a/docs/database.md +++ b/docs/database.md @@ -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. diff --git a/kanta/__main__.py b/kanta/__main__.py index e57b760..03568eb 100644 --- a/kanta/__main__.py +++ b/kanta/__main__.py @@ -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," diff --git a/kanta/grep.py b/kanta/grep.py index 4e9624f..8a7bdfa 100644 --- a/kanta/grep.py +++ b/kanta/grep.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index ac084ea..4cf2711 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/tests/test_grep.py b/tests/test_grep.py index 4362a7b..fe04acd 100644 --- a/tests/test_grep.py +++ b/tests/test_grep.py @@ -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):