1#![warn(clippy::return_self_not_must_use)]
32
33use std::ops::Range;
34use std::path::Path;
35use std::sync::{Arc, OnceLock};
36
37use lumis::highlight::highlight_iter;
38use lumis::languages::{Language, available_languages, language_id_for_filename};
39use lumis::themes::{self, Theme, UnderlineStyle};
40use rich::color::Color;
41use rich::protocol::{
42 CodeHighlighter, HighlightError, HighlightSpan, HighlightedCode, HighlightedLine,
43};
44use rich::style::Style;
45use rich_plugin_api::{Plugin, PluginError, PluginMetadata, PluginRegistrar};
46
47pub const DEFAULT_THEME: &str = "monokai";
49
50#[derive(Clone, Copy, Debug, Default)]
52pub struct LumisHighlighter;
53
54impl LumisHighlighter {
55 pub fn new() -> Self {
56 LumisHighlighter
57 }
58
59 pub fn shared() -> Arc<dyn CodeHighlighter> {
61 static SHARED: OnceLock<Arc<LumisHighlighter>> = OnceLock::new();
62 SHARED.get_or_init(|| Arc::new(LumisHighlighter)).clone()
63 }
64}
65
66enum Palette {
68 Theme(Box<Theme>),
69 Ansi(bool),
71}
72
73fn palette(name: &str) -> Result<Palette, HighlightError> {
74 match name {
75 "ansi_dark" => Ok(Palette::Ansi(true)),
76 "ansi_light" => Ok(Palette::Ansi(false)),
77 _ => themes::get(name)
78 .map(|theme| Palette::Theme(Box::new(theme)))
79 .map_err(|_| HighlightError::UnknownTheme(name.to_string())),
80 }
81}
82
83fn color(value: Option<&str>) -> Option<Color> {
85 value.and_then(|value| Color::parse(value).ok())
86}
87
88fn theme_style(style: &themes::Style, base: &Style) -> Style {
90 let mut out = base.clone();
91 if let Some(fg) = color(style.fg.as_deref()) {
92 out = out.with_color(fg);
93 }
94 if let Some(bg) = color(style.bg.as_deref()) {
95 out = out.with_bgcolor(bg);
96 }
97 let mut flags = Vec::new();
98 if style.bold {
99 flags.push("bold");
100 }
101 if style.italic {
102 flags.push("italic");
103 }
104 if style.text_decoration.underline != UnderlineStyle::None {
105 flags.push("underline");
106 }
107 if style.text_decoration.strikethrough {
108 flags.push("strike");
109 }
110 if !flags.is_empty() {
111 out = out.combine(&Style::parse(&flags.join(" ")).expect("valid style"));
112 }
113 out
114}
115
116#[derive(Clone, Copy, Debug, PartialEq, Eq)]
118enum TokenType {
119 Token,
120 Comment,
121 CommentPreproc,
122 Keyword,
123 KeywordType,
124 OperatorWord,
125 NameBuiltin,
126 NameFunction,
127 NameNamespace,
128 NameClass,
129 NameDecorator,
130 NameVariable,
131 NameConstant,
132 NameAttribute,
133 NameTag,
134 String,
135 Number,
136 GenericDeleted,
137 GenericInserted,
138 GenericHeading,
139 Error,
140}
141
142const SCOPES: &[(&str, TokenType)] = &[
145 ("comment", TokenType::Comment),
146 ("keyword.directive", TokenType::CommentPreproc),
147 ("keyword.type", TokenType::KeywordType),
148 ("keyword.operator", TokenType::OperatorWord),
149 ("keyword", TokenType::Keyword),
150 ("boolean", TokenType::Keyword),
151 ("constant.builtin", TokenType::Keyword),
152 ("type.builtin", TokenType::KeywordType),
153 ("type", TokenType::NameClass),
154 ("constructor", TokenType::NameClass),
155 ("function.builtin", TokenType::NameBuiltin),
156 ("variable.builtin", TokenType::NameBuiltin),
157 ("module.builtin", TokenType::NameBuiltin),
158 ("function", TokenType::NameFunction),
159 ("module", TokenType::NameNamespace),
160 ("namespace", TokenType::NameNamespace),
161 ("attribute", TokenType::NameDecorator),
162 ("variable.parameter", TokenType::NameVariable),
163 ("constant", TokenType::NameConstant),
164 ("property", TokenType::NameAttribute),
165 ("variable.member", TokenType::NameAttribute),
166 ("tag.attribute", TokenType::NameAttribute),
167 ("tag", TokenType::NameTag),
168 ("string", TokenType::String),
169 ("character", TokenType::String),
170 ("number", TokenType::Number),
171 ("diff.minus", TokenType::GenericDeleted),
172 ("diff.plus", TokenType::GenericInserted),
173 ("markup.heading", TokenType::GenericHeading),
174 ("error", TokenType::Error),
175];
176
177fn token_type(scope: &str) -> TokenType {
178 SCOPES
179 .iter()
180 .filter(|(prefix, _)| {
181 scope == *prefix
182 || scope
183 .strip_prefix(prefix)
184 .is_some_and(|rest| rest.starts_with('.'))
185 })
186 .max_by_key(|(prefix, _)| prefix.len())
187 .map_or(TokenType::Token, |(_, token)| *token)
188}
189
190fn ansi_markup(token: TokenType, dark: bool) -> &'static str {
192 use TokenType::*;
193 match (token, dark) {
194 (Token, _) => "",
195 (Comment, _) => "dim",
196 (CommentPreproc, true) => "bright_cyan",
197 (CommentPreproc, false) => "cyan",
198 (Keyword, true) => "bright_blue",
199 (Keyword, false) => "blue",
200 (KeywordType | NameBuiltin | NameAttribute, true) => "bright_cyan",
201 (KeywordType | NameBuiltin | NameAttribute, false) => "cyan",
202 (OperatorWord, true) => "bright_magenta",
203 (OperatorWord, false) => "magenta",
204 (NameFunction, true) => "bright_green",
205 (NameFunction, false) => "green",
206 (NameNamespace, true) => "bright_cyan underline",
207 (NameNamespace, false) => "cyan underline",
208 (NameClass, true) => "bright_green underline",
209 (NameClass, false) => "green underline",
210 (NameDecorator, true) => "bright_magenta bold",
211 (NameDecorator, false) => "magenta bold",
212 (NameVariable | NameConstant, true) => "bright_red",
213 (NameVariable | NameConstant, false) => "red",
214 (NameTag, _) => "bright_blue",
215 (String, _) => "yellow",
216 (Number, true) => "bright_blue",
217 (Number, false) => "blue",
218 (GenericDeleted, _) => "bright_red",
219 (GenericInserted, true) => "bright_green",
220 (GenericInserted, false) => "green",
221 (GenericHeading, _) => "bold",
222 (Error, _) => "red underline",
223 }
224}
225
226fn ansi_style(scope: &str, dark: bool) -> Style {
227 Style::parse(ansi_markup(token_type(scope), dark)).expect("valid style")
228}
229
230struct Lines<'a> {
232 code: &'a str,
233 starts: Vec<usize>,
235 lines: Vec<HighlightedLine>,
236}
237
238impl<'a> Lines<'a> {
239 fn new(code: &'a str) -> Self {
240 let mut starts = vec![0];
241 starts.extend(code.match_indices('\n').map(|(at, _)| at + 1));
242 let lines = vec![HighlightedLine::default(); starts.len()];
243 Lines {
244 code,
245 starts,
246 lines,
247 }
248 }
249
250 fn line_of(&self, at: usize) -> usize {
252 self.starts.partition_point(|&start| start <= at) - 1
253 }
254
255 fn push(&mut self, range: Range<usize>, style: &Style) {
257 if range.is_empty() || style.is_null() || range.end > self.code.len() {
258 return;
259 }
260 for line in self.line_of(range.start)..=self.line_of(range.end - 1) {
261 let begin = self.starts[line];
262 let end = self
264 .starts
265 .get(line + 1)
266 .map_or(self.code.len(), |next| next - 1);
267 let (from, to) = (range.start.max(begin), range.end.min(end));
268 if from < to {
269 self.lines[line].spans.push(HighlightSpan {
270 range: from - begin..to - begin,
271 style: style.clone(),
272 });
273 }
274 if end < self.code.len() && range.start <= end && end < range.end {
275 self.lines[line].newline_style = Some(style.clone());
276 }
277 }
278 }
279}
280
281fn lumis_language(name: &str) -> &str {
285 const ALIASES: &[(&str, &str)] = &[
286 ("shell", "bash"),
287 ("sh", "bash"),
288 ("zsh", "bash"),
289 ("ksh", "bash"),
290 ("console", "bash"),
291 ("shell-session", "bash"),
292 ("shellsession", "bash"),
293 ("python3", "python"),
294 ("py3", "python"),
295 ("golang", "go"),
296 ("patch", "diff"),
297 ("udiff", "diff"),
298 ("jsonc", "json"),
299 ("json5", "json"),
300 ];
301 ALIASES
302 .iter()
303 .find(|(alias, _)| alias.eq_ignore_ascii_case(name))
304 .map_or(name, |(_, target)| target)
305}
306
307impl CodeHighlighter for LumisHighlighter {
308 fn highlight(
309 &self,
310 code: &str,
311 language: Option<&str>,
312 theme: &str,
313 ) -> Result<HighlightedCode, HighlightError> {
314 let palette = palette(theme)?;
315 let language = match language {
316 Some(name) => Language::guess(Some(lumis_language(name)), ""),
319 None => Language::PlainText,
320 };
321 let (theme, background, base) = match palette {
322 Palette::Theme(theme) => {
323 let background = color(theme.bg());
324 let mut base = Style::new();
325 if let Some(fg) = color(theme.fg()) {
326 base = base.with_color(fg);
327 }
328 if let Some(bg) = &background {
329 base = base.with_bgcolor(bg.clone());
330 }
331 (Some(*theme), background, Some(base))
332 }
333 Palette::Ansi(dark) => {
334 let mut lines = Lines::new(code);
335 highlight_iter(code, language, None, |_, _, range, scope, _| {
336 lines.push(range, &ansi_style(scope, dark));
337 Ok::<(), std::convert::Infallible>(())
338 })
339 .map_err(|error| HighlightError::Engine(error.to_string()))?;
340 return Ok(HighlightedCode {
341 lines: lines.lines,
342 background: None,
343 default_style: Style::new(),
344 });
345 }
346 };
347 let base = base.unwrap_or_default();
348 let mut lines = Lines::new(code);
349 highlight_iter(code, language, theme, |_, _, range, scope, style| {
350 if !scope.is_empty() {
353 lines.push(range, &theme_style(style, &base));
354 }
355 Ok::<(), std::convert::Infallible>(())
356 })
357 .map_err(|error| HighlightError::Engine(error.to_string()))?;
358 Ok(HighlightedCode {
359 lines: lines.lines,
360 background,
361 default_style: base,
362 })
363 }
364
365 fn default_theme(&self) -> &str {
366 DEFAULT_THEME
367 }
368
369 fn themes(&self) -> Vec<String> {
370 let mut names: Vec<String> = themes::available_themes()
371 .map(|theme| theme.name.clone())
372 .collect();
373 names.extend(["ansi_dark".to_string(), "ansi_light".to_string()]);
374 names.sort();
375 names
376 }
377
378 fn languages(&self) -> Vec<String> {
379 available_languages()
380 .into_iter()
381 .map(|language| language.id.to_string())
382 .collect()
383 }
384
385 fn language_for_path(&self, path: &Path) -> Option<String> {
386 language_id_for_filename(&path.to_string_lossy())
387 .filter(|id| *id != "plaintext")
388 .map(str::to_string)
389 }
390}
391
392#[derive(Clone, Copy, Debug, Default)]
394pub struct LumisPlugin;
395
396impl Plugin for LumisPlugin {
397 fn metadata(&self) -> PluginMetadata {
398 PluginMetadata::new(
399 "lumis",
400 "lumis syntax highlighter",
401 env!("CARGO_PKG_VERSION"),
402 )
403 .description("tree-sitter highlighting with Neovim themes")
404 }
405
406 fn register(&self, registrar: &mut dyn PluginRegistrar) -> Result<(), PluginError> {
407 registrar.code_highlighter("lumis", LumisHighlighter::shared());
408 Ok(())
409 }
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 #[test]
417 fn scopes_map_to_upstream_token_types() {
418 assert_eq!(token_type("keyword"), TokenType::Keyword);
419 assert_eq!(token_type("keyword.function"), TokenType::Keyword);
420 assert_eq!(token_type("keyword.operator"), TokenType::OperatorWord);
421 assert_eq!(
422 token_type("keyword.directive.define"),
423 TokenType::CommentPreproc
424 );
425 assert_eq!(token_type("comment.documentation"), TokenType::Comment);
426 assert_eq!(token_type("function.builtin"), TokenType::NameBuiltin);
427 assert_eq!(token_type("function.method.call"), TokenType::NameFunction);
428 assert_eq!(token_type("string.escape"), TokenType::String);
429 assert_eq!(token_type("punctuation.bracket"), TokenType::Token);
430 assert_eq!(token_type("variable"), TokenType::Token);
431 assert_eq!(token_type("keywordish"), TokenType::Token);
433 assert_eq!(token_type(""), TokenType::Token);
434 }
435
436 #[test]
437 fn spans_split_at_line_breaks() {
438 let code = "ab\ncd\n";
439 let mut lines = Lines::new(code);
440 let bold = Style::parse("bold").unwrap();
441 lines.push(1..5, &bold);
442 assert_eq!(lines.lines.len(), 3);
443 assert_eq!(lines.lines[0].spans[0].range, 1..2);
444 assert_eq!(lines.lines[0].newline_style, Some(bold.clone()));
445 assert_eq!(lines.lines[1].spans[0].range, 0..2);
446 assert_eq!(lines.lines[1].newline_style, None);
447 assert!(lines.lines[2].spans.is_empty());
448 lines.push(4..40, &bold);
450 lines.push(2..2, &bold);
451 assert_eq!(lines.lines[1].spans.len(), 1);
452 }
453}