use crate::error::{AppError, PipelineError};
use crate::resolve::Resolver;
use crate::validator::{
is_collection, line_matches, ns_arr_parts, split_element, CompiledSchema, SchemaField,
};
use std::collections::{HashMap, HashSet};
pub fn denormalize(raiv: &str) -> Result<String, PipelineError> {
let mut table: HashMap<String, String> = HashMap::new();
let mut out = String::new();
for line in raiv.split_inclusive('\n') {
let body = line.trim_end_matches(['\n', '\r']);
let eol = &line[body.len()..];
if !is_nondata_line(body) {
if let Some(tick) = body.find('\'') {
if let Some(eq_rel) = body[tick..].find('=') {
let eq = tick + eq_rel;
let namepath = &body[tick + 1..eq];
let value = &body[eq + 1..];
let resolved = resolve_value(value, &table)?;
table.insert(namepath.to_string(), resolved.clone());
out.push_str(&body[..eq + 1]);
out.push_str(&resolved);
out.push_str(eol);
continue;
}
}
}
out.push_str(line);
}
Ok(out)
}
pub fn denormalize_with(raiv: &str, resolver: &Resolver) -> Result<String, PipelineError> {
let daiv = denormalize(raiv)?;
match crate::validator::schema_for_daiv(&daiv, resolver)? {
None => Ok(daiv),
Some(schema) => materialize(&daiv, &schema),
}
}
fn materialize(daiv: &str, schema: &CompiledSchema) -> Result<String, PipelineError> {
let lines: Vec<&str> = daiv.split_inclusive('\n').collect();
let nps: Vec<Option<String>> = lines.iter().map(|l| namepath_of(l)).collect();
let present: HashSet<&str> = nps.iter().flatten().map(String::as_str).collect();
let eol = if daiv.contains("\r\n") { "\r\n" } else { "\n" };
let fields = &schema.fields;
let mut out = String::new();
let mut si = 0usize;
let mut di = 0usize;
while di < lines.len() {
let Some(np) = &nps[di] else {
out.push_str(lines[di]);
di += 1;
continue;
};
if !fields.iter().any(|f| line_matches(f, np)) {
out.push_str(lines[di]);
di += 1;
continue;
}
while si < fields.len() && !line_matches(&fields[si], np) {
emit_absent(&fields[si], &present, eol, &mut out)?;
si += 1;
}
if si == fields.len() {
out.push_str(lines[di]);
di += 1;
continue;
}
if let Some((arr, _)) = ns_arr_parts(&fields[si]) {
let arr = arr.to_string();
let mut gend = si;
while gend < fields.len()
&& ns_arr_parts(&fields[gend]).is_some_and(|(a, _)| a == arr)
{
gend += 1;
}
di = materialize_ns_array(
&fields[si..gend],
&arr,
&lines,
&nps,
di,
eol,
&mut out,
)?;
si = gend;
continue;
}
out.push_str(lines[di]);
if !crate::validator::is_map_entry(&fields[si])
&& crate::validator::scalar_arr_prefix(&fields[si]).is_none()
{
si += 1;
}
di += 1;
}
while si < fields.len() {
emit_absent(&fields[si], &present, eol, &mut out)?;
si += 1;
}
Ok(out)
}
#[allow(clippy::too_many_arguments)]
fn materialize_ns_array(
group: &[SchemaField],
arr: &str,
lines: &[&str],
nps: &[Option<String>],
mut di: usize,
eol: &str,
out: &mut String,
) -> Result<usize, PipelineError> {
let prefix = format!("{arr}/");
let in_run = |i: usize| {
nps.get(i)
.and_then(|n| n.as_ref())
.is_some_and(|n| n.starts_with(&prefix))
};
while di < lines.len() && in_run(di) {
let np = nps[di].as_ref().expect("in_run checked");
let Some((idx, _)) = split_element(np, &prefix) else {
out.push_str(lines[di]);
di += 1;
continue;
};
let mut dj = di;
let mut elem_fields: HashSet<&str> = HashSet::new();
while dj < lines.len() && in_run(dj) {
let n = nps[dj].as_ref().expect("in_run checked");
match split_element(n, &prefix) {
Some((i2, f)) if i2 == idx => {
elem_fields.insert(f);
dj += 1;
}
Some(_) => break, None => dj += 1, }
}
let mut gj = 0usize;
for k in di..dj {
let n = nps[k].as_ref().expect("in_run checked");
let field = match split_element(n, &prefix) {
Some((_, f)) => f,
None => {
out.push_str(lines[k]);
continue;
}
};
let hit = (gj..group.len())
.find(|&g| ns_arr_parts(&group[g]).is_some_and(|(_, f)| f == field));
if let Some(gk) = hit {
for g in &group[gj..gk] {
emit_absent_element(g, &elem_fields, arr, idx, eol, out)?;
}
gj = gk + 1;
}
out.push_str(lines[k]);
}
for g in &group[gj..] {
emit_absent_element(g, &elem_fields, arr, idx, eol, out)?;
}
di = dj;
}
Ok(di)
}
fn emit_absent_element(
g: &SchemaField,
elem_fields: &HashSet<&str>,
arr: &str,
idx: usize,
eol: &str,
out: &mut String,
) -> Result<(), PipelineError> {
let field = ns_arr_parts(g).map(|(_, f)| f).unwrap_or_default();
if elem_fields.contains(field) {
return Ok(());
}
if !g.optional {
return Err(PipelineError::App(AppError::RequiredFieldSchema));
}
let (ty, value) = materialized_parts(g)?;
ensure_eol(out, eol);
out.push_str(&format!("{ty}'{arr}/{idx}::{field}={value}{eol}"));
Ok(())
}
fn ensure_eol(out: &mut String, eol: &str) {
if !out.is_empty() && !out.ends_with('\n') {
out.push_str(eol);
}
}
fn emit_absent(
f: &SchemaField,
present: &HashSet<&str>,
eol: &str,
out: &mut String,
) -> Result<(), PipelineError> {
if is_collection(f) || present.contains(f.namepath.as_str()) {
return Ok(());
}
if !f.optional {
return Err(PipelineError::App(AppError::RequiredFieldSchema));
}
let (ty, value) = materialized_parts(f)?;
ensure_eol(out, eol);
out.push_str(&format!("{ty}'{}={value}{eol}", f.namepath));
Ok(())
}
fn materialized_parts(f: &SchemaField) -> Result<(String, String), PipelineError> {
let anno = f.items.iter().find_map(|i| match i {
crate::anno::Item::Anno(a) => Some(a),
_ => None,
});
match anno {
Some(a) if !a.union.is_empty() => {
let name = crate::validator::union_pick(a, &f.default)
.ok_or(PipelineError::App(AppError::SchemaOptionalWithoutDefault))?;
let value = if name == "null" { "" } else { f.default.as_str() };
Ok((format!("!{name}"), value.to_string()))
}
Some(a) => {
if !crate::validator::default_applicable(&f.items, &f.default) {
return Err(PipelineError::App(AppError::SchemaOptionalWithoutDefault));
}
let unit = a
.unit
.as_ref()
.map(|u| format!(":{u}"))
.unwrap_or_default();
Ok((format!("!{}{unit}", a.type_name), f.default.clone()))
}
None => {
if !crate::validator::default_applicable(&f.items, &f.default) {
return Err(PipelineError::App(AppError::SchemaOptionalWithoutDefault));
}
Ok(("!str".to_string(), f.default.clone()))
}
}
}
fn is_nondata_line(body: &str) -> bool {
let s = body.trim_start_matches([' ', '\t']);
s.is_empty()
|| s.starts_with('#')
|| s.starts_with("//")
|| s.starts_with(".!")
|| s.starts_with(".?")
}
fn namepath_of(line: &str) -> Option<String> {
let s = line
.trim_end_matches(['\n', '\r'])
.trim_start_matches([' ', '\t']);
if is_nondata_line(s) {
return None;
}
let tick = s.find('\'')?;
let rest = &s[tick + 1..];
let eq = crate::validator::first_eq(rest)?;
Some(rest[..eq].to_string())
}
fn resolve_value(
value: &str,
table: &HashMap<String, String>,
) -> Result<String, PipelineError> {
let b = value.as_bytes();
let mut out = String::with_capacity(value.len());
let mut i = 0;
while i < b.len() {
if b[i] != b'$' {
let start = i;
while i < b.len() && b[i] != b'$' {
i += 1;
}
out.push_str(&value[start..i]);
continue;
}
match b.get(i + 1) {
Some(b'$') => {
out.push('$');
i += 2;
}
Some(b'.') => {
return Err(PipelineError::Other(format!(
"unresolved variable in .raiv: {value}"
)));
}
_ => {
let start = i + 1;
let end = start + fieldref_len(&b[start..]);
if end == start {
return Err(PipelineError::App(AppError::UndefinedReference));
}
let target = canonical_ref(&value[start..end]);
let v = table
.get(&target)
.ok_or(PipelineError::App(AppError::UndefinedReference))?;
out.push_str(v);
i = end;
}
}
}
Ok(out)
}
fn fieldref_len(b: &[u8]) -> usize {
let mut i = 0;
while i < b.len() {
let c = b[i];
if c.is_ascii_alphanumeric()
|| c == b'_'
|| c == b'/'
|| (c == b'@' && (i == 0 || b[i - 1] == b'/'))
{
i += 1;
} else if c == b':' && b.get(i + 1) == Some(&b':') {
i += 2;
} else {
break;
}
}
while i > 0 && matches!(b[i - 1], b'/' | b':' | b'@') {
i -= 1;
}
i
}
fn canonical_ref(r: &str) -> String {
if r.contains("::") {
if r.starts_with('/') {
r.to_string()
} else {
format!("/{r}")
}
} else {
format!("::{r}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn commented_out_line_does_not_define_reference() {
let r = denormalize("#!int'::port=8080\n!int'::port_backup=$port\n");
assert!(matches!(
r,
Err(PipelineError::App(AppError::UndefinedReference))
));
}
#[test]
fn comment_with_dollar_and_eq_passes_through() {
let out = denormalize("# don't touch: x=$y\n!int'::a=1\n").unwrap();
assert!(out.contains("# don't touch: x=$y"));
assert!(out.contains("!int'::a=1"));
}
#[test]
fn fieldref_len_admits_array_element_reference() {
assert_eq!(
fieldref_len(b"@servers/0::name"),
"@servers/0::name".len()
);
}
}