use schemars::JsonSchema;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
pub const SCHEMA_URL: &str =
"https://raw.githubusercontent.com/valtyr/primate/main/primate.schema.json";
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(title = "primate.toml", deny_unknown_fields)]
pub struct Config {
pub input: PathBuf,
pub sourcemap: Option<PathBuf>,
#[serde(rename = "output")]
pub outputs: Vec<OutputConfig>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct OutputConfig {
pub generator: Option<String>,
pub plugin: Option<String>,
pub path: PathBuf,
#[serde(default)]
#[schemars(with = "std::collections::HashMap<String, serde_json::Value>")]
pub options: HashMap<String, toml::Value>,
}
impl Config {
pub fn load(path: &std::path::Path) -> Result<Self, ConfigError> {
let content = std::fs::read_to_string(path)?;
let config: Config = toml::from_str(&content)?;
config.validate()?;
Ok(config)
}
pub fn sourcemap_path(&self, config_path: &std::path::Path) -> PathBuf {
let config_dir = config_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(std::path::Path::new("."));
if let Some(ref sourcemap) = self.sourcemap {
if sourcemap.is_absolute() {
sourcemap.clone()
} else {
config_dir.join(sourcemap)
}
} else {
config_dir.join("primate.sourcemap.json")
}
}
fn validate(&self) -> Result<(), ConfigError> {
if self.outputs.is_empty() {
return Err(ConfigError::NoOutputs);
}
for (i, output) in self.outputs.iter().enumerate() {
match (&output.generator, &output.plugin) {
(Some(_), Some(_)) => {
return Err(ConfigError::BothGeneratorAndPlugin(i));
}
(None, None) => {
return Err(ConfigError::NeitherGeneratorNorPlugin(i));
}
_ => {}
}
}
Ok(())
}
pub fn check(content: &str, file_path: &str) -> crate::diagnostics::Diagnostics {
use crate::diagnostics::{Diagnostic, Diagnostics, Severity};
let mut diagnostics = Diagnostics::new();
match toml::from_str::<Config>(content) {
Ok(config) => {
if let Err(e) = config.validate() {
let (message, index) = match &e {
ConfigError::NoOutputs => {
("at least one [[output]] is required".to_string(), None)
}
ConfigError::BothGeneratorAndPlugin(i) => (
format!(
"output[{}]: exactly one of 'generator' or 'plugin' must be specified, but both were",
i
),
Some(*i),
),
ConfigError::NeitherGeneratorNorPlugin(i) => (
format!(
"output[{}]: exactly one of 'generator' or 'plugin' must be specified, but neither was",
i
),
Some(*i),
),
_ => (e.to_string(), None),
};
let line = if let Some(idx) = index {
find_output_line(content, idx).unwrap_or(1)
} else {
1
};
diagnostics.add(Diagnostic {
file: file_path.to_string(),
line,
column: 1,
length: None,
severity: Severity::Error,
code: "config-error".to_string(),
message,
targets: vec![],
});
}
}
Err(e) => {
let (line, col) = if let Some(span) = e.span() {
let (l, c) = index_to_line_col(content, span.start);
(l as u32, c as u32)
} else {
(1, 1)
};
diagnostics.add(Diagnostic {
file: file_path.to_string(),
line,
column: col,
length: None,
severity: Severity::Error,
code: "parse-error".to_string(),
message: e.to_string(),
targets: vec![],
});
}
}
diagnostics
}
}
fn index_to_line_col(content: &str, index: usize) -> (usize, usize) {
let mut line = 1;
let mut col = 1;
for (i, c) in content.char_indices() {
if i == index {
break;
}
if c == '\n' {
line += 1;
col = 1;
} else {
col += 1;
}
}
(line, col)
}
fn find_output_line(content: &str, index: usize) -> Option<u32> {
let mut count = 0;
for (i, line) in content.lines().enumerate() {
if line.trim().starts_with("[[output]]") {
if count == index {
return Some((i + 1) as u32);
}
count += 1;
}
}
None
}
#[cfg(test)]
mod tests {
use super::Config;
#[test]
fn committed_schema_matches_generated() {
let generated = schemars::schema_for!(Config);
let mut generated_json = serde_json::to_string_pretty(&generated).unwrap();
generated_json.push('\n');
let committed =
std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/primate.schema.json"))
.expect("primate.schema.json missing — run `cargo run --bin gen-schema`");
assert_eq!(
committed.trim_end(),
generated_json.trim_end(),
"primate.schema.json is stale — run `cargo run --bin gen-schema` and commit"
);
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("failed to read config file: {0}")]
Io(#[from] std::io::Error),
#[error("failed to parse config file: {0}")]
Parse(#[from] toml::de::Error),
#[error("at least one [[output]] is required")]
NoOutputs,
#[error("output[{0}]: exactly one of 'generator' or 'plugin' must be specified, but both were")]
BothGeneratorAndPlugin(usize),
#[error(
"output[{0}]: exactly one of 'generator' or 'plugin' must be specified, but neither was"
)]
NeitherGeneratorNorPlugin(usize),
}