subplot/
codegen.rs

1use crate::html::Location;
2use crate::scenarios::ScenarioFilter;
3use crate::{resource, Document, SubplotError, TemplateSpec};
4use std::collections::{HashMap, HashSet};
5use std::fs::File;
6use std::io::Write;
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, Mutex};
9
10use base64::prelude::{Engine as _, BASE64_STANDARD};
11
12use serde::Serialize;
13use tera::{Context, Filter, Tera, Value};
14
15/// Generate a test program from a document, using a template spec.
16pub(crate) fn generate_test_program_string(
17    doc: &mut Document,
18    template: &str,
19    filter: &ScenarioFilter,
20) -> Result<String, SubplotError> {
21    let context = context(doc, template, filter)?;
22    let docimpl = doc
23        .meta()
24        .document_impl(template)
25        .ok_or(SubplotError::MissingTemplate)?;
26
27    let code = tera(docimpl.spec(), template)?
28        .render("template", &context)
29        .expect("render");
30    Ok(code)
31}
32
33/// Generate a test program from a document, using a template spec
34/// and write it to a file.
35pub fn generate_test_program(
36    doc: &mut Document,
37    filename: &Path,
38    template: &str,
39    filter: &ScenarioFilter,
40) -> Result<(), SubplotError> {
41    let code = generate_test_program_string(doc, template, filter)?;
42    write(filename, &code)?;
43    Ok(())
44}
45
46fn context(
47    doc: &mut Document,
48    template: &str,
49    filter: &ScenarioFilter,
50) -> Result<Context, SubplotError> {
51    let mut context = Context::new();
52    let scenarios = doc.matched_scenarios(template, filter)?;
53    context.insert("scenarios", &scenarios);
54    context.insert("files", doc.embedded_files());
55
56    let mut funcs = vec![];
57    if let Some(docimpl) = doc.meta().document_impl(template) {
58        for filename in docimpl.functions_filenames() {
59            let content = resource::read_as_string(filename, Some(template))
60                .map_err(|err| SubplotError::FunctionsFileNotFound(filename.into(), err))?;
61            funcs.push(Func::new(filename, content));
62        }
63    }
64    context.insert("functions", &funcs);
65
66    // Any of the above could fail for more serious reasons, but if we get this far
67    // and our context would have no scenarios in it, then we complain.
68    if scenarios.is_empty() {
69        return Err(SubplotError::NoScenariosMatched(template.to_string()));
70    }
71    Ok(context)
72}
73
74fn tera(tmplspec: &TemplateSpec, templatename: &str) -> Result<Tera, SubplotError> {
75    let mut tera = Tera::default();
76    tera.register_filter("base64", base64);
77    tera.register_filter("nameslug", UniqueNameSlug::default());
78    tera.register_filter("commentsafe", commentsafe);
79    tera.register_filter("location", locationfilter);
80    let dirname = tmplspec.template_filename().parent().unwrap();
81    for helper in tmplspec.helpers() {
82        let helper_path = dirname.join(helper);
83        let helper_content = resource::read_as_string(&helper_path, Some(templatename))
84            .map_err(|err| SubplotError::ReadFile(helper_path.clone(), err))?;
85        let helper_name = helper.display().to_string();
86        tera.add_raw_template(&helper_name, &helper_content)
87            .map_err(|err| SubplotError::TemplateError(helper_name.to_string(), err))?;
88    }
89    let path = tmplspec.template_filename();
90    let template = resource::read_as_string(path, Some(templatename))
91        .map_err(|err| SubplotError::ReadFile(path.to_path_buf(), err))?;
92    tera.add_raw_template("template", &template)
93        .map_err(|err| {
94            SubplotError::TemplateError(tmplspec.template_filename().display().to_string(), err)
95        })?;
96    Ok(tera)
97}
98
99pub(crate) fn write(filename: &Path, content: &str) -> Result<(), SubplotError> {
100    let mut f: File = File::create(filename)
101        .map_err(|err| SubplotError::CreateFile(filename.to_path_buf(), err))?;
102    f.write_all(content.as_bytes())
103        .map_err(|err| SubplotError::WriteFile(filename.to_path_buf(), err))?;
104    Ok(())
105}
106
107fn base64(v: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
108    match v {
109        Value::String(s) => Ok(Value::String(BASE64_STANDARD.encode(s))),
110        _ => Err(tera::Error::msg(
111            "can only base64 encode strings".to_string(),
112        )),
113    }
114}
115
116fn locationfilter(v: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
117    let location: Location = serde_json::from_value(v.clone())?;
118    Ok(Value::String(format!(
119        "{:?}",
120        match location {
121            Location::Known {
122                filename,
123                line,
124                col,
125            } => format!("{}:{}:{}", filename.display(), line, col),
126            Location::Unknown => "unknown".to_string(),
127        }
128    )))
129}
130
131#[derive(Default)]
132struct UniqueNameSlug {
133    names: Arc<Mutex<HashSet<String>>>,
134}
135
136impl Filter for UniqueNameSlug {
137    fn filter(&self, name: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
138        match name {
139            Value::String(s) => {
140                let mut newname = s
141                    .chars()
142                    .map(|c| match c {
143                        'a'..='z' | '0'..='9' => c,
144                        'A'..='Z' => c.to_ascii_lowercase(),
145                        _ => '_',
146                    })
147                    .collect::<String>();
148
149                if newname.is_empty() || newname.chars().next().unwrap().is_ascii_digit() {
150                    newname.insert(0, 'n');
151                    newname.insert(1, '_');
152                }
153
154                let mut idx = 0;
155                let mut set = self.names.lock().unwrap();
156                let pfx = newname.clone();
157                while set.contains(&newname) {
158                    newname = format!("{pfx}_{idx}");
159                    idx += 1;
160                }
161                set.insert(newname.clone());
162                Ok(Value::String(newname))
163            }
164            _ => Err(tera::Error::msg(
165                "can only create nameslugs from strings".to_string(),
166            )),
167        }
168    }
169}
170fn commentsafe(s: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
171    match s {
172        Value::String(s) => {
173            let mut cleaned = String::with_capacity(s.len());
174            for c in s.chars() {
175                match c {
176                    '\n' => cleaned.push_str("\\n"),
177                    '\r' => cleaned.push_str("\\r"),
178                    '\\' => cleaned.push_str("\\\\"),
179                    _ => cleaned.push(c),
180                }
181            }
182            Ok(Value::String(cleaned))
183        }
184        _ => Err(tera::Error::msg(
185            "can only make clean comments from strings".to_string(),
186        )),
187    }
188}
189
190#[derive(Debug, Serialize)]
191pub struct Func {
192    pub source: PathBuf,
193    pub code: String,
194}
195
196impl Func {
197    pub fn new(source: &Path, code: String) -> Func {
198        Func {
199            source: source.to_path_buf(),
200            code,
201        }
202    }
203}
204
205#[cfg(test)]
206mod test {
207    use std::collections::HashMap;
208    use tera::{Filter, Value};
209    #[test]
210    fn verify_commentsafe_filter() {
211        static GOOD_CASES: &[(&str, &str)] = &[
212            ("", ""),                                             // Empty
213            ("hello world", "hello world"),                       // basic strings pass through
214            ("Capitalised Words", "Capitalised Words"),           // capitals are OK
215            ("multiple\nlines\rblah", "multiple\\nlines\\rblah"), // line breaks are made into spaces
216        ];
217        for (input, output) in GOOD_CASES.iter().copied() {
218            let input = Value::from(input);
219            let output = Value::from(output);
220            let empty = HashMap::new();
221            assert_eq!(super::commentsafe(&input, &empty).ok(), Some(output));
222        }
223    }
224
225    #[test]
226    fn verify_name_slugification() {
227        static GOOD_CASES: &[(&str, &str)] = &[
228            // Simple words pass through
229            ("foobar", "foobar"),
230            // Capital letters are lowercased, deduped
231            ("FooBar", "foobar_0"),
232            // Non-ascii characters are changed for underscores
233            ("Motörhead", "mot_rhead"),
234            // As is whitespace etc.
235            ("foo bar", "foo_bar"),
236            // setup for later
237            ("check ipv4 stuff", "check_ipv4_stuff"),
238            // only difference is a digit
239            ("check ipv6 stuff", "check_ipv6_stuff"),
240            // leading digit
241            ("1 is the loneliest number", "n_1_is_the_loneliest_number"),
242            // identical, deduped
243            ("check ipv6 stuff", "check_ipv6_stuff_0"),
244        ];
245        let filt = super::UniqueNameSlug::default();
246        for (input, output) in GOOD_CASES.iter().copied() {
247            let input = Value::from(input);
248            let output = Value::from(output);
249            let empty = HashMap::new();
250            assert_eq!(filt.filter(&input, &empty).ok(), Some(output));
251        }
252    }
253}