just playing with tangled
at ig/vimdiffwarn 528 lines 17 kB view raw
1use std::collections::HashMap; 2use std::fs; 3use std::io; 4use std::io::Write as _; 5use std::path::Path; 6use std::path::PathBuf; 7use std::process::ExitStatus; 8 9use bstr::ByteVec as _; 10use indexmap::IndexMap; 11use indoc::indoc; 12use itertools::FoldWhile; 13use itertools::Itertools as _; 14use jj_lib::backend::CommitId; 15use jj_lib::commit::Commit; 16use jj_lib::config::ConfigGetError; 17use jj_lib::file_util::IoResultExt as _; 18use jj_lib::file_util::PathError; 19use jj_lib::settings::UserSettings; 20use thiserror::Error; 21 22use crate::cli_util::short_commit_hash; 23use crate::cli_util::WorkspaceCommandTransaction; 24use crate::command_error::CommandError; 25use crate::config::CommandNameAndArgs; 26use crate::formatter::PlainTextFormatter; 27use crate::text_util; 28use crate::ui::Ui; 29 30#[derive(Debug, Error)] 31pub enum TextEditError { 32 #[error("Failed to run editor '{name}'")] 33 FailedToRun { name: String, source: io::Error }, 34 #[error("Editor '{command}' exited with {status}")] 35 ExitStatus { command: String, status: ExitStatus }, 36} 37 38#[derive(Debug, Error)] 39#[error("Failed to edit {name}", name = name.as_deref().unwrap_or("file"))] 40pub struct TempTextEditError { 41 #[source] 42 pub error: Box<dyn std::error::Error + Send + Sync>, 43 /// Short description of the edited content. 44 pub name: Option<String>, 45 /// Path to the temporary file. 46 pub path: Option<PathBuf>, 47} 48 49impl TempTextEditError { 50 fn new(error: Box<dyn std::error::Error + Send + Sync>, path: Option<PathBuf>) -> Self { 51 TempTextEditError { 52 error, 53 name: None, 54 path, 55 } 56 } 57 58 /// Adds short description of the edited content. 59 pub fn with_name(mut self, name: impl Into<String>) -> Self { 60 self.name = Some(name.into()); 61 self 62 } 63} 64 65/// Configured text editor. 66#[derive(Clone, Debug)] 67pub struct TextEditor { 68 editor: CommandNameAndArgs, 69 dir: Option<PathBuf>, 70} 71 72impl TextEditor { 73 pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> { 74 let editor = settings.get("ui.editor")?; 75 Ok(TextEditor { editor, dir: None }) 76 } 77 78 pub fn with_temp_dir(mut self, dir: impl Into<PathBuf>) -> Self { 79 self.dir = Some(dir.into()); 80 self 81 } 82 83 /// Opens the given `path` in editor. 84 pub fn edit_file(&self, path: impl AsRef<Path>) -> Result<(), TextEditError> { 85 let mut cmd = self.editor.to_command(); 86 cmd.arg(path.as_ref()); 87 tracing::info!(?cmd, "running editor"); 88 let status = cmd.status().map_err(|source| TextEditError::FailedToRun { 89 name: self.editor.split_name().into_owned(), 90 source, 91 })?; 92 if status.success() { 93 Ok(()) 94 } else { 95 let command = self.editor.to_string(); 96 Err(TextEditError::ExitStatus { command, status }) 97 } 98 } 99 100 /// Writes the given `content` to temporary file and opens it in editor. 101 pub fn edit_str( 102 &self, 103 content: impl AsRef<[u8]>, 104 suffix: Option<&str>, 105 ) -> Result<String, TempTextEditError> { 106 let path = self 107 .write_temp_file(content.as_ref(), suffix) 108 .map_err(|err| TempTextEditError::new(err.into(), None))?; 109 self.edit_file(&path) 110 .map_err(|err| TempTextEditError::new(err.into(), Some(path.clone())))?; 111 let edited = fs::read_to_string(&path) 112 .context(&path) 113 .map_err(|err| TempTextEditError::new(err.into(), Some(path.clone())))?; 114 // Delete the file only if everything went well. 115 fs::remove_file(path).ok(); 116 Ok(edited) 117 } 118 119 fn write_temp_file(&self, content: &[u8], suffix: Option<&str>) -> Result<PathBuf, PathError> { 120 let dir = self.dir.clone().unwrap_or_else(tempfile::env::temp_dir); 121 let mut file = tempfile::Builder::new() 122 .prefix("editor-") 123 .suffix(suffix.unwrap_or("")) 124 .tempfile_in(&dir) 125 .context(&dir)?; 126 file.write_all(content).context(file.path())?; 127 let (_, path) = file 128 .keep() 129 .or_else(|err| Err(err.error).context(err.file.path()))?; 130 Ok(path) 131 } 132} 133 134fn append_blank_line(text: &mut String) { 135 if !text.is_empty() && !text.ends_with('\n') { 136 text.push('\n'); 137 } 138 let last_line = text.lines().next_back(); 139 if last_line.is_some_and(|line| line.starts_with("JJ:")) { 140 text.push_str("JJ:\n"); 141 } else { 142 text.push('\n'); 143 } 144} 145 146/// Cleanup a description by normalizing line endings, and removing leading and 147/// trailing blank lines. 148fn cleanup_description_lines<I>(lines: I) -> String 149where 150 I: IntoIterator, 151 I::Item: AsRef<str>, 152{ 153 let description = lines 154 .into_iter() 155 .fold_while(String::new(), |acc, line| { 156 let line = line.as_ref(); 157 if line.strip_prefix("JJ: ignore-rest").is_some() { 158 FoldWhile::Done(acc) 159 } else if line.starts_with("JJ:") { 160 FoldWhile::Continue(acc) 161 } else { 162 FoldWhile::Continue(acc + line + "\n") 163 } 164 }) 165 .into_inner(); 166 text_util::complete_newline(description.trim_matches('\n')) 167} 168 169pub fn edit_description(editor: &TextEditor, description: &str) -> Result<String, CommandError> { 170 let mut description = description.to_owned(); 171 append_blank_line(&mut description); 172 description.push_str("JJ: Lines starting with \"JJ:\" (like this one) will be removed.\n"); 173 174 let description = editor 175 .edit_str(description, Some(".jjdescription")) 176 .map_err(|err| err.with_name("description"))?; 177 178 Ok(cleanup_description_lines(description.lines())) 179} 180 181/// Edits the descriptions of the given commits in a single editor session. 182pub fn edit_multiple_descriptions( 183 ui: &Ui, 184 editor: &TextEditor, 185 tx: &WorkspaceCommandTransaction, 186 commits: &[(&CommitId, Commit)], 187) -> Result<ParsedBulkEditMessage<CommitId>, CommandError> { 188 let mut commits_map = IndexMap::new(); 189 let mut bulk_message = String::new(); 190 191 bulk_message.push_str(indoc! {r#" 192 JJ: Enter or edit commit descriptions after the `JJ: describe` lines. 193 JJ: Warning: 194 JJ: - The text you enter will be lost on a syntax error. 195 JJ: - The syntax of the separator lines may change in the future. 196 JJ: 197 "#}); 198 for (commit_id, temp_commit) in commits { 199 let commit_hash = short_commit_hash(commit_id); 200 bulk_message.push_str("JJ: describe "); 201 bulk_message.push_str(&commit_hash); 202 bulk_message.push_str(" -------\n"); 203 commits_map.insert(commit_hash, *commit_id); 204 let template = description_template(ui, tx, "", temp_commit)?; 205 bulk_message.push_str(&template); 206 append_blank_line(&mut bulk_message); 207 } 208 bulk_message.push_str("JJ: Lines starting with \"JJ:\" (like this one) will be removed.\n"); 209 210 let bulk_message = editor 211 .edit_str(bulk_message, Some(".jjdescription")) 212 .map_err(|err| err.with_name("description"))?; 213 214 Ok(parse_bulk_edit_message(&bulk_message, &commits_map)?) 215} 216 217#[derive(Debug)] 218pub struct ParsedBulkEditMessage<T> { 219 /// The parsed, formatted descriptions. 220 pub descriptions: HashMap<T, String>, 221 /// Commit IDs that were expected while parsing the edited messages, but 222 /// which were not found. 223 pub missing: Vec<String>, 224 /// Commit IDs that were found multiple times while parsing the edited 225 /// messages. 226 pub duplicates: Vec<String>, 227 /// Commit IDs that were found while parsing the edited messages, but which 228 /// were not originally being edited. 229 pub unexpected: Vec<String>, 230} 231 232#[derive(Debug, Error, PartialEq)] 233pub enum ParseBulkEditMessageError { 234 #[error(r#"Found the following line without a commit header: "{0}""#)] 235 LineWithoutCommitHeader(String), 236} 237 238/// Parse the bulk message of edited commit descriptions. 239fn parse_bulk_edit_message<T>( 240 message: &str, 241 commit_ids_map: &IndexMap<String, &T>, 242) -> Result<ParsedBulkEditMessage<T>, ParseBulkEditMessageError> 243where 244 T: Eq + std::hash::Hash + Clone, 245{ 246 let mut descriptions = HashMap::new(); 247 let mut duplicates = Vec::new(); 248 let mut unexpected = Vec::new(); 249 250 let mut messages: Vec<(&str, Vec<&str>)> = vec![]; 251 for line in message.lines() { 252 if let Some(commit_id_prefix) = line.strip_prefix("JJ: describe ") { 253 let commit_id_prefix = 254 commit_id_prefix.trim_end_matches(|c: char| c.is_ascii_whitespace() || c == '-'); 255 messages.push((commit_id_prefix, vec![])); 256 } else if let Some((_, lines)) = messages.last_mut() { 257 lines.push(line); 258 } 259 // Do not allow lines without a commit header, except for empty lines or comments. 260 else if !line.trim().is_empty() && !line.starts_with("JJ:") { 261 return Err(ParseBulkEditMessageError::LineWithoutCommitHeader( 262 line.to_owned(), 263 )); 264 }; 265 } 266 267 for (commit_id_prefix, description_lines) in messages { 268 let Some(&commit_id) = commit_ids_map.get(commit_id_prefix) else { 269 unexpected.push(commit_id_prefix.to_string()); 270 continue; 271 }; 272 if descriptions.contains_key(commit_id) { 273 duplicates.push(commit_id_prefix.to_string()); 274 continue; 275 } 276 descriptions.insert( 277 commit_id.clone(), 278 cleanup_description_lines(&description_lines), 279 ); 280 } 281 282 let missing: Vec<_> = commit_ids_map 283 .iter() 284 .filter(|(_, commit_id)| !descriptions.contains_key(*commit_id)) 285 .map(|(commit_id_prefix, _)| commit_id_prefix.to_string()) 286 .collect(); 287 288 Ok(ParsedBulkEditMessage { 289 descriptions, 290 missing, 291 duplicates, 292 unexpected, 293 }) 294} 295 296/// Combines the descriptions from the input commits. If only one is non-empty, 297/// then that one is used. 298pub fn try_combine_messages(sources: &[Commit], destination: &Commit) -> Option<String> { 299 let non_empty = sources 300 .iter() 301 .chain(std::iter::once(destination)) 302 .filter(|c| !c.description().is_empty()) 303 .take(2) 304 .collect_vec(); 305 match *non_empty.as_slice() { 306 [] => Some(String::new()), 307 [commit] => Some(commit.description().to_owned()), 308 [_, _, ..] => None, 309 } 310} 311 312/// Produces a combined description with "JJ: " comment lines. 313/// 314/// This includes empty descriptins too, so the user doesn't have to wonder why 315/// they only see 2 descriptions when they combined 3 commits. 316pub fn combine_messages_for_editing(sources: &[Commit], destination: &Commit) -> String { 317 let mut combined = String::new(); 318 combined.push_str("JJ: Description from the destination commit:\n"); 319 combined.push_str(destination.description()); 320 for commit in sources { 321 combined.push_str("\nJJ: Description from source commit:\n"); 322 combined.push_str(commit.description()); 323 } 324 combined 325} 326 327/// Create a description from a list of paragraphs. 328/// 329/// Based on the Git CLI behavior. See `opt_parse_m()` and `cleanup_mode` in 330/// `git/builtin/commit.c`. 331pub fn join_message_paragraphs(paragraphs: &[String]) -> String { 332 // Ensure each paragraph ends with a newline, then add another newline between 333 // paragraphs. 334 paragraphs 335 .iter() 336 .map(|p| text_util::complete_newline(p.as_str())) 337 .join("\n") 338} 339 340/// Renders commit description template, which will be edited by user. 341pub fn description_template( 342 ui: &Ui, 343 tx: &WorkspaceCommandTransaction, 344 intro: &str, 345 commit: &Commit, 346) -> Result<String, CommandError> { 347 // TODO: Should "ui.default-description" be deprecated? 348 // We might want default description templates per command instead. For 349 // example, "backout_description" template will be rendered against the 350 // commit to be backed out, and the generated description could be set 351 // without spawning editor. 352 353 // Named as "draft" because the output can contain "JJ:" comment lines. 354 let template_key = "templates.draft_commit_description"; 355 let template_text = tx.settings().get_string(template_key)?; 356 let template = tx.parse_commit_template(ui, &template_text)?; 357 358 let mut output = Vec::new(); 359 if !intro.is_empty() { 360 writeln!(output, "JJ: {intro}").unwrap(); 361 } 362 template 363 .format(commit, &mut PlainTextFormatter::new(&mut output)) 364 .expect("write() to vec backed formatter should never fail"); 365 // Template output is usually UTF-8, but it can contain file content. 366 Ok(output.into_string_lossy()) 367} 368 369#[cfg(test)] 370mod tests { 371 use indexmap::indexmap; 372 use indoc::indoc; 373 use maplit::hashmap; 374 375 use super::parse_bulk_edit_message; 376 use crate::description_util::ParseBulkEditMessageError; 377 378 #[test] 379 fn test_parse_complete_bulk_edit_message() { 380 let result = parse_bulk_edit_message( 381 indoc! {" 382 JJ: describe 1 ------- 383 Description 1 384 385 JJ: describe 2 386 Description 2 387 388 JJ: describe 3 -- 389 Description 3 390 "}, 391 &indexmap! { 392 "1".to_string() => &1, 393 "2".to_string() => &2, 394 "3".to_string() => &3, 395 }, 396 ) 397 .unwrap(); 398 assert_eq!( 399 result.descriptions, 400 hashmap! { 401 1 => "Description 1\n".to_string(), 402 2 => "Description 2\n".to_string(), 403 3 => "Description 3\n".to_string(), 404 } 405 ); 406 assert!(result.missing.is_empty()); 407 assert!(result.duplicates.is_empty()); 408 assert!(result.unexpected.is_empty()); 409 } 410 411 #[test] 412 fn test_parse_bulk_edit_message_with_missing_descriptions() { 413 let result = parse_bulk_edit_message( 414 indoc! {" 415 JJ: describe 1 ------- 416 Description 1 417 "}, 418 &indexmap! { 419 "1".to_string() => &1, 420 "2".to_string() => &2, 421 }, 422 ) 423 .unwrap(); 424 assert_eq!( 425 result.descriptions, 426 hashmap! { 427 1 => "Description 1\n".to_string(), 428 } 429 ); 430 assert_eq!(result.missing, vec!["2".to_string()]); 431 assert!(result.duplicates.is_empty()); 432 assert!(result.unexpected.is_empty()); 433 } 434 435 #[test] 436 fn test_parse_bulk_edit_message_with_duplicate_descriptions() { 437 let result = parse_bulk_edit_message( 438 indoc! {" 439 JJ: describe 1 ------- 440 Description 1 441 442 JJ: describe 1 ------- 443 Description 1 (repeated) 444 "}, 445 &indexmap! { 446 "1".to_string() => &1, 447 }, 448 ) 449 .unwrap(); 450 assert_eq!( 451 result.descriptions, 452 hashmap! { 453 1 => "Description 1\n".to_string(), 454 } 455 ); 456 assert!(result.missing.is_empty()); 457 assert_eq!(result.duplicates, vec!["1".to_string()]); 458 assert!(result.unexpected.is_empty()); 459 } 460 461 #[test] 462 fn test_parse_bulk_edit_message_with_unexpected_descriptions() { 463 let result = parse_bulk_edit_message( 464 indoc! {" 465 JJ: describe 1 ------- 466 Description 1 467 468 JJ: describe 3 ------- 469 Description 3 (unexpected) 470 "}, 471 &indexmap! { 472 "1".to_string() => &1, 473 }, 474 ) 475 .unwrap(); 476 assert_eq!( 477 result.descriptions, 478 hashmap! { 479 1 => "Description 1\n".to_string(), 480 } 481 ); 482 assert!(result.missing.is_empty()); 483 assert!(result.duplicates.is_empty()); 484 assert_eq!(result.unexpected, vec!["3".to_string()]); 485 } 486 487 #[test] 488 fn test_parse_bulk_edit_message_with_no_header() { 489 let result = parse_bulk_edit_message( 490 indoc! {" 491 Description 1 492 "}, 493 &indexmap! { 494 "1".to_string() => &1, 495 }, 496 ); 497 assert_eq!( 498 result.unwrap_err(), 499 ParseBulkEditMessageError::LineWithoutCommitHeader("Description 1".to_string()) 500 ); 501 } 502 503 #[test] 504 fn test_parse_bulk_edit_message_with_comment_before_header() { 505 let result = parse_bulk_edit_message( 506 indoc! {" 507 JJ: Custom comment and empty lines below should be accepted 508 509 510 JJ: describe 1 ------- 511 Description 1 512 "}, 513 &indexmap! { 514 "1".to_string() => &1, 515 }, 516 ) 517 .unwrap(); 518 assert_eq!( 519 result.descriptions, 520 hashmap! { 521 1 => "Description 1\n".to_string(), 522 } 523 ); 524 assert!(result.missing.is_empty()); 525 assert!(result.duplicates.is_empty()); 526 assert!(result.unexpected.is_empty()); 527 } 528}