nu_cmd_extra/extra/formats/to/
html.rs

1use fancy_regex::Regex;
2use nu_cmd_base::formats::to::delimited::merge_descriptors;
3use nu_engine::command_prelude::*;
4use nu_protocol::{Config, DataSource, PipelineMetadata};
5use nu_utils::IgnoreCaseExt;
6use rust_embed::RustEmbed;
7use serde::{Deserialize, Serialize};
8use std::{collections::HashMap, error::Error, fmt::Write};
9
10#[derive(Serialize, Deserialize, Debug)]
11pub struct HtmlThemes {
12    themes: Vec<HtmlTheme>,
13}
14
15#[allow(non_snake_case)]
16#[derive(Serialize, Deserialize, Debug)]
17pub struct HtmlTheme {
18    name: String,
19    black: String,
20    red: String,
21    green: String,
22    yellow: String,
23    blue: String,
24    purple: String,
25    cyan: String,
26    white: String,
27    brightBlack: String,
28    brightRed: String,
29    brightGreen: String,
30    brightYellow: String,
31    brightBlue: String,
32    brightPurple: String,
33    brightCyan: String,
34    brightWhite: String,
35    background: String,
36    foreground: String,
37}
38
39impl Default for HtmlThemes {
40    fn default() -> Self {
41        HtmlThemes {
42            themes: vec![HtmlTheme::default()],
43        }
44    }
45}
46
47impl Default for HtmlTheme {
48    fn default() -> Self {
49        HtmlTheme {
50            name: "nu_default".to_string(),
51            black: "black".to_string(),
52            red: "red".to_string(),
53            green: "green".to_string(),
54            yellow: "#717100".to_string(),
55            blue: "blue".to_string(),
56            purple: "#c800c8".to_string(),
57            cyan: "#037979".to_string(),
58            white: "white".to_string(),
59            brightBlack: "black".to_string(),
60            brightRed: "red".to_string(),
61            brightGreen: "green".to_string(),
62            brightYellow: "#717100".to_string(),
63            brightBlue: "blue".to_string(),
64            brightPurple: "#c800c8".to_string(),
65            brightCyan: "#037979".to_string(),
66            brightWhite: "white".to_string(),
67            background: "white".to_string(),
68            foreground: "black".to_string(),
69        }
70    }
71}
72
73#[derive(RustEmbed)]
74#[folder = "assets/"]
75struct Assets;
76
77#[derive(Clone)]
78pub struct ToHtml;
79
80impl Command for ToHtml {
81    fn name(&self) -> &str {
82        "to html"
83    }
84
85    fn signature(&self) -> Signature {
86        Signature::build("to html")
87            .input_output_types(vec![(Type::Nothing, Type::Any), (Type::Any, Type::String)])
88            .allow_variants_without_examples(true)
89            .switch("html-color", "change ansi colors to html colors", Some('c'))
90            .switch("no-color", "remove all ansi colors in output", Some('n'))
91            .switch(
92                "dark",
93                "indicate your background color is a darker color",
94                Some('d'),
95            )
96            .switch(
97                "partial",
98                "only output the html for the content itself",
99                Some('p'),
100            )
101            .named(
102                "theme",
103                SyntaxShape::String,
104                "the name of the theme to use (github, blulocolight, ...); case-insensitive",
105                Some('t'),
106            )
107            .switch(
108                "list",
109                "produce a color table of all available themes",
110                Some('l'),
111            )
112            .category(Category::Formats)
113    }
114
115    fn examples(&self) -> Vec<Example> {
116        vec![
117            Example {
118                description: "Outputs an  HTML string representing the contents of this table",
119                example: "[[foo bar]; [1 2]] | to html",
120                result: Some(Value::test_string(
121                    r#"<html><style>body { background-color:white;color:black; }</style><body><table><thead><tr><th>foo</th><th>bar</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table></body></html>"#,
122                )),
123            },
124            Example {
125                description: "Optionally, only output the html for the content itself",
126                example: "[[foo bar]; [1 2]] | to html --partial",
127                result: Some(Value::test_string(
128                    r#"<div style="background-color:white;color:black;"><table><thead><tr><th>foo</th><th>bar</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table></div>"#,
129                )),
130            },
131            Example {
132                description: "Optionally, output the string with a dark background",
133                example: "[[foo bar]; [1 2]] | to html --dark",
134                result: Some(Value::test_string(
135                    r#"<html><style>body { background-color:black;color:white; }</style><body><table><thead><tr><th>foo</th><th>bar</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table></body></html>"#,
136                )),
137            },
138        ]
139    }
140
141    fn description(&self) -> &str {
142        "Convert table into simple HTML."
143    }
144
145    fn extra_description(&self) -> &str {
146        "Screenshots of the themes can be browsed here: https://github.com/mbadolato/iTerm2-Color-Schemes."
147    }
148
149    fn run(
150        &self,
151        engine_state: &EngineState,
152        stack: &mut Stack,
153        call: &Call,
154        input: PipelineData,
155    ) -> Result<PipelineData, ShellError> {
156        to_html(input, call, engine_state, stack)
157    }
158}
159
160fn get_theme_from_asset_file(
161    is_dark: bool,
162    theme: Option<&Spanned<String>>,
163) -> Result<HashMap<&'static str, String>, ShellError> {
164    let theme_name = match theme {
165        Some(s) => &s.item,
166        None => {
167            return Ok(convert_html_theme_to_hash_map(
168                is_dark,
169                &HtmlTheme::default(),
170            ));
171        }
172    };
173
174    let theme_span = theme.map(|s| s.span).unwrap_or(Span::unknown());
175
176    // 228 themes come from
177    // https://github.com/mbadolato/iTerm2-Color-Schemes/tree/master/windowsterminal
178    // we should find a hit on any name in there
179    let asset = get_html_themes("228_themes.json").unwrap_or_default();
180
181    // Find the theme by theme name
182    let th = asset
183        .themes
184        .into_iter()
185        .find(|n| n.name.eq_ignore_case(theme_name)); // case insensitive search
186
187    let th = match th {
188        Some(t) => t,
189        None => {
190            return Err(ShellError::TypeMismatch {
191                err_message: format!("Unknown HTML theme '{theme_name}'"),
192                span: theme_span,
193            });
194        }
195    };
196
197    Ok(convert_html_theme_to_hash_map(is_dark, &th))
198}
199
200fn convert_html_theme_to_hash_map(
201    is_dark: bool,
202    theme: &HtmlTheme,
203) -> HashMap<&'static str, String> {
204    let mut hm: HashMap<&str, String> = HashMap::with_capacity(18);
205
206    hm.insert("bold_black", theme.brightBlack[..].to_string());
207    hm.insert("bold_red", theme.brightRed[..].to_string());
208    hm.insert("bold_green", theme.brightGreen[..].to_string());
209    hm.insert("bold_yellow", theme.brightYellow[..].to_string());
210    hm.insert("bold_blue", theme.brightBlue[..].to_string());
211    hm.insert("bold_magenta", theme.brightPurple[..].to_string());
212    hm.insert("bold_cyan", theme.brightCyan[..].to_string());
213    hm.insert("bold_white", theme.brightWhite[..].to_string());
214
215    hm.insert("black", theme.black[..].to_string());
216    hm.insert("red", theme.red[..].to_string());
217    hm.insert("green", theme.green[..].to_string());
218    hm.insert("yellow", theme.yellow[..].to_string());
219    hm.insert("blue", theme.blue[..].to_string());
220    hm.insert("magenta", theme.purple[..].to_string());
221    hm.insert("cyan", theme.cyan[..].to_string());
222    hm.insert("white", theme.white[..].to_string());
223
224    // Try to make theme work with light or dark but
225    // flipping the foreground and background but leave
226    // the other colors the same.
227    if is_dark {
228        hm.insert("background", theme.black[..].to_string());
229        hm.insert("foreground", theme.white[..].to_string());
230    } else {
231        hm.insert("background", theme.white[..].to_string());
232        hm.insert("foreground", theme.black[..].to_string());
233    }
234
235    hm
236}
237
238fn get_html_themes(json_name: &str) -> Result<HtmlThemes, Box<dyn Error>> {
239    match Assets::get(json_name) {
240        Some(content) => Ok(nu_json::from_slice(&content.data)?),
241        None => Ok(HtmlThemes::default()),
242    }
243}
244
245fn to_html(
246    input: PipelineData,
247    call: &Call,
248    engine_state: &EngineState,
249    stack: &mut Stack,
250) -> Result<PipelineData, ShellError> {
251    let head = call.head;
252    let html_color = call.has_flag(engine_state, stack, "html-color")?;
253    let no_color = call.has_flag(engine_state, stack, "no-color")?;
254    let dark = call.has_flag(engine_state, stack, "dark")?;
255    let partial = call.has_flag(engine_state, stack, "partial")?;
256    let list = call.has_flag(engine_state, stack, "list")?;
257    let theme: Option<Spanned<String>> = call.get_flag(engine_state, stack, "theme")?;
258    let config = &stack.get_config(engine_state);
259
260    let vec_of_values = input.into_iter().collect::<Vec<Value>>();
261    let headers = merge_descriptors(&vec_of_values);
262    let headers = Some(headers)
263        .filter(|headers| !headers.is_empty() && (headers.len() > 1 || !headers[0].is_empty()));
264    let mut output_string = String::new();
265    let mut regex_hm: HashMap<u32, (&str, String)> = HashMap::with_capacity(17);
266
267    if list {
268        // Being essentially a 'help' option, this can afford to be relatively unoptimised
269        return Ok(theme_demo(head));
270    }
271    let theme_span = match &theme {
272        Some(v) => v.span,
273        None => head,
274    };
275
276    let color_hm = match get_theme_from_asset_file(dark, theme.as_ref()) {
277        Ok(c) => c,
278        Err(e) => match e {
279            ShellError::TypeMismatch {
280                err_message,
281                span: _,
282            } => {
283                return Err(ShellError::TypeMismatch {
284                    err_message,
285                    span: theme_span,
286                });
287            }
288            _ => return Err(e),
289        },
290    };
291
292    // change the color of the page
293    if !partial {
294        write!(
295            &mut output_string,
296            r"<html><style>body {{ background-color:{};color:{}; }}</style><body>",
297            color_hm
298                .get("background")
299                .expect("Error getting background color"),
300            color_hm
301                .get("foreground")
302                .expect("Error getting foreground color")
303        )
304        .unwrap();
305    } else {
306        write!(
307            &mut output_string,
308            "<div style=\"background-color:{};color:{};\">",
309            color_hm
310                .get("background")
311                .expect("Error getting background color"),
312            color_hm
313                .get("foreground")
314                .expect("Error getting foreground color")
315        )
316        .unwrap();
317    }
318
319    let inner_value = match vec_of_values.len() {
320        0 => String::default(),
321        1 => match headers {
322            Some(headers) => html_table(vec_of_values, headers, config),
323            None => {
324                let value = &vec_of_values[0];
325                html_value(value.clone(), config)
326            }
327        },
328        _ => match headers {
329            Some(headers) => html_table(vec_of_values, headers, config),
330            None => html_list(vec_of_values, config),
331        },
332    };
333
334    output_string.push_str(&inner_value);
335
336    if !partial {
337        output_string.push_str("</body></html>");
338    } else {
339        output_string.push_str("</div>")
340    }
341
342    // Check to see if we want to remove all color or change ansi to html colors
343    if html_color {
344        setup_html_color_regexes(&mut regex_hm, &color_hm);
345        output_string = run_regexes(&regex_hm, &output_string);
346    } else if no_color {
347        setup_no_color_regexes(&mut regex_hm);
348        output_string = run_regexes(&regex_hm, &output_string);
349    }
350
351    let metadata = PipelineMetadata {
352        data_source: nu_protocol::DataSource::None,
353        content_type: Some(mime::TEXT_HTML_UTF_8.to_string()),
354    };
355
356    Ok(Value::string(output_string, head).into_pipeline_data_with_metadata(metadata))
357}
358
359fn theme_demo(span: Span) -> PipelineData {
360    // If asset doesn't work, make sure to return the default theme
361    let html_themes = get_html_themes("228_themes.json").unwrap_or_default();
362    let result: Vec<Value> = html_themes
363        .themes
364        .into_iter()
365        .map(|n| {
366            Value::record(
367                record! {
368                    "name" => Value::string(n.name, span),
369                    "black" => Value::string(n.black, span),
370                    "red" => Value::string(n.red, span),
371                    "green" => Value::string(n.green, span),
372                    "yellow" => Value::string(n.yellow, span),
373                    "blue" => Value::string(n.blue, span),
374                    "purple" => Value::string(n.purple, span),
375                    "cyan" => Value::string(n.cyan, span),
376                    "white" => Value::string(n.white, span),
377                    "brightBlack" => Value::string(n.brightBlack, span),
378                    "brightRed" => Value::string(n.brightRed, span),
379                    "brightGreen" => Value::string(n.brightGreen, span),
380                    "brightYellow" => Value::string(n.brightYellow, span),
381                    "brightBlue" => Value::string(n.brightBlue, span),
382                    "brightPurple" => Value::string(n.brightPurple, span),
383                    "brightCyan" => Value::string(n.brightCyan, span),
384                    "brightWhite" => Value::string(n.brightWhite, span),
385                    "background" => Value::string(n.background, span),
386                    "foreground" => Value::string(n.foreground, span),
387                },
388                span,
389            )
390        })
391        .collect();
392    Value::list(result, span).into_pipeline_data_with_metadata(PipelineMetadata {
393        data_source: DataSource::HtmlThemes,
394        content_type: None,
395    })
396}
397
398fn html_list(list: Vec<Value>, config: &Config) -> String {
399    let mut output_string = String::new();
400    output_string.push_str("<ol>");
401    for value in list {
402        output_string.push_str("<li>");
403        output_string.push_str(&html_value(value, config));
404        output_string.push_str("</li>");
405    }
406    output_string.push_str("</ol>");
407    output_string
408}
409
410fn html_table(table: Vec<Value>, headers: Vec<String>, config: &Config) -> String {
411    let mut output_string = String::new();
412
413    output_string.push_str("<table>");
414
415    output_string.push_str("<thead><tr>");
416    for header in &headers {
417        output_string.push_str("<th>");
418        output_string.push_str(&v_htmlescape::escape(header).to_string());
419        output_string.push_str("</th>");
420    }
421    output_string.push_str("</tr></thead><tbody>");
422
423    for row in table {
424        let span = row.span();
425        if let Value::Record { val: row, .. } = row {
426            output_string.push_str("<tr>");
427            for header in &headers {
428                let data = row
429                    .get(header)
430                    .cloned()
431                    .unwrap_or_else(|| Value::nothing(span));
432                output_string.push_str("<td>");
433                output_string.push_str(&html_value(data, config));
434                output_string.push_str("</td>");
435            }
436            output_string.push_str("</tr>");
437        }
438    }
439    output_string.push_str("</tbody></table>");
440
441    output_string
442}
443
444fn html_value(value: Value, config: &Config) -> String {
445    let mut output_string = String::new();
446    match value {
447        Value::Binary { val, .. } => {
448            let output = nu_pretty_hex::pretty_hex(&val);
449            output_string.push_str("<pre>");
450            output_string.push_str(&output);
451            output_string.push_str("</pre>");
452        }
453        other => output_string.push_str(
454            &v_htmlescape::escape(&other.to_abbreviated_string(config))
455                .to_string()
456                .replace('\n', "<br>"),
457        ),
458    }
459    output_string
460}
461
462fn setup_html_color_regexes(
463    hash: &mut HashMap<u32, (&'static str, String)>,
464    color_hm: &HashMap<&str, String>,
465) {
466    // All the bold colors
467    hash.insert(
468        0,
469        (
470            r"(?P<reset>\[0m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
471            // Reset the text color, normal weight font
472            format!(
473                r"<span style='color:{};font-weight:normal;'>$word</span>",
474                color_hm
475                    .get("foreground")
476                    .expect("Error getting reset text color")
477            ),
478        ),
479    );
480    hash.insert(
481        1,
482        (
483            // Bold Black
484            r"(?P<bb>\[1;30m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
485            format!(
486                r"<span style='color:{};font-weight:bold;'>$word</span>",
487                color_hm
488                    .get("foreground")
489                    .expect("Error getting bold black text color")
490            ),
491        ),
492    );
493    hash.insert(
494        2,
495        (
496            // Bold Red
497            r"(?P<br>\[1;31m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
498            format!(
499                r"<span style='color:{};font-weight:bold;'>$word</span>",
500                color_hm
501                    .get("bold_red")
502                    .expect("Error getting bold red text color"),
503            ),
504        ),
505    );
506    hash.insert(
507        3,
508        (
509            // Bold Green
510            r"(?P<bg>\[1;32m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
511            format!(
512                r"<span style='color:{};font-weight:bold;'>$word</span>",
513                color_hm
514                    .get("bold_green")
515                    .expect("Error getting bold green text color"),
516            ),
517        ),
518    );
519    hash.insert(
520        4,
521        (
522            // Bold Yellow
523            r"(?P<by>\[1;33m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
524            format!(
525                r"<span style='color:{};font-weight:bold;'>$word</span>",
526                color_hm
527                    .get("bold_yellow")
528                    .expect("Error getting bold yellow text color"),
529            ),
530        ),
531    );
532    hash.insert(
533        5,
534        (
535            // Bold Blue
536            r"(?P<bu>\[1;34m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
537            format!(
538                r"<span style='color:{};font-weight:bold;'>$word</span>",
539                color_hm
540                    .get("bold_blue")
541                    .expect("Error getting bold blue text color"),
542            ),
543        ),
544    );
545    hash.insert(
546        6,
547        (
548            // Bold Magenta
549            r"(?P<bm>\[1;35m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
550            format!(
551                r"<span style='color:{};font-weight:bold;'>$word</span>",
552                color_hm
553                    .get("bold_magenta")
554                    .expect("Error getting bold magenta text color"),
555            ),
556        ),
557    );
558    hash.insert(
559        7,
560        (
561            // Bold Cyan
562            r"(?P<bc>\[1;36m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
563            format!(
564                r"<span style='color:{};font-weight:bold;'>$word</span>",
565                color_hm
566                    .get("bold_cyan")
567                    .expect("Error getting bold cyan text color"),
568            ),
569        ),
570    );
571    hash.insert(
572        8,
573        (
574            // Bold White
575            // Let's change this to black since the html background
576            // is white. White on white = no bueno.
577            r"(?P<bw>\[1;37m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
578            format!(
579                r"<span style='color:{};font-weight:bold;'>$word</span>",
580                color_hm
581                    .get("foreground")
582                    .expect("Error getting bold bold white text color"),
583            ),
584        ),
585    );
586    // All the normal colors
587    hash.insert(
588        9,
589        (
590            // Black
591            r"(?P<b>\[30m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
592            format!(
593                r"<span style='color:{};'>$word</span>",
594                color_hm
595                    .get("foreground")
596                    .expect("Error getting black text color"),
597            ),
598        ),
599    );
600    hash.insert(
601        10,
602        (
603            // Red
604            r"(?P<r>\[31m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
605            format!(
606                r"<span style='color:{};'>$word</span>",
607                color_hm.get("red").expect("Error getting red text color"),
608            ),
609        ),
610    );
611    hash.insert(
612        11,
613        (
614            // Green
615            r"(?P<g>\[32m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
616            format!(
617                r"<span style='color:{};'>$word</span>",
618                color_hm
619                    .get("green")
620                    .expect("Error getting green text color"),
621            ),
622        ),
623    );
624    hash.insert(
625        12,
626        (
627            // Yellow
628            r"(?P<y>\[33m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
629            format!(
630                r"<span style='color:{};'>$word</span>",
631                color_hm
632                    .get("yellow")
633                    .expect("Error getting yellow text color"),
634            ),
635        ),
636    );
637    hash.insert(
638        13,
639        (
640            // Blue
641            r"(?P<u>\[34m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
642            format!(
643                r"<span style='color:{};'>$word</span>",
644                color_hm.get("blue").expect("Error getting blue text color"),
645            ),
646        ),
647    );
648    hash.insert(
649        14,
650        (
651            // Magenta
652            r"(?P<m>\[35m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
653            format!(
654                r"<span style='color:{};'>$word</span>",
655                color_hm
656                    .get("magenta")
657                    .expect("Error getting magenta text color"),
658            ),
659        ),
660    );
661    hash.insert(
662        15,
663        (
664            // Cyan
665            r"(?P<c>\[36m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
666            format!(
667                r"<span style='color:{};'>$word</span>",
668                color_hm.get("cyan").expect("Error getting cyan text color"),
669            ),
670        ),
671    );
672    hash.insert(
673        16,
674        (
675            // White
676            // Let's change this to black since the html background
677            // is white. White on white = no bueno.
678            r"(?P<w>\[37m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
679            format!(
680                r"<span style='color:{};'>$word</span>",
681                color_hm
682                    .get("foreground")
683                    .expect("Error getting white text color"),
684            ),
685        ),
686    );
687}
688
689fn setup_no_color_regexes(hash: &mut HashMap<u32, (&'static str, String)>) {
690    // We can just use one regex here because we're just removing ansi sequences
691    // and not replacing them with html colors.
692    // attribution: https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python
693    hash.insert(
694        0,
695        (
696            r"(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])",
697            r"$name_group_doesnt_exist".to_string(),
698        ),
699    );
700}
701
702fn run_regexes(hash: &HashMap<u32, (&'static str, String)>, contents: &str) -> String {
703    let mut working_string = contents.to_owned();
704    let hash_count: u32 = hash.len() as u32;
705    for n in 0..hash_count {
706        let value = hash.get(&n).expect("error getting hash at index");
707        let re = Regex::new(value.0).expect("problem with color regex");
708        let after = re.replace_all(&working_string, &value.1[..]).to_string();
709        working_string = after;
710    }
711    working_string
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    #[test]
719    fn test_examples() {
720        use crate::test_examples;
721
722        test_examples(ToHtml {})
723    }
724
725    #[test]
726    fn get_theme_from_asset_file_returns_default() {
727        let result = super::get_theme_from_asset_file(false, None);
728
729        assert!(result.is_ok(), "Expected Ok result for None theme");
730
731        let theme_map = result.unwrap();
732
733        assert_eq!(
734            theme_map.get("background").map(String::as_str),
735            Some("white"),
736            "Expected default background color to be white"
737        );
738
739        assert_eq!(
740            theme_map.get("foreground").map(String::as_str),
741            Some("black"),
742            "Expected default foreground color to be black"
743        );
744
745        assert!(
746            theme_map.contains_key("red"),
747            "Expected default theme to have a 'red' color"
748        );
749
750        assert!(
751            theme_map.contains_key("bold_green"),
752            "Expected default theme to have a 'bold_green' color"
753        );
754    }
755
756    #[test]
757    fn returns_a_valid_theme() {
758        let theme_name = "Dracula".to_string().into_spanned(Span::new(0, 7));
759        let result = super::get_theme_from_asset_file(false, Some(&theme_name));
760
761        assert!(result.is_ok(), "Expected Ok result for valid theme");
762        let theme_map = result.unwrap();
763        let required_keys = [
764            "background",
765            "foreground",
766            "red",
767            "green",
768            "blue",
769            "bold_red",
770            "bold_green",
771            "bold_blue",
772        ];
773
774        for key in required_keys {
775            assert!(
776                theme_map.contains_key(key),
777                "Expected theme to contain key '{key}'"
778            );
779        }
780    }
781
782    #[test]
783    fn fails_with_unknown_theme_name() {
784        let result = super::get_theme_from_asset_file(
785            false,
786            Some(&"doesnt-exist".to_string().into_spanned(Span::new(0, 13))),
787        );
788
789        assert!(result.is_err(), "Expected error for invalid theme name");
790
791        if let Err(err) = result {
792            assert!(
793                matches!(err, ShellError::TypeMismatch { .. }),
794                "Expected TypeMismatch error, got: {err:?}"
795            );
796
797            if let ShellError::TypeMismatch { err_message, span } = err {
798                assert!(
799                    err_message.contains("doesnt-exist"),
800                    "Error message should mention theme name, got: {err_message}"
801                );
802                assert_eq!(span.start, 0);
803                assert_eq!(span.end, 13);
804            }
805        }
806    }
807}