use anyhow::{Context, Result};
use inflector::Inflector;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, atomic::AtomicUsize};
use xsd_parser::{
Config, Ident2, Name, TypeIdent,
config::Schema,
generate,
models::{
NameBuilder as DefaultNameBuilder, format_ident, format_unknown_variant, make_type_name,
meta::MetaType,
},
traits::{NameBuilder, Naming},
};
const ORIGINAL_XSD_DIR: &str = "xsd";
const OUTPUT_FILE_NAME: &str = "generated_schema.rs";
const FEATURE_SCHEMAS: &[(&str, &str)] = &[("net", "net_file.xsd"), ("routes", "routes_file.xsd")];
fn feature_enabled(name: &str) -> bool {
env::var(format!(
"CARGO_FEATURE_{}",
name.to_uppercase().replace('-', "_")
))
.is_ok()
}
fn resolve_dtd_entities(content: &str) -> String {
let entities = parse_dtd_entities(content);
if entities.is_empty() {
return content.to_string();
}
let mut segments = content.split('&');
let first = segments.next().unwrap_or_default().to_string();
segments.fold(first, |mut resolved, segment| {
resolved.push_str(&resolve_entity_reference(segment, &entities));
resolved
})
}
fn parse_dtd_entities(content: &str) -> Vec<(&str, &str)> {
content
.lines()
.filter_map(|line| {
let rest = line.trim().strip_prefix("<!ENTITY ")?.strip_suffix('>')?;
let (name, value) = rest.split_once(char::is_whitespace)?;
let value = value.trim().strip_prefix('"')?.strip_suffix('"')?;
Some((name.trim(), value))
})
.collect()
}
fn resolve_entity_reference(segment: &str, entities: &[(&str, &str)]) -> String {
let Some((name, rest)) = segment.split_once(';') else {
return format!("&{segment}");
};
let value = match name {
"amp" | "lt" | "gt" | "apos" | "quot" => return format!("&{name};{rest}"),
_ => entities
.iter()
.find_map(|(n, v)| (*n == name).then_some(*v)),
};
match value {
Some(value) => format!("{value}{rest}"),
None => format!("&{segment}"),
}
}
const RENAMED_TYPES: &[(&str, &str)] = &[
(r#""idType""#, r#""sumoIdType""#),
(r#""floatType""#, r#""sumoFloatType""#),
(r#""timeType""#, r#""sumoTimeType""#),
];
fn rename_colliding_types(content: &str) -> String {
RENAMED_TYPES
.iter()
.fold(content.to_string(), |content, (from, to)| {
content.replace(from, to)
})
}
fn patch_xsd(content: &str) -> String {
rename_colliding_types(&resolve_dtd_entities(content))
}
fn copy_and_patch_schemas(src: &Path, dst: &Path) -> Result<()> {
fs::create_dir_all(dst)?;
fs::read_dir(src)?.try_for_each(|entry| {
let entry = entry?;
let path = entry.path();
let dest_path = dst.join(entry.file_name());
if path.is_dir() {
return copy_and_patch_schemas(&path, &dest_path);
}
if path.extension().is_some_and(|ext| ext == "xsd") {
let content = fs::read_to_string(&path)?;
fs::write(&dest_path, patch_xsd(&content))?;
} else {
fs::copy(&path, &dest_path)?;
}
Ok(())
})
}
#[derive(Debug, Clone, Default)]
struct SumoNaming(Arc<AtomicUsize>);
impl Naming for SumoNaming {
fn clone_boxed(&self) -> Box<dyn Naming> {
Box::new(self.clone())
}
fn builder(&self) -> Box<dyn NameBuilder> {
Box::new(DefaultNameBuilder::new(
self.0.clone(),
Box::new(self.clone()),
))
}
fn unify(&self, s: &str) -> String {
unify(s)
}
fn make_type_name(&self, postfixes: &[String], ty: &MetaType, ident: &TypeIdent) -> Name {
make_type_name(self, postfixes, ty, ident)
}
fn make_unknown_variant(&self, id: usize) -> Ident2 {
format_unknown_variant(id)
}
fn format_module_name(&self, s: &str) -> String {
format_ident(self.unify(s).to_snake_case())
}
fn format_type_name(&self, s: &str) -> String {
format_ident(self.unify(s))
}
fn format_field_name(&self, s: &str) -> String {
format_ident(self.unify(s).to_snake_case())
}
fn format_variant_name(&self, s: &str) -> String {
format_variant_name(s)
}
fn format_constant_name(&self, s: &str) -> String {
format_ident(self.unify(s).to_screaming_snake_case())
}
}
fn unify(s: &str) -> String {
let mut done = true;
let unified = s.replace(
|c: char| {
let replace = !c.is_alphanumeric();
if c != '_' && !replace {
done = false;
}
c != '_' && replace
},
"_",
);
if done {
unified
} else {
unified.to_screaming_snake_case().to_pascal_case()
}
}
fn format_variant_name(s: &str) -> String {
let sanitized: String = s
.chars()
.map(|c| {
if c.is_alphanumeric() {
c.to_string()
} else {
describe_symbol(c)
}
})
.collect();
format_ident(if sanitized.is_empty() {
"Empty".to_string()
} else {
sanitized
})
}
fn describe_symbol(c: char) -> String {
match c {
'-' => "Dash".to_string(),
'=' => "Eq".to_string(),
'_' => "_".to_string(),
other => format!("U{:x}", other as u32),
}
}
fn pretty_print(code: &str) -> String {
match syn::parse_file(code) {
Ok(parsed) => prettyplease::unparse(&parsed),
Err(error) => {
println!("cargo:warning=could not pretty-print the generated schema: {error}");
code.to_string()
}
}
}
fn main() -> Result<()> {
println!("cargo:rerun-if-changed={ORIGINAL_XSD_DIR}");
let out_dir =
PathBuf::from(env::var("OUT_DIR").context("OUT_DIR environment variable is not set")?);
let dest_path = out_dir.join(OUTPUT_FILE_NAME);
let active_schemas: Vec<&str> = FEATURE_SCHEMAS
.iter()
.filter(|(feature, _)| feature_enabled(feature))
.map(|(_, xsd)| *xsd)
.collect();
if active_schemas.is_empty() {
fs::write(&dest_path, "")
.with_context(|| format!("Failed to write empty schema to {}", dest_path.display()))?;
return Ok(());
}
let patched_xsd_dir = out_dir.join("patched_xsd");
copy_and_patch_schemas(Path::new(ORIGINAL_XSD_DIR), &patched_xsd_dir)
.context("Failed to patch and copy XSD schemas")?;
let mut config = Config::default()
.with_naming(SumoNaming::default())
.with_quick_xml_deserialize();
config.parser.schemas = active_schemas
.iter()
.map(|xsd| Schema::File(patched_xsd_dir.join(xsd)))
.collect();
let code = generate(config)
.map_err(|e| anyhow::anyhow!("Error generating code from XSD schema: {e:?}"))?;
fs::write(&dest_path, pretty_print(&code.to_string()))
.with_context(|| format!("Failed to write generated file to {}", dest_path.display()))?;
Ok(())
}