exhash 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,7 +25,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
25
25
 
26
26
  [[package]]
27
27
  name = "exhash"
28
- version = "0.2.2"
28
+ version = "0.2.3"
29
29
  dependencies = [
30
30
  "pyo3",
31
31
  "regex",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "exhash"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  edition = "2021"
5
5
  license = "MIT OR Apache-2.0"
6
6
  description = "Verified line-addressed file editor using lnhash addresses"
@@ -49,6 +49,7 @@ cargo test && pytest -q
49
49
 
50
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
51
  The `$` (last line) and `%` (whole file) address forms are resolved against the current buffer and do not require hashes.
52
+ `edit_text_with_sw` exposes configurable shift width for `<` and `>`; `edit_text` defaults to `sw=4`.
52
53
 
53
54
  ## Release
54
55
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: exhash
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Summary: Verified line-addressed file editor using lnhash addresses
@@ -71,6 +71,9 @@ EOF
71
71
  # Dry-run
72
72
  exhash --dry-run file.txt '12|abcd|d'
73
73
 
74
+ # Set shift width for < and >
75
+ exhash --sw 2 file.txt '12|abcd|>1'
76
+
74
77
  # Last line and whole file shorthands (no hash)
75
78
  exhash file.txt '$d'
76
79
  exhash file.txt '%j'
@@ -117,7 +120,7 @@ view = lnhashview(text) # ["1|a1b2| foo", "2|c3d4| bar"]
117
120
 
118
121
  ### Editing
119
122
 
120
- `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):
123
+ `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):
121
124
 
122
125
  ```py
123
126
  addr = lnhash(1, "foo") # "1|a1b2|"
@@ -134,6 +137,9 @@ res = exhash(text, [f"{a1}s/foo/FOO/", f"{a2}s/bar/BAR/"])
134
137
 
135
138
  # Append multiline text (no dot terminator)
136
139
  res = exhash(text, [f"{addr}a\nnew line 1\nnew line 2"])
140
+
141
+ # Change shift width for < and >
142
+ res = exhash(text, [f"{addr}>1"], sw=2)
137
143
  ```
138
144
 
139
145
  ### Result dict
@@ -56,6 +56,9 @@ EOF
56
56
  # Dry-run
57
57
  exhash --dry-run file.txt '12|abcd|d'
58
58
 
59
+ # Set shift width for < and >
60
+ exhash --sw 2 file.txt '12|abcd|>1'
61
+
59
62
  # Last line and whole file shorthands (no hash)
60
63
  exhash file.txt '$d'
61
64
  exhash file.txt '%j'
@@ -102,7 +105,7 @@ view = lnhashview(text) # ["1|a1b2| foo", "2|c3d4| bar"]
102
105
 
103
106
  ### Editing
104
107
 
105
- `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):
108
+ `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):
106
109
 
107
110
  ```py
108
111
  addr = lnhash(1, "foo") # "1|a1b2|"
@@ -119,6 +122,9 @@ res = exhash(text, [f"{a1}s/foo/FOO/", f"{a2}s/bar/BAR/"])
119
122
 
120
123
  # Append multiline text (no dot terminator)
121
124
  res = exhash(text, [f"{addr}a\nnew line 1\nnew line 2"])
125
+
126
+ # Change shift width for < and >
127
+ res = exhash(text, [f"{addr}>1"], sw=2)
122
128
  ```
123
129
 
124
130
  ### Result dict
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "exhash"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Verified line-addressed file editor using lnhash addresses"
9
9
  license = {text = "MIT OR Apache-2.0"}
10
10
  requires-python = ">=3.10"
@@ -28,7 +28,7 @@ def exhash_result(results:list[dict]) -> str:
28
28
  return '\n'.join(out)
29
29
 
30
30
 
