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 .switch("raw", "do not escape html tags", Some('r'))
113 .category(Category::Formats)
114 }
115
116 fn examples(&self) -> Vec<Example<'_>> {
117 vec![
118 Example {
119 description: "Outputs an HTML string representing the contents of this table",
120 example: "[[foo bar]; [1 2]] | to html",
121 result: Some(Value::test_string(
122 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>"#,
123 )),
124 },
125 Example {
126 description: "Outputs an HTML string using a record of xml data",
127 example: r#"{tag: a attributes: { style: "color: red" } content: ["hello!"] } | to xml | to html --raw"#,
128 result: Some(Value::test_string(
129 r#"<html><style>body { background-color:white;color:black; }</style><body><a style="color: red">hello!</a></body></html>"#,
130 )),
131 },
132 Example {
133 description: "Optionally, only output the html for the content itself",
134 example: "[[foo bar]; [1 2]] | to html --partial",
135 result: Some(Value::test_string(
136 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>"#,
137 )),
138 },
139 Example {
140 description: "Optionally, output the string with a dark background",
141 example: "[[foo bar]; [1 2]] | to html --dark",
142 result: Some(Value::test_string(
143 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>"#,
144 )),
145 },
146 ]
147 }
148
149 fn description(&self) -> &str {
150 "Convert table into simple HTML."
151 }
152
153 fn extra_description(&self) -> &str {
154 "Screenshots of the themes can be browsed here: https://github.com/mbadolato/iTerm2-Color-Schemes."
155 }
156
157 fn run(
158 &self,
159 engine_state: &EngineState,
160 stack: &mut Stack,
161 call: &Call,
162 input: PipelineData,
163 ) -> Result<PipelineData, ShellError> {
164 to_html(input, call, engine_state, stack)
165 }
166}
167
168fn get_theme_from_asset_file(
169 is_dark: bool,
170 theme: Option<&Spanned<String>>,
171) -> Result<HashMap<&'static str, String>, ShellError> {
172 let theme_name = match theme {
173 Some(s) => &s.item,
174 None => {
175 return Ok(convert_html_theme_to_hash_map(
176 is_dark,
177 &HtmlTheme::default(),
178 ));
179 }
180 };
181
182 let theme_span = theme.map(|s| s.span).unwrap_or(Span::unknown());
183
184 let asset = get_html_themes("228_themes.json").unwrap_or_default();
188
189 let th = asset
191 .themes
192 .into_iter()
193 .find(|n| n.name.eq_ignore_case(theme_name)); let th = match th {
196 Some(t) => t,
197 None => {
198 return Err(ShellError::TypeMismatch {
199 err_message: format!("Unknown HTML theme '{theme_name}'"),
200 span: theme_span,
201 });
202 }
203 };
204
205 Ok(convert_html_theme_to_hash_map(is_dark, &th))
206}
207
208fn convert_html_theme_to_hash_map(
209 is_dark: bool,
210 theme: &HtmlTheme,
211) -> HashMap<&'static str, String> {
212 let mut hm: HashMap<&str, String> = HashMap::with_capacity(18);
213
214 hm.insert("bold_black", theme.brightBlack[..].to_string());
215 hm.insert("bold_red", theme.brightRed[..].to_string());
216 hm.insert("bold_green", theme.brightGreen[..].to_string());
217 hm.insert("bold_yellow", theme.brightYellow[..].to_string());
218 hm.insert("bold_blue", theme.brightBlue[..].to_string());
219 hm.insert("bold_magenta", theme.brightPurple[..].to_string());
220 hm.insert("bold_cyan", theme.brightCyan[..].to_string());
221 hm.insert("bold_white", theme.brightWhite[..].to_string());
222
223 hm.insert("black", theme.black[..].to_string());
224 hm.insert("red", theme.red[..].to_string());
225 hm.insert("green", theme.green[..].to_string());
226 hm.insert("yellow", theme.yellow[..].to_string());
227 hm.insert("blue", theme.blue[..].to_string());
228 hm.insert("magenta", theme.purple[..].to_string());
229 hm.insert("cyan", theme.cyan[..].to_string());
230 hm.insert("white", theme.white[..].to_string());
231
232 if is_dark {
236 hm.insert("background", theme.black[..].to_string());
237 hm.insert("foreground", theme.white[..].to_string());
238 } else {
239 hm.insert("background", theme.white[..].to_string());
240 hm.insert("foreground", theme.black[..].to_string());
241 }
242
243 hm
244}
245
246fn get_html_themes(json_name: &str) -> Result<HtmlThemes, Box<dyn Error>> {
247 match Assets::get(json_name) {
248 Some(content) => Ok(nu_json::from_slice(&content.data)?),
249 None => Ok(HtmlThemes::default()),
250 }
251}
252
253fn to_html(
254 input: PipelineData,
255 call: &Call,
256 engine_state: &EngineState,
257 stack: &mut Stack,
258) -> Result<PipelineData, ShellError> {
259 let head = call.head;
260 let html_color = call.has_flag(engine_state, stack, "html-color")?;
261 let no_color = call.has_flag(engine_state, stack, "no-color")?;
262 let dark = call.has_flag(engine_state, stack, "dark")?;
263 let partial = call.has_flag(engine_state, stack, "partial")?;
264 let list = call.has_flag(engine_state, stack, "list")?;
265 let raw = call.has_flag(engine_state, stack, "raw")?;
266 let theme: Option<Spanned<String>> = call.get_flag(engine_state, stack, "theme")?;
267 let config = &stack.get_config(engine_state);
268
269 let vec_of_values = input.into_iter().collect::<Vec<Value>>();
270 let headers = merge_descriptors(&vec_of_values);
271 let headers = Some(headers)
272 .filter(|headers| !headers.is_empty() && (headers.len() > 1 || !headers[0].is_empty()));
273 let mut output_string = String::new();
274 let mut regex_hm: HashMap<u32, (&str, String)> = HashMap::with_capacity(17);
275
276 if list {
277 return Ok(theme_demo(head));
279 }
280 let theme_span = match &theme {
281 Some(v) => v.span,
282 None => head,
283 };
284
285 let color_hm = match get_theme_from_asset_file(dark, theme.as_ref()) {
286 Ok(c) => c,
287 Err(e) => match e {
288 ShellError::TypeMismatch {
289 err_message,
290 span: _,
291 } => {
292 return Err(ShellError::TypeMismatch {
293 err_message,
294 span: theme_span,
295 });
296 }
297 _ => return Err(e),
298 },
299 };
300
301 if !partial {
303 write!(
304 &mut output_string,
305 r"<html><style>body {{ background-color:{};color:{}; }}</style><body>",
306 color_hm
307 .get("background")
308 .expect("Error getting background color"),
309 color_hm
310 .get("foreground")
311 .expect("Error getting foreground color")
312 )
313 .ok();
314 } else {
315 write!(
316 &mut output_string,
317 "<div style=\"background-color:{};color:{};\">",
318 color_hm
319 .get("background")
320 .expect("Error getting background color"),
321 color_hm
322 .get("foreground")
323 .expect("Error getting foreground color")
324 )
325 .ok();
326 }
327
328 let inner_value = match vec_of_values.len() {
329 0 => String::default(),
330 1 => match headers {
331 Some(headers) => html_table(vec_of_values, headers, raw, config),
332 None => {
333 let value = &vec_of_values[0];
334 html_value(value.clone(), raw, config)
335 }
336 },
337 _ => match headers {
338 Some(headers) => html_table(vec_of_values, headers, raw, config),
339 None => html_list(vec_of_values, raw, config),
340 },
341 };
342
343 output_string.push_str(&inner_value);
344
345 if !partial {
346 output_string.push_str("</body></html>");
347 } else {
348 output_string.push_str("</div>")
349 }
350
351 if html_color {
353 setup_html_color_regexes(&mut regex_hm, &color_hm);
354 output_string = run_regexes(®ex_hm, &output_string);
355 } else if no_color {
356 setup_no_color_regexes(&mut regex_hm);
357 output_string = run_regexes(®ex_hm, &output_string);
358 }
359
360 let metadata = PipelineMetadata {
361 data_source: nu_protocol::DataSource::None,
362 content_type: Some(mime::TEXT_HTML_UTF_8.to_string()),
363 ..Default::default()
364 };
365
366 Ok(Value::string(output_string, head).into_pipeline_data_with_metadata(metadata))
367}
368
369fn theme_demo(span: Span) -> PipelineData {
370 let html_themes = get_html_themes("228_themes.json").unwrap_or_default();
372 let result: Vec<Value> = html_themes
373 .themes
374 .into_iter()
375 .map(|n| {
376 Value::record(
377 record! {
378 "name" => Value::string(n.name, span),
379 "black" => Value::string(n.black, span),
380 "red" => Value::string(n.red, span),
381 "green" => Value::string(n.green, span),
382 "yellow" => Value::string(n.yellow, span),
383 "blue" => Value::string(n.blue, span),
384 "purple" => Value::string(n.purple, span),
385 "cyan" => Value::string(n.cyan, span),
386 "white" => Value::string(n.white, span),
387 "brightBlack" => Value::string(n.brightBlack, span),
388 "brightRed" => Value::string(n.brightRed, span),
389 "brightGreen" => Value::string(n.brightGreen, span),
390 "brightYellow" => Value::string(n.brightYellow, span),
391 "brightBlue" => Value::string(n.brightBlue, span),
392 "brightPurple" => Value::string(n.brightPurple, span),
393 "brightCyan" => Value::string(n.brightCyan, span),
394 "brightWhite" => Value::string(n.brightWhite, span),
395 "background" => Value::string(n.background, span),
396 "foreground" => Value::string(n.foreground, span),
397 },
398 span,
399 )
400 })
401 .collect();
402 Value::list(result, span).into_pipeline_data_with_metadata(PipelineMetadata {
403 data_source: DataSource::HtmlThemes,
404 ..Default::default()
405 })
406}
407
408fn html_list(list: Vec<Value>, raw: bool, config: &Config) -> String {
409 let mut output_string = String::new();
410 output_string.push_str("<ol>");
411 for value in list {
412 output_string.push_str("<li>");
413 output_string.push_str(&html_value(value, raw, config));
414 output_string.push_str("</li>");
415 }
416 output_string.push_str("</ol>");
417 output_string
418}
419
420fn html_table(table: Vec<Value>, headers: Vec<String>, raw: bool, config: &Config) -> String {
421 let mut output_string = String::new();
422
423 output_string.push_str("<table>");
424
425 output_string.push_str("<thead><tr>");
426 for header in &headers {
427 output_string.push_str("<th>");
428 output_string.push_str(&v_htmlescape::escape(header).to_string());
429 output_string.push_str("</th>");
430 }
431 output_string.push_str("</tr></thead><tbody>");
432
433 for row in table {
434 let span = row.span();
435 if let Value::Record { val: row, .. } = row {
436 output_string.push_str("<tr>");
437 for header in &headers {
438 let data = row
439 .get(header)
440 .cloned()
441 .unwrap_or_else(|| Value::nothing(span));
442 output_string.push_str("<td>");
443 output_string.push_str(&html_value(data, raw, config));
444 output_string.push_str("</td>");
445 }
446 output_string.push_str("</tr>");
447 }
448 }
449 output_string.push_str("</tbody></table>");
450
451 output_string
452}
453
454fn html_value(value: Value, raw: bool, config: &Config) -> String {
455 let mut output_string = String::new();
456 match value {
457 Value::Binary { val, .. } => {
458 let output = nu_pretty_hex::pretty_hex(&val);
459 output_string.push_str("<pre>");
460 output_string.push_str(&output);
461 output_string.push_str("</pre>");
462 }
463 other => {
464 if raw {
465 output_string.push_str(
466 &other
467 .to_abbreviated_string(config)
468 .to_string()
469 .replace('\n', "<br>"),
470 )
471 } else {
472 output_string.push_str(
473 &v_htmlescape::escape(&other.to_abbreviated_string(config))
474 .to_string()
475 .replace('\n', "<br>"),
476 )
477 }
478 }
479 }
480 output_string
481}
482
483fn setup_html_color_regexes(
484 hash: &mut HashMap<u32, (&'static str, String)>,
485 color_hm: &HashMap<&str, String>,
486) {
487 hash.insert(
489 0,
490 (
491 r"(?P<reset>\[0m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
492 format!(
494 r"<span style='color:{};font-weight:normal;'>$word</span>",
495 color_hm
496 .get("foreground")
497 .expect("Error getting reset text color")
498 ),
499 ),
500 );
501 hash.insert(
502 1,
503 (
504 r"(?P<bb>\[1;30m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
506 format!(
507 r"<span style='color:{};font-weight:bold;'>$word</span>",
508 color_hm
509 .get("foreground")
510 .expect("Error getting bold black text color")
511 ),
512 ),
513 );
514 hash.insert(
515 2,
516 (
517 r"(?P<br>\[1;31m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
519 format!(
520 r"<span style='color:{};font-weight:bold;'>$word</span>",
521 color_hm
522 .get("bold_red")
523 .expect("Error getting bold red text color"),
524 ),
525 ),
526 );
527 hash.insert(
528 3,
529 (
530 r"(?P<bg>\[1;32m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
532 format!(
533 r"<span style='color:{};font-weight:bold;'>$word</span>",
534 color_hm
535 .get("bold_green")
536 .expect("Error getting bold green text color"),
537 ),
538 ),
539 );
540 hash.insert(
541 4,
542 (
543 r"(?P<by>\[1;33m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
545 format!(
546 r"<span style='color:{};font-weight:bold;'>$word</span>",
547 color_hm
548 .get("bold_yellow")
549 .expect("Error getting bold yellow text color"),
550 ),
551 ),
552 );
553 hash.insert(
554 5,
555 (
556 r"(?P<bu>\[1;34m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
558 format!(
559 r"<span style='color:{};font-weight:bold;'>$word</span>",
560 color_hm
561 .get("bold_blue")
562 .expect("Error getting bold blue text color"),
563 ),
564 ),
565 );
566 hash.insert(
567 6,
568 (
569 r"(?P<bm>\[1;35m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
571 format!(
572 r"<span style='color:{};font-weight:bold;'>$word</span>",
573 color_hm
574 .get("bold_magenta")
575 .expect("Error getting bold magenta text color"),
576 ),
577 ),
578 );
579 hash.insert(
580 7,
581 (
582 r"(?P<bc>\[1;36m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
584 format!(
585 r"<span style='color:{};font-weight:bold;'>$word</span>",
586 color_hm
587 .get("bold_cyan")
588 .expect("Error getting bold cyan text color"),
589 ),
590 ),
591 );
592 hash.insert(
593 8,
594 (
595 r"(?P<bw>\[1;37m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
599 format!(
600 r"<span style='color:{};font-weight:bold;'>$word</span>",
601 color_hm
602 .get("foreground")
603 .expect("Error getting bold bold white text color"),
604 ),
605 ),
606 );
607 hash.insert(
609 9,
610 (
611 r"(?P<b>\[30m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
613 format!(
614 r"<span style='color:{};'>$word</span>",
615 color_hm
616 .get("foreground")
617 .expect("Error getting black text color"),
618 ),
619 ),
620 );
621 hash.insert(
622 10,
623 (
624 r"(?P<r>\[31m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
626 format!(
627 r"<span style='color:{};'>$word</span>",
628 color_hm.get("red").expect("Error getting red text color"),
629 ),
630 ),
631 );
632 hash.insert(
633 11,
634 (
635 r"(?P<g>\[32m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
637 format!(
638 r"<span style='color:{};'>$word</span>",
639 color_hm
640 .get("green")
641 .expect("Error getting green text color"),
642 ),
643 ),
644 );
645 hash.insert(
646 12,
647 (
648 r"(?P<y>\[33m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
650 format!(
651 r"<span style='color:{};'>$word</span>",
652 color_hm
653 .get("yellow")
654 .expect("Error getting yellow text color"),
655 ),
656 ),
657 );
658 hash.insert(
659 13,
660 (
661 r"(?P<u>\[34m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
663 format!(
664 r"<span style='color:{};'>$word</span>",
665 color_hm.get("blue").expect("Error getting blue text color"),
666 ),
667 ),
668 );
669 hash.insert(
670 14,
671 (
672 r"(?P<m>\[35m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
674 format!(
675 r"<span style='color:{};'>$word</span>",
676 color_hm
677 .get("magenta")
678 .expect("Error getting magenta text color"),
679 ),
680 ),
681 );
682 hash.insert(
683 15,
684 (
685 r"(?P<c>\[36m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
687 format!(
688 r"<span style='color:{};'>$word</span>",
689 color_hm.get("cyan").expect("Error getting cyan text color"),
690 ),
691 ),
692 );
693 hash.insert(
694 16,
695 (
696 r"(?P<w>\[37m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
700 format!(
701 r"<span style='color:{};'>$word</span>",
702 color_hm
703 .get("foreground")
704 .expect("Error getting white text color"),
705 ),
706 ),
707 );
708}
709
710fn setup_no_color_regexes(hash: &mut HashMap<u32, (&'static str, String)>) {
711 hash.insert(
715 0,
716 (
717 r"(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])",
718 r"$name_group_doesnt_exist".to_string(),
719 ),
720 );
721}
722
723fn run_regexes(hash: &HashMap<u32, (&'static str, String)>, contents: &str) -> String {
724 let mut working_string = contents.to_owned();
725 let hash_count: u32 = hash.len() as u32;
726 for n in 0..hash_count {
727 let value = hash.get(&n).expect("error getting hash at index");
728 let re = Regex::new(value.0).expect("problem with color regex");
729 let after = re.replace_all(&working_string, &value.1[..]).to_string();
730 working_string = after;
731 }
732 working_string
733}
734
735#[cfg(test)]
736mod tests {
737 use super::*;
738
739 #[test]
740 fn test_examples() {
741 use crate::test_examples_with_commands;
742 use nu_command::ToXml;
743
744 test_examples_with_commands(ToHtml {}, &[&ToXml])
745 }
746
747 #[test]
748 fn get_theme_from_asset_file_returns_default() {
749 let result = super::get_theme_from_asset_file(false, None);
750
751 assert!(result.is_ok(), "Expected Ok result for None theme");
752
753 let theme_map = result.unwrap();
754
755 assert_eq!(
756 theme_map.get("background").map(String::as_str),
757 Some("white"),
758 "Expected default background color to be white"
759 );
760
761 assert_eq!(
762 theme_map.get("foreground").map(String::as_str),
763 Some("black"),
764 "Expected default foreground color to be black"
765 );
766
767 assert!(
768 theme_map.contains_key("red"),
769 "Expected default theme to have a 'red' color"
770 );
771
772 assert!(
773 theme_map.contains_key("bold_green"),
774 "Expected default theme to have a 'bold_green' color"
775 );
776 }
777
778 #[test]
779 fn returns_a_valid_theme() {
780 let theme_name = "Dracula".to_string().into_spanned(Span::new(0, 7));
781 let result = super::get_theme_from_asset_file(false, Some(&theme_name));
782
783 assert!(result.is_ok(), "Expected Ok result for valid theme");
784 let theme_map = result.unwrap();
785 let required_keys = [
786 "background",
787 "foreground",
788 "red",
789 "green",
790 "blue",
791 "bold_red",
792 "bold_green",
793 "bold_blue",
794 ];
795
796 for key in required_keys {
797 assert!(
798 theme_map.contains_key(key),
799 "Expected theme to contain key '{key}'"
800 );
801 }
802 }
803
804 #[test]
805 fn fails_with_unknown_theme_name() {
806 let result = super::get_theme_from_asset_file(
807 false,
808 Some(&"doesnt-exist".to_string().into_spanned(Span::new(0, 13))),
809 );
810
811 assert!(result.is_err(), "Expected error for invalid theme name");
812
813 if let Err(err) = result {
814 assert!(
815 matches!(err, ShellError::TypeMismatch { .. }),
816 "Expected TypeMismatch error, got: {err:?}"
817 );
818
819 if let ShellError::TypeMismatch { err_message, span } = err {
820 assert!(
821 err_message.contains("doesnt-exist"),
822 "Error message should mention theme name, got: {err_message}"
823 );
824 assert_eq!(span.start, 0);
825 assert_eq!(span.end, 13);
826 }
827 }
828 }
829}