trybuild_internals_api/
normalize.rs

1#[cfg(test)]
2#[path = "tests.rs"]
3mod tests;
4
5use self::Normalization::*;
6use crate::directory::Directory;
7use crate::run::PathDependency;
8use std::cmp;
9use std::path::Path;
10
11#[derive(Copy, Clone)]
12pub struct Context<'a> {
13    pub krate: &'a str,
14    pub source_dir: &'a Directory,
15    pub workspace: &'a Directory,
16    pub input_file: &'a Path,
17    pub target_dir: &'a Directory,
18    pub path_dependencies: &'a [PathDependency],
19}
20
21macro_rules! normalizations {
22    ($($name:ident,)*) => {
23        #[derive(PartialOrd, PartialEq, Copy, Clone)]
24        enum Normalization {
25            $($name,)*
26        }
27
28        impl Normalization {
29            const ALL: &'static [Self] = &[$($name),*];
30        }
31
32        impl Default for Variations {
33            fn default() -> Self {
34                Variations {
35                    variations: [$(($name, String::new()).1),*],
36                }
37            }
38        }
39    };
40}
41
42normalizations! {
43    Basic,
44    StripCouldNotCompile,
45    StripCouldNotCompile2,
46    StripForMoreInformation,
47    StripForMoreInformation2,
48    TrimEnd,
49    RustLib,
50    TypeDirBackslash,
51    WorkspaceLines,
52    PathDependencies,
53    CargoRegistry,
54    ArrowOtherCrate,
55    RelativeToDir,
56    LinesOutsideInputFile,
57    Unindent,
58    AndOthers,
59    StripLongTypeNameFiles,
60    UnindentAfterHelp,
61    AndOthersVerbose,
62    // New normalization steps are to be inserted here at the end so that any
63    // snapshots saved before your normalization change remain passing.
64}
65
66/// For a given compiler output, produces the set of saved outputs against which
67/// the compiler's output would be considered correct. If the test's saved
68/// stderr file is identical to any one of these variations, the test will pass.
69///
70/// This is a set rather than just one normalized output in order to avoid
71/// breaking existing tests when introducing new normalization steps. Someone
72/// may have saved stderr snapshots with an older version of trybuild, and those
73/// tests need to continue to pass with newer versions of trybuild.
74///
75/// There is one "preferred" variation which is what we print when the stderr
76/// file is absent or not a match.
77pub fn diagnostics(output: &str, context: Context) -> Variations {
78    let output = output.replace("\r\n", "\n");
79
80    let mut result = Variations::default();
81    for (i, normalization) in Normalization::ALL.iter().enumerate() {
82        result.variations[i] = apply(&output, *normalization, context);
83    }
84
85    result
86}
87
88pub struct Variations {
89    variations: [String; Normalization::ALL.len()],
90}
91
92impl Variations {
93    pub fn preferred(&self) -> &str {
94        self.variations.last().unwrap()
95    }
96
97    pub fn any<F: FnMut(&str) -> bool>(&self, mut f: F) -> bool {
98        self.variations.iter().any(|stderr| f(stderr))
99    }
100
101    pub fn concat(&mut self, other: &Self) {
102        for (this, other) in self.variations.iter_mut().zip(&other.variations) {
103            if !this.is_empty() && !other.is_empty() {
104                this.push('\n');
105            }
106            this.push_str(other);
107        }
108    }
109}
110
111pub fn trim<S: AsRef<[u8]>>(output: S) -> String {
112    let bytes = output.as_ref();
113    let mut normalized = String::from_utf8_lossy(bytes).into_owned();
114
115    let len = normalized.trim_end().len();
116    normalized.truncate(len);
117
118    if !normalized.is_empty() {
119        normalized.push('\n');
120    }
121
122    normalized
123}
124
125fn apply(original: &str, normalization: Normalization, context: Context) -> String {
126    let mut normalized = String::new();
127
128    let lines: Vec<&str> = original.lines().collect();
129    let mut filter = Filter {
130        all_lines: &lines,
131        normalization,
132        context,
133        hide_numbers: 0,
134        other_types: None,
135    };
136    for i in 0..lines.len() {
137        if let Some(line) = filter.apply(i) {
138            normalized += &line;
139            if !normalized.ends_with("\n\n") {
140                normalized.push('\n');
141            }
142        }
143    }
144
145    normalized = unindent(normalized, normalization);
146
147    trim(normalized)
148}
149
150struct Filter<'a> {
151    all_lines: &'a [&'a str],
152    normalization: Normalization,
153    context: Context<'a>,
154    hide_numbers: usize,
155    other_types: Option<usize>,
156}
157
158impl<'a> Filter<'a> {
159    fn apply(&mut self, index: usize) -> Option<String> {
160        let mut line = self.all_lines[index].to_owned();
161
162        if self.hide_numbers > 0 {
163            hide_leading_numbers(&mut line);
164            self.hide_numbers -= 1;
165        }
166
167        let trim_start = line.trim_start();
168        let indent = line.len() - trim_start.len();
169        let prefix = if trim_start.starts_with("--> ") {
170            Some("--> ")
171        } else if trim_start.starts_with("::: ") {
172            Some("::: ")
173        } else {
174            None
175        };
176
177        if prefix == Some("--> ") && self.normalization < ArrowOtherCrate {
178            if let Some(cut_end) = line.rfind(&['/', '\\'][..]) {
179                let cut_start = indent + 4;
180                line.replace_range(cut_start..cut_end + 1, "$DIR/");
181                return Some(line);
182            }
183        }
184
185        if prefix.is_some() {
186            line = line.replace('\\', "/");
187            let line_lower = line.to_ascii_lowercase();
188            let target_dir_pat = self
189                .context
190                .target_dir
191                .to_string_lossy()
192                .to_ascii_lowercase()
193                .replace('\\', "/");
194            let source_dir_pat = self
195                .context
196                .source_dir
197                .to_string_lossy()
198                .to_ascii_lowercase()
199                .replace('\\', "/");
200            let mut other_crate = false;
201            if line_lower.find(&target_dir_pat) == Some(indent + 4) {
202                let mut offset = indent + 4 + target_dir_pat.len();
203                let mut out_dir_crate_name = None;
204                while let Some(slash) = line[offset..].find('/') {
205                    let component = &line[offset..offset + slash];
206                    if component == "out" {
207                        if let Some(out_dir_crate_name) = out_dir_crate_name {
208                            let replacement = format!("$OUT_DIR[{}]", out_dir_crate_name);
209                            line.replace_range(indent + 4..offset + 3, &replacement);
210                            other_crate = true;
211                            break;
212                        }
213                    } else if component.len() > 17
214                        && component.rfind('-') == Some(component.len() - 17)
215                        && is_ascii_lowercase_hex(&component[component.len() - 16..])
216                    {
217                        out_dir_crate_name = Some(&component[..component.len() - 17]);
218                    } else {
219                        out_dir_crate_name = None;
220                    }
221                    offset += slash + 1;
222                }
223            } else if let Some(i) = line_lower.find(&source_dir_pat) {
224                if self.normalization >= RelativeToDir && i == indent + 4 {
225                    line.replace_range(i..i + source_dir_pat.len(), "");
226                    if self.normalization < LinesOutsideInputFile {
227                        return Some(line);
228                    }
229                    let input_file_pat = self
230                        .context
231                        .input_file
232                        .to_string_lossy()
233                        .to_ascii_lowercase()
234                        .replace('\\', "/");
235                    if line_lower[i + source_dir_pat.len()..].starts_with(&input_file_pat) {
236                        // Keep line numbers only within the input file (the
237                        // path passed to our `fn compile_fail`. All other
238                        // source files get line numbers erased below.
239                        return Some(line);
240                    }
241                } else {
242                    line.replace_range(i..i + source_dir_pat.len() - 1, "$DIR");
243                    if self.normalization < LinesOutsideInputFile {
244                        return Some(line);
245                    }
246                }
247                other_crate = true;
248            } else {
249                let workspace_pat = self
250                    .context
251                    .workspace
252                    .to_string_lossy()
253                    .to_ascii_lowercase()
254                    .replace('\\', "/");
255                if let Some(i) = line_lower.find(&workspace_pat) {
256                    line.replace_range(i..i + workspace_pat.len() - 1, "$WORKSPACE");
257                    other_crate = true;
258                }
259            }
260            if self.normalization >= PathDependencies && !other_crate {
261                for path_dep in self.context.path_dependencies {
262                    let path_dep_pat = path_dep
263                        .normalized_path
264                        .to_string_lossy()
265                        .to_ascii_lowercase()
266                        .replace('\\', "/");
267                    if let Some(i) = line_lower.find(&path_dep_pat) {
268                        let var = format!("${}", path_dep.name.to_uppercase().replace('-', "_"));
269                        line.replace_range(i..i + path_dep_pat.len() - 1, &var);
270                        other_crate = true;
271                        break;
272                    }
273                }
274            }
275            if self.normalization >= RustLib && !other_crate {
276                if let Some(pos) = line.find("/rustlib/src/rust/src/") {
277                    // --> /home/.rustup/toolchains/nightly/lib/rustlib/src/rust/src/libstd/net/ip.rs:83:1
278                    // --> $RUST/src/libstd/net/ip.rs:83:1
279                    line.replace_range(indent + 4..pos + 17, "$RUST");
280                    other_crate = true;
281                } else if let Some(pos) = line.find("/rustlib/src/rust/library/") {
282                    // --> /home/.rustup/toolchains/nightly/lib/rustlib/src/rust/library/std/src/net/ip.rs:83:1
283                    // --> $RUST/std/src/net/ip.rs:83:1
284                    line.replace_range(indent + 4..pos + 25, "$RUST");
285                    other_crate = true;
286                } else if line[indent + 4..].starts_with("/rustc/")
287                    && line
288                        .get(indent + 11..indent + 51)
289                        .map_or(false, is_ascii_lowercase_hex)
290                    && line[indent + 51..].starts_with("/library/")
291                {
292                    // --> /rustc/c5c7d2b37780dac1092e75f12ab97dd56c30861e/library/std/src/net/ip.rs:83:1
293                    // --> $RUST/std/src/net/ip.rs:83:1
294                    line.replace_range(indent + 4..indent + 59, "$RUST");
295                    other_crate = true;
296                }
297            }
298            if self.normalization >= CargoRegistry && !other_crate {
299                if let Some(pos) = line
300                    .find("/registry/src/github.com-")
301                    .or_else(|| line.find("/registry/src/index.crates.io-"))
302                {
303                    let hash_start = pos + line[pos..].find('-').unwrap() + 1;
304                    let hash_end = hash_start + 16;
305                    if line
306                        .get(hash_start..hash_end)
307                        .map_or(false, is_ascii_lowercase_hex)
308                        && line[hash_end..].starts_with('/')
309                    {
310                        // --> /home/.cargo/registry/src/github.com-1ecc6299db9ec823/serde_json-1.0.64/src/de.rs:2584:8
311                        // --> $CARGO/serde_json-1.0.64/src/de.rs:2584:8
312                        line.replace_range(indent + 4..hash_end, "$CARGO");
313                        other_crate = true;
314                    }
315                }
316            }
317            if other_crate && self.normalization >= WorkspaceLines {
318                // Blank out line numbers for this particular error since rustc
319                // tends to reach into code from outside of the test case. The
320                // test stderr shouldn't need to be updated every time we touch
321                // those files.
322                hide_trailing_numbers(&mut line);
323                self.hide_numbers = 1;
324                while let Some(next_line) = self.all_lines.get(index + self.hide_numbers) {
325                    match next_line.trim_start().chars().next().unwrap_or_default() {
326                        '0'..='9' | '|' | '.' => self.hide_numbers += 1,
327                        _ => break,
328                    }
329                }
330            }
331            return Some(line);
332        }
333
334        if line.starts_with("error: aborting due to ") {
335            return None;
336        }
337
338        if line == "To learn more, run the command again with --verbose." {
339            return None;
340        }
341
342        if trim_start.starts_with("= note: this compiler was built on 2")
343            && trim_start.ends_with("; consider upgrading it if it is out of date")
344        {
345            return None;
346        }
347
348        if self.normalization >= StripCouldNotCompile {
349            if line.starts_with("error: Could not compile `") {
350                return None;
351            }
352        }
353
354        if self.normalization >= StripCouldNotCompile2 {
355            if line.starts_with("error: could not compile `") {
356                return None;
357            }
358        }
359
360        if self.normalization >= StripForMoreInformation {
361            if line.starts_with("For more information about this error, try `rustc --explain") {
362                return None;
363            }
364        }
365
366        if self.normalization >= StripForMoreInformation2 {
367            if line.starts_with("Some errors have detailed explanations:") {
368                return None;
369            }
370            if line.starts_with("For more information about an error, try `rustc --explain") {
371                return None;
372            }
373        }
374
375        if self.normalization >= TrimEnd {
376            line.truncate(line.trim_end().len());
377        }
378
379        if self.normalization >= TypeDirBackslash {
380            if line
381                .trim_start()
382                .starts_with("= note: required because it appears within the type")
383            {
384                line = line.replace('\\', "/");
385            }
386        }
387
388        if self.normalization >= AndOthers {
389            let trim_start = line.trim_start();
390            if trim_start.starts_with("and ") && line.ends_with(" others") {
391                let indent = line.len() - trim_start.len();
392                let num_start = indent + "and ".len();
393                let num_end = line.len() - " others".len();
394                if num_start < num_end
395                    && line[num_start..num_end].bytes().all(|b| b.is_ascii_digit())
396                {
397                    line.replace_range(num_start..num_end, "$N");
398                }
399            }
400        }
401
402        if self.normalization >= StripLongTypeNameFiles {
403            let trimmed_line = line.trim_start();
404            let trimmed_line = trimmed_line
405                .strip_prefix("= note: ")
406                .unwrap_or(trimmed_line);
407            if trimmed_line.starts_with("the full type name has been written to")
408                || trimmed_line.starts_with("the full name for the type has been written to")
409            {
410                return None;
411            }
412        }
413
414        if self.normalization >= AndOthersVerbose {
415            let trim_start = line.trim_start();
416            if trim_start.starts_with("= help: the following types implement trait ")
417                || trim_start.starts_with("= help: the following other types implement trait ")
418            {
419                self.other_types = Some(0);
420            } else if let Some(count_other_types) = &mut self.other_types {
421                if indent >= 12 && trim_start != "and $N others" {
422                    *count_other_types += 1;
423                    if *count_other_types == 9 {
424                        if let Some(next) = self.all_lines.get(index + 1) {
425                            let next_trim_start = next.trim_start();
426                            let next_indent = next.len() - next_trim_start.len();
427                            if indent == next_indent {
428                                line.replace_range(indent - 2.., "and $N others");
429                            }
430                        }
431                    } else if *count_other_types > 9 {
432                        return None;
433                    }
434                } else {
435                    self.other_types = None;
436                }
437            }
438        }
439
440        line = line.replace(self.context.krate, "$CRATE");
441        line = replace_case_insensitive(&line, &self.context.source_dir.to_string_lossy(), "$DIR/");
442        line = replace_case_insensitive(
443            &line,
444            &self.context.workspace.to_string_lossy(),
445            "$WORKSPACE/",
446        );
447
448        Some(line)
449    }
450}
451
452fn is_ascii_lowercase_hex(s: &str) -> bool {
453    s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
454}
455
456// "10 | T: Send,"  ->  "   | T: Send,"
457fn hide_leading_numbers(line: &mut String) {
458    let n = line.bytes().take_while(u8::is_ascii_digit).count();
459    for i in 0..n {
460        line.replace_range(i..i + 1, " ");
461    }
462}
463
464// "main.rs:22:29"  ->  "main.rs"
465fn hide_trailing_numbers(line: &mut String) {
466    for _ in 0..2 {
467        let digits = line.bytes().rev().take_while(u8::is_ascii_digit).count();
468        if digits == 0 || !line[..line.len() - digits].ends_with(':') {
469            return;
470        }
471        line.truncate(line.len() - digits - 1);
472    }
473}
474
475fn replace_case_insensitive(line: &str, pattern: &str, replacement: &str) -> String {
476    let line_lower = line.to_ascii_lowercase().replace('\\', "/");
477    let pattern_lower = pattern.to_ascii_lowercase().replace('\\', "/");
478    let mut replaced = String::with_capacity(line.len());
479
480    let line_lower = line_lower.as_str();
481    let mut split = line_lower.split(&pattern_lower);
482    let mut pos = 0;
483    let mut insert_replacement = false;
484    while let Some(keep) = split.next() {
485        if insert_replacement {
486            replaced.push_str(replacement);
487            pos += pattern.len();
488        }
489        let mut keep = &line[pos..pos + keep.len()];
490        if insert_replacement {
491            let end_of_maybe_path = keep.find(&[' ', ':'][..]).unwrap_or(keep.len());
492            replaced.push_str(&keep[..end_of_maybe_path].replace('\\', "/"));
493            pos += end_of_maybe_path;
494            keep = &keep[end_of_maybe_path..];
495        }
496        replaced.push_str(keep);
497        pos += keep.len();
498        insert_replacement = true;
499        if replaced.ends_with(|ch: char| ch.is_ascii_alphanumeric()) {
500            if let Some(ch) = line[pos..].chars().next() {
501                replaced.push(ch);
502                pos += ch.len_utf8();
503                split = line_lower[pos..].split(&pattern_lower);
504                insert_replacement = false;
505            }
506        }
507    }
508
509    replaced
510}
511
512#[derive(PartialEq)]
513enum IndentedLineKind {
514    // `error`
515    // `warning`
516    Heading,
517
518    // Contains max number of spaces that can be cut based on this line.
519    // `   --> foo` = 2
520    // `    | foo` = 3
521    // `   ::: foo` = 2
522    // `10  | foo` = 1
523    Code(usize),
524
525    // `note:`
526    // `...`
527    Note,
528
529    // Contains number of leading spaces.
530    Other(usize),
531}
532
533fn unindent(diag: String, normalization: Normalization) -> String {
534    if normalization < Unindent {
535        return diag;
536    }
537
538    let mut normalized = String::new();
539    let mut lines = diag.lines();
540
541    while let Some(line) = lines.next() {
542        normalized.push_str(line);
543        normalized.push('\n');
544
545        if indented_line_kind(line, normalization) != IndentedLineKind::Heading {
546            continue;
547        }
548
549        let mut ahead = lines.clone();
550        let Some(next_line) = ahead.next() else {
551            continue;
552        };
553
554        if let IndentedLineKind::Code(indent) = indented_line_kind(next_line, normalization) {
555            if next_line[indent + 1..].starts_with("--> ") {
556                let mut lines_in_block = 1;
557                let mut least_indent = indent;
558                while let Some(line) = ahead.next() {
559                    match indented_line_kind(line, normalization) {
560                        IndentedLineKind::Heading => break,
561                        IndentedLineKind::Code(indent) => {
562                            lines_in_block += 1;
563                            least_indent = cmp::min(least_indent, indent);
564                        }
565                        IndentedLineKind::Note => lines_in_block += 1,
566                        IndentedLineKind::Other(spaces) => {
567                            if spaces > 10 {
568                                lines_in_block += 1;
569                            } else {
570                                break;
571                            }
572                        }
573                    }
574                }
575                for _ in 0..lines_in_block {
576                    let line = lines.next().unwrap();
577                    if let IndentedLineKind::Code(_) | IndentedLineKind::Other(_) =
578                        indented_line_kind(line, normalization)
579                    {
580                        let space = line.find(' ').unwrap();
581                        normalized.push_str(&line[..space]);
582                        normalized.push_str(&line[space + least_indent..]);
583                    } else {
584                        normalized.push_str(line);
585                    }
586                    normalized.push('\n');
587                }
588            }
589        }
590    }
591
592    normalized
593}
594
595fn indented_line_kind(line: &str, normalization: Normalization) -> IndentedLineKind {
596    if let Some(heading_len) = if line.starts_with("error") {
597        Some("error".len())
598    } else if line.starts_with("warning") {
599        Some("warning".len())
600    } else {
601        None
602    } {
603        if line[heading_len..].starts_with(&[':', '['][..]) {
604            return IndentedLineKind::Heading;
605        }
606    }
607
608    if line.starts_with("note:")
609        || line == "..."
610        || normalization >= UnindentAfterHelp && line.starts_with("help:")
611    {
612        return IndentedLineKind::Note;
613    }
614
615    let is_space = |b: &u8| *b == b' ';
616    if let Some(rest) = line.strip_prefix("... ") {
617        let spaces = rest.bytes().take_while(is_space).count();
618        return IndentedLineKind::Code(spaces);
619    }
620
621    let digits = line.bytes().take_while(u8::is_ascii_digit).count();
622    let spaces = line[digits..].bytes().take_while(|b| *b == b' ').count();
623    let rest = &line[digits + spaces..];
624    if spaces > 0
625        && (rest == "|"
626            || rest.starts_with("| ")
627            || digits == 0
628                && (rest.starts_with("--> ") || rest.starts_with("::: ") || rest.starts_with("= ")))
629    {
630        return IndentedLineKind::Code(spaces - 1);
631    }
632
633    IndentedLineKind::Other(if digits == 0 { spaces } else { 0 })
634}