diff --git a/docs/database.md b/docs/database.md index 476954d..3cab082 100644 --- a/docs/database.md +++ b/docs/database.md @@ -82,7 +82,7 @@ A revision number identifies a *state position between diffs*, not a diff: Like repeated `--grep` patterns (which must all match), repeated ranges are restrictive: `-r r1:r5 -r 0:2` prints only the first two records of those in the revision window, regardless of flag order. Plain ranges truncate Python-style and print nothing when the intersection is empty; a single plain index must exist and fails with exit code 2 otherwise. -`--context N` (default 0) adds GNU grep-style context as the final step: each printed item is expanded by N surrounding records drawn from the records in the processed range, free of the `--grep` and plain-number filters, with overlapping expansions merged so nearby matches do not duplicate context records. +`--context N` (default 0) adds GNU grep-style context as the final step: each printed item is expanded by N surrounding records drawn from the whole file — expanding whatever would be shown past any range restriction, so `-r s3 --context 2` shows that snapshot plus the two records before and after it. Overlapping expansions merge so nearby matches do not duplicate context records. 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). diff --git a/kanta/__main__.py b/kanta/__main__.py index b686b3f..6c77406 100644 --- a/kanta/__main__.py +++ b/kanta/__main__.py @@ -289,9 +289,10 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: help=( "Print N surrounding records around each printed item, GNU grep" " style. Applied last, after --grep filtering and plain-number" - " -r slicing: context records are drawn from the records in the" - " processed range, free of those filters, and overlapping" - " expansions merge. Without --grep this has no visible effect." + " -r slicing: context records are drawn from the whole file," + " expanding whatever would be shown past any range restriction," + " and overlapping expansions merge. Without any other filter" + " this has no visible effect." ), ) args = parser.parse_args(argv) @@ -547,7 +548,7 @@ async def _run(args: argparse.Namespace) -> int: grep_patterns = [GrepPattern.parse(p) for p in args.grep or ()] - if selection.snapshot is not None: + if selection.snapshot is not None and not args.context: snap_event = selection.snapshot state = snap_event.snap.state version = snap_event.snap.v @@ -560,16 +561,31 @@ async def _run(args: argparse.Namespace) -> int: ) _print() else: - # Replay up to the range end, collecting the in-window item pool - # and tracking which items survive --grep. + # Replay, collecting the item pool and tracking which items + # survive the range window and --grep. With --context the pool + # spans the whole file, so context can expand whatever would be + # shown past any range restriction; otherwise the pool is + # limited to the window. state = {} version = 0 + window_state: dict[str, Any] = {} + window_version = 0 + pool_end = end_of_file(events) if args.context else selection.end_line pool: list[tuple[Event, dict[str, Any] | None, dict[str, Any], Any]] = [] matched: list[int] = [] - for event, previous, current in replay_events(events, selection.end_line): + for event, previous, current in replay_events(events, pool_end): + if event.line_number < selection.end_line: + window_state = current + window_version = event.version state = current version = event.version - if event.line_number < selection.start_line or args.quiet: + if ( + args.quiet + or event.line_number < selection.start_line + or event.line_number >= selection.end_line + ): + if not args.quiet and args.context: + pool.append((event, previous, current, None)) continue highlight = None if isinstance(event, SnapshotEvent): @@ -629,6 +645,9 @@ async def _run(args: argparse.Namespace) -> int: last_event, _, last_current, _ = items[-1] state = last_current version = last_event.version + else: + state = window_state + version = window_version for event, previous, current, highlight in items: label = record_label(event.line_number, event.record_index) if isinstance(event, SnapshotEvent): diff --git a/tests/test_range.py b/tests/test_range.py index f7245be..31acbea 100644 --- a/tests/test_range.py +++ b/tests/test_range.py @@ -2,13 +2,17 @@ import asyncio import json +from datetime import UTC, datetime from kanta.serialization import JsonSerializer +from kanta.structs import META_FORMAT, ChangeRecord, Snapshot from tests.support import Data, make_kanta # The CLI reads files with its own JSON Kanta instance unless -k is given. JSON = ("json", JsonSerializer) +T0 = datetime(2026, 1, 1, tzinfo=UTC) + def _run_cli(path, *args): from kanta.__main__ import main @@ -148,7 +152,7 @@ def test_unit_and_plain_combine(tmp_path, capsys): def test_context_expands_after_grep(tmp_path, capsys): - """Context records come from the in-window pool, free of --grep.""" + """Context records come from the whole file, free of --grep.""" path = tmp_path / "data.kantadb" _build(path, increments=5) capsys.readouterr() @@ -168,7 +172,7 @@ def test_context_expands_after_grep(tmp_path, capsys): assert "inc1" not in err assert "inc2" in err - # Context is clamped at the start of the range. + # Context is clamped at the start of the file. assert _run_cli(path, "--grep", "bootstrap", "--context", "2") == 0 err = capsys.readouterr().err assert "bootstrap" in err @@ -177,6 +181,62 @@ def test_context_expands_after_grep(tmp_path, capsys): assert "inc2" not in err +def test_context_expands_past_unit_window(tmp_path, capsys): + """Context expands whatever a unit selection would show, past the window.""" + path = tmp_path / "data.kantadb" + _build(path) + capsys.readouterr() + + # -r r2 alone shows only inc0 (the record producing state 2); context 1 + # expands past the window to bootstrap and inc1, and the dump follows + # the last printed record. + assert _run_cli(path, "-r", "r2", "--context", "1", "-o", "-") == 0 + out = capsys.readouterr() + assert "bootstrap" in out.err + assert "inc0" in out.err + assert "inc1" in out.err + assert "inc2" not in out.err + assert json.loads(out.out) == {"users": {}, "counter": 2} + + +def test_context_expands_snapshot_selection(tmp_path, capsys): + """-r sN --context N shows the snapshot plus its neighbors.""" + path = tmp_path / "data.kantadb" + serializer = JsonSerializer() + framer = serializer.framer_cls() + out_bytes = bytearray() + out_bytes += framer.frame_change( + serializer.encode(ChangeRecord(ts=T0, a="inc0", v=0, diff={"counter": 1})), + record_offset=len(out_bytes), + ) + snap = Snapshot(ts=T0, v=0, state={"counter": 1}, r=1, k=META_FORMAT) + out_bytes += framer.frame_snapshot( + serializer.encode(snap), record_offset=len(out_bytes) + ) + out_bytes += framer.frame_change( + serializer.encode(ChangeRecord(ts=T0, a="inc1", v=0, diff={"counter": 2})), + record_offset=len(out_bytes), + ) + path.write_bytes(bytes(out_bytes)) + capsys.readouterr() + + # Without context, s1 (the file starts with a change, so s0 is l0) + # shows just the snapshot and its state. + assert _run_cli(path, "-r", "s1", "-o", "-") == 0 + out = capsys.readouterr() + assert "snapshot s1" in out.err + assert "inc0" not in out.err + assert json.loads(out.out) == {"counter": 1} + + # With context, the selection expands past the snapshot line. + assert _run_cli(path, "-r", "s1", "--context", "1", "-o", "-") == 0 + out = capsys.readouterr() + assert "inc0" in out.err + assert "snapshot s1" in out.err + assert "inc1" in out.err + assert json.loads(out.out) == {"counter": 2} + + def test_context_merges_overlaps(tmp_path, capsys): """Nearby matches share context instead of duplicating records.""" path = tmp_path / "data.kantadb"