Add: validation of bundled themes in build workflow (#11627)

* Add: xtask to check themes for validation warnings

* Update: tidied up runtime paths

* Update: test build workflow

* Update: address clippy lints

* Revert: only trigger workflow on push to master branch

* Add: Theme::from_keys factory method to construct theme from Toml keys

* Update: returning validation failures in Loader.load method

* Update: commented out invalid keys from affected themes

* Update: correct invalid keys so that valid styles still applied

* Update: include default and base16_default themes in check

* Update: renamed validation_failures to load_errors

* Update: introduce load_with_warnings helper function and centralise logging of theme warnings

* Update: use consistent naming throughout
This commit is contained in:
Tim 2024-09-28 12:52:09 +01:00 committed by GitHub
parent 70bbc9d526
commit 82dd963693
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 143 additions and 61 deletions

View File

@ -16,6 +16,7 @@ jobs:
steps: steps:
- name: Checkout sources - name: Checkout sources
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install stable toolchain - name: Install stable toolchain
uses: dtolnay/rust-toolchain@1.70 uses: dtolnay/rust-toolchain@1.70
@ -107,6 +108,9 @@ jobs:
- name: Validate queries - name: Validate queries
run: cargo xtask query-check run: cargo xtask query-check
- name: Validate themes
run: cargo xtask theme-check
- name: Generate docs - name: Generate docs
run: cargo xtask docgen run: cargo xtask docgen

View File

@ -53,20 +53,34 @@ pub fn new(dirs: &[PathBuf]) -> Self {
/// Loads a theme searching directories in priority order. /// Loads a theme searching directories in priority order.
pub fn load(&self, name: &str) -> Result<Theme> { pub fn load(&self, name: &str) -> Result<Theme> {
let (theme, warnings) = self.load_with_warnings(name)?;
for warning in warnings {
warn!("Theme '{}': {}", name, warning);
}
Ok(theme)
}
/// Loads a theme searching directories in priority order, returning any warnings
pub fn load_with_warnings(&self, name: &str) -> Result<(Theme, Vec<String>)> {
if name == "default" { if name == "default" {
return Ok(self.default()); return Ok((self.default(), Vec::new()));
} }
if name == "base16_default" { if name == "base16_default" {
return Ok(self.base16_default()); return Ok((self.base16_default(), Vec::new()));
} }
let mut visited_paths = HashSet::new(); let mut visited_paths = HashSet::new();
let theme = self.load_theme(name, &mut visited_paths).map(Theme::from)?; let (theme, warnings) = self
.load_theme(name, &mut visited_paths)
.map(Theme::from_toml)?;
Ok(Theme { let theme = Theme {
name: name.into(), name: name.into(),
..theme ..theme
}) };
Ok((theme, warnings))
} }
/// Recursively load a theme, merging with any inherited parent themes. /// Recursively load a theme, merging with any inherited parent themes.
@ -87,10 +101,7 @@ fn load_theme(&self, name: &str, visited_paths: &mut HashSet<PathBuf>) -> Result
let theme_toml = if let Some(parent_theme_name) = inherits { let theme_toml = if let Some(parent_theme_name) = inherits {
let parent_theme_name = parent_theme_name.as_str().ok_or_else(|| { let parent_theme_name = parent_theme_name.as_str().ok_or_else(|| {
anyhow!( anyhow!("Expected 'inherits' to be a string: {}", parent_theme_name)
"Theme: expected 'inherits' to be a string: {}",
parent_theme_name
)
})?; })?;
let parent_theme_toml = match parent_theme_name { let parent_theme_toml = match parent_theme_name {
@ -181,9 +192,9 @@ fn path(&self, name: &str, visited_paths: &mut HashSet<PathBuf>) -> Result<PathB
}) })
.ok_or_else(|| { .ok_or_else(|| {
if cycle_found { if cycle_found {
anyhow!("Theme: cycle found in inheriting: {}", name) anyhow!("Cycle found in inheriting: {}", name)
} else { } else {
anyhow!("Theme: file not found for: {}", name) anyhow!("File not found for: {}", name)
} }
}) })
} }
@ -220,19 +231,11 @@ pub struct Theme {
impl From<Value> for Theme { impl From<Value> for Theme {
fn from(value: Value) -> Self { fn from(value: Value) -> Self {
if let Value::Table(table) = value { let (theme, warnings) = Theme::from_toml(value);
let (styles, scopes, highlights) = build_theme_values(table); for warning in warnings {
warn!("{}", warning);
Self {
styles,
scopes,
highlights,
..Default::default()
}
} else {
warn!("Expected theme TOML value to be a table, found {:?}", value);
Default::default()
} }
theme
} }
} }
@ -242,31 +245,29 @@ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
D: Deserializer<'de>, D: Deserializer<'de>,
{ {
let values = Map::<String, Value>::deserialize(deserializer)?; let values = Map::<String, Value>::deserialize(deserializer)?;
let (theme, warnings) = Theme::from_keys(values);
let (styles, scopes, highlights) = build_theme_values(values); for warning in warnings {
warn!("{}", warning);
Ok(Self { }
styles, Ok(theme)
scopes,
highlights,
..Default::default()
})
} }
} }
fn build_theme_values( fn build_theme_values(
mut values: Map<String, Value>, mut values: Map<String, Value>,
) -> (HashMap<String, Style>, Vec<String>, Vec<Style>) { ) -> (HashMap<String, Style>, Vec<String>, Vec<Style>, Vec<String>) {
let mut styles = HashMap::new(); let mut styles = HashMap::new();
let mut scopes = Vec::new(); let mut scopes = Vec::new();
let mut highlights = Vec::new(); let mut highlights = Vec::new();
let mut warnings = Vec::new();
// TODO: alert user of parsing failures in editor // TODO: alert user of parsing failures in editor
let palette = values let palette = values
.remove("palette") .remove("palette")
.map(|value| { .map(|value| {
ThemePalette::try_from(value).unwrap_or_else(|err| { ThemePalette::try_from(value).unwrap_or_else(|err| {
warn!("{}", err); warnings.push(err);
ThemePalette::default() ThemePalette::default()
}) })
}) })
@ -279,7 +280,7 @@ fn build_theme_values(
for (name, style_value) in values { for (name, style_value) in values {
let mut style = Style::default(); let mut style = Style::default();
if let Err(err) = palette.parse_style(&mut style, style_value) { if let Err(err) = palette.parse_style(&mut style, style_value) {
warn!("{}", err); warnings.push(err);
} }
// these are used both as UI and as highlights // these are used both as UI and as highlights
@ -288,7 +289,7 @@ fn build_theme_values(
highlights.push(style); highlights.push(style);
} }
(styles, scopes, highlights) (styles, scopes, highlights, warnings)
} }
impl Theme { impl Theme {
@ -354,6 +355,27 @@ pub fn is_16_color(&self) -> bool {
.all(|color| !matches!(color, Some(Color::Rgb(..)))) .all(|color| !matches!(color, Some(Color::Rgb(..))))
}) })
} }
fn from_toml(value: Value) -> (Self, Vec<String>) {
if let Value::Table(table) = value {
Theme::from_keys(table)
} else {
warn!("Expected theme TOML value to be a table, found {:?}", value);
Default::default()
}
}
fn from_keys(toml_keys: Map<String, Value>) -> (Self, Vec<String>) {
let (styles, scopes, highlights, load_errors) = build_theme_values(toml_keys);
let theme = Self {
styles,
scopes,
highlights,
..Default::default()
};
(theme, load_errors)
}
} }
struct ThemePalette { struct ThemePalette {
@ -408,7 +430,7 @@ fn ansi_string_to_rgb(s: &str) -> Result<Color, String> {
if let Ok(index) = s.parse::<u8>() { if let Ok(index) = s.parse::<u8>() {
return Ok(Color::Indexed(index)); return Ok(Color::Indexed(index));
} }
Err(format!("Theme: malformed ANSI: {}", s)) Err(format!("Malformed ANSI: {}", s))
} }
fn hex_string_to_rgb(s: &str) -> Result<Color, String> { fn hex_string_to_rgb(s: &str) -> Result<Color, String> {
@ -422,13 +444,13 @@ fn hex_string_to_rgb(s: &str) -> Result<Color, String> {
} }
} }
Err(format!("Theme: malformed hexcode: {}", s)) Err(format!("Malformed hexcode: {}", s))
} }
fn parse_value_as_str(value: &Value) -> Result<&str, String> { fn parse_value_as_str(value: &Value) -> Result<&str, String> {
value value
.as_str() .as_str()
.ok_or(format!("Theme: unrecognized value: {}", value)) .ok_or(format!("Unrecognized value: {}", value))
} }
pub fn parse_color(&self, value: Value) -> Result<Color, String> { pub fn parse_color(&self, value: Value) -> Result<Color, String> {
@ -445,14 +467,14 @@ pub fn parse_modifier(value: &Value) -> Result<Modifier, String> {
value value
.as_str() .as_str()
.and_then(|s| s.parse().ok()) .and_then(|s| s.parse().ok())
.ok_or(format!("Theme: invalid modifier: {}", value)) .ok_or(format!("Invalid modifier: {}", value))
} }
pub fn parse_underline_style(value: &Value) -> Result<UnderlineStyle, String> { pub fn parse_underline_style(value: &Value) -> Result<UnderlineStyle, String> {
value value
.as_str() .as_str()
.and_then(|s| s.parse().ok()) .and_then(|s| s.parse().ok())
.ok_or(format!("Theme: invalid underline style: {}", value)) .ok_or(format!("Invalid underline style: {}", value))
} }
pub fn parse_style(&self, style: &mut Style, value: Value) -> Result<(), String> { pub fn parse_style(&self, style: &mut Style, value: Value) -> Result<(), String> {
@ -462,9 +484,7 @@ pub fn parse_style(&self, style: &mut Style, value: Value) -> Result<(), String>
"fg" => *style = style.fg(self.parse_color(value)?), "fg" => *style = style.fg(self.parse_color(value)?),
"bg" => *style = style.bg(self.parse_color(value)?), "bg" => *style = style.bg(self.parse_color(value)?),
"underline" => { "underline" => {
let table = value let table = value.as_table_mut().ok_or("Underline must be table")?;
.as_table_mut()
.ok_or("Theme: underline must be table")?;
if let Some(value) = table.remove("color") { if let Some(value) = table.remove("color") {
*style = style.underline_color(self.parse_color(value)?); *style = style.underline_color(self.parse_color(value)?);
} }
@ -473,13 +493,11 @@ pub fn parse_style(&self, style: &mut Style, value: Value) -> Result<(), String>
} }
if let Some(attr) = table.keys().next() { if let Some(attr) = table.keys().next() {
return Err(format!("Theme: invalid underline attribute: {attr}")); return Err(format!("Invalid underline attribute: {attr}"));
} }
} }
"modifiers" => { "modifiers" => {
let modifiers = value let modifiers = value.as_array().ok_or("Modifiers should be an array")?;
.as_array()
.ok_or("Theme: modifiers should be an array")?;
for modifier in modifiers { for modifier in modifiers {
if modifier if modifier
@ -492,7 +510,7 @@ pub fn parse_style(&self, style: &mut Style, value: Value) -> Result<(), String>
} }
} }
} }
_ => return Err(format!("Theme: invalid style attribute: {}", name)), _ => return Err(format!("Invalid style attribute: {}", name)),
} }
} }
} else { } else {

View File

@ -68,8 +68,10 @@
"ui.statusline.select" = { fg = "my_gray7", bg = "my_black", modifiers = ["bold"] } "ui.statusline.select" = { fg = "my_gray7", bg = "my_black", modifiers = ["bold"] }
"ui.text.focus" = "my_white1" "ui.text.focus" = "my_white1"
"ui.text" = "my_white1" "ui.text" = "my_white1"
"ui.virtual.inlay-hint" = { fg = "my_gray4", bg="my_black", modifiers = ["normal"] } # Invalid modifier: "normal". See 'https://github.com/helix-editor/helix/issues/5709'
"ui.virtual.inlay-hint.parameter" = { fg = "my_gray4", modifiers = ["normal"] } "ui.virtual.inlay-hint" = { fg = "my_gray4", bg="my_black" } #, modifiers = ["normal"] }
# "ui.virtual.inlay-hint.parameter" = { fg = "my_gray4", modifiers = ["normal"] }
"ui.virtual.inlay-hint.parameter" = "my_gray4"
"ui.virtual.inlay-hint.type" = { fg = "my_gray4", modifiers = ["italic"] } "ui.virtual.inlay-hint.type" = { fg = "my_gray4", modifiers = ["italic"] }
"ui.virtual.jump-label" = { fg = "my_yellow2", modifiers = ["bold"] } "ui.virtual.jump-label" = { fg = "my_yellow2", modifiers = ["bold"] }
"ui.virtual.ruler" = { bg = "my_gray1" } "ui.virtual.ruler" = { bg = "my_gray1" }

View File

@ -68,7 +68,8 @@
"ui.menu.selected" = { fg = "dark_red", bg = "light_blue" } "ui.menu.selected" = { fg = "dark_red", bg = "light_blue" }
"ui.selection" = { bg = "lightgoldenrod1" } "ui.selection" = { bg = "lightgoldenrod1" }
"ui.selection.primary" = { bg = "lightgoldenrod2" } "ui.selection.primary" = { bg = "lightgoldenrod2" }
"ui.virtual.whitespace" = "highlight" # Malformed ANSI: highlight. See 'https://github.com/helix-editor/helix/issues/5709'
# "ui.virtual.whitespace" = "highlight"
"ui.virtual.ruler" = { bg = "gray95" } "ui.virtual.ruler" = { bg = "gray95" }
"ui.virtual.inlay-hint" = { fg = "gray75" } "ui.virtual.inlay-hint" = { fg = "gray75" }
"ui.cursorline.primary" = { bg = "darkseagreen2" } "ui.cursorline.primary" = { bg = "darkseagreen2" }

View File

@ -61,8 +61,11 @@
"ui.virtual" = { fg = "base5", bg = "base6" } "ui.virtual" = { fg = "base5", bg = "base6" }
"ui.virtual.whitespace" = { fg = "base5" } "ui.virtual.whitespace" = { fg = "base5" }
"ui.virtual.ruler" = { bg = "base6" } "ui.virtual.ruler" = { bg = "base6" }
"ui.virtual.inlay-hint" = { fg = "base4", modifiers = ["normal"] } # Invalid modifier: "normal". See 'https://github.com/helix-editor/helix/issues/5709'
"ui.virtual.inlay-hint.parameter" = { fg = "base3", modifiers = ["normal"] } # "ui.virtual.inlay-hint" = { fg = "base4", modifiers = ["normal"] }
# "ui.virtual.inlay-hint.parameter" = { fg = "base3", modifiers = ["normal"] }
"ui.virtual.inlay-hint" = "base4"
"ui.virtual.inlay-hint.parameter" = "base3"
"ui.virtual.inlay-hint.type" = { fg = "base3", modifiers = ["italic"] } "ui.virtual.inlay-hint.type" = { fg = "base3", modifiers = ["italic"] }
"ui.linenr" = { bg = "base6" } "ui.linenr" = { bg = "base6" }

View File

@ -25,7 +25,8 @@
"ui.statusline.normal" = { fg = "sumiInk0", bg = "crystalBlue", modifiers = ["bold"] } "ui.statusline.normal" = { fg = "sumiInk0", bg = "crystalBlue", modifiers = ["bold"] }
"ui.statusline.insert" = { fg = "sumiInk0", bg = "autumnGreen", modifiers = ["bold"] } "ui.statusline.insert" = { fg = "sumiInk0", bg = "autumnGreen", modifiers = ["bold"] }
"ui.statusline.select" = { fg = "sumiInk0", bg = "oniViolet", modifiers = ["bold"] } "ui.statusline.select" = { fg = "sumiInk0", bg = "oniViolet", modifiers = ["bold"] }
"ui.statusline.separator" = { fg = "", bg = "" } # Malformed ANSI: "". See 'https://github.com/helix-editor/helix/issues/5709'
# "ui.statusline.separator" = { fg = "", bg = "" }
"ui.bufferline" = { fg = "fujiGray", bg = "sumiInk0" } "ui.bufferline" = { fg = "fujiGray", bg = "sumiInk0" }
"ui.bufferline.active" = { fg = "oldWhite", bg = "sumiInk0" } "ui.bufferline.active" = { fg = "oldWhite", bg = "sumiInk0" }

View File

@ -79,7 +79,8 @@
"ui.statusline" = { fg = "active_text", bg = "#414339" } "ui.statusline" = { fg = "active_text", bg = "#414339" }
"ui.statusline.inactive" = { fg = "active_text", bg = "#75715e" } "ui.statusline.inactive" = { fg = "active_text", bg = "#75715e" }
"ui.bufferline" = { fg = "grey2", bg = "bg3" } # Malformed ANSI: grey2, bg3. See 'https://github.com/helix-editor/helix/issues/5709'
# "ui.bufferline" = { fg = "grey2", bg = "bg3" }
"ui.bufferline.active" = { fg = "active_text", bg = "selection", modifiers = [ "ui.bufferline.active" = { fg = "active_text", bg = "selection", modifiers = [
"bold", "bold",
] } ] }

View File

@ -8,9 +8,12 @@ inherits = "monokai"
"type" = { fg = "type", modifiers = ["bold"] } "type" = { fg = "type", modifiers = ["bold"] }
"ui.statusline.normal" = { fg = "light-black", bg = "cyan" } # Malformed ANSI: light-black, purple. See 'https://github.com/helix-editor/helix/issues/5709'
"ui.statusline.insert" = { fg = "light-black", bg = "green" } # "ui.statusline.normal" = { fg = "light-black", bg = "cyan" }
"ui.statusline.select" = { fg = "light-black", bg = "purple" } "ui.statusline.normal" = { bg = "cyan" }
# "ui.statusline.insert" = { fg = "light-black", bg = "green" }
"ui.statusline.insert" = { bg = "green" }
# "ui.statusline.select" = { fg = "light-black", bg = "purple" }
"ui.virtual.jump-label" = { fg = "cyan", modifiers = ["bold"] } "ui.virtual.jump-label" = { fg = "cyan", modifiers = ["bold"] }

View File

@ -61,7 +61,8 @@
"ui.cursor" = { fg = "white", modifiers = ["reversed"] } "ui.cursor" = { fg = "white", modifiers = ["reversed"] }
"ui.cursor.primary" = { fg = "white", modifiers = ["reversed"] } "ui.cursor.primary" = { fg = "white", modifiers = ["reversed"] }
"ui.cursor.match" = { fg = "blue", modifiers = ["underlined"] } "ui.cursor.match" = { fg = "blue", modifiers = ["underlined"] }
"ui.cursor.insert" = { fg = "dark-blue" } # Malformed ANSI: dark-blue. See 'https://github.com/helix-editor/helix/issues/5709'
# "ui.cursor.insert" = { fg = "dark-blue" }
"ui.selection" = { bg = "faint-gray" } "ui.selection" = { bg = "faint-gray" }
"ui.selection.primary" = { bg = "#293b5bff" } "ui.selection.primary" = { bg = "#293b5bff" }

View File

@ -2,6 +2,7 @@
mod helpers; mod helpers;
mod path; mod path;
mod querycheck; mod querycheck;
mod theme_check;
use std::{env, error::Error}; use std::{env, error::Error};
@ -11,6 +12,7 @@ pub mod tasks {
use crate::docgen::{lang_features, typable_commands, write}; use crate::docgen::{lang_features, typable_commands, write};
use crate::docgen::{LANG_SUPPORT_MD_OUTPUT, TYPABLE_COMMANDS_MD_OUTPUT}; use crate::docgen::{LANG_SUPPORT_MD_OUTPUT, TYPABLE_COMMANDS_MD_OUTPUT};
use crate::querycheck::query_check; use crate::querycheck::query_check;
use crate::theme_check::theme_check;
use crate::DynError; use crate::DynError;
pub fn docgen() -> Result<(), DynError> { pub fn docgen() -> Result<(), DynError> {
@ -23,6 +25,10 @@ pub fn querycheck() -> Result<(), DynError> {
query_check() query_check()
} }
pub fn themecheck() -> Result<(), DynError> {
theme_check()
}
pub fn print_help() { pub fn print_help() {
println!( println!(
" "
@ -43,6 +49,7 @@ fn main() -> Result<(), DynError> {
Some(t) => match t.as_str() { Some(t) => match t.as_str() {
"docgen" => tasks::docgen()?, "docgen" => tasks::docgen()?,
"query-check" => tasks::querycheck()?, "query-check" => tasks::querycheck()?,
"theme-check" => tasks::themecheck()?,
invalid => return Err(format!("Invalid task name: {}", invalid).into()), invalid => return Err(format!("Invalid task name: {}", invalid).into()),
}, },
}; };

View File

@ -11,8 +11,16 @@ pub fn book_gen() -> PathBuf {
project_root().join("book/src/generated/") project_root().join("book/src/generated/")
} }
pub fn runtime() -> PathBuf {
project_root().join("runtime")
}
pub fn ts_queries() -> PathBuf { pub fn ts_queries() -> PathBuf {
project_root().join("runtime/queries") runtime().join("queries")
}
pub fn themes() -> PathBuf {
runtime().join("themes")
} }
pub fn lang_config() -> PathBuf { pub fn lang_config() -> PathBuf {

33
xtask/src/theme_check.rs Normal file
View File

@ -0,0 +1,33 @@
use helix_view::theme::Loader;
use crate::{path, DynError};
pub fn theme_check() -> Result<(), DynError> {
let theme_names = [
vec!["default".to_string(), "base16_default".to_string()],
Loader::read_names(&path::themes()),
]
.concat();
let loader = Loader::new(&[path::runtime()]);
let mut errors_present = false;
for name in theme_names {
let (_, warnings) = loader.load_with_warnings(&name).unwrap();
if !warnings.is_empty() {
errors_present = true;
println!("Theme '{name}' loaded with errors:");
for warning in warnings {
println!("\t* {}", warning);
}
}
}
match errors_present {
true => Err("Errors found when loading bundled themes".into()),
false => {
println!("Theme check successful!");
Ok(())
}
}
}