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)]
44#[non_exhaustive]
45pub enum ConfigParseSource {
46 #[error(transparent)]
48 Toml(#[from] toml::de::Error),
49 #[error(transparent)]
51 Figment(#[from] figment::Error),
52}
53
54#[derive(Debug, Error, Diagnostic)]
56#[non_exhaustive]
57pub enum ConfigError {
58 #[error("unsupported config extension `{0}` (expected .toml, .yaml, .yml, or .json)")]
60 UnsupportedExtension(String),
61 #[error("config file not found: {0}")]
63 NotFound(String),
64 #[error("failed to read config file `{path}`: {source}")]
66 Read {
67 path: String,
69 #[source]
71 source: std::io::Error,
72 },
73 #[error("failed to parse config file `{path}`")]
82 #[diagnostic(code(plumb::config::parse))]
83 Parse {
84 path: String,
86 #[source]
88 source: Box<ConfigParseSource>,
89 #[source_code]
91 source_code: Option<NamedSource<String>>,
92 #[label("invalid config")]
94 span: Option<SourceSpan>,
95 },
96 #[error("invalid config value at `{value_path}` in `{path}`: {message}")]
99 #[diagnostic(code(plumb::config::validation))]
100 Validation {
101 path: String,
103 value_path: String,
105 message: String,
107 #[source_code]
109 source_code: Option<NamedSource<String>>,
110 #[label("invalid value")]
113 span: Option<SourceSpan>,
114 },
115 #[error("failed to parse CSS file `{path}`: {message}")]
118 #[diagnostic(code(plumb::config::css_parse))]
119 CssParse {
120 path: String,
122 message: String,
124 #[source_code]
126 source_code: Option<NamedSource<String>>,
127 #[label("invalid CSS")]
129 span: Option<SourceSpan>,
130 },
131 #[error("failed to emit schema: {0}")]
133 Schema(#[source] serde_json::Error),
134 #[error("failed to import DTCG token file `{path}`: {reason}")]
136 #[diagnostic(code(plumb::config::dtcg_parse))]
137 DtcgParse {
138 path: String,
140 #[source_code]
142 source_code: Option<NamedSource<String>>,
143 #[label("invalid token")]
145 span: Option<SourceSpan>,
146 reason: String,
148 },
149 #[error("DTCG alias error in `{path}`: {reason} (cycle: {cycle:?})")]
151 #[diagnostic(code(plumb::config::dtcg_alias))]
152 DtcgAlias {
153 path: String,
155 #[source_code]
157 source_code: Option<NamedSource<String>>,
158 cycle: Vec<String>,
160 reason: String,
162 },
163 #[error("tailwind adapter unavailable: {reason}")]
166 #[diagnostic(
167 code(plumb::config::tailwind_unavailable),
168 help("install Node.js (https://nodejs.org) or pass --tailwind-node <path>")
169 )]
170 TailwindUnavailable {
171 reason: String,
173 },
174 #[error("invalid tailwind config path `{path}`: {reason}")]
176 #[diagnostic(code(plumb::config::tailwind_bad_path))]
177 TailwindBadPath {
178 path: String,
180 reason: String,
182 },
183 #[error("failed to evaluate tailwind config `{path}`: {reason}")]
185 #[diagnostic(code(plumb::config::tailwind_eval))]
186 TailwindEval {
187 path: String,
189 reason: String,
192 stderr: String,
195 },
196}
197
198pub fn load(path: &Path) -> Result<Config, ConfigError> {
208 if !path.exists() {
209 return Err(ConfigError::NotFound(path.display().to_string()));
210 }
211
212 let ext = path
213 .extension()
214 .and_then(|e| e.to_str())
215 .unwrap_or("")
216 .to_ascii_lowercase();
217
218 let (config, contents, format) = match ext.as_str() {
219 "toml" => {
220 let (cfg, body) = load_toml(path)?;
221 (cfg, body, SourceFormat::Toml)
222 }
223 "yaml" | "yml" => {
224 let (cfg, body) = load_yaml(path)?;
225 (cfg, body, SourceFormat::Yaml)
226 }
227 "json" => {
228 let (cfg, body) = load_json(path)?;
229 (cfg, body, SourceFormat::Json)
230 }
231 other => return Err(ConfigError::UnsupportedExtension(other.to_owned())),
232 };
233
234 if let Some(issue) = validate::validate(&config) {
235 return Err(validation_error(path, contents, format, issue));
236 }
237
238 Ok(config)
239}
240
241fn validation_error(
242 path: &Path,
243 contents: String,
244 format: SourceFormat,
245 issue: ValidationIssue,
246) -> ConfigError {
247 let span = locate_path(&contents, format, &issue.path_segments);
248 let language = match format {
249 SourceFormat::Toml => "toml",
250 SourceFormat::Yaml => "yaml",
251 SourceFormat::Json => "json",
252 };
253 ConfigError::Validation {
254 path: path.display().to_string(),
255 value_path: issue.path_segments.join("."),
256 message: issue.message,
257 source_code: Some(
258 NamedSource::new(path.display().to_string(), contents).with_language(language),
259 ),
260 span,
261 }
262}
263
264fn load_toml(path: &Path) -> Result<(Config, String), ConfigError> {
265 let contents = fs::read_to_string(path).map_err(|source| ConfigError::Read {
266 path: path.display().to_string(),
267 source,
268 })?;
269
270 let parsed = toml::from_str::<Config>(&contents).map_err(|source| {
271 let span = source.span().and_then(source_span);
272 ConfigError::Parse {
273 path: path.display().to_string(),
274 source: Box::new(ConfigParseSource::Toml(source)),
275 source_code: Some(
276 NamedSource::new(path.display().to_string(), contents.clone())
277 .with_language("toml"),
278 ),
279 span,
280 }
281 })?;
282
283 Ok((parsed, contents))
284}
285
286fn load_yaml(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(Yaml::file(path));
293 let cfg = figment
294 .extract::<Config>()
295 .map_err(|source| build_figment_parse_error(path, &contents, SourceFormat::Yaml, source))?;
296 Ok((cfg, contents))
297}
298
299fn load_json(path: &Path) -> Result<(Config, String), ConfigError> {
300 let contents = fs::read_to_string(path).map_err(|source| ConfigError::Read {
301 path: path.display().to_string(),
302 source,
303 })?;
304
305 let figment = Figment::new().merge(Json::file(path));
306 let cfg = figment
307 .extract::<Config>()
308 .map_err(|source| build_figment_parse_error(path, &contents, SourceFormat::Json, source))?;
309 Ok((cfg, contents))
310}
311
312fn build_figment_parse_error(
313 path: &Path,
314 contents: &str,
315 format: SourceFormat,
316 source: figment::Error,
317) -> ConfigError {
318 let segments: Vec<String> = source.path.clone();
319 let span = if segments.is_empty() {
320 None
321 } else {
322 locate_path(contents, format, &segments)
323 };
324 let language = match format {
325 SourceFormat::Toml => "toml",
326 SourceFormat::Yaml => "yaml",
327 SourceFormat::Json => "json",
328 };
329 let display_path = config_error_path(&source).unwrap_or_else(|| path.display().to_string());
330 ConfigError::Parse {
331 path: display_path,
332 source: Box::new(ConfigParseSource::Figment(source)),
333 source_code: Some(
334 NamedSource::new(path.display().to_string(), contents.to_owned())
335 .with_language(language),
336 ),
337 span,
338 }
339}
340
341fn source_span(range: Range<usize>) -> Option<SourceSpan> {
342 let len = range.end.checked_sub(range.start)?;
343 Some((range.start, len).into())
344}
345
346fn config_error_path(source: &figment::Error) -> Option<String> {
347 source
348 .metadata
349 .as_ref()
350 .and_then(|metadata| metadata.source.as_ref())
351 .map(ToString::to_string)
352}
353
354pub fn emit_schema() -> Result<String, ConfigError> {
360 let schema = schemars::schema_for!(Config);
361 serde_json::to_string_pretty(&schema).map_err(ConfigError::Schema)
362}