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 crate::ast::visit::Visitor;
19
use crate::ast::{
20
    visit, Comment, Entry, FilterValue, JsonValue, Method, Placeholder, Regex, Request, Response,
21
    Template, Whitespace, U64,
22
};
23
use crate::ast::{
24
    CookiePath, HurlFile, MultilineString, Number, PredicateFuncValue, QueryValue, StatusValue,
25
    VersionValue,
26
};
27
use crate::typing::{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
590
pub fn format(file: &HurlFile, standalone: bool) -> String {
34
590
    let mut fmt = HtmlFormatter::new();
35
590
    let body = fmt.format(file);
36
590
    if standalone {
37
5
        let css = include_str!("hurl.css");
38
5
        format!(
39
5
            r#"<!DOCTYPE html>
40
5
<html>
41
5
    <head>
42
5
        <meta charset="utf-8">
43
5
        <title>Hurl File</title>
44
5
        <style>
45
5
{css}
46
5
        </style>
47
5
    </head>
48
5
    <body>
49
5
{body}
50
5
    </body>
51
5
</html>
52
5
"#
53
5
        )
54
    } else {
55
585
        body.to_string()
56
    }
57
}
58

            
59
490
pub fn hurl_css() -> String {
60
490
    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
590
    fn new() -> Self {
98
590
        HtmlFormatter {
99
590
            buffer: String::new(),
100
        }
101
    }
102

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

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

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

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

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

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

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

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

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

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

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

            
166
1565
    fn visit_comment(&mut self, comment: &Comment) {
167
1565
        self.span_open(HURL_COMMENT_CLASS);
168
1565
        self.push_source(&comment.to_source());
169
1565
        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
1610
    fn visit_entry(&mut self, entry: &Entry) {
179
1610
        self.span_open(HURL_ENTRY_CLASS);
180
1610
        visit::walk_entry(self, entry);
181
1610
        self.span_close();
182
    }
183

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
347
55450
    fn visit_whitespace(&mut self, ws: &Whitespace) {
348
55450
        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, MultilineString, MultilineStringKind, SourceInfo, Template,
357
        TemplateElement, Whitespace,
358
    };
359
    use crate::format::html::HtmlFormatter;
360
    use crate::reader::Pos;
361
    use crate::typing::ToSource;
362

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

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

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

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

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

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

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