1#![forbid(unsafe_code)]
9#![deny(missing_docs)]
10#![deny(clippy::unwrap_used, clippy::expect_used)]
11
12use std::fs;
13use std::ops::Range;
14use std::path::Path;
15
16use figment::Figment;
17use figment::providers::{Format, Json, Yaml};
18use miette::{Diagnostic, NamedSource, SourceSpan};
19use plumb_core::Config;
20use thiserror::Error;
21
22mod css_props;
23mod dtcg;
24mod span;
25pub mod tailwind;
26mod validate;
27
28pub use css_props::{CssPropertyScrape, ScrapedValue, scrape_css_properties};
29pub use dtcg::{DtcgImport, DtcgSource, DtcgWarning, DtcgWarningKind, MAX_NESTING, merge_dtcg};
30
31use span::{SourceFormat, locate_path};
32pub use tailwind::{TailwindOptions, merge_tailwind};
33use validate::ValidationIssue;
34
35#[derive(Debug, Error)]
37#[non_exhaustive]
38pub enum ConfigParseSource {
39 #[error("{0}")]
41 Toml(#[from] toml::de::Error),
42 #[error("{0}")]
44 Figment(#[from] figment::Error),
45}
46
47#[derive(Debug, Error, Diagnostic)]
49#[non_exhaustive]
50pub enum ConfigError {
51 #[error("unsupported config extension `{0}` (expected .toml, .yaml, .yml, or .json)")]
53 UnsupportedExtension(String),
54 #[error("config file not found: {0}")]
56 NotFound(String),
57 #[error("failed to read config file `{path}`: {source}")]
59 Read {
60 path: String,
62 #[source]
64 source: std::io::Error,
65 },
66 #[error("failed to parse config file `{path}`: {source}")]
69 #[diagnostic(code(plumb::config::parse))]
70 Parse {
71 path: String,
73 #[source]
75 source: Box<ConfigParseSource>,
76 #[source_code]
78 source_code: Option<NamedSource<String>>,
79 #[label("invalid config")]
81 span: Option<SourceSpan>,
82 },
83 #[error("invalid config value at `{value_path}` in `{path}`: {message}")]
86 #[diagnostic(code(plumb::config::validation))]
87 Validation {
88 path: String,
90 value_path: String,
92 message: String,
94 #[source_code]
96 source_code: Option<NamedSource<String>>,
97 #[label("invalid value")]
100 span: Option<SourceSpan>,
101 },
102 #[error("failed to parse CSS file `{path}`: {message}")]
105 #[diagnostic(code(plumb::config::css_parse))]
106 CssParse {
107 path: String,
109 message: String,
111 #[source_code]
113 source_code: Option<NamedSource<String>>,
114 #[label("invalid CSS")]
116 span: Option<SourceSpan>,
117 },
118 #[error("failed to emit schema: {0}")]
120 Schema(#[source] serde_json::Error),
121 #[error("failed to import DTCG token file `{path}`: {reason}")]
123 #[diagnostic(code(plumb::config::dtcg_parse))]
124 DtcgParse {
125 path: String,
127 #[source_code]
129 source_code: Option<NamedSource<String>>,
130 #[label("invalid token")]
132 span: Option<SourceSpan>,
133 reason: String,
135 },
136 #[error("DTCG alias error in `{path}`: {reason} (cycle: {cycle:?})")]
138 #[diagnostic(code(plumb::config::dtcg_alias))]
139 DtcgAlias {
140 path: String,
142 #[source_code]
144 source_code: Option<NamedSource<String>>,
145 cycle: Vec<String>,
147 reason: String,
149 },
150 #[error("tailwind adapter unavailable: {reason}")]
153 #[diagnostic(
154 code(plumb::config::tailwind_unavailable),
155 help("install Node.js (https://nodejs.org) or pass --tailwind-node <path>")
156 )]
157 TailwindUnavailable {
158 reason: String,
160 },
161 #[error("invalid tailwind config path `{path}`: {reason}")]
163 #[diagnostic(code(plumb::config::tailwind_bad_path))]
164 TailwindBadPath {
165 path: String,
167 reason: String,
169 },
170 #[error("failed to evaluate tailwind config `{path}`: {reason}")]
172 #[diagnostic(code(plumb::config::tailwind_eval))]
173 TailwindEval {
174 path: String,
176 reason: String,
179 stderr: String,
182 },
183}
184
185pub fn load(path: &Path) -> Result<Config, ConfigError> {
195 if !path.exists() {
196 return Err(ConfigError::NotFound(path.display().to_string()));
197 }
198
199 let ext = path
200 .extension()
201 .and_then(|e| e.to_str())
202 .unwrap_or("")
203 .to_ascii_lowercase();
204
205 let (config, contents, format) = match ext.as_str() {
206 "toml" => {
207 let (cfg, body) = load_toml(path)?;
208 (cfg, body, SourceFormat::Toml)
209 }
210 "yaml" | "yml" => {
211 let (cfg, body) = load_yaml(path)?;
212 (cfg, body, SourceFormat::Yaml)
213 }
214 "json" => {
215 let (cfg, body) = load_json(path)?;
216 (cfg, body, SourceFormat::Json)
217 }
218 other => return Err(ConfigError::UnsupportedExtension(other.to_owned())),
219 };
220
221 if let Some(issue) = validate::validate(&config) {
222 return Err(validation_error(path, contents, format, issue));
223 }
224
225 Ok(config)
226}
227
228fn validation_error(
229 path: &Path,
230 contents: String,
231 format: SourceFormat,
232 issue: ValidationIssue,
233) -> ConfigError {
234 let span = locate_path(&contents, format, &issue.path_segments);
235 let language = match format {
236 SourceFormat::Toml => "toml",
237 SourceFormat::Yaml => "yaml",
238 SourceFormat::Json => "json",
239 };
240 ConfigError::Validation {
241 path: path.display().to_string(),
242 value_path: issue.path_segments.join("."),
243 message: issue.message,
244 source_code: Some(
245 NamedSource::new(path.display().to_string(), contents).with_language(language),
246 ),
247 span,
248 }
249}
250
251fn load_toml(path: &Path) -> Result<(Config, String), ConfigError> {
252 let contents = fs::read_to_string(path).map_err(|source| ConfigError::Read {
253 path: path.display().to_string(),
254 source,
255 })?;
256
257 let parsed = toml::from_str::<Config>(&contents).map_err(|source| {
258 let span = source.span().and_then(source_span);
259 ConfigError::Parse {
260 path: path.display().to_string(),
261 source: Box::new(ConfigParseSource::Toml(source)),
262 source_code: Some(
263 NamedSource::new(path.display().to_string(), contents.clone())
264 .with_language("toml"),
265 ),
266 span,
267 }
268 })?;
269
270 Ok((parsed, contents))
271}
272
273fn load_yaml(path: &Path) -> Result<(Config, String), ConfigError> {
274 let contents = fs::read_to_string(path).map_err(|source| ConfigError::Read {
275 path: path.display().to_string(),
276 source,
277 })?;
278
279 let figment = Figment::new().merge(Yaml::file(path));
280 let cfg = figment
281 .extract::<Config>()
282 .map_err(|source| build_figment_parse_error(path, &contents, SourceFormat::Yaml, source))?;
283 Ok((cfg, contents))
284}
285
286fn load_json(path: &Path) -> Result<(Config, String), ConfigError> {
287 let contents = fs::read_to_string(path).map_err(|source| ConfigError::Read {
288 path: path.display().to_string(),
289 source,
290 })?;
291
292 let figment = Figment::new().merge(Json::file(path));
293 let cfg = figment
294 .extract::<Config>()
295 .map_err(|source| build_figment_parse_error(path, &contents, SourceFormat::Json, source))?;
296 Ok((cfg, contents))
297}
298
299fn build_figment_parse_error(
300 path: &Path,
301 contents: &str,
302 format: SourceFormat,
303 source: figment::Error,
304) -> ConfigError {
305 let segments: Vec<String> = source.path.clone();
306 let span = if segments.is_empty() {
307 None
308 } else {
309 locate_path(contents, format, &segments)
310 };
311 let language = match format {
312 SourceFormat::Toml => "toml",
313 SourceFormat::Yaml => "yaml",
314 SourceFormat::Json => "json",
315 };
316 let display_path = config_error_path(&source).unwrap_or_else(|| path.display().to_string());
317 ConfigError::Parse {
318 path: display_path,
319 source: Box::new(ConfigParseSource::Figment(source)),
320 source_code: Some(
321 NamedSource::new(path.display().to_string(), contents.to_owned())
322 .with_language(language),
323 ),
324 span,
325 }
326}
327
328fn source_span(range: Range<usize>) -> Option<SourceSpan> {
329 let len = range.end.checked_sub(range.start)?;
330 Some((range.start, len).into())
331}
332
333fn config_error_path(source: &figment::Error) -> Option<String> {
334 source
335 .metadata
336 .as_ref()
337 .and_then(|metadata| metadata.source.as_ref())
338 .map(ToString::to_string)
339}
340
341pub fn emit_schema() -> Result<String, ConfigError> {
347 let schema = schemars::schema_for!(Config);
348 serde_json::to_string_pretty(&schema).map_err(ConfigError::Schema)
349}