Oregami
Repositories/oxedyne/ore

oxedyne/ore/cli/src/note.rs

19.2 KiB, 7 runs

created by r2848102244:122, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Saying something about content, at a place in a file the writer has open.
2//!
3//! An anchored note is the review comment's primitive. The engine has carried
4//! one since notes were added: [`Op::Note`] names content rather than a line, so
5//! the note narrows when the content is edited around, travels when the content
6//! is moved -- into another file included -- and says so plainly when the content
7//! it was about has been deleted. What the engine has never had is a way for a
8//! person to write one, which is what this module is.
9//!
10//! # Why a coordinate and not a byte range
11//!
12//! The engine wants [`ContentRange`]s, which are the history's own names for
13//! bytes and no use at all to somebody reading a file. What a person has in front
14//! of them is a path and a line, and it is the same path and line `ore flags`
15//! prints under a flag, so a coordinate read out of one command can be handed
16//! straight to this one. The translation is a render away: the file's bytes, the
17//! offsets its lines begin at, and [`Rendered::span`] to turn an offset and a
18//! length into the content that occupies it.
19//!
20//! # The three spellings
21//!
22//! `path:12` is a line. `path:12-40` is a range of lines. `path:120..480` is a
23//! range of bytes, for the files where a line number would be a fiction and for
24//! anybody who would rather be exact. The separator alone says which is meant,
25//! so nothing has to be flagged.
26//!
27//! A line's newline belongs to the line it ends, which is the rule
28//! [`crate::place`] reads coordinates out by, and this module writes them in by
29//! the same rule: a note on line 12 is about every byte of line 12 including the
30//! newline that finishes it. Were it otherwise, a note read back would name a
31//! line other than the one it was written against.
32//!
33//! # A file with no lines
34//!
35//! A file holding NUL bytes has no lines to number, so a line coordinate against
36//! one is refused and the byte spelling offered by name. The alternative is
37//! counting newlines in a binary and pretending the answer means something.
38//!
39//! # When a note does not follow content across a file boundary
40//!
41//! It follows an [`Op::Move`], and what authors one is capture's move detection,
42//! which pairs an exact removal with an exact insertion of at least
43//! [`crate::capture::MIN_MOVE_LEN`] bytes. Cut fewer than that from one file into
44//! another and there is no move in the log to follow: those bytes were deleted
45//! and different bytes were written, and a note on them says its content has been
46//! deleted, which is what the history records. That is the floor's doing rather
47//! than the note's, and it is worth knowing before anybody diagnoses it twice.
48//!
49//! # Capture first, like every verb
50//!
51//! The note is anchored to the content the log holds, so the working copy is
52//! captured before anything is measured. Without that, a person noting line 12 of
53//! a file they have just edited would be noting whatever line 12 was before the
54//! edit -- content still in the render, and the wrong content.
55
56use crate::capture;
57use crate::keys::mark_of;
58use crate::place::Where;
59use crate::repo::Repo;
60use crate::tree::{
61 self,
62 Tree,
63};
64use crate::verbs::report;
65
66use oxedyne_fe2o3_core::prelude::*;
67use oxedyne_fe2o3_ore::id::ContentRange;
68use oxedyne_fe2o3_ore::op::Op;
69use oxedyne_fe2o3_ore::seq::render::Rendered;
70
71
72/// How far into a file the test for lines looks.
73///
74/// The same distance [`crate::place`] sniffs, and for the same reason: what it
75/// decides is whether a line coordinate means anything, and reading all of a
76/// large file to decide that is work nobody asked for.
77const SNIFF: usize = 8192;
78
79
80/// A place in a file, as the writer of a note spelled it.
81#[derive(Debug, PartialEq)]
82pub enum Coord {
83 /// One line, counting from one.
84 Line(u64),
85 /// A range of lines, inclusive at both ends.
86 Lines(u64, u64),
87 /// A range of bytes, the end exclusive.
88 Bytes(u64, u64),
89}
90
91
92/// What `ore note` was asked to do.
93pub enum Asked {
94 /// Say every note there is, which is what the bare verb means.
95 List,
96 /// Write one, at a place in a file.
97 Write {
98 /// The path, as the working copy spells it.
99 path: String,
100 /// The place in that file.
101 at: Coord,
102 /// What the note says.
103 text: String,
104 },
105}
106
107impl Asked {
108
109 /// Reads the arguments `ore note` was given.
110 ///
111 /// With none it lists, in the manner of `ore flags`. Otherwise the first
112 /// argument is the coordinate and everything after it is the note, joined by
113 /// single spaces. Taking the rest rather than insisting on one quoted
114 /// argument is the forgiving choice, and the only thing it costs is that runs
115 /// of spaces inside an unquoted note are not preserved.
116 pub fn read(rest: &[String])
117 -> Outcome<Self>
118 {
119 let (first, said) = match rest.split_first() {
120 Some(pair) => pair,
121 None => return Ok(Self::List),
122 };
123 let (path, at) = res!(split(first));
124 let text = said.join(" ");
125 if text.trim().is_empty() {
126 return Err(err!(
127 "`ore note` was given the place {:?} and nothing to say about it. A \
128 note whose text is empty says less than no note at all, since a \
129 reader has to open it to find that out. `ore note` on its own says \
130 every note there is.", first;
131 Invalid, Input, Missing));
132 }
133 Ok(Self::Write { path: fmt!("{}", path), at, text })
134 }
135}
136
137
138/// One coordinate, spelled for a person who has just been told they got one
139/// wrong.
140const EXAMPLE: &str = "src/main.rs:12 \"this branch never fires\"";
141
142
143/// Splits a coordinate into the path and the place in it.
144///
145/// The path is everything before the last colon, so that a path holding a colon
146/// is not mistaken for a coordinate. A Windows-shaped `C:\...` is therefore read
147/// correctly for free, and a file genuinely named with a trailing `:12` is not,
148/// which is the trade every tool that prints `path:line` has already made.
149fn split(arg: &str)
150 -> Outcome<(&str, Coord)>
151{
152 let at = match arg.rfind(':') {
153 Some(i) => i,
154 None => return Err(err!(
155 "{:?} names a file but no place in it. A note is about content, so it \
156 needs a line, a range of lines, or a range of bytes: `ore note {}` .",
157 arg, EXAMPLE;
158 Invalid, Input, Missing)),
159 };
160 let (path, rest) = arg.split_at(at);
161 // The colon itself belongs to neither half.
162 let rest = &rest[1..];
163 if path.is_empty() {
164 return Err(err!(
165 "{:?} names a place but no file to find it in.", arg;
166 Invalid, Input, Missing));
167 }
168 Ok((path, res!(place(rest, arg))))
169}
170
171/// Reads the part after the last colon, which is one of the three spellings.
172fn place(rest: &str, whole: &str)
173 -> Outcome<Coord>
174{
175 if let Some((from, to)) = rest.split_once("..") {
176 let from = res!(number(from, whole));
177 let to = res!(number(to, whole));
178 if to <= from {
179 return Err(err!(
180 "{:?} asks for bytes {}..{}, which is no bytes at all. The first \
181 number is the byte the note starts at and the second is the byte it \
182 stops before, so the second must be the larger.", whole, from, to;
183 Invalid, Input, Range));
184 }
185 return Ok(Coord::Bytes(from, to));
186 }
187 if let Some((from, to)) = rest.split_once('-') {
188 let from = res!(number(from, whole));
189 let to = res!(number(to, whole));
190 if to < from {
191 return Err(err!(
192 "{:?} asks for lines {} to {}, which runs backwards.",
193 whole, from, to;
194 Invalid, Input, Range));
195 }
196 return Ok(Coord::Lines(from, to));
197 }
198 Ok(Coord::Line(res!(number(rest, whole))))
199}
200
201/// Reads one number out of a coordinate, complaining about the coordinate rather
202/// than the number, since the coordinate is what was typed.
203fn number(said: &str, whole: &str)
204 -> Outcome<u64>
205{
206 match said.trim().parse::<u64>() {
207 Ok(n) => Ok(n),
208 Err(_) => Err(err!(
209 "{:?} is not a place this reads. The part after the last colon is a \
210 line (`12`), a range of lines (`12-40`) or a range of bytes \
211 (`120..480`), and {:?} is none of them.", whole, said;
212 Invalid, Input)),
213 }
214}
215
216
217/// Where the lines of a file begin, or nothing where its bytes are not lines.
218///
219/// The offsets kept are of the newlines themselves, matching [`crate::place`], so
220/// that the two modules agree byte for byte about which line a byte is on.
221fn newlines(bytes: &[u8])
222 -> Option<Vec<u64>>
223{
224 if bytes.iter().take(SNIFF).any(|b| *b == 0) {
225 return None;
226 }
227 let mut at: Vec<u64> = Vec::new();
228 for (i, b) in bytes.iter().enumerate() {
229 if *b == b'\n' {
230 at.push(i as u64);
231 }
232 }
233 Some(at)
234}
235
236/// Returns the bytes a range of lines occupies, the end exclusive.
237///
238/// Line `n` runs from the byte after the newline that ended line `n - 1` to the
239/// newline that ends line `n`, inclusive of that newline. A last line with no
240/// newline of its own ends where the file does.
241fn bytes_of_lines(lines: &[u64], len: u64, from: u64, to: u64)
242 -> Outcome<(u64, u64)>
243{
244 // A file's line count is its newline count, plus one for anything after the
245 // last newline. An empty file has no lines and no note to hang on one.
246 let count = if len == 0 {
247 0
248 } else if lines.last() == Some(&(len - 1)) {
249 lines.len() as u64
250 } else {
251 lines.len() as u64 + 1
252 };
253 if from == 0 {
254 return Err(err!(
255 "Lines count from one, so there is no line 0.";
256 Invalid, Input, Range));
257 }
258 if to > count {
259 return Err(err!(
260 "The file has {} line{}, so line {} is not in it.",
261 count, if count == 1 { "" } else { "s" }, to;
262 Invalid, Input, Range));
263 }
264 let at = if from == 1 {
265 0
266 } else {
267 lines[from as usize - 2] + 1
268 };
269 let end = match lines.get(to as usize - 1) {
270 Some(n) => n + 1,
271 None => len,
272 };
273 Ok((at, end))
274}
275
276/// Returns the bytes a coordinate names in a rendered file.
277fn bytes_of(file: &Rendered, at: &Coord, path: &str)
278 -> Outcome<(u64, u64)>
279{
280 let len = file.bytes().len() as u64;
281 match at {
282 Coord::Bytes(from, to) => {
283 if *to > len {
284 return Err(err!(
285 "{} holds {} byte{}, so there is nothing at byte {}.",
286 path, len, if len == 1 { "" } else { "s" }, to;
287 Invalid, Input, Range));
288 }
289 Ok((*from, *to))
290 },
291 Coord::Line(n) => lines_of(file, path, *n, *n),
292 Coord::Lines(a, b) => lines_of(file, path, *a, *b),
293 }
294}
295
296/// Returns the bytes a range of lines names, refusing a file that has no lines.
297fn lines_of(file: &Rendered, path: &str, from: u64, to: u64)
298 -> Outcome<(u64, u64)>
299{
300 let bytes = file.bytes();
301 let lines = match newlines(bytes) {
302 Some(l) => l,
303 None => return Err(err!(
304 "{} holds bytes that are not text, so a line number in it would mean \
305 nothing. Name the bytes instead, as in `{}:0..64` .", path, path;
306 Invalid, Input)),
307 };
308 let (at, end) = res!(bytes_of_lines(&lines, bytes.len() as u64, from, to));
309 Ok((at, end))
310}
311
312
313/// `ore note` -- says something about the content at a place in a file, and goes
314/// on saying it about that content wherever the content ends up.
315pub fn note(repo: &mut Repo, asked: &Asked)
316 -> Outcome<()>
317{
318 let mut what = res!(capture::capture(repo));
319 report(&what);
320 println!();
321 // The capture hands its render on where that render is still the
322 // present and holds everything a whole one holds, so the second
323 // render most verbs used to make is skipped.
324 let tree = match what.tree.take() {
325 Some(tree) => tree,
326 None => res!(tree::whole(&repo.log)),
327 };
328 let (path, at, text) = match asked {
329 Asked::List => return list(repo, &tree),
330 Asked::Write { path, at, text } => (path, at, text),
331 };
332 let file = res!(found(&tree, path));
333 let (from, end) = res!(bytes_of(file, at, path));
334 let on = res!(spanned(file, from, end, path));
335 let bytes = end - from;
336 let replica = repo.cfg.replica;
337 let id = res!(repo.author(replica, Op::Note {
338 on,
339 text: text.as_bytes().to_vec(),
340 }));
341 println!("note {} is about {} byte{} at {}",
342 id,
343 bytes, if bytes == 1 { "" } else { "s" },
344 said(path, at),
345 );
346 println!("it will follow that content through every edit and every move, and \
347 say so if the content is deleted");
348 Ok(())
349}
350
351/// Says every note the repository holds, and where each one has ended up.
352///
353/// Repository-wide rather than per-file because a note's content may have moved
354/// into another file since it was written, or been deleted altogether, and
355/// neither of those notes is reachable by asking about the file it was written
356/// against. `ore who <file>` is the per-file reading; this is the one that misses
357/// nothing.
358fn list(repo: &Repo, tree: &Tree)
359 -> Outcome<()>
360{
361 let notes = tree.repo.notes();
362 if notes.is_empty() {
363 println!("no notes; `ore note {}` writes one", EXAMPLE);
364 return Ok(());
365 }
366 println!("{} note{}", notes.len(), if notes.len() == 1 { "" } else { "s" });
367 let placed = Where::of(tree);
368 for note in notes {
369 println!();
370 println!("note {} {}", mark_of(&repo.prov, note.note()), note.note());
371 if note.on_dead() {
372 // Not a fault and not a loss: the log holds what it said and what it
373 // was about, and this is what says no margin will show it.
374 println!(" every byte it was about has been deleted");
375 } else {
376 // The byte count as well as the coordinate, because a note narrows
377 // under an edit and the coordinate alone does not show it: a note over
378 // three lines whose middle line was replaced still reads as three lines
379 // and is about two of them.
380 for place in note.files() {
381 let bytes: u64 = place.spans.iter().map(|s| s.len).sum();
382 println!(" {}, {} byte{}",
383 placed.of_spans(place.file, &place.spans),
384 bytes, if bytes == 1 { "" } else { "s" },
385 );
386 }
387 }
388 println!(" {}", note.text_lossy());
389 }
390 Ok(())
391}
392
393/// Finds the file a coordinate names, under the name the working copy gives it.
394///
395/// The layout rather than the recorded path, since two files may hold one path
396/// and the loser is written to a derived name: what a person can point at is what
397/// is on the disk in front of them.
398fn found<'a>(tree: &'a Tree, path: &str)
399 -> Outcome<&'a Rendered>
400{
401 let layout = res!(tree.layout());
402 let want = path.as_bytes();
403 match layout.file_at(want).and_then(|id| tree.get(id)) {
404 Some(f) => Ok(f),
405 None => {
406 let known: Vec<String> = layout.at.keys().map(|p| tree::shown(p)).collect();
407 Err(err!(
408 "The repository holds no file {:?}. It holds: {}.",
409 path,
410 if known.is_empty() { fmt!("nothing") } else { known.join(", ") };
411 Invalid, Input, NotFound))
412 },
413 }
414}
415
416/// Turns an offset and an end into the content that occupies them.
417///
418/// The engine refuses a span of nothing, since a note about nothing is a mark
419/// with extra spelling, and this says which coordinate asked for nothing rather
420/// than passing the engine's wording on.
421fn spanned(file: &Rendered, at: u64, end: u64, path: &str)
422 -> Outcome<Vec<ContentRange>>
423{
424 if end <= at {
425 return Err(err!(
426 "That place in {} holds no bytes, so there is nothing for a note to be \
427 about. An empty line is bytes -- its newline -- but a line past the end \
428 of a file is not.", path;
429 Invalid, Input, Range));
430 }
431 file.span(at as usize, (end - at) as usize)
432}
433
434/// Writes a coordinate back the way it came, for the line that confirms it.
435fn said(path: &str, at: &Coord) -> String {
436 match at {
437 Coord::Line(n) => fmt!("{}:{}", path, n),
438 Coord::Lines(a, b) => fmt!("{}:{}-{}", path, a, b),
439 Coord::Bytes(a, b) => fmt!("{}:{}..{}", path, a, b),
440 }
441}
442
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447
448 /// The three spellings are told apart by their separator alone.
449 #[test]
450 fn coordinate_spellings()
451 -> Outcome<()>
452 {
453 assert_eq!(res!(split("src/main.rs:12")), ("src/main.rs", Coord::Line(12)));
454 assert_eq!(res!(split("src/main.rs:12-40")), ("src/main.rs", Coord::Lines(12, 40)));
455 assert_eq!(res!(split("src/main.rs:120..480")), ("src/main.rs", Coord::Bytes(120, 480)));
456 // The last colon splits, so a path holding one survives.
457 assert_eq!(res!(split("C:\\src\\main.rs:12")), ("C:\\src\\main.rs", Coord::Line(12)));
458 Ok(())
459 }
460
461 /// A coordinate that names no place, no file, or no sane range is refused.
462 #[test]
463 fn coordinate_refusals()
464 -> Outcome<()>
465 {
466 assert!(split("src/main.rs").is_err()); // no place
467 assert!(split(":12").is_err()); // no file
468 assert!(split("src/main.rs:").is_err()); // nothing after the colon
469 assert!(split("src/main.rs:top").is_err()); // not a number
470 assert!(split("src/main.rs:40-12").is_err()); // backwards
471 assert!(split("src/main.rs:480..120").is_err()); // backwards
472 assert!(split("src/main.rs:120..120").is_err()); // no bytes at all
473 Ok(())
474 }
475
476 /// A line's newline belongs to the line it ends, and the last line ends where
477 /// the file does whether or not it has one.
478 #[test]
479 fn lines_to_bytes()
480 -> Outcome<()>
481 {
482 // 0123 4567 89
483 let bytes = b"ab\ncd\nef";
484 let lines = match newlines(bytes) {
485 Some(l) => l,
486 None => return Err(err!("The fixture is text."; Test, Invalid)),
487 };
488 let len = bytes.len() as u64;
489 // Line 1 is "ab\n": its newline is included.
490 assert_eq!(res!(bytes_of_lines(&lines, len, 1, 1)), (0, 3));
491 // Line 2 is "cd\n".
492 assert_eq!(res!(bytes_of_lines(&lines, len, 2, 2)), (3, 6));
493 // Line 3 has no newline of its own and ends where the file ends.
494 assert_eq!(res!(bytes_of_lines(&lines, len, 3, 3)), (6, 8));
495 // A range spans from the first line's start to the last line's end.
496 assert_eq!(res!(bytes_of_lines(&lines, len, 1, 2)), (0, 6));
497 assert_eq!(res!(bytes_of_lines(&lines, len, 1, 3)), (0, 8));
498 // There are three lines and no fourth, and no line zero.
499 assert!(bytes_of_lines(&lines, len, 4, 4).is_err());
500 assert!(bytes_of_lines(&lines, len, 0, 1).is_err());
501 Ok(())
502 }
503
504 /// A file whose last byte is a newline has no phantom line after it.
505 #[test]
506 fn trailing_newline_is_not_a_line()
507 -> Outcome<()>
508 {
509 let bytes = b"ab\n";
510 let lines = match newlines(bytes) {
511 Some(l) => l,
512 None => return Err(err!("The fixture is text."; Test, Invalid)),
513 };
514 assert_eq!(res!(bytes_of_lines(&lines, 3, 1, 1)), (0, 3));
515 assert!(bytes_of_lines(&lines, 3, 2, 2).is_err());
516 Ok(())
517 }
518
519 /// An empty line is still bytes, because its newline is a byte.
520 #[test]
521 fn empty_line_is_still_bytes()
522 -> Outcome<()>
523 {
524 let bytes = b"a\n\nb";
525 let lines = match newlines(bytes) {
526 Some(l) => l,
527 None => return Err(err!("The fixture is text."; Test, Invalid)),
528 };
529 assert_eq!(res!(bytes_of_lines(&lines, 4, 2, 2)), (2, 3));
530 Ok(())
531 }
532
533 /// A file holding NUL bytes has no lines to count.
534 #[test]
535 fn nul_bytes_have_no_lines() {
536 assert!(newlines(b"ab\0cd\n").is_none());
537 assert!(newlines(b"ab\ncd\n").is_some());
538 }
539
540 /// The note is everything after the coordinate, and an empty one is refused.
541 #[test]
542 fn text_is_the_rest()
543 -> Outcome<()>
544 {
545 let said = |v: &[&str]| -> Vec<String> {
546 v.iter().map(|s| fmt!("{}", s)).collect()
547 };
548 // Unquoted words are joined, and the coordinate is not one of them.
549 match res!(Asked::read(&said(&["src/main.rs:12", "never", "fires"]))) {
550 Asked::Write { path, at, text } => {
551 assert_eq!(text, "never fires");
552 assert_eq!(path, "src/main.rs");
553 assert_eq!(at, Coord::Line(12));
554 },
555 Asked::List => return Err(err!(
556 "A coordinate and a note were read as a listing."; Test, Mismatch)),
557 }
558 // One quoted argument says the same thing.
559 match res!(Asked::read(&said(&["src/main.rs:12", "never fires"]))) {
560 Asked::Write { text, .. } => assert_eq!(text, "never fires"),
561 Asked::List => return Err(err!(
562 "A coordinate and a note were read as a listing."; Test, Mismatch)),
563 }
564 // A place with nothing to say about it is refused, whitespace included.
565 assert!(Asked::read(&said(&["src/main.rs:12"])).is_err());
566 assert!(Asked::read(&said(&["src/main.rs:12", " "])).is_err());
567 // The bare verb lists rather than refusing.
568 assert!(matches!(res!(Asked::read(&said(&[]))), Asked::List));
569 Ok(())
570 }
571}