1
/*
2
 * Hurl (https://hurl.dev)
3
 * Copyright (C) 2026 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 crate::ast::visit::Visitor;
19
use crate::ast::{
20
    Comment, Entry, FilterValue, JsonValue, Method, Placeholder, Regex, Request, Response,
21
    Template, U64, Whitespace, visit,
22
};
23
use crate::ast::{
24
    CookiePath, HurlFile, MultilineString, Number, PredicateFuncValue, QueryValue, StatusValue,
25
    VersionValue,
26
};
27
use crate::types::{DurationUnit, SourceString, ToSource};
28

            
29
/// Returns an HTML string of the Hurl file `hurl_file`.
30
///
31
/// If `standalone` is true, a complete HTML body with inline styling is returned.
32
/// Otherwise, a `<pre>` HTML tag is returned, without styling.
33
480
pub fn format(file: &HurlFile, standalone: bool) -> String {
34
480
    let mut fmt = HtmlFormatter::new();
35
480
    let body = fmt.format(file);
36
480
    if standalone {
37
4
        let css = include_str!("hurl.css");
38
4
        format!(
39
            r#"<!DOCTYPE html>
40
<html>
41
    <head>
42
        <meta charset="utf-8">
43
        <title>Hurl File</title>
44
        <style>
45
{css}
46
        </style>
47
    </head>
48
    <body>
49
{body}
50
    </body>
51
</html>
52
"#
53
        )
54
    } else {
55
476
        body.to_string()
56
    }
57
}
58

            
59
400
pub fn hurl_css() -> String {
60
400
    include_str!("hurl.css").to_string()
61
}
62

            
63
/// A HTML formatter for Hurl content.
64
struct HtmlFormatter {
65
    buffer: String,
66
}
67

            
68
const HURL_BASE64_VALUE_CLASS: &str = "base64";
69
const HURL_BOOLEAN_CLASS: &str = "boolean";
70
const HURL_COMMENT_CLASS: &str = "comment";
71
const HURL_DURATION_UNIT: &str = "unit";
72
const HURL_ENTRY_CLASS: &str = "entry";
73
const HURL_HEX_CLASS: &str = "hex";
74
const HURL_FILENAME_CLASS: &str = "filename";
75
const HURL_FILTER_KIND_CLASS: &str = "filter-type";
76
const HURL_JSON_CLASS: &str = "json";
77
const HURL_LANG_CLASS: &str = "language-hurl";
78
const HURL_METHOD_CLASS: &str = "method";
79
const HURL_MULTILINESTRING_CLASS: &str = "multiline";
80
const HURL_NULL_CLASS: &str = "null";
81
const HURL_NUMBER_CLASS: &str = "number";
82
const HURL_NOT_CLASS: &str = "not";
83
const HURL_PLACEHOLDER_CLASS: &str = "expr";
84
const HURL_PREDICATE_TYPE_CLASS: &str = "predicate-type";
85
const HURL_QUERY_TYPE_CLASS: &str = "query-type";
86
const HURL_REGEX_CLASS: &str = "regex";
87
const HURL_REQUEST_CLASS: &str = "request";
88
const HURL_RESPONSE_CLASS: &str = "response";
89
const HURL_SECTION_HEADER_CLASS: &str = "section-header";
90
const HURL_STRING_CLASS: &str = "string";
91
const HURL_URL_CLASS: &str = "url";
92
const HURL_VERSION_CLASS: &str = "version";
93
const HURL_XML_CLASS: &str = "xml";
94

            
95
impl HtmlFormatter {
96
    /// Creates a new HTML formatter.
97
480
    fn new() -> Self {
98
480
        HtmlFormatter {
99
480
            buffer: String::new(),
100
        }
101
    }
102

            
103
480
    fn format(&mut self, file: &HurlFile) -> &str {
104
480
        self.buffer.clear();
105
480
        self.visit_hurl_file(file);
106
480
        &self.buffer
107
    }
108

            
109
480
    fn pre_open(&mut self, class: &'static str) {
110
480
        self.buffer.push_str("<pre><code class=\"");
111
480
        self.buffer.push_str(class);
112
480
        self.buffer.push_str("\">");
113
    }
114

            
115
480
    fn pre_close(&mut self) {
116
480
        self.buffer.push_str("</code></pre>");
117
    }
118

            
119
29008
    fn span_open(&mut self, class: &'static str) {
120
29008
        self.buffer.push_str("<span class=\"");
121
29008
        self.buffer.push_str(class);
122
29008
        self.buffer.push_str("\">");
123
    }
124

            
125
29008
    fn span_close(&mut self) {
126
29008
        self.buffer.push_str("</span>");
127
    }
128

            
129
14052
    fn push_source(&mut self, source: &SourceString) {
130
        // SourceString must be escaped before wrote
131
14052
        self.push_untrusted(source.as_str());
132
    }
133

            
134
14940
    fn push_untrusted(&mut self, str: &str) {
135
14940
        let escaped = str
136
14940
            .replace('&', "&amp;")
137
14940
            .replace('<', "&lt;")
138
14940
            .replace('>', "&gt;");
139
14940
        self.buffer.push_str(&escaped);
140
    }
141

            
142
63268
    fn push_trusted(&mut self, str: &str) {
143
63268
        self.buffer.push_str(str);
144
    }
145
}
146

            
147
impl Visitor for HtmlFormatter {
148
52
    fn visit_base64_value(&mut self, _value: &[u8], source: &SourceString) {
149
52
        self.span_open(HURL_BASE64_VALUE_CLASS);
150
52
        self.push_source(source);
151
52
        self.span_close();
152
    }
153

            
154
372
    fn visit_bool(&mut self, value: bool) {
155
372
        self.span_open(HURL_BOOLEAN_CLASS);
156
372
        self.push_trusted(&value.to_string());
157
372
        self.span_close();
158
    }
159

            
160
64
    fn visit_cookie_path(&mut self, path: &CookiePath) {
161
64
        self.span_open(HURL_STRING_CLASS);
162
64
        self.push_source(&path.to_source());
163
64
        self.span_close();
164
    }
165

            
166
1464
    fn visit_comment(&mut self, comment: &Comment) {
167
1464
        self.span_open(HURL_COMMENT_CLASS);
168
1464
        self.push_source(&comment.to_source());
169
1464
        self.span_close();
170
    }
171

            
172
40
    fn visit_duration_unit(&mut self, unit: DurationUnit) {
173
40
        self.span_open(HURL_DURATION_UNIT);
174
40
        self.push_trusted(&unit.to_string());
175
40
        self.span_close();
176
    }
177

            
178
1340
    fn visit_entry(&mut self, entry: &Entry) {
179
1340
        self.span_open(HURL_ENTRY_CLASS);
180
1340
        visit::walk_entry(self, entry);
181
1340
        self.span_close();
182
    }
183

            
184
156
    fn visit_filename(&mut self, filename: &Template) {
185
156
        self.span_open(HURL_FILENAME_CLASS);
186
156
        self.push_source(&filename.to_source());
187
156
        self.span_close();
188
    }
189

            
190
1232
    fn visit_filter_kind(&mut self, kind: &FilterValue) {
191
1232
        self.span_open(HURL_FILTER_KIND_CLASS);
192
1232
        self.push_trusted(kind.identifier());
193
1232
        self.span_close();
194
    }
195

            
196
276
    fn visit_hex_value(&mut self, _value: &[u8], source: &SourceString) {
197
276
        self.span_open(HURL_HEX_CLASS);
198
276
        self.push_source(source);
199
276
        self.span_close();
200
    }
201

            
202
480
    fn visit_hurl_file(&mut self, file: &HurlFile) {
203
480
        self.pre_open(HURL_LANG_CLASS);
204
480
        visit::walk_hurl_file(self, file);
205
480
        self.pre_close();
206
    }
207

            
208
132
    fn visit_i64(&mut self, n: i64) {
209
132
        self.span_open(HURL_NUMBER_CLASS);
210
132
        self.push_trusted(&n.to_string());
211
132
        self.span_close();
212
    }
213

            
214
64
    fn visit_json_body(&mut self, json: &JsonValue) {
215
64
        self.span_open(HURL_JSON_CLASS);
216
64
        self.push_source(&json.to_source());
217
64
        self.span_close();
218
    }
219

            
220
2820
    fn visit_literal(&mut self, lit: &'static str) {
221
2820
        self.push_trusted(lit);
222
    }
223

            
224
1340
    fn visit_method(&mut self, method: &Method) {
225
1340
        self.span_open(HURL_METHOD_CLASS);
226
1340
        self.push_trusted(&method.to_string());
227
1340
        self.span_close();
228
    }
229

            
230
188
    fn visit_multiline_string(&mut self, string: &MultilineString) {
231
188
        self.span_open(HURL_MULTILINESTRING_CLASS);
232
188
        self.push_source(&string.to_source());
233
188
        self.span_close();
234
    }
235

            
236
232
    fn visit_not(&mut self, identifier: &'static str) {
237
232
        self.span_open(HURL_NOT_CLASS);
238
232
        self.push_trusted(identifier);
239
232
        self.span_close();
240
    }
241

            
242
24
    fn visit_null(&mut self, null: &'static str) {
243
24
        self.span_open(HURL_NULL_CLASS);
244
24
        self.push_trusted(null);
245
24
        self.span_close();
246
    }
247

            
248
852
    fn visit_number(&mut self, number: &Number) {
249
852
        self.span_open(HURL_NUMBER_CLASS);
250
852
        self.push_source(&number.to_source());
251
852
        self.span_close();
252
    }
253

            
254
232
    fn visit_placeholder(&mut self, placeholder: &Placeholder) {
255
232
        self.span_open(HURL_PLACEHOLDER_CLASS);
256
232
        self.push_source(&placeholder.to_source());
257
232
        self.span_close();
258
    }
259

            
260
3080
    fn visit_predicate_kind(&mut self, kind: &PredicateFuncValue) {
261
3080
        self.span_open(HURL_PREDICATE_TYPE_CLASS);
262
3080
        self.push_source(&kind.to_source());
263
3080
        self.span_close();
264
    }
265

            
266
3316
    fn visit_query_kind(&mut self, kind: &QueryValue) {
267
3316
        self.span_open(HURL_QUERY_TYPE_CLASS);
268
3316
        self.push_trusted(kind.identifier());
269
3316
        self.span_close();
270
    }
271

            
272
1340
    fn visit_request(&mut self, request: &Request) {
273
1340
        self.span_open(HURL_REQUEST_CLASS);
274
1340
        visit::walk_request(self, request);
275
1340
        self.span_close();
276
    }
277
1168
    fn visit_response(&mut self, response: &Response) {
278
1168
        self.span_open(HURL_RESPONSE_CLASS);
279
1168
        visit::walk_response(self, response);
280
1168
        self.span_close();
281
    }
282

            
283
124
    fn visit_regex(&mut self, regex: &Regex) {
284
124
        self.span_open(HURL_REGEX_CLASS);
285
124
        self.push_source(&regex.to_source());
286
124
        self.span_close();
287
    }
288

            
289
1168
    fn visit_status(&mut self, value: &StatusValue) {
290
1168
        self.span_open(HURL_NUMBER_CLASS);
291
1168
        self.push_trusted(&value.to_string());
292
1168
        self.span_close();
293
    }
294

            
295
868
    fn visit_string(&mut self, value: &str) {
296
868
        self.span_open(HURL_STRING_CLASS);
297
868
        self.push_untrusted(value);
298
868
        self.span_close();
299
    }
300

            
301
1104
    fn visit_section_header(&mut self, name: &str) {
302
1104
        self.span_open(HURL_SECTION_HEADER_CLASS);
303
1104
        self.push_trusted(name);
304
1104
        self.span_close();
305
    }
306

            
307
6160
    fn visit_template(&mut self, template: &Template) {
308
6160
        self.span_open(HURL_STRING_CLASS);
309
6160
        self.push_source(&template.to_source());
310
6160
        self.span_close();
311
    }
312

            
313
1340
    fn visit_url(&mut self, url: &Template) {
314
1340
        self.span_open(HURL_URL_CLASS);
315
1340
        self.push_source(&url.to_source());
316
1340
        self.span_close();
317
    }
318

            
319
52
    fn visit_u64(&mut self, n: &U64) {
320
52
        self.span_open(HURL_NUMBER_CLASS);
321
52
        self.push_trusted(n.to_source().as_str());
322
52
        self.span_close();
323
    }
324

            
325
40
    fn visit_usize(&mut self, n: usize) {
326
40
        self.span_open(HURL_NUMBER_CLASS);
327
40
        self.push_trusted(&n.to_string());
328
40
        self.span_close();
329
    }
330

            
331
96
    fn visit_variable_name(&mut self, name: &str) {
332
96
        self.push_trusted(name);
333
    }
334

            
335
1168
    fn visit_version(&mut self, value: &VersionValue) {
336
1168
        self.span_open(HURL_VERSION_CLASS);
337
1168
        self.push_trusted(&value.to_string());
338
1168
        self.span_close();
339
    }
340

            
341
20
    fn visit_xml_body(&mut self, xml: &str) {
342
20
        self.span_open(HURL_XML_CLASS);
343
20
        self.push_untrusted(xml);
344
20
        self.span_close();
345
    }
346

            
347
50132
    fn visit_whitespace(&mut self, ws: &Whitespace) {
348
50132
        self.push_trusted(ws.as_str());
349
    }
350
}
351

            
352
#[cfg(test)]
353
mod tests {
354
    use crate::ast::visit::Visitor;
355
    use crate::ast::{
356
        JsonObjectElement, JsonValue, JsonValueKind, MultilineString, MultilineStringKind,
357
        SourceInfo, Template, TemplateElement, Whitespace,
358
    };
359
    use crate::format::html::HtmlFormatter;
360
    use crate::reader::Pos;
361
    use crate::types::ToSource;
362

            
363
    fn json_value(kind: JsonValueKind) -> JsonValue {
364
        JsonValue::new(SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), kind)
365
    }
366

            
367
    #[test]
368
    fn test_multiline_string() {
369
        // ```
370
        // line1
371
        // line2
372
        // ```
373
        let kind = MultilineStringKind::Text(Template {
374
            delimiter: None,
375
            elements: vec![TemplateElement::String {
376
                value: "line1\nline2\n".to_string(),
377
                source: "line1\nline2\n".to_source(),
378
            }],
379
            source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)),
380
        });
381
        let multiline_string = MultilineString {
382
            space: Whitespace {
383
                value: String::new(),
384
                source_info: SourceInfo {
385
                    start: Pos { line: 1, column: 4 },
386
                    end: Pos { line: 1, column: 4 },
387
                },
388
            },
389
            newline: Whitespace {
390
                value: "\n".to_string(),
391
                source_info: SourceInfo {
392
                    start: Pos { line: 1, column: 4 },
393
                    end: Pos { line: 2, column: 1 },
394
                },
395
            },
396
            kind,
397
        };
398
        let mut fmt = HtmlFormatter::new();
399
        fmt.visit_multiline_string(&multiline_string);
400
        assert_eq!(
401
            fmt.buffer,
402
            "<span class=\"multiline\">```\nline1\nline2\n```</span>"
403
        );
404
    }
405

            
406
    #[test]
407
    fn test_json() {
408
        let value = json_value(JsonValueKind::Object {
409
            space0: String::new(),
410
            elements: vec![JsonObjectElement {
411
                space0: "\n   ".to_string(),
412
                name: Template::new(
413
                    Some('"'),
414
                    vec![TemplateElement::String {
415
                        value: "id".to_string(),
416
                        source: "id".to_source(),
417
                    }],
418
                    SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)),
419
                ),
420
                space1: String::new(),
421
                space2: " ".to_string(),
422
                value: json_value(JsonValueKind::Number("1".to_string())),
423
                space3: "\n".to_string(),
424
            }],
425
        });
426
        let mut fmt = HtmlFormatter::new();
427
        fmt.visit_json_body(&value);
428
        assert_eq!(fmt.buffer, "<span class=\"json\">{\n   \"id\": 1\n}</span>");
429
    }
430

            
431
    #[test]
432
    fn test_json_encoded_newline() {
433
        let value = json_value(JsonValueKind::String(Template::new(
434
            Some('"'),
435
            vec![TemplateElement::String {
436
                value: "\n".to_string(),
437
                source: "\\n".to_source(),
438
            }],
439
            SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)),
440
        )));
441
        let mut fmt = HtmlFormatter::new();
442
        fmt.visit_json_body(&value);
443
        assert_eq!(fmt.buffer, "<span class=\"json\">\"\\n\"</span>");
444
    }
445

            
446
    #[test]
447
    fn test_xml() {
448
        let value = "<?xml version=\"1.0\"?>\n<drink>café</drink>";
449

            
450
        let mut fmt = HtmlFormatter::new();
451
        fmt.visit_xml_body(value);
452
        assert_eq!(
453
            fmt.buffer,
454
            "<span class=\"xml\">&lt;?xml version=\"1.0\"?&gt;\n&lt;drink&gt;café&lt;/drink&gt;</span>"
455
        );
456
    }
457

            
458
    #[test]
459
    fn test_xml_escape() {
460
        let mut fmt = HtmlFormatter::new();
461
        fmt.push_untrusted("hello");
462
        assert_eq!(fmt.buffer, "hello");
463

            
464
        let mut fmt = HtmlFormatter::new();
465
        fmt.push_untrusted("<?xml version=\"1.0\"?>");
466
        assert_eq!(fmt.buffer, "&lt;?xml version=\"1.0\"?&gt;");
467
    }
468
}