Skip to main content

redos_detector/
to_friendly.rs

1//! Renders a result as human-readable text.
2//!
3//! Do not parse this output. It may change between versions. Column widths are
4//! measured in UTF-16 code units to match the engine the analysis targets, and
5//! breadcrumbs are joined with the arrow `→`.
6
7use crate::{AnalysisLimit, Report, Score, TrailEntrySide};
8
9const TRUNCATE_LENGTH: usize = 100;
10
11/// Default number of trails to print.
12pub const DEFAULT_RESULTS_LIMIT: u64 = 15;
13
14/// Options for [`to_friendly`].
15#[derive(Clone, Debug)]
16pub struct ToFriendlyConfig {
17    /// The maximum number of trails to print. `None` prints every trail.
18    pub results_limit: Option<u64>,
19    /// Always include trails even when the pattern is safe.
20    pub always_include_trails: bool,
21}
22
23impl Default for ToFriendlyConfig {
24    fn default() -> Self {
25        ToFriendlyConfig {
26            results_limit: Some(DEFAULT_RESULTS_LIMIT),
27            always_include_trails: false,
28        }
29    }
30}
31
32fn utf16_len(s: &str) -> usize {
33    s.encode_utf16().count()
34}
35
36fn pad_start(s: &str, width: usize) -> String {
37    let len = utf16_len(s);
38    if len >= width {
39        s.to_string()
40    } else {
41        format!("{}{}", " ".repeat(width - len), s)
42    }
43}
44
45fn pad_end(s: &str, width: usize) -> String {
46    let len = utf16_len(s);
47    if len >= width {
48        s.to_string()
49    } else {
50        format!("{}{}", s, " ".repeat(width - len))
51    }
52}
53
54fn get_breadcrumbs(side: &TrailEntrySide) -> String {
55    let mut parts: Vec<String> = side
56        .backreference_stack
57        .iter()
58        .map(|b| b.node.start.offset.to_string())
59        .collect();
60    parts.reverse();
61    parts.push(side.node.start.offset.to_string());
62    parts.join("\u{2192}")
63}
64
65fn score_string(score: Score) -> String {
66    match score {
67        Score::Infinite => "There could be infinite backtracks.".to_string(),
68        Score::Finite(value) => format!("Score: {}", value),
69    }
70}
71
72/// Renders `result` as text.
73///
74/// `results_limit` of `None` prints every trail.
75pub fn to_friendly(result: &Report, config: &ToFriendlyConfig) -> String {
76    let results_limit = config.results_limit;
77    let score_str = score_string(result.score);
78
79    if result.is_safe() && !config.always_include_trails {
80        return format!("Regex is safe. {}", score_str);
81    }
82
83    let mut output_lines: Vec<String> = Vec::new();
84
85    if result.pattern_downgraded {
86        output_lines.push(format!("Pattern was downgraded to `{}`.", result.pattern));
87    }
88
89    if result.trails.is_empty() {
90        let mut parts: Vec<String> = Vec::new();
91        parts.push(if result.is_safe() {
92            format!("Regex is safe. {}", score_str)
93        } else {
94            format!("Regex may not be safe. {}", score_str)
95        });
96        if result.error == Some(AnalysisLimit::TimedOut) {
97            parts.push("Timed out.".to_string());
98        }
99        if result.error == Some(AnalysisLimit::HitMaxSteps) {
100            parts.push("Reached steps limit.".to_string());
101        }
102        if !result.is_safe() {
103            parts.push("The pattern may have too many variations.".to_string());
104        }
105        output_lines.push(parts.join(" "));
106    } else {
107        let limit = results_limit.map_or(result.trails.len(), |n| n as usize);
108        let result_blocks: Vec<String> =
109            result.trails.iter().take(limit).map(render_block).collect();
110
111        output_lines.push(format!(
112            "Regex is {}safe. {}",
113            if !result.is_safe() { "not " } else { "" },
114            score_str
115        ));
116
117        if results_limit != Some(0) {
118            output_lines.push(String::new());
119            let plural = if result.trails.len() > 1 { "s" } else { "" };
120            let singular = if result.trails.len() == 1 { "s" } else { "" };
121            output_lines.push(format!(
122                "The following trail{} show{} how the same input can be matched multiple ways.",
123                plural, singular
124            ));
125            output_lines.extend(result_blocks);
126            output_lines.push(String::new());
127            if let Some(error) = result.error {
128                output_lines.push(error_message(error).to_string());
129            }
130            if results_limit.is_some_and(|n| result.trails.len() as u64 > n) {
131                output_lines
132                    .push("There are more results than this but hit results limit.".to_string());
133            }
134        }
135    }
136
137    output_lines.join("\n")
138}
139
140fn render_block(trail: &crate::Trail) -> String {
141    let row_contents: Vec<[String; 4]> = trail
142        .entries
143        .iter()
144        .take(TRUNCATE_LENGTH)
145        .map(|entry| {
146            [
147                get_breadcrumbs(&entry.a),
148                format!("`{}`", entry.a.node.source),
149                get_breadcrumbs(&entry.b),
150                format!("`{}`", entry.b.node.source),
151            ]
152        })
153        .collect();
154
155    let max_col1 = row_contents
156        .iter()
157        .map(|c| utf16_len(&c[0]))
158        .max()
159        .unwrap_or(0);
160    let max_col2 = row_contents
161        .iter()
162        .map(|c| utf16_len(&c[1]))
163        .max()
164        .unwrap_or(0);
165    let max_col3 = row_contents
166        .iter()
167        .map(|c| utf16_len(&c[2]))
168        .max()
169        .unwrap_or(0);
170
171    let mut rows: Vec<String> = row_contents
172        .iter()
173        .map(|c| {
174            format!(
175                "{}: {} | {}: {}",
176                pad_start(&c[0], max_col1),
177                pad_end(&c[1], max_col2),
178                pad_start(&c[2], max_col3),
179                c[3]
180            )
181        })
182        .collect();
183
184    if trail.entries.len() > TRUNCATE_LENGTH {
185        rows.push("\u{2026}".to_string());
186    }
187
188    let max_row_length = rows.iter().map(|r| utf16_len(r)).max().unwrap_or(0);
189    rows.push("=".repeat(max_row_length));
190
191    rows.join("\n")
192}
193
194fn error_message(error: AnalysisLimit) -> &'static str {
195    match error {
196        AnalysisLimit::HitMaxScore => {
197            "Hit the max score so there may be more results than shown here."
198        }
199        AnalysisLimit::HitMaxSteps => {
200            "Hit maximum number of steps so there may be more results than shown here."
201        }
202        AnalysisLimit::TimedOut => "Timed out so there may be more results than shown here.",
203    }
204}