diff --git a/DEV.md b/DEV.md index e2212b3..83defeb 100644 --- a/DEV.md +++ b/DEV.md @@ -89,7 +89,7 @@ The Rust core takes commands three ways: - Multi-buffer structural (`edit_buffers` binding): Python passes ordered `(target, text)` buffers and target-resolved command tuples. Rust keeps one engine per target and executes the full call, including cross-target `m`/`t`, before returning per-target results. - Compact ex-style strings, where strings are the input medium: - `parse_commands_from_script(&str)`: for script strings; commands are separated by newlines. Single-line `a/i/c` text may be inline; if omitted, following lines up to `.` are used as the text block. - - `parse_commands_from_args(&[String], &mut BufRead)`: used by the `exhash` CLI via the `exhash_argv` binding; each arg is a command. Single-line `a/i/c` text may be inline; if omitted, text blocks are read from the stdin stream terminated by `.`. + - `parse_commands_from_args(&[String], &mut BufRead)`: used by the `exhash` CLI via the `exhash_argv` binding; each arg is a command. Single-line `a/i/c` text may be inline. One command may instead read a multiline text block from stdin through EOF. File-qualified addresses and notebook cell prefixes are resolved by the Python `file_exhash` wrapper after tuple normalization. The resulting target identifiers are opaque to Rust. diff --git a/README.md b/README.md index 175a89e..b289f39 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ When passing multiple commands, each command's lnhashes are verified immediately For CLI multiline `a/i/c` commands, omit inline text and provide the text block on stdin: ```bash -printf "new line 1\nnew line 2\n.\n" | exhash file.txt "2|beef|a" +printf "new line 1\nnew line 2\n" | exhash file.txt "2|beef|a" ``` If the file does not exist and the command set is valid on empty input, exhash treats it as an empty file and writes the result. For example, `0|0000|a` can create a new file. @@ -102,12 +102,14 @@ In `--stdin` mode, multiline `a/i/c` text blocks are not available. ### Notebook cells -`lnhashview-cell` and `exhash-cell` apply the same workflow to one notebook cell, addressed by its exact or unique ID prefix: +`lnhashview-cell` and `exhash-cell` apply the same workflow to notebook cells, addressed by exact or unique ID prefixes. Pass comma-separated IDs to view several cells together; the output adds a `# cell ` header to each group: ```bash lnhashview-cell nbs/00_core.ipynb ab12cd34 +lnhashview-cell nbs/00_core.ipynb ab12cd34,ef56ab78 exhash-cell nbs/00_core.ipynb ab12cd34 '3|beef|s/old/new/' exhash-cell --dry-run nbs/00_core.ipynb ab12cd34 '3|beef|d' +printf 'replacement line\n' | exhash-cell nbs/00_core.ipynb ab12cd34 '3|beef|c' ``` ### Document outlines diff --git a/python/exhash/_cli.py b/python/exhash/_cli.py index 53e93f1..33bf322 100644 --- a/python/exhash/_cli.py +++ b/python/exhash/_cli.py @@ -19,8 +19,8 @@ COMMANDS s/pat/rep/[flags] Substitute (Rust regex; flags g, i). y/src/dst/ transliterate. - d delete a/i/c append/insert/change (inline text, or a text block via stdin - terminated by a '.' line) j join m/t move/copy to dest >/< indent/dedent + d delete a/i/c append/insert/change (inline text, or one text block read + from stdin through EOF) j join m/t move/copy to dest >/< indent/dedent sort p print g/pat/cmd, g!/pat/cmd, v/pat/cmd global OPTIONS @@ -37,8 +37,11 @@ EXHASH_CELL_USAGE = """\ Usage: exhash-cell [-h] [--dry-run] [--sw N] [commands...] -Apply exhash commands to one notebook cell. Multiline a/i/c text blocks are read -from stdin and terminated by a line containing only '.'. +Apply compact exhash commands to one notebook cell. Put the verified address and +operation in one argument, for example '3|beef|s/old/new/' or '3|beef|d'. + +Multiline a/i/c text blocks use a command such as '3|beef|c'. The one command +that reads a text block consumes stdin through EOF. Every input line is literal. """ @@ -142,14 +145,18 @@ def _int(v, name): @call_parse(pos=['start', 'end']) def lnhashview_cell_main( file:str, # Notebook path - cell_id:str, # Exact or uniquely prefixed cell ID + cell_id:str, # Exact/prefixed cell ID, or comma-separated IDs start:int=None, # First source line to show end:int=None, # Last source line to show ): - "Show hash-addressed source lines from one notebook cell." - from . import lnhashview_cell + "Show hash-addressed source lines from one or more notebook cells." + from . import lnhashview_cell, lnhashview_cells if start is not None and end is None: end = start - try: print(lnhashview_cell(file, cell_id, start, end)) + cell_ids = [o.strip() for o in cell_id.split(',')] + if any(not o for o in cell_ids): _die("error: cell IDs must be a comma-separated list without empty entries", 2) + try: + res = lnhashview_cell(file, cell_ids[0], start, end) if len(cell_ids) == 1 else lnhashview_cells(file, *cell_ids, start=start, end=end) + print(res) except Exception as e: _die(f"error: {e}") diff --git a/src/parse.rs b/src/parse.rs index 095fcdc..9f3bcaf 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -49,13 +49,18 @@ pub struct Subst { pub case_insensitive: bool, } -/// Parse commands from CLI argv, reading any multiline text blocks from `stdin`. +/// Parse commands from CLI argv, reading one multiline text block from `stdin` through EOF. /// /// Each element of `args` is a single command line (e.g. `42|a3f2|s/foo/bar/g`). pub fn parse_commands_from_args(args: &[String], stdin: &mut impl BufRead) -> Result, EditError> { let mut out = Vec::with_capacity(args.len()); + let mut read_stdin = false; for a in args { - let cmd = parse_command_with_text(a, || read_text_block_from_bufread(stdin))?; + let cmd = parse_command_with_text(a, || { + if read_stdin { return Err(EditError::new("only one command can read a text block from stdin")); } + read_stdin = true; + read_text_block_from_bufread(stdin) + })?; out.push(cmd); } Ok(out) @@ -365,14 +370,13 @@ fn read_text_block_from_bufread(stdin: &mut impl BufRead) -> Result, loop { buf.clear(); let n = stdin.read_line(&mut buf).map_err(|e| EditError::new(format!("failed to read stdin: {e}")))?; - if n == 0 { return Err(EditError::new("unexpected EOF while reading text block")); } + if n == 0 { break; } // Trim \n, then optional \r. if buf.ends_with('\n') { buf.pop(); if buf.ends_with('\r') { buf.pop(); } } - if buf == "." { break; } - if buf == ".." { out.push(".".to_string()); } else { out.push(buf.clone()); } + out.push(buf.clone()); } Ok(out) } diff --git a/tests/test_cells.py b/tests/test_cells.py index 3dc2c66..aa0226a 100644 --- a/tests/test_cells.py +++ b/tests/test_cells.py @@ -1,4 +1,4 @@ -import json, pytest +import json, subprocess, pytest from exhash import lnhash, lnhashview_cell, lnhashview_cells, cell_exhash, file_exhash from exhash._cli import exhash_cell_main @@ -29,6 +29,16 @@ def test_lnhashview_cells(tmp_path): assert lines[4].startswith(lnhash(1, 'x=1')) assert str(lines) == chr(10).join(lines) + +def test_lnhashview_cell_cli_accepts_comma_separated_ids(tmp_path): + p = tmp_path/'t.ipynb' + mk_nb(p, [('aaaa1111', 'x=1'), ('bbbb2222', 'y=2')]) + single = subprocess.run(['lnhashview-cell', str(p), 'aaaa'], text=True, capture_output=True) + multiple = subprocess.run(['lnhashview-cell', str(p), 'aaaa,bbbb'], text=True, capture_output=True) + assert single.returncode == multiple.returncode == 0 + assert single.stdout.startswith(f'{lnhash(1, "x=1")}x=1') and '# cell' not in single.stdout + assert '# cell aaaa1111' in multiple.stdout and '# cell bbbb2222' in multiple.stdout + def test_lnhashview_cell_prefix_and_errors(tmp_path): p = tmp_path/'t.ipynb' mk_nb(p, [('aaaa1111', 'x=1'), ('aabb2222', 'y=2')]) @@ -81,6 +91,14 @@ def test_exhash_cell_cli_dry_run_then_write(tmp_path, capsys): assert 'x=2' in capsys.readouterr().out +def test_exhash_cell_cli_help(capsys): + exhash_cell_main(['--help']) + help_ = capsys.readouterr().err + assert "'3|beef|s/old/new/'" in help_ + assert "'3|beef|c'" in help_ + assert 'through EOF' in help_ + + def test_cell_exhash_stacks_call_start_addresses(tmp_path): p = tmp_path/'t.ipynb' mk_nb(p, [('aaaa1111', 'x = one + two')]) diff --git a/tests/test_cli.py b/tests/test_cli.py index a7505b6..5f80af8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -105,7 +105,7 @@ def test_move_to_last_line_destination(tmp_path): def test_multiline_append_from_stdin(tmp_path): f = tmp_path / "f.txt" f.write_text("a\n") - out = run([str(f), f"{lnhash(1, 'a')}a"], input="x\ny\n.\n") + out = run([str(f), f"{lnhash(1, 'a')}a"], input="x\ny\n") assert out.returncode == 0 assert add(2, "x") in out.stdout assert add(3, "y") in out.stdout @@ -122,11 +122,18 @@ def test_inline_change_from_arg(tmp_path): def test_creates_missing_file_with_zero_append(tmp_path): f = tmp_path / "new.txt" - out = run([str(f), "0|0000|a"], input="first line\n.\n") + out = run([str(f), "0|0000|a"], input="first line\n") assert out.returncode == 0 assert out.stdout == diff(f"{add(1, 'first line')}\n") assert f.read_text() == "first line\n" +def test_multiline_stdin_is_literal(tmp_path): + f = tmp_path / "f.txt" + f.write_text("a\n") + out = run([str(f), f"{lnhash(1, 'a')}a"], input=".\n..\n") + assert out.returncode == 0 + assert f.read_text() == "a\n.\n..\n" + def test_rejects_binary_file(tmp_path): f = tmp_path / "f.bin" f.write_bytes(b"a\0b\n") diff --git a/tests/test_exhash.py b/tests/test_exhash.py index 50d1f75..130f1e9 100644 --- a/tests/test_exhash.py +++ b/tests/test_exhash.py @@ -535,8 +535,7 @@ def test_missing_path_error_messages(tmp_path): def test_unexpanded_ipython_variable_is_named_in_path_errors(tmp_path): "An undefined {name}/$name in a %%exhash line reaches us as literal text, so say so instead of blaming the directory" for p in ('{impdir}/DEV.md', '$impdir/DEV.md', '${impdir}/DEV.md'): - with pytest.raises(FileNotFoundError, match='unexpanded IPython variable'): - file_exhash(p, ("0|0000|", "a", "hi")) + with pytest.raises(FileNotFoundError, match='unexpanded IPython variable'): file_exhash(p, ("0|0000|", "a", "hi")) with pytest.raises(FileNotFoundError, match='unexpanded IPython variable'): cell_exhash('{nbdir}/nb.ipynb', 'ab12', ("1|abcd|", "c", "hi")) with pytest.raises(FileNotFoundError) as e: # an ordinary missing path keeps the plain message