Skip to content

Commit edc0883

Browse files
committed
fixes #11
1 parent c6a327e commit edc0883

6 files changed

Lines changed: 73 additions & 11 deletions

File tree

DEV.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ Maturin's `data` option in `pyproject.toml` points to `python/exhash.data/`. Fil
8686

8787
The Rust core has three parsing functions:
8888

89-
- `parse_commands_from_strs(&[&str])` — for the Python API; each string is one command, text blocks are the remaining lines (no `.` terminator)
89+
- `parse_commands_from_strs(&[&str])` — for the Python API; each string is one command, text blocks are the remaining lines (no `.` terminator; a trailing `.` line is literal text and the Python binding warns about this common mistake)
9090
- `parse_commands_from_script(&str)` — for script strings; commands separated by newlines, text blocks terminated by `.`
9191
- `parse_commands_from_args(&[String], &mut BufRead)` — for the CLI; each arg is a command, text blocks read from stdin terminated by `.`
9292

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ view = lnhashview_file("f.py") # same but reads from file
108108

109109
### Editing
110110

111-
`exhash(text, cmds, sw=4)` takes the text and a required iterable of command strings (use `[]` for no-op). `sw` controls how far `<` and `>` shift. For `a`/`i`/`c` commands, lines after the command are the text block (no `.` terminator needed):
111+
`exhash(text, cmds, sw=4)` takes the text and a required iterable of command strings (use `[]` for no-op). `sw` controls how far `<` and `>` shift. For `a`/`i`/`c` commands, lines after the command are the text block. Do not include an ex-style trailing `.` line here: unlike CLI/script mode, `exhash(text, cmds)` does not use one, and a final `.` line is inserted literally.
112112

