1use crate::errors::{Result, RichError};
14use crate::style::{Meta, MetaValue, Style, StyleType};
15use crate::text::{Span, Text};
16
17struct RawSpan {
18 start: usize,
19 end: usize,
20 style: StyleType,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct Tag {
26 pub name: String,
28 pub parameters: Option<String>,
30}
31
32impl Tag {
33 pub fn new(name: impl Into<String>, parameters: Option<String>) -> Self {
35 Tag {
36 name: name.into(),
37 parameters,
38 }
39 }
40
41 pub fn markup(&self) -> String {
44 match &self.parameters {
45 None => format!("[{}]", self.name),
46 Some(parameters) => format!("[{}={}]", self.name, parameters),
47 }
48 }
49}
50
51impl std::fmt::Display for Tag {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 match &self.parameters {
55 None => f.write_str(&self.name),
56 Some(parameters) => write!(f, "{} {}", self.name, parameters),
57 }
58 }
59}
60
61pub(crate) fn py_repr_str(value: &str) -> String {
63 let quote = if value.contains('\'') && !value.contains('"') {
64 '"'
65 } else {
66 '\''
67 };
68 let mut out = String::with_capacity(value.len() + 2);
69 out.push(quote);
70 for ch in value.chars() {
71 match ch {
72 '\\' => out.push_str("\\\\"),
73 '\n' => out.push_str("\\n"),
74 '\r' => out.push_str("\\r"),
75 '\t' => out.push_str("\\t"),
76 ch if ch == quote => {
77 out.push('\\');
78 out.push(ch);
79 }
80 ch if (ch as u32) < 0x20 || ch as u32 == 0x7f => {
81 out.push_str(&format!("\\x{:02x}", ch as u32));
82 }
83 ch => out.push(ch),
84 }
85 }
86 out.push(quote);
87 out
88}
89
90fn is_tag_start(c: char) -> bool {
94 c.is_ascii_lowercase() || c == '#' || c == '/' || c == '@'
95}
96
97#[cfg(test)]
112static RE_TAGS: std::sync::LazyLock<fancy_regex::Regex> = std::sync::LazyLock::new(|| {
113 fancy_regex::Regex::new(r"((\\*)\[([a-z#/@][^\[]*?)\])").expect("RE_TAGS is a valid pattern")
114});
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120struct TagMatch {
121 start: usize,
122 bracket: usize,
123 end: usize,
124}
125
126fn tag_matches(markup: &str) -> Vec<TagMatch> {
135 let bytes = markup.as_bytes();
136 let mut matches = Vec::new();
137 let mut searched = 0usize;
139 let mut at = 0usize;
140 while let Some(offset) = bytes[at..].iter().position(|&b| b == b'[') {
141 let bracket = at + offset;
142 at = bracket + 1;
143 if !bytes
144 .get(bracket + 1)
145 .is_some_and(|&b| is_tag_start(char::from(b)))
146 {
147 continue;
148 }
149 let body = bracket + 2;
152 match bytes[body..].iter().position(|&b| b == b']' || b == b'[') {
153 Some(close) if bytes[body + close] == b']' => {
154 let end = body + close + 1;
155 let mut start = bracket;
156 while start > searched && bytes[start - 1] == b'\\' {
157 start -= 1;
158 }
159 matches.push(TagMatch {
160 start,
161 bracket,
162 end,
163 });
164 searched = end;
165 at = end;
166 }
167 Some(_) => {}
169 None => break,
171 }
172 }
173 matches
174}
175
176enum Event<'a> {
178 Text(std::borrow::Cow<'a, str>),
179 Tag {
180 name: &'a str,
181 parameters: Option<String>,
182 position: usize,
184 },
185}
186
187fn parse(markup: &str) -> Result<Vec<Event<'_>>> {
189 let mut events: Vec<Event> = Vec::new();
190 let mut position = 0usize;
191
192 for found in tag_matches(markup) {
193 let whole = &markup[found.start..found.end];
194 let escapes = &markup[found.start..found.bracket];
195 let tag_text = &markup[found.bracket + 1..found.end - 1];
196 let (mut start, end) = (found.start, found.end);
197
198 if start > position {
199 events.push(Event::Text(unescape_brackets(&markup[position..start])));
200 }
201
202 if !escapes.is_empty() {
203 let (backslashes, escaped) = (escapes.len() / 2, escapes.len() % 2 == 1);
206 if backslashes > 0 {
207 events.push(Event::Text("\\".repeat(backslashes).into()));
208 start += backslashes * 2;
209 }
210 if escaped {
211 events.push(Event::Text(whole[escapes.len()..].into()));
214 position = end;
215 continue;
216 }
217 }
218
219 let (name, parameters) = match tag_text.split_once('=') {
222 Some((name, params)) => (name, Some(params.to_string())),
223 None => (tag_text, None),
224 };
225 events.push(Event::Tag {
226 name,
227 parameters,
228 position: start,
229 });
230 position = end;
231 }
232
233 if position < markup.len() {
234 events.push(Event::Text(unescape_brackets(&markup[position..])));
235 }
236 Ok(events)
237}
238
239fn unescape_brackets(text: &str) -> std::borrow::Cow<'_, str> {
242 if !text.contains('\\') {
245 return text.into();
246 }
247 text.replace("\\[", "[").into()
248}
249
250fn push_plain(plain: &mut String, chunk: &str) {
257 if crate::text::has_control_code(chunk) {
258 plain.extend(chunk.chars().filter(|c| !crate::text::is_control_code(*c)));
259 } else {
260 plain.push_str(chunk);
261 }
262}
263
264pub fn escape(markup: &str) -> String {
268 let bytes = markup.as_bytes();
269 let mut out = String::with_capacity(markup.len() + 2);
270 let mut i = 0;
271 while i < bytes.len() {
272 let run_start = i;
274 while i < bytes.len() && bytes[i] == b'\\' {
275 i += 1;
276 }
277 let backslashes = i - run_start;
278 let is_opener =
280 bytes.get(i) == Some(&b'[') && markup[i + 1..].chars().next().is_some_and(is_tag_start);
281 if is_opener {
282 for _ in 0..backslashes * 2 + 1 {
284 out.push('\\');
285 }
286 out.push('[');
287 i += 1;
288 } else {
289 for _ in 0..backslashes {
290 out.push('\\');
291 }
292 if let Some(c) = markup[i..].chars().next() {
293 out.push(c);
294 i += c.len_utf8();
295 }
296 }
297 }
298 if out.ends_with('\\') && !out.ends_with("\\\\") {
300 out.push('\\');
301 }
302 out
303}
304
305pub fn render(markup: &str) -> Result<Text> {
312 render_inner(markup, false, &StyleType::default())
313}
314
315pub fn render_with(
320 markup: &str,
321 style: impl Into<StyleType>,
322 emoji: bool,
323 emoji_variant: Option<crate::emoji::EmojiVariant>,
324) -> Result<Text> {
325 let style = style.into();
326 if !markup.contains('[') {
327 let plain = if emoji {
328 crate::emoji::replace_with_variant(markup, emoji_variant)
329 } else {
330 markup.to_string()
331 };
332 return Ok(Text::styled(plain, style));
333 }
334 render_inner(markup, emoji, &style)
335}
336
337pub fn render_emoji(markup: &str) -> Result<Text> {
346 render_inner(markup, true, &StyleType::default())
347}
348
349fn match_handler(parameters: &str) -> Option<(&str, Option<&str>)> {
351 let is_name = |c: char| c.is_alphanumeric() || c == '_' || c == '.';
352 if parameters.chars().all(is_name) {
355 return Some((parameters, None));
356 }
357 let open = parameters.find(|c: char| !is_name(c))?;
358 let (name, rest) = parameters.split_at(open);
359 if rest.starts_with('(') && rest.ends_with(')') && rest.len() >= 2 && !rest.contains('\n') {
362 return Some((name, Some(rest)));
363 }
364 None
365}
366
367fn handler_meta(name: &str, parameters: Option<&str>) -> Result<Meta> {
371 let mut meta = Meta::new();
372 let Some(raw) = parameters.filter(|parameters| !parameters.is_empty()) else {
373 meta.insert(name, MetaValue::Tuple(Vec::new()));
374 return Ok(meta);
375 };
376 let mut parameters = raw.trim();
377 let mut handler_name = "";
378 if let Some((handler, arguments)) = match_handler(parameters) {
379 handler_name = handler;
380 parameters = arguments.unwrap_or("()");
381 }
382 let value = literal_eval(parameters).map_err(|error| {
383 RichError::Markup(match error {
384 LiteralError::Syntax(message) => format!(
385 "error parsing {} in {}; {message}",
386 py_repr_str(parameters),
387 py_repr_str(raw)
388 ),
389 LiteralError::Value(message) => {
390 format!("error parsing {}; {message}", py_repr_str(raw))
391 }
392 })
393 })?;
394 let value = if handler_name.is_empty() {
395 value
396 } else {
397 let arguments = match value {
398 MetaValue::Tuple(items) => MetaValue::Tuple(items),
399 other => MetaValue::Tuple(vec![other]),
400 };
401 MetaValue::Tuple(vec![MetaValue::Str(handler_name.to_string()), arguments])
402 };
403 meta.insert(name, value);
404 Ok(meta)
405}
406
407#[derive(Debug, PartialEq)]
410enum LiteralError {
411 Syntax(String),
412 Value(String),
413}
414
415fn literal_eval(source: &str) -> std::result::Result<MetaValue, LiteralError> {
420 tokenize_check(source)?;
421 let mut parser = LiteralParser {
422 chars: source
423 .trim_matches(|c| c == ' ' || c == '\t')
424 .chars()
425 .collect(),
426 position: 0,
427 };
428 let value = parser.expression()?;
429 parser.skip_space();
430 if parser.position < parser.chars.len() {
431 return Err(parser.trailing());
432 }
433 Ok(value)
434}
435
436fn tokenize_check(source: &str) -> std::result::Result<(), LiteralError> {
439 let syntax = |message: String| Err(LiteralError::Syntax(message));
440 let mut open: Vec<char> = Vec::new();
441 let mut chars = source.chars();
442 while let Some(c) = chars.next() {
443 match c {
444 '\'' | '"' => loop {
445 match chars.next() {
446 None => {
447 return syntax(
448 "unterminated string literal (detected at line 1)".to_string(),
449 )
450 }
451 Some('\\') => {
452 chars.next();
453 }
454 Some(end) if end == c => break,
455 Some(_) => {}
456 }
457 },
458 '(' | '[' | '{' => open.push(c),
459 ')' | ']' | '}' => match open.pop() {
460 None => return syntax(format!("unmatched '{c}'")),
461 Some(opener) => {
462 let expected = match opener {
463 '(' => ')',
464 '[' => ']',
465 _ => '}',
466 };
467 if c != expected {
468 return syntax(format!(
469 "closing parenthesis '{c}' does not match opening parenthesis '{opener}'"
470 ));
471 }
472 }
473 },
474 _ => {}
475 }
476 }
477 match open.last() {
478 Some(opener) => syntax(format!("'{opener}' was never closed")),
479 None => Ok(()),
480 }
481}
482
483struct LiteralParser {
484 chars: Vec<char>,
485 position: usize,
486}
487
488impl LiteralParser {
489 fn peek(&self) -> Option<char> {
490 self.chars.get(self.position).copied()
491 }
492
493 fn skip_space(&mut self) {
494 while matches!(self.peek(), Some(' ' | '\t')) {
495 self.position += 1;
496 }
497 }
498
499 fn malformed() -> LiteralError {
500 LiteralError::Value("malformed node or string".to_string())
501 }
502
503 fn trailing(&self) -> LiteralError {
506 match self.peek() {
507 Some(c) if "+-*/%&|^<>=.@[(".contains(c) => Self::malformed(),
508 Some(')') => LiteralError::Syntax("unmatched ')'".to_string()),
509 Some(']') => LiteralError::Syntax("unmatched ']'".to_string()),
510 _ => LiteralError::Syntax("invalid syntax".to_string()),
511 }
512 }
513
514 fn expression(&mut self) -> std::result::Result<MetaValue, LiteralError> {
515 self.skip_space();
516 let Some(c) = self.peek() else {
517 return Err(LiteralError::Syntax("invalid syntax".to_string()));
518 };
519 match c {
520 '(' | '[' => self.sequence(c),
521 '\'' | '"' => {
522 let mut value = self.string()?;
523 loop {
525 self.skip_space();
526 match self.peek() {
527 Some('\'' | '"') => value.push_str(&self.string()?),
528 _ => break,
529 }
530 }
531 Ok(MetaValue::Str(value))
532 }
533 '+' | '-' => {
534 self.position += 1;
535 self.skip_space();
536 match self.peek() {
537 Some(d) if d.is_ascii_digit() || d == '.' => match self.number()? {
538 MetaValue::Int(value) if c == '-' => Ok(MetaValue::Int(-value)),
539 MetaValue::Float(value) if c == '-' => Ok(MetaValue::Float(-value)),
540 value => Ok(value),
541 },
542 _ => Err(Self::malformed()),
543 }
544 }
545 c if c.is_ascii_digit() || c == '.' => self.number(),
546 c if c.is_alphabetic() || c == '_' => {
547 let start = self.position;
548 while self.peek().is_some_and(|c| c.is_alphanumeric() || c == '_') {
549 self.position += 1;
550 }
551 let word: String = self.chars[start..self.position].iter().collect();
552 match word.as_str() {
553 "True" => Ok(MetaValue::Bool(true)),
554 "False" => Ok(MetaValue::Bool(false)),
555 "None" => Ok(MetaValue::None),
556 _ => Err(Self::malformed()),
557 }
558 }
559 '{' => Err(Self::malformed()),
560 _ => Err(LiteralError::Syntax("invalid syntax".to_string())),
561 }
562 }
563
564 fn sequence(&mut self, open: char) -> std::result::Result<MetaValue, LiteralError> {
565 let close = if open == '(' { ')' } else { ']' };
566 self.position += 1;
567 let mut items = Vec::new();
568 let mut trailing_comma = false;
569 loop {
570 self.skip_space();
571 match self.peek() {
572 None => return Err(LiteralError::Syntax(format!("'{open}' was never closed"))),
573 Some(c) if c == close => {
574 self.position += 1;
575 break;
576 }
577 _ => {}
578 }
579 items.push(self.expression()?);
580 self.skip_space();
581 match self.peek() {
582 Some(',') => {
583 self.position += 1;
584 trailing_comma = true;
585 }
586 Some(c) if c == close => {
587 trailing_comma = false;
588 }
589 None => return Err(LiteralError::Syntax(format!("'{open}' was never closed"))),
590 Some(_) => return Err(self.trailing()),
591 }
592 }
593 Ok(if open == '[' {
594 MetaValue::List(items)
595 } else if items.len() == 1 && !trailing_comma {
596 items.pop().expect("one item")
598 } else {
599 MetaValue::Tuple(items)
600 })
601 }
602
603 fn string(&mut self) -> std::result::Result<String, LiteralError> {
604 let quote = self.peek().expect("a quote");
605 self.position += 1;
606 let mut value = String::new();
607 loop {
608 let Some(c) = self.peek() else {
609 return Err(LiteralError::Syntax(
610 "unterminated string literal (detected at line 1)".to_string(),
611 ));
612 };
613 self.position += 1;
614 if c == quote {
615 return Ok(value);
616 }
617 if c != '\\' {
618 value.push(c);
619 continue;
620 }
621 let Some(escaped) = self.peek() else {
622 return Err(LiteralError::Syntax(
623 "unterminated string literal (detected at line 1)".to_string(),
624 ));
625 };
626 self.position += 1;
627 match escaped {
628 'n' => value.push('\n'),
629 't' => value.push('\t'),
630 'r' => value.push('\r'),
631 '0' => value.push('\0'),
632 '\\' | '\'' | '"' => value.push(escaped),
633 other => {
634 value.push('\\');
635 value.push(other);
636 }
637 }
638 }
639 }
640
641 fn number(&mut self) -> std::result::Result<MetaValue, LiteralError> {
642 let start = self.position;
643 while self
644 .peek()
645 .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
646 {
647 let c = self.peek().expect("checked");
648 self.position += 1;
649 if matches!(c, 'e' | 'E') && matches!(self.peek(), Some('+' | '-')) {
651 let radix_prefix = self.chars[start..].starts_with(&['0', 'x'])
652 || self.chars[start..].starts_with(&['0', 'X']);
653 if !radix_prefix {
654 self.position += 1;
655 }
656 }
657 }
658 let word: String = self.chars[start..self.position]
659 .iter()
660 .filter(|&&c| c != '_')
661 .collect();
662 let invalid = || LiteralError::Syntax("invalid syntax".to_string());
663 let lower = word.to_ascii_lowercase();
664 for (prefix, radix) in [("0x", 16), ("0o", 8), ("0b", 2)] {
665 if let Some(digits) = lower.strip_prefix(prefix) {
666 return i64::from_str_radix(digits, radix)
667 .map(MetaValue::Int)
668 .map_err(|_| invalid());
669 }
670 }
671 if word.chars().all(|c| c.is_ascii_digit()) {
672 if word.len() > 1 && word.starts_with('0') && word.chars().any(|c| c != '0') {
674 return Err(invalid());
675 }
676 return word.parse().map(MetaValue::Int).map_err(|_| invalid());
677 }
678 word.parse::<f64>()
679 .ok()
680 .filter(|_| !lower.contains("inf") && !lower.contains("nan"))
681 .map(MetaValue::Float)
682 .ok_or_else(invalid)
683 }
684}
685
686fn render_inner(markup: &str, emoji: bool, base_style: &StyleType) -> Result<Text> {
687 let mut plain = String::with_capacity(markup.len());
689 let mut raw_spans: Vec<RawSpan> = Vec::new();
690 let mut stack: Vec<(String, Option<String>, usize)> = Vec::new();
693
694 for event in parse(markup)? {
695 match event {
696 Event::Text(chunk) if emoji && chunk.contains(':') => {
699 push_plain(&mut plain, &crate::emoji::replace(&chunk))
700 }
701 Event::Text(chunk) => push_plain(&mut plain, &chunk),
702 Event::Tag {
703 name: tag_name,
704 parameters,
705 position,
706 } => {
707 let at = || markup[..position].chars().count();
711 if let Some(name) = tag_name.strip_prefix('/') {
712 let name = name.trim();
713 let end = plain.len();
714 let closing = || Tag::new(tag_name, parameters.clone()).markup();
717 let (open_name, open_parameters, start) = if name.is_empty() {
718 stack.pop().ok_or_else(|| {
720 RichError::Markup(format!(
721 "closing tag '[/]' at position {} has nothing to close",
722 at()
723 ))
724 })?
725 } else {
726 let wanted = Style::normalize(name);
730 let pos = stack
731 .iter()
732 .rposition(|(open, _, _)| *open == wanted)
733 .ok_or_else(|| {
734 RichError::Markup(format!(
735 "closing tag '{}' at position {} doesn't match any open tag",
736 closing(),
737 at()
738 ))
739 })?;
740 stack.remove(pos)
741 };
742 let style = if open_name.starts_with('@') {
743 StyleType::Style(Style::from_meta(handler_meta(
744 &open_name,
745 open_parameters.as_deref(),
746 )?))
747 } else {
748 StyleType::Name(Tag::new(open_name, open_parameters).to_string())
749 };
750 raw_spans.push(RawSpan { start, end, style });
751 } else {
752 stack.push((Style::normalize(tag_name), parameters, plain.len()));
753 }
754 }
755 }
756 }
757
758 let end = plain.len();
760 while let Some((open_name, open_parameters, start)) = stack.pop() {
763 raw_spans.push(RawSpan {
764 start,
765 end,
766 style: StyleType::Name(Tag::new(open_name, open_parameters).to_string()),
767 });
768 }
769
770 let mut spans: Vec<Span> = raw_spans
780 .into_iter()
781 .map(|raw| Span {
782 start: raw.start,
783 end: raw.end,
784 style: raw.style,
785 })
786 .collect();
787 spans.reverse();
797 spans.sort_by_key(|span| span.start);
798
799 let mut text = Text::styled(plain, base_style.clone());
800 for span in spans {
801 text.push_span(span);
802 }
803 Ok(text)
804}
805
806#[cfg(test)]
807mod tests {
808 use super::*;
809
810 #[test]
814 fn tag_matches_are_re_tags() {
815 const ALPHABET: [char; 9] = ['\\', '[', ']', 'a', 'Z', '/', '@', '\n', 'é'];
816 let reference = |text: &str| -> Vec<TagMatch> {
817 RE_TAGS
818 .captures_iter(text)
819 .map(|captures| {
820 let captures = captures.unwrap();
821 let whole = captures.get(1).unwrap();
822 let escapes = captures.get(2).unwrap();
823 let tag = captures.get(3).unwrap();
824 assert_eq!(tag.start(), escapes.end() + 1);
825 assert_eq!(tag.end() + 1, whole.end());
826 TagMatch {
827 start: whole.start(),
828 bracket: escapes.end(),
829 end: whole.end(),
830 }
831 })
832 .collect()
833 };
834 let check = |text: &str| assert_eq!(tag_matches(text), reference(text), "{text:?}");
835 let mut texts = vec![String::new()];
836 for _ in 0..6 {
837 texts = texts
838 .iter()
839 .flat_map(|text| ALPHABET.iter().map(move |c| format!("{text}{c}")))
840 .collect();
841 texts.iter().for_each(|text| check(text));
842 }
843 let mut seed = 0x9e37_79b9u32;
844 for _ in 0..20_000 {
845 seed ^= seed << 13;
846 seed ^= seed >> 17;
847 seed ^= seed << 5;
848 let length = seed as usize % 40;
849 let text: String = (0..length)
850 .map(|_| {
851 seed ^= seed << 13;
852 seed ^= seed >> 17;
853 seed ^= seed << 5;
854 ALPHABET[seed as usize % ALPHABET.len()]
855 })
856 .collect();
857 check(&text);
858 }
859 check("[bold red]x[/] \\[b]y\\\\[i]z[link=https://a.b/c?d=[e]]");
860 }
861
862 #[test]
865 fn error_positions_count_characters_of_the_original() {
866 let message = |source: &str| match render_emoji(source) {
867 Err(RichError::Markup(message)) => message,
868 other => panic!("{other:?}"),
869 };
870 assert!(message("é中[/i]").contains("position 2 "));
871 assert!(message(":smile: [/i]").contains("position 8 "));
872 assert_eq!(render_emoji("[b]:smile:[/b]").unwrap().plain(), "😄");
873 assert_eq!(render("[b]:smile:[/b]").unwrap().plain(), ":smile:");
874 }
875 use crate::color::ColorSystem;
876 use crate::theme::Theme;
877
878 fn render_to_ansi(markup: &str) -> String {
879 let text = render(markup).unwrap();
880 let segments = text.render(&Theme::default_theme(), &Style::new());
881 segments
882 .iter()
883 .map(|s| {
884 s.style
885 .clone()
886 .unwrap_or_default()
887 .render(&s.text, Some(ColorSystem::Truecolor))
888 })
889 .collect()
890 }
891
892 #[test]
893 fn simple_tags() {
894 assert_eq!(
895 render_to_ansi("[bold]Hello[/] [red]World[/]"),
896 "\x1b[1mHello\x1b[0m \x1b[31mWorld\x1b[0m"
897 );
898 }
899
900 #[test]
908 fn control_codes_never_desynchronise_span_offsets() {
909 let text = render("a\r[red]\u{2593}x[/]").expect("valid markup");
910 assert_eq!(text.plain(), "a\u{2593}x");
911 for span in text.spans() {
912 assert!(
913 text.plain().is_char_boundary(span.start)
914 && text.plain().is_char_boundary(span.end),
915 "span {span:?} does not land on char boundaries of {:?}",
916 text.plain()
917 );
918 }
919 let rendered = render_to_ansi("a\r[red]\u{2593}x[/]");
921 assert!(rendered.contains('\u{2593}'), "got {rendered:?}");
922 }
923
924 #[test]
925 fn nested_inner_wins() {
926 let out = render_to_ansi("[red]a[blue]x[/]b[/]");
928 assert_eq!(out, "\x1b[31ma\x1b[0m\x1b[34mx\x1b[0m\x1b[31mb\x1b[0m");
929 }
930
931 #[test]
932 fn escaped_bracket_is_literal() {
933 let text = render("\\[not a tag]").unwrap();
934 assert_eq!(text.plain(), "[not a tag]");
935 }
936
937 #[test]
938 fn theme_name_resolves() {
939 assert_eq!(render_to_ansi("[repr.number]7[/]"), "\x1b[1;36m7\x1b[0m");
943 }
944
945 #[test]
946 fn none_is_the_null_style() {
947 assert!(Style::parse("none").unwrap().is_null());
951 assert!(Style::parse("").unwrap().is_null());
952 assert!(Style::parse("bold none").is_err());
953 }
954
955 #[test]
956 fn bracket_is_literal_unless_tag_start() {
957 assert_eq!(render("[Hello] world").unwrap().plain(), "[Hello] world");
959 assert_eq!(render("[42] x").unwrap().plain(), "[42] x");
960 }
961
962 #[test]
963 fn hex_tag_and_meta_tag() {
964 assert_eq!(
966 render_to_ansi("[#ff0000]x[/]"),
967 "\x1b[38;2;255;0;0mx\x1b[0m"
968 );
969 assert_eq!(render_to_ansi("[@foo]y[/]"), "y");
970 }
971
972 #[test]
973 fn unmatched_closing_tags_error() {
974 assert!(render("a[/]b").is_err());
975 assert!(render("[bold]a[/red]").is_err());
976 assert!(render("x[/red]y").is_err());
977 assert!(render("[bold]hi").is_ok());
979 }
980
981 #[test]
982 fn escape_matches_upstream() {
983 assert_eq!(escape("[bold]"), "\\[bold]");
984 assert_eq!(escape("a[b]c"), "a\\[b]c");
985 assert_eq!(escape("back\\slash"), "back\\slash");
986 assert_eq!(escape("trailing\\"), "trailing\\\\");
987 assert_eq!(escape("[Hello]"), "[Hello]"); assert_eq!(render(&escape("[bold]")).unwrap().plain(), "[bold]");
990 }
991}