-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathzone_editor.rs
More file actions
496 lines (427 loc) · 15.5 KB
/
Copy pathzone_editor.rs
File metadata and controls
496 lines (427 loc) · 15.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
// SPDX-License-Identifier: AGPL-3.0
//! # Zone Content Editor
//!
//! Manipulates markdown content blocks between zones with automatic
//! line number recalculation. When content is moved, copied, or appended,
//! all hex-word line numbers are updated to remain consistent.
//!
//! ## Design
//!
//! The editor works by tracking **line offset deltas** — when a content
//! block grows or shrinks, subsequent sections shift. The editor applies
//! these deltas to all hex-word lines in the document.
//!
//! ## Python Integration
//!
//! All operations are CLI-exposed and return clean stdout for `subprocess`:
//!
//! ```python
//! import subprocess
//!
//! # Copy zone 0 from Alpha to Beta
//! subprocess.run([
//! "regedited", "zone-copy", "doc.md",
//! "--from", "Alpha", "--from-zone", "0",
//! "--to", "Beta", "--to-zone", "1"
//! ])
//!
//! # Append zone content
//! subprocess.run([
//! "regedited", "zone-append", "doc.md", "Alpha", "0",
//! "--text", "new content here"
//! ])
//! ```
use crate::{
ascii_store::AsciiStore,
extract_lines,
header::{scan_content, update_lines, SectionInfo},
zone_type::{encode_hex_word, ZoneType},
RegeditedError, Result,
};
/// A line offset delta to apply after content changes
#[derive(Debug, Clone)]
pub struct LineDelta {
/// Line number where the change starts
pub start_line: usize,
/// How many lines were added (positive) or removed (negative)
pub delta: i64,
}
/// Apply a list of line deltas to a document
///
/// When content blocks grow or shrink, all hex-word line numbers
/// after the change point need to be shifted. This function
/// rebuilds the document with corrected line numbers.
pub fn apply_line_deltas(content: &str, deltas: &[LineDelta]) -> Result<String> {
if deltas.is_empty() {
return Ok(content.to_string());
}
// Sort deltas by start line (reverse order so earlier changes
// don't affect later line numbers)
let mut sorted = deltas.to_vec();
sorted.sort_by_key(|delta| std::cmp::Reverse(delta.start_line));
let mut result = content.to_string();
for delta in &sorted {
// Re-scan the document to find all hex-word lines.
let header = scan_content(&result)?;
let lines: Vec<&str> = result.lines().collect();
// Build a list of (line_index, new_content) changes
let mut changes: Vec<(usize, String)> = Vec::new();
for info in header.sections.values() {
// Update the hex-word line
let ascii_line = info.ascii_line;
if ascii_line >= lines.len() {
continue;
}
let old_ascii = lines[ascii_line];
let new_ascii = shift_hex_word_line(old_ascii, delta.start_line, delta.delta)?;
if new_ascii != old_ascii {
changes.push((ascii_line, new_ascii));
}
}
// Apply all changes at once
if !changes.is_empty() {
result = update_lines(&result, &changes)?;
}
}
Ok(result)
}
/// Shift line numbers in a hex-word line that are >= threshold
fn shift_hex_word_line(line: &str, threshold: usize, delta: i64) -> Result<String> {
use crate::zone_type::decode_hex_word;
let parts: Vec<&str> = line.split(" : ").collect();
if parts.len() != 6 {
// Not a valid hex-word line, return as-is
return Ok(line.to_string());
}
let mut new_parts = Vec::new();
for part in parts {
let trimmed = part.trim();
match decode_hex_word(trimmed) {
Ok((line_num, zt)) => {
let new_line = if line_num as usize >= threshold {
let shifted = (line_num as i64) + delta;
if shifted < 0 {
0
} else {
shifted as u32
}
} else {
line_num
};
new_parts.push(encode_hex_word(new_line, zt));
}
Err(_) => {
// Can't decode, keep as-is
new_parts.push(part.to_string());
}
}
}
Ok(new_parts.join(" : "))
}
/// Extract the raw content of a zone
pub fn extract_zone_content(
content: &str,
section: &SectionInfo,
zone_index: usize,
) -> Result<String> {
// Read the Hex-word line to get the zone's line range
let ascii_line = section.ascii_line;
let lines: Vec<&str> = content.lines().collect();
if ascii_line >= lines.len() {
return Ok(String::new());
}
let ascii = AsciiStore::from_line(lines[ascii_line])?;
let zone = ascii
.zone(zone_index)
.ok_or_else(|| RegeditedError::Parse(format!("Zone {} not found", zone_index)))?;
if zone.is_empty() {
return Ok(String::new());
}
let extracted = extract_lines(content.as_bytes(), zone.start as usize, zone.end as usize)?;
String::from_utf8(extracted)
.map_err(|e| RegeditedError::Parse(format!("Zone content is not valid UTF-8: {}", e)))
}
/// Replace a zone's content with new text
///
/// The section's content block (between --- and next section) is modified
/// to replace the lines within the zone's range. After replacement,
/// all subsequent sections' hex-word line numbers are recalculated.
pub fn replace_zone_content(
content: &str,
section: &SectionInfo,
zone_index: usize,
new_content: &str,
) -> Result<String> {
// Get the zone's current line range
let lines: Vec<&str> = content.lines().collect();
let ascii_line = section.ascii_line;
if ascii_line >= lines.len() {
return Err(RegeditedError::ZoneOutOfBounds {
line: ascii_line,
max_lines: lines.len(),
});
}
let mut ascii = AsciiStore::from_line(lines[ascii_line])?;
let zone = ascii
.zone(zone_index)
.ok_or_else(|| RegeditedError::Parse(format!("Zone {} not found", zone_index)))?;
if zone.is_empty() {
return Err(RegeditedError::Parse(format!(
"Zone {} is empty (0x0000000 : 0x0000000)",
zone_index
)));
}
let start_line = zone.start as usize;
let end_line = zone.end as usize;
let zone_type = zone.zone_type;
// Count lines for delta calculation
let old_line_count = (end_line - start_line) + 1;
let new_line_count = new_content.lines().count();
let delta = new_line_count as i64 - old_line_count as i64;
// Build the new document:
// - Lines before the zone start
// - New content
// - Lines after the zone end
let mut new_lines: Vec<String> = Vec::new();
// Lines before the zone
for line in lines.iter().take(start_line.min(lines.len())) {
new_lines.push((*line).to_string());
}
// New content (add trailing newline if needed)
for line in new_content.lines() {
new_lines.push(line.to_string());
}
// Lines after the zone
if end_line + 1 < lines.len() {
for line in lines.iter().skip(end_line + 1) {
new_lines.push((*line).to_string());
}
}
let mut result = new_lines.join("\n");
// Apply line number deltas if content size changed
// Threshold is end_line + 1 because:
// - The zone's start line stays the same (content insertion point doesn't move)
// - The zone's own hex-word end is updated after later ranges shift
// - All OTHER hex-words pointing to lines AFTER the old zone end must shift
if delta != 0 {
let deltas = vec![LineDelta {
start_line: end_line + 1,
delta,
}];
result = apply_line_deltas(&result, &deltas)?;
}
// The edited zone keeps the same start line, but its end line must track
// the replacement size. Apply this after shifting later ranges so the
// edited zone does not get shifted a second time.
if new_line_count == 0 {
ascii.set_zone(zone_index, 0, 0, ZoneType::Markdown)?;
result = update_lines(&result, &[(ascii_line, ascii.to_line())])?;
} else {
let new_end_line = start_line + new_line_count - 1;
ascii.set_zone(
zone_index,
start_line as u32,
new_end_line as u32,
zone_type,
)?;
result = update_lines(&result, &[(ascii_line, ascii.to_line())])?;
}
Ok(result)
}
/// Append content to a zone
///
/// The new content is inserted at the end of the zone's current range.
/// The zone's end line is updated, and all subsequent line numbers shift.
pub fn append_zone_content(
content: &str,
section: &SectionInfo,
zone_index: usize,
append_content: &str,
) -> Result<String> {
// First, extract the current zone content
let current = extract_zone_content(content, section, zone_index)?;
// Append new content
let mut combined = current;
if !append_content.is_empty() {
if !combined.is_empty() && !combined.ends_with('\n') {
combined.push('\n');
}
combined.push_str(append_content);
}
// Replace with combined content
replace_zone_content(content, section, zone_index, &combined)
}
/// Copy zone content from one section/zone to another
///
/// The target zone's content is replaced with the source zone's content.
/// This is the primary operation for Python scripting.
pub fn copy_zone_content(
content: &str,
from_section: &SectionInfo,
from_zone: usize,
to_section: &SectionInfo,
to_zone: usize,
) -> Result<String> {
// Extract source content
let source_content = extract_zone_content(content, from_section, from_zone)?;
// Replace target content
replace_zone_content(content, to_section, to_zone, &source_content)
}
/// Swap zone content between two zones
pub fn swap_zone_content(
content: &str,
section_a: &SectionInfo,
zone_a: usize,
section_b: &SectionInfo,
zone_b: usize,
) -> Result<String> {
let content_a = extract_zone_content(content, section_a, zone_a)?;
let content_b = extract_zone_content(content, section_b, zone_b)?;
let mut result = replace_zone_content(content, section_a, zone_a, &content_b)?;
result = replace_zone_content(&result, section_b, zone_b, &content_a)?;
Ok(result)
}
/// Get a zone's line range for external use
pub fn get_zone_range(
content: &str,
section: &SectionInfo,
zone_index: usize,
) -> Result<(usize, usize, ZoneType)> {
let lines: Vec<&str> = content.lines().collect();
let ascii_line = section.ascii_line;
if ascii_line >= lines.len() {
return Err(RegeditedError::ZoneOutOfBounds {
line: ascii_line,
max_lines: lines.len(),
});
}
let ascii = AsciiStore::from_line(lines[ascii_line])?;
let zone = ascii
.zone(zone_index)
.ok_or_else(|| RegeditedError::Parse(format!("Zone {} not found", zone_index)))?;
Ok((zone.start as usize, zone.end as usize, zone.zone_type))
}
/// Format zone info for Python-scriptable output
pub fn format_zone_info(content: &str, section: &SectionInfo, zone_index: usize) -> Result<String> {
let (start, end, zt) = get_zone_range(content, section, zone_index)?;
let extracted = extract_zone_content(content, section, zone_index)?;
let line_count = extracted.lines().count();
Ok(format!(
"zone_index={}\nstart_line={}\nend_line={}\nzone_type={}\ntype_nibble={}\nline_count={}\nbyte_size={}\n---CONTENT---\n{}",
zone_index, start, end, zt.short(), zt.nibble(), line_count, extracted.len(), extracted
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::header::scan_content;
fn test_doc() -> String {
r#"# Test Doc
## SECTION: Alpha
index: 100
0x0000000 : 0x0000000 : 0x000000A : 0x00000014 : 0x0000000 : 0x0000000
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
alpha s1
alpha s2
alpha s3
---
Line 10 content
Line 11 content
Line 12 content
Line 13 content
Line 14 content
Line 15 content
Line 16 content
Line 17 content
Line 18 content
Line 19 content
Line 20 content
## SECTION: Beta
index: 200
0x0000000 : 0x0000000 : 0x0000000 : 0x0000000 : 0x0000000 : 0x0000000
10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90
beta s1
beta s2
beta s3
---
Beta line 25
Beta line 26
"#
.to_string()
}
#[test]
fn test_extract_zone_content() {
let doc = test_doc();
let header = scan_content(&doc).unwrap();
let alpha = header.get_section("Alpha").unwrap();
let content = extract_zone_content(&doc, alpha, 1).unwrap();
assert!(content.contains("Line 10 content"));
assert!(content.contains("Line 20 content"));
}
#[test]
fn test_replace_zone_content() {
let doc = test_doc();
let header = scan_content(&doc).unwrap();
let alpha = header.get_section("Alpha").unwrap().clone();
let new =
replace_zone_content(&doc, &alpha, 1, "REPLACED LINE 1\nREPLACED LINE 2").unwrap();
// Should have replaced lines 10-20 with 2 new lines
assert!(new.contains("REPLACED LINE 1"));
assert!(!new.contains("Line 12 content"));
// Beta section should still exist
assert!(new.contains("Beta line 25"));
}
#[test]
fn test_append_zone_content() {
let doc = test_doc();
let header = scan_content(&doc).unwrap();
let alpha = header.get_section("Alpha").unwrap().clone();
let new = append_zone_content(&doc, &alpha, 1, "\nAPPENDED LINE").unwrap();
assert!(new.contains("Line 10 content"));
assert!(new.contains("APPENDED LINE"));
}
#[test]
fn test_copy_zone_content() {
// Use a doc where zone 2 has a valid range (not empty)
let doc = r#"# Test
## SECTION: Alpha
index: 100
0x0000000 : 0x0000000 : 0x000000A : 0x0000014 : 0x000000A : 0x0000014
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
alpha s1
alpha s2
alpha s3
---
Line 10 content
Line 11 content
"#;
let header = scan_content(doc).unwrap();
let alpha = header.get_section("Alpha").unwrap().clone();
// Copy zone 1 (lines 10-20) → zone 2 (also lines 10-20)
let new = copy_zone_content(doc, &alpha, 1, &alpha, 2).unwrap();
// Zone 2 should now have zone 1's content
assert!(new.contains("Line 10 content"));
}
#[test]
fn test_shift_hex_word_line() {
// New TxLLLLLLL format
let line = "0x000000A : 0x0000014 : 1x000001E : 1x0000028 : 0x0000000 : 0x0000000";
let shifted = shift_hex_word_line(line, 15, 5).unwrap();
// Lines >= 15 should shift by +5 (new format)
// 0x000000A (type 0, line 10) < 15 → stays 10
// 0x0000014 (type 0, line 20) >= 15 → 25 = 0x19 → "0x0000019"
// 1x000001E (type 1, line 30) >= 15 → 35 = 0x23 → "1x0000023"
assert!(shifted.contains("0x000000A")); // 10, unchanged
assert!(shifted.contains("0x0000019")); // 25 = 20 + 5
}
#[test]
fn test_get_zone_range() {
let doc = test_doc();
let header = scan_content(&doc).unwrap();
let alpha = header.get_section("Alpha").unwrap();
let (start, end, zt) = get_zone_range(&doc, alpha, 1).unwrap();
assert_eq!(start, 10);
assert_eq!(end, 20);
assert_eq!(zt, ZoneType::Markdown);
}
}