diff --git a/Cargo.lock b/Cargo.lock index c1df983..ae01436 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -780,6 +780,7 @@ dependencies = [ "inquire", "jsonpath", "log", + "minijinja", "mktemp", "notify", "ratatui", @@ -1300,12 +1301,29 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "memo-map" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" + [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minijinja" +version = "2.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328251e58ad8e415be6198888fc207502727dc77945806421ab34f35bf012e7d" +dependencies = [ + "memo-map", + "serde", + "serde_json", +] + [[package]] name = "miniz_oxide" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index bb9a8a2..2fefa87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ tui-input = "0.11.0" syntect-tui = "3.0.4" syntect = "5.2.0" graphql-parser = "0.4.0" +minijinja = { version = "2", features = ["json"] } [dev-dependencies] mktemp = "0.5.1" diff --git a/example/create_issue.http b/example/create_issue.http index 287013b..fb3b2e2 100644 --- a/example/create_issue.http +++ b/example/create_issue.http @@ -4,5 +4,5 @@ Accept: application/vnd.github+json { "title": "Found a bug", "body": "I'm having a problem with this.", - "labels": [ {{ label[, ]["] }} ], + "labels": ["{{ label | select_multiple | join('", "') }}"], } diff --git a/example/get_issues.http b/example/get_issues.http index b4f77b8..631bb2d 100644 --- a/example/get_issues.http +++ b/example/get_issues.http @@ -1,2 +1,2 @@ -GET {{base_url}}/repos/{{reponame}}/issues?page={{page|1}}&per_page=10 HTTP/1.1 +GET {{base_url}}/repos/{{reponame}}/issues?page={{page | fallback(1)}}&per_page=10 HTTP/1.1 Accept: application/vnd.github+json diff --git a/src/cli.rs b/src/cli.rs index d69b131..926ee43 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -25,7 +25,7 @@ pub struct Args { conflicts_with = "name", conflicts_with = "repeat", conflicts_with = "flurry", - conflicts_with = "watch", + conflicts_with = "watch" )] pub select: Option>, diff --git a/src/env.rs b/src/env.rs index 829e580..5a4965b 100644 --- a/src/env.rs +++ b/src/env.rs @@ -51,7 +51,8 @@ impl CookieStore for HitmanCookieJar { } fn cookies(&self, _: &Url) -> Option { - let data_file = read_toml(&self.root_dir.join(DATA_FILE)).ok().flatten()?; + let data_file = + read_toml(&self.root_dir.join(DATA_FILE)).ok().flatten()?; match data_file.get(COOKIE_KEY)? { Value::Array(arr) => { @@ -213,7 +214,8 @@ fn merge(config: &mut TomlTable, other: TomlTable) { fn read_toml(file_path: &Path) -> Result> { match fs::read_to_string(file_path) { Ok(content) => { - let cfg = toml::from_str::(&content).with_context(|| format!("When reading {file_path:?}"))?; + let cfg = toml::from_str::(&content) + .with_context(|| format!("When reading {file_path:?}"))?; Ok(Some(cfg)) } diff --git a/src/lib.rs b/src/lib.rs index 066ec2f..8f79ee9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,4 +9,3 @@ pub mod substitute; pub mod util; pub mod prompt; - diff --git a/src/main.rs b/src/main.rs index 96fbb73..b8c9894 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,8 +7,8 @@ use std::env::current_dir; use tokio::sync::mpsc; use hitman::env::{ - find_available_requests, get_target, load_env, - select_target, set_target, watch_list, + find_available_requests, get_target, load_env, select_target, set_target, + watch_list, }; use hitman::flurry::flurry_attack; use hitman::monitor::monitor; @@ -34,7 +34,8 @@ async fn main() -> Result<()> { set_interactive_mode(!(args.non_interactive || args.watch)); if let Some(arg) = args.select { - let root_dir = find_root_dir(¤t_dir()?)?.context("No hitman.toml found")?; + let root_dir = + find_root_dir(¤t_dir()?)?.context("No hitman.toml found")?; match arg { Some(target) => set_target(&root_dir, &target)?, @@ -49,7 +50,10 @@ async fn main() -> Result<()> { let file_path = cwd.join(file_path); let resolved = resolve_path(&file_path)?; - let target = args.target.clone().unwrap_or_else(|| get_target(&resolved.root_dir)); + let target = args + .target + .clone() + .unwrap_or_else(|| get_target(&resolved.root_dir)); if let Some(flurry_size) = args.flurry { let scope = load_env(&target, &resolved, &args.options)?; @@ -64,8 +68,7 @@ async fn main() -> Result<()> { let scope = load_env(&target, &resolved, &args.options)?; monitor(&resolved, delay_seconds, &scope).await } else { - let res = - run_once(&target, &resolved, &args.options).await; + let res = run_once(&target, &resolved, &args.options).await; if args.watch { watch_mode(&target, &resolved, &args.options).await @@ -90,10 +93,12 @@ async fn main() -> Result<()> { let file_path = &files[selected.index]; let resolved = resolve_path(file_path)?; - let target = args.target.clone().unwrap_or_else(|| get_target(&resolved.root_dir)); + let target = args + .target + .clone() + .unwrap_or_else(|| get_target(&resolved.root_dir)); - let result = - run_once(&target, &resolved, &args.options).await; + let result = run_once(&target, &resolved, &args.options).await; if !args.repeat { break result; @@ -154,9 +159,7 @@ async fn watch_mode( if let Some(event) = rx.recv().await { if let EventKind::Modify(_) = event.kind { watcher.unwatch_all()?; - if let Err(err) = - run_once(target, resolved, options).await - { + if let Err(err) = run_once(target, resolved, options).await { error!("# {err}"); } watcher.watch_all()?; diff --git a/src/prompt.rs b/src/prompt.rs index 679663e..4855c2f 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -1,6 +1,7 @@ use anyhow::{bail, Result}; use fuzzy_matcher::skim::SkimMatcherV2; use inquire::{list_option::ListOption, DateSelect, MultiSelect, Select, Text}; +use minijinja::Value as JinjaValue; use std::{collections::HashMap, env, string::ToString}; use toml::Value; @@ -11,7 +12,6 @@ use crate::{ substitute::{ prepare_request, Substitution::{Complete, ValueMissing}, - SubstitutionValue, }, }; @@ -47,12 +47,12 @@ pub fn get_interaction() -> Box { pub trait UserInteraction { fn prompt(&self, key: &str, fallback: Option<&str>) -> Result; - fn select(&self, key: &str, values: &[Value]) -> Result; + fn select(&self, key: &str, values: &[Value]) -> Result; fn select_multiple( &self, key: &str, values: &[Value], - ) -> Result>; + ) -> Result; } pub fn prepare_request_interactive( @@ -63,7 +63,7 @@ pub fn prepare_request_interactive( where I: UserInteraction + ?Sized, { - let mut vars = HashMap::new(); + let mut vars: HashMap = HashMap::new(); loop { match prepare_request(resolved, &vars)? { @@ -74,23 +74,15 @@ where multiple, } => { let value = match scope.lookup(&key)? { - Replacement::Value(value) => { - SubstitutionValue::Single(value) - } - Replacement::ValueNotFound { key } => { - SubstitutionValue::Single( - interaction.prompt(&key, fallback.as_deref())?, - ) - } + Replacement::Value(value) => JinjaValue::from(value), + Replacement::ValueNotFound { key } => JinjaValue::from( + interaction.prompt(&key, fallback.as_deref())?, + ), Replacement::MultipleValuesFound { key, values } => { if multiple { - SubstitutionValue::Multiple( - interaction.select_multiple(&key, &values)?, - ) + interaction.select_multiple(&key, &values)? } else { - SubstitutionValue::Single( - interaction.select(&key, &values)?, - ) + interaction.select(&key, &values)? } } }; @@ -122,11 +114,10 @@ impl UserInteraction for NoUserInteraction { if let Some(val) = fallback.map(ToString::to_string) { return Ok(val); } - bail!("Replacement not found: {key}"); } - fn select(&self, key: &str, values: &[toml::Value]) -> Result { + fn select(&self, key: &str, values: &[toml::Value]) -> Result { let suggestions = self.get_suggestions(key, values); bail!("Replacement not selected: {key}\nSuggestions:\n{suggestions}"); } @@ -135,7 +126,7 @@ impl UserInteraction for NoUserInteraction { &self, key: &str, values: &[toml::Value], - ) -> Result> { + ) -> Result { let suggestions = self.get_suggestions(key, values); bail!("Replacement not selected: {key}\nSuggestions:\n{suggestions}"); } @@ -148,7 +139,7 @@ impl UserInteraction for CliUserInteraction { prompt_user(key, fallback) } - fn select(&self, key: &str, values: &[toml::Value]) -> Result { + fn select(&self, key: &str, values: &[toml::Value]) -> Result { select_replacement(key, values) } @@ -156,7 +147,7 @@ impl UserInteraction for CliUserInteraction { &self, key: &str, values: &[Value], - ) -> Result> { + ) -> Result { select_replacement_multiple(key, values) } } @@ -190,7 +181,7 @@ fn prompt_for_date(key: &str) -> Result> { Ok(res.map(formatter)) } -fn select_replacement(key: &str, values: &[Value]) -> Result { +fn select_replacement(key: &str, values: &[Value]) -> Result { let list_options = values_to_list_options(values); let selected = Select::new(&format!("Select value for {key}"), list_options) @@ -198,13 +189,15 @@ fn select_replacement(key: &str, values: &[Value]) -> Result { .with_page_size(15) .prompt()?; - list_option_to_string(key, values, &selected) + Ok(JinjaValue::from(list_option_to_string( + key, values, &selected, + )?)) } fn select_replacement_multiple( key: &str, values: &[Value], -) -> Result> { +) -> Result { let list_options = values_to_list_options(values); let selected = MultiSelect::new(&format!("Select value for {key}"), list_options) @@ -212,10 +205,14 @@ fn select_replacement_multiple( .with_page_size(15) .prompt()?; - selected + let items: Vec = selected .iter() - .map(|item| list_option_to_string(key, values, item)) - .collect() + .map(|item| { + list_option_to_string(key, values, item).map(JinjaValue::from) + }) + .collect::>>()?; + + Ok(JinjaValue::from(items)) } fn values_to_list_options(values: &[Value]) -> Vec> { diff --git a/src/substitute.rs b/src/substitute.rs index 6386aae..dcc49b4 100644 --- a/src/substitute.rs +++ b/src/substitute.rs @@ -1,5 +1,9 @@ -use anyhow::{bail, Context}; +use std::cell::Cell; +use std::sync::Arc; + +use anyhow::Context; use httparse::Status; +use minijinja::{value::Object, Environment, UndefinedBehavior, Value}; use reqwest::{ header::{HeaderMap, HeaderName, HeaderValue}, Method, Url, @@ -27,17 +31,34 @@ pub enum Substitution { pub use Substitution::{Complete, ValueMissing}; +thread_local! { + static MISSING: Cell> = const { Cell::new(None) }; + static MULTIPLE: Cell = const { Cell::new(false) }; + static FALLBACK: Cell> = const { Cell::new(None) }; +} + +#[derive(Debug)] +struct TrackingContext { + vars: HashMap, +} + +impl Object for TrackingContext { + fn get_value(self: &Arc, key: &Value) -> Option { + let key_str = key.as_str()?; + match self.vars.get(key_str) { + Some(v) => Some(v.clone()), + None => { + MISSING.set(Some(key_str.to_string())); + Some(Value::UNDEFINED) + } + } + } +} + pub fn prepare_request( resolved: &Resolved, - vars: &HashMap>, + vars: &HashMap, ) -> anyhow::Result> { - // FIXME This is still doing too much: - // - Substituting placeholders in the raw input text - // - Parsing the result as HTTP, yielding method, url, headers and body - // - Loading and parsing GraphQL - // - Generating variables for GraphQL (quite different for raw text - // substitution) - let input = read_to_string(resolved.http_file())?; let buf = match substitute(&input, vars)? { Complete(buf) => buf, @@ -86,18 +107,11 @@ pub fn prepare_request( return Ok(ValueMissing { key: key.name, fallback: None, - multiple: key.list, + multiple: false, }); }; - match value { - SubstitutionValue::Single(item) => { - map.insert(key.name, serde_json::to_value(item)?); - } - SubstitutionValue::Multiple(items) => { - map.insert(key.name, serde_json::to_value(items)?); - } - }; + map.insert(key.name, serde_json::to_value(value)?); } let variables = serde_json::to_value(map)?; @@ -138,185 +152,47 @@ pub fn prepare_request( })) } -#[derive(Debug, Clone)] -pub enum SubstitutionValue { - Single(T), - Multiple(Vec), -} - pub fn substitute( input: &str, - vars: &HashMap>, + vars: &HashMap, ) -> anyhow::Result> { - let mut output = String::new(); - - for line in input.lines() { - let res = match substitute_line(line, vars)? { - Complete(l) => l, - m @ ValueMissing { .. } => return Ok(m), - }; - output.push_str(&res); - output.push('\n'); - } - - Ok(Complete(output)) -} - -fn substitute_line( - line: &str, - vars: &HashMap>, -) -> anyhow::Result> { - let mut output = String::new(); - let mut slice = line; - loop { - match slice.find("{{") { - None => { - if slice.contains("}}") { - bail!("Syntax error"); - } - output.push_str(slice); - break; - } - Some(pos) => { - output.push_str(&slice[..pos]); - slice = &slice[pos..]; - - let Some(end) = slice.find("}}").map(|i| i + 2) else { - bail!("Syntax error"); - }; - - let rep = match substitute_inner(&slice[2..end - 2], vars) { - Complete(v) => v, - m @ ValueMissing { .. } => return Ok(m), - }; - - // Nested substitution - let rep = match substitute_line(&rep, vars)? { - Complete(v) => v, - m @ ValueMissing { .. } => return Ok(m), - }; - output.push_str(&rep); - - slice = &slice[end..]; - } - } - } - - Ok(Complete(output)) -} - -#[derive(Debug)] -struct Pair { - open: String, - close: String, -} - -#[derive(Debug)] -struct ListSyntax { - separator: String, - pair: Option, -} - -fn parse_list_syntax(s: &str) -> anyhow::Result { - let first_open = s.find('[').context("Invalid list syntax")?; - let first_close = s[first_open..] - .find(']') - .map(|i| i + first_open) - .context("Invalid list syntax")?; - - let separator = &s[first_open + 1..first_close]; - - let Some(second_open) = s[first_close..].find('[').map(|i| i + first_close) - else { - return Ok(ListSyntax { - separator: separator.to_string(), - pair: None, - }); - }; - - let second_close = s[second_open..] - .find(']') - .map(|i| i + second_open) - .context("Invalid list syntax")?; - - let Some(third_open) = - s[second_close..].find('[').map(|i| i + second_close) - else { - return Ok(ListSyntax { - separator: separator.to_string(), - pair: Some(Pair { - open: s[second_open + 1..second_close].to_string(), - close: s[second_open + 1..second_close].to_string(), - }), - }); - }; - - let third_close = s[third_open..] - .find(']') - .map(|i| i + third_open) - .context("Invalid list syntax")?; - - Ok(ListSyntax { - separator: separator.to_string(), - pair: Some(Pair { - open: s[second_open + 1..second_close].to_string(), - close: s[third_open + 1..third_close].to_string(), - }), - }) -} - -fn substitute_inner( - inner: &str, - vars: &HashMap>, -) -> Substitution { - let mut parts = inner.split('|'); - - // Only valid with ascii_alphabetic, ascii_digit or underscores in key name - let valid_character = |c: &char| -> bool { - c.is_ascii_alphabetic() || c.is_ascii_digit() || *c == '_' - }; - - let key = parts.next().unwrap_or("").trim(); - let parsed_key = key - .chars() - .skip_while(|c| !valid_character(c)) - .take_while(valid_character) - .collect::(); - - let fallback = parts.next().map(str::trim); - - let list_syntax = parse_list_syntax(key); - - let substitution = vars.get(&parsed_key).map(|v| match v { - SubstitutionValue::Single(s) => key.replace(&parsed_key, s), - SubstitutionValue::Multiple(list) => { - let syntax = list_syntax.as_ref().unwrap(); - - let start = inner.find(parsed_key.as_str()).unwrap(); - let end = inner.rfind(']').unwrap(); - let joined = list - .iter() - .map(|s| { - if let Some(pair) = &syntax.pair { - format!("{}{}{}", pair.open, s, pair.close) - } else { - s.to_string() - } - }) - .collect::>() - .join(&syntax.separator); - - key.replace(&inner[start..=end], &joined) + MISSING.set(None); + MULTIPLE.set(false); + FALLBACK.set(None); + let ctx = TrackingContext { vars: vars.clone() }; + + let mut env = Environment::new(); + env.set_undefined_behavior(UndefinedBehavior::Strict); + env.set_keep_trailing_newline(true); + env.add_filter("select_multiple", |v: Value| { + MULTIPLE.set(true); + v + }); + env.add_filter("select_one", |v: Value| { + MULTIPLE.set(false); + v + }); + env.add_filter("fallback", |v: Value, fallback: String| { + if v.is_undefined() { + FALLBACK.set(Some(fallback)); } + v }); - match substitution { - Some(s) => Complete(s), - None => ValueMissing { - key: parsed_key, - fallback: fallback.map(ToString::to_string), - multiple: list_syntax.is_ok(), - }, + let ctx_val = Value::from_object(ctx); + + match env.render_str(input, ctx_val) { + Ok(output) => Ok(Complete(output)), + Err(e) => { + if let Some(key) = MISSING.take() { + return Ok(ValueMissing { + key, + multiple: MULTIPLE.take(), + fallback: FALLBACK.take(), + }); + } + Err(e.into()) + } } } @@ -324,35 +200,26 @@ fn substitute_inner( mod tests { use super::*; - fn create_vars() -> HashMap> { + fn create_vars() -> HashMap { let mut vars = HashMap::new(); + vars.insert("url".to_string(), Value::from("example.com")); + vars.insert("token".to_string(), Value::from("abc123")); + vars.insert("integer".to_string(), Value::from(42i64)); + vars.insert("api_url1".to_string(), Value::from("foo.com")); vars.insert( - "url".to_string(), - SubstitutionValue::Single("example.com".to_string()), - ); - vars.insert( - "token".to_string(), - SubstitutionValue::Single("abc123".to_string()), - ); - vars.insert( - "integer".to_string(), - SubstitutionValue::Single("42".to_string()), - ); - vars.insert( - "api_url1".to_string(), - SubstitutionValue::Single("foo.com".to_string()), - ); - vars.insert( - "nested".to_string(), - SubstitutionValue::Single("the answer is {{integer}}".to_string()), + "list".to_string(), + Value::from(vec![ + Value::from("1"), + Value::from("2"), + Value::from("3"), + ]), ); vars.insert( - "list".to_string(), - SubstitutionValue::Multiple(vec![ - "1".to_string(), - "2".to_string(), - "3".to_string(), + "label".to_string(), + Value::from_serialize(vec![ + serde_json::json!({"value": "bug", "name": "bug"}), + serde_json::json!({"value": "docs", "name": "documentation"}), ]), ); @@ -380,13 +247,15 @@ mod tests { let vars = create_vars(); let res = substitute("foo={{integer}}", &vars).unwrap(); - assert_eq!(res, Complete("foo=42\n".to_string())); + assert_eq!(res, Complete("foo=42".to_string())); } #[test] fn substitutes_placeholder_with_default_value() { let vars = create_vars(); - let res = substitute("foo: {{url | fallback.com}}\n", &vars).unwrap(); + let res = + substitute("foo: {{ url | fallback('fallback.com') }}\n", &vars) + .unwrap(); assert_eq!(res, Complete("foo: example.com\n".to_string())); } @@ -394,7 +263,9 @@ mod tests { #[test] fn substitutes_default_value() { let vars = create_vars(); - let res = substitute("foo: {{href | fallback.com }}\n", &vars).unwrap(); + let res = + substitute("foo: {{ href | fallback('fallback.com') }}\n", &vars) + .unwrap(); assert_eq!( res, @@ -407,26 +278,22 @@ mod tests { } #[test] - fn substitutes_default_value_multiple() { + fn returns_value_missing_for_missing_variable() { let vars = create_vars(); - let res = substitute( - r#"foo: {{href | "fallback.com", "foobar.com" }}\n"#, - &vars, - ) - .unwrap(); + let res = substitute("foo: {{ href }}\n", &vars).unwrap(); assert_eq!( res, ValueMissing { key: "href".to_string(), - fallback: Some("\"fallback.com\", \"foobar.com\"".to_string()), + fallback: None, multiple: false, } ); } #[test] - fn substitutes_single_variable_with_speces() { + fn substitutes_single_variable_with_spaces() { let vars = create_vars(); let res = substitute("foo {{ url }}\nbar\n", &vars).unwrap(); @@ -449,173 +316,113 @@ mod tests { assert_eq!(res, Complete("foo example.com, bar abc123\n".to_string())); } - #[test] - fn substitutes_nested_variable() { - let vars = create_vars(); - let res = substitute("# {{nested}}!\n", &vars).unwrap(); - - assert_eq!(res, Complete("# the answer is 42!\n".to_string())); - } - - #[test] - fn substitutes_only_characters_inside_quotes() { - let vars = create_vars(); - let res = substitute("foo: {{ \"integer\" }}", &vars).unwrap(); - - assert_eq!(res, Complete("foo: \"42\"\n".to_string())); - } - - #[test] - fn substitutes_only_characters_inside_list() { - let vars = create_vars(); - let res = substitute("foo: {{ [url] }}", &vars).unwrap(); - - assert_eq!(res, Complete("foo: [example.com]\n".to_string())); - } - - #[test] - fn substitutes_only_characters_inside_list_inside_quotes() { - let vars = create_vars(); - let res = substitute("foo: {{ [\"url\"] }}", &vars).unwrap(); - - assert_eq!(res, Complete("foo: [\"example.com\"]\n".to_string())); - } - - #[test] - fn substitutes_variable_on_the_same_line_in_list() { - let vars = create_vars(); - let res = substitute("foo: [{{ \"url\" }}, {{ \"integer\" }}]", &vars) - .unwrap(); - - assert_eq!( - res, - Complete("foo: [\"example.com\", \"42\"]\n".to_string()) - ); - } - - #[test] - fn substitutes_only_numbers_inside_quote() { - let vars = create_vars(); - let res = substitute("foo: {{ \"integer\" }}", &vars).unwrap(); - - assert_eq!(res, Complete("foo: \"42\"\n".to_string())); - } - #[test] fn substitutes_variable_with_underscore_and_number_in_name() { let vars = create_vars(); let res = substitute("foo: {{ api_url1 }}", &vars).unwrap(); - assert_eq!(res, Complete("foo: foo.com\n".to_string())); + assert_eq!(res, Complete("foo: foo.com".to_string())); } #[test] - fn fails_for_unmatched_open() { + fn substitutes_list_joined() { let vars = create_vars(); - let res = substitute("foo {{url\n", &vars); + let res = substitute("foo: {{ list | join('') }}", &vars).unwrap(); - assert!(res.is_err()); - } - - #[test] - fn fails_for_unmatched_close() { - let vars = create_vars(); - let res = substitute("foo url}} bar\n", &vars); - - assert!(res.is_err()); - } - - #[test] - fn substitutes_list() { - let vars = create_vars(); - let res = substitute("foo: {{ list[] }}", &vars).unwrap(); - - assert_eq!(res, Complete("foo: 123\n".to_string())); + assert_eq!(res, Complete("foo: 123".to_string())); } #[test] fn substitutes_comma_separated_list() { let vars = create_vars(); - let res = substitute("foo: [ {{ list [, ] }} ]", &vars).unwrap(); + let res = + substitute("foo: [ {{ list | join(', ') }} ]", &vars).unwrap(); - assert_eq!(res, Complete("foo: [ 1, 2, 3 ]\n".to_string())); + assert_eq!(res, Complete("foo: [ 1, 2, 3 ]".to_string())); } #[test] - fn substitutes_list_multi_char_separator() { + fn substitutes_list_quoted_join() { let vars = create_vars(); - let res = substitute("foo: {{ list [>>, <<]}}", &vars).unwrap(); + let res = + substitute(r#"foo: ["{{ list | join('", "') }}"]"#, &vars).unwrap(); - assert_eq!(res, Complete("foo: 1>>, <<2>>, <<3\n".to_string())); + assert_eq!(res, Complete(r#"foo: ["1", "2", "3"]"#.to_string())); } #[test] - fn substitutes_list_custom_open_pair() { + fn substitutes_list_of_objects() { let vars = create_vars(); - let res = substitute("foo: {{ list [:] ['] }}", &vars).unwrap(); + let res = substitute( + r#"{% for l in label %}"{{ l.value }}"{% if not loop.last %}, {% endif %}{% endfor %}"#, + &vars, + ) + .unwrap(); - assert_eq!(res, Complete("foo: '1':'2':'3'\n".to_string())); + assert_eq!(res, Complete(r#""bug", "docs""#.to_string())); } #[test] - fn substitutes_list_custom_open_and_close_pair() { + fn returns_value_missing_when_var_missing_but_other_has_default() { let vars = create_vars(); - let res = - substitute("foo: {{ list [ - ] [<<][>>] }}", &vars).unwrap(); + let res = substitute("{{ url | default('x') }} {{ missing }}", &vars) + .unwrap(); - assert_eq!(res, Complete("foo: <<1>> - <<2>> - <<3>>\n".to_string())); + assert_eq!( + res, + ValueMissing { + key: "missing".to_string(), + multiple: false, + fallback: None, + } + ); } #[test] - fn substitutes_list_default_value_multiple() { + fn returns_multiple_true_when_select_multiple_filter_used() { let vars = create_vars(); - let res = substitute( - "foo: {{ missing_list [ - ] [<<][>>] | 9 8 7 }}", - &vars, - ) - .unwrap(); + let res = substitute("{{ missing | select_multiple }}", &vars).unwrap(); assert_eq!( res, ValueMissing { - key: "missing_list".to_string(), - fallback: Some("9 8 7".to_string()), + key: "missing".to_string(), multiple: true, + fallback: None, } ); } #[test] - fn substitutes_list_default_value_multiple_with_separator() { + fn returns_fallback_when_fallback_filter_used() { let vars = create_vars(); - let res = substitute( - "foo: [ {{ missing_list [ - ] [<<][>>] | \"9\", \"8\", \"7\" }} ]", - &vars, - ) - .unwrap(); + let res = + substitute("{{ href | fallback('fallback.com') }}", &vars).unwrap(); assert_eq!( res, ValueMissing { - key: "missing_list".to_string(), - fallback: Some("\"9\", \"8\", \"7\"".to_string()), - multiple: true, + key: "href".to_string(), + multiple: false, + fallback: Some("fallback.com".to_string()), } ); } #[test] - fn substitutes_list_creates_object() { + fn fallback_filter_is_noop_when_value_present() { let vars = create_vars(); let res = - substitute("foo: {{ list [, ] [{ \"Id\": \"] [\" }] }}", &vars) - .unwrap(); + substitute("{{ url | fallback('fallback.com') }}", &vars).unwrap(); - assert_eq!( - res, - Complete( - "foo: { \"Id\": \"1\" }, { \"Id\": \"2\" }, { \"Id\": \"3\" }\n".to_string() - ) - ); + assert_eq!(res, Complete("example.com".to_string())); + } + + #[test] + fn fails_for_template_syntax_error() { + let vars = create_vars(); + let res = substitute("{% if %}", &vars); + + assert!(res.is_err()); } } diff --git a/src/ui/app.rs b/src/ui/app.rs index 777c568..8a2f887 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -33,9 +33,9 @@ use hitman::{ substitute::{ prepare_request, Substitution::{Complete, ValueMissing}, - SubstitutionValue, }, }; +use minijinja::Value as JinjaValue; use super::{ centered, @@ -46,12 +46,17 @@ use super::{ output::{HttpRequestInfo, RequestStatus}, progress::Progress, prompt::SimplePrompt, - select::{ - PromptSelectItem, RequestSelector, Select, SelectIntent, SelectItem, - }, + select::{RequestSelector, Select, SelectIntent, SelectItem}, Component, InteractiveComponent, PromptComponent, PromptIntent, }; +// Used by UI layer to track whether a single or multiple values were selected +#[derive(Debug, Clone)] +pub enum SubstitutionValue { + Single(T), + Multiple(Vec), +} + pub trait Screen { type B: Backend; @@ -80,7 +85,7 @@ pub enum AppState { PendingValue { file_path: String, key: String, - pending_vars: HashMap>, + pending_vars: HashMap, component: Box, }, @@ -106,12 +111,12 @@ pub enum Intent { PreviewRequest(Option), PrepareRequest { file_path: String, - vars: HashMap>, + vars: HashMap, }, AskForValue { key: String, file_path: String, - pending_vars: HashMap>, + pending_vars: HashMap, params: AskForValueParams, }, SendRequest { @@ -321,7 +326,7 @@ impl App { fn try_request( &self, file_path: String, - mut vars: HashMap>, + mut vars: HashMap, ) -> Result> { // FIXME Call resolve_path and load_env once, and keep result in state @@ -342,7 +347,7 @@ impl App { match scope.lookup(&key)? { Replacement::Value(value) => { - vars.insert(key, SubstitutionValue::Single(value)); + vars.insert(key, JinjaValue::from(value)); Some(Intent::PrepareRequest { file_path, vars }) } Replacement::MultipleValuesFound { key, values } => { @@ -561,14 +566,13 @@ impl InteractiveComponent for App { PromptIntent::Abort => { return Some(Abort); } - PromptIntent::Accept(s) => match s { - SubstitutionValue::Single(s) => { - return Some(AcceptNewRequest(s)) - } - SubstitutionValue::Multiple(_) => { - unreachable!("Can never accept multiple requests") - } - }, + PromptIntent::Accept(v) => { + let s = v + .as_str() + .unwrap_or_default() + .to_string(); + return Some(AcceptNewRequest(s)); + } } } } @@ -606,7 +610,7 @@ impl InteractiveComponent for App { fn convert_prompt_intent( key: &str, file_path: &str, - pending_vars: &HashMap>, + pending_vars: &HashMap, intent: PromptIntent, ) -> Intent { match intent { @@ -790,16 +794,3 @@ impl SelectItem for Value { } } } - -impl PromptSelectItem for Value { - fn to_value(&self) -> String { - match self { - Self::Table(t) => match t.get("value") { - Some(Self::String(value)) => value.clone(), - Some(value) => value.to_string(), - _ => t.to_string(), - }, - other => other.to_string(), - } - } -} diff --git a/src/ui/datepicker.rs b/src/ui/datepicker.rs index 07e2524..ea6ed4d 100644 --- a/src/ui/datepicker.rs +++ b/src/ui/datepicker.rs @@ -1,5 +1,5 @@ use chrono::{Datelike, Days, Local, Months, NaiveDate, Weekday}; -use hitman::substitute::SubstitutionValue; +use minijinja::Value as JinjaValue; use ratatui::{ layout::{Constraint, Layout}, prelude::{Alignment::Center, Margin}, @@ -153,9 +153,10 @@ impl PromptComponent for DatePicker { self.selected.checked_add_days(Days::new(1)).unwrap(); } KeyMapping::Accept => { - return Some(PromptIntent::Accept(SubstitutionValue::Single( - format!("{}", self.selected), - ))); + return Some(PromptIntent::Accept(JinjaValue::from(format!( + "{}", + self.selected + )))); } KeyMapping::Abort => { return Some(PromptIntent::Abort); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ea735d7..d7449ef 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,5 +1,5 @@ use crossterm::event::Event; -use hitman::substitute::SubstitutionValue; +use minijinja::Value as JinjaValue; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, Frame, @@ -26,7 +26,7 @@ pub trait InteractiveComponent: Component { pub enum PromptIntent { Abort, - Accept(SubstitutionValue), + Accept(JinjaValue), } pub trait PromptComponent: Component { diff --git a/src/ui/output.rs b/src/ui/output.rs index 876922c..310d602 100644 --- a/src/ui/output.rs +++ b/src/ui/output.rs @@ -58,7 +58,7 @@ pub enum Content { #[default] Empty, Preview(String), - Request(HttpRequestInfo), + Request(Box), } pub struct OutputView { @@ -91,7 +91,7 @@ impl OutputView { } self.scroll = (0, 0); - self.content = Content::Request(info); + self.content = Content::Request(Box::new(info)); } pub fn reset(&mut self) { @@ -131,7 +131,7 @@ impl OutputView { s } - fn make_lines(&self) -> Vec { + fn make_lines(&self) -> Vec> { let mut lines: Vec = Vec::new(); match &self.content { @@ -272,7 +272,7 @@ impl SyntaxHighlighter { } } - fn lines(&self) -> Option> { + fn lines(&self) -> Option>> { self.cache.as_ref().map(|lines| { lines .iter() diff --git a/src/ui/prompt.rs b/src/ui/prompt.rs index fae8ee6..a8fdb8f 100644 --- a/src/ui/prompt.rs +++ b/src/ui/prompt.rs @@ -1,5 +1,5 @@ use crossterm::event::Event; -use hitman::substitute::SubstitutionValue; +use minijinja::Value as JinjaValue; use ratatui::{ layout::Rect, style::Stylize, @@ -37,14 +37,12 @@ impl SimplePrompt { } } - fn value(&self) -> SubstitutionValue { + fn value(&self) -> JinjaValue { let input_value = self.input.value().to_string(); if input_value.is_empty() { - SubstitutionValue::Single( - self.fallback.clone().unwrap_or(input_value), - ) + JinjaValue::from(self.fallback.clone().unwrap_or(input_value)) } else { - SubstitutionValue::Single(input_value) + JinjaValue::from(input_value) } } } diff --git a/src/ui/select.rs b/src/ui/select.rs index e922d25..93f8829 100644 --- a/src/ui/select.rs +++ b/src/ui/select.rs @@ -1,6 +1,6 @@ use crossterm::event::Event; use fuzzy_matcher::skim::SkimMatcherV2; -use hitman::substitute::SubstitutionValue; +use minijinja::Value as JinjaValue; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Style, Stylize}, @@ -14,6 +14,8 @@ use ratatui::{ }; use tui_input::{backend::crossterm::EventHandler, Input}; +use crate::ui::app::SubstitutionValue; + use super::{ keymap::{mapkey, KeyMapping}, Component, InteractiveComponent, PromptComponent, PromptIntent, @@ -92,10 +94,6 @@ pub trait SelectItem { fn text(&self) -> String; } -pub trait PromptSelectItem: SelectItem { - fn to_value(&self) -> String; -} - impl SelectItem for String { fn text(&self) -> String { self.clone() @@ -378,7 +376,16 @@ impl PromptComponent for Select { fn handle_prompt(&mut self, event: &Event) -> Option { self.handle_event(event).and_then(|intent| match intent { SelectIntent::Abort => Some(PromptIntent::Abort), - SelectIntent::Accept(item) => Some(PromptIntent::Accept(item)), + SelectIntent::Accept(item) => match item { + SubstitutionValue::Single(s) => { + Some(PromptIntent::Accept(JinjaValue::from(s))) + } + SubstitutionValue::Multiple(ss) => { + let items: Vec = + ss.into_iter().map(JinjaValue::from).collect(); + Some(PromptIntent::Accept(JinjaValue::from(items))) + } + }, SelectIntent::Change(_) => None, }) } @@ -389,15 +396,15 @@ impl PromptComponent for Select { self.handle_event(event).and_then(|intent| match intent { SelectIntent::Abort => Some(PromptIntent::Abort), SelectIntent::Accept(item) => match item { - SubstitutionValue::Single(v) => Some(PromptIntent::Accept( - SubstitutionValue::Single(v.to_value()), - )), + SubstitutionValue::Single(v) => { + Some(PromptIntent::Accept(JinjaValue::from_serialize(&v))) + } SubstitutionValue::Multiple(vs) => { - Some(PromptIntent::Accept(SubstitutionValue::Multiple( - vs.into_iter() - .map(|v| v.to_value()) - .collect::>(), - ))) + let items: Vec = vs + .into_iter() + .map(|v| JinjaValue::from_serialize(&v)) + .collect(); + Some(PromptIntent::Accept(JinjaValue::from(items))) } }, SelectIntent::Change(_) => None,