31
- def exhash(text:str, cmds:list[str]) -> dict:
31
+ def exhash(text:str, cmds:list[str], sw:int=4) -> dict:
32
32
  """Verified line-addressed editor. Apply commands to `text`, return a result dict.
33
33
 
34
34
  Commands primarily use lnhash addresses: ``lineno|hash|cmd`` where hash is
@@ -55,13 +55,15 @@ def exhash(text:str, cmds:list[str]) -> dict:
55
55
  j Join with next line; with range, joins all
56
56
  m dest Move line(s) after dest address
57
57
  t dest Copy line(s) after dest address
58
- >[n] Indent n levels (default 1, 4 spaces each)
59
- <[n] Dedent n levels (default 1)
58
+ >[n] Indent n levels (default 1, `sw` spaces each)
59
+ <[n] Dedent n levels (default 1, `sw` spaces each)
60
60
  sort Sort lines alphabetically
61
61
  p Print (include in output without changing)
62
62
  g/pat/cmd Global: run cmd on matching lines
63
63
  g!/pat/cmd Inverted global (also v/pat/cmd)
64
64
 
65
+ `sw` controls shift width for `<` and `>` and defaults to 4.
66
+
65
67
  For a/i/c, remaining lines in the command string are the text block
66
68
  (no '.' terminator needed, unlike the CLI).
67
69
 
@@ -84,5 +86,5 @@ def exhash(text:str, cmds:list[str]) -> dict:
84
86
  "\\n".join(res["lines"]) # "baz\\nbar"
85
87
  res = exhash(text, [f"{addr}a\\nnew line 1\\nnew line 2"])
86
88
  """
87
- r = _exhash(text, *cmds)
89
+ r = _exhash(text, *cmds, sw=sw)
88
90
  return dict(lines=r.lines, hashes=r.hashes, modified=r.modified, deleted=r.deleted)
@@ -4,11 +4,11 @@ use std::io::{self, Read};
4
4
  use std::path::{Path, PathBuf};
5
5
  use std::process;
6
6
 
7
- use exhash::{edit_text, parse_commands_from_args};
7
+ use exhash::{edit_text_with_sw, parse_commands_from_args};
8
8
 
