1use crate::style::{Style, StyleStack};
13use crate::text::Text;
14
15#[derive(Debug, Clone, PartialEq)]
21pub struct Tag {
22 pub name: String,
23 pub parameters: Option<String>,
24}
25
26impl Tag {
27 pub fn new(name: impl Into<String>) -> Self {
28 Self {
29 name: name.into(),
30 parameters: None,
31 }
32 }
33
34 pub fn with_params(name: impl Into<String>, params: impl Into<String>) -> Self {
35 Self {
36 name: name.into(),
37 parameters: Some(params.into()),
38 }
39 }
40
41 pub fn is_closing(&self) -> bool {
43 self.name == "/" || self.name.starts_with('/')
44 }
45
46 pub fn closing_name(&self) -> &str {
48 if self.name == "/" {
49 ""
50 } else {
51 &self.name[1..]
52 }
53 }
54
55 pub fn markup(&self) -> String {
57 if let Some(ref params) = self.parameters {
58 format!("[{}={}]", self.name, params)
59 } else {
60 format!("[{}]", self.name)
61 }
62 }
63}
64
65const MAX_MARKUP_DEPTH: usize = 100;
72
73pub fn render(markup: &str) -> Text {
79 let mut text = Text::new("");
80 let mut style_stack = StyleStack::new(Style::new());
81
82 let bytes = markup.as_bytes();
83 let len = bytes.len();
84 let mut pos = 0usize;
85
86 while pos < len {
87 if bytes[pos] == b'[' {
88 if pos + 1 < len && bytes[pos + 1] == b'[' {
90 text.append_styled("[", style_stack.current());
91 pos += 2;
92 continue;
93 }
94
95 let end = match bytes[pos..].iter().position(|&c| c == b']') {
97 Some(e) => pos + e,
98 None => {
99 text.append_styled("[", style_stack.current());
101 pos += 1;
102 continue;
103 }
104 };
105
106 let tag_str = std::str::from_utf8(&bytes[pos + 1..end]).unwrap_or("");
108 pos = end + 1;
109
110 if tag_str.is_empty() {
111 continue;
112 }
113
114 let tag = parse_tag(tag_str);
116
117 if tag.is_closing() {
118 let closing = tag.closing_name();
119 if closing.is_empty() {
120 while !style_stack.is_empty() {
122 style_stack.pop();
123 }
124 } else {
125 style_stack.pop_to(closing);
127 }
128 } else {
129 if style_stack.len() < MAX_MARKUP_DEPTH {
132 let style = tag_to_style(&tag);
133 style_stack.push_named(tag.name.clone(), style);
134 }
135 }
136 } else {
137 let start = pos;
139 while pos < len && bytes[pos] != b'[' {
140 pos += 1;
141 }
142 let chunk = &markup[start..pos];
146 let sanitized = crate::export::strip_ansi_escapes(chunk);
148 text.append_styled(sanitized, style_stack.current());
149 }
150 }
151
152 text
153}
154
155fn parse_tag(s: &str) -> Tag {
157 if s.starts_with('/') {
159 return Tag::new(s.to_string());
160 }
161
162 if let Some(eq) = s.find('=') {
164 let name = s[..eq].to_string();
165 let value = s[eq + 1..].to_string();
166 let value = value.trim_matches('"').trim_matches('\'').to_string();
168 return Tag::with_params(name, value);
169 }
170
171 if let Some(lparen) = s.find('(') {
173 if s.ends_with(')') {
174 let name = s[..lparen].to_string();
175 let params = s[lparen + 1..s.len() - 1].to_string();
176 return Tag::with_params(name, params);
177 }
178 }
179
180 Tag::new(s.to_string())
181}
182
183fn tag_to_style(tag: &Tag) -> Style {
185 let name = &tag.name;
186
187 match name.as_str() {
188 "bold" | "b" => Style::new().bold(true),
189 "dim" | "d" => Style::new().dim(true),
190 "italic" | "i" => Style::new().italic(true),
191 "underline" | "u" => Style::new().underline(true),
192 "blink" => Style::new().blink(true),
193 "reverse" | "r" => Style::new().reverse(true),
194 "strike" | "s" => Style::new().strike(true),
195
196 "/bold" | "/b" | "/dim" | "/d" | "/italic" | "/i" | "/underline" | "/u" | "/blink"
197 | "/reverse" | "/r" | "/strike" | "/s" => Style::null(),
198
199 _ => {
200 if let Some(color_name) = name.strip_prefix("on ") {
202 if let Ok(c) = crate::color::Color::parse(color_name) {
203 return Style::new().bgcolor(c);
204 }
205 }
206
207 if let Some(on_pos) = name.find(" on ") {
209 let fg_name = &name[..on_pos];
210 let bg_name = &name[on_pos + 4..];
211 if let Ok(fg) = crate::color::Color::parse(fg_name) {
212 let mut style = Style::new().color(fg);
213 if let Ok(bg) = crate::color::Color::parse(bg_name) {
214 style = style.bgcolor(bg);
215 }
216 return style;
217 }
218 }
219
220 if let Ok(c) = crate::color::Color::parse(name) {
222 return Style::new().color(c);
223 }
224
225 if let Some(ref params) = tag.parameters {
227 if let Ok(c) = crate::color::Color::parse(params) {
228 return Style::new().color(c);
229 }
230 }
231
232 Style::new()
234 }
235 }
236}
237
238pub fn escape(markup: &str) -> String {
244 markup.replace('[', "[[")
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 #[test]
252 fn test_escape() {
253 assert_eq!(escape("[bold]"), "[[bold]");
254 }
255
256 #[test]
257 fn test_render_bold() {
258 let t = render("[bold]Hello[/bold]");
259 assert_eq!(t.plain, "Hello");
260 assert!(!t.spans.is_empty()); }
262
263 #[test]
264 fn test_render_literal_bracket() {
265 let t = render("[[hello]]");
266 assert!(t.plain.contains("hello"));
268 }
269
270 #[test]
271 fn test_render_color() {
272 let t = render("[red]red text[/red]");
273 assert_eq!(t.plain, "red text");
274 assert!(!t.spans.is_empty());
275 }
276
277 #[test]
278 fn test_parse_tag() {
279 let tag = parse_tag("bold");
280 assert_eq!(tag.name, "bold");
281
282 let tag = parse_tag("color=red");
283 assert_eq!(tag.name, "color");
284 assert_eq!(tag.parameters, Some("red".into()));
285
286 let tag = parse_tag("/bold");
287 assert!(tag.is_closing());
288 }
289}