Skip to content

Commit 7134705

Browse files
committed
fixes #10
1 parent 2b37704 commit 7134705

9 files changed

Lines changed: 203 additions & 75 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "exhash"
3-
version = "0.2.7"
3+
version = "0.3.0"
44
edition = "2021"
55
license = "MIT OR Apache-2.0"
66
description = "Verified line-addressed file editor using lnhash addresses"

README.md

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ In `--stdin` mode, multiline `a/i/c` text blocks are not available.
9595
## Python API
9696

9797
```py
98-
from exhash import exhash, exhash_file, exhash_result, lnhash, lnhashview, lnhashview_file, line_hash
98+
from exhash import exhash, exhash_file, lnhash, lnhashview, lnhashview_file, line_hash
9999
```
100100

101101
### Viewing
@@ -144,21 +144,32 @@ res = exhash("foo\nbar\n", [f"{a1},{a2}s/foo\nbar/replaced/"])
144144
```py
145145
view = lnhashview_file("file.py")
146146

147-
# Returns result dict, file unchanged
147+
# Returns EditResult, file unchanged
148148
res = exhash_file("file.py", [f"{addr}s/foo/bar/"])
149149

150-
# With inplace=True, writes back on success; no changes if any command fails
151-
res = exhash_file("file.py", [f"{addr}s/foo/bar/"], inplace=True)
150+
# With inplace=True, writes back on success and returns diff string
151+
diff = exhash_file("file.py", [f"{addr}s/foo/bar/"], inplace=True)
152152
```
153153

154-
### Result dict
154+
### EditResult
155+
156+
`exhash()` returns an `EditResult` with attributes (also accessible via `res["key"]`):
155157

156158
- `lines` — list of output lines
157159
- `hashes` — lnhash for each output line
158160
- `modified` — 1-based line numbers of modified/added lines
159161
- `deleted` — 1-based line numbers of removed lines (in original)
162+
- `origins` — for each output line, the 1-based original line number (None if inserted)
163+
164+
`res.format_diff(context=1)` returns a unified-diff-style summary showing only changed lines with context:
160165

161-
`exhash_result([res1, res2, ...])` renders modified lines in lnhash format, matching the old `repr(EditResult)` style.
166+
```py
167+
res = exhash(text, [f"{addr}s/foo/baz/"])
168+
print(res.format_diff())
169+
# -1|a1b2| foo
170+
# +1|c3d4| baz
171+
# 2|e5f6| bar
172+
```
162173

163174
## Tests
164175

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "maturin"
44

55
[project]
66
name = "exhash"
7-
version = "0.2.7"
7+
version = "0.3.0"
88
description = "Verified line-addressed file editor using lnhash addresses"
99
license = {text = "MIT OR Apache-2.0"}
1010
requires-python = ">=3.10"

python/exhash/__init__.py

Lines changed: 14 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,8 @@ def lnhashview_file(path:str, start:int=None, end:int=None) -> list[str]:
2121
return _lnhashview(Path(path).read_text(), start, end)
2222

2323

