Mercurial > public > mercurial-scm > hg
comparison rust/rhg/src/commands/annotate.rs @ 52767:6183949219b2
rhg: implement rhg annotate
This initial implementation produces the same output as Python for all the files
I've tried, and is usually 1.5-9x faster. The algorithm is mostly the same, but
one key difference is that the Rust implementation only converts filelog
revisions to changelog revisions if they will actually appear in the output.
This does not support all the command line flags yet. In particular, --template,
--include, --exclude, --skip, and whitespace-related flags will cause fallback
to Python. Also, --rev 'wdir()' (often used by editor plugins) is not supported.
There is also no pager.
author | Mitchell Kember <mkember@janestreet.com> |
---|---|
date | Fri, 24 Jan 2025 12:01:12 -0500 |
parents | |
children | 874c64e041b5 |
comparison
equal
deleted
inserted
replaced
52766:549b58b1ce72 | 52767:6183949219b2 |
---|---|
1 use core::str; | |
2 use std::{collections::hash_map::Entry, ffi::OsString}; | |
3 | |
4 use format_bytes::format_bytes; | |
5 use hg::{ | |
6 encoding::Encoder, | |
7 operations::{ | |
8 annotate, AnnotateOptions, AnnotateOutput, ChangesetAnnotation, | |
9 }, | |
10 revlog::changelog::Changelog, | |
11 FastHashMap, Revision, | |
12 }; | |
13 | |
14 use crate::{error::CommandError, utils::path_utils::resolve_file_args}; | |
15 | |
16 pub const HELP_TEXT: &str = " | |
17 show changeset information by line for each file | |
18 "; | |
19 | |
20 pub fn args() -> clap::Command { | |
21 clap::command!("annotate") | |
22 .alias("blame") | |
23 .arg( | |
24 clap::Arg::new("files") | |
25 .help("files to annotate") | |
26 .required(true) | |
27 .num_args(1..) | |
28 .value_name("FILE") | |
29 .value_parser(clap::value_parser!(OsString)), | |
30 ) | |
31 .arg( | |
32 clap::Arg::new("rev") | |
33 .help("annotate the specified revision") | |
34 .short('r') | |
35 .long("rev") | |
36 .value_name("REV") | |
37 .default_value("."), | |
38 ) | |
39 .arg( | |
40 clap::Arg::new("no-follow") | |
41 .help("don't follow copies and renames") | |
42 .long("no-follow") | |
43 .action(clap::ArgAction::SetTrue), | |
44 ) | |
45 .arg( | |
46 clap::Arg::new("text") | |
47 .help("treat all files as text") | |
48 .short('a') | |
49 .long("text") | |
50 .action(clap::ArgAction::SetTrue), | |
51 ) | |
52 .arg( | |
53 clap::Arg::new("user") | |
54 .help("list the author (long with -v)") | |
55 .short('u') | |
56 .long("user") | |
57 .action(clap::ArgAction::SetTrue), | |
58 ) | |
59 .arg( | |
60 clap::Arg::new("number") | |
61 .help("list the revision number (default)") | |
62 .short('n') | |
63 .long("number") | |
64 .action(clap::ArgAction::SetTrue), | |
65 ) | |
66 .arg( | |
67 clap::Arg::new("changeset") | |
68 .help("list the changeset") | |
69 .short('c') | |
70 .long("changeset") | |
71 .action(clap::ArgAction::SetTrue), | |
72 ) | |
73 .arg( | |
74 clap::Arg::new("date") | |
75 .help("list the date (short with -q)") | |
76 .short('d') | |
77 .long("date") | |
78 .action(clap::ArgAction::SetTrue), | |
79 ) | |
80 .arg( | |
81 clap::Arg::new("file") | |
82 .help("list the filename") | |
83 .short('f') | |
84 .long("file") | |
85 .action(clap::ArgAction::SetTrue), | |
86 ) | |
87 .arg( | |
88 clap::Arg::new("line-number") | |
89 .help("show the line number at the first appearance") | |
90 .short('l') | |
91 .long("line-number") | |
92 .action(clap::ArgAction::SetTrue), | |
93 ) | |
94 .arg( | |
95 clap::Arg::new("quiet") | |
96 .help("show short date for -d") | |
97 .short('q') | |
98 .long("quiet") | |
99 .action(clap::ArgAction::SetTrue), | |
100 ) | |
101 .arg( | |
102 clap::Arg::new("verbose") | |
103 .help("show full username for -u") | |
104 .short('v') | |
105 .long("verbose") | |
106 .action(clap::ArgAction::SetTrue) | |
107 .conflicts_with("quiet"), | |
108 ) | |
109 .about(HELP_TEXT) | |
110 } | |
111 | |
112 pub fn run(invocation: &crate::CliInvocation) -> Result<(), CommandError> { | |
113 let config = invocation.config; | |
114 if config.has_non_empty_section(b"annotate") { | |
115 return Err(CommandError::unsupported( | |
116 "rhg annotate does not support any [annotate] configs", | |
117 )); | |
118 } | |
119 | |
120 let repo = invocation.repo?; | |
121 let args = invocation.subcommand_args; | |
122 | |
123 let rev = args.get_one::<String>("rev").expect("rev has a default"); | |
124 let rev = hg::revset::resolve_single(rev, repo)?; | |
125 | |
126 let files = match args.get_many::<OsString>("files") { | |
127 None => vec![], | |
128 Some(files) => resolve_file_args(repo, files)?, | |
129 }; | |
130 | |
131 let options = AnnotateOptions { | |
132 treat_binary_as_text: args.get_flag("text"), | |
133 follow_copies: !args.get_flag("no-follow"), | |
134 }; | |
135 | |
136 let mut include = Include { | |
137 user: args.get_flag("user"), | |
138 number: args.get_flag("number"), | |
139 changeset: args.get_flag("changeset"), | |
140 date: args.get_flag("date"), | |
141 file: args.get_flag("file"), | |
142 line_number: args.get_flag("line-number"), | |
143 }; | |
144 if !(include.user || include.file || include.date || include.changeset) { | |
145 include.number = true; | |
146 } | |
147 if include.line_number && !(include.number || include.changeset) { | |
148 return Err(CommandError::abort( | |
149 "at least one of -n/-c is required for -l", | |
150 )); | |
151 } | |
152 | |
153 let verbosity = match (args.get_flag("quiet"), args.get_flag("verbose")) { | |
154 (false, false) => Verbosity::Default, | |
155 (true, false) => Verbosity::Quiet, | |
156 (false, true) => Verbosity::Verbose, | |
157 (true, true) => unreachable!(), | |
158 }; | |
159 | |
160 let changelog = repo.changelog()?; | |
161 let mut formatter = Formatter::new( | |
162 &changelog, | |
163 invocation.ui.encoder(), | |
164 include, | |
165 verbosity, | |
166 ); | |
167 let mut stdout = invocation.ui.stdout_buffer(); | |
168 for path in files { | |
169 match annotate(repo, &path, rev, options)? { | |
170 AnnotateOutput::Text(text) => { | |
171 let annotations = formatter.format(text.annotations)?; | |
172 for (annotation, line) in annotations.iter().zip(&text.lines) { | |
173 stdout.write_all(&format_bytes!( | |
174 b"{}: {}", annotation, line | |
175 ))?; | |
176 } | |
177 if let Some(line) = text.lines.last() { | |
178 if !line.ends_with(b"\n") { | |
179 stdout.write_all(b"\n")?; | |
180 } | |
181 } | |
182 } | |
183 AnnotateOutput::Binary => { | |
184 stdout.write_all(&format_bytes!( | |
185 b"{}: binary file\n", | |
186 path.as_bytes() | |
187 ))?; | |
188 } | |
189 AnnotateOutput::NotFound => { | |
190 let short = changelog.node_from_rev(rev).short(); | |
191 return Err(CommandError::abort(format!( | |
192 "{path}: no such file in rev {short:x}", | |
193 ))); | |
194 } | |
195 } | |
196 } | |
197 stdout.flush()?; | |
198 | |
199 Ok(()) | |
200 } | |
201 | |
202 struct Formatter<'a> { | |
203 changelog: &'a Changelog, | |
204 encoder: &'a Encoder, | |
205 include: Include, | |
206 verbosity: Verbosity, | |
207 cache: FastHashMap<Revision, ChangesetData>, | |
208 } | |
209 | |
210 #[derive(Copy, Clone)] | |
211 struct Include { | |
212 user: bool, | |
213 number: bool, | |
214 changeset: bool, | |
215 date: bool, | |
216 file: bool, | |
217 line_number: bool, | |
218 } | |
219 | |
220 impl Include { | |
221 fn count(&self) -> usize { | |
222 // Rust guarantees false is 0 and true is 1. | |
223 self.user as usize | |
224 + self.number as usize | |
225 + self.changeset as usize | |
226 + self.date as usize | |
227 + self.file as usize | |
228 + self.line_number as usize | |
229 } | |
230 } | |
231 | |
232 #[derive(Copy, Clone)] | |
233 enum Verbosity { | |
234 Quiet, | |
235 Default, | |
236 Verbose, | |
237 } | |
238 | |
239 #[derive(Default)] | |
240 struct ChangesetData { | |
241 user: Option<Vec<u8>>, | |
242 changeset: Option<Vec<u8>>, | |
243 date: Option<Vec<u8>>, | |
244 } | |
245 | |
246 impl ChangesetData { | |
247 fn create( | |
248 revision: Revision, | |
249 changelog: &Changelog, | |
250 include: Include, | |
251 verbosity: Verbosity, | |
252 ) -> Result<ChangesetData, CommandError> { | |
253 let mut result = ChangesetData::default(); | |
254 if !(include.user || include.changeset || include.date) { | |
255 return Ok(result); | |
256 } | |
257 let entry = changelog.entry(revision)?; | |
258 let data = entry.data()?; | |
259 if include.user { | |
260 let user = match verbosity { | |
261 Verbosity::Verbose => data.user(), | |
262 _ => hg::utils::strings::short_user(data.user()), | |
263 }; | |
264 result.user = Some(user.to_vec()); | |
265 } | |
266 if include.changeset { | |
267 let changeset = entry.as_revlog_entry().node().short(); | |
268 result.changeset = Some(format!("{:x}", changeset).into_bytes()); | |
269 } | |
270 if include.date { | |
271 let date = data.timestamp()?.format(match verbosity { | |
272 Verbosity::Quiet => "%Y-%m-%d", | |
273 _ => "%a %b %d %H:%M:%S %Y %z", | |
274 }); | |
275 result.date = Some(format!("{}", date).into_bytes()); | |
276 } | |
277 Ok(result) | |
278 } | |
279 } | |
280 | |
281 impl<'a> Formatter<'a> { | |
282 fn new( | |
283 changelog: &'a Changelog, | |
284 encoder: &'a Encoder, | |
285 include: Include, | |
286 verbosity: Verbosity, | |
287 ) -> Self { | |
288 let cache = FastHashMap::default(); | |
289 Self { | |
290 changelog, | |
291 encoder, | |
292 include, | |
293 verbosity, | |
294 cache, | |
295 } | |
296 } | |
297 | |
298 fn format( | |
299 &mut self, | |
300 annotations: Vec<ChangesetAnnotation>, | |
301 ) -> Result<Vec<Vec<u8>>, CommandError> { | |
302 let mut lines: Vec<Vec<Vec<u8>>> = | |
303 Vec::with_capacity(annotations.len()); | |
304 let num_fields = self.include.count(); | |
305 let mut widths = vec![0usize; num_fields]; | |
306 for annotation in annotations { | |
307 let revision = annotation.revision; | |
308 let data = match self.cache.entry(revision) { | |
309 Entry::Occupied(occupied) => occupied.into_mut(), | |
310 Entry::Vacant(vacant) => vacant.insert(ChangesetData::create( | |
311 revision, | |
312 self.changelog, | |
313 self.include, | |
314 self.verbosity, | |
315 )?), | |
316 }; | |
317 let mut fields = Vec::with_capacity(num_fields); | |
318 if let Some(user) = &data.user { | |
319 fields.push(user.clone()); | |
320 } | |
321 if self.include.number { | |
322 fields.push(format_bytes!(b"{}", revision)); | |
323 } | |
324 if let Some(changeset) = &data.changeset { | |
325 fields.push(changeset.clone()); | |
326 } | |
327 if let Some(date) = &data.date { | |
328 fields.push(date.clone()); | |
329 } | |
330 if self.include.file { | |
331 fields.push(annotation.path.into_vec()); | |
332 } | |
333 if self.include.line_number { | |
334 fields.push(format_bytes!(b"{}", annotation.line_number)); | |
335 } | |
336 for (field, width) in fields.iter().zip(widths.iter_mut()) { | |
337 *width = std::cmp::max( | |
338 *width, | |
339 self.encoder.column_width_bytes(field), | |
340 ); | |
341 } | |
342 lines.push(fields); | |
343 } | |
344 let total_width = widths.iter().sum::<usize>() + num_fields - 1; | |
345 Ok(lines | |
346 .iter() | |
347 .map(|fields| { | |
348 let mut bytes = Vec::with_capacity(total_width); | |
349 for (i, (field, width)) in | |
350 fields.iter().zip(widths.iter()).enumerate() | |
351 { | |
352 if i > 0 { | |
353 let colon = | |
354 self.include.line_number && i == num_fields - 1; | |
355 bytes.push(if colon { b':' } else { b' ' }); | |
356 } | |
357 let padding = | |
358 width - self.encoder.column_width_bytes(field); | |
359 bytes.resize(bytes.len() + padding, b' '); | |
360 bytes.extend_from_slice(field); | |
361 } | |
362 bytes | |
363 }) | |
364 .collect()) | |
365 } | |
366 } |