From d56ad0f744f115e1ea5cb8000a7e065c64dc55f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicholas=20Schultz-M=C3=B8ller?= Date: Mon, 10 Feb 2025 06:17:50 +0100 Subject: [PATCH] Add support for non-space indention (#720) - Adds support for non-space indention - even a "weird" mix of e.g. tabs and spaces. - Note, I had to add `String` field to the `Visitor` struct becasue the whitespace before the macro keyword is not included in the syn crate. - I only added a few new test cases with pure tabs or a combination of tabs and white spaces as most existing tests were also impacted by the change (and thus also tested the feature). - Added changelog entry. --- CHANGELOG.md | 4 ++ cargo-insta/src/inline.rs | 68 ++++++++++++++++++---- insta/src/snapshot.rs | 117 ++++++++++++++++++++++++++------------ 3 files changed, 141 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 794575be..a20e5857 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to insta and cargo-insta are documented here. +## 1.42.3 + +- Support other indention characters than spaces in inline snapshots. #679 + ## 1.42.2 - Stop `\t` and `\x1b` (ANSI color escape) from causing snapshots to be escaped. #715 diff --git a/cargo-insta/src/inline.rs b/cargo-insta/src/inline.rs index 5fdc8097..3182dc28 100644 --- a/cargo-insta/src/inline.rs +++ b/cargo-insta/src/inline.rs @@ -14,7 +14,7 @@ use syn::spanned::Spanned; struct InlineSnapshot { start: (usize, usize), end: (usize, usize), - indentation: usize, + indentation: String, } #[derive(Clone)] @@ -106,7 +106,7 @@ impl FilePatcher { // replace lines let snapshot_line_contents = - [prefix, snapshot.to_inline(inline.indentation), suffix].join(""); + [prefix, snapshot.to_inline(&inline.indentation), suffix].join(""); self.lines.splice( inline.start.0..=inline.end.0, @@ -124,9 +124,13 @@ impl FilePatcher { } fn find_snapshot_macro(&self, line: usize) -> Option { - struct Visitor(usize, Option); + struct Visitor(usize, Option, String); - fn scan_for_path_start(tokens: &[TokenTree], pos: usize) -> usize { + fn indentation(column_of_macro_start: usize, code_line: &str) -> String { + code_line[..column_of_macro_start].to_string() + } + + fn scan_for_path_start(tokens: &[TokenTree], pos: usize, code_line: &str) -> String { let mut rev_tokens = tokens[..=pos].iter().rev(); let mut start = rev_tokens.next().unwrap(); loop { @@ -144,7 +148,7 @@ impl FilePatcher { } break; } - start.span().start().column + indentation(start.span().start().column, code_line) } impl Visitor { @@ -156,7 +160,7 @@ impl FilePatcher { if punct.as_char() == '!' { if let Some(TokenTree::Group(ref group)) = tokens.get(idx + 2) { // Found a macro, determine its indentation - let indentation = scan_for_path_start(tokens, idx); + let indentation = scan_for_path_start(tokens, idx, &self.2); // Extract tokens from the macro arguments let tokens: Vec<_> = group.stream().into_iter().collect(); // Try to extract a snapshot, passing the calculated indentation @@ -175,7 +179,7 @@ impl FilePatcher { } } - fn try_extract_snapshot(&mut self, tokens: &[TokenTree], indentation: usize) -> bool { + fn try_extract_snapshot(&mut self, tokens: &[TokenTree], indentation: String) -> bool { // ignore optional trailing comma let tokens = match tokens.last() { Some(TokenTree::Punct(ref punct)) if punct.as_char() == ',' => { @@ -230,8 +234,9 @@ impl FilePatcher { } } fn visit_macro(&mut self, i: &'ast syn::Macro) { - let indentation = i.span().start().column; - let start = i.span().start().line; + let span_start = i.span().start(); + let indentation = indentation(span_start.column, &self.2); + let start = span_start.line; let end = i .tokens .clone() @@ -258,7 +263,8 @@ impl FilePatcher { } } - let mut visitor = Visitor(line, None); + let code_line = self.lines[line - 1].clone(); + let mut visitor = Visitor(line, None, code_line); syn::visit::visit_file(&mut visitor, &self.source); visitor.1 } @@ -308,6 +314,46 @@ fn test_function() { "####); // Assert the indentation - assert_debug_snapshot!(snapshot.indentation, @"4"); + assert_debug_snapshot!(snapshot.indentation, @r#"" ""#); + } + + #[test] + fn test_find_snapshot_macro_with_tabs() { + let content = r######" +use insta::assert_snapshot; + +fn test_function() { + assert_snapshot!("test\ntest", @r###" + test + test + "###); +} +"######; + + let file_patcher = FilePatcher { + filename: PathBuf::new(), + lines: content.lines().map(String::from).collect(), + source: syn::parse_file(content).unwrap(), + inline_snapshots: vec![], + }; + + // The snapshot macro starts on line 5 (1-based index) + let snapshot = file_patcher.find_snapshot_macro(5).unwrap(); + + // Extract the snapshot content + let snapshot_content: Vec = + file_patcher.lines[snapshot.start.0..=snapshot.end.0].to_vec(); + + assert_debug_snapshot!(snapshot_content, @r####" + [ + "\tassert_snapshot!(\"test\\ntest\", @r###\"", + "\ttest", + "\ttest", + "\t\"###);", + ] + "####); + + // Assert the indentation + assert_debug_snapshot!(snapshot.indentation, @r#""\t""#); } } diff --git a/insta/src/snapshot.rs b/insta/src/snapshot.rs index 8b72a686..21a96adb 100644 --- a/insta/src/snapshot.rs +++ b/insta/src/snapshot.rs @@ -685,7 +685,7 @@ impl TextSnapshotContents { /// Returns the string literal, including `#` delimiters, to insert into a /// Rust source file. - pub fn to_inline(&self, indentation: usize) -> String { + pub fn to_inline(&self, indentation: &str) -> String { let contents = self.normalize(); let mut out = String::new(); @@ -725,15 +725,14 @@ impl TextSnapshotContents { // it, but it works...) .map(|l| { format!( - "\n{:width$}{l}", - "", - width = if l.is_empty() { 0 } else { indentation }, + "\n{i}{l}", + i = if l.is_empty() { "" } else { indentation }, l = l ) }) // `lines` removes the final line ending — add back. Include // indentation so the closing delimited aligns with the full string. - .chain(Some(format!("\n{:width$}", "", width = indentation))), + .chain(Some(format!("\n{}", indentation))), ); } else { out.push_str(contents.as_str()); @@ -811,23 +810,26 @@ fn test_required_hashes() { assert_snapshot!(required_hashes(r###"r"#"Raw string"#""###), @"2"); } -fn count_leading_spaces(value: &str) -> usize { - value.chars().take_while(|x| x.is_whitespace()).count() +fn leading_space(value: &str) -> String { + value + .chars() + .take_while(|x| x.is_whitespace()) + .collect::() } -fn min_indentation(snapshot: &str) -> usize { +fn min_indentation(snapshot: &str) -> String { let lines = snapshot.trim_end().lines(); if lines.clone().count() <= 1 { // not a multi-line string - return 0; + return "".into(); } lines .filter(|l| !l.is_empty()) - .map(count_leading_spaces) - .min() - .unwrap_or(0) + .map(leading_space) + .min_by(|a, b| a.len().cmp(&b.len())) + .unwrap_or("".into()) } /// Removes excess indentation, and changes newlines to \n. @@ -835,7 +837,7 @@ fn normalize_inline_snapshot(snapshot: &str) -> String { let indentation = min_indentation(snapshot); snapshot .lines() - .map(|l| l.get(indentation..).unwrap_or("")) + .map(|l| l.get(indentation.len()..).unwrap_or("")) .collect::>() .join("\n") } @@ -934,13 +936,13 @@ fn test_snapshot_contents() { use similar_asserts::assert_eq; let snapshot_contents = TextSnapshotContents::new("testing".to_string(), TextSnapshotKind::Inline); - assert_eq!(snapshot_contents.to_inline(0), r#""testing""#); + assert_eq!(snapshot_contents.to_inline(""), r#""testing""#); let t = &" a b"[1..]; assert_eq!( - TextSnapshotContents::new(t.to_string(), TextSnapshotKind::Inline).to_inline(0), + TextSnapshotContents::new(t.to_string(), TextSnapshotKind::Inline).to_inline(""), r##"r" a b @@ -948,7 +950,7 @@ b ); assert_eq!( - TextSnapshotContents::new("a\nb".to_string(), TextSnapshotKind::Inline).to_inline(4), + TextSnapshotContents::new("a\nb".to_string(), TextSnapshotKind::Inline).to_inline(" "), r##"r" a b @@ -957,7 +959,7 @@ b assert_eq!( TextSnapshotContents::new("\n a\n b".to_string(), TextSnapshotKind::Inline) - .to_inline(0), + .to_inline(""), r##"r" a b @@ -965,7 +967,8 @@ b ); assert_eq!( - TextSnapshotContents::new("\na\n\nb".to_string(), TextSnapshotKind::Inline).to_inline(4), + TextSnapshotContents::new("\na\n\nb".to_string(), TextSnapshotKind::Inline) + .to_inline(" "), r##"r" a @@ -974,23 +977,23 @@ b ); assert_eq!( - TextSnapshotContents::new("\n ab\n".to_string(), TextSnapshotKind::Inline).to_inline(0), + TextSnapshotContents::new("\n ab\n".to_string(), TextSnapshotKind::Inline).to_inline(""), r##""ab""## ); assert_eq!( - TextSnapshotContents::new("ab".to_string(), TextSnapshotKind::Inline).to_inline(0), + TextSnapshotContents::new("ab".to_string(), TextSnapshotKind::Inline).to_inline(""), r#""ab""# ); // Test control and special characters assert_eq!( - TextSnapshotContents::new("a\tb".to_string(), TextSnapshotKind::Inline).to_inline(0), + TextSnapshotContents::new("a\tb".to_string(), TextSnapshotKind::Inline).to_inline(""), r##""a b""## ); assert_eq!( - TextSnapshotContents::new("a\t\nb".to_string(), TextSnapshotKind::Inline).to_inline(0), + TextSnapshotContents::new("a\t\nb".to_string(), TextSnapshotKind::Inline).to_inline(""), r##"r" a b @@ -998,18 +1001,18 @@ b ); assert_eq!( - TextSnapshotContents::new("a\rb".to_string(), TextSnapshotKind::Inline).to_inline(0), + TextSnapshotContents::new("a\rb".to_string(), TextSnapshotKind::Inline).to_inline(""), r##""a\rb""## ); assert_eq!( - TextSnapshotContents::new("a\0b".to_string(), TextSnapshotKind::Inline).to_inline(0), + TextSnapshotContents::new("a\0b".to_string(), TextSnapshotKind::Inline).to_inline(""), // Nul byte is printed as `\0` in Rust string literals r##""a\0b""## ); assert_eq!( - TextSnapshotContents::new("a\u{FFFD}b".to_string(), TextSnapshotKind::Inline).to_inline(0), + TextSnapshotContents::new("a\u{FFFD}b".to_string(), TextSnapshotKind::Inline).to_inline(""), // Replacement character is returned as the character in literals r##""a�b""## ); @@ -1018,12 +1021,12 @@ b #[test] fn test_snapshot_contents_hashes() { assert_eq!( - TextSnapshotContents::new("a###b".to_string(), TextSnapshotKind::Inline).to_inline(0), + TextSnapshotContents::new("a###b".to_string(), TextSnapshotKind::Inline).to_inline(""), r#""a###b""# ); assert_eq!( - TextSnapshotContents::new("a\n\\###b".to_string(), TextSnapshotKind::Inline).to_inline(0), + TextSnapshotContents::new("a\n\\###b".to_string(), TextSnapshotKind::Inline).to_inline(""), r#####"r" a \###b @@ -1141,6 +1144,30 @@ a" r###"a a"### ); + + assert_eq!( + normalize_inline_snapshot( + r#" + 1 + 2"# + ), + r###" + 1 +2"### + ); + + assert_eq!( + normalize_inline_snapshot( + r#" + 1 + 2 + "# + ), + r###" +1 +2 +"### + ); } #[test] @@ -1150,52 +1177,68 @@ fn test_min_indentation() { 1 2 "#; - assert_eq!(min_indentation(t), 3); + assert_eq!(min_indentation(t), " ".to_string()); let t = r#" 1 2"#; - assert_eq!(min_indentation(t), 4); + assert_eq!(min_indentation(t), " ".to_string()); let t = r#" 1 2 "#; - assert_eq!(min_indentation(t), 12); + assert_eq!(min_indentation(t), " ".to_string()); let t = r#" 1 2 "#; - assert_eq!(min_indentation(t), 3); + assert_eq!(min_indentation(t), " ".to_string()); let t = r#" a "#; - assert_eq!(min_indentation(t), 8); + assert_eq!(min_indentation(t), " ".to_string()); let t = ""; - assert_eq!(min_indentation(t), 0); + assert_eq!(min_indentation(t), "".to_string()); let t = r#" a b c "#; - assert_eq!(min_indentation(t), 0); + assert_eq!(min_indentation(t), "".to_string()); let t = r#" a "#; - assert_eq!(min_indentation(t), 0); + assert_eq!(min_indentation(t), "".to_string()); let t = " a"; - assert_eq!(min_indentation(t), 4); + assert_eq!(min_indentation(t), " ".to_string()); let t = r#"a a"#; - assert_eq!(min_indentation(t), 0); + assert_eq!(min_indentation(t), "".to_string()); + + let t = r#" + 1 + 2 + "#; + assert_eq!(min_indentation(t), " ".to_string()); + + let t = r#" + 1 + 2"#; + assert_eq!(min_indentation(t), " ".to_string()); + + let t = r#" + 1 + 2"#; + assert_eq!(min_indentation(t), " ".to_string()); } #[test]