1#![forbid(unsafe_code)]
9#![doc(
10 html_logo_url = "https://raw.githubusercontent.com/aram-devdocs/plumb/main/assets/brand/plumb-mark.svg",
11 html_favicon_url = "https://raw.githubusercontent.com/aram-devdocs/plumb/main/theme/favicon.svg"
12)]
13#![deny(missing_docs)]
14#![deny(clippy::unwrap_used, clippy::expect_used)]
15
16use std::fs;
17use std::ops::Range;
18use std::path::Path;
19
20use figment::Figment;
21use figment::providers::{Format, Json, Yaml};
22use miette::{Diagnostic, NamedSource, SourceSpan};
23use plumb_core::Config;
24use thiserror::Error;
25
26mod css_props;
27mod dtcg;
28mod span;
29pub mod tailwind;
30mod validate;
31
32pub use css_props::{CssPropertyScrape, ScrapedValue, scrape_css_properties};
33pub use dtcg::{DtcgImport, DtcgSource, DtcgWarning, DtcgWarningKind, MAX_NESTING, merge_dtcg};
34
35use span::{SourceFormat, locate_path};
36pub use tailwind::{TailwindOptions, merge_tailwind};
37use validate::ValidationIssue;
38
39#[derive(Debug, Error)]
48#[non_exhaustive]
49pub enum ConfigParseSource {
50 #[error(transparent)]
52 Toml(#[from] toml::de::Error),
53 #[error(transparent)]
55 Figment(#[from] figment::Error),
56}
57
58#[derive(Debug, Error, Diagnostic)]
60#[non_exhaustive]
61pub enum ConfigError {
62 #[error("unsupported config extension `{0}` (expected .toml, .yaml, .yml, or .json)")]
64 UnsupportedExtension(String),
65 #[error("config file not found: {0}")]
67 NotFound(String),
68 #[error("failed to read config file `{path}`: {source}")]
70 Read {
71 path: String,
73 #[source]
75 source: std::io::Error,
76 },
77 #[error("failed to parse config file `{path}`")]
86 #[diagnostic(code(plumb::config::parse))]
87 Parse {
88 path: String,
90 #[source]
92 source: Box<ConfigParseSource>,
93 #[source_code]
95 source_code: Option<NamedSource<String>>,
96 #[label("invalid config")]
98 span: Option<SourceSpan>,
99 },
100 #[error("invalid config value at `{value_path}` in `{path}`: {message}")]
103 #[diagnostic(code(plumb::config::validation))]
104 Validation {
105 path: String,
107 value_path: String,
109 message: String,
111 #[source_code]
113 source_code: Option<NamedSource<String>>,
114 #[label("invalid value")]
117 span: Option<SourceSpan>,
118 },
119 #[error("failed to parse CSS file `{path}`: {message}")]
122 #[diagnostic(code(plumb::config::css_parse))]
123 CssParse {
124 path: String,
126 message: String,
128 #[source_code]
130 source_code: Option<NamedSource<String>>,
131 #[label("invalid CSS")]
133 span: Option<SourceSpan>,
134 },
135 #[error("failed to emit schema: {0}")]
137 Schema(#[source] serde_json::Error),
138 #[error("failed to import DTCG token file `{path}`: {reason}")]
140 #[diagnostic(code(plumb::config::dtcg_parse))]
141 DtcgParse {
142 path: String,
144 #[source_code]
146 source_code: Option<NamedSource<String>>,
147 #[label("invalid token")]
149 span: Option<SourceSpan>,
150 reason: String,
152 },
153 #[error("DTCG alias error in `{path}`: {reason} (cycle: {cycle:?})")]
155 #[diagnostic(code(plumb::config::dtcg_alias))]
156 DtcgAlias {
157 path: String,
159 #[source_code]
161 source_code: Option<NamedSource<String>>,
162 cycle: Vec<String>,
164 reason: String,
166 },
167 #[error("tailwind adapter unavailable: {reason}")]
170 #[diagnostic(
171 code(plumb::config::tailwind_unavailable),
172 help("install Node.js (https://nodejs.org) or pass --tailwind-node <path>")
173 )]
174 TailwindUnavailable {
175 reason: String,
177 },
178 #[error("invalid tailwind config path `{path}`: {reason}")]
180 #[diagnostic(code(plumb::config::tailwind_bad_path))]
181 TailwindBadPath {
182 path: String,
184 reason: String,
186 },
187 #[error("failed to evaluate tailwind config `{path}`: {reason}")]
189 #[diagnostic(code(plumb::config::tailwind_eval))]
190 TailwindEval {
191 path: String,
193 reason: String,
196 stderr: String,
199 },
200}
201
202pub fn load(path: &Path) -> Result<Config, ConfigError> {
212 if !path.exists() {
213 return Err(ConfigError::NotFound(path.display().to_string()));
214 }
215
216 let ext = path
217 .extension()
218 .and_then(|e| e.to_str())
219 .unwrap_or("")
220 .to_ascii_lowercase();
221
222 let (config, contents, format) = match ext.as_str() {
223 "toml" => {
224 let (cfg, body) = load_toml(path)?;
225 (cfg, body, SourceFormat::Toml)
226 }
227 "yaml" | "yml" => {
228 let (cfg, body) = load_yaml(path)?;
229 (cfg, body, SourceFormat::Yaml)
230 }
231 "json" => {
232 let (cfg, body) = load_json(path)?;
233 (cfg, body, SourceFormat::Json)
234 }
235 other => return Err(ConfigError::UnsupportedExtension(other.to_owned())),
236 };
237
238 if let Some(issue) = validate::validate(&config) {
239 return Err(validation_error(path, contents, format, issue));
240 }
241
242 Ok(config)
243}
244
245fn validation_error(
246 path: &Path,
247 contents: String,
248 format: SourceFormat,
249 issue: ValidationIssue,
250) -> ConfigError {
251 let span = locate_path(&contents, format, &issue.path_segments);
252 let language = match format {
253 SourceFormat::Toml => "toml",
254 SourceFormat::Yaml => "yaml",
255 SourceFormat::Json => "json",
256 };
257 ConfigError::Validation {
258 path: path.display().to_string(),
259 value_path: issue.path_segments.join("."),
260 message: issue.message,
261 source_code: Some(
262 NamedSource::new(path.display().to_string(), contents).with_language(language),
263 ),
264 span,
265 }
266}
267
268fn load_toml(path: &Path) -> Result<(Config, String), ConfigError> {
269 let contents = fs::read_to_string(path).map_err(|source| ConfigError::Read {
270 path: path.display().to_string(),
271 source,
272 })?;
273
274 let parsed = toml::from_str::<Config>(&contents).map_err(|source| {
275 let span = source.span().and_then(source_span);
276 ConfigError::Parse {
277 path: path.display().to_string(),
278 source: Box::new(ConfigParseSource::Toml(source)),
279 source_code: Some(
280 NamedSource::new(path.display().to_string(), contents.clone())
281 .with_language("toml"),
282 ),
283 span,
284 }
285 })?;
286
287 Ok((parsed, contents))
288}
289
290fn load_yaml(path: &Path) -> Result<(Config, String), ConfigError> {
291 let contents = fs::read_to_string(path).map_err(|source| ConfigError::Read {
292 path: path.display().to_string(),
293 source,
294 })?;
295
296 let figment = Figment::new().merge(Yaml::file(path));
297 let cfg = figment
298 .extract::<Config>()
299 .map_err(|source| build_figment_parse_error(path, &contents, SourceFormat::Yaml, source))?;
300 Ok((cfg, contents))
301}
302
303fn load_json(path: &Path) -> Result<(Config, String), ConfigError> {
304 let contents = fs::read_to_string(path).map_err(|source| ConfigError::Read {
305 path: path.display().to_string(),
306 source,
307 })?;
308
309 let figment = Figment::new().merge(Json::file(path));
310 let cfg = figment
311 .extract::<Config>()
312 .map_err(|source| build_figment_parse_error(path, &contents, SourceFormat::Json, source))?;
313 Ok((cfg, contents))
314}
315
316fn build_figment_parse_error(
317 path: &Path,
318 contents: &str,
319 format: SourceFormat,
320 source: figment::Error,
321) -> ConfigError {
322 let segments: Vec<String> = source.path.clone();
323 let span = if segments.is_empty() {
324 None
325 } else {
326 locate_path(contents, format, &segments)
327 };
328 let language = match format {
329 SourceFormat::Toml => "toml",
330 SourceFormat::Yaml => "yaml",
331 SourceFormat::Json => "json",
332 };
333 let display_path = config_error_path(&source).unwrap_or_else(|| path.display().to_string());
334 ConfigError::Parse {
335 path: display_path,
336 source: Box::new(ConfigParseSource::Figment(source)),
337 source_code: Some(
338 NamedSource::new(path.display().to_string(), contents.to_owned())
339 .with_language(language),
340 ),
341 span,
342 }
343}
344
345fn source_span(range: Range<usize>) -> Option<SourceSpan> {
346 let len = range.end.checked_sub(range.start)?;
347 Some((range.start, len).into())
348}
349
350fn config_error_path(source: &figment::Error) -> Option<String> {
351 source
352 .metadata
353 .as_ref()
354 .and_then(|metadata| metadata.source.as_ref())
355 .map(ToString::to_string)
356}
357
358pub fn emit_schema() -> Result<String, ConfigError> {
364 let schema = schemars::schema_for!(Config);
365 serde_json::to_string_pretty(&schema).map_err(ConfigError::Schema)
366}