longcipher_leptos_components/components/editor/
syntax.rs

1//! Syntax highlighting support
2//!
3//! Provides code syntax highlighting using syntect.
4
5#[cfg(feature = "syntax-highlighting")]
6use syntect::highlighting::ThemeSet;
7#[cfg(feature = "syntax-highlighting")]
8use syntect::parsing::SyntaxSet;
9
10/// Supported languages for syntax highlighting.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Language {
13    /// Rust
14    Rust,
15    /// JavaScript
16    JavaScript,
17    /// TypeScript
18    TypeScript,
19    /// Python
20    Python,
21    /// HTML
22    Html,
23    /// CSS
24    Css,
25    /// JSON
26    Json,
27    /// YAML
28    Yaml,
29    /// TOML
30    Toml,
31    /// Markdown
32    Markdown,
33    /// SQL
34    Sql,
35    /// Shell/Bash
36    Shell,
37    /// Go
38    Go,
39    /// C
40    C,
41    /// C++
42    Cpp,
43    /// Java
44    Java,
45    /// Plain text (no highlighting)
46    PlainText,
47}
48
49impl Language {
50    /// Detect language from file extension.
51    #[must_use]
52    pub fn from_extension(ext: &str) -> Self {
53        match ext.to_lowercase().as_str() {
54            "rs" => Self::Rust,
55            "js" | "mjs" | "cjs" => Self::JavaScript,
56            "ts" | "mts" | "cts" | "tsx" => Self::TypeScript,
57            "py" | "pyi" => Self::Python,
58            "html" | "htm" => Self::Html,
59            "css" | "scss" | "sass" | "less" => Self::Css,
60            "json" => Self::Json,
61            "yaml" | "yml" => Self::Yaml,
62            "toml" => Self::Toml,
63            "md" | "markdown" => Self::Markdown,
64            "sql" => Self::Sql,
65            "sh" | "bash" | "zsh" | "fish" => Self::Shell,
66            "go" => Self::Go,
67            "c" | "h" => Self::C,
68            "cpp" | "cxx" | "cc" | "hpp" | "hxx" => Self::Cpp,
69            "java" => Self::Java,
70            _ => Self::PlainText,
71        }
72    }
73
74    /// Get the syntect syntax name.
75    #[must_use]
76    pub fn syntax_name(&self) -> &'static str {
77        match self {
78            Self::Rust => "Rust",
79            Self::JavaScript => "JavaScript",
80            Self::TypeScript => "TypeScript",
81            Self::Python => "Python",
82            Self::Html => "HTML",
83            Self::Css => "CSS",
84            Self::Json => "JSON",
85            Self::Yaml => "YAML",
86            Self::Toml => "TOML",
87            Self::Markdown => "Markdown",
88            Self::Sql => "SQL",
89            Self::Shell => "Bourne Again Shell (bash)",
90            Self::Go => "Go",
91            Self::C => "C",
92            Self::Cpp => "C++",
93            Self::Java => "Java",
94            Self::PlainText => "Plain Text",
95        }
96    }
97}
98
99/// Configuration for syntax highlighting.
100#[derive(Debug, Clone)]
101pub struct SyntaxConfig {
102    /// The language to use
103    pub language: Language,
104    /// Whether to use dark theme
105    pub is_dark: bool,
106    /// Whether highlighting is enabled
107    pub enabled: bool,
108}
109
110impl Default for SyntaxConfig {
111    fn default() -> Self {
112        Self {
113            language: Language::PlainText,
114            is_dark: true,
115            enabled: true,
116        }
117    }
118}
119
120/// A highlighted line with styled spans.
121#[derive(Debug, Clone)]
122pub struct HighlightedLine {
123    /// Spans of text with their styles
124    pub spans: Vec<HighlightedSpan>,
125}
126
127/// A span of highlighted text.
128#[derive(Debug, Clone)]
129pub struct HighlightedSpan {
130    /// The text content
131    pub text: String,
132    /// Foreground color (CSS format)
133    pub color: String,
134    /// Font weight (normal, bold)
135    pub font_weight: String,
136    /// Font style (normal, italic)
137    pub font_style: String,
138}
139
140impl HighlightedSpan {
141    /// Create a plain (unstyled) span.
142    #[must_use]
143    pub fn plain(text: impl Into<String>) -> Self {
144        Self {
145            text: text.into(),
146            color: "inherit".to_string(),
147            font_weight: "normal".to_string(),
148            font_style: "normal".to_string(),
149        }
150    }
151
152    /// Generate CSS style string for this span.
153    #[must_use]
154    pub fn style(&self) -> String {
155        format!(
156            "color: {}; font-weight: {}; font-style: {}",
157            self.color, self.font_weight, self.font_style
158        )
159    }
160}
161
162/// Syntax highlighter.
163#[cfg(feature = "syntax-highlighting")]
164pub struct Highlighter {
165    syntax_set: SyntaxSet,
166    theme_set: ThemeSet,
167}
168
169#[cfg(feature = "syntax-highlighting")]
170impl Highlighter {
171    /// Create a new highlighter with default syntax and theme sets.
172    #[must_use]
173    pub fn new() -> Self {
174        Self {
175            syntax_set: SyntaxSet::load_defaults_newlines(),
176            theme_set: ThemeSet::load_defaults(),
177        }
178    }
179
180    /// Highlight a line of code.
181    pub fn highlight_line(&self, line: &str, language: Language, is_dark: bool) -> HighlightedLine {
182        use syntect::easy::HighlightLines;
183
184        let theme_name = if is_dark {
185            "base16-ocean.dark"
186        } else {
187            "base16-ocean.light"
188        };
189
190        let syntax = self
191            .syntax_set
192            .find_syntax_by_name(language.syntax_name())
193            .or_else(|| Some(self.syntax_set.find_syntax_plain_text()));
194
195        let theme = self.theme_set.themes.get(theme_name).unwrap_or_else(|| {
196            self.theme_set
197                .themes
198                .values()
199                .next()
200                .expect("No themes available")
201        });
202
203        let spans = if let Some(syntax) = syntax {
204            let mut highlighter = HighlightLines::new(syntax, theme);
205
206            match highlighter.highlight_line(line, &self.syntax_set) {
207                Ok(ranges) => ranges
208                    .iter()
209                    .map(|(style, text)| HighlightedSpan {
210                        text: text.to_string(),
211                        color: format!(
212                            "rgb({}, {}, {})",
213                            style.foreground.r, style.foreground.g, style.foreground.b
214                        ),
215                        font_weight: if style
216                            .font_style
217                            .contains(syntect::highlighting::FontStyle::BOLD)
218                        {
219                            "bold".to_string()
220                        } else {
221                            "normal".to_string()
222                        },
223                        font_style: if style
224                            .font_style
225                            .contains(syntect::highlighting::FontStyle::ITALIC)
226                        {
227                            "italic".to_string()
228                        } else {
229                            "normal".to_string()
230                        },
231                    })
232                    .collect(),
233                Err(_) => vec![HighlightedSpan::plain(line)],
234            }
235        } else {
236            vec![HighlightedSpan::plain(line)]
237        };
238
239        HighlightedLine { spans }
240    }
241}
242
243#[cfg(feature = "syntax-highlighting")]
244impl Default for Highlighter {
245    fn default() -> Self {
246        Self::new()
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn test_language_from_extension() {
256        assert_eq!(Language::from_extension("rs"), Language::Rust);
257        assert_eq!(Language::from_extension("js"), Language::JavaScript);
258        assert_eq!(Language::from_extension("py"), Language::Python);
259        assert_eq!(Language::from_extension("unknown"), Language::PlainText);
260    }
261
262    #[test]
263    fn test_highlighted_span_style() {
264        let span = HighlightedSpan {
265            text: "let".to_string(),
266            color: "rgb(255, 0, 0)".to_string(),
267            font_weight: "bold".to_string(),
268            font_style: "normal".to_string(),
269        };
270
271        let style = span.style();
272        assert!(style.contains("color: rgb(255, 0, 0)"));
273        assert!(style.contains("font-weight: bold"));
274    }
275}