1use std::path::Path;
10use std::sync::{Arc, OnceLock};
11
12use syntect::highlighting::{
13 Color as SynColor, FontStyle, ScopeSelectors, Style as SynStyle, StyleModifier, Theme,
14 ThemeItem, ThemeSet, ThemeSettings,
15};
16use syntect::parsing::{SyntaxReference, SyntaxSet};
17use syntect::util::LinesWithEndings;
18
19#[cfg(not(feature = "syntax-cache"))]
20use syntect::easy::HighlightLines;
21
22use crate::color::Color;
23use crate::protocol::{
24 CodeHighlighter, HighlightError, HighlightSpan, HighlightedCode, HighlightedLine,
25};
26use crate::style::Style;
27
28pub(crate) const DEFAULT_THEME: &str = "base16-ocean.dark";
30
31#[derive(Clone, Copy, Debug, Default)]
36pub struct SyntectHighlighter;
37
38impl SyntectHighlighter {
39 pub fn new() -> Self {
40 SyntectHighlighter
41 }
42
43 pub fn shared() -> Arc<dyn CodeHighlighter> {
45 static SHARED: OnceLock<Arc<dyn CodeHighlighter>> = OnceLock::new();
46 SHARED.get_or_init(|| Arc::new(SyntectHighlighter)).clone()
47 }
48}
49
50pub(crate) fn syntax_set() -> &'static SyntaxSet {
51 static SET: OnceLock<SyntaxSet> = OnceLock::new();
52 SET.get_or_init(SyntaxSet::load_defaults_newlines)
53}
54
55pub(crate) fn theme_set() -> &'static ThemeSet {
56 static SET: OnceLock<ThemeSet> = OnceLock::new();
57 SET.get_or_init(ThemeSet::load_defaults)
58}
59
60fn resolve(language: Option<&str>) -> &'static SyntaxReference {
62 let syntaxes = syntax_set();
63 language
64 .and_then(|lang| {
65 syntaxes
66 .find_syntax_by_token(lang)
67 .or_else(|| syntaxes.find_syntax_by_extension(lang))
68 })
69 .unwrap_or_else(|| syntaxes.find_syntax_plain_text())
70}
71
72fn to_color(c: SynColor) -> Color {
74 Color::from_rgb(c.r, c.g, c.b)
75}
76
77fn to_style(s: SynStyle) -> Style {
79 let mut style = Style::new()
80 .with_color(to_color(s.foreground))
81 .with_bgcolor(to_color(s.background));
82 if s.font_style.contains(FontStyle::BOLD) {
83 style = style.combine(&Style::parse("bold").expect("valid style"));
84 }
85 if s.font_style.contains(FontStyle::ITALIC) {
86 style = style.combine(&Style::parse("italic").expect("valid style"));
87 }
88 if s.font_style.contains(FontStyle::UNDERLINE) {
89 style = style.combine(&Style::parse("underline").expect("valid style"));
90 }
91 style
92}
93
94#[derive(Clone, Copy)]
106enum TokenType {
107 Token,
108 Comment,
109 CommentPreproc,
110 Keyword,
111 KeywordType,
112 Operator,
113 OperatorWord,
114 NameBuiltin,
115 NameFunction,
116 NameNamespace,
117 NameClass,
118 NameException,
119 NameDecorator,
120 NameVariable,
121 NameConstant,
122 NameAttribute,
123 NameTag,
124 String,
125 Number,
126 GenericDeleted,
127 GenericInserted,
128 GenericHeading,
129 GenericSubheading,
130 Error,
131}
132
133const SCOPES: &[(TokenType, &str)] = &[
136 (
137 TokenType::Comment,
138 "comment, punctuation.definition.comment",
139 ),
140 (
141 TokenType::CommentPreproc,
142 "meta.preprocessor, keyword.control.import.include, meta.annotation",
143 ),
144 (
145 TokenType::Keyword,
146 "keyword, storage.type, storage.modifier, constant.language",
147 ),
148 (
149 TokenType::KeywordType,
150 "support.type, storage.type.primitive, storage.type.numeric, storage.type.builtin",
151 ),
152 (TokenType::Operator, "keyword.operator"),
153 (
154 TokenType::OperatorWord,
155 "keyword.operator.logical, keyword.operator.word",
156 ),
157 (
158 TokenType::NameBuiltin,
159 "support.function.builtin, variable.language",
160 ),
161 (TokenType::NameFunction, "entity.name.function"),
162 (
163 TokenType::NameNamespace,
164 "entity.name.namespace, entity.name.module",
165 ),
166 (
167 TokenType::NameClass,
168 "entity.name.class, entity.name.struct, entity.name.enum, entity.name.union, \
169 entity.name.trait, entity.name.type",
170 ),
171 (TokenType::NameException, "support.type.exception"),
172 (
173 TokenType::NameDecorator,
174 "meta.decorator, meta.annotation.python, entity.name.function.decorator",
175 ),
176 (
177 TokenType::NameVariable,
178 "variable.other.readwrite.instance, variable.other.readwrite.class, \
179 variable.other.readwrite.global, variable.other.readwrite.shell, \
180 variable.other.normal.shell, variable.other.php",
181 ),
182 (
183 TokenType::NameConstant,
184 "variable.other.constant, constant.other, entity.name.constant, support.constant",
185 ),
186 (TokenType::NameAttribute, "entity.other.attribute-name"),
187 (TokenType::NameTag, "entity.name.tag"),
188 (
190 TokenType::String,
191 "string, punctuation.definition.string, storage.type.string",
192 ),
193 (TokenType::Number, "constant.numeric"),
194 (TokenType::GenericDeleted, "markup.deleted"),
195 (TokenType::GenericInserted, "markup.inserted"),
196 (
197 TokenType::GenericHeading,
198 "markup.heading, meta.diff.header",
199 ),
200 (
201 TokenType::GenericSubheading,
202 "markup.heading.2, markup.heading.3, markup.heading.4, markup.heading.5, \
203 markup.heading.6, meta.diff.range",
204 ),
205 (TokenType::Error, "invalid"),
206];
207
208fn ansi_style_markup(class: TokenType, dark: bool) -> &'static str {
210 use TokenType::*;
211 match (class, dark) {
212 (Token | Operator, _) => "",
213 (Comment, _) => "dim",
214 (CommentPreproc, true) => "bright_cyan",
215 (CommentPreproc, false) => "cyan",
216 (Keyword, true) => "bright_blue",
217 (Keyword, false) => "blue",
218 (KeywordType, true) => "bright_cyan",
219 (KeywordType, false) => "cyan",
220 (OperatorWord, true) => "bright_magenta",
221 (OperatorWord, false) => "magenta",
222 (NameBuiltin, true) => "bright_cyan",
223 (NameBuiltin, false) => "cyan",
224 (NameFunction, true) => "bright_green",
225 (NameFunction, false) => "green",
226 (NameNamespace, true) => "bright_cyan underline",
227 (NameNamespace, false) => "cyan underline",
228 (NameClass, true) => "bright_green underline",
229 (NameClass, false) => "green underline",
230 (NameException, true) => "bright_cyan",
231 (NameException, false) => "cyan",
232 (NameDecorator, true) => "bright_magenta bold",
233 (NameDecorator, false) => "magenta bold",
234 (NameVariable, true) => "bright_red",
235 (NameVariable, false) => "red",
236 (NameConstant, true) => "bright_red",
237 (NameConstant, false) => "red",
238 (NameAttribute, true) => "bright_cyan",
239 (NameAttribute, false) => "cyan",
240 (NameTag, _) => "bright_blue",
241 (String, _) => "yellow",
242 (Number, true) => "bright_blue",
243 (Number, false) => "blue",
244 (GenericDeleted, _) => "bright_red",
245 (GenericInserted, true) => "bright_green",
246 (GenericInserted, false) => "green",
247 (GenericHeading, _) => "bold",
248 (GenericSubheading, true) => "bright_magenta bold",
249 (GenericSubheading, false) => "magenta bold",
250 (Error, _) => "red underline",
251 }
252}
253
254const CLASSES: [TokenType; 24] = [
256 TokenType::Token,
257 TokenType::Comment,
258 TokenType::CommentPreproc,
259 TokenType::Keyword,
260 TokenType::KeywordType,
261 TokenType::Operator,
262 TokenType::OperatorWord,
263 TokenType::NameBuiltin,
264 TokenType::NameFunction,
265 TokenType::NameNamespace,
266 TokenType::NameClass,
267 TokenType::NameException,
268 TokenType::NameDecorator,
269 TokenType::NameVariable,
270 TokenType::NameConstant,
271 TokenType::NameAttribute,
272 TokenType::NameTag,
273 TokenType::String,
274 TokenType::Number,
275 TokenType::GenericDeleted,
276 TokenType::GenericInserted,
277 TokenType::GenericHeading,
278 TokenType::GenericSubheading,
279 TokenType::Error,
280];
281
282fn marker(class: TokenType) -> SynColor {
283 SynColor {
284 r: class as u8,
285 g: 0x5a,
286 b: 0xa5,
287 a: 0xff,
288 }
289}
290
291fn ansi_marker_theme() -> &'static Theme {
293 static THEME: OnceLock<Theme> = OnceLock::new();
294 THEME.get_or_init(|| Theme {
295 name: Some("rich-ansi-markers".into()),
296 settings: ThemeSettings {
297 foreground: Some(marker(TokenType::Token)),
298 background: Some(marker(TokenType::Token)),
299 ..ThemeSettings::default()
300 },
301 scopes: SCOPES
302 .iter()
303 .map(|&(class, selectors)| ThemeItem {
304 scope: selectors
305 .parse::<ScopeSelectors>()
306 .expect("valid scope selectors"),
307 style: StyleModifier {
308 foreground: Some(marker(class)),
309 background: None,
310 font_style: None,
311 },
312 })
313 .collect(),
314 ..Theme::default()
315 })
316}
317
318fn ansi_style(foreground: SynColor, dark: bool) -> Style {
320 static DARK: OnceLock<Vec<Style>> = OnceLock::new();
321 static LIGHT: OnceLock<Vec<Style>> = OnceLock::new();
322 let table = if dark { &DARK } else { &LIGHT };
323 let styles = table.get_or_init(|| {
324 CLASSES
325 .iter()
326 .map(|&class| {
327 let markup = ansi_style_markup(class, dark);
328 if markup.is_empty() {
329 Style::new()
330 } else {
331 Style::parse(markup).expect("valid ANSI theme style")
332 }
333 })
334 .collect()
335 });
336 styles
337 .get(foreground.r as usize)
338 .cloned()
339 .unwrap_or_default()
340}
341
342enum Resolved {
344 Syntect(&'static Theme),
345 Ansi { dark: bool },
346}
347
348fn resolve_theme(name: &str) -> Result<Resolved, HighlightError> {
349 match name {
350 "ansi_dark" => Ok(Resolved::Ansi { dark: true }),
351 "ansi_light" => Ok(Resolved::Ansi { dark: false }),
352 _ => theme_set()
353 .themes
354 .get(name)
355 .map(Resolved::Syntect)
356 .ok_or_else(|| HighlightError::UnknownTheme(name.to_string())),
357 }
358}
359
360impl CodeHighlighter for SyntectHighlighter {
361 fn highlight(
362 &self,
363 code: &str,
364 language: Option<&str>,
365 theme: &str,
366 ) -> Result<HighlightedCode, HighlightError> {
367 let resolved = resolve_theme(theme)?;
368 let (syn_theme, background, default_style) = match &resolved {
369 Resolved::Syntect(theme) => {
370 let background = theme.settings.background.map(to_color);
371 let mut default_style = Style::new();
372 if let Some(bg) = &background {
373 default_style = default_style.with_bgcolor(bg.clone());
374 }
375 (*theme, background, default_style)
376 }
377 Resolved::Ansi { .. } => (ansi_marker_theme(), None, Style::new()),
378 };
379 let convert = |style: SynStyle| match resolved {
380 Resolved::Syntect(_) => to_style(style),
381 Resolved::Ansi { dark } => ansi_style(style.foreground, dark),
382 };
383
384 let syntaxes = syntax_set();
385 let syntax = resolve(language);
386 #[cfg(not(feature = "syntax-cache"))]
387 let mut highlighter = HighlightLines::new(syntax, syn_theme);
388 #[cfg(feature = "syntax-cache")]
389 let mut highlighter = super::cache::CachedHighlighter::new(syntax, syn_theme);
390
391 let mut lines = Vec::new();
392 for line in LinesWithEndings::from(code) {
393 let body = line.strip_suffix('\n').map_or(line.len(), str::len);
394 let mut highlighted = HighlightedLine::default();
395 let mut position = 0usize;
396 for (style, token) in highlighter
398 .highlight_line(line, syntaxes)
399 .unwrap_or_default()
400 {
401 let style = convert(style);
402 let start = position;
403 position += token.len();
404 if token.ends_with('\n') {
405 highlighted.newline_style = Some(style.clone());
406 }
407 let end = position.min(body);
408 if end > start {
409 highlighted.spans.push(HighlightSpan {
410 range: start..end,
411 style,
412 });
413 }
414 }
415 lines.push(highlighted);
416 }
417 if code.is_empty() || code.ends_with('\n') {
420 lines.push(HighlightedLine::default());
421 }
422 Ok(HighlightedCode {
423 lines,
424 background,
425 default_style,
426 })
427 }
428
429 fn default_theme(&self) -> &str {
430 DEFAULT_THEME
431 }
432
433 fn token_style(&self, theme: &str, token: &str) -> Option<Style> {
436 match (resolve_theme(theme).ok()?, token) {
437 (Resolved::Ansi { .. }, "Comment") => Some(Style::parse("dim").expect("valid style")),
438 (Resolved::Ansi { .. }, _) => None,
439 (Resolved::Syntect(theme), "Text") => theme
440 .settings
441 .foreground
442 .map(|color| Style::new().with_color(to_color(color))),
443 (Resolved::Syntect(theme), "Comment") => {
444 let scope = syntect::parsing::Scope::new("comment").ok()?;
445 let style =
446 syntect::highlighting::Highlighter::new(theme).style_for_stack(&[scope]);
447 Some(
448 to_style(style)
449 .without_color()
450 .combine(&Style::new().with_color(to_color(style.foreground))),
451 )
452 }
453 _ => None,
454 }
455 }
456
457 fn themes(&self) -> Vec<String> {
458 let mut names: Vec<String> = theme_set().themes.keys().cloned().collect();
459 names.extend(["ansi_dark".to_string(), "ansi_light".to_string()]);
460 names.sort();
461 names
462 }
463
464 fn languages(&self) -> Vec<String> {
465 let mut names: Vec<String> = syntax_set()
466 .syntaxes()
467 .iter()
468 .map(|syntax| syntax.name.clone())
469 .collect();
470 names.sort();
471 names.dedup();
472 names
473 }
474
475 fn language_for_path(&self, path: &Path) -> Option<String> {
476 let syntaxes = syntax_set();
477 let by_name = path
478 .file_name()
479 .and_then(|name| name.to_str())
480 .and_then(|name| syntaxes.find_syntax_by_extension(name));
481 let by_extension = path
482 .extension()
483 .and_then(|ext| ext.to_str())
484 .and_then(|ext| syntaxes.find_syntax_by_extension(ext));
485 by_name.or(by_extension).map(|syntax| syntax.name.clone())
486 }
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492
493 #[test]
494 fn every_ansi_scope_selector_parses_and_every_style_is_valid() {
495 let _ = ansi_marker_theme();
496 for dark in [true, false] {
497 for class in CLASSES {
498 let _ = ansi_style(marker(class), dark);
499 }
500 }
501 }
502
503 #[test]
504 fn markers_are_in_class_order() {
505 for (index, class) in CLASSES.iter().enumerate() {
506 assert_eq!(*class as usize, index);
507 }
508 }
509
510 #[test]
511 fn lines_follow_split_semantics() {
512 let highlighter = SyntectHighlighter;
513 for code in ["", "a", "a\n", "a\nb", "a\n\nb\n", "\n"] {
514 let out = highlighter
515 .highlight(code, Some("python"), DEFAULT_THEME)
516 .unwrap();
517 assert_eq!(out.lines.len(), code.split('\n').count(), "{code:?}");
518 }
519 }
520
521 #[test]
522 fn unknown_theme_is_an_error_and_unknown_language_is_plain() {
523 let highlighter = SyntectHighlighter;
524 assert_eq!(
525 highlighter.highlight("x", None, "no-such-theme"),
526 Err(HighlightError::UnknownTheme("no-such-theme".into()))
527 );
528 let plain = highlighter
529 .highlight("x = 1", Some("no-such-language"), DEFAULT_THEME)
530 .unwrap();
531 assert_eq!(plain.lines.len(), 1);
532 }
533
534 #[test]
535 fn themes_include_the_ansi_pair_and_the_default() {
536 let themes = SyntectHighlighter.themes();
537 for name in ["ansi_dark", "ansi_light", DEFAULT_THEME] {
538 assert!(
539 themes.iter().any(|t| t == name),
540 "{name} missing: {themes:?}"
541 );
542 }
543 }
544
545 #[test]
546 fn language_for_path_uses_extensions() {
547 let highlighter = SyntectHighlighter;
548 assert_eq!(
549 highlighter
550 .language_for_path(Path::new("src/main.rs"))
551 .as_deref(),
552 Some("Rust")
553 );
554 assert_eq!(
555 highlighter.language_for_path(Path::new("notes.unknownext")),
556 None
557 );
558 }
559}