1
/*
2
 * Hurl (https://hurl.dev)
3
 * Copyright (C) 2025 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:   integer <2>
50
    ///   expected: greater than integer <5>
51
    /// ```
52
    ///
53
    /// ```text
54
    /// {
55
    ///    "name": "John",
56
    ///-   "age": 27
57
    ///+   "age": 28
58
    /// }
59
    /// ```
60
153
    fn message(&self, content: &[&str]) -> StyledString {
61
153
        let mut text = StyledString::new();
62
153
        add_source_line(&mut text, content, self.source_info().start.line);
63
153
        text.append(self.fixme(content));
64

            
65
153
        let error_line = self.source_info().start.line;
66
153
        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
3627
    fn render(
91
3627
        &self,
92
3627
        filename: &str,
93
3627
        content: &str,
94
3627
        entry_src_info: Option<SourceInfo>,
95
3627
        format: OutputFormat,
96
3627
    ) -> String {
97
3627
        let mut text = StyledString::new();
98
3627
        let lines = content.lines().collect::<Vec<_>>();
99

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

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

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

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

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

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

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

            
131
/// Show column position with carets
132
3505
pub fn add_carets(message: &str, source_info: SourceInfo, content: &[&str]) -> String {
133
3505
    let error_line = source_info.start.line;
134
3505
    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
3505
    let width = if source_info.end.column > error_column {
137
3140
        source_info.end.column - error_column
138
    } else {
139
365
        1
140
    };
141
3505
    let line_raw = if let Some(value) = content.get(error_line - 1) {
142
3505
        value
143
    } else {
144
        ""
145
    };
146
3505
    let prefix = get_carets(line_raw, error_column, width);
147

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

            
163
/// Format used by to_string
164
#[derive(Clone, Debug, PartialEq, Eq)]
165
pub enum OutputFormat {
166
    Plain,
167
    Terminal(bool), // Replace \r\n by \n
168
}
169

            
170
5960
pub fn add_line_info_prefix(
171
5960
    text: &StyledString,
172
5960
    content: &[&str],
173
5960
    error_line: usize,
174
5960
) -> StyledString {
175
5960
    let text = text.clone();
176
5960
    let separator = "|";
177

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

            
191
5960
    let mut text2 = StyledString::new();
192
14715
    for (i, line) in text.split('\n').iter().enumerate() {
193
14715
        text2.push("\n");
194
14715
        text2.append(if i == 0 {
195
5960
            prefix_with_number.clone()
196
        } else {
197
8755
            prefix.clone()
198
        });
199
14715
        text2.append(line.clone());
200
    }
201

            
202
    //  Appends additional empty line
203
5960
    if !text2.ends_with("|") {
204
5960
        text2.push("\n");
205
5960
        text2.append(prefix.clone());
206
    }
207

            
208
5960
    text2
209
}
210

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

            
225
3505
    let mut prefix = " ".repeat(error_column + tab_shift * 3);
226
3505
    prefix.push_str("^".repeat(width).as_str());
227
3505
    prefix.push(' ');
228
3505
    prefix
229
}
230

            
231
5960
pub fn add_source_line(text: &mut StyledString, content: &[&str], line: usize) {
232
5960
    let line = if let Some(value) = content.get(line - 1) {
233
5960
        value
234
    } else {
235
        ""
236
    };
237
5960
    let line = line.replace('\t', "    ");
238
5960
    text.push(" ");
239
5960
    text.push(&line);
240
5960
    text.push("\n");
241
}
242

            
243
6045
fn add_filename_with_sourceinfo(
244
6045
    text: &mut StyledString,
245
6045
    spaces: &str,
246
6045
    filename: &str,
247
6045
    error_line: usize,
248
6045
    error_column: usize,
249
6045
) {
250
6045
    text.push(spaces);
251
6045
    text.push_with("-->", Style::new().blue().bold());
252
6045
    text.push(format!(" {filename}:{error_line}:{error_column}").as_str());
253
6045
    text.push("\n");
254
}
255

            
256
5790
fn add_entry_line(
257
5790
    text: &mut StyledString,
258
5790
    lines: &[&str],
259
5790
    error_line: usize,
260
5790
    entry_line: usize,
261
5790
    prefix: &StyledString,
262
5790
) {
263
5790
    if entry_line != error_line {
264
5535
        let line = lines.get(entry_line - 1).unwrap();
265
5535
        let line = line.replace('\t', "    ");
266
5535
        text.push("\n");
267
5535
        text.append(prefix.clone());
268
5535
        text.push(" ");
269
5535
        text.push_with(&line, Style::new().bright_black());
270
    }
271

            
272
5790
    if error_line - entry_line > 1 {
273
5350
        text.push("\n");
274
5350
        text.append(prefix.clone());
275
5350
        text.push_with(" ...", Style::new().bright_black());
276
    }
277
}
278

            
279
6045
fn format_error_message(message: &StyledString, format: OutputFormat) -> String {
280
6045
    let colored = format == OutputFormat::Terminal(true);
281
6045
    let message = if colored {
282
210
        message.to_string(Format::Ansi)
283
    } else {
284
5835
        message.to_string(Format::Plain)
285
    };
286

            
287
6045
    match format {
288
        OutputFormat::Terminal(_) => {
289
5935
            message.replace("\r\n", "\n") // CRLF must be replaced by LF in the terminal
290
        }
291
110
        OutputFormat::Plain => message,
292
    }
293
}
294

            
295
#[cfg(test)]
296
mod tests {
297
    use super::*;
298
    use crate::reader::Pos;
299
    use crate::text::{Format, Style};
300

            
301
    #[test]
302
    fn test_add_carets() {
303
        // `Hello World`
304
        // ^^^^^^^^^^^^^ actual value is <Hello World!>
305
        assert_eq!(
306
            add_carets(
307
                "actual value is <Hello World!>",
308
                SourceInfo::new(Pos::new(1, 1), Pos::new(1, 14)),
309
                &["`Hello World`"]
310
            ),
311
            " ^^^^^^^^^^^^^ actual value is <Hello World!>".to_string()
312
        );
313

            
314
        // end of file missing ```
315
        let content = [
316
            "POST https://fake.com",
317
            "```",
318
            "{ \"test\": true}",
319
            "",
320
            "HTTP 200",
321
        ];
322
        assert_eq!(
323
            add_carets(
324
                "expecting '```'",
325
                SourceInfo::new(Pos::new(6, 1), Pos::new(6, 1)),
326
                &content
327
            ),
328
            " ^ expecting '```'".to_string()
329
        );
330
    }
331

            
332
    #[test]
333
    fn test_add_source_line() {
334
        let mut text = StyledString::new();
335
        let content = ["Invalid method", "get http://localhost"];
336
        add_source_line(&mut text, &content, 2);
337
        eprintln!("{:?}", text);
338
        assert_eq!(text.to_string(Format::Plain), " get http://localhost\n");
339

            
340
        let mut text = StyledString::new();
341
        add_source_line(&mut text, &content, 6);
342
        eprintln!("{:?}", text);
343
        assert_eq!(text.to_string(Format::Plain), " \n");
344
    }
345

            
346
    #[test]
347
    fn test_get_carets() {
348
        // `Hello World`
349
        // ^^^^^^^^^^^^^ actual value is <Hello World!>
350
        assert_eq!(
351
            get_carets("`Hello World`", 1, 13),
352
            " ^^^^^^^^^^^^^ ".to_string()
353
        );
354

            
355
        // Content-Length: 200
356
        //                 ^^^ actual value is <12>
357
        assert_eq!(
358
            get_carets("Content-Length: 200", 17, 3),
359
            "                 ^^^ ".to_string()
360
        );
361

            
362
        // With a tab instead of a space
363
        // Content-Length:    200
364
        //                    ^^^ actual value is <12>
365
        assert_eq!(
366
            get_carets("Content-Length:\t200", 17, 3),
367
            "                    ^^^ ".to_string()
368
        );
369
    }
370

            
371
    #[test]
372
    fn test_diff_error() {
373
        // For the crate colored to output ANSI escape code in test environment.
374
        crate::text::init_crate_colored();
375

            
376
        let content = r#"GET http://localhost:8000/failed/multiline/json
377
HTTP 200
378
```
379
{
380
  "name": "John",
381
  "age": 27
382
}
383
```
384
"#;
385
        let filename = "test.hurl";
386
        struct E;
387
        impl DisplaySourceError for E {
388
            fn source_info(&self) -> SourceInfo {
389
                SourceInfo::new(Pos::new(4, 1), Pos::new(4, 0))
390
            }
391

            
392
            fn description(&self) -> String {
393
                "Assert body value".to_string()
394
            }
395

            
396
            fn fixme(&self, _lines: &[&str]) -> StyledString {
397
                let mut diff = StyledString::new();
398
                diff.push(" {\n   \"name\": \"John\",\n");
399
                diff.push_with("-  \"age\": 27", Style::new().red());
400
                diff.push("\n");
401
                diff.push_with("+  \"age\": 28", Style::new().green());
402
                diff.push("\n }\n");
403
                diff
404
            }
405
            fn message(&self, lines: &[&str]) -> StyledString {
406
                let s = self.fixme(lines);
407
                add_line_info_prefix(&s, &[], 4)
408
            }
409
        }
410
        let error = E;
411

            
412
        let lines = content.lines().collect::<Vec<_>>();
413
        assert_eq!(
414
            error.message(&lines).to_string(Format::Plain),
415
            r#"
416
 4 | {
417
   |   "name": "John",
418
   |-  "age": 27
419
   |+  "age": 28
420
   | }
421
   |"#
422
        );
423
        assert_eq!(
424
            error.message(&lines).to_string(Format::Ansi),
425
            "\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"
426
        );
427

            
428
        assert_eq!(
429
            error.render(filename, content, None, OutputFormat::Terminal(false)),
430
            r#"Assert body value
431
  --> test.hurl:4:1
432
   |
433
 4 | {
434
   |   "name": "John",
435
   |-  "age": 27
436
   |+  "age": 28
437
   | }
438
   |"#
439
        );
440
    }
441
}