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

View file

@ -53,20 +53,34 @@ impl Loader {
/// Loads a theme searching directories in priority order.
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" {
return Ok(self.default());
return Ok((self.default(), Vec::new()));
}
if name == "base16_default" {
return Ok(self.base16_default());
return Ok((self.base16_default(), Vec::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(),
..theme
})
};
Ok((theme, warnings))
}
/// Recursively load a theme, merging with any inherited parent themes.
@ -87,10 +101,7 @@ impl Loader {
let theme_toml = if let Some(parent_theme_name) = inherits {
let parent_theme_name = parent_theme_name.as_str().ok_or_else(|| {
anyhow!(
"Theme: expected 'inherits' to be a string: {}",
parent_theme_name
)
anyhow!("Expected 'inherits' to be a string: {}", parent_theme_name)
})?;
let parent_theme_toml = match parent_theme_name {
@ -181,9 +192,9 @@ impl Loader {
})
.ok_or_else(|| {
if cycle_found {
anyhow!("Theme: cycle found in inheriting: {}", name)
anyhow!("Cycle found in inheriting: {}", name)
} 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 {
fn from(value: Value) -> Self {
if let Value::Table(table) = value {
let (styles, scopes, highlights) = build_theme_values(table);
Self {
styles,
scopes,
highlights,
..Default::default()
}
} else {
warn!("Expected theme TOML value to be a table, found {:?}", value);
Default::default()
let (theme, warnings) = Theme::from_toml(value);
for warning in warnings {
warn!("{}", warning);
}
theme
}
}
@ -242,31 +245,29 @@ impl<'de> Deserialize<'de> for Theme {
D: Deserializer<'de>,
{
let values = Map::<String, Value>::deserialize(deserializer)?;
let (styles, scopes, highlights) = build_theme_values(values);
Ok(Self {
styles,
scopes,
highlights,
..Default::default()
})
let (theme, warnings) = Theme::from_keys(values);
for warning in warnings {
warn!("{}", warning);
}
Ok(theme)
}
}
fn build_theme_values(
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 scopes = Vec::new();
let mut highlights = Vec::new();
let mut warnings = Vec::new();
// TODO: alert user of parsing failures in editor
let palette = values
.remove("palette")
.map(|value| {
ThemePalette::try_from(value).unwrap_or_else(|err| {
warn!("{}", err);
warnings.push(err);
ThemePalette::default()
})
})
@ -279,7 +280,7 @@ fn build_theme_values(
for (name, style_value) in values {
let mut style = Style::default();
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
@ -288,7 +289,7 @@ fn build_theme_values(
highlights.push(style);
}
(styles, scopes, highlights)
(styles, scopes, highlights, warnings)
}
impl Theme {
@ -354,6 +355,27 @@ impl Theme {
.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 {
@ -408,7 +430,7 @@ impl ThemePalette {
if let Ok(index) = s.parse::<u8>() {
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> {
@ -422,13 +444,13 @@ impl ThemePalette {
}
}
Err(format!("Theme: malformed hexcode: {}", s))
Err(format!("Malformed hexcode: {}", s))
}
fn parse_value_as_str(value: &Value) -> Result<&str, String> {
value
.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> {
@ -445,14 +467,14 @@ impl ThemePalette {
value
.as_str()
.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> {
value
.as_str()
.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> {
@ -462,9 +484,7 @@ impl ThemePalette {
"fg" => *style = style.fg(self.parse_color(value)?),
"bg" => *style = style.bg(self.parse_color(value)?),
"underline" => {
let table = value
.as_table_mut()
.ok_or("Theme: underline must be table")?;
let table = value.as_table_mut().ok_or("Underline must be table")?;
if let Some(value) = table.remove("color") {
*style = style.underline_color(self.parse_color(value)?);
}
@ -473,13 +493,11 @@ impl ThemePalette {
}
if let Some(attr) = table.keys().next() {
return Err(format!("Theme: invalid underline attribute: {attr}"));
return Err(format!("Invalid underline attribute: {attr}"));
}
}
"modifiers" => {
let modifiers = value
.as_array()
.ok_or("Theme: modifiers should be an array")?;
let modifiers = value.as_array().ok_or("Modifiers should be an array")?;
for modifier in modifiers {
if modifier
@ -492,7 +510,7 @@ impl ThemePalette {
}
}
}
_ => return Err(format!("Theme: invalid style attribute: {}", name)),
_ => return Err(format!("Invalid style attribute: {}", name)),
}
}
} else {

View file

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

View file

@ -68,7 +68,8 @@
"ui.menu.selected" = { fg = "dark_red", bg = "light_blue" }
"ui.selection" = { bg = "lightgoldenrod1" }
"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.inlay-hint" = { fg = "gray75" }
"ui.cursorline.primary" = { bg = "darkseagreen2" }

View file

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

View file

@ -25,7 +25,8 @@
"ui.statusline.normal" = { fg = "sumiInk0", bg = "crystalBlue", modifiers = ["bold"] }
"ui.statusline.insert" = { fg = "sumiInk0", bg = "autumnGreen", 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.active" = { fg = "oldWhite", bg = "sumiInk0" }

View file

@ -79,7 +79,8 @@
"ui.statusline" = { fg = "active_text", bg = "#414339" }
"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 = [
"bold",
] }

View file

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

View file

@ -61,7 +61,8 @@
"ui.cursor" = { fg = "white", modifiers = ["reversed"] }
"ui.cursor.primary" = { fg = "white", modifiers = ["reversed"] }
"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.primary" = { bg = "#293b5bff" }

View file

@ -2,6 +2,7 @@ mod docgen;
mod helpers;
mod path;
mod querycheck;
mod theme_check;
use std::{env, error::Error};
@ -11,6 +12,7 @@ pub mod tasks {
use crate::docgen::{lang_features, typable_commands, write};
use crate::docgen::{LANG_SUPPORT_MD_OUTPUT, TYPABLE_COMMANDS_MD_OUTPUT};
use crate::querycheck::query_check;
use crate::theme_check::theme_check;
use crate::DynError;
pub fn docgen() -> Result<(), DynError> {
@ -23,6 +25,10 @@ pub mod tasks {
query_check()
}
pub fn themecheck() -> Result<(), DynError> {
theme_check()
}
pub fn print_help() {
println!(
"
@ -43,6 +49,7 @@ fn main() -> Result<(), DynError> {
Some(t) => match t.as_str() {
"docgen" => tasks::docgen()?,
"query-check" => tasks::querycheck()?,
"theme-check" => tasks::themecheck()?,
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/")
}
pub fn runtime() -> PathBuf {
project_root().join("runtime")
}
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 {

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(())
}
}
}