113113
```py
114114
addr = lnhash(1, "foo") # "1|a1b2|"
@@ -126,6 +126,9 @@ res = exhash(text, [f"{a1}s/foo/FOO/", f"{a2}s/bar/BAR/"])
126126
# Append multiline text (no dot terminator)
127127
res = exhash(text, [f"{addr}a\nnew line 1\nnew line 2"])
128128

129+
# Wrong for the Python API: the trailing "." would be inserted literally
130+
# res = exhash(text, [f"{addr}a\nnew line 1\nnew line 2\n."])
131+
129132
# Change shift width for < and >
130133
res = exhash(text, [f"{addr}>1"], sw=2)
131134

python/exhash/__init__.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,10 @@ def exhash(text:str, cmds:list[str], sw:int=4):
6060
6161
`sw` controls shift width for `<` and `>` and defaults to 4.
6262
63-
For a/i/c, remaining lines in the command string are the text block
64-
(no '.' terminator needed, unlike the CLI).
63+
For a/i/c, remaining lines in the command string are the text block.
64+
Do not include an ex-style trailing ``.`` terminator here: unlike CLI/script
65+
mode, ``exhash(text, cmds)`` does not use one. If you include a final ``.``
66+
line, it is inserted literally and exhash emits a warning.
6567
6668
Returns an EditResult with attributes (also accessible as dict keys):
6769
lines list of output lines
@@ -88,7 +90,7 @@ def exhash(text:str, cmds:list[str], sw:int=4):
8890

8991

9092
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.'
93+
'Like ``exhash`` but reads from file at ``path``. Uses the same no-``.``-terminator rule for a/i/c text blocks. If ``inplace``, writes back and returns diff string.'
9294
text = Path(path).read_text()
9395
r = _exhash(text, *cmds, sw=sw)
9496
if inplace:

src/parse.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ pub fn parse_commands_from_args(
7171
/// Parse commands from a list of individual command strings (for programmatic APIs).
7272
///
7373
/// Each string is one command. For `a`/`i`/`c`, lines after the first are the text
74-
/// block (no `.` terminator needed). For other commands, extra lines are an error.
74+
/// block (no `.` terminator needed; a trailing `.` line is literal text). For
75+
/// other commands, extra lines are an error.
7576
pub fn parse_commands_from_strs(cmds: &[&str]) -> Result<Vec<Command>, EditError> {
7677
let mut out = Vec::with_capacity(cmds.len());
7778
for s in cmds {

src/python.rs

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
use pyo3::exceptions::PyValueError;
1+
use pyo3::exceptions::{PyUserWarning, PyValueError};
22
use pyo3::prelude::*;
33

4+
use crate::{Command, Subcommand};
5+
46
#[pyclass]
57
#[derive(Clone)]
68
struct EditResultPy {
@@ -70,10 +72,11 @@ fn lnhashview(text: &str, start: Option<usize>, end: Option<usize>) -> PyResult<
7072

7173
#[pyfunction]
7274
#[pyo3(name = "exhash", signature = (text, *cmds, sw=4))]
73-
fn py_exhash(text: &str, cmds: Vec<String>, sw: usize) -> PyResult<EditResultPy> {
75+
fn py_exhash(py: Python<'_>, text: &str, cmds: Vec<String>, sw: usize) -> PyResult<EditResultPy> {
7476
let cmd_refs: Vec<&str> = cmds.iter().map(|s| s.as_str()).collect();
7577
let parsed = crate::parse_commands_from_strs(&cmd_refs)
7678
.map_err(|e| PyValueError::new_err(e.to_string()))?;
79+
warn_on_ex_style_dot_terminators(py, &cmds, &parsed)?;
7780
let res = crate::edit_text_with_sw(text, &parsed, sw)
7881
.map_err(|e| PyValueError::new_err(e.to_string()))?;
7982
Ok(EditResultPy {
@@ -92,3 +95,41 @@ fn exhash(m: &Bound<'_, PyModule>) -> PyResult<()> {
9295
m.add_function(wrap_pyfunction!(py_exhash, m)?)?;
9396
Ok(())
9497
}
98+
99+
fn warn_on_ex_style_dot_terminators(py: Python<'_>, inputs: &[String], parsed: &[Command]) -> PyResult<()> {
100+
for (i, (input, cmd)) in inputs.iter().zip(parsed.iter()).enumerate() {
101+
if command_has_text_block(cmd) && looks_like_ex_style_dot_terminator(input) {
102+
let msg = format!(
103+
"cmds[{i}] ends with a '.' line. In exhash(text, cmds), a/i/c text blocks do not use ex-style '.' terminators; that final '.' line will be inserted literally."
104+
);
105+
let warnings = py.import("warnings")?;
106+
warnings.call_method1("warn", (msg, py.get_type::<PyUserWarning>(), 2))?;
107+
}
108+
}
109+
Ok(())
110+
}
111+
112+
fn command_has_text_block(cmd: &Command) -> bool {
113+
match &cmd.cmd {
114+
Subcommand::Append(_) | Subcommand::Insert(_) | Subcommand::Change(_) => true,
115+
Subcommand::Global { cmd, .. } => matches!(
116+
cmd.as_ref(),
117+
Subcommand::Append(_) | Subcommand::Insert(_) | Subcommand::Change(_)
118+
),
119+
_ => false,
120+
}
121+
}
122+
123+
fn looks_like_ex_style_dot_terminator(input: &str) -> bool {
124+
let Some((_, rest)) = input.split_once('\n') else {
125+
return false;
126+
};
127+
let mut lines: Vec<&str> = rest
128+
.split('\n')
129+
.map(|line| line.strip_suffix('\r').unwrap_or(line))
130+
.collect();
131+
while matches!(lines.last(), Some(&"")) {
132+
lines.pop();
133+
}
134+
matches!(lines.last(), Some(&"."))
135+
}

tests/test_exhash.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import pytest
1+
import warnings, pytest
22
from exhash import line_hash, lnhash, lnhashview, exhash
33

44
def test_line_hash_returns_4_hex():
@@ -119,6 +119,21 @@ def test_exhash_append():
119119
assert res["lines"] == ["a", "x", "y", "b"]
120120
assert res["modified"] == [2, 3]
121121

122+
def test_exhash_warns_on_ex_style_dot_terminator_for_text_block():
123+
text = "a\nb\n"
124+
addr = lnhash(1, "a")
125+
with pytest.warns(UserWarning, match=r"final '\.' line will be inserted literally"): res = exhash(text, [addr+"a\nx\n."])
126+
assert res["lines"] == ["a", "x", ".", "b"]
127+
128+
def test_exhash_does_not_warn_for_nontrailing_dot_line():
129+
text = "a\nb\n"
130+
addr = lnhash(1, "a")
131+
with warnings.catch_warnings(record=True) as caught:
132+
warnings.simplefilter("always")
133+
res = exhash(text, [addr+"a\n.\nx"])
134+
assert res["lines"] == ["a", ".", "x", "b"]
135+
assert not caught
136+
122137
def test_exhash_insert():
123138
text = "a\nb\n"
124139
addr = lnhash(2, "b")
@@ -211,7 +226,7 @@ def test_exhash_literal_newline_in_replacement():
211226
assert res["lines"] == ["foo", "bar", "baz"]
212227

213228
def test_exhash_file_read(tmp_path):
214-
from exhash import lnhashview_file, exhash_file
229+
from exhash import lnhashview_file
215230
f = tmp_path / "test.txt"
216231
f.write_text("hello\nworld\n")
217232
lines = lnhashview_file(str(f))
@@ -229,7 +244,7 @@ def test_exhash_file_inplace(tmp_path):
229244
assert f.read_text() == "baz\nbar\n"
230245

231246
def test_exhash_file_inplace_no_change_on_error(tmp_path):
232-
from exhash import exhash_file, lnhash
247+
from exhash import exhash_file
233248
f = tmp_path / "test.txt"
234249
f.write_text("foo\nbar\n")
235250
with pytest.raises(ValueError): exhash_file(str(f), ["99|ffff|s/x/y/"], inplace=True)

0 commit comments

Comments
 (0)