diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f5b6c37..ed943ecd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,18 +16,24 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Validate configs + - name: Validate YAML configs uses: cardinalby/schema-validator-action@v3 with: file: 'etc/defaults/config*.yaml' schema: 'schema/json/config.schema.json' - - name: Validate themes + - name: Validate YAML themes uses: cardinalby/schema-validator-action@v3 with: - file: 'etc/defaults/themes/*.yaml|src/testing/assets/themes/*.yaml' + file: 'etc/defaults/themes/*.yaml' schema: 'schema/json/theme.schema.json' + - name: Setup taplo + uses: uncenter/setup-taplo@v1 + + - name: Validate TOML configs and themes + run: taplo check + - name: Install latest nightly uses: actions-rs/toolchain@v1 with: diff --git a/.taplo.toml b/.taplo.toml new file mode 100644 index 00000000..e36642c3 --- /dev/null +++ b/.taplo.toml @@ -0,0 +1 @@ +include = ["src/testing/assets/themes/test.toml"] diff --git a/Cargo.lock b/Cargo.lock index 5d8fb72a..560e45b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -815,7 +815,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hl" -version = "0.29.7-alpha.4" +version = "0.29.7-alpha.5" dependencies = [ "bincode", "byte-strings", @@ -869,6 +869,7 @@ dependencies = [ "strum", "thiserror", "titlecase", + "toml", "wildflower", "wildmatch", "winapi-util", diff --git a/Cargo.toml b/Cargo.toml index 2483f204..a05ad49a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = [".", "crate/encstr"] [workspace.package] repository = "https://github.com/pamburus/hl" authors = ["Pavel Ivanov "] -version = "0.29.7-alpha.4" +version = "0.29.7-alpha.5" edition = "2021" license = "MIT" @@ -74,6 +74,7 @@ snap = "1" strum = { version = "0", features = ["derive"] } thiserror = "1" titlecase = "3" +toml = "0" wildflower = { git = "https://github.com/cassaundra/wildflower.git" } winapi-util = { version = "0" } wyhash = "0" diff --git a/Makefile b/Makefile index 8439e935..918b6c74 100644 --- a/Makefile +++ b/Makefile @@ -16,15 +16,20 @@ help: .PHONY: help ## Run continuous integration tests -ci: check-fmt test build +ci: check-fmt check-schema test build @cargo run -- --version .PHONY: ci ## Run code formatting tests -check-fmt: +check-fmt: contrib-build @cargo +nightly fmt --all -- --check .PHONY: check-fmt +## Run schema validation tests +check-schema: contrib-build + @taplo check +.PHONY: check-schema + ## Automatically format code fmt: @cargo +nightly fmt --all diff --git a/contrib/bin/setup.sh b/contrib/bin/setup.sh index 8172d906..670ec198 100755 --- a/contrib/bin/setup.sh +++ b/contrib/bin/setup.sh @@ -120,12 +120,20 @@ setup_screenshot_tools() { setup_alacritty } +setup_taplo() { + setup_cargo + if [ ! -x "$(command -v taplo)" ]; then + cargo install taplo-cli --locked --features lsp + fi +} + # --- main --- while [ $# -gt 0 ]; do case $1 in build) setup_cargo + setup_taplo ;; coverage) setup_coverage_tools diff --git a/src/error.rs b/src/error.rs index b7dc3546..4ef77fc9 100644 --- a/src/error.rs +++ b/src/error.rs @@ -43,12 +43,21 @@ pub enum Error { UnrecognizedTime(String), #[error("unknown theme {name:?}, use any of {known:?}")] UnknownTheme { name: String, known: Vec }, + #[error("failed to load theme {}: {source}", HILITE.paint(.filename))] + FailedToLoadTheme { + name: String, + filename: String, + #[source] + source: Box, + }, #[error("failed to parse utf-8 string: {0}")] Utf8Error(#[from] std::str::Utf8Error), #[error("failed to construct utf-8 string from bytes: {0}")] FromUtf8Error(#[from] std::string::FromUtf8Error), #[error("failed to parse yaml: {0}")] YamlError(#[from] serde_yaml::Error), + #[error(transparent)] + TomlError(#[from] toml::de::Error), #[error("failed to parse json: {0}")] WrongFieldFilter(String), #[error("wrong regular expression: {0}")] diff --git a/src/testing/assets/themes/invalid-type.yaml/.gitignore b/src/testing/assets/themes/invalid-type.yaml/.gitignore new file mode 100644 index 00000000..b722e9e1 --- /dev/null +++ b/src/testing/assets/themes/invalid-type.yaml/.gitignore @@ -0,0 +1 @@ +!.gitignore \ No newline at end of file diff --git a/src/testing/assets/themes/invalid.json b/src/testing/assets/themes/invalid.json new file mode 100644 index 00000000..1d0c7aad --- /dev/null +++ b/src/testing/assets/themes/invalid.json @@ -0,0 +1,9 @@ +{ + "elements": { + "input": { + "modes": [ + "invalid" + ] + } + } +} \ No newline at end of file diff --git a/src/testing/assets/themes/test.toml b/src/testing/assets/themes/test.toml new file mode 100644 index 00000000..d845f7d9 --- /dev/null +++ b/src/testing/assets/themes/test.toml @@ -0,0 +1,74 @@ +#:schema ../../../../schema/json/theme.schema.json + +[elements.input] +modes = ["faint"] + +[elements.time] +modes = ["faint", "italic"] + +[elements.logger] +modes = ["faint", "underline"] + +[elements.caller] +modes = ["faint", "italic"] + +[elements.level] +foreground = "cyan" + +[elements.message] +foreground = "default" +modes = ["bold"] + +[elements.field] +modes = ["faint"] + +[elements.key] +foreground = "green" + +[elements.ellipsis] +modes = ["faint"] + +[elements.object] +foreground = "yellow" + +[elements.array] +foreground = "bright-yellow" + +[elements.string] +foreground = "default" + +[elements.number] +foreground = "bright-blue" + +[elements.boolean] +foreground = "bright-green" + +[elements.null] +foreground = "bright-red" + +[levels.debug.level-inner] +foreground = "bright-magenta" + +[levels.info.level-inner] +foreground = "cyan" + +[levels.warning.level] +foreground = "yellow" +modes = ["reverse"] + +[levels.error.time] +foreground = "bright-red" + +[levels.error.level] +foreground = "bright-red" +modes = ["reverse"] + +[indicators.sync.synced] +text = " " + +[indicators.sync.failed] +text = "!" + +[indicators.sync.failed.inner.style] +foreground = "yellow" +modes = ["bold"] diff --git a/src/testing/assets/themes/test.yaml b/src/testing/assets/themes/test.yaml deleted file mode 100644 index 369cf5b2..00000000 --- a/src/testing/assets/themes/test.yaml +++ /dev/null @@ -1,62 +0,0 @@ -# yaml-language-server: $schema=../../../../schema/json/theme.schema.json -$schema: https://raw.githubusercontent.com/pamburus/hl/master/schema/json/theme.schema.json - -elements: - input: - modes: [faint] - time: - modes: [faint, italic] - logger: - modes: [faint, underline] - caller: - modes: [faint, italic] - level: - foreground: cyan - message: - foreground: default - modes: [bold] - field: - modes: [faint] - key: - foreground: green - ellipsis: - modes: [faint] - object: - foreground: yellow - array: - foreground: bright-yellow - string: - foreground: default - number: - foreground: bright-blue - boolean: - foreground: bright-green - 'null': - foreground: bright-red -levels: - debug: - level-inner: - foreground: bright-magenta - info: - level-inner: - foreground: cyan - warning: - level: - foreground: yellow - modes: [reverse] - error: - time: - foreground: bright-red - level: - foreground: bright-red - modes: [reverse] -indicators: - sync: - synced: - text: ' ' - failed: - text: '!' - inner: - style: - foreground: 'yellow' - modes: [bold] diff --git a/src/themecfg.rs b/src/themecfg.rs index efaf6bb3..0633aa18 100644 --- a/src/themecfg.rs +++ b/src/themecfg.rs @@ -4,7 +4,7 @@ use std::{ convert::TryFrom, fmt::{self, Write}, io::ErrorKind, - path::{Path, PathBuf}, + path::{Component, Path, PathBuf}, str::{self, FromStr}, }; @@ -17,7 +17,9 @@ use serde::{ de::{MapAccess, Visitor}, Deserialize, Deserializer, }; +use serde_json as json; use serde_yaml as yaml; +use strum::{EnumIter, IntoEnumIterator}; // local imports use crate::{error::*, level::Level}; @@ -34,10 +36,9 @@ pub struct Theme { impl Theme { pub fn load(app_dirs: &AppDirs, name: &str) -> Result { - let filename = Self::filename(name); - match Self::load_from(Self::themes_dir(app_dirs), &filename) { + match Self::load_from(&Self::themes_dir(app_dirs), name) { Err(Error::Io(e)) => match e.kind() { - ErrorKind::NotFound => match Self::load_embedded::(name, &filename) { + ErrorKind::NotFound => match Self::load_embedded::(name) { Err(Error::UnknownTheme { name, mut known }) => { if let Some(names) = Self::custom_names(app_dirs).ok() { known.extend(names.into_iter().filter_map(|n| n.ok())); @@ -57,7 +58,7 @@ impl Theme { } pub fn embedded(name: &str) -> Result { - Self::load_embedded::(name, &Self::filename(name)) + Self::load_embedded::(name) } pub fn list(app_dirs: &AppDirs) -> Result> { @@ -74,7 +75,7 @@ impl Theme { result.insert(name, ThemeOrigin::Custom.into()); } Err(e) => { - eprintln!("failed to list custom theme: {}", e); + eprintln!("failed to list custom themes: {}", e); } } } @@ -83,29 +84,62 @@ impl Theme { Ok(result) } - fn load_embedded(name: &str, filename: &str) -> Result { - Self::from_buf( - S::get(&filename) - .ok_or_else(|| Error::UnknownTheme { - name: name.to_string(), - known: Self::embedded_names().into_iter().collect(), - })? - .data - .as_ref(), - ) + fn load_embedded(name: &str) -> Result { + for format in Format::iter() { + let filename = Self::filename(name, format); + if let Some(file) = S::get(&filename) { + return Self::from_buf(file.data.as_ref(), format); + } + } + + Err(Error::UnknownTheme { + name: name.to_string(), + known: Self::embedded_names().into_iter().collect(), + }) } - fn from_buf(data: &[u8]) -> Result { - Ok(yaml::from_str(std::str::from_utf8(data)?)?) + fn from_buf(data: &[u8], format: Format) -> Result { + let s = std::str::from_utf8(data)?; + match format { + Format::Yaml => Ok(yaml::from_str(s)?), + Format::Toml => Ok(toml::from_str(s)?), + Format::Json => Ok(json::from_str(s)?), + } } - fn load_from(dir: PathBuf, filename: &str) -> Result { - let f = std::fs::File::open(dir.join(filename))?; - Ok(yaml::from_reader(f)?) + fn load_from(dir: &PathBuf, name: &str) -> Result { + for format in Format::iter() { + let filename = Self::filename(name, format); + let path = PathBuf::from(&filename); + let path = if matches!(path.components().next(), Some(Component::ParentDir | Component::CurDir)) { + path + } else { + dir.join(&filename) + }; + match std::fs::read(&path) { + Ok(data) => { + return Self::from_buf(&data, format).map_err(|e| Error::FailedToLoadTheme { + name: name.to_string(), + filename: path.display().to_string(), + source: Box::new(e), + }); + } + Err(e) => match e.kind() { + ErrorKind::NotFound => continue, + _ => return Err(e.into()), + }, + } + } + + Err(std::io::Error::new(ErrorKind::NotFound, "theme file not found").into()) } - fn filename(name: &str) -> String { - format!("{}.{}", name, Self::EXTENSION) + fn filename(name: &str, format: Format) -> String { + if Self::strip_extension(&name, format).is_some() { + return name.to_string(); + } + + format!("{}.{}", name, format.extension()) } fn themes_dir(app_dirs: &AppDirs) -> PathBuf { @@ -113,7 +147,7 @@ impl Theme { } fn embedded_names() -> impl IntoIterator { - Assets::iter().filter_map(|a| Self::strip_extension(&a).map(|n| n.to_string())) + Assets::iter().filter_map(|a| Self::strip_known_extension(&a).map(|n| n.to_string())) } fn custom_names(app_dirs: &AppDirs) -> Result>> { @@ -126,16 +160,44 @@ impl Theme { .path() .file_name() .and_then(|n| n.to_str()) - .and_then(|a| Self::strip_extension(&a).map(|n| n.to_string()))) + .and_then(|a| Self::strip_known_extension(&a).map(|n| n.to_string()))) }) .filter_map(|x| x.transpose())) } - fn strip_extension(filename: &str) -> Option<&str> { - filename.strip_suffix(Self::EXTENSION).and_then(|r| r.strip_suffix(".")) + fn strip_extension(filename: &str, format: Format) -> Option<&str> { + filename + .strip_suffix(format.extension()) + .and_then(|r| r.strip_suffix(".")) + } + + fn strip_known_extension(filename: &str) -> Option<&str> { + for format in Format::iter() { + if let Some(name) = Self::strip_extension(filename, format) { + return Some(name); + } + } + None } +} + +// --- - const EXTENSION: &'static str = "yaml"; +#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, EnumIter)] +pub enum Format { + Yaml, + Toml, + Json, +} + +impl Format { + pub fn extension(&self) -> &str { + match self { + Self::Yaml => "yaml", + Self::Toml => "toml", + Self::Json => "json", + } + } } // --- @@ -469,7 +531,7 @@ pub mod testing { struct Assets; pub fn theme() -> Result { - Theme::load_embedded::("test", &Theme::filename("test")) + Theme::load_embedded::("test") } } @@ -477,10 +539,45 @@ pub mod testing { mod tests { use super::*; + #[test] + fn test_load() { + let app_dirs = AppDirs { + config_dir: PathBuf::from("src/testing/assets"), + cache_dir: Default::default(), + data_dir: Default::default(), + state_dir: Default::default(), + }; + assert_ne!(Theme::load(&app_dirs, "test").unwrap().elements.len(), 0); + assert_ne!(Theme::load(&app_dirs, "universal").unwrap().elements.len(), 0); + assert!(Theme::load(&app_dirs, "non-existent").is_err()); + assert!(Theme::load(&app_dirs, "invalid").is_err()); + assert!(Theme::load(&app_dirs, "invalid-type").is_err()); + } + #[test] fn test_load_from() { - let theme = Theme::load_from("src/testing/assets/themes".into(), "test.yaml").unwrap(); - assert_ne!(theme.elements.len(), 0); + let path = PathBuf::from("etc/defaults/themes"); + assert_ne!(Theme::load_from(&path, "universal").unwrap().elements.len(), 0); + + let path = PathBuf::from("src/testing/assets/themes"); + assert_ne!(Theme::load_from(&path, "test").unwrap().elements.len(), 0); + assert_ne!(Theme::load_from(&path, "test.toml").unwrap().elements.len(), 0); + assert_ne!( + Theme::load_from(&path, "./src/testing/assets/themes/test.toml") + .unwrap() + .elements + .len(), + 0 + ); + assert!(Theme::load_from(&path, "non-existent").is_err()); + assert!(Theme::load_from(&path, "invalid").is_err()); + assert!(Theme::load_from(&path, "invalid-type").is_err()); + } + + #[test] + fn test_embedded() { + assert_ne!(Theme::embedded("universal").unwrap().elements.len(), 0); + assert!(Theme::embedded("non-existent").is_err()); } #[test]