use std::sync::Arc;
use serde_json::Value;
use crate::program::{compile_sources, Output, Program, Source};
use crate::shared::{Code, Duplicates, Fail, Renderers, Routers};
use crate::value::Val;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Shape {
Tree,
Records,
}
impl Shape {
fn parse(name: &str) -> Option<Shape> {
match name {
"tree" => Some(Shape::Tree),
"records" => Some(Shape::Records),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
Shape::Tree => "tree",
Shape::Records => "records",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Root {
Object,
Array,
Any,
}
impl Root {
fn parse(name: &str) -> Option<Root> {
match name {
"object" => Some(Root::Object),
"array" => Some(Root::Array),
"any" => Some(Root::Any),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
Root::Object => "object",
Root::Array => "array",
Root::Any => "any",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PartText {
pub entry: String,
pub source: Option<String>,
}
impl PartText {
pub fn new(entry: &str, source: Option<&str>) -> PartText {
PartText {
entry: entry.to_string(),
source: source.map(str::to_string),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Descriptor {
pub package: String,
pub manifest: String,
pub lift: Option<PartText>,
pub embed: Option<PartText>,
pub render: Option<PartText>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Alc {
pub file: String,
pub entry: String,
pub text: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Render {
Alc(Alc),
Json,
Csv,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Part {
pub id: String,
pub reads: Vec<Shape>,
pub writes: Shape,
pub root: Root,
pub schema: Option<String>,
pub lift: Option<Alc>,
pub embed: Option<Alc>,
pub render: Render,
pub loss: Vec<String>,
}
impl Part {
pub fn from_descriptor(d: &Descriptor) -> Option<Part> {
let manifest: Value = serde_json::from_str(&d.manifest).ok()?;
let id = manifest.get("languageId")?.as_str()?.to_string();
let t = manifest.get("translate")?;
let reads: Vec<Shape> = match t.get("reads")? {
Value::String(one) => vec![Shape::parse(one)?],
Value::Array(list) => list
.iter()
.map(|v| v.as_str().and_then(Shape::parse))
.collect::<Option<Vec<Shape>>>()?,
_ => return None,
};
if reads.is_empty() {
return None;
}
let writes = Shape::parse(t.get("writes")?.as_str()?)?;
let root = match t.get("root") {
None => Root::Any,
Some(v) => Root::parse(v.as_str()?)?,
};
let schema = match t.get("schema") {
None => None,
Some(v) => Some(v.as_str().filter(|s| !s.is_empty())?.to_string()),
};
let file = |path: &str| format!("{}/{path}", d.package);
let alc = |key: &str, part: &Option<PartText>| -> Option<Option<Alc>> {
match (t.get(key), part) {
(None, None) => Some(None),
(Some(Value::String(path)), Some(p))
if path.ends_with(".alc") && !p.entry.is_empty() =>
{
Some(Some(Alc {
file: file(path),
entry: p.entry.clone(),
text: p.source.clone()?,
}))
}
_ => None,
}
};
let lift = alc("lift", &d.lift)?;
let embed = alc("embed", &d.embed)?;
if embed.is_some() && schema.is_none() {
return None;
}
let part = d.render.as_ref()?;
if part.entry.is_empty() {
return None;
}
let render = match t.get("render")?.as_str()? {
"json" if part.entry == "json" && part.source.is_none() => Render::Json,
"csv" if part.entry == "csv" && part.source.is_none() => Render::Csv,
path if path.ends_with(".alc") => Render::Alc(Alc {
file: file(path),
entry: part.entry.clone(),
text: part.source.clone()?,
}),
_ => return None,
};
let loss = t
.get("loss")
.and_then(Value::as_array)
.map(|lines| {
lines
.iter()
.filter_map(|l| l.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
Some(Part {
id,
reads,
writes,
root,
schema,
lift,
embed,
render,
loss,
})
}
pub fn of_output(output: Output) -> Result<Part, Fail> {
let reads = match output {
Output::JsonEvents => Shape::Tree,
Output::TableRows => Shape::Records,
Output::Text => {
return Err(Fail::new(
Code::DslTypeError,
"render_of_text: the program renders its own text, which no render takes",
))
}
};
Ok(Part {
id: "program".to_string(),
reads: vec![reads],
writes: reads,
root: Root::Any,
schema: None,
lift: None,
embed: None,
render: Render::Json,
loss: Vec::new(),
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Adapter {
WrapObject(String),
WrapArray,
Embed,
InferredTable,
Records,
}
impl Adapter {
pub fn name(&self) -> &'static str {
match self {
Adapter::WrapObject(_) => "wrap-object",
Adapter::WrapArray => "wrap-array",
Adapter::Embed => "embed",
Adapter::InferredTable => "the inferred table",
Adapter::Records => "records",
}
}
pub fn loss(&self) -> Vec<String> {
match self {
Adapter::WrapObject(key) => vec![format!(
"A document whose root is not an object is written as the one member {} of an \
object, since the format's document is one.",
quoted(key)
)],
Adapter::WrapArray => vec![
"A document whose root is not an array is written as the one element of an \
array, since the format's document is a sequence."
.to_string(),
],
Adapter::Embed => Vec::new(),
Adapter::InferredTable => vec![
"The rows are the elements of the root array: an object row's members are its \
cells, an array row's cells are its positions, and a scalar row is one cell \
named value."
.to_string(),
"The columns are the first row's: a member or a cell a later row adds is not \
written, one it lacks is written empty, a member repeated in a row keeps its \
last value, and a row of another kind than the first has a cell only where the \
first row's columns find one."
.to_string(),
],
Adapter::Records => vec![
"Each row is written as an object keyed by the column labels: a cell the row \
lacks is an absent member, and of two columns with one label the last gives \
the member."
.to_string(),
],
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Front {
Tree,
None,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Native {
Json,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Options {
pub key: String,
}
impl Default for Options {
fn default() -> Options {
Options {
key: "items".to_string(),
}
}
}
pub const CSV_OPTIONS: &str = "(record (entry :delimiter \",\") (entry :newline \"\\r\\n\") \
(entry :header true) (entry :null-text \"\") (entry :missing \"\") \
(entry :non-finite :literal) (entry :no-columns :empty))";
pub const JSON_OPTIONS: &str = "(record (entry :non-finite :null))";
const INFERRED: &str = "(record (entry :columns :infer) (entry :rows (path each-index)))";
pub const PROGRAM_EXPORT: &str = "program-export";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Composition {
pub sources: Vec<(String, String)>,
pub main_file: String,
pub main: String,
pub adapters: Vec<Adapter>,
pub front: Front,
pub native: Option<Native>,
pub loss: Vec<String>,
}
impl Composition {
pub fn compile(
&self,
program: Option<Source<'_>>,
routers: Arc<dyn Routers<Val>>,
renderers: Arc<dyn Renderers>,
) -> Result<Program, Fail> {
let mut sources: Vec<Source<'_>> = self
.sources
.iter()
.map(|(file, text)| Source::new(file, text))
.collect();
if let Some(p) = program {
sources.push(p.export_as(PROGRAM_EXPORT));
}
sources.push(Source::new(&self.main_file, &self.main));
let program = compile_sources(&sources, routers, renderers)?;
if self.adapters.contains(&Adapter::InferredTable) {
program.with_duplicates(Duplicates::LastWins)
} else {
Ok(program)
}
}
}
pub fn compose(
source: Option<&Part>,
target: &Part,
options: &Options,
main_file: &str,
) -> Result<Composition, Fail> {
compose_over(source, "input", target, options, main_file, Front::Tree)
}
pub fn compose_program(
output: Output,
target: &Part,
options: &Options,
main_file: &str,
) -> Result<Composition, Fail> {
let mut source = Part::of_output(output)?;
if target.embed.is_none() {
source.schema = target.schema.clone();
}
let input = format!("({PROGRAM_EXPORT} input)");
compose_over(
Some(&source),
&input,
target,
options,
main_file,
Front::None,
)
}
fn compose_over(
source: Option<&Part>,
input: &str,
target: &Part,
options: &Options,
main_file: &str,
front: Front,
) -> Result<Composition, Fail> {
let tree = [Shape::Tree];
let reads = source.map_or(&tree[..], |p| &p.reads[..]);
let lift = source.and_then(|p| p.lift.as_ref());
let source_schema = source.and_then(|p| p.schema.as_deref());
let mut sources: Vec<(String, String)> = Vec::new();
let mut adapters: Vec<Adapter> = Vec::new();
let mut expr = input.to_string();
let mut tree_events = true;
match target.writes {
Shape::Records => {
if reads[0] == Shape::Records {
if let Some(l) = lift {
expr = format!("({} {expr})", l.entry);
sources.push((l.file.clone(), l.text.clone()));
}
} else {
if target.root == Root::Array {
expr = format!("(wrap-array {expr})");
adapters.push(Adapter::WrapArray);
}
expr = format!("(table-from-json {INFERRED} {expr})");
adapters.push(Adapter::InferredTable);
}
}
Shape::Tree => {
if !reads.contains(&Shape::Tree) {
expr = format!("(records {expr})");
adapters.push(Adapter::Records);
tree_events = false;
}
match target.schema.as_deref() {
Some(schema) if source_schema != Some(schema) => match &target.embed {
Some(embed) => {
expr = format!("({} {expr})", embed.entry);
sources.push((embed.file.clone(), embed.text.clone()));
adapters.push(Adapter::Embed);
}
None => {
return Err(Fail::new(
Code::TargetValueUnrepresentable,
format!(
"schema_only: {} writes a {schema} tree, the tree its own documents \
read as, and this document is not one; a program that makes one \
can be composed with the render",
target.id
),
))
}
},
_ => match target.root {
Root::Object => {
expr = format!("(wrap-object {} {expr})", quoted(&options.key));
adapters.push(Adapter::WrapObject(options.key.clone()));
}
Root::Array => {
expr = format!("(wrap-array {expr})");
adapters.push(Adapter::WrapArray);
}
Root::Any => {}
},
}
}
}
let native = match (&target.render, expr == input) {
(Render::Json, true) => Some(Native::Json),
_ => None,
};
let render = match &target.render {
Render::Json => format!("json {JSON_OPTIONS}"),
Render::Csv => format!("csv {CSV_OPTIONS}"),
Render::Alc(alc) => {
sources.insert(0, (alc.file.clone(), alc.text.clone()));
alc.entry.clone()
}
};
let main = format!("def export [input] ({render} {expr})");
let mut loss = target.loss.clone();
for adapter in &adapters {
loss.extend(adapter.loss());
}
let front = match (front, tree_events, target.writes) {
(Front::Tree, true, Shape::Tree) => Front::Tree,
_ => Front::None,
};
Ok(Composition {
sources,
main_file: main_file.to_string(),
main,
adapters,
front,
native,
loss,
})
}
fn quoted(s: &str) -> String {
serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn part(id: &str, translate: &str, render: Option<PartText>) -> Option<Part> {
Part::from_descriptor(&Descriptor {
package: format!("tabnas-{id}"),
manifest: format!(r#"{{"languageId": "{id}", "translate": {translate}}}"#),
lift: None,
embed: None,
render,
})
}
fn own(id: &str) -> Option<PartText> {
Some(PartText::new(
&format!("{id}-render"),
Some(&format!("def {id}-render [input] (json input)")),
))
}
fn tree(id: &str, root: &str) -> Part {
part(
id,
&format!(
r#"{{"reads": "tree", "writes": "tree", "root": "{root}", "render": "alchemy/render.alc"}}"#
),
own(id),
)
.unwrap()
}
#[test]
fn a_descriptor_reads_into_a_part() {
let toml = tree("toml", "object");
assert_eq!(toml.root, Root::Object);
assert_eq!(toml.reads, vec![Shape::Tree]);
let Render::Alc(alc) = &toml.render else {
panic!("an alchemy render")
};
assert_eq!(alc.file, "tabnas-toml/alchemy/render.alc");
assert_eq!(alc.entry, "toml-render");
let json = part(
"json",
r#"{"reads": "tree", "writes": "tree", "render": "json"}"#,
Some(PartText::new("json", None)),
)
.unwrap();
assert_eq!((json.render.clone(), json.root), (Render::Json, Root::Any));
assert!(part("x", r#"{"reads": "tree", "writes": "tree", "root": "table", "render": "alchemy/render.alc"}"#, own("x")).is_none());
assert!(part(
"x",
r#"{"reads": "blob", "writes": "tree", "render": "alchemy/render.alc"}"#,
own("x")
)
.is_none());
assert!(part(
"x",
r#"{"reads": "tree", "writes": "tree", "render": "x.txt"}"#,
own("x")
)
.is_none());
assert!(part("x", r#"{"reads": "tree"}"#, own("x")).is_none());
let embedded = Part::from_descriptor(&Descriptor {
package: "tabnas-xml".into(),
manifest: r#"{"languageId": "xml", "translate": {"reads": "tree", "writes": "tree", "root": "any", "embed": "alchemy/embed.alc", "render": "alchemy/render.alc"}}"#.into(),
lift: None,
embed: Some(PartText::new("xml-embed", Some("def xml-embed [input] input"))),
render: own("xml"),
});
assert!(embedded.is_none(), "an embed names a schema");
}
#[test]
fn the_route_wraps_a_root_and_embeds_into_a_schema() {
let o = Options::default();
let toml = tree("toml", "object");
let c = compose(None, &toml, &o, "main").unwrap();
assert_eq!(
c.main,
"def export [input] (toml-render (wrap-object \"items\" input))"
);
assert_eq!(c.adapters, vec![Adapter::WrapObject("items".into())]);
assert_eq!(c.front, Front::Tree);
assert_eq!(c.sources[0].0, "tabnas-toml/alchemy/render.alc");
let jsonl = tree("jsonl", "array");
let c = compose(None, &jsonl, &o, "main").unwrap();
assert_eq!(
c.main,
"def export [input] (jsonl-render (wrap-array input))"
);
let yaml = tree("yaml", "any");
let c = compose(None, &yaml, &o, "main").unwrap();
assert_eq!(c.main, "def export [input] (yaml-render input)");
assert!(c.adapters.is_empty());
let xml = Part {
schema: Some("xml-element".into()),
embed: Some(Alc {
file: "tabnas-xml/alchemy/embed.alc".into(),
entry: "xml-embed".into(),
text: "def xml-embed [input] input".into(),
}),
..tree("xml", "any")
};
let c = compose(None, &xml, &o, "main").unwrap();
assert_eq!(c.main, "def export [input] (xml-render (xml-embed input))");
assert_eq!(c.sources.len(), 2);
let c = compose(Some(&xml), &xml, &o, "main").unwrap();
assert_eq!(c.main, "def export [input] (xml-render input)");
let css = Part {
schema: Some("css".into()),
..tree("css", "any")
};
let f = compose(None, &css, &o, "main").unwrap_err();
assert_eq!(f.code, Code::TargetValueUnrepresentable);
assert!(
f.message.starts_with("schema_only: css writes a css tree"),
"{f}"
);
}
#[test]
fn records_targets_take_a_trees_rows_and_a_lifted_formats_records() {
let o = Options::default();
let csv = part(
"csv",
r#"{"reads": "tree", "writes": "records", "root": "array", "render": "csv"}"#,
Some(PartText::new("csv", None)),
)
.unwrap();
let c = compose(None, &csv, &o, "main").unwrap();
assert_eq!(
c.main,
format!("def export [input] (csv {CSV_OPTIONS} (table-from-json {INFERRED} (wrap-array input)))")
);
assert_eq!(c.adapters, vec![Adapter::WrapArray, Adapter::InferredTable]);
assert_eq!(c.front, Front::None);
let md = Part::from_descriptor(&Descriptor {
package: "tabnas-markdown".into(),
manifest: r#"{"languageId": "markdown", "translate": {"reads": ["records", "tree"], "writes": "records", "root": "array", "lift": "alchemy/lift.alc", "render": "alchemy/render.alc"}}"#.into(),
lift: Some(PartText::new("markdown-lift", Some("def markdown-lift [input] input"))),
embed: None,
render: own("markdown"),
})
.unwrap();
let c = compose(Some(&md), &csv, &o, "main").unwrap();
assert_eq!(
c.main,
format!("def export [input] (csv {CSV_OPTIONS} (markdown-lift input))")
);
assert!(c.adapters.is_empty());
let yaml = tree("yaml", "any");
let c = compose(Some(&md), &yaml, &o, "main").unwrap();
assert_eq!(c.main, "def export [input] (yaml-render input)");
let json = part(
"json",
r#"{"reads": "tree", "writes": "tree", "render": "json"}"#,
Some(PartText::new("json", None)),
)
.unwrap();
let c = compose(None, &json, &o, "main").unwrap();
assert_eq!(c.native, Some(Native::Json));
assert_eq!(
c.main,
"def export [input] (json (record (entry :non-finite :null)) input)"
);
}
#[test]
fn a_programs_output_stands_in_the_sources_place() {
let o = Options::default();
let toml = tree("toml", "object");
let c = compose_program(Output::TableRows, &toml, &o, "main").unwrap();
assert_eq!(
c.main,
"def export [input] (toml-render (wrap-object \"items\" (records (program-export input))))"
);
assert_eq!(c.front, Front::None);
let c = compose_program(Output::JsonEvents, &toml, &o, "main").unwrap();
assert_eq!(
c.main,
"def export [input] (toml-render (wrap-object \"items\" (program-export input)))"
);
let f = compose_program(Output::Text, &toml, &o, "main").unwrap_err();
assert!(f.message.starts_with("render_of_text"), "{f}");
let key = Options {
key: "a \"b\"".into(),
};
let c = compose(None, &toml, &key, "main").unwrap();
assert_eq!(
c.main,
"def export [input] (toml-render (wrap-object \"a \\\"b\\\"\" input))"
);
assert!(c.loss.iter().any(|l| l.contains("\"a \\\"b\\\"\"")));
}
#[test]
fn a_program_makes_a_schema_only_targets_tree() {
let o = Options::default();
let css = Part {
schema: Some("css".into()),
..tree("css", "any")
};
let c = compose_program(Output::JsonEvents, &css, &o, "main").unwrap();
assert_eq!(
c.main,
"def export [input] (css-render (program-export input))"
);
assert!(c.adapters.is_empty());
let c = compose_program(Output::TableRows, &css, &o, "main").unwrap();
assert_eq!(c.adapters, vec![Adapter::Records]);
let object = Part {
schema: Some("x-tree".into()),
..tree("x", "object")
};
let c = compose_program(Output::JsonEvents, &object, &o, "main").unwrap();
assert_eq!(c.adapters, vec![Adapter::WrapObject("items".into())]);
let xml = Part {
schema: Some("xml-element".into()),
embed: Some(Alc {
file: "tabnas-xml/alchemy/embed.alc".into(),
entry: "xml-embed".into(),
text: "def xml-embed [input] input".into(),
}),
..tree("xml", "any")
};
let c = compose_program(Output::JsonEvents, &xml, &o, "main").unwrap();
assert_eq!(
c.main,
"def export [input] (xml-render (xml-embed (program-export input)))"
);
assert_eq!(c.adapters, vec![Adapter::Embed]);
assert_eq!(c.sources.len(), 2);
let c = compose_program(Output::TableRows, &xml, &o, "main").unwrap();
assert_eq!(
c.main,
"def export [input] (xml-render (xml-embed (records (program-export input))))"
);
assert_eq!(c.adapters, vec![Adapter::Records, Adapter::Embed]);
}
}