1use std::sync::LazyLock;
2
3use ratatui::style::{Color, Modifier, Style};
4use ratatui::text::{Line, Span};
5use syntect::highlighting::ThemeSet;
6use syntect::parsing::{ParseState, Scope, ScopeStack, SyntaxSet};
7
8use crate::tui::theme::{SyntaxStyles, Theme};
9
10static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
11static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
12
13#[derive(Clone, Copy)]
14enum ScopeKind {
15 Keyword,
16 Str,
17 Comment,
18 Function,
19 Type,
20 Number,
21 Constant,
22 Attribute,
23}
24
25static SCOPE_MATCHERS: LazyLock<Vec<(Scope, ScopeKind)>> = LazyLock::new(|| {
26 use ScopeKind::*;
27 [
28 ("entity.other.attribute-name", Attribute),
29 ("entity.name.function", Function),
30 ("entity.name.type", Type),
31 ("entity.name.class", Type),
32 ("entity.name.tag", Keyword),
33 ("constant.character", Str),
34 ("constant.language", Constant),
35 ("constant.numeric", Number),
36 ("support.function", Function),
37 ("support.type", Type),
38 ("variable.language", Keyword),
39 ("meta.attribute", Attribute),
40 ("keyword", Keyword),
41 ("storage", Keyword),
42 ("comment", Comment),
43 ("string", Str),
44 ]
45 .into_iter()
46 .filter_map(|(s, kind)| Some((Scope::new(s).ok()?, kind)))
47 .collect()
48});
49
50fn resolve_scope(stack: &ScopeStack, styles: &SyntaxStyles) -> Style {
51 for scope in stack.as_slice().iter().rev() {
52 for (prefix, kind) in SCOPE_MATCHERS.iter() {
53 if prefix.is_prefix_of(*scope) {
54 return match kind {
55 ScopeKind::Keyword => styles.keyword,
56 ScopeKind::Str => styles.string,
57 ScopeKind::Comment => styles.comment,
58 ScopeKind::Function => styles.function,
59 ScopeKind::Type => styles.type_name,
60 ScopeKind::Number => styles.number,
61 ScopeKind::Constant => styles.constant,
62 ScopeKind::Attribute => styles.attribute,
63 };
64 }
65 }
66 }
67 Style::default()
68}
69
70fn word_wrap(text: &str, max_width: usize) -> Vec<String> {
71 if max_width == 0 {
72 return vec![text.to_string()];
73 }
74 let mut result: Vec<String> = Vec::new();
75 for raw in text.lines() {
76 if raw.is_empty() {
77 result.push(String::new());
78 continue;
79 }
80 let mut current = String::new();
81 let mut current_len: usize = 0;
82 for word in raw.split_whitespace() {
83 let word_len = word.chars().count();
84 if current.is_empty() {
85 current.push_str(word);
86 current_len = word_len;
87 } else if current_len + 1 + word_len <= max_width {
88 current.push(' ');
89 current.push_str(word);
90 current_len += 1 + word_len;
91 } else {
92 result.push(std::mem::take(&mut current));
93 current.push_str(word);
94 current_len = word_len;
95 }
96 }
97 if !current.is_empty() {
98 result.push(current);
99 }
100 }
101 if result.is_empty() {
102 result.push(String::new());
103 }
104 result
105}
106
107fn truncate_code_line(line: &str, max_chars: usize) -> String {
108 if line.chars().count() <= max_chars {
109 return line.to_string();
110 }
111 let truncated: String = line.chars().take(max_chars.saturating_sub(1)).collect();
112 format!("{}…", truncated)
113}
114
115pub fn render_markdown(text: &str, theme: &Theme, width: u16) -> Vec<Line<'static>> {
116 let mut lines: Vec<Line<'static>> = Vec::new();
117 let mut in_code_block = false;
118 let mut code_lang = String::new();
119 let mut code_lines: Vec<String> = Vec::new();
120 let mut just_closed_code = false;
121
122 for raw_line in text.lines() {
123 if raw_line.starts_with("```") {
124 if in_code_block {
125 render_code_block(&code_lang, &code_lines, theme, width, &mut lines);
126 code_lines.clear();
127 code_lang.clear();
128 in_code_block = false;
129 just_closed_code = true;
130 } else {
131 in_code_block = true;
132 code_lang = raw_line.trim_start_matches('`').trim().to_string();
133 if let Some(last) = lines.last() {
134 if last.spans.iter().all(|s| s.content.trim().is_empty()) {
135 lines.pop();
136 }
137 }
138 }
139 continue;
140 }
141
142 if in_code_block {
143 code_lines.push(raw_line.to_string());
144 continue;
145 }
146
147 if raw_line.is_empty() {
148 if just_closed_code {
149 continue;
150 }
151 lines.push(Line::from(""));
152 continue;
153 }
154 just_closed_code = false;
155
156 if let Some(heading) = raw_line.strip_prefix("### ") {
157 lines.push(Line::from(Span::styled(
158 heading.to_string(),
159 theme
160 .heading
161 .patch(Style::default().add_modifier(Modifier::BOLD)),
162 )));
163 } else if let Some(heading) = raw_line.strip_prefix("## ") {
164 lines.push(Line::from(Span::styled(heading.to_string(), theme.heading)));
165 } else if let Some(heading) = raw_line.strip_prefix("# ") {
166 lines.push(Line::from(Span::styled(
167 heading.to_string(),
168 theme
169 .heading
170 .patch(Style::default().add_modifier(Modifier::BOLD)),
171 )));
172 } else if let Some(quote) = raw_line.strip_prefix("> ") {
173 lines.push(Line::from(vec![
174 Span::styled(" │ ", theme.blockquote),
175 Span::styled(quote.to_string(), theme.blockquote),
176 ]));
177 } else if raw_line.starts_with("- ") || raw_line.starts_with("* ") {
178 let content = &raw_line[2..];
179 let prefix_len = 4usize;
180 let wrap_w = (width as usize).saturating_sub(prefix_len);
181 let sub_lines = word_wrap(content, wrap_w);
182 for (i, sub) in sub_lines.into_iter().enumerate() {
183 if i == 0 {
184 let spans = parse_inline(&sub, theme);
185 let mut full = vec![Span::styled(" \u{00b7} ", theme.list_bullet)];
186 full.extend(spans);
187 lines.push(Line::from(full));
188 } else {
189 let spans = parse_inline(&sub, theme);
190 let mut full = vec![Span::raw(" ")];
191 full.extend(spans);
192 lines.push(Line::from(full));
193 }
194 }
195 } else if raw_line
196 .chars()
197 .next()
198 .map(|c| c.is_ascii_digit())
199 .unwrap_or(false)
200 && raw_line.contains(". ")
201 {
202 if let Some(pos) = raw_line.find(". ") {
203 let num = &raw_line[..pos + 2];
204 let content = &raw_line[pos + 2..];
205 let prefix_len = num.chars().count() + 3;
206 let wrap_w = (width as usize).saturating_sub(prefix_len);
207 let sub_lines = word_wrap(content, wrap_w);
208 let indent = " ".repeat(prefix_len);
209 for (i, sub) in sub_lines.into_iter().enumerate() {
210 if i == 0 {
211 let spans = parse_inline(&sub, theme);
212 let mut full = vec![Span::styled(format!(" {} ", num), theme.list_bullet)];
213 full.extend(spans);
214 lines.push(Line::from(full));
215 } else {
216 let spans = parse_inline(&sub, theme);
217 let mut full = vec![Span::raw(indent.clone())];
218 full.extend(spans);
219 lines.push(Line::from(full));
220 }
221 }
222 }
223 } else if raw_line.trim() == "---" || raw_line.trim() == "***" {
224 lines.push(Line::from(Span::styled(
225 "\u{2500}".repeat(width.saturating_sub(4) as usize),
226 theme.border,
227 )));
228 } else {
229 let sub_lines = word_wrap(raw_line, width as usize);
230 for sub in sub_lines {
231 let spans = parse_inline(&sub, theme);
232 lines.push(Line::from(spans));
233 }
234 }
235 }
236
237 if in_code_block {
238 render_code_block(&code_lang, &code_lines, theme, width, &mut lines);
239 }
240
241 let mut deduped: Vec<Line<'static>> = Vec::with_capacity(lines.len());
242 let mut prev_empty = false;
243 for line in lines {
244 let is_empty = line.spans.iter().all(|s| s.content.is_empty());
245 if is_empty && prev_empty {
246 continue;
247 }
248 prev_empty = is_empty;
249 deduped.push(line);
250 }
251 deduped
252}
253
254pub fn render_code_block(
255 lang: &str,
256 code_lines: &[String],
257 theme: &Theme,
258 width: u16,
259 output: &mut Vec<Line<'static>>,
260) {
261 let w = width as usize;
262 let bg = theme.code_bg;
263 let pad = " ";
264 let pad_len = 1;
265
266 output.push(Line::from(""));
267
268 let fill = |content_len: usize| -> String { " ".repeat(w.saturating_sub(content_len)) };
269
270 if !lang.is_empty() {
272 let badge = format!("{}{}", pad, lang);
273 let badge_len = badge.chars().count();
274 output.push(Line::from(vec![
275 Span::styled(badge, Style::default().fg(theme.muted_fg).bg(bg)),
276 Span::styled(fill(badge_len), Style::default().bg(bg)),
277 ]));
278 } else {
279 output.push(Line::from(Span::styled(
280 " ".repeat(w),
281 Style::default().bg(bg),
282 )));
283 }
284
285 let is_diff = lang == "diff" || lang == "patch";
286 if is_diff {
287 for raw_line in code_lines {
288 let line = &truncate_code_line(raw_line, w.saturating_sub(pad_len));
289 let diff_style = if line.starts_with('+') {
290 theme.diff_add.bg(bg)
291 } else if line.starts_with('-') {
292 theme.diff_remove.bg(bg)
293 } else if line.starts_with('@') {
294 theme.diff_hunk.bg(bg)
295 } else {
296 Style::default().fg(theme.fg).bg(bg)
297 };
298 let content = format!("{}{}", pad, line);
299 let content_len = content.chars().count();
300 output.push(Line::from(vec![
301 Span::styled(content, diff_style),
302 Span::styled(fill(content_len), Style::default().bg(bg)),
303 ]));
304 }
305 if code_lines.is_empty() {
306 output.push(Line::from(Span::styled(
307 " ".repeat(w),
308 Style::default().bg(bg),
309 )));
310 }
311 } else if let Some(syntect_theme_name) = theme.syntect_theme
312 && !lang.is_empty()
313 && let Some(syntax) = SYNTAX_SET.find_syntax_by_token(lang)
314 && let Some(st_theme) = THEME_SET.themes.get(syntect_theme_name)
315 {
316 let mut highlighter = syntect::easy::HighlightLines::new(syntax, st_theme);
317 for raw_line in code_lines {
318 let line: &str = &truncate_code_line(raw_line, w.saturating_sub(pad_len));
319 let highlighted = highlighter.highlight_line(line, &SYNTAX_SET);
320 match highlighted {
321 Ok(ranges) => {
322 let mut spans = vec![Span::styled(pad, Style::default().bg(bg))];
323 let mut content_len = pad_len;
324 for (style, text) in ranges {
325 let fg = style.foreground;
326 let clean = text.trim_end_matches('\n');
327 if clean.is_empty() {
328 continue;
329 }
330 content_len += clean.chars().count();
331 spans.push(Span::styled(
332 clean.to_string(),
333 Style::default().fg(Color::Rgb(fg.r, fg.g, fg.b)).bg(bg),
334 ));
335 }
336 spans.push(Span::styled(fill(content_len), Style::default().bg(bg)));
337 output.push(Line::from(spans));
338 }
339 Err(_) => {
340 let content = format!("{}{}", pad, line);
341 let content_len = content.chars().count();
342 output.push(Line::from(vec![
343 Span::styled(content, Style::default().fg(theme.fg).bg(bg)),
344 Span::styled(fill(content_len), Style::default().bg(bg)),
345 ]));
346 }
347 }
348 }
349 if code_lines.is_empty() {
350 output.push(Line::from(Span::styled(
351 " ".repeat(w),
352 Style::default().bg(bg),
353 )));
354 }
355 } else if let Some(styles) = &theme.syntax
356 && !lang.is_empty()
357 && let Some(syntax) = SYNTAX_SET.find_syntax_by_token(lang)
358 {
359 let mut state = ParseState::new(syntax);
360 let mut stack = ScopeStack::new();
361 for raw_line in code_lines {
362 let line = &truncate_code_line(raw_line, w.saturating_sub(pad_len));
363 match state.parse_line(line, &SYNTAX_SET) {
364 Ok(ops) => {
365 let mut spans = vec![Span::styled(pad, Style::default().bg(bg))];
366 let mut content_len = pad_len;
367 let mut prev = 0;
368 for (pos, op) in &ops {
369 let pos = (*pos).min(line.len());
370 if pos > prev {
371 let text = &line[prev..pos];
372 content_len += text.chars().count();
373 spans.push(Span::styled(
374 text.to_string(),
375 resolve_scope(&stack, styles).bg(bg),
376 ));
377 }
378 let _ = stack.apply(op);
379 prev = pos;
380 }
381 if prev < line.len() {
382 let text = &line[prev..];
383 content_len += text.chars().count();
384 spans.push(Span::styled(
385 text.to_string(),
386 resolve_scope(&stack, styles).bg(bg),
387 ));
388 }
389 spans.push(Span::styled(fill(content_len), Style::default().bg(bg)));
390 output.push(Line::from(spans));
391 }
392 Err(_) => {
393 let content = format!("{}{}", pad, line);
394 let content_len = content.chars().count();
395 output.push(Line::from(vec![
396 Span::styled(content, Style::default().fg(theme.fg).bg(bg)),
397 Span::styled(fill(content_len), Style::default().bg(bg)),
398 ]));
399 }
400 }
401 }
402 if code_lines.is_empty() {
403 output.push(Line::from(Span::styled(
404 " ".repeat(w),
405 Style::default().bg(bg),
406 )));
407 }
408 } else {
409 let code_style = Style::default().fg(theme.fg).bg(bg);
410 for raw_line in code_lines {
411 let line = &truncate_code_line(raw_line, w.saturating_sub(pad_len));
412 let content = format!("{}{}", pad, line);
413 let content_len = content.chars().count();
414 output.push(Line::from(vec![
415 Span::styled(content, code_style),
416 Span::styled(fill(content_len), Style::default().bg(bg)),
417 ]));
418 }
419 if code_lines.is_empty() {
420 output.push(Line::from(Span::styled(
421 " ".repeat(w),
422 Style::default().bg(bg),
423 )));
424 }
425 }
426
427 output.push(Line::from(""));
428}
429
430#[allow(clippy::while_let_on_iterator)]
431fn parse_inline(text: &str, theme: &Theme) -> Vec<Span<'static>> {
432 let mut spans: Vec<Span<'static>> = Vec::new();
433 let mut chars = text.char_indices().peekable();
434 let mut current = String::new();
435
436 while let Some((_i, c)) = chars.next() {
437 match c {
438 '`' => {
439 if !current.is_empty() {
440 spans.push(Span::raw(std::mem::take(&mut current)));
441 }
442 let mut code = String::new();
443 let mut closed = false;
444 while let Some((_, ch)) = chars.next() {
445 if ch == '`' {
446 closed = true;
447 break;
448 }
449 code.push(ch);
450 }
451 if closed {
452 spans.push(Span::styled(code, theme.inline_code));
453 } else {
454 spans.push(Span::raw(format!("`{}", code)));
455 }
456 }
457 '*' => {
458 let next_is_star = chars.peek().map(|(_, ch)| *ch == '*').unwrap_or(false);
459 if next_is_star {
460 chars.next();
461 if !current.is_empty() {
462 spans.push(Span::raw(std::mem::take(&mut current)));
463 }
464 let mut bold_text = String::new();
465 let mut closed = false;
466 while let Some((_, ch)) = chars.next() {
467 if ch == '*' && chars.peek().map(|(_, c)| *c == '*').unwrap_or(false) {
468 chars.next();
469 closed = true;
470 break;
471 }
472 bold_text.push(ch);
473 }
474 if closed {
475 spans.push(Span::styled(bold_text, theme.bold));
476 } else {
477 spans.push(Span::raw(format!("**{}", bold_text)));
478 }
479 } else {
480 if !current.is_empty() {
481 spans.push(Span::raw(std::mem::take(&mut current)));
482 }
483 let mut italic_text = String::new();
484 let mut closed = false;
485 while let Some((_, ch)) = chars.next() {
486 if ch == '*' {
487 closed = true;
488 break;
489 }
490 italic_text.push(ch);
491 }
492 if closed {
493 spans.push(Span::styled(italic_text, theme.italic));
494 } else {
495 spans.push(Span::raw(format!("*{}", italic_text)));
496 }
497 }
498 }
499 '[' => {
500 if !current.is_empty() {
501 spans.push(Span::raw(std::mem::take(&mut current)));
502 }
503 let mut link_text = String::new();
504 let mut found_bracket = false;
505 while let Some((_, ch)) = chars.next() {
506 if ch == ']' {
507 found_bracket = true;
508 break;
509 }
510 link_text.push(ch);
511 }
512 if found_bracket && chars.peek().map(|(_, c)| *c == '(').unwrap_or(false) {
513 chars.next();
514 let mut _url = String::new();
515 while let Some((_, ch)) = chars.next() {
516 if ch == ')' {
517 break;
518 }
519 _url.push(ch);
520 }
521 spans.push(Span::styled(link_text, theme.link));
522 } else {
523 spans.push(Span::raw(format!("[{}", link_text)));
524 if found_bracket {
525 spans.push(Span::raw("]"));
526 }
527 }
528 }
529 _ => {
530 current.push(c);
531 }
532 }
533 }
534
535 if !current.is_empty() {
536 spans.push(Span::raw(current));
537 }
538
539 if spans.is_empty() {
540 spans.push(Span::raw(""));
541 }
542
543 spans
544}