#![forbid(unsafe_code)]
#![doc(
html_logo_url = "https://raw.githubusercontent.com/aram-devdocs/plumb/main/assets/brand/plumb-mark.svg",
html_favicon_url = "https://raw.githubusercontent.com/aram-devdocs/plumb/main/theme/favicon.svg"
)]
#![deny(missing_docs)]
#![deny(clippy::unwrap_used, clippy::expect_used)]
use std::fs;
use std::ops::Range;
use std::path::Path;
use figment::Figment;
use figment::providers::{Format, Json, Yaml};
use miette::{Diagnostic, NamedSource, SourceSpan};
use plumb_core::Config;
use thiserror::Error;
mod css_props;
mod dtcg;
mod span;
pub mod tailwind;
mod validate;
pub use css_props::{CssPropertyScrape, ScrapedValue, scrape_css_properties};
pub use dtcg::{DtcgImport, DtcgSource, DtcgWarning, DtcgWarningKind, MAX_NESTING, merge_dtcg};
use span::{SourceFormat, locate_path};
pub use tailwind::{TailwindOptions, merge_tailwind};
use validate::ValidationIssue;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ConfigParseSource {
#[error(transparent)]
Toml(#[from] toml::de::Error),
#[error(transparent)]
Figment(#[from] figment::Error),
}
#[derive(Debug, Error, Diagnostic)]
#[non_exhaustive]
pub enum ConfigError {
#[error("unsupported config extension `{0}` (expected .toml, .yaml, .yml, or .json)")]
UnsupportedExtension(String),
#[error("config file not found: {0}")]
NotFound(String),
#[error("failed to read config file `{path}`: {source}")]
Read {
path: String,
#[source]
source: std::io::Error,
},
#[error("failed to parse config file `{path}`")]
#[diagnostic(code(plumb::config::parse))]
Parse {
path: String,
#[source]
source: Box<ConfigParseSource>,
#[source_code]
source_code: Option<NamedSource<String>>,
#[label("invalid config")]
span: Option<SourceSpan>,
},
#[error("invalid config value at `{value_path}` in `{path}`: {message}")]
#[diagnostic(code(plumb::config::validation))]
Validation {
path: String,
value_path: String,
message: String,
#[source_code]
source_code: Option<NamedSource<String>>,
#[label("invalid value")]
span: Option<SourceSpan>,
},
#[error("failed to parse CSS file `{path}`: {message}")]
#[diagnostic(code(plumb::config::css_parse))]
CssParse {
path: String,
message: String,
#[source_code]
source_code: Option<NamedSource<String>>,
#[label("invalid CSS")]
span: Option<SourceSpan>,
},
#[error("failed to emit schema: {0}")]
Schema(#[source] serde_json::Error),
#[error("failed to import DTCG token file `{path}`: {reason}")]
#[diagnostic(code(plumb::config::dtcg_parse))]
DtcgParse {
path: String,
#[source_code]
source_code: Option<NamedSource<String>>,
#[label("invalid token")]
span: Option<SourceSpan>,
reason: String,
},
#[error("DTCG alias error in `{path}`: {reason} (cycle: {cycle:?})")]
#[diagnostic(code(plumb::config::dtcg_alias))]
DtcgAlias {
path: String,
#[source_code]
source_code: Option<NamedSource<String>>,
cycle: Vec<String>,
reason: String,
},
#[error("tailwind adapter unavailable: {reason}")]
#[diagnostic(
code(plumb::config::tailwind_unavailable),
help("install Node.js (https://nodejs.org) or pass --tailwind-node <path>")
)]
TailwindUnavailable {
reason: String,
},
#[error("invalid tailwind config path `{path}`: {reason}")]
#[diagnostic(code(plumb::config::tailwind_bad_path))]
TailwindBadPath {
path: String,
reason: String,
},
#[error("failed to evaluate tailwind config `{path}`: {reason}")]
#[diagnostic(code(plumb::config::tailwind_eval))]
TailwindEval {
path: String,
reason: String,
stderr: String,
},
}
pub fn load(path: &Path) -> Result<Config, ConfigError> {
if !path.exists() {
return Err(ConfigError::NotFound(path.display().to_string()));
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let (config, contents, format) = match ext.as_str() {
"toml" => {
let (cfg, body) = load_toml(path)?;
(cfg, body, SourceFormat::Toml)
}
"yaml" | "yml" => {
let (cfg, body) = load_yaml(path)?;
(cfg, body, SourceFormat::Yaml)
}
"json" => {
let (cfg, body) = load_json(path)?;
(cfg, body, SourceFormat::Json)
}
other => return Err(ConfigError::UnsupportedExtension(other.to_owned())),
};
if let Some(issue) = validate::validate(&config) {
return Err(validation_error(path, contents, format, issue));
}
Ok(config)
}
fn validation_error(
path: &Path,
contents: String,
format: SourceFormat,
issue: ValidationIssue,
) -> ConfigError {
let span = locate_path(&contents, format, &issue.path_segments);
let language = match format {
SourceFormat::Toml => "toml",
SourceFormat::Yaml => "yaml",
SourceFormat::Json => "json",
};
ConfigError::Validation {
path: path.display().to_string(),
value_path: issue.path_segments.join("."),
message: issue.message,
source_code: Some(
NamedSource::new(path.display().to_string(), contents).with_language(language),
),
span,
}
}
fn load_toml(path: &Path) -> Result<(Config, String), ConfigError> {
let contents = fs::read_to_string(path).map_err(|source| ConfigError::Read {
path: path.display().to_string(),
source,
})?;
let parsed = toml::from_str::<Config>(&contents).map_err(|source| {
let span = source.span().and_then(source_span);
ConfigError::Parse {
path: path.display().to_string(),
source: Box::new(ConfigParseSource::Toml(source)),
source_code: Some(
NamedSource::new(path.display().to_string(), contents.clone())
.with_language("toml"),
),
span,
}
})?;
Ok((parsed, contents))
}
fn load_yaml(path: &Path) -> Result<(Config, String), ConfigError> {
let contents = fs::read_to_string(path).map_err(|source| ConfigError::Read {
path: path.display().to_string(),
source,
})?;
let figment = Figment::new().merge(Yaml::file(path));
let cfg = figment
.extract::<Config>()
.map_err(|source| build_figment_parse_error(path, &contents, SourceFormat::Yaml, source))?;
Ok((cfg, contents))
}
fn load_json(path: &Path) -> Result<(Config, String), ConfigError> {
let contents = fs::read_to_string(path).map_err(|source| ConfigError::Read {
path: path.display().to_string(),
source,
})?;
let figment = Figment::new().merge(Json::file(path));
let cfg = figment
.extract::<Config>()
.map_err(|source| build_figment_parse_error(path, &contents, SourceFormat::Json, source))?;
Ok((cfg, contents))
}
fn build_figment_parse_error(
path: &Path,
contents: &str,
format: SourceFormat,
source: figment::Error,
) -> ConfigError {
let segments: Vec<String> = source.path.clone();
let span = if segments.is_empty() {
None
} else {
locate_path(contents, format, &segments)
};
let language = match format {
SourceFormat::Toml => "toml",
SourceFormat::Yaml => "yaml",
SourceFormat::Json => "json",
};
let display_path = config_error_path(&source).unwrap_or_else(|| path.display().to_string());
ConfigError::Parse {
path: display_path,
source: Box::new(ConfigParseSource::Figment(source)),
source_code: Some(
NamedSource::new(path.display().to_string(), contents.to_owned())
.with_language(language),
),
span,
}
}
fn source_span(range: Range<usize>) -> Option<SourceSpan> {
let len = range.end.checked_sub(range.start)?;
Some((range.start, len).into())
}
fn config_error_path(source: &figment::Error) -> Option<String> {
source
.metadata
.as_ref()
.and_then(|metadata| metadata.source.as_ref())
.map(ToString::to_string)
}
pub fn emit_schema() -> Result<String, ConfigError> {
let schema = schemars::schema_for!(Config);
serde_json::to_string_pretty(&schema).map_err(ConfigError::Schema)
}