From c77124812de41212ca0378dcc44ed6fa71e983f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 06:52:16 -0400 Subject: [PATCH] Code colors per language: comments, keywords and constants by file; Shell comments start words (Ely R2.12) --- src/editor/syntax.rs | 17 +++- src/forms/code.rs | 36 +++++--- src/forms/mod.rs | 2 + src/forms/syntax.rs | 212 +++++++++++++++++++++++++++++++++++++++++++ src/git/rows.rs | 125 ++++++++++++------------- src/git/viewer.rs | 13 ++- 6 files changed, 317 insertions(+), 88 deletions(-) create mode 100644 src/forms/syntax.rs diff --git a/src/editor/syntax.rs b/src/editor/syntax.rs index 3df8917..3c73829 100644 --- a/src/editor/syntax.rs +++ b/src/editor/syntax.rs @@ -3,7 +3,7 @@ use std::ops::Range; use gpui::{App, HighlightStyle}; use super::buffer::Buffer; -use crate::forms::{Kind, code_highlights_with, lex}; +use crate::forms::{Kind, Syntax, code_highlights_with, lex}; /// Each line that opens a block, and the last line the block holds, read from indentation. pub(crate) fn folds(buffer: &Buffer) -> Vec<(usize, usize)> { @@ -110,16 +110,23 @@ pub(crate) fn matched(brackets: &[Bracket], caret: usize) -> Option<(usize, usiz /// A line's syntax colors as highlight styles. pub(crate) fn colors(text: &str, cx: &App) -> Vec<(Range, HighlightStyle)> { - colors_with(text, "//", cx) + styles(crate::forms::code_highlights(text, cx)) } -/// Colors for a line whose comments start with `comment`. +/// A line's colors as `lang` reads it. pub(crate) fn colors_with( text: &str, - comment: &str, + lang: &Syntax, cx: &App, ) -> Vec<(Range, HighlightStyle)> { - code_highlights_with(text, comment, cx) + styles(code_highlights_with(text, lang, cx)) +} + +/// Highlights as gpui styles. +fn styles( + highlights: Vec<(Range, crate::forms::Highlight)>, +) -> Vec<(Range, HighlightStyle)> { + highlights .into_iter() .map(|(range, highlight)| { ( diff --git a/src/forms/code.rs b/src/forms/code.rs index e45073e..2cc52f7 100644 --- a/src/forms/code.rs +++ b/src/forms/code.rs @@ -5,7 +5,7 @@ use gpui::{ prelude::*, }; -use super::{Highlight, Input, TextInput}; +use super::{Highlight, Input, TextInput, syntax::Syntax}; use crate::theme::{ActiveTheme, ControlSize, TextSize}; /// What a span of source is. @@ -42,13 +42,21 @@ const CONSTANTS: &[&str] = &[ "FALSE", ]; +/// Every language at once, for code of unknown kind. +const GENERIC: Syntax = Syntax { + comment: "//", + word_comment: false, + keywords: KEYWORDS, + constants: CONSTANTS, +}; + /// Splits source into spans; spaces fall between them. pub(crate) fn lex(source: &str) -> Vec<(Range, Kind)> { - lex_with(source, "//") + lex_with(source, &GENERIC) } -/// Splits source whose line comments start with `comment`. -pub(crate) fn lex_with(source: &str, comment: &str) -> Vec<(Range, Kind)> { +/// Splits source as `syntax` reads it. +pub(crate) fn lex_with(source: &str, syntax: &Syntax) -> Vec<(Range, Kind)> { let mut out = Vec::new(); let mut chars = source.char_indices().peekable(); while let Some((start, ch)) = chars.next() { @@ -66,7 +74,13 @@ pub(crate) fn lex_with(source: &str, comment: &str) -> Vec<(Range, Kind)> } Kind::Text } - _ if source[start..].starts_with(comment) => { + _ if source[start..].starts_with(syntax.comment) + && (!syntax.word_comment + || source[..start] + .chars() + .next_back() + .is_none_or(char::is_whitespace)) => + { while chars.next_if(|(_, next)| *next != '\n').is_some() {} Kind::Comment } @@ -101,17 +115,17 @@ fn painted(spans: impl Iterator, Hsla)>) -> Vec<(Range Vec<(Range, Highlight)> { - code_highlights_with(source, "//", cx) + code_highlights_with(source, &GENERIC, cx) } -/// Colors for code whose line comments start with `comment`. +/// Colors for code as `lang` reads it. pub(crate) fn code_highlights_with( source: &str, - comment: &str, + lang: &Syntax, cx: &App, ) -> Vec<(Range, Highlight)> { let syntax = &cx.theme().colors.syntax; - let spans = lex_with(source, comment); + let spans = lex_with(source, lang); let next_is = |ix: usize, mark: &str| { spans .get(ix + 1) @@ -123,8 +137,8 @@ pub(crate) fn code_highlights_with( Kind::Comment => syntax.comment, Kind::Text => syntax.string, Kind::Number => syntax.number, - Kind::Word if KEYWORDS.contains(&text) => syntax.keyword, - Kind::Word if CONSTANTS.contains(&text) => syntax.constant, + Kind::Word if lang.keywords.contains(&text) => syntax.keyword, + Kind::Word if lang.constants.contains(&text) => syntax.constant, Kind::Word if next_is(ix, "(") => syntax.function, Kind::Word if text.starts_with(char::is_uppercase) => syntax.type_name, Kind::Word => syntax.variable, diff --git a/src/forms/mod.rs b/src/forms/mod.rs index e1e8298..04f7a5c 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -37,6 +37,7 @@ mod slider; mod stepper; mod structure; mod switch; +mod syntax; mod tags; #[cfg(all(test, feature = "test-support"))] mod tests; @@ -112,6 +113,7 @@ pub use structure::{ DirtyIndicator, Form, FormError, FormField, FormLabel, FormSection, InlineForm, }; pub use switch::Switch; +pub(crate) use syntax::{Syntax, syntax_of}; pub use tags::TagInput; pub(crate) use text::{ Backspace, Copy, Cut, Delete, DeleteWordLeft, Down, Enter, Redo, Submit, Undo, Up, bind_keys, diff --git a/src/forms/syntax.rs b/src/forms/syntax.rs new file mode 100644 index 0000000..7c27d37 --- /dev/null +++ b/src/forms/syntax.rs @@ -0,0 +1,212 @@ +//! How one language's code reads, by file name. + +/// A language's comments, keywords and constants. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct Syntax { + /// The mark that opens a line comment. + pub comment: &'static str, + /// The mark counts only where a word starts (Shell). + pub word_comment: bool, + pub keywords: &'static [&'static str], + pub constants: &'static [&'static str], +} + +/// C, Rust, JavaScript, Go, Java, Swift and kin. +pub(crate) const C_LIKE: Syntax = Syntax { + comment: "//", + word_comment: false, + keywords: &[ + "as", + "async", + "await", + "break", + "case", + "catch", + "class", + "const", + "continue", + "do", + "else", + "enum", + "export", + "extends", + "fn", + "for", + "from", + "func", + "function", + "if", + "impl", + "import", + "in", + "interface", + "let", + "match", + "mut", + "new", + "package", + "pub", + "return", + "static", + "struct", + "switch", + "throw", + "trait", + "try", + "type", + "use", + "var", + "where", + "while", + "yield", + ], + constants: &[ + "false", + "nil", + "null", + "self", + "Self", + "this", + "true", + "undefined", + "None", + "Some", + ], +}; + +const PYTHON: Syntax = Syntax { + comment: "#", + word_comment: false, + keywords: &[ + "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del", + "elif", "else", "except", "finally", "for", "from", "global", "if", "import", "in", "is", + "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while", "with", + "yield", + ], + constants: &["True", "False", "None", "self"], +}; + +const SHELL: Syntax = Syntax { + comment: "#", + word_comment: true, + keywords: &[ + "case", "do", "done", "elif", "else", "esac", "export", "fi", "for", "function", "if", + "in", "local", "return", "then", "until", "while", + ], + constants: &["true", "false"], +}; + +/// TOML, YAML, INI and other settings: keys are not words. +const SETTINGS: Syntax = Syntax { + comment: "#", + word_comment: true, + keywords: &[], + constants: &["true", "false", "null", "yes", "no", "on", "off"], +}; + +const INI: Syntax = Syntax { + comment: ";", + ..SETTINGS +}; + +const SQL: Syntax = Syntax { + comment: "--", + word_comment: false, + keywords: &[ + "SELECT", "FROM", "WHERE", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "ON", "AND", "OR", + "NOT", "AS", "GROUP", "BY", "ORDER", "HAVING", "LIMIT", "OFFSET", "INSERT", "INTO", + "VALUES", "UPDATE", "SET", "DELETE", "CREATE", "TABLE", "INDEX", "DROP", "ALTER", "DESC", + "ASC", "DISTINCT", "UNION", "WITH", "CASE", "WHEN", "THEN", "ELSE", "END", "IS", "IN", + "LIKE", "BETWEEN", "EXISTS", + ], + constants: &["NULL", "TRUE", "FALSE"], +}; + +const LUA: Syntax = Syntax { + comment: "--", + word_comment: false, + keywords: &[ + "and", "break", "do", "else", "elseif", "end", "for", "function", "goto", "if", "in", + "local", "not", "or", "repeat", "return", "then", "until", "while", + ], + constants: &["true", "false", "nil"], +}; + +const LISP: Syntax = Syntax { + comment: ";", + word_comment: false, + keywords: &[ + "defun", "defn", "define", "lambda", "let", "if", "cond", "quote", + ], + constants: &["nil", "t", "true", "false"], +}; + +/// Ruby, Perl, CMake and other `#` languages. +const HASH: Syntax = Syntax { + comment: "#", + ..C_LIKE +}; + +const TEX: Syntax = Syntax { + comment: "%", + word_comment: false, + keywords: &[], + constants: &[], +}; + +/// Extensions of prose and plain text, which read plain. +const PROSE: &[&str] = &[ + "md", "markdown", "mdown", "mkd", "txt", "text", "rst", "adoc", "asciidoc", "org", "csv", + "tsv", "log", +]; + +/// Languages by extension; any other code reads C-like. +const BY_EXTENSION: &[(&Syntax, &[&str])] = &[ + (&PYTHON, &["py", "pyi", "pyw"]), + (&SHELL, &["sh", "bash", "zsh", "fish", "ksh"]), + ( + &HASH, + &[ + "rb", "pl", "r", "cmake", "mk", "nix", "ex", "exs", "jl", "nim", "cr", "tcl", "ps1", + ], + ), + ( + &SETTINGS, + &[ + "toml", + "yaml", + "yml", + "conf", + "cfg", + "gitignore", + "env", + "properties", + ], + ), + (&INI, &["ini"]), + (&SQL, &["sql"]), + (&LUA, &["lua"]), + (&LISP, &["clj", "cljs", "lisp", "el", "scm"]), + (&TEX, &["tex", "sty", "bib"]), +]; + +/// How the file at `path` reads; none for prose. +pub(crate) fn syntax_of(path: &str) -> Option<&'static Syntax> { + let name = path.rsplit_once('/').map_or(path, |(_, name)| name); + // Named files first: CMakeLists.txt is not prose. + match name { + "Makefile" | "GNUmakefile" | "Dockerfile" | "CMakeLists.txt" => return Some(&SHELL), + _ => {} + } + let ext = match name.rsplit_once('.') { + Some((_, ext)) => ext.to_ascii_lowercase(), + None => return None, + }; + if PROSE.contains(&ext.as_str()) { + return None; + } + let found = BY_EXTENSION + .iter() + .find(|(_, exts)| exts.contains(&ext.as_str())); + Some(found.map_or(&C_LIKE, |(syntax, _)| *syntax)) +} diff --git a/src/git/rows.rs b/src/git/rows.rs index e6dded7..711f8c1 100644 --- a/src/git/rows.rs +++ b/src/git/rows.rs @@ -10,6 +10,7 @@ use super::{ }; use crate::{ editor::{code_colors_with, stack}, + forms::Syntax, theme::Palette, }; @@ -78,77 +79,24 @@ pub(super) fn washes(kind: LineKind, colors: &Palette) -> (Option, Hsla) { } } -/// Extensions of prose and plain text. -const PROSE: [&str; 13] = [ - "md", "markdown", "mdown", "mkd", "txt", "text", "rst", "adoc", "asciidoc", "org", "csv", - "tsv", "log", -]; - -/// Line comment marks by extension; others take `//`. -const COMMENTS: [(&str, &[&str]); 4] = [ - ( - "#", - &[ - "py", - "rb", - "sh", - "bash", - "zsh", - "fish", - "toml", - "yaml", - "yml", - "pl", - "r", - "conf", - "nix", - "ex", - "exs", - "cmake", - "mk", - "ps1", - "tcl", - "jl", - "cr", - "nim", - "gitignore", - ], - ), - ("--", &["sql", "lua", "hs", "elm", "ada"]), - ( - ";", - &["ini", "clj", "cljs", "lisp", "el", "scm", "asm", "s"], - ), - ("%", &["tex", "sty", "erl", "m"]), -]; - -/// How `path` reads: none for prose, else its comment mark. -pub(super) fn syntax(path: &str) -> Option<&'static str> { - let name = path.rsplit_once('/').map_or(path, |(_, name)| name); - let ext = match name.rsplit_once('.') { - Some((stem, ext)) if !stem.is_empty() => ext.to_ascii_lowercase(), - Some(("", dotfile)) => dotfile.to_ascii_lowercase(), - _ if matches!(name, "Makefile" | "Dockerfile" | "CMakeLists.txt") => return Some("#"), - _ => return None, - }; - if PROSE.contains(&ext.as_str()) { - return None; - } - let found = COMMENTS - .iter() - .find(|(_, exts)| exts.contains(&ext.as_str())); - Some(found.map_or("//", |(mark, _)| *mark)) -} - -/// A line's syntax colors as the file at `path` reads. -pub(super) fn colors_of(text: &str, path: &str, cx: &App) -> Vec<(Range, HighlightStyle)> { - syntax(path).map_or_else(Vec::new, |comment| code_colors_with(text, comment, cx)) +/// A line's syntax colors; none for prose. +pub(super) fn colors_of( + text: &str, + syntax: Option<&Syntax>, + cx: &App, +) -> Vec<(Range, HighlightStyle)> { + syntax.map_or_else(Vec::new, |syntax| code_colors_with(text, syntax, cx)) } /// A line colored as its file reads, changed words washed. -pub(super) fn code(line: &DiffLine, colors: &Palette, path: &str, cx: &App) -> StyledText { +pub(super) fn code( + line: &DiffLine, + colors: &Palette, + syntax: Option<&Syntax>, + cx: &App, +) -> StyledText { let (_, word) = washes(line.kind, colors); - let mut styles = colors_of(&line.text, path, cx); + let mut styles = colors_of(&line.text, syntax, cx); styles.extend(line.words.iter().map(|range| { ( range.clone(), @@ -181,7 +129,8 @@ mod tests { crate::init_for_tests(cx); let syntax = crate::theme::ActiveTheme::theme(cx).colors.syntax.clone(); let color = |text: &str, path: &str, cx: &gpui::App| { - super::colors_of(text, path, cx) + let syntax = crate::forms::syntax_of(path); + super::colors_of(text, syntax, cx) .into_iter() .map(|(range, style)| (range, style.color)) .collect::>() @@ -204,6 +153,8 @@ mod tests { "run.sh", ".gitignore", "Makefile", + "CMakeLists.txt", + "build.cmake", ] { assert_eq!( color("# return 42", path, cx), @@ -218,9 +169,47 @@ mod tests { let rust = color("return 42 // done", "src/main.rs", cx); assert_eq!(rust[0], (0..6, Some(syntax.keyword)), "{rust:?}"); assert_eq!(rust.last(), Some(&(10..17, Some(syntax.comment)))); + // Each language's own words. + let python = color("assert ready and True", "a.py", cx); + assert_eq!( + [python[0].1, python[2].1, python[3].1], + [ + Some(syntax.keyword), + Some(syntax.keyword), + Some(syntax.constant) + ] + ); + assert_ne!( + color("return = 42", "config.toml", cx)[0].1, + Some(syntax.keyword), + "a TOML key" + ); + // Shell comments start words only. + let shell = color("echo foo#bar 42", "run.sh", cx); + assert!( + shell.iter().all(|(_, c)| *c != Some(syntax.comment)), + "{shell:?}" + ); + assert_eq!( + color("echo x # note", "run.sh", cx).last(), + Some(&(7..13, Some(syntax.comment))) + ); }); } + #[test] + fn a_viewer_reads_its_file_once_built() { + let viewer = |path: &str| { + super::super::viewer::DiffViewer::new("d", path.to_string(), "a\n", "b\n").syntax + }; + assert_eq!(viewer("notes.md"), None); + assert_eq!(viewer("a.py"), crate::forms::syntax_of("a.py")); + assert_eq!( + viewer("CMakeLists.txt"), + crate::forms::syntax_of("Makefile") + ); + } + #[test] fn a_line_speaks_its_number_change_and_text() { let stretches = super::super::diff::diff("a\nb\n", "a\nc\n", 1); diff --git a/src/git/viewer.rs b/src/git/viewer.rs index 620dac7..e0d32f1 100644 --- a/src/git/viewer.rs +++ b/src/git/viewer.rs @@ -13,6 +13,7 @@ use super::{ }; use crate::{ buttons::{Button, ButtonVariant, SegmentedControl}, + forms::{Syntax, syntax_of}, i18n, primitives::FocusRing, theme::{ActiveTheme, ControlSize, TextSize}, @@ -38,6 +39,8 @@ type OnLine = Rc; pub struct DiffViewer { id: ElementId, path: SharedString, + /// How its lines read, from the path. + pub(super) syntax: Option<&'static Syntax>, stretches: Rc>, stat: (usize, usize), layout: DiffLayout, @@ -57,9 +60,11 @@ impl DiffViewer { old: &str, new: &str, ) -> Self { + let path: SharedString = path.into(); Self { id: id.into(), - path: path.into(), + syntax: syntax_of(&path), + path, stretches: Rc::new(diff(old, new, CONTEXT)), stat: super::diff::stat(old, new), layout: DiffLayout::Unified, @@ -194,7 +199,7 @@ impl RenderOnce for DiffViewer { .len() .max(2); let (id, on_open) = (self.id.clone(), self.on_open.clone()); - let path = self.path.clone(); + let syntax = self.syntax; let (button, button_height) = ( self.hunk_action.is_some(), theme.control_height(ControlSize::Sm), @@ -272,7 +277,7 @@ impl RenderOnce for DiffViewer { .children(line.map(|(_, line)| { div() .whitespace_nowrap() - .child(code(line, &colors, &path, cx)) + .child(code(line, &colors, syntax, cx)) })); match line { Some((place, line)) => { @@ -331,7 +336,7 @@ impl RenderOnce for DiffViewer { .flex_1() .min_w_0() .overflow_hidden() - .child(code(line, &colors, &path, cx)), + .child(code(line, &colors, syntax, cx)), ) .into_any_element() }