Skip to content

Commit c9d8282

Browse files
committed
fixes #3
1 parent cd2d758 commit c9d8282

7 files changed

Lines changed: 111 additions & 73 deletions

File tree

DEV.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ tools/build.sh release
4545
cargo test && pytest -q
4646
```
4747

48+
## Hash verification timing
49+
50+
`edit_text` verifies lnhashes command-by-command against the current in-memory buffer, immediately before each command executes (not all upfront). If an earlier command shifts or rewrites a later target line, that later command will fail with a stale-hash error unless you recompute addresses.
51+
4852
## Release
4953

5054
Publishing is handled by GitHub Actions in `.github/workflows/ci.yml` and is triggered by pushing a tag matching `v*`.

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ EOF
4848
exhash --dry-run file.txt '12|abcd|d'
4949
```
5050

51+
When passing multiple commands, each command's lnhashes are verified immediately before that command runs.
52+
5153
For `a/i/c` commands, provide the text block on stdin:
5254

5355
```bash
@@ -77,7 +79,7 @@ view = lnhashview(text) # ["1|a1b2| foo", "2|c3d4| bar"]
7779

7880
### Editing
7981

80-
`exhash(text, cmds)` takes the text and a required list of command strings (use `[]` for no-op). For `a`/`i`/`c` commands, lines after the command are the text block (no `.` terminator needed):
82+
`exhash(text, cmds)` takes the text and a required iterable of command strings (use `[]` for no-op). For `a`/`i`/`c` commands, lines after the command are the text block (no `.` terminator needed):
8183

8284
```py
8385
addr = lnhash(1, "foo") # "1|a1b2|"
@@ -89,6 +91,9 @@ print(res["modified"]) # [1]
8991
a1, a2 = lnhash(1, "foo"), lnhash(2, "bar")
9092
res = exhash(text, [f"{a1}s/foo/FOO/", f"{a2}s/bar/BAR/"])
9193

