Skip to content

Commit 424689f

Browse files
committed
bump
1 parent 4e17857 commit 424689f

7 files changed

Lines changed: 325 additions & 46 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.5"
3+
version = "0.2.6"
44
edition = "2021"
55
license = "MIT OR Apache-2.0"
66
description = "Verified line-addressed file editor using lnhash addresses"

README.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ Substitute uses Rust regex syntax:
7272
- Pattern syntax is from [`regex`](https://docs.rs/regex/latest/regex/)
7373
- Replacement syntax is from [`regex::Replacer`](https://docs.rs/regex/latest/regex/struct.Regex.html#method.replace), e.g. `$1`, `$0`, `${name}`
7474
- `\/` escapes the command delimiter in pattern/replacement
75+
- Custom delimiters: `s`, `y`, `g`, `g!`, and `v` all accept any non-alphanumeric char as delimiter instead of `/`, e.g. `s@pat@rep@`, `g@pat@cmd`. Each command in a combo picks its own delimiter independently: `g@a/b@s/old/new/`
76+
- Literal newlines in pattern/replacement are supported (joins/splits lines as needed)
7577
- Transliteration uses `y/src/dst/` and requires source/destination to have equal character counts
7678

7779
When passing multiple commands, each command's lnhashes are verified immediately before that command runs.
@@ -93,14 +95,15 @@ In `--stdin` mode, multiline `a/i/c` text blocks are not available.
9395
## Python API
9496

9597
```py
96-
from exhash import exhash, exhash_result, lnhash, lnhashview, line_hash
98+
from exhash import exhash, exhash_file, exhash_result, lnhash, lnhashview, lnhashview_file, line_hash
9799
```
98100

99101
### Viewing
100102

101103
```py
102104
text = "foo\nbar\n"
103-
view = lnhashview(text) # ["1|a1b2| foo", "2|c3d4| bar"]
105+
view = lnhashview(text) # ["1|a1b2| foo", "2|c3d4| bar"]
106+
view = lnhashview_file("f.py") # same but reads from file
104107
```
105108

106109
### Editing
@@ -125,6 +128,27 @@ res = exhash(text, [f"{addr}a\nnew line 1\nnew line 2"])
125128

126129
# Change shift width for < and >
127130
res = exhash(text, [f"{addr}>1"], sw=2)
131+
132+
# Custom delimiters (useful when pattern/replacement contains /)
133+
res = exhash(text, [f"{addr}s|foo|bar|"])
134+
135+
# Literal newlines in pattern/replacement (joins/splits lines)
136+
a1, a2 = lnhash(1, "foo"), lnhash(2, "bar")
137+
res = exhash("foo\nbar\n", [f"{a1},{a2}s/foo\nbar/replaced/"])
138+
```
139+
140+
### File helpers
141+
142+
`exhash_file` and `lnhashview_file` read directly from a file path:
143+
144+
```py
145+
view = lnhashview_file("file.py")
146+
147+
# Returns result dict, file unchanged
148+
res = exhash_file("file.py", [f"{addr}s/foo/bar/"])
149+
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)
128152
```
129153

130154
### Result dict

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.5"
7+
version = "0.2.6"
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: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,10 @@ def exhash(text:str, cmds:list[str], sw:int=4) -> dict:
5353
Commands:
5454
s/pat/rep/[flags] Substitute using Rust regex syntax.
5555
Replacement supports $1, $0, ${name}. Flags: g=all, i=case-insensitive
56-
y/src/dst/ Transliterate chars in-place (source and destination lengths must match)
56+
Any non-alphanumeric delimiter works: s@pat@rep@, s|pat|rep|g
57+
Literal newlines in pat/rep are supported (joins/splits lines)
58+
y/src/dst/ Transliterate chars in-place (also supports custom delimiters;
59+
source and destination lengths must match)
5760
d Delete line(s)
5861
a Append text after line
5962
i Insert text before line
@@ -65,8 +68,8 @@ def exhash(text:str, cmds:list[str], sw:int=4) -> dict:
6568
<[n] Dedent n levels (default 1, `sw` spaces each)
6669
sort Sort lines alphabetically
6770
p Print (include in output without changing)
68-
g/pat/cmd Global: run cmd on matching lines
69-
g!/pat/cmd Inverted global (also v/pat/cmd)
71+
g/pat/cmd Global: run cmd on matching lines (custom delimiters ok: g@pat@cmd)
72+
g!/pat/cmd Inverted global (also v/pat/cmd; custom delimiters ok)
7073
7174
`sw` controls shift width for `<` and `>` and defaults to 4.
7275

src/engine.rs

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -261,20 +261,42 @@ impl Engine {
261261
fn substitute_range(&mut self, start: usize, end: usize, s: &Subst) -> Result<(), EditError> {
262262
let (s_idx, e_idx) = self.resolve_range(start, end)?;
263263
let re = build_regex(&s.pattern, s.case_insensitive)?;
264-
for idx in s_idx..=e_idx {
265-
let old = self.lines[idx].text.clone();
266-
let new = if s.global {
267-
re.replace_all(&old, s.replacement.as_str()).to_string()
264+
let multiline = s.pattern.contains('\n') || s.replacement.contains('\n');
265+
if multiline {
266+
// Join range into single string, apply substitute, split back
267+
let joined: String = (s_idx..=e_idx)
268+
.map(|i| self.lines[i].text.as_str())
269+
.collect::<Vec<_>>()
270+
.join("\n");
271+
let result = if s.global {
272+
re.replace_all(&joined, s.replacement.as_str()).to_string()
268273
} else {
269-
// replace first match
270-
if !re.is_match(&old) {
271-
continue;
272-
}
273-
re.replace(&old, s.replacement.as_str()).to_string()
274+
if !re.is_match(&joined) { return Ok(()); }
275+
re.replace(&joined, s.replacement.as_str()).to_string()
274276
};
275-
if new != old {
276-
self.lines[idx].text = new;
277-
self.lines[idx].modified = true;
277+
if result == joined { return Ok(()); }
278+
let new_lines: Vec<String> = result.split('\n').map(|s| s.to_string()).collect();
279+
let origins: Vec<Option<usize>> = (s_idx..=e_idx).map(|i| self.lines[i].origin).collect();
280+
let new_line_objs: Vec<Line> = new_lines.into_iter().enumerate().map(|(i, text)| Line {
281+
text,
282+
origin: origins.get(i).copied().flatten(),
283+
modified: true,
284+
global_mark: false,
285+
}).collect();
286+
self.lines.splice(s_idx..=e_idx, new_line_objs);
287+
} else {
288+
for idx in s_idx..=e_idx {
289+
let old = self.lines[idx].text.clone();
290+
let new = if s.global {
291+
re.replace_all(&old, s.replacement.as_str()).to_string()
292+
} else {
293+
if !re.is_match(&old) { continue; }
294+
re.replace(&old, s.replacement.as_str()).to_string()
295+
};
296+
if new != old {
297+
self.lines[idx].text = new;
298+
self.lines[idx].modified = true;
299+
}
278300
}
279301
}
280302
Ok(())
@@ -684,7 +706,7 @@ fn dedent(line: &str, levels: usize, sw: usize) -> String {
684706
mod tests {
685707
use super::*;
686708
use crate::lnhash::{format_lnhash, line_hash_u16};
687-
use crate::parse::parse_commands_from_script;
709+
use crate::parse::{parse_commands_from_script, parse_commands_from_strs};
688710

689711
fn addr(lineno: usize, line: &str) -> String {
690712
format_lnhash(lineno, line)
@@ -985,4 +1007,31 @@ mod tests {
9851007
let err = edit_text(input, &cmds).unwrap_err();
9861008
assert!(err.message().contains("stale lnhash at line 3"));
9871009
}
1010+
1011+
#[test]
1012+
fn substitute_multiline_pattern_joins_and_replaces() {
1013+
let input = "foo\nbar\nbaz\n";
1014+
let cmd_str = format!("{},{}s/foo\nbar/replaced/", addr(1, "foo"), addr(2, "bar"));
1015+
let cmds = parse_commands_from_strs(&[&cmd_str]).unwrap();
1016+
let res = edit_text(input, &cmds).unwrap();
1017+
assert_eq!(res.lines, vec!["replaced", "baz"]);
1018+
}
1019+
1020+
#[test]
1021+
fn substitute_newline_in_replacement_splits_line() {
1022+
let input = "foobar\nbaz\n";
1023+
let cmd_str = format!("{}s/foobar/foo\nbar/", addr(1, "foobar"));
1024+
let cmds = parse_commands_from_strs(&[&cmd_str]).unwrap();
1025+
let res = edit_text(input, &cmds).unwrap();
1026+
assert_eq!(res.lines, vec!["foo", "bar", "baz"]);
1027+
}
1028+
1029+
#[test]
1030+
fn substitute_custom_delimiter() {
1031+
let input = "a/b\n";
1032+
let cmd = format!("{}s|a/b|c/d|", addr(1, "a/b"));
1033+
let cmds = parse_commands_from_script(&cmd).unwrap();
1034+
let res = edit_text(input, &cmds).unwrap();
1035+
assert_eq!(res.lines, vec!["c/d"]);
1036+
}
9881037
}

0 commit comments

Comments
 (0)