1
/*
2
 * Hurl (https://hurl.dev)
3
 * Copyright (C) 2024 Orange
4
 *
5
 * Licensed under the Apache License, Version 2.0 (the "License");
6
 * you may not use this file except in compliance with the License.
7
 * You may obtain a copy of the License at
8
 *
9
 *          http://www.apache.org/licenses/LICENSE-2.0
10
 *
11
 * Unless required by applicable law or agreed to in writing, software
12
 * distributed under the License is distributed on an "AS IS" BASIS,
13
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
 * See the License for the specific language governing permissions and
15
 * limitations under the License.
16
 *
17
 */
18
use std::cmp::max;
19

            
20
use crate::ast::SourceInfo;
21
use crate::text::{Format, Style, StyledString};
22

            
23
pub trait DisplaySourceError {
24
    fn source_info(&self) -> SourceInfo;
25
    fn description(&self) -> String;
26
    fn fixme(&self, content: &[&str]) -> StyledString;
27

            
28
    /// Returns the constructed message for the error
29
    ///
30
    /// It may include:
31
    ///
32
    /// - source line
33
    /// - column position and number of characters (with one or more carets)
34
    ///
35
    /// Examples:
36
    ///
37
    /// ```text
38
    /// GET abc
39
    ///     ^ expecting http://, https:// or {{
40
    /// ```
41
    ///
42
    /// ```text
43
    /// HTTP/1.0 200
44
    ///          ^^^ actual value is <404>
45
    /// ```
46
    ///
47
    /// ```text
48
    /// jsonpath "$.count" >= 5
49
    ///   actual:   int <2>
50
    ///   expected: greater than int <5>
51
    /// ```
52
    ///
53
    /// ```text
54
    /// {
55
    ///    "name": "John",
56
    ///-   "age": 27
57
    ///+   "age": 28
58
    /// }
59
    /// ```
60
150
    fn message(&self, content: &[&str]) -> StyledString {
61
150
        let mut text = StyledString::new();
62
150
        add_source_line(&mut text, content, self.source_info().start.line);
63
150
        text.append(self.fixme(content));
64
150

            
65
150
        let error_line = self.source_info().start.line;
66
150
        add_line_info_prefix(&text, content, error_line)
67
    }
68

            
69
    /// Returns the string representation of an `error`, given `lines` of content and a `filename`.
70
    ///
71
    /// The source information where the error occurred can be retrieved in `error`; optionally,
72
    /// `entry_src_info` is the optional source information for the entry where the error happened.
73
    /// If `colored` is true, the string use ANSI escape codes to add color and improve the readability
74
    /// of the representation.
75
    ///
76
    /// Example:
77
    ///
78
    ///
79
    ///
80
    /// ```text
81
    /// Assert status code                        | > description()
82
    ///  --> test.hurl:2:10                       | > add_filename_with_sourceinfo()
83
    ///   |                                       |
84
    ///   | GET http://foo.com                    | > add_entry_line()
85
    ///   | ...                                   |
86
    /// 2 | HTTP/1.0 200                          | > message()
87
    ///   |          ^^^ actual value is <404>    | >
88
    ///   |                                       |
89
    /// ```
90
2931
    fn to_string(
91
2931
        &self,
92
2931
        filename: &str,
93
2931
        content: &str,
94
2931
        entry_src_info: Option<SourceInfo>,
95
2931
        format: OutputFormat,
96
2931
    ) -> String {
97
2931
        let mut text = StyledString::new();
98
2931
        let lines = content.lines().collect::<Vec<_>>();
99
2931

            
100
2931
        let error_line = self.source_info().start.line;
101
2931
        let error_column = self.source_info().start.column;
102
2931

            
103
2931
        // Push one-line description of the error
104
2931
        text.push_with(&self.description(), Style::new().bold());
105
2931
        text.push("\n");
106
2931

            
107
2931
        // We build the prefix
108
2931
        let loc_max_width = max(lines.len().to_string().len(), 2);
109
2931
        let spaces = " ".repeat(loc_max_width);
110
2931
        let separator = "|";
111
2931
        let mut prefix = StyledString::new();
112
2931
        prefix.push_with(&format!("{spaces} {separator}"), Style::new().blue().bold());
113
2931

            
114
2931
        // Add filename, with a left margin space for error line number.
115
2931
        add_filename_with_sourceinfo(&mut text, &spaces, filename, error_line, error_column);
116
2931

            
117
2931
        // First line of the error message
118
2931
        text.append(prefix.clone());
119
2931

            
120
2931
        // We can have an optional source line for context
121
3858
        let entry_line = entry_src_info.map(|e| e.start.line);
122
2931
        if let Some(entry_line) = entry_line {
123
2781
            add_entry_line(&mut text, &lines, error_line, entry_line, &prefix);
124
        }
125

            
126
2931
        text.append(self.message(&lines));
127
2931
        format_error_message(&text, format)
128
    }
129
}
130

            
131
/// Show column position with carets
132
2765
pub fn add_carets(message: &str, source_info: SourceInfo, content: &[&str]) -> String {
133
2765
    let error_line = source_info.start.line;
134
2765
    let error_column = source_info.start.column;
135
    // Error source info start and end can be on different lines, we insure a minimum width.
136
2765
    let width = if source_info.end.column > error_column {
137
2450
        source_info.end.column - error_column
138
    } else {
139
315
        1
140
    };
141
2765
    let line_raw = content.get(error_line - 1).unwrap();
142
2765
    let prefix = get_carets(line_raw, error_column, width);
143
2765

            
144
2765
    let mut s = String::new();
145
2765
    for (i, line) in message.lines().enumerate() {
146
2765
        if i == 0 {
147
2765
            s.push_str(format!("{prefix}{line}").as_str());
148
2765
        } else {
149
            s.push('\n');
150
            if !line.is_empty() {
151
                s.push_str(" ".repeat(prefix.len()).as_str());
152
            }
153
            s.push_str(line);
154
        };
155
    }
156
2765
    s
157
}
158

            
159
/// Format used by to_string
160
#[derive(Clone, Debug, PartialEq, Eq)]
161
pub enum OutputFormat {
162
    Plain,
163
    Terminal(bool), // Replace \r\n by \n
164
}
165

            
166
4800
pub fn add_line_info_prefix(
167
4800
    text: &StyledString,
168
4800
    content: &[&str],
169
4800
    error_line: usize,
170
4800
) -> StyledString {
171
4800
    let text = text.clone();
172
4800
    let separator = "|";
173
4800

            
174
4800
    let loc_max_width = max(content.len().to_string().len(), 2);
175
4800
    let spaces = " ".repeat(loc_max_width);
176
4800
    let mut prefix = StyledString::new();
177
4800
    prefix.push_with(
178
4800
        format!("{spaces} {separator}").as_str(),
179
4800
        Style::new().blue().bold(),
180
4800
    );
181
4800
    let mut prefix_with_number = StyledString::new();
182
4800
    prefix_with_number.push_with(
183
4800
        format!("{error_line:>loc_max_width$} {separator}").as_str(),
184
4800
        Style::new().blue().bold(),
185
4800
    );
186
4800

            
187
4800
    let mut text2 = StyledString::new();
188
11980
    for (i, line) in text.split('\n').iter().enumerate() {
189
11980
        text2.push("\n");
190
11980
        text2.append(if i == 0 {
191
4800
            prefix_with_number.clone()
192
        } else {
193
7180
            prefix.clone()
194
        });
195
11980
        text2.append(line.clone());
196
    }
197

            
198
    //  Appends additional empty line
199
4800
    if !text2.ends_with("|") {
200
4800
        text2.push("\n");
201
4800
        text2.append(prefix.clone());
202
    }
203

            
204
4800
    text2
205
}
206

            
207
/// Generate carets for the given source_line/source_info
208
2765
fn get_carets(line_raw: &str, error_column: usize, width: usize) -> String {
209
2765
    //  We take tabs into account because we have normalize the display of the error line by replacing
210
2765
    //  tabs with 4 spaces.
211
2765
    let mut tab_shift = 0;
212
29260
    for (i, c) in line_raw.chars().enumerate() {
213
29260
        if i >= (error_column - 1) {
214
2735
            break;
215
26525
        };
216
26525
        if c == '\t' {
217
10
            tab_shift += 1;
218
        }
219
    }
220

            
221
2765
    let mut prefix = " ".repeat(error_column + tab_shift * 3);
222
2765
    prefix.push_str("^".repeat(width).as_str());
223
2765
    prefix.push(' ');
224
2765
    prefix
225
}
226

            
227
4800
pub fn add_source_line(text: &mut StyledString, content: &[&str], line: usize) {
228
4800
    let line = content.get(line - 1).unwrap();
229
4800
    let line = line.replace('\t', "    ");
230
4800
    text.push(" ");
231
4800
    text.push(&line);
232
4800
    text.push("\n");
233
}
234

            
235
4885
fn add_filename_with_sourceinfo(
236
4885
    text: &mut StyledString,
237
4885
    spaces: &str,
238
4885
    filename: &str,
239
4885
    error_line: usize,
240
4885
    error_column: usize,
241
4885
) {
242
4885
    text.push(spaces);
243
4885
    text.push_with("-->", Style::new().blue().bold());
244
4885
    text.push(format!(" {filename}:{error_line}:{error_column}").as_str());
245
4885
    text.push("\n");
246
}
247

            
248
4635
fn add_entry_line(
249
4635
    text: &mut StyledString,
250
4635
    lines: &[&str],
251
4635
    error_line: usize,
252
4635
    entry_line: usize,
253
4635
    prefix: &StyledString,
254
4635
) {
255
4635
    if entry_line != error_line {
256
2560
        let line = lines.get(entry_line - 1).unwrap();
257
2560
        let line = line.replace('\t', "    ");
258
2560
        text.push("\n");
259
2560
        text.append(prefix.clone());
260
2560
        text.push(" ");
261
2560
        text.push_with(&line, Style::new().bright_black());
262
    }
263

            
264
4635
    if error_line - entry_line > 1 {
265
2410
        text.push("\n");
266
2410
        text.append(prefix.clone());
267
2410
        text.push_with(" ...", Style::new().bright_black());
268
    }
269
}
270

            
271
4885
fn format_error_message(message: &StyledString, format: OutputFormat) -> String {
272
4885
    let colored = format == OutputFormat::Terminal(true);
273
4885
    let message = if colored {
274
210
        message.to_string(Format::Ansi)
275
    } else {
276
4675
        message.to_string(Format::Plain)
277
    };
278

            
279
4885
    match format {
280
        OutputFormat::Terminal(_) => {
281
4775
            message.replace("\r\n", "\n") // CRLF must be replaced by LF in the terminal
282
        }
283
110
        OutputFormat::Plain => message,
284
    }
285
}
286

            
287
#[cfg(test)]
288
mod tests {
289
    use super::*;
290
    use crate::reader::Pos;
291
    use crate::text::{Format, Style};
292

            
293
    #[test]
294
    fn test_add_carets() {
295
        // `Hello World`
296
        // ^^^^^^^^^^^^^ actual value is <Hello World!>
297
        assert_eq!(
298
            add_carets(
299
                "actual value is <Hello World!>",
300
                SourceInfo::new(Pos::new(1, 1), Pos::new(1, 14)),
301
                &["`Hello World`"]
302
            ),
303
            " ^^^^^^^^^^^^^ actual value is <Hello World!>".to_string()
304
        );
305
    }
306

            
307
    #[test]
308
    fn test_get_carets() {
309
        // `Hello World`
310
        // ^^^^^^^^^^^^^ actual value is <Hello World!>
311
        assert_eq!(
312
            get_carets("`Hello World`", 1, 13),
313
            " ^^^^^^^^^^^^^ ".to_string()
314
        );
315

            
316
        // Content-Length: 200
317
        //                 ^^^ actual value is <12>
318
        assert_eq!(
319
            get_carets("Content-Length: 200", 17, 3),
320
            "                 ^^^ ".to_string()
321
        );
322

            
323
        // With a tab instead of a space
324
        // Content-Length:    200
325
        //                    ^^^ actual value is <12>
326
        assert_eq!(
327
            get_carets("Content-Length:\t200", 17, 3),
328
            "                    ^^^ ".to_string()
329
        );
330
    }
331

            
332
    #[test]
333
    fn test_diff_error() {
334
        crate::text::init_crate_colored();
335

            
336
        let content = r#"GET http://localhost:8000/failed/multiline/json
337
HTTP 200
338
```
339
{
340
  "name": "John",
341
  "age": 27
342
}
343
```
344
"#;
345
        let filename = "test.hurl";
346
        struct E;
347
        impl DisplaySourceError for E {
348
            fn source_info(&self) -> SourceInfo {
349
                SourceInfo::new(Pos::new(4, 1), Pos::new(4, 0))
350
            }
351

            
352
            fn description(&self) -> String {
353
                "Assert body value".to_string()
354
            }
355

            
356
            fn fixme(&self, _lines: &[&str]) -> StyledString {
357
                let mut diff = StyledString::new();
358
                diff.push(" {\n   \"name\": \"John\",\n");
359
                diff.push_with("-  \"age\": 27", Style::new().red());
360
                diff.push("\n");
361
                diff.push_with("+  \"age\": 28", Style::new().green());
362
                diff.push("\n }\n");
363
                diff
364
            }
365
            fn message(&self, lines: &[&str]) -> StyledString {
366
                let s = self.fixme(lines);
367
                add_line_info_prefix(&s, &[], 4)
368
            }
369
        }
370
        let error = E;
371

            
372
        let lines = content.lines().collect::<Vec<_>>();
373
        assert_eq!(
374
            error.message(&lines).to_string(Format::Plain),
375
            r#"
376
 4 | {
377
   |   "name": "John",
378
   |-  "age": 27
379
   |+  "age": 28
380
   | }
381
   |"#
382
        );
383
        assert_eq!(
384
            error.message(&lines).to_string(Format::Ansi),
385
            "\n\u{1b}[1;34m 4 |\u{1b}[0m {\n\u{1b}[1;34m   |\u{1b}[0m   \"name\": \"John\",\n\u{1b}[1;34m   |\u{1b}[0m\u{1b}[31m-  \"age\": 27\u{1b}[0m\n\u{1b}[1;34m   |\u{1b}[0m\u{1b}[32m+  \"age\": 28\u{1b}[0m\n\u{1b}[1;34m   |\u{1b}[0m }\n\u{1b}[1;34m   |\u{1b}[0m"
386
        );
387

            
388
        assert_eq!(
389
            error.to_string(filename, content, None, OutputFormat::Terminal(false)),
390
            r#"Assert body value
391
  --> test.hurl:4:1
392
   |
393
 4 | {
394
   |   "name": "John",
395
   |-  "age": 27
396
   |+  "age": 28
397
   | }
398
   |"#
399
        );
400
    }
401
}