24-
def exhash_result(results:list[dict]) -> str:
25-
'Format modified lines from exhash result dicts in lnhash view format.'
26-
if not isinstance(results, list): raise TypeError("results must be a list[dict]")
27-
out = []
28-
for r in results:
29-
if not isinstance(r, dict): raise TypeError("results must be a list[dict]")
30-
lines, hashes, modified = r.get("lines"), r.get("hashes"), r.get("modified")
31-
if not isinstance(lines, list) or not isinstance(hashes, list) or not isinstance(modified, list):
32-
raise TypeError("each result must include list fields: lines, hashes, modified")
33-
out += [f"{hashes[i-1]} {lines[i-1]}" for i in modified if isinstance(i, int) and 0 < i <= len(hashes)]
34-
return '\n'.join(out)
35-
36-
37-
def exhash(text:str, cmds:list[str], sw:int=4) -> dict:
38-
"""Verified line-addressed editor. Apply commands to `text`, return a result dict.
24+
def exhash(text:str, cmds:list[str], sw:int=4):
25+
"""Verified line-addressed editor. Apply commands to `text`, return an EditResult.
3926
4027
Commands primarily use lnhash addresses: ``lineno|hash|cmd`` where hash is
4128
a 4-char hex content hash. Use ``lnhashview(text)`` or
@@ -76,11 +63,14 @@ def exhash(text:str, cmds:list[str], sw:int=4) -> dict:
7663
For a/i/c, remaining lines in the command string are the text block
7764
(no '.' terminator needed, unlike the CLI).
7865
79-
Returns a dict with:
66+
Returns an EditResult with attributes (also accessible as dict keys):
8067
lines list of output lines
8168
hashes lnhash for each output line
8269
modified 1-based line numbers of modified/added lines
8370
deleted 1-based line numbers of removed lines (in original)
71+
origins for each output line, the 1-based original line number (None if inserted)
72+
73+
Call ``res.format_diff(context=1)`` for a unified-diff-style summary.
8474
8575
`cmds` is a required iterable of command strings. For `a`/`i`/`c`, include
8676
the text block in the same command string after a newline.
@@ -92,16 +82,16 @@ def exhash(text:str, cmds:list[str], sw:int=4) -> dict:
9282
addr = lnhash(1, "foo") # "1|a1b2|"
9383
res = exhash(text, [f"{addr}s/foo/baz/"])
9484
print(res["lines"]) # ["baz", "bar"]
95-
"\\n".join(res["lines"]) # "baz\\nbar"
96-
res = exhash(text, [f"{addr}a\\nnew line 1\\nnew line 2"])
85+
print(res.format_diff()) # unified-diff-style summary
9786
"""
98-
r = _exhash(text, *cmds, sw=sw)
99-
return dict(lines=r.lines, hashes=r.hashes, modified=r.modified, deleted=r.deleted)
87+
return _exhash(text, *cmds, sw=sw)
10088

10189

102-
def exhash_file(path:str, cmds:list[str], sw:int=4, inplace:bool=False) -> dict:
103-
'Like ``exhash`` but reads from file at ``path``. If ``inplace``, writes result back (atomically on success only).'
90+
def exhash_file(path:str, cmds:list[str], sw:int=4, inplace:bool=False):
91+
'Like ``exhash`` but reads from file at ``path``. If ``inplace``, writes back and returns diff string.'
10492
text = Path(path).read_text()
105-
r = exhash(text, cmds, sw=sw)
106-
if inplace: Path(path).write_text('\n'.join(r['lines']) + '\n' if r['lines'] else '')
93+
r = _exhash(text, *cmds, sw=sw)
94+
if inplace:
95+
Path(path).write_text('\n'.join(r['lines']) + '\n' if r['lines'] else '')
96+
return r.format_diff()
10797
return r

src/bin/exhash.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -269,10 +269,7 @@ fn main() {
269269
}
270270
}
271271

272-
for lineno in &result.modified {
273-
let i = lineno - 1;
274-
if let (Some(h), Some(line)) = (result.hashes.get(i), result.lines.get(i)) {
275-
println!("{h} {line}");
276-
}
277-
}
272+
let original_lines: Vec<&str> = text.lines().collect();
273+
let diff = result.format_diff(&original_lines, 1);
274+
if !diff.is_empty() { print!("{diff}"); }
278275
}

src/engine.rs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,95 @@ pub struct EditResult {
1717
pub modified: Vec<usize>,
1818
/// Old-file 1-based line numbers that were removed.
1919
pub deleted: Vec<usize>,
20+
/// For each output line, the 1-based original line number it came from (None if inserted).
21+
pub origins: Vec<Option<usize>>,
22+
}
23+
24+
impl EditResult {
25+
/// Format a unified-diff-style summary of changes.
26+
///
27+
/// Each line is prefixed with ` ` (context), `+` (added/modified), or `-` (deleted),
28+
/// followed by the lnhash and content. `context` controls how many unchanged lines
29+
/// surround each hunk (default 1).
30+
pub fn format_diff(&self, original_lines: &[&str], context: usize) -> String {
31+
use crate::lnhash::format_lnhash;
32+
33+
let mod_set: BTreeSet<usize> = self.modified.iter().copied().collect();
34+
let del_set: BTreeSet<usize> = self.deleted.iter().copied().collect();
35+
36+
// Build interleaved sequence of (tag, lnhash, text) where tag is ' ', '+', '-'
37+
// Walk new lines, inserting deleted old lines at the right positions.
38+
let mut events: Vec<(char, String, &str)> = Vec::new();
39+
let mut next_old = 1usize; // next original line we expect
40+
41+
for (new_idx, line) in self.lines.iter().enumerate() {
42+
let new_lineno = new_idx + 1;
43+
let origin = self.origins[new_idx];
44+
45+
// Emit any deleted old lines that came before this line's origin
46+
if let Some(orig) = origin {
47+
while next_old < orig {
48+
if del_set.contains(&next_old) {
49+
let old_line = original_lines[next_old - 1];
50+
events.push(('-', format_lnhash(next_old, old_line), old_line));
51+
}
52+
next_old += 1;
53+
}
54+
next_old = orig + 1;
55+
}
56+
57+
if mod_set.contains(&new_lineno) {
58+
// Show the old line as deleted if this was a modification (not insertion)
59+
if let Some(orig) = origin {
60+
let old_line = original_lines[orig - 1];
61+
if old_line != line.as_str() {
62+
events.push(('-', format_lnhash(orig, old_line), old_line));
63+
}
64+
}
65+
events.push(('+', self.hashes[new_idx].clone(), line.as_str()));
66+
} else {
67+
events.push((' ', self.hashes[new_idx].clone(), line.as_str()));
68+
}
69+
}
70+
71+
// Emit any remaining deleted lines at the end
72+
let old_len = original_lines.len();
73+
while next_old <= old_len {
74+
if del_set.contains(&next_old) {
75+
let old_line = original_lines[next_old - 1];
76+
events.push(('-', format_lnhash(next_old, old_line), old_line));
77+
}
78+
next_old += 1;
79+
}
80+
81+
// Now group into hunks with context
82+
let interesting: BTreeSet<usize> = events.iter().enumerate()
83+
.filter(|(_, (tag, _, _))| *tag != ' ')
84+
.flat_map(|(i, _)| {
85+
let start = i.saturating_sub(context);
86+
let end = (i + context).min(events.len() - 1);
87+
start..=end
88+
})
89+
.collect();
90+
91+
if interesting.is_empty() { return String::new(); }
92+
93+
let mut out = String::new();
94+
let mut last: Option<usize> = None;
95+
for i in &interesting {
96+
if let Some(prev) = last {
97+
if *i > prev + 1 { out.push_str("---\n"); }
98+
}
99+
let (tag, ref hash, text) = events[*i];
100+
out.push(tag);
101+
out.push_str(hash);
102+
out.push_str(" ");
103+
out.push_str(text);
104+
out.push('\n');
105+
last = Some(*i);
106+
}
107+
out
108+
}
20109
}
21110

22111
#[derive(Debug, Clone)]
@@ -641,12 +730,14 @@ pub fn edit_text_with_sw(input: &str, commands: &[Command], sw: usize) -> Result
641730
.collect();
642731

643732
let deleted: Vec<usize> = eng.deleted.into_iter().collect();
733+
let origins: Vec<Option<usize>> = eng.lines.iter().map(|l| l.origin).collect();
644734

645735
Ok(EditResult {
646736
lines,
647737
hashes,
648738
modified,
649739
deleted,
740+
origins,
650741
})
651742
}
652743

src/python.rs

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,45 @@ struct EditResultPy {
1212
modified: Vec<usize>,
1313
#[pyo3(get)]
1414
deleted: Vec<usize>,
15+
#[pyo3(get)]
16+
origins: Vec<Option<usize>>,
17+
original_text: String,
1518
}
1619

20+
#[pymethods]
21+
impl EditResultPy {
22+
#[pyo3(signature = (context=1))]
23+
fn format_diff(&self, context: usize) -> String {
24+
let original_lines: Vec<&str> = self.original_text.lines().collect();
25+
let result = crate::EditResult {
26+
lines: self.lines.clone(), hashes: self.hashes.clone(),
27+
modified: self.modified.clone(), deleted: self.deleted.clone(),
28+
origins: self.origins.clone(),
29+
};
30+
result.format_diff(&original_lines, context)
31+
}
32+
33+
fn __str__(&self) -> String { self.format_diff(1) }
34+
35+
fn __repr__(&self) -> String {
36+
let diff = self.format_diff(1);
37+
if diff.is_empty() {
38+
format!("EditResult({} lines, no changes)", self.lines.len())
39+
} else {
40+
format!("EditResult({} lines, {} modified, {} deleted)\n{}",
41+
self.lines.len(), self.modified.len(), self.deleted.len(), diff)
42+
}
43+
}
1744

18-
impl From<crate::EditResult> for EditResultPy {
19-
fn from(r: crate::EditResult) -> Self {
20-
Self { lines: r.lines, hashes: r.hashes, modified: r.modified, deleted: r.deleted }
45+
fn __getitem__(&self, key: &str) -> PyResult<PyObject> {
46+
Python::with_gil(|py| match key {
47+
"lines" => Ok(self.lines.clone().into_pyobject(py)?.into_any().unbind()),
48+
"hashes" => Ok(self.hashes.clone().into_pyobject(py)?.into_any().unbind()),
49+
"modified" => Ok(self.modified.clone().into_pyobject(py)?.into_any().unbind()),
50+
"deleted" => Ok(self.deleted.clone().into_pyobject(py)?.into_any().unbind()),
51+
"origins" => Ok(self.origins.clone().into_pyobject(py)?.into_any().unbind()),
52+
_ => Err(pyo3::exceptions::PyKeyError::new_err(key.to_string())),
53+
})
2154
}
2255
}
2356

@@ -43,7 +76,11 @@ fn py_exhash(text: &str, cmds: Vec<String>, sw: usize) -> PyResult<EditResultPy>
4376
.map_err(|e| PyValueError::new_err(e.to_string()))?;
4477
let res = crate::edit_text_with_sw(text, &parsed, sw)
4578
.map_err(|e| PyValueError::new_err(e.to_string()))?;
46-
Ok(res.into())
79+
Ok(EditResultPy {
80+
lines: res.lines, hashes: res.hashes, modified: res.modified,
81+
deleted: res.deleted, origins: res.origins,
82+
original_text: text.to_string(),
83+
})
4784
}
4885

4986
#[pymodule]

0 commit comments

Comments
 (0)