Skip to main content

cargo_zng/res/built_in/
rp.rs

1use std::{io::Write as _, mem};
2
3use convert_case::{Case, Casing as _};
4
5use super::*;
6
7const RP_HELP: &str = r#"
8Replace ${VAR|<file|!cmd} occurrences in the content
9
10The request file:
11  source/greetings.txt.zr-rp
12   | Thanks for using ${ZR_APP}!
13
14Writes the text content with ZR_APP replaced:
15  target/greetings.txt
16  | Thanks for using Foo App!
17
18The parameters syntax is ${VAR|!|<[:[case]][?else]}:
19
20${VAR}          — Replaces with the env var value, or fails if it is not set.
21${VAR:case}     — Replaces with the env var value, case converted.
22${VAR:?else}    — If VAR is not set or is empty uses 'else' instead.
23
24${<file.txt}    — Replaces with the 'file.txt' content. 
25                  Paths are relative to the workspace root.
26${<file:case}   — Replaces with the 'file.txt' content, case converted.
27${<file:?else}  — If file cannot be read or is empty uses 'else' instead.
28
29${!cmd -h}      — Replaces with the stdout of the bash script line. 
30                  The script runs the same bash used by '.zr-sh'.
31                  The script must be defined all in one line.
32                  A separate bash instance is used for each occurrence.
33                  The working directory is the workspace root.
34${!cmd:case}    — Replaces with the stdout, case converted. 
35                  If the script contains ':' quote it with double quotes\"
36${!cmd:?else}  — If script fails or ha no stdout, uses 'else' instead.
37
38$${VAR}         — Escapes $, replaces with '${VAR}'.
39
40The :case functions are:
41
42:k or :kebab  — kebab-case (cleaned)
43:K or :KEBAB  — UPPER-KEBAB-CASE (cleaned)
44:s or :snake  — snake_case (cleaned)
45:S or :SNAKE  — UPPER_SNAKE_CASE (cleaned)
46:l or :lower  — lower case
47:U or :UPPER  — UPPER CASE
48:T or :Title  — Title Case
49:c or :camel  — camelCase (cleaned)
50:P or :Pascal — PascalCase (cleaned)
51:Tr or :Train — Train-Case (cleaned)
52:           — Unchanged
53:clean      — Cleaned
54:f or :file — Sanitize file name
55
56Cleaned values only keep ascii alphabetic first char and ascii alphanumerics, ' ', '-' and '_' other chars.
57More then one case function can be used, separated by pipe ':T|f' converts to title case and sanitize for file name. 
58
59
60The fallback(:?else) can have nested ${...} patterns. 
61You can set both case and else: '${VAR:case?else}'.
62
63Variables:
64
65All env variables can be used, of particular use with this tool are:
66
67ZR_APP_ID — package.metadata.zng.about.app_id or "qualifier.org.app" in snake_case
68ZR_APP — package.metadata.zng.about.app or package.name
69ZR_ORG — package.metadata.zng.about.org or the first package.authors
70ZR_VERSION — package.version
71ZR_DESCRIPTION — package.description
72ZR_HOMEPAGE — package.homepage
73ZR_LICENSE — package.license
74ZR_PKG_NAME — package.name
75ZR_PKG_AUTHORS — package.authors
76ZR_CRATE_NAME — package.name in snake_case
77ZR_QUALIFIER — package.metadata.zng.about.qualifier or the first components `ZR_APP_ID` except the last two
78ZR_META_*` — any other custom string value in package.metadata.zng.about.*
79
80See `zng::env::about` for more details about metadata vars.
81See the cargo-zng crate docs for a full list of ZR vars.
82
83"#;
84pub(super) fn rp() {
85    help(RP_HELP);
86
87    // target derived from the request place
88    let content = fs::File::open(path(ZR_REQUEST)).unwrap_or_else(|e| fatal!("cannot read, {e}"));
89    let target = path(ZR_TARGET);
90    let target = fs::File::create(target).unwrap_or_else(|e| fatal!("cannot write, {e}"));
91    let mut target = io::BufWriter::new(target);
92
93    let mut content = io::BufReader::new(content);
94    let mut line = String::new();
95    let mut ln = 1;
96    while content.read_line(&mut line).unwrap_or_else(|e| fatal!("cannot read, {e}")) > 0 {
97        let line_r = replace(&line, 0).unwrap_or_else(|e| fatal!("line {ln}, {e}"));
98        target.write_all(line_r.as_bytes()).unwrap_or_else(|e| fatal!("cannot write, {e}"));
99        ln += 1;
100        line.clear();
101    }
102    target.flush().unwrap_or_else(|e| fatal!("cannot write, {e}"));
103}
104
105const MAX_RECURSION: usize = 32;
106fn replace(line: &str, recursion_depth: usize) -> Result<String, String> {
107    let mut n2 = '\0';
108    let mut n1 = '\0';
109    let mut out = String::with_capacity(line.len());
110
111    let mut iterator = line.char_indices();
112    'main: while let Some((ci, c)) = iterator.next() {
113        if n1 == '$' && c == '{' {
114            out.pop();
115            if n2 == '$' {
116                out.push('{');
117                n1 = '{';
118                continue 'main;
119            }
120
121            let start = ci + 1;
122            let mut depth = 0;
123            let mut end = usize::MAX;
124            'seek_end: for (i, c) in iterator.by_ref() {
125                if c == '{' {
126                    depth += 1;
127                } else if c == '}' {
128                    if depth == 0 {
129                        end = i;
130                        break 'seek_end;
131                    }
132                    depth -= 1;
133                }
134            }
135            if end == usize::MAX {
136                let end = (start + 10).min(line.len());
137                return Err(format!("replace not closed at: ${{{}", &line[start..end]));
138            } else {
139                let mut var = &line[start..end];
140                let mut case = "";
141                let mut fallback = None;
142
143                // escape ":"
144                let mut search_start = 0;
145                if var.starts_with('!') {
146                    let mut quoted = false;
147                    let mut escape_next = false;
148                    for (i, c) in var.char_indices() {
149                        if mem::take(&mut escape_next) {
150                            continue;
151                        }
152                        if c == '\\' {
153                            escape_next = true;
154                        } else if c == '"' {
155                            quoted = !quoted;
156                        } else if !quoted && c == ':' {
157                            search_start = i;
158                            break;
159                        }
160                    }
161                }
162                if let Some(i) = var[search_start..].find(':') {
163                    let i = search_start + i;
164                    case = &var[i + 1..];
165                    var = &var[..i];
166                    if let Some(i) = case.find('?') {
167                        fallback = Some(&case[i + 1..]);
168                        case = &case[..i];
169                    }
170                }
171
172                let value = if let Some(path) = var.strip_prefix('<') {
173                    match std::fs::read_to_string(path) {
174                        Ok(s) => Some(s),
175                        Err(e) => {
176                            error!("cannot read `{path}`, {e}");
177                            None
178                        }
179                    }
180                } else if let Some(script) = var.strip_prefix('!') {
181                    match sh_run(script.to_owned(), true, None) {
182                        Ok(r) => Some(r),
183                        Err(e) => fatal!("{e}"),
184                    }
185                } else {
186                    env::var(var).ok()
187                };
188
189                let value = match value {
190                    Some(s) => {
191                        let st = s.trim();
192                        if st.is_empty() {
193                            None
194                        } else if st == s {
195                            Some(s)
196                        } else {
197                            Some(st.to_owned())
198                        }
199                    }
200                    _ => None,
201                };
202
203                if let Some(mut value) = value {
204                    for case in case.split('|') {
205                        value = match case {
206                            "k" | "kebab" => util::clean_value(&value, false).unwrap().to_case(Case::Kebab),
207                            "K" | "KEBAB" => util::clean_value(&value, false).unwrap().to_case(Case::UpperKebab),
208                            "s" | "snake" => util::clean_value(&value, false).unwrap().to_case(Case::Snake),
209                            "S" | "SNAKE" => util::clean_value(&value, false).unwrap().to_case(Case::UpperSnake),
210                            "l" | "lower" => value.to_case(Case::Lower),
211                            "U" | "UPPER" => value.to_case(Case::Upper),
212                            "T" | "Title" => value.to_case(Case::Title),
213                            "c" | "camel" => util::clean_value(&value, false).unwrap().to_case(Case::Camel),
214                            "P" | "Pascal" => util::clean_value(&value, false).unwrap().to_case(Case::Pascal),
215                            "Tr" | "Train" => util::clean_value(&value, false).unwrap().to_case(Case::Train),
216                            "" => value,
217                            "clean" => util::clean_value(&value, false).unwrap(),
218                            "f" | "file" => sanitise_file_name::sanitise(&value),
219                            unknown => return Err(format!("unknown case '{unknown}'")),
220                        };
221                    }
222                    out.push_str(&value);
223                } else if let Some(fallback) = fallback {
224                    if let Some(error) = fallback.strip_prefix('!') {
225                        if error.contains('$') && recursion_depth < MAX_RECURSION {
226                            return Err(replace(error, recursion_depth + 1).unwrap_or_else(|_| error.to_owned()));
227                        } else {
228                            return Err(error.to_owned());
229                        }
230                    } else if fallback.contains('$') && recursion_depth < MAX_RECURSION {
231                        out.push_str(&replace(fallback, recursion_depth + 1)?);
232                    } else {
233                        out.push_str(fallback);
234                    }
235                } else {
236                    return Err(format!("${{{var}}} output is empty"));
237                }
238            }
239        } else {
240            out.push(c);
241        }
242        n2 = n1;
243        n1 = c;
244    }
245    Ok(out)
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn replace_tests() {
254        unsafe {
255            // SAFETY: potentially not safe as tests run in parallel and I don't want to audit every C dep
256            // of code that runs in other tests. If a segfault happen during test run caused by this I intend
257            // to print the test runner log and frame it.
258            std::env::set_var("ZR_RP_TEST", "test value");
259        }
260
261        assert_eq!("", replace("", 0).unwrap());
262        assert_eq!("normal text", replace("normal text", 0).unwrap());
263        assert_eq!("escaped ${NOT}", replace("escaped $${NOT}", 0).unwrap());
264        assert_eq!("replace 'test value'", replace("replace '${ZR_RP_TEST}'", 0).unwrap());
265        assert_eq!("${} output is empty", replace("empty '${}'", 0).unwrap_err()); // hmm
266        assert_eq!(
267            "${ZR_RP_TEST_NOT_SET} output is empty",
268            replace("not set '${ZR_RP_TEST_NOT_SET}'", 0).unwrap_err()
269        );
270        assert_eq!(
271            "not set 'fallback!'",
272            replace("not set '${ZR_RP_TEST_NOT_SET:?fallback!}'", 0).unwrap()
273        );
274        assert_eq!(
275            "not set 'nested 'test value'.'",
276            replace("not set '${ZR_RP_TEST_NOT_SET:?nested '${ZR_RP_TEST}'.}'", 0).unwrap()
277        );
278        assert_eq!("test value", replace("${ZR_RP_TEST_NOT_SET:?${ZR_RP_TEST}}", 0).unwrap());
279        assert_eq!(
280            "curly test value",
281            replace("curly ${ZR_RP_TEST:?{not {what} {is} {going {on {here {:?}}}}}}", 0).unwrap()
282        );
283
284        assert_eq!("replace not closed at: ${MISSING", replace("${MISSING", 0).unwrap_err());
285        assert_eq!("replace not closed at: ${MIS", replace("${MIS", 0).unwrap_err());
286        assert_eq!("replace not closed at: ${MIS:?{", replace("${MIS:?{", 0).unwrap_err());
287        assert_eq!("replace not closed at: ${MIS:?{}", replace("${MIS:?{}", 0).unwrap_err());
288
289        assert_eq!("TEST VALUE", replace("${ZR_RP_TEST:U}", 0).unwrap());
290        assert_eq!("TEST-VALUE", replace("${ZR_RP_TEST:K}", 0).unwrap());
291        assert_eq!("TEST_VALUE", replace("${ZR_RP_TEST:S}", 0).unwrap());
292        assert_eq!("testValue", replace("${ZR_RP_TEST:c}", 0).unwrap());
293    }
294
295    #[test]
296    fn replace_cmd_case() {
297        assert_eq!("cmd HELLO:?WORLD", replace("cmd ${!printf \"hello:?world\":U}", 0).unwrap(),)
298    }
299}