9
9
  fn usage() {
10
10
  eprintln!("\
11
- Usage: exhash [-h] [--dry-run] [--stdin] <file|-> [commands...]
11
+ Usage: exhash [-h] [--dry-run] [--stdin] [--sw N] <file|-> [commands...]
12
12
 
13
13
  Verified line-addressed file editor using lnhash addresses.
14
14
 
@@ -36,8 +36,8 @@ COMMANDS
36
36
  j Join with next line; with range, joins all lines in range
37
37
  m dest Move line(s) after dest address
38
38
  t dest Copy line(s) after dest address
39
- >[n] Indent n levels (default 1, 4 spaces each)
40
- <[n] Dedent n levels (default 1)
39
+ >[n] Indent n levels (default 1, --sw spaces each)
40
+ <[n] Dedent n levels (default 1, --sw spaces each)
41
41
  sort Sort lines alphabetically
42
42
  p Print (include lines in output without changing them)
43
43
  g/pat/cmd Global: run cmd on matching lines
@@ -50,6 +50,7 @@ TEXT BLOCKS (a/i/c)
50
50
 
51
51
  OPTIONS
52
52
  --dry-run Don't write; show what would change on stdout
53
+ --sw N Shift width for < and > (default 4)
53
54
  --stdin Read input from stdin (file arg must be '-');
54
55
  outputs full file in lnhash format.
55
56
  Text blocks (a/i/c) not supported in this mode.
@@ -125,6 +126,7 @@ fn main() {
125
126
 
126
127
  let mut dry_run = false;
127
128
  let mut stdin_mode = false;
129
+ let mut sw = 4usize;
128
130
 
129
131
  let mut idx = 1;
130
132
  while idx < args.len() {
@@ -137,6 +139,20 @@ fn main() {
137
139
  stdin_mode = true;
138
140
  idx += 1;
139
141
  }
142
+ "--sw" => {
143
+ let Some(val) = args.get(idx + 1) else {
144
+ eprintln!("error: --sw requires an integer argument");
145
+ process::exit(2);
146
+ };
147
+ sw = match val.parse::<usize>() {
148
+ Ok(n) => n,
149
+ Err(_) => {
150
+ eprintln!("error: invalid --sw value {val:?}");
151
+ process::exit(2);
152
+ }
153
+ };
154
+ idx += 2;
155
+ }
140
156
  "--help" | "-h" => {
141
157
  usage();
142
158
  return;
@@ -184,7 +200,7 @@ fn main() {
184
200
  }
185
201
  };
186
202
 
187
- let result = match edit_text(&input, &commands) {
203
+ let result = match edit_text_with_sw(&input, &commands, sw) {
188
204
  Ok(r) => r,
189
205
  Err(e) => {
190
206
  eprintln!("error: {e}");
@@ -230,7 +246,7 @@ fn main() {
230
246
  }
231
247
  };
232
248
 
233
- let result = match edit_text(&text, &commands) {
249
+ let result = match edit_text_with_sw(&text, &commands, sw) {
234
250
  Ok(r) => r,
235
251
  Err(e) => {
236
252
  eprintln!("error: {e}");
@@ -30,10 +30,11 @@ struct Line {
30
30
  struct Engine {
31
31
  lines: Vec<Line>,
32
32
  deleted: BTreeSet<usize>,
33
+ sw: usize,
33
34
  }
34
35
 
35
36
  impl Engine {
36
- fn new(input_lines: Vec<String>) -> Self {
37
+ fn new(input_lines: Vec<String>, sw: usize) -> Self {
37
38
  let lines = input_lines
38
39
  .into_iter()
39
40
  .enumerate()
@@ -47,6 +48,7 @@ impl Engine {
47
48
  Self {
48
49
  lines,
49
50
  deleted: BTreeSet::new(),
51
+ sw,
50
52
  }
51
53
  }
52
54
 
@@ -490,10 +492,10 @@ impl Engine {
490
492
 
491
493
  fn indent_range(&mut self, start: usize, end: usize, levels: usize) -> Result<(), EditError> {
492
494
  let (s, e) = self.resolve_range(start, end)?;
493
- if levels == 0 {
495
+ if levels == 0 || self.sw == 0 {
494
496
  return Ok(());
495
497
  }
496
- let prefix = " ".repeat(levels);
498
+ let prefix = " ".repeat(self.sw * levels);
497
499
  for idx in s..=e {
498
500
  let new = format!("{}{}", prefix, self.lines[idx].text);
499
501
  self.lines[idx].text = new;
@@ -504,12 +506,12 @@ impl Engine {
504
506
 
505
507
  fn dedent_range(&mut self, start: usize, end: usize, levels: usize) -> Result<(), EditError> {
506
508
  let (s, e) = self.resolve_range(start, end)?;
507
- if levels == 0 {
509
+ if levels == 0 || self.sw == 0 {
508
510
  return Ok(());
509
511
  }
510
512
  for idx in s..=e {
511
513
  let old = self.lines[idx].text.clone();
512
- let new = dedent(&old, levels);
514
+ let new = dedent(&old, levels, self.sw);
513
515
  if new != old {
514
516
  self.lines[idx].text = new;
515
517
  self.lines[idx].modified = true;
@@ -590,9 +592,13 @@ impl Engine {
590
592
  /// Each command's lnhashes are verified against the current text immediately before that
591
593
  /// command is applied.
592
594
  pub fn edit_text(input: &str, commands: &[Command]) -> Result<EditResult, EditError> {
595
+ edit_text_with_sw(input, commands, 4)
596
+ }
597
+
598
+ pub fn edit_text_with_sw(input: &str, commands: &[Command], sw: usize) -> Result<EditResult, EditError> {
593
599
  let input_lines: Vec<String> = input.lines().map(|l| l.to_string()).collect();
594
600
 
595
- let mut eng = Engine::new(input_lines);
601
+ let mut eng = Engine::new(input_lines, sw);
596
602
  for c in commands {
597
603
  eng.verify_command(c)?;
598
604
  eng.apply_command(c)?;
@@ -634,6 +640,7 @@ fn build_regex(pattern: &str, case_insensitive: bool) -> Result<Regex, EditError
634
640
  }
635
641
 
636
642
  fn join_strings(a: &str, b: &str) -> String {
643
+ let b = b.trim_start_matches(char::is_whitespace);
637
644
  if a.is_empty() {
638
645
  return b.to_string();
639
646
  }
@@ -641,29 +648,27 @@ fn join_strings(a: &str, b: &str) -> String {
641
648
  return a.to_string();
642
649
  }
643
650
  let a_end_ws = a.chars().last().map(|c| c.is_whitespace()).unwrap_or(false);
644
- let b_start_ws = b.chars().next().map(|c| c.is_whitespace()).unwrap_or(false);
645
- if a_end_ws || b_start_ws {
651
+ if a_end_ws {
646
652
  format!("{a}{b}")
647
653
  } else {
648
654
  format!("{a} {b}")
649
655
  }
650
656
  }
651
657
 
652
- fn dedent(line: &str, levels: usize) -> String {
658
+ fn dedent(line: &str, levels: usize, sw: usize) -> String {
653
659
  let mut s = line.to_string();
660
+ if sw == 0 {
661
+ return s;
662
+ }
654
663
  for _ in 0..levels {
655
- if s.starts_with(" ") {
656
- s = s[4..].to_string();
657
- continue;
658
- }
659
664
  if s.starts_with('\t') {
660
665
  s = s[1..].to_string();
661
666
  continue;
662
667
  }
663
- // Remove up to 4 leading spaces as one level.
668
+ // Remove up to `sw` leading spaces as one level.
664
669
  let mut removed = 0usize;
665
670
  let bytes = s.as_bytes();
666
- while removed < 4 && removed < bytes.len() && bytes[removed] == b' ' {
671
+ while removed < sw && removed < bytes.len() && bytes[removed] == b' ' {
667
672
  removed += 1;
668
673
  }
669
674
  if removed > 0 {
@@ -755,6 +760,15 @@ mod tests {
755
760
  assert_eq!(res.deleted, vec![2]);
756
761
  }
757
762
 
763
+ #[test]
764
+ fn join_single_address_collapses_indented_next_line_to_single_space() {
765
+ let input = "hello\n world\n";
766
+ let cmd = format!("{}j", addr(1, "hello"));
767
+ let cmds = parse_commands_from_script(&cmd).unwrap();
768
+ let res = edit_text(input, &cmds).unwrap();
769
+ assert_eq!(res.lines, vec!["hello world".to_string()]);
770
+ }
771
+
758
772
  #[test]
759
773
  fn percent_address_joins_whole_file() {
760
774
  let input = "a\nb\nc\n";
@@ -854,6 +868,18 @@ mod tests {
854
868
  assert_eq!(res.modified, vec![1, 2]);
855
869
  }
856
870
 
871
+ #[test]
872
+ fn indent_and_dedent_with_custom_sw() {
873
+ let input = "a\n b\n";
874
+ let cmd1 = format!("{}>2", addr(1, "a"));
875
+ let cmd2 = format!("{}<1", addr(2, " b"));
876
+ let script = format!("{}\n{}\n", cmd1, cmd2);
877
+ let cmds = parse_commands_from_script(&script).unwrap();
878
+ let res = edit_text_with_sw(input, &cmds, 2).unwrap();
879
+ assert_eq!(res.lines, vec![" a".to_string(), "b".to_string()]);
880
+ assert_eq!(res.modified, vec![1, 2]);
881
+ }
882
+
857
883
  #[test]
858
884
  fn sort_range() {
859
885
  let input = "c\na\nb\n";
@@ -935,6 +961,17 @@ mod tests {
935
961
  assert_eq!(res.modified, vec![1]);
936
962
  }
937
963
 
964
+ #[test]
965
+ fn join_range_preserves_outer_whitespace_and_trims_middle_line_prefixes() {
966
+ let input = " alpha\n beta \n gamma \n";
967
+ let cmd = format!("{},{}j", addr(1, " alpha"), addr(3, " gamma "));
968
+ let cmds = parse_commands_from_script(&cmd).unwrap();
969
+ let res = edit_text(input, &cmds).unwrap();
970
+ assert_eq!(res.lines, vec![" alpha beta gamma ".to_string()]);
971
+ assert_eq!(res.deleted, vec![2, 3]);
972
+ assert_eq!(res.modified, vec![1]);
973
+ }
974
+
938
975
  #[test]
939
976
  fn multi_command_rechecks_hashes_after_each_command() {
940
977
  let input = "a\nb\nc\n";
@@ -10,7 +10,7 @@ mod parse;
10
10
  #[cfg(feature = "pyo3")]
11
11
  mod python;
12
12
 
13
- pub use engine::{edit_text, EditResult};
13
+ pub use engine::{edit_text, edit_text_with_sw, EditResult};
14
14
  pub use lnhash::{format_lnhash, line_hash_u16, parse_lnhash, LnHash};
15
15
  pub use parse::{parse_commands_from_args, parse_commands_from_script, parse_commands_from_strs, Address, Command, Subcommand};
16
16
 
@@ -36,12 +36,12 @@ fn lnhashview(text: &str) -> Vec<String> {
36
36
  }
37
37
 
38
38
  #[pyfunction]
39
- #[pyo3(name = "exhash", signature = (text, *cmds))]
40
- fn py_exhash(text: &str, cmds: Vec<String>) -> PyResult<EditResultPy> {
39
+ #[pyo3(name = "exhash", signature = (text, *cmds, sw=4))]
40
+ fn py_exhash(text: &str, cmds: Vec<String>, sw: usize) -> PyResult<EditResultPy> {
41
41
  let cmd_refs: Vec<&str> = cmds.iter().map(|s| s.as_str()).collect();
42
42
  let parsed = crate::parse_commands_from_strs(&cmd_refs)
43
43
  .map_err(|e| PyValueError::new_err(e.to_string()))?;
44
- let res = crate::edit_text(text, &parsed)
44
+ let res = crate::edit_text_with_sw(text, &parsed, sw)
45
45
  .map_err(|e| PyValueError::new_err(e.to_string()))?;
46
46
  Ok(res.into())
47
47
  }
@@ -129,6 +129,31 @@ fn exhash_dry_run_does_not_write() {
129
129
  assert_eq!(read_file(&file), "foo\nbar\n");
130
130
  }
131
131
 
132
+ #[test]
133
+ fn exhash_custom_sw_option_changes_shift_width() {
134
+ let dir = mk_temp_dir("exhash_custom_sw");
135
+ let file = dir.join("f.txt");
136
+ write_file(&file, "a\n");
137
+
138
+ let a1 = format_lnhash(1, "a");
139
+ let cmd = format!("{a1}>1");
140
+
141
+ let bin = env!("CARGO_BIN_EXE_exhash");
142
+ let out = Command::new(bin)
143
+ .arg("--sw")
144
+ .arg("2")
145
+ .arg(&file)
146
+ .arg(cmd)
147
+ .output()
148
+ .unwrap();
149
+ assert!(out.status.success());
150
+
151
+ let stdout = String::from_utf8(out.stdout).unwrap();
152
+ let expected = format!("{} a\n", format_lnhash(1, " a"));
153
+ assert_eq!(stdout, expected);
154
+ assert_eq!(read_file(&file), " a\n");
155
+ }
156
+
132
157
  #[test]
133
158
  fn exhash_rejects_stale_lnhash_and_leaves_file_unchanged() {
134
159
  let dir = mk_temp_dir("exhash_stale");
@@ -105,6 +105,13 @@ def test_exhash_move_destination_can_use_last_line():
105
105
  assert res["lines"] == ["b", "c", "a"]
106
106
  assert res["modified"] == [3]
107
107
 
108
+ def test_exhash_custom_sw():
109
+ text = "a\n"
110
+ a1 = lnhash(1, "a")
111
+ res = exhash(text, [f"{a1}>1"], sw=2)
112
+ assert res["lines"] == [" a"]
113
+ assert res["modified"] == [1]
114
+
108
115
  def test_exhash_append():
109
116
  text = "a\nb\n"
110
117
  addr = lnhash(1, "a")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes