feat(tui): enable syntax highlighting by default and refactor highlighting logic
- Set `default_syntax_highlighting` to true in core config. - Added language‑aware syntax selector (`select_syntax_for_language`) and highlighter builder (`build_highlighter_for_language`) with unit test. - Integrated new highlight module into `ChatApp`, using `UnicodeSegmentation` for proper grapheme handling. - Simplified `should_highlight_code` to always return true and removed extended‑color detection logic. - Reworked code rendering to use `inline_code_spans_from_text` and `wrap_highlight_segments` for accurate line wrapping and styling. - Cleaned up removed legacy keyword/comment parsing and extended‑color detection code.
This commit is contained in:
@@ -1389,7 +1389,7 @@ impl UiSettings {
|
||||
}
|
||||
|
||||
const fn default_syntax_highlighting() -> bool {
|
||||
false
|
||||
true
|
||||
}
|
||||
|
||||
const fn default_show_timestamps() -> bool {
|
||||
|
||||
@@ -23,10 +23,10 @@ use tokio::{
|
||||
task::{self, JoinHandle},
|
||||
};
|
||||
use tui_textarea::{CursorMove, Input, TextArea};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::commands;
|
||||
use crate::config;
|
||||
use crate::events::Event;
|
||||
use crate::model_info_panel::ModelInfoPanel;
|
||||
@@ -39,6 +39,7 @@ use crate::state::{
|
||||
};
|
||||
use crate::toast::{Toast, ToastLevel, ToastManager};
|
||||
use crate::ui::format_tool_output;
|
||||
use crate::{commands, highlight};
|
||||
// Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly
|
||||
// imports `AgentExecutor` to avoid a circular dependency on `owlen-cli`.
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
@@ -211,7 +212,6 @@ pub struct ChatApp {
|
||||
show_cursor_outside_insert: bool, // Configurable cursor visibility flag
|
||||
syntax_highlighting: bool, // Whether syntax highlighting is enabled
|
||||
show_message_timestamps: bool, // Whether to render timestamps in chat headers
|
||||
supports_extended_colors: bool, // Terminal supports 256-color output
|
||||
auto_scroll: AutoScroll, // Auto-scroll state for message rendering
|
||||
thinking_scroll: AutoScroll, // Auto-scroll state for thinking panel
|
||||
viewport_height: usize, // Track the height of the messages viewport
|
||||
@@ -516,7 +516,6 @@ impl ChatApp {
|
||||
new_message_alert: false,
|
||||
show_cursor_outside_insert,
|
||||
syntax_highlighting,
|
||||
supports_extended_colors: detect_extended_color_support(),
|
||||
show_message_timestamps: show_timestamps,
|
||||
};
|
||||
|
||||
@@ -1910,7 +1909,7 @@ impl ChatApp {
|
||||
}
|
||||
|
||||
pub fn should_highlight_code(&self) -> bool {
|
||||
self.syntax_highlighting && self.supports_extended_colors
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn render_message_lines_cached(
|
||||
@@ -2019,12 +2018,16 @@ impl ChatApp {
|
||||
chunks.push(String::new());
|
||||
}
|
||||
for chunk in chunks {
|
||||
let text = if indent.is_empty() {
|
||||
chunk.clone()
|
||||
} else {
|
||||
format!("{indent}{chunk}")
|
||||
};
|
||||
rendered.push(Line::from(vec![Span::styled(text, content_style)]));
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
if !indent.is_empty() {
|
||||
spans.push(Span::styled(indent.to_string(), content_style));
|
||||
}
|
||||
|
||||
let inline_spans =
|
||||
inline_code_spans_from_text(&chunk, theme, content_style);
|
||||
spans.extend(inline_spans);
|
||||
|
||||
rendered.push(Line::from(spans));
|
||||
*indicator_target = Some(rendered.len() - 1);
|
||||
}
|
||||
}
|
||||
@@ -8334,16 +8337,6 @@ impl ChatApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_extended_color_support() -> bool {
|
||||
let term = std::env::var("TERM").unwrap_or_default();
|
||||
if term.contains("256") || term.contains("direct") || term.contains("truecolor") {
|
||||
return true;
|
||||
}
|
||||
|
||||
let colorterm = std::env::var("COLORTERM").unwrap_or_default();
|
||||
colorterm.contains("24bit") || colorterm.contains("truecolor")
|
||||
}
|
||||
|
||||
pub(crate) fn role_label_parts(role: &Role) -> (&'static str, &'static str) {
|
||||
match role {
|
||||
Role::User => ("👤", "You"),
|
||||
@@ -8447,312 +8440,124 @@ fn wrap_code(text: &str, width: usize) -> Vec<String> {
|
||||
wrapped
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum CommentMarker {
|
||||
DoubleSlash,
|
||||
Hash,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum CodeState {
|
||||
Normal,
|
||||
String { delimiter: char, escaped: bool },
|
||||
}
|
||||
|
||||
const RUST_KEYWORDS: &[&str] = &[
|
||||
"as", "async", "await", "break", "const", "crate", "dyn", "else", "enum", "extern", "false",
|
||||
"fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
|
||||
"return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use",
|
||||
"where", "while",
|
||||
];
|
||||
|
||||
const PYTHON_KEYWORDS: &[&str] = &[
|
||||
"and", "as", "assert", "break", "class", "continue", "def", "del", "elif", "else", "except",
|
||||
"false", "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "none",
|
||||
"nonlocal", "not", "or", "pass", "raise", "return", "true", "try", "while", "with", "yield",
|
||||
];
|
||||
|
||||
const JS_KEYWORDS: &[&str] = &[
|
||||
"async",
|
||||
"await",
|
||||
"break",
|
||||
"case",
|
||||
"catch",
|
||||
"class",
|
||||
"const",
|
||||
"continue",
|
||||
"debugger",
|
||||
"default",
|
||||
"delete",
|
||||
"do",
|
||||
"else",
|
||||
"export",
|
||||
"extends",
|
||||
"finally",
|
||||
"for",
|
||||
"function",
|
||||
"if",
|
||||
"import",
|
||||
"in",
|
||||
"instanceof",
|
||||
"let",
|
||||
"new",
|
||||
"return",
|
||||
"switch",
|
||||
"this",
|
||||
"throw",
|
||||
"try",
|
||||
"typeof",
|
||||
"var",
|
||||
"void",
|
||||
"while",
|
||||
"with",
|
||||
"yield",
|
||||
];
|
||||
|
||||
const GO_KEYWORDS: &[&str] = &[
|
||||
"break",
|
||||
"case",
|
||||
"chan",
|
||||
"const",
|
||||
"continue",
|
||||
"default",
|
||||
"defer",
|
||||
"else",
|
||||
"fallthrough",
|
||||
"for",
|
||||
"func",
|
||||
"go",
|
||||
"goto",
|
||||
"if",
|
||||
"import",
|
||||
"interface",
|
||||
"map",
|
||||
"package",
|
||||
"range",
|
||||
"return",
|
||||
"select",
|
||||
"struct",
|
||||
"switch",
|
||||
"type",
|
||||
"var",
|
||||
];
|
||||
|
||||
const C_KEYWORDS: &[&str] = &[
|
||||
"auto", "break", "case", "char", "const", "continue", "default", "do", "double", "else",
|
||||
"enum", "extern", "float", "for", "goto", "if", "inline", "int", "long", "register",
|
||||
"restrict", "return", "short", "signed", "sizeof", "static", "struct", "switch", "typedef",
|
||||
"union", "unsigned", "void", "volatile", "while",
|
||||
];
|
||||
|
||||
const BASH_KEYWORDS: &[&str] = &[
|
||||
"case", "do", "done", "elif", "else", "esac", "fi", "for", "function", "if", "in", "select",
|
||||
"then", "until", "while",
|
||||
];
|
||||
|
||||
const JSON_KEYWORDS: &[&str] = &["false", "null", "true"];
|
||||
|
||||
const YAML_KEYWORDS: &[&str] = &["false", "no", "null", "true", "yes"];
|
||||
|
||||
const TOML_KEYWORDS: &[&str] = &["false", "inf", "nan", "true"];
|
||||
|
||||
fn keyword_list(language: &str) -> Option<&'static [&'static str]> {
|
||||
match language {
|
||||
"rust" | "rs" => Some(RUST_KEYWORDS),
|
||||
"python" | "py" => Some(PYTHON_KEYWORDS),
|
||||
"javascript" | "js" => Some(JS_KEYWORDS),
|
||||
"typescript" | "ts" => Some(JS_KEYWORDS),
|
||||
"go" | "golang" => Some(GO_KEYWORDS),
|
||||
"c" | "cpp" | "c++" => Some(C_KEYWORDS),
|
||||
"bash" | "sh" | "shell" => Some(BASH_KEYWORDS),
|
||||
"json" => Some(JSON_KEYWORDS),
|
||||
"yaml" | "yml" => Some(YAML_KEYWORDS),
|
||||
"toml" => Some(TOML_KEYWORDS),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn comment_marker(language: &str) -> Option<CommentMarker> {
|
||||
match language {
|
||||
"rust" | "rs" | "javascript" | "js" | "typescript" | "ts" | "go" | "golang" | "c"
|
||||
| "cpp" | "c++" | "java" => Some(CommentMarker::DoubleSlash),
|
||||
"python" | "py" | "bash" | "sh" | "shell" | "yaml" | "yml" | "toml" => {
|
||||
Some(CommentMarker::Hash)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn flush_normal_buffer(
|
||||
buffer: &mut String,
|
||||
keywords: Option<&[&str]>,
|
||||
spans: &mut Vec<Span<'static>>,
|
||||
base_style: Style,
|
||||
keyword_style: Style,
|
||||
) {
|
||||
if buffer.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(keys) = keywords {
|
||||
let mut token = String::new();
|
||||
|
||||
for ch in buffer.chars() {
|
||||
if ch.is_alphanumeric() || ch == '_' {
|
||||
token.push(ch);
|
||||
} else {
|
||||
if !token.is_empty() {
|
||||
let lower = token.to_ascii_lowercase();
|
||||
let style = if keys.binary_search(&lower.as_str()).is_ok() {
|
||||
keyword_style
|
||||
} else {
|
||||
base_style
|
||||
};
|
||||
spans.push(Span::styled(token.clone(), style));
|
||||
token.clear();
|
||||
}
|
||||
|
||||
let mut punct = String::new();
|
||||
punct.push(ch);
|
||||
spans.push(Span::styled(punct, base_style));
|
||||
}
|
||||
}
|
||||
|
||||
if !token.is_empty() {
|
||||
let lower = token.to_ascii_lowercase();
|
||||
let style = if keys.binary_search(&lower.as_str()).is_ok() {
|
||||
keyword_style
|
||||
} else {
|
||||
base_style
|
||||
};
|
||||
spans.push(Span::styled(token.clone(), style));
|
||||
}
|
||||
} else {
|
||||
spans.push(Span::styled(buffer.clone(), base_style));
|
||||
}
|
||||
|
||||
buffer.clear();
|
||||
}
|
||||
|
||||
fn highlight_code_spans(
|
||||
chunk: &str,
|
||||
language: Option<&str>,
|
||||
fn wrap_highlight_segments(
|
||||
segments: Vec<(Style, String)>,
|
||||
code_width: usize,
|
||||
theme: &Theme,
|
||||
syntax_highlighting: bool,
|
||||
) -> Vec<Span<'static>> {
|
||||
let base_style = Style::default()
|
||||
.fg(theme.code_block_text)
|
||||
.bg(theme.code_block_background);
|
||||
) -> Vec<Vec<(Style, String)>> {
|
||||
let mut rows: Vec<Vec<(Style, String)>> = Vec::new();
|
||||
let mut current: Vec<(Style, String)> = Vec::new();
|
||||
let mut current_width: usize = 0;
|
||||
|
||||
if !syntax_highlighting {
|
||||
return vec![Span::styled(chunk.to_string(), base_style)];
|
||||
let push_row = |rows: &mut Vec<Vec<(Style, String)>>,
|
||||
current: &mut Vec<(Style, String)>,
|
||||
current_width: &mut usize| {
|
||||
rows.push(std::mem::take(current));
|
||||
*current_width = 0;
|
||||
};
|
||||
|
||||
for (style_raw, text) in segments {
|
||||
let mut remaining = text.as_str();
|
||||
if remaining.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
while !remaining.is_empty() {
|
||||
if current_width >= code_width {
|
||||
push_row(&mut rows, &mut current, &mut current_width);
|
||||
}
|
||||
|
||||
let available = code_width.saturating_sub(current_width);
|
||||
if available == 0 {
|
||||
push_row(&mut rows, &mut current, &mut current_width);
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut take_bytes = 0;
|
||||
let mut take_width = 0;
|
||||
|
||||
for grapheme in remaining.graphemes(true) {
|
||||
let grapheme_width = UnicodeWidthStr::width(grapheme);
|
||||
if take_width + grapheme_width > available {
|
||||
break;
|
||||
}
|
||||
take_bytes += grapheme.len();
|
||||
take_width += grapheme_width;
|
||||
if take_width == available {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if take_bytes == 0 {
|
||||
push_row(&mut rows, &mut current, &mut current_width);
|
||||
continue;
|
||||
}
|
||||
|
||||
let chunk = &remaining[..take_bytes];
|
||||
remaining = &remaining[take_bytes..];
|
||||
|
||||
let mut style = style_raw;
|
||||
if style.fg.is_none() {
|
||||
style = style.fg(theme.code_block_text);
|
||||
}
|
||||
style = style.bg(theme.code_block_background);
|
||||
|
||||
current.push((style, chunk.to_string()));
|
||||
current_width += take_width;
|
||||
}
|
||||
}
|
||||
|
||||
let normalized = language.map(|lang| lang.trim().to_ascii_lowercase());
|
||||
let lang_ref = normalized.as_deref();
|
||||
let keywords = lang_ref.and_then(keyword_list);
|
||||
let comment = lang_ref.and_then(comment_marker);
|
||||
if !current.is_empty() {
|
||||
rows.push(current);
|
||||
} else if rows.is_empty() {
|
||||
rows.push(Vec::new());
|
||||
}
|
||||
|
||||
let keyword_style = Style::default()
|
||||
.fg(theme.code_block_keyword)
|
||||
rows
|
||||
}
|
||||
|
||||
fn inline_code_spans_from_text(text: &str, theme: &Theme, base_style: Style) -> Vec<Span<'static>> {
|
||||
let tick_count = text.matches('`').count();
|
||||
if tick_count < 2 || (tick_count & 1) != 0 {
|
||||
return vec![Span::styled(text.to_string(), base_style)];
|
||||
}
|
||||
|
||||
let code_style = Style::default()
|
||||
.fg(theme.code_block_text)
|
||||
.bg(theme.code_block_background)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let string_style = Style::default()
|
||||
.fg(theme.code_block_string)
|
||||
.bg(theme.code_block_background);
|
||||
let comment_style = Style::default()
|
||||
.fg(theme.code_block_comment)
|
||||
.bg(theme.code_block_background)
|
||||
.add_modifier(Modifier::ITALIC);
|
||||
|
||||
let mut spans = Vec::new();
|
||||
let mut buffer = String::new();
|
||||
let chars: Vec<char> = chunk.chars().collect();
|
||||
let mut idx = 0;
|
||||
let mut state = CodeState::Normal;
|
||||
let mut in_code = false;
|
||||
|
||||
while idx < chars.len() {
|
||||
match state {
|
||||
CodeState::Normal => {
|
||||
if let Some(marker) = comment {
|
||||
let is_comment = match marker {
|
||||
CommentMarker::DoubleSlash => {
|
||||
chars[idx] == '/' && idx + 1 < chars.len() && chars[idx + 1] == '/'
|
||||
}
|
||||
CommentMarker::Hash => chars[idx] == '#',
|
||||
};
|
||||
|
||||
if is_comment {
|
||||
flush_normal_buffer(
|
||||
&mut buffer,
|
||||
keywords,
|
||||
&mut spans,
|
||||
base_style,
|
||||
keyword_style,
|
||||
);
|
||||
let comment_text: String = chars[idx..].iter().collect();
|
||||
spans.push(Span::styled(comment_text, comment_style));
|
||||
return spans;
|
||||
}
|
||||
}
|
||||
|
||||
let ch = chars[idx];
|
||||
if ch == '"' || ch == '\'' {
|
||||
flush_normal_buffer(
|
||||
&mut buffer,
|
||||
keywords,
|
||||
&mut spans,
|
||||
base_style,
|
||||
keyword_style,
|
||||
);
|
||||
buffer.push(ch);
|
||||
state = CodeState::String {
|
||||
delimiter: ch,
|
||||
escaped: false,
|
||||
};
|
||||
} else {
|
||||
buffer.push(ch);
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
CodeState::String { delimiter, escaped } => {
|
||||
let ch = chars[idx];
|
||||
buffer.push(ch);
|
||||
|
||||
let mut next_state = CodeState::String {
|
||||
delimiter,
|
||||
escaped: false,
|
||||
};
|
||||
|
||||
if escaped {
|
||||
next_state = CodeState::String {
|
||||
delimiter,
|
||||
escaped: false,
|
||||
};
|
||||
} else if ch == '\\' {
|
||||
next_state = CodeState::String {
|
||||
delimiter,
|
||||
escaped: true,
|
||||
};
|
||||
} else if ch == delimiter {
|
||||
spans.push(Span::styled(buffer.clone(), string_style));
|
||||
for ch in text.chars() {
|
||||
if ch == '`' {
|
||||
if in_code {
|
||||
if !buffer.is_empty() {
|
||||
spans.push(Span::styled(buffer.clone(), code_style));
|
||||
buffer.clear();
|
||||
next_state = CodeState::Normal;
|
||||
}
|
||||
|
||||
state = next_state;
|
||||
idx += 1;
|
||||
} else if !buffer.is_empty() {
|
||||
spans.push(Span::styled(buffer.clone(), base_style));
|
||||
buffer.clear();
|
||||
}
|
||||
in_code = !in_code;
|
||||
} else {
|
||||
buffer.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
match state {
|
||||
CodeState::String { .. } => {
|
||||
spans.push(Span::styled(buffer.clone(), string_style));
|
||||
}
|
||||
CodeState::Normal => {
|
||||
flush_normal_buffer(&mut buffer, keywords, &mut spans, base_style, keyword_style);
|
||||
}
|
||||
if in_code {
|
||||
return vec![Span::styled(text.to_string(), base_style)];
|
||||
}
|
||||
|
||||
if !buffer.is_empty() {
|
||||
spans.push(Span::styled(buffer, base_style));
|
||||
}
|
||||
|
||||
if spans.is_empty() {
|
||||
spans.push(Span::styled(String::new(), base_style));
|
||||
}
|
||||
|
||||
spans
|
||||
@@ -8823,52 +8628,60 @@ fn append_code_block_lines(
|
||||
top_spans.push(Span::styled("╮", border_style));
|
||||
rendered.push(Line::from(top_spans));
|
||||
|
||||
if code_lines.is_empty() {
|
||||
let chunks = wrap_code("", code_width);
|
||||
for chunk in chunks {
|
||||
let mut highlighter = if syntax_highlighting {
|
||||
Some(highlight::build_highlighter_for_language(language))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut process_line = |line: &str| {
|
||||
let segments = if let Some(highlighter) = highlighter.as_mut() {
|
||||
let mut segments = highlight::highlight_line(highlighter, line);
|
||||
if segments.is_empty() {
|
||||
segments.push((Style::default(), String::new()));
|
||||
}
|
||||
segments
|
||||
} else {
|
||||
vec![(Style::default(), line.to_string())]
|
||||
};
|
||||
|
||||
let has_content = segments.iter().any(|(_, text)| !text.is_empty());
|
||||
let rows = if has_content {
|
||||
wrap_highlight_segments(segments, code_width, theme)
|
||||
} else {
|
||||
vec![Vec::new()]
|
||||
};
|
||||
|
||||
for row in rows {
|
||||
let mut spans = Vec::new();
|
||||
spans.push(Span::styled(indent.to_string(), border_style));
|
||||
spans.push(Span::styled("│", border_style));
|
||||
|
||||
let mut code_spans = highlight_code_spans(&chunk, language, theme, syntax_highlighting);
|
||||
spans.append(&mut code_spans);
|
||||
|
||||
let display_width = UnicodeWidthStr::width(chunk.as_str());
|
||||
if display_width < code_width {
|
||||
spans.push(Span::styled(
|
||||
" ".repeat(code_width - display_width),
|
||||
text_style,
|
||||
));
|
||||
let mut row_width = 0;
|
||||
if row.is_empty() {
|
||||
spans.push(Span::styled(" ".repeat(code_width), text_style));
|
||||
} else {
|
||||
for (style, piece) in row {
|
||||
let width = UnicodeWidthStr::width(piece.as_str());
|
||||
row_width += width;
|
||||
spans.push(Span::styled(piece, style));
|
||||
}
|
||||
if row_width < code_width {
|
||||
spans.push(Span::styled(" ".repeat(code_width - row_width), text_style));
|
||||
}
|
||||
}
|
||||
|
||||
spans.push(Span::styled("│", border_style));
|
||||
rendered.push(Line::from(spans));
|
||||
*indicator_target = Some(rendered.len() - 1);
|
||||
}
|
||||
};
|
||||
|
||||
if code_lines.is_empty() {
|
||||
process_line("");
|
||||
} else {
|
||||
for line in code_lines {
|
||||
let chunks = wrap_code(line.as_str(), code_width);
|
||||
for chunk in chunks {
|
||||
let mut spans = Vec::new();
|
||||
spans.push(Span::styled(indent.to_string(), border_style));
|
||||
spans.push(Span::styled("│", border_style));
|
||||
|
||||
let mut code_spans =
|
||||
highlight_code_spans(&chunk, language, theme, syntax_highlighting);
|
||||
spans.append(&mut code_spans);
|
||||
|
||||
let display_width = UnicodeWidthStr::width(chunk.as_str());
|
||||
if display_width < code_width {
|
||||
spans.push(Span::styled(
|
||||
" ".repeat(code_width - display_width),
|
||||
text_style,
|
||||
));
|
||||
}
|
||||
|
||||
spans.push(Span::styled("│", border_style));
|
||||
rendered.push(Line::from(spans));
|
||||
*indicator_target = Some(rendered.len() - 1);
|
||||
}
|
||||
process_line(line);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,42 @@ fn select_syntax(path_hint: Option<&Path>) -> &'static SyntaxReference {
|
||||
SYNTAX_SET.find_syntax_plain_text()
|
||||
}
|
||||
|
||||
fn select_syntax_for_language(language: Option<&str>) -> &'static SyntaxReference {
|
||||
let token = language
|
||||
.map(|lang| lang.trim().to_ascii_lowercase())
|
||||
.filter(|lang| !lang.is_empty());
|
||||
|
||||
if let Some(token) = token {
|
||||
let mut attempts: Vec<&str> = vec![token.as_str()];
|
||||
match token.as_str() {
|
||||
"c++" => attempts.extend(["cpp", "c"]),
|
||||
"c#" | "cs" => attempts.extend(["csharp", "cs"]),
|
||||
"shell" => attempts.extend(["bash", "sh"]),
|
||||
"typescript" | "ts" => attempts.extend(["typescript", "ts", "tsx"]),
|
||||
"javascript" | "js" => attempts.extend(["javascript", "js", "jsx"]),
|
||||
"py" => attempts.push("python"),
|
||||
"rs" => attempts.push("rust"),
|
||||
"yml" => attempts.push("yaml"),
|
||||
other => {
|
||||
if let Some(stripped) = other.strip_prefix('.') {
|
||||
attempts.push(stripped);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for candidate in attempts {
|
||||
if let Some(syntax) = SYNTAX_SET.find_syntax_by_token(candidate) {
|
||||
return syntax;
|
||||
}
|
||||
if let Some(syntax) = SYNTAX_SET.find_syntax_by_extension(candidate) {
|
||||
return syntax;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SYNTAX_SET.find_syntax_plain_text()
|
||||
}
|
||||
|
||||
fn path_hint_from_components(absolute: Option<&Path>, display: Option<&str>) -> Option<PathBuf> {
|
||||
if let Some(abs) = absolute {
|
||||
return Some(abs.to_path_buf());
|
||||
@@ -100,3 +136,25 @@ pub fn highlight_line(
|
||||
|
||||
segments
|
||||
}
|
||||
|
||||
pub fn build_highlighter_for_language(language: Option<&str>) -> HighlightLines<'static> {
|
||||
let syntax = select_syntax_for_language(language);
|
||||
HighlightLines::new(syntax, &THEME)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rust_highlighting_produces_colored_segment() {
|
||||
let mut highlighter = build_highlighter_for_language(Some("rust"));
|
||||
let segments = highlight_line(&mut highlighter, "fn main() {}");
|
||||
assert!(
|
||||
segments
|
||||
.iter()
|
||||
.any(|(style, text)| style.fg.is_some() && !text.trim().is_empty()),
|
||||
"Expected at least one colored segment"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user