From 04f7bc897e70f9ca3967d919de57999229484e30 Mon Sep 17 00:00:00 2001 From: Shunsuke Hayashi Date: Sat, 22 Nov 2025 18:10:39 +0900 Subject: [PATCH] feat(tui): implement syntax highlighting for code blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add syntax.rs with syntect integration: - SyntaxHighlighter with configurable themes - Support for Rust, Python, JavaScript, TypeScript, etc. - Language alias normalization (js→JavaScript, py→Python) - highlight_code() and render_code_block() helpers - Global syntax/theme sets with lazy loading - Fallback for unknown languages - 10 unit tests Dependencies added: syntect, once_cell Closes #12 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Cargo.toml | 2 + crates/miyabi-tui/Cargo.toml | 2 + crates/miyabi-tui/src/lib.rs | 2 + crates/miyabi-tui/src/syntax.rs | 278 ++++++++++++++++++++++++++++++++ 4 files changed, 284 insertions(+) create mode 100644 crates/miyabi-tui/src/syntax.rs diff --git a/Cargo.toml b/Cargo.toml index f8f2635..65cfee2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ crossterm = { version = "0.29.0", features = ["bracketed-paste", "event-stream"] textwrap = "0.16" unicode-width = "0.2" pulldown-cmark = "0.12" +syntect = "5" # CLI clap = { version = "4", features = ["derive"] } @@ -59,3 +60,4 @@ reqwest = { version = "0.12", features = ["json", "stream"] } # Utilities chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4", "serde"] } +once_cell = "1" diff --git a/crates/miyabi-tui/Cargo.toml b/crates/miyabi-tui/Cargo.toml index e146c99..217072a 100644 --- a/crates/miyabi-tui/Cargo.toml +++ b/crates/miyabi-tui/Cargo.toml @@ -29,6 +29,7 @@ crossterm = { workspace = true } textwrap = { workspace = true } unicode-width = { workspace = true } pulldown-cmark = { workspace = true } +syntect = { workspace = true } # Async Runtime tokio = { workspace = true } @@ -44,3 +45,4 @@ serde_json = { workspace = true } # Utilities chrono = { workspace = true } +once_cell = { workspace = true } diff --git a/crates/miyabi-tui/src/lib.rs b/crates/miyabi-tui/src/lib.rs index 6dbb962..3d642fb 100644 --- a/crates/miyabi-tui/src/lib.rs +++ b/crates/miyabi-tui/src/lib.rs @@ -12,6 +12,7 @@ pub mod markdown_stream; pub mod diff_render; pub mod diff_viewer; pub mod markdown_parser; +pub mod syntax; pub use app::App; pub use event::{Event, EventHandler}; @@ -22,3 +23,4 @@ pub use markdown_stream::{MarkdownStream, StreamState, StreamBuffer}; pub use diff_render::{DiffRender, DiffLine, DiffLineType, DiffHunk, FileDiff}; pub use diff_viewer::{DiffViewer, DiffViewerOptions, DiffColors, render_diff, render_diff_minimal}; pub use markdown_parser::MarkdownParser; +pub use syntax::{SyntaxHighlighter, highlight_code, render_code_block, normalize_language}; diff --git a/crates/miyabi-tui/src/syntax.rs b/crates/miyabi-tui/src/syntax.rs new file mode 100644 index 0000000..1aff49c --- /dev/null +++ b/crates/miyabi-tui/src/syntax.rs @@ -0,0 +1,278 @@ +//! Syntax Highlighting +//! +//! This module provides syntax highlighting for code blocks using syntect. + +use once_cell::sync::Lazy; +use ratatui::{ + style::{Color, Style}, + text::{Line, Span}, +}; +use syntect::{ + easy::HighlightLines, + highlighting::{Theme, ThemeSet}, + parsing::SyntaxSet, + util::LinesWithEndings, +}; + +/// Global syntax set (loaded once) +static SYNTAX_SET: Lazy = Lazy::new(SyntaxSet::load_defaults_newlines); + +/// Global theme set +static THEME_SET: Lazy = Lazy::new(ThemeSet::load_defaults); + +/// Get the default theme +fn default_theme() -> &'static Theme { + &THEME_SET.themes["base16-ocean.dark"] +} + +/// Syntax highlighter for code blocks +#[derive(Debug, Clone)] +pub struct SyntaxHighlighter { + /// Theme name to use + theme_name: String, +} + +impl Default for SyntaxHighlighter { + fn default() -> Self { + Self::new() + } +} + +impl SyntaxHighlighter { + /// Create a new syntax highlighter + pub fn new() -> Self { + Self { + theme_name: "base16-ocean.dark".to_string(), + } + } + + /// Create with a specific theme + pub fn with_theme(theme_name: impl Into) -> Self { + Self { + theme_name: theme_name.into(), + } + } + + /// Get the theme to use + fn get_theme(&self) -> &Theme { + THEME_SET + .themes + .get(&self.theme_name) + .unwrap_or_else(default_theme) + } + + /// Highlight code and return styled lines + pub fn highlight(&self, code: &str, language: &str) -> Vec> { + let syntax = SYNTAX_SET + .find_syntax_by_token(language) + .or_else(|| SYNTAX_SET.find_syntax_by_extension(language)) + .unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text()); + + let theme = self.get_theme(); + let mut highlighter = HighlightLines::new(syntax, theme); + let mut lines = Vec::new(); + + for line in LinesWithEndings::from(code) { + match highlighter.highlight_line(line, &SYNTAX_SET) { + Ok(ranges) => { + let spans: Vec> = ranges + .into_iter() + .map(|(style, text)| { + let fg = Color::Rgb( + style.foreground.r, + style.foreground.g, + style.foreground.b, + ); + Span::styled( + text.trim_end_matches('\n').to_string(), + Style::default().fg(fg), + ) + }) + .collect(); + lines.push(Line::from(spans)); + } + Err(_) => { + // Fallback to plain text + lines.push(Line::from(Span::styled( + line.trim_end_matches('\n').to_string(), + Style::default().fg(Color::Green), + ))); + } + } + } + + lines + } + + /// Highlight a single line of code + pub fn highlight_line(&self, line: &str, language: &str) -> Line<'static> { + let lines = self.highlight(line, language); + lines.into_iter().next().unwrap_or_default() + } + + /// Get list of supported languages + pub fn supported_languages() -> Vec { + SYNTAX_SET + .syntaxes() + .iter() + .map(|s| s.name.clone()) + .collect() + } + + /// Check if a language is supported + pub fn is_supported(language: &str) -> bool { + SYNTAX_SET.find_syntax_by_token(language).is_some() + || SYNTAX_SET.find_syntax_by_extension(language).is_some() + } +} + +/// Map common language aliases to syntect names +pub fn normalize_language(lang: &str) -> &str { + match lang.to_lowercase().as_str() { + "js" | "javascript" => "JavaScript", + "ts" | "typescript" => "TypeScript", + "py" | "python" => "Python", + "rb" | "ruby" => "Ruby", + "rs" | "rust" => "Rust", + "go" | "golang" => "Go", + "c" => "C", + "cpp" | "c++" => "C++", + "cs" | "csharp" => "C#", + "java" => "Java", + "sh" | "bash" | "shell" => "Bourne Again Shell (bash)", + "zsh" => "Bourne Again Shell (bash)", + "json" => "JSON", + "yaml" | "yml" => "YAML", + "toml" => "TOML", + "xml" => "XML", + "html" => "HTML", + "css" => "CSS", + "sql" => "SQL", + "md" | "markdown" => "Markdown", + "dockerfile" => "Dockerfile", + "makefile" | "make" => "Makefile", + _ => lang, + } +} + +/// Highlight code with automatic language detection +pub fn highlight_code(code: &str, language: &str) -> Vec> { + let highlighter = SyntaxHighlighter::new(); + let normalized = normalize_language(language); + highlighter.highlight(code, normalized) +} + +/// Render a code block with borders +pub fn render_code_block(code: &str, language: &str) -> Vec> { + let mut lines = Vec::new(); + + // Header + let header = if language.is_empty() { + "```".to_string() + } else { + format!("```{}", language) + }; + lines.push(Line::from(Span::styled( + header, + Style::default().fg(Color::DarkGray), + ))); + + // Highlighted code + let highlighted = highlight_code(code, language); + lines.extend(highlighted); + + // Footer + lines.push(Line::from(Span::styled( + "```", + Style::default().fg(Color::DarkGray), + ))); + + lines +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_highlighter_creation() { + let highlighter = SyntaxHighlighter::new(); + assert_eq!(highlighter.theme_name, "base16-ocean.dark"); + } + + #[test] + fn test_highlight_rust() { + let highlighter = SyntaxHighlighter::new(); + let code = "fn main() {\n println!(\"Hello\");\n}"; + let lines = highlighter.highlight(code, "rust"); + + assert_eq!(lines.len(), 3); + } + + #[test] + fn test_highlight_python() { + let highlighter = SyntaxHighlighter::new(); + let code = "def hello():\n print('Hello')"; + let lines = highlighter.highlight(code, "python"); + + assert_eq!(lines.len(), 2); + } + + #[test] + fn test_highlight_unknown_language() { + let highlighter = SyntaxHighlighter::new(); + let code = "some code"; + let lines = highlighter.highlight(code, "unknown_lang_xyz"); + + // Should fall back to plain text + assert_eq!(lines.len(), 1); + } + + #[test] + fn test_normalize_language() { + assert_eq!(normalize_language("js"), "JavaScript"); + assert_eq!(normalize_language("ts"), "TypeScript"); + assert_eq!(normalize_language("py"), "Python"); + assert_eq!(normalize_language("rs"), "Rust"); + } + + #[test] + fn test_is_supported() { + assert!(SyntaxHighlighter::is_supported("rust")); + assert!(SyntaxHighlighter::is_supported("python")); + assert!(SyntaxHighlighter::is_supported("javascript")); + } + + #[test] + fn test_render_code_block() { + let code = "let x = 1;"; + let lines = render_code_block(code, "rust"); + + // Should have header, code, footer + assert!(lines.len() >= 3); + } + + #[test] + fn test_highlight_code_helper() { + let code = "console.log('hello');"; + let lines = highlight_code(code, "js"); + + assert_eq!(lines.len(), 1); + } + + #[test] + fn test_empty_code() { + let highlighter = SyntaxHighlighter::new(); + let lines = highlighter.highlight("", "rust"); + + assert!(lines.is_empty() || lines.len() == 1); + } + + #[test] + fn test_multiline_code() { + let code = "fn main() {\n let x = 1;\n let y = 2;\n println!(\"{}\", x + y);\n}"; + let lines = highlight_code(code, "rust"); + + assert_eq!(lines.len(), 5); + } +}