Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DEV.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 <id>` 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
Expand Down
23 changes: 15 additions & 8 deletions python/exhash/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,8 +37,11 @@
EXHASH_CELL_USAGE = """\
Usage: exhash-cell [-h] [--dry-run] [--sw N] <notebook> <cell-id> [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.
"""


Expand Down Expand Up @@ -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}")


Expand Down
14 changes: 9 additions & 5 deletions src/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<Command>, 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)
Expand Down Expand Up @@ -365,14 +370,13 @@ fn read_text_block_from_bufread(stdin: &mut impl BufRead) -> Result<Vec<String>,
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)
}
Expand Down
20 changes: 19 additions & 1 deletion tests/test_cells.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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')])
Expand Down Expand Up @@ -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')])
Expand Down
11 changes: 9 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down
3 changes: 1 addition & 2 deletions tests/test_exhash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down