mod names;
mod ops;
mod types;
use std::io;
use std::path::{Path, PathBuf};
use syn::visit_mut::VisitMut;
use thiserror::Error;
use crate::model::DocumentError;
use crate::overlay::OverlayError;
use crate::{Document, LoadError, overlay};
const DEFAULT_COMMAND: &str = "cargo run -p xtask -- bless";
const ALLOW: &str = "\
#![allow(
clippy::all,
clippy::pedantic,
clippy::restriction,
missing_debug_implementations,
unreachable_pub,
unused,
rustdoc::all,
reason = \"generated source is not graded on style; the allow covers this \\
module and nothing else\"
)]
";
#[derive(Debug, Clone)]
pub struct Settings {
document: PathBuf,
overlays: Vec<PathBuf>,
replacements: Vec<(String, String)>,
command: String,
}
impl Settings {
#[must_use]
pub fn new(document: impl Into<PathBuf>) -> Self {
Self {
document: document.into(),
overlays: Vec::new(),
replacements: Vec::new(),
command: DEFAULT_COMMAND.to_owned(),
}
}
#[must_use]
pub fn overlay(mut self, overlay: impl Into<PathBuf>) -> Self {
self.overlays.push(overlay.into());
self
}
#[must_use]
pub fn replace(mut self, format: impl Into<String>, rust_type: impl Into<String>) -> Self {
self.replacements.push((format.into(), rust_type.into()));
self
}
#[must_use]
pub fn regenerated_by(mut self, command: impl Into<String>) -> Self {
self.command = command.into();
self
}
pub fn write_to(&self, crate_dir: impl AsRef<Path>) -> Result<Vec<PathBuf>, GenerateError> {
let dir = crate_dir.as_ref();
let corrected = self.correct()?;
let spec = dir.join("spec").join(self.corrected_name());
let types = dir.join("src/types.rs");
let ops = dir.join("src/ops.rs");
let model = dir.join("src/model.postcard");
let header = self.rust_header(&spec);
let document = format!("{}{}", self.document_header(), corrected.yaml);
let (source, names) = types::emit(&corrected.api, &header, &self.replacements)?;
write_bytes(&spec, document.as_bytes())?;
write_rust(&types, &source)?;
write_rust(
&ops,
&ops::emit(&corrected.api, &corrected.model, &header, &names)?,
)?;
write_model(&model, &corrected.model)?;
Ok(vec![spec, types, ops, model])
}
fn correct(&self) -> Result<Corrected, GenerateError> {
let fault = |path: &Path| {
let path = path.to_path_buf();
move |source| GenerateError::Overlay { path, source }
};
let mut overlaid = overlay::parse(&read(&self.document)?).map_err(fault(&self.document))?;
for layer in &self.overlays {
overlaid = overlay::apply(overlaid, &read(layer)?).map_err(fault(layer))?;
}
let yaml = serde_yaml_ng::to_string(&overlaid).map_err(GenerateError::Yaml)?;
let model = Document::load(&yaml, &[]).map_err(GenerateError::Unusable)?;
let api = serde_json::from_value(overlaid).map_err(GenerateError::NotOpenApi)?;
Ok(Corrected { yaml, model, api })
}
fn corrected_name(&self) -> String {
let stem = self
.document
.file_stem()
.unwrap_or(self.document.as_os_str())
.to_string_lossy();
format!("{stem}.overlaid.yaml")
}
fn document_header(&self) -> String {
format!(
"# Generated by `{}` from {}.\n\
# Do not edit: every correction belongs in an Overlay.\n",
self.command,
listed(&self.inputs(file_name)),
)
}
fn rust_header(&self, corrected: &Path) -> String {
let belongs = match self.overlays.as_slice() {
[] => format!("is generated from `{}`", locator(&self.document)),
layers => {
let named: Vec<String> = layers
.iter()
.map(|path| format!("`{}`", locator(path)))
.collect();
format!("belongs in {}", listed(&named))
}
};
format!(
"//! Generated by `{}` from `{}`.\n\
//! Do not edit: every correction {belongs}.\n\
{ALLOW}",
self.command,
locator(corrected),
)
}
fn inputs(&self, name: impl Fn(&Path) -> String) -> Vec<String> {
std::iter::once(&self.document)
.chain(&self.overlays)
.map(|path| name(path))
.collect()
}
}
fn listed(items: &[String]) -> String {
match items.split_last() {
None => String::new(),
Some((last, [])) => last.clone(),
Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
}
}
struct Corrected {
yaml: String,
model: Document,
api: openapiv3::OpenAPI,
}
fn locator(path: &Path) -> String {
match path.parent().and_then(Path::file_name) {
Some(parent) => format!("{}/{}", parent.to_string_lossy(), file_name(path)),
None => file_name(path),
}
}
fn file_name(path: &Path) -> String {
path.file_name()
.unwrap_or(path.as_os_str())
.to_string_lossy()
.into_owned()
}
fn read(path: &Path) -> Result<String, GenerateError> {
std::fs::read_to_string(path).map_err(|source| GenerateError::Read {
path: path.to_path_buf(),
source,
})
}
struct Prose;
impl VisitMut for Prose {
fn visit_attribute_mut(&mut self, attr: &mut syn::Attribute) {
let syn::Meta::NameValue(pair) = &mut attr.meta else {
return;
};
if !pair.path.is_ident("doc") {
return;
}
let syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(text),
..
}) = &mut pair.value
else {
return;
};
*text = syn::LitStr::new(&unrunnable(&text.value()), text.span());
}
}
const TAB: usize = 4;
const KEEP: usize = 3;
const HANG: usize = 4;
const BULLET: char = '-';
const ORDERED: usize = 9;
fn unrunnable(prose: &str) -> String {
let capped = capped_lines(prose);
if !capped.contains('\n') {
return capped;
}
format!("\n{capped}\n")
}
fn capped_lines(prose: &str) -> String {
let mut fenced = false;
let lines: Vec<String> = prose
.split('\n')
.map(|line| {
if fenced && fence(line).is_none() {
return line.to_owned();
}
let tamed = dashed(&unhung(&capped(line)));
let Some((marker, language)) = fence(&tamed) else {
return tamed;
};
if fenced {
fenced = false;
return tamed;
}
fenced = true;
if compiled(language) {
let indent: String = tamed.chars().take_while(|c| c.is_whitespace()).collect();
format!("{indent}{marker}{INERT}")
} else {
tamed
}
})
.collect();
lines.join("\n")
}
const INERT: &str = "text";
const COMPILED: [&str; 7] = [
"compile_fail",
"ignore",
"no_run",
"rust",
"should_panic",
"standalone_crate",
"test_harness",
];
fn compiled(language: &str) -> bool {
let word = language.split([',', ' ', '\t']).next().unwrap_or(language);
word.is_empty() || word.starts_with("edition") || COMPILED.contains(&word)
}
fn fence(line: &str) -> Option<(&str, &str)> {
let body = line.trim_start();
let mark = ['`', '~']
.into_iter()
.find(|mark| body.chars().take(3).filter(|c| c == mark).count() == 3)?;
let run = body.len() - body.trim_start_matches(mark).len();
let (marker, language) = body.split_at(run);
Some((marker, language.trim()))
}
fn capped(line: &str) -> String {
let content = line.trim_start();
if content.is_empty() || columns(line) <= KEEP {
return line.to_owned();
}
format!("{}{content}", " ".repeat(KEEP))
}
fn columns(text: &str) -> usize {
text.chars()
.take_while(|c| c.is_whitespace())
.map(|c| if c == '\t' { TAB } else { 1 })
.sum()
}
fn unhung(line: &str) -> String {
let content = line.trim_start();
let Some(width) = list_marker(content) else {
return line.to_owned();
};
let (indent, rest) = line.split_at(line.len() - content.len());
let (marker, after) = rest.split_at(width);
let text = after.trim_start();
if text.is_empty() || columns(after) <= HANG {
return line.to_owned();
}
format!("{indent}{marker}{}{text}", " ".repeat(HANG))
}
fn dashed(line: &str) -> String {
let content = line.trim_start();
if !content.starts_with('*') || list_marker(content).is_none() {
return line.to_owned();
}
let (indent, rest) = line.split_at(line.len() - content.len());
let (_, after) = rest.split_at(1);
format!("{indent}{BULLET}{after}")
}
fn list_marker(content: &str) -> Option<usize> {
let width = if content.starts_with(['-', '+', '*']) {
1
} else {
let digits = content.chars().take_while(char::is_ascii_digit).count();
if digits == 0 || digits > ORDERED {
return None;
}
if !content.get(digits..)?.starts_with(['.', ')']) {
return None;
}
digits + 1
};
content
.get(width..)?
.starts_with([' ', '\t'])
.then_some(width)
}
fn write_bytes(path: &Path, contents: &[u8]) -> Result<(), GenerateError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|source| GenerateError::Write {
path: parent.to_path_buf(),
source,
})?;
}
std::fs::write(path, contents).map_err(|source| GenerateError::Write {
path: path.to_path_buf(),
source,
})
}
fn write_model(path: &Path, model: &Document) -> Result<(), GenerateError> {
let blob = model.to_blob().map_err(GenerateError::Blob)?;
let read_back = Document::from_blob(&blob).map_err(GenerateError::Blob)?;
if &read_back != model {
return Err(GenerateError::RoundTrip);
}
write_bytes(path, &blob)
}
fn write_rust(path: &Path, source: &str) -> Result<(), GenerateError> {
write_bytes(path, source.as_bytes())?;
rustfmt(path)
}
fn rustfmt(path: &Path) -> Result<(), GenerateError> {
let status = std::process::Command::new("rustfmt")
.arg("--edition")
.arg("2024")
.arg(path)
.status()
.map_err(|source| {
if source.kind() == io::ErrorKind::NotFound {
GenerateError::RustfmtMissing
} else {
GenerateError::RustfmtSpawn {
path: path.to_path_buf(),
source,
}
}
})?;
if status.success() {
Ok(())
} else {
Err(GenerateError::RustfmtFailed {
path: path.to_path_buf(),
})
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum GenerateError {
#[error("reading {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("writing {path}: {source}")]
Write {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("{path}: {source}")]
Overlay {
path: PathBuf,
#[source]
source: OverlayError,
},
#[error("the overlaid document is not representable as YAML: {0}")]
Yaml(#[source] serde_yaml_ng::Error),
#[error("the overlaid document does not describe a usable CLI: {0}")]
Unusable(#[source] LoadError),
#[error("the overlaid document is not an OpenAPI 3 document: {0}")]
NotOpenApi(#[source] serde_json::Error),
#[error("schema `{name}` is not representable as JSON: {source}")]
Schema {
name: String,
#[source]
source: serde_json::Error,
},
#[error("typify cannot build Rust types from the document's schemas: {0}")]
Typify(#[source] typify::Error),
#[error(
"`#/components/schemas/{schema}` is referenced but not declared, so no \
wrapper can name the type it would be"
)]
NoType { schema: String },
#[error(
"the document's schemas `{first}` and `{second}` are both `{rust}` in Rust; \
rename one of them in an Overlay"
)]
OneType {
first: String,
second: String,
rust: String,
},
#[error("{0}")]
Unsupported(String),
#[error("{op}: {source}")]
Operation {
op: String,
#[source]
source: Box<GenerateError>,
},
#[error("the generated {file} is not valid Rust: {source}")]
NotRust {
file: &'static str,
#[source]
source: syn::Error,
},
#[error("the reduced model does not survive a round trip: {0}")]
Blob(#[source] DocumentError),
#[error("the reduced model is not the document's reduction after a round trip")]
RoundTrip,
#[error("rustfmt is not on PATH, and a bless step formats every Rust file it writes")]
RustfmtMissing,
#[error("running rustfmt on {path}: {source}")]
RustfmtSpawn {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("rustfmt rejected the generated {path}")]
RustfmtFailed { path: PathBuf },
}