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

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

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

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

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

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

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

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

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

            
144
3225
    let mut s = String::new();
145
3225
    for (i, line) in message.lines().enumerate() {
146
3225
        if i == 0 {
147
3225
            s.push_str(format!("{prefix}{line}").as_str());
148
3225
        } 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
3225
    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
5620
pub fn add_line_info_prefix(
167
5620
    text: &StyledString,
168
5620
    content: &[&str],
169
5620
    error_line: usize,
170
5620
) -> StyledString {
171
5620
    let text = text.clone();
172
5620
    let separator = "|";
173
5620

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

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

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

            
204
5620
    text2
205
}
206

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

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

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

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

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

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

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

            
279
5705
    match format {
280
        OutputFormat::Terminal(_) => {
281
5595
            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
        // For the crate colored to output ANSI escape code in test environment.
335
        crate::text::init_crate_colored();
336

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

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

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

            
373
        let lines = content.lines().collect::<Vec<_>>();
374
        assert_eq!(
375
            error.message(&lines).to_string(Format::Plain),
376
            r#"
377
 4 | {
378
   |   "name": "John",
379
   |-  "age": 27
380
   |+  "age": 28
381
   | }
382
   |"#
383
        );
384
        assert_eq!(
385
            error.message(&lines).to_string(Format::Ansi),
386
            "\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"
387
        );
388

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