use std::ops::Range;
use gpui::{App, HighlightStyle, Hsla};
const PLAIN_LANGUAGES: &[&str] = &["text", "plaintext", "plain", "none", "txt"];
const LANGUAGE_ALIASES: &[(&str, &str)] = &[
("bash", "bash"),
("console", "bash"),
("golang", "go"),
("htm", "html"),
("jsonc", "json"),
("objc", "objective-c"),
("rs", "rust"),
("sh", "bash"),
("shell", "bash"),
("yml", "yaml"),
("zsh", "bash"),
];
pub fn normalize_language(info: &str) -> Option<String> {
let token = info
.trim()
.split([',', ' ', '\t'])
.next()
.unwrap_or("")
.trim();
if token.is_empty() {
return None;
}
let lowered = token.to_ascii_lowercase();
if PLAIN_LANGUAGES.contains(&lowered.as_str()) {
return None;
}
Some(
LANGUAGE_ALIASES
.iter()
.find(|(alias, _)| *alias == lowered)
.map(|(_, canonical)| (*canonical).to_string())
.unwrap_or(lowered),
)
}
#[cfg(feature = "editor")]
pub use editor_bridge::{
code_highlight_themes, init_code_highlighting, set_code_highlight_theme, CodeHighlightTheme,
DEFAULT_DARK_THEME, DEFAULT_LIGHT_THEME,
};
#[cfg(feature = "editor")]
pub(crate) fn code_highlights(
text: &str,
language: Option<&str>,
background: Hsla,
cx: &App,
) -> Vec<(Range<usize>, HighlightStyle)> {
editor_bridge::code_highlights(text, language, background, cx)
}
#[cfg(not(feature = "editor"))]
pub(crate) fn code_highlights(
_text: &str,
_language: Option<&str>,
_background: Hsla,
_cx: &App,
) -> Vec<(Range<usize>, HighlightStyle)> {
Vec::new()
}
#[cfg(feature = "editor")]
mod editor_bridge {
use std::cell::RefCell;
use std::collections::HashMap;
use std::ops::Range;
use std::rc::Rc;
use gpui::{App, Global, HighlightStyle, Hsla};
use crate::editor::SyntaxHighlighter;
pub const DEFAULT_DARK_THEME: &str = "base16-ocean.dark";
pub const DEFAULT_LIGHT_THEME: &str = "InspiredGitHub";
const MAX_HIGHLIGHT_BYTES: usize = 256 * 1024;
const MAX_CACHED_BLOCKS: usize = 256;
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub enum CodeHighlightTheme {
#[default]
FollowApp,
Pinned(String),
}
type CacheKey = (String, String, String);
type Highlights = Vec<(Range<usize>, HighlightStyle)>;
struct CodeHighlighter {
highlighter: RefCell<SyntaxHighlighter>,
theme: RefCell<CodeHighlightTheme>,
cache: RefCell<HashMap<CacheKey, Rc<Highlights>>>,
resolved: RefCell<HashMap<String, Option<String>>>,
}
impl CodeHighlighter {
fn new() -> Self {
Self {
highlighter: RefCell::new(SyntaxHighlighter::new()),
theme: RefCell::new(CodeHighlightTheme::default()),
cache: RefCell::new(HashMap::new()),
resolved: RefCell::new(HashMap::new()),
}
}
fn theme_for(&self, background: Hsla) -> String {
match &*self.theme.borrow() {
CodeHighlightTheme::Pinned(name) => name.clone(),
CodeHighlightTheme::FollowApp => if background.l > 0.5 {
DEFAULT_LIGHT_THEME
} else {
DEFAULT_DARK_THEME
}
.to_string(),
}
}
fn resolve(&self, token: &str) -> Option<String> {
if let Some(hit) = self.resolved.borrow().get(token) {
return hit.clone();
}
let resolved = self.highlighter.borrow().resolve_language(token);
self.resolved
.borrow_mut()
.insert(token.to_string(), resolved.clone());
resolved
}
fn highlights(&self, text: &str, token: &str, background: Hsla) -> Rc<Highlights> {
let empty = || Rc::new(Vec::new());
if text.len() > MAX_HIGHLIGHT_BYTES {
return empty();
}
let Some(language) = self.resolve(token) else {
return empty();
};
let theme = self.theme_for(background);
let key = (text.to_string(), language.clone(), theme.clone());
if let Some(hit) = self.cache.borrow().get(&key) {
return hit.clone();
}
{
let mut highlighter = self.highlighter.borrow_mut();
if highlighter.current_theme() != theme {
highlighter.set_theme(&theme);
}
}
let plain_foreground = self.highlighter.borrow().get_theme_foreground();
let highlights: Highlights = self
.highlighter
.borrow()
.highlight_block(text, &language)
.into_iter()
.filter(|(_, style)| {
style.font_weight.is_some()
|| style.font_style.is_some()
|| style.underline.is_some()
|| style.color.is_some_and(|color| color != plain_foreground)
})
.collect();
let highlights = Rc::new(highlights);
let mut cache = self.cache.borrow_mut();
if cache.len() >= MAX_CACHED_BLOCKS {
cache.clear();
}
cache.insert(key, highlights.clone());
highlights
}
fn set_theme(&self, theme: CodeHighlightTheme) {
*self.theme.borrow_mut() = theme;
}
fn available_themes(&self) -> Vec<String> {
self.highlighter.borrow().available_themes()
}
}
struct GlobalCodeHighlighter(Rc<CodeHighlighter>);
impl Global for GlobalCodeHighlighter {}
pub fn init_code_highlighting(cx: &mut App) {
cx.set_global(GlobalCodeHighlighter(Rc::new(CodeHighlighter::new())));
}
pub fn set_code_highlight_theme(cx: &mut App, theme: CodeHighlightTheme) {
if let Some(global) = cx.try_global::<GlobalCodeHighlighter>() {
global.0.set_theme(theme);
}
}
pub fn code_highlight_themes(cx: &App) -> Vec<String> {
cx.try_global::<GlobalCodeHighlighter>()
.map(|global| global.0.available_themes())
.unwrap_or_default()
}
pub(super) fn code_highlights(
text: &str,
language: Option<&str>,
background: Hsla,
cx: &App,
) -> Vec<(Range<usize>, HighlightStyle)> {
let Some(language) = language else {
return Vec::new();
};
let Some(global) = cx.try_global::<GlobalCodeHighlighter>() else {
return Vec::new();
};
(*global.0.highlights(text, language, background)).clone()
}
#[cfg(test)]
mod tests {
use super::*;
fn highlighter() -> CodeHighlighter {
CodeHighlighter::new()
}
const DARK: Hsla = Hsla {
h: 0.0,
s: 0.0,
l: 0.1,
a: 1.0,
};
const LIGHT: Hsla = Hsla {
h: 0.0,
s: 0.0,
l: 0.95,
a: 1.0,
};
fn assert_well_formed(text: &str, highlights: &[(Range<usize>, HighlightStyle)]) {
let mut previous_end = 0;
for (range, _) in highlights {
assert!(range.start < range.end, "empty range {range:?}");
assert!(
range.start >= previous_end,
"range {range:?} overlaps or precedes {previous_end}"
);
assert!(range.end <= text.len(), "range {range:?} past end of text");
assert!(
text.is_char_boundary(range.start) && text.is_char_boundary(range.end),
"range {range:?} splits a codepoint"
);
previous_end = range.end;
}
}
#[test]
fn highlights_are_well_formed() {
let highlighter = highlighter();
let text = "fn main() {\n println!(\"hello\");\n}\n";
let highlights = highlighter.highlights(text, "rust", DARK);
assert!(!highlights.is_empty(), "expected rust to highlight");
assert_well_formed(text, &highlights);
}
#[test]
fn multibyte_highlights_are_well_formed() {
let highlighter = highlighter();
let text = "fn main() {\n let grüße = \"héllo 🎉 wörld\";\n}\n";
let highlights = highlighter.highlights(text, "rust", DARK);
assert_well_formed(text, &highlights);
}
#[test]
fn highlight_block_carries_state_across_lines() {
let highlighter = highlighter();
let text = "/* opening\n still a comment\n closing */\nfn after() {}\n";
let highlights = highlighter.highlights(text, "rust", DARK);
assert_well_formed(text, &highlights);
let comment_line_two = text.find("still").expect("fixture has the word");
let covering = highlights
.iter()
.find(|(range, _)| range.contains(&comment_line_two));
assert!(
covering.is_some(),
"line 2 of a block comment should still be styled: {highlights:?}"
);
}
#[test]
fn unknown_language_renders_plain() {
let highlighter = highlighter();
let highlights = highlighter.highlights("some text", "definitely-not-a-language", DARK);
assert!(highlights.is_empty());
}
#[test]
fn empty_and_newlineless_blocks_are_safe() {
let highlighter = highlighter();
assert!(highlighter.highlights("", "rust", DARK).is_empty());
let text = "let x = 1;";
let highlights = highlighter.highlights(text, "rust", DARK);
assert_well_formed(text, &highlights);
}
#[test]
fn no_span_carries_a_background() {
let highlighter = highlighter();
let text = "fn main() {\n let x = 1;\n}\n";
for (_, style) in highlighter.highlights(text, "rust", DARK).iter() {
assert!(style.background_color.is_none(), "{style:?}");
}
}
#[test]
fn oversized_blocks_render_plain() {
let highlighter = highlighter();
let text = "// x\n".repeat(MAX_HIGHLIGHT_BYTES / 5 + 1);
assert!(text.len() > MAX_HIGHLIGHT_BYTES);
assert!(highlighter.highlights(&text, "rust", DARK).is_empty());
}
#[test]
fn a_repeated_block_is_only_highlighted_once() {
let highlighter = highlighter();
let text = "fn main() {}\n";
let first = highlighter.highlights(text, "rust", DARK);
assert_eq!(highlighter.cache.borrow().len(), 1);
let second = highlighter.highlights(text, "rust", DARK);
assert_eq!(highlighter.cache.borrow().len(), 1, "expected a cache hit");
assert_eq!(*first, *second);
}
#[test]
fn theme_follows_the_block_background_unless_pinned() {
let highlighter = highlighter();
assert_eq!(highlighter.theme_for(DARK), DEFAULT_DARK_THEME);
assert_eq!(highlighter.theme_for(LIGHT), DEFAULT_LIGHT_THEME);
highlighter.set_theme(CodeHighlightTheme::Pinned("Solarized (dark)".into()));
assert_eq!(highlighter.theme_for(DARK), "Solarized (dark)");
assert_eq!(highlighter.theme_for(LIGHT), "Solarized (dark)");
}
#[test]
fn changing_theme_re_highlights() {
let highlighter = highlighter();
let text = "fn main() {}\n";
let dark = highlighter.highlights(text, "rust", DARK);
let light = highlighter.highlights(text, "rust", LIGHT);
assert_eq!(highlighter.cache.borrow().len(), 2);
assert_ne!(*dark, *light, "expected different colors per theme");
}
#[gpui::test]
fn highlighting_is_opt_in_per_app(cx: &mut gpui::TestAppContext) {
let text = "fn main() {\n println!(\"hello\");\n}\n";
cx.update(|cx| {
assert!(
super::super::code_highlights(text, Some("rust"), DARK, cx).is_empty(),
"no highlighting before init_code_highlighting"
);
assert!(code_highlight_themes(cx).is_empty());
init_code_highlighting(cx);
assert!(
!super::super::code_highlights(text, Some("rust"), DARK, cx).is_empty(),
"expected highlighting once initialized"
);
assert!(code_highlight_themes(cx).contains(&DEFAULT_DARK_THEME.to_string()));
assert!(
super::super::code_highlights(text, None, DARK, cx).is_empty(),
"a bare fence has no language to highlight with"
);
assert!(
super::super::code_highlights(text, Some("not-a-language"), DARK, cx)
.is_empty(),
"an unknown language renders plain"
);
set_code_highlight_theme(cx, CodeHighlightTheme::Pinned("InspiredGitHub".into()));
assert!(!super::super::code_highlights(text, Some("rust"), DARK, cx).is_empty());
});
}
#[test]
fn common_fence_labels_resolve() {
let highlighter = highlighter();
for token in [
"rust", "rs", "python", "js", "JS", "json", "yaml", "yml", "html", "css", "c",
"cpp", "go", "golang", "ruby", "bash", "sh", "shell", "zsh", "sql", "xml",
"markdown", "java", "php",
] {
let normalized = super::super::normalize_language(token)
.unwrap_or_else(|| panic!("{token} should normalize to a language"));
assert!(
highlighter.resolve(&normalized).is_some(),
"{token} (normalized to {normalized}) should resolve to a syntax"
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn leading_word_only() {
assert_eq!(normalize_language("rust"), Some("rust".into()));
assert_eq!(normalize_language("rust,ignore"), Some("rust".into()));
assert_eq!(normalize_language("rust no_run"), Some("rust".into()));
assert_eq!(normalize_language(" rust "), Some("rust".into()));
assert_eq!(normalize_language("rust,linenos=1"), Some("rust".into()));
}
#[test]
fn case_is_folded() {
assert_eq!(normalize_language("Rust"), Some("rust".into()));
assert_eq!(normalize_language("JSON"), Some("json".into()));
}
#[test]
fn aliases_map_to_one_grammar() {
assert_eq!(normalize_language("golang"), Some("go".into()));
assert_eq!(normalize_language("yml"), Some("yaml".into()));
assert_eq!(normalize_language("jsonc"), Some("json".into()));
assert_eq!(normalize_language("objc"), Some("objective-c".into()));
for shell in ["sh", "bash", "zsh", "shell", "console"] {
assert_eq!(normalize_language(shell), Some("bash".into()), "{shell}");
}
}
#[test]
fn no_language_is_none() {
assert_eq!(normalize_language(""), None);
assert_eq!(normalize_language(" "), None);
for plain in ["text", "plaintext", "plain", "none", "txt", "TEXT"] {
assert_eq!(normalize_language(plain), None, "{plain}");
}
}
#[test]
fn typescript_is_not_aliased_to_javascript() {
assert_eq!(normalize_language("ts"), Some("ts".into()));
}
}