94+
# Hashes are checked just-in-time per command.
95+
# If earlier commands change/shift a later target line, recompute lnhash first.
96+
9297
# Append multiline text (no dot terminator)
9398
res = exhash(text, [f"{addr}a\nnew line 1\nnew line 2"])
9499
```

python/exhash/__init__.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def lnhashview(text:str) -> list[str]:
1616

1717

1818
def exhash_result(results:list[dict]) -> str:
19-
"""Format modified lines from exhash result dicts in lnhash view format."""
19+
'Format modified lines from exhash result dicts in lnhash view format.'
2020
if not isinstance(results, list): raise TypeError("results must be a list[dict]")
2121
out = []
2222
for r in results:
@@ -34,6 +34,8 @@ def exhash(text:str, cmds:list[str]) -> dict:
3434
Commands use lnhash addresses: ``lineno|hash|cmd`` where hash is a 4-char
3535
hex content hash. Use ``lnhashview(text)`` or ``lnhash(lineno, line)`` to
3636
get addresses.
37+
Each command's hashes are verified against current text immediately before
38+
that command executes.
3739
3840
Addressing:
3941
Single: ``12|a3f2|cmd``
@@ -65,8 +67,8 @@ def exhash(text:str, cmds:list[str]) -> dict:
6567
modified 1-based line numbers of modified/added lines
6668
deleted 1-based line numbers of removed lines (in original)
6769
68-
`cmds` is a required list of command strings. For `a`/`i`/`c`, include the
69-
text block in the same command string after a newline.
70+
`cmds` is a required iterable of command strings. For `a`/`i`/`c`, include
71+
the text block in the same command string after a newline.
7072
7173
Examples::
7274

src/bin/exhash.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ ADDRESSING
1717
hex content hash. Use lnhashview to get addresses:
1818
lnhashview file.txt show all lines with addresses
1919
lnhashview file.txt 10 20 show lines 10-20
20+
With multiple commands, hashes are checked immediately before each command runs.
2021
2122
Single: 12|a3f2|cmd
2223
Range: 12|a3f2|,15|b1c3|cmd

src/engine.rs

Lines changed: 63 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,62 @@ impl Engine {
6161
self.apply_subcommand(start, end, cmd.has_comma, &cmd.cmd)
6262
}
6363

64+
fn verify_command(&self, cmd: &Command) -> Result<(), EditError> {
65+
self.verify_lnhash(cmd.addr1, &cmd.cmd)?;
66+
if let Some(a2) = cmd.addr2 {
67+
self.verify_lnhash(a2, &cmd.cmd)?;
68+
}
69+
self.verify_subcommand_refs(&cmd.cmd)?;
70+
Ok(())
71+
}
72+
73+
fn verify_subcommand_refs(&self, cmd: &Subcommand) -> Result<(), EditError> {
74+
match cmd {
75+
Subcommand::Move { dest } | Subcommand::Copy { dest } => {
76+
self.verify_lnhash_basic(*dest)?;
77+
Ok(())
78+
}
79+
Subcommand::Global { cmd, .. } => self.verify_subcommand_refs(cmd),
80+
_ => Ok(()),
81+
}
82+
}
83+
84+
fn verify_lnhash(&self, addr: crate::LnHash, cmd: &Subcommand) -> Result<(), EditError> {
85+
if addr.lineno == 0 {
86+
// Only valid for i/a, enforced by parser.
87+
if addr.hash != 0 {
88+
return Err(EditError::new("0|0000| must have hash 0000"));
89+
}
90+
match cmd {
91+
Subcommand::Append(_) | Subcommand::Insert(_) => Ok(()),
92+
_ => Err(EditError::new("0|0000| is only valid with i or a")),
93+
}
94+
} else {
95+
self.verify_lnhash_basic(addr)
96+
}
97+
}
98+
99+
fn verify_lnhash_basic(&self, addr: crate::LnHash) -> Result<(), EditError> {
100+
if addr.lineno == 0 {
101+
return Err(EditError::new("address 0 is not allowed here"));
102+
}
103+
if addr.lineno > self.lines.len() {
104+
return Err(EditError::new(format!(
105+
"address out of range: {} > {}",
106+
addr.lineno,
107+
self.lines.len()
108+
)));
109+
}
110+
let actual = line_hash_u16(&self.lines[addr.lineno - 1].text);
111+
if actual != addr.hash {
112+
return Err(EditError::new(format!(
113+
"stale lnhash at line {}: expected {:04x}, got {:04x}",
114+
addr.lineno, addr.hash, actual
115+
)));
116+
}
117+
Ok(())
118+
}
119+
64120
fn apply_subcommand(
65121
&mut self,
66122
start: usize,
@@ -441,14 +497,14 @@ impl Engine {
441497

442498
/// Apply `commands` to the input text.
443499
///
444-
/// All lnhashes in the command list are verified against `input` before any edits are applied.
500+
/// Each command's lnhashes are verified against the current text immediately before that
501+
/// command is applied.
445502
pub fn edit_text(input: &str, commands: &[Command]) -> Result<EditResult, EditError> {
446503
let input_lines: Vec<String> = input.lines().map(|l| l.to_string()).collect();
447504

448-
verify_all(&input_lines, commands)?;
449-
450505
let mut eng = Engine::new(input_lines);
451506
for c in commands {
507+
eng.verify_command(c)?;
452508
eng.apply_command(c)?;
453509
}
454510

@@ -476,64 +532,6 @@ pub fn edit_text(input: &str, commands: &[Command]) -> Result<EditResult, EditEr
476532
})
477533
}
478534

479-
fn verify_all(input_lines: &[String], commands: &[Command]) -> Result<(), EditError> {
480-
for c in commands {
481-
verify_lnhash(input_lines, c.addr1, &c.cmd)?;
482-
if let Some(a2) = c.addr2 {
483-
verify_lnhash(input_lines, a2, &c.cmd)?;
484-
}
485-
verify_subcommand_refs(input_lines, &c.cmd)?;
486-
}
487-
Ok(())
488-
}
489-
490-
fn verify_subcommand_refs(input_lines: &[String], cmd: &Subcommand) -> Result<(), EditError> {
491-
match cmd {
492-
Subcommand::Move { dest } | Subcommand::Copy { dest } => {
493-
verify_lnhash_basic(input_lines, *dest)?;
494-
Ok(())
495-
}
496-
Subcommand::Global { cmd, .. } => verify_subcommand_refs(input_lines, cmd),
497-
_ => Ok(()),
498-
}
499-
}
500-
501-
fn verify_lnhash(input_lines: &[String], addr: crate::LnHash, cmd: &Subcommand) -> Result<(), EditError> {
502-
if addr.lineno == 0 {
503-
// Only valid for i/a, enforced by parser.
504-
if addr.hash != 0 {
505-
return Err(EditError::new("0|0000| must have hash 0000"));
506-
}
507-
match cmd {
508-
Subcommand::Append(_) | Subcommand::Insert(_) => Ok(()),
509-
_ => Err(EditError::new("0|0000| is only valid with i or a")),
510-
}
511-
} else {
512-
verify_lnhash_basic(input_lines, addr)
513-
}
514-
}
515-
516-
fn verify_lnhash_basic(input_lines: &[String], addr: crate::LnHash) -> Result<(), EditError> {
517-
if addr.lineno == 0 {
518-
return Err(EditError::new("address 0 is not allowed here"));
519-
}
520-
if addr.lineno > input_lines.len() {
521-
return Err(EditError::new(format!(
522-
"address out of range: {} > {}",
523-
addr.lineno,
524-
input_lines.len()
525-
)));
526-
}
527-
let actual = line_hash_u16(&input_lines[addr.lineno - 1]);
528-
if actual != addr.hash {
529-
return Err(EditError::new(format!(
530-
"stale lnhash at line {}: expected {:04x}, got {:04x}",
531-
addr.lineno, addr.hash, actual
532-
)));
533-
}
534-
Ok(())
535-
}
536-
537535
fn build_regex(pattern: &str, case_insensitive: bool) -> Result<Regex, EditError> {
538536
if case_insensitive {
539537
RegexBuilder::new(pattern)
@@ -780,18 +778,16 @@ mod tests {
780778
}
781779

782780
#[test]
783-
fn multi_command_line_numbers_shift() {
781+
fn multi_command_rechecks_hashes_after_each_command() {
784782
let input = "a\nb\nc\n";
785-
// Insert X before line 2, then delete line 3 (which was originally 2 before insertion? actually after insertion, line3 is original b)
783+
// Insert X before line 2, then try to delete original line 3 by stale lnhash.
786784
let script = format!(
787785
"{}i\nX\n.\n{}d\n",
788786
addr(2, "b"),
789787
addr(3, "c")
790788
);
791789
let cmds = parse_commands_from_script(&script).unwrap();
792-
let res = edit_text(input, &cmds).unwrap();
793-
// After insertion: a, X, b, c. Then delete line3 -> b removed.
794-
assert_eq!(res.lines, vec!["a".to_string(), "X".to_string(), "c".to_string()]);
795-
assert_eq!(res.deleted, vec![2]);
790+
let err = edit_text(input, &cmds).unwrap_err();
791+
assert!(err.message().contains("stale lnhash at line 3"));
796792
}
797793
}

tests/cli.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,30 @@ fn exhash_rejects_stale_lnhash_and_leaves_file_unchanged() {
130130
assert_eq!(read_file(&file), "HELLO\nworld\n");
131131
}
132132

133+
#[test]
134+
fn exhash_rechecks_hashes_between_commands() {
135+
let dir = mk_temp_dir("exhash_stale_between_commands");
136+
let file = dir.join("f.txt");
137+
write_file(&file, "a\nb\n");
138+
139+
let a1 = format_lnhash(1, "a");
140+
let cmd1 = format!("{}s/a/A/", a1);
141+
let cmd2 = format!("{}d", a1);
142+
143+
let bin = env!("CARGO_BIN_EXE_exhash");
144+
let out = Command::new(bin)
145+
.arg(&file)
146+
.arg(cmd1)
147+
.arg(cmd2)
148+
.output()
149+
.unwrap();
150+
assert!(!out.status.success());
151+
assert!(String::from_utf8(out.stderr).unwrap().contains("stale lnhash"));
152+
153+
// No partial write on command failure.
154+
assert_eq!(read_file(&file), "a\nb\n");
155+
}
156+
133157
#[test]
134158
fn exhash_multiline_append_from_stdin() {
135159
let dir = mk_temp_dir("exhash_multiline");

tests/test_exhash.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,11 @@ def test_exhash_multiple_cmds():
107107
assert res["lines"] == ["A", "b", "C"]
108108
assert res["modified"] == [1, 3]
109109

110+
def test_exhash_rechecks_hash_before_each_command():
111+
text = "a\nb\nc\n"
112+
a2, a3 = lnhash(2, "b"), lnhash(3, "c")
113+
with pytest.raises(ValueError, match="stale"): exhash(text, [f"{a2}i\nx", f"{a3}d"])
114+
110115
def test_exhash_append_trailing_newline():
111116
text = "a\nb\n"
112117
addr = lnhash(1, "a")
@@ -118,7 +123,8 @@ def test_exhash_multiline_non_text_cmd_raises():
118123
addr = lnhash(1, "a")
119124
with pytest.raises(ValueError): exhash(text, [f"{addr}d\nextra"])
120125

121-
def test_exhash_requires_list_cmds():
126+
def test_exhash_accepts_tuple_cmds():
122127
text = "a\nb\n"
123128
a1, a2 = lnhash(1, "a"), lnhash(2, "b")
124-
with pytest.raises(TypeError): exhash(text, (f"{a1}s/a/A/", f"{a2}s/b/B/"))
129+
res = exhash(text, (f"{a1}s/a/A/", f"{a2}s/b/B/"))
130+
assert res["lines"] == ["A", "B"]

0 commit comments

Comments
 (0)