Repository: mitsuhiko/minijinja (language: rust)

Issue:
Allow minijinja-cli to read from a config file for options
Hi 👋🏼 

I was curious if you would find any benefit to use a config file in addition to args for minijinja-cli.

My use-case is I always want these args set `--env --trim-blocks --lstrip-blocks --autoescape=none` but having them be read from a config file instead would be awesome to I don't need to continue putting them in the CLI command.

So instead of running `minijinja-cli --env --trim-blocks --lstrip-blocks --autoescape=none ...` I could do the below:

## Purposed CLI

`minijinja-cli --config minininja.toml ...`

Note: `--config` could be defaulted to `minininja.toml` if found so it could be even simpler by just running `minijinja-cli ...` 

## Purposed Config file

```toml
[minijinja]
env = true
trim_blocks = true
lstrip_blocks = true
autoescape = "none"
```

Thanks for this very useful tool!


Discussion / hints:

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 735eab13..780cf14f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -648,6 +648,7 @@ dependencies = [
  "anstyle",
  "clap_lex",
  "once_cell",
+ "terminal_size",
 ]
 
 [[package]]
@@ -1704,6 +1705,12 @@ version = "0.1.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f051f77a7c8e6957c0696eac88f26b0117e54f52d3fc682ab19397a8812846a4"
 
+[[package]]
+name = "linux-raw-sys"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519"
+
 [[package]]
 name = "linux-raw-sys"
 version = "0.4.14"
@@ -1925,6 +1932,7 @@ dependencies = [
  "clap_mangen",
  "configparser",
  "dunce",
+ "home",
  "insta",
  "insta-cmd",
  "minijinja",
@@ -2692,6 +2700,20 @@ dependencies = [
  "windows-sys 0.42.0",
 ]
 
+[[package]]
+name = "rustix"
+version = "0.37.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fea8ca367a3a01fe35e6943c400addf443c0f57670e6ec51196f71a4b8762dd2"
+dependencies = [
+ "bitflags 1.3.2",
+ "errno 0.3.9",
+ "io-lifetimes",
+ "libc",
+ "linux-raw-sys 0.3.8",
+ "windows-sys 0.48.0",
+]
+
 [[package]]
 name = "rustix"
 version = "0.38.34"
@@ -3117,6 +3139,16 @@ dependencies = [
  "winapi-util",
 ]
 
+[[package]]
+name = "terminal_size"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e6bf6f19e9f8ed8d4048dc22981458ebcf406d67e94cd422e5ecd73d63b3237"
+dependencies = [
+ "rustix 0.37.27",
+ "windows-sys 0.48.0",
+]
+
 [[package]]
 name = "thiserror"
 version = "1.0.38"
diff --git a/Makefile b/Makefile
index 65a2b4a8..98779171 100644
--- a/Makefile
+++ b/Makefile
@@ -20,7 +20,7 @@ test-msrv:
 	@cd minijinja; cargo test --all-features
 
 .PHONY: test
-test: test-msrv
+test: test-msrv test-cli
 	@echo "CARGO TEST MINIJINJA-CONTRIB ALL FEATURES"
 	@cd minijinja-contrib; cargo test --all-features
 
diff --git a/minijinja-cli/Cargo.toml b/minijinja-cli/Cargo.toml
index af8e6afa..afd75744 100644
--- a/minijinja-cli/Cargo.toml
+++ b/minijinja-cli/Cargo.toml
@@ -23,6 +23,7 @@ completions = ["clap_complete", "clap_complete_nushell", "clap_complete_fig"]
 unicode = ["minijinja/unicode"]
 ini = ["configparser"]
 contrib = ["minijinja-contrib"]
+toml = ["dep:toml", "home"]
 
 [dependencies]
 anyhow = "1.0.74"
@@ -31,6 +32,7 @@ clap = { version = "4.3.21", default-features = false, features = [
     "std",
     "cargo",
     "help",
+    "wrap_help",
     "usage",
     "error-context",
 ] }
@@ -46,7 +48,7 @@ minijinja = { version = "=2.3.1", path = "../minijinja", features = [
 ] }
 minijinja-contrib = { version = "=2.3.1", optional = true, path = "../minijinja-contrib", features = ["pycompat", "datetime", "timezone", "rand"] }
 rustyline = { version = "12.0.0", optional = true }
-serde = "1.0.183"
+serde = { version = "1.0.183", features = ["derive", "rc"] }
 serde_json = "1.0.105"
 serde_json5 = { version = "0.1.0", optional = true }
 serde_qs = { version = "0.12.0", optional = true }
@@ -57,6 +59,7 @@ clap_complete = { version = "4", optional = true }
 clap_complete_fig = { version = "4", optional = true }
 clap_complete_nushell = { version = "4", optional = true }
 configparser = { version = "3.1.0", optional = true }
+home = { version = "0.5.5", optional = true }
 
 [build-dependencies]
 clap = { version = "4.3.21", default-features = false, features = [
diff --git a/minijinja-cli/README.md b/minijinja-cli/README.md
index 550ef003..f4bc90c1 100644
--- a/minijinja-cli/README.md
+++ b/minijinja-cli/README.md
@@ -45,7 +45,7 @@ You can also install it with [Homebrew](https://brew.sh/)
 brew install minijinja-cli
 ```
 
-## Arguments
+## Arguments and Options
 
 `minijinja-cli` has two positional arguments to refer to files.  Either one of them can
 be set to `-` to read from stdin.  This is the default for the template, but only one
@@ -60,68 +60,8 @@ can be set to stdin at once.
     When data is read from `stdin`, `--format` must be specified as auto detection
     is based on file extensions.
 
-## Options
-
-- `-f`, `--format` `<FORMAT>`:
-    this defines the input format of the data file.  The default is `auto` which
-    turns on auto detection based on the file extension.  For the supported formats
-    see the next section.
-- `-a`, `--autoescape` `<MODE>`:
-    picks an auto escape mode.  The default is auto detection (`auto`) based on
-    file extension.  The options are `none` to disable escaping, `html` to
-    enable HTML/XML escaping, `json` to enable JSON (YAML compatible)
-    serialization.
-- `-D`, `--define` `<EXPR>`:
-    defines a variable from an expression.  The supported formats are `NAME` to define
-    the variable `NAME` with the value `true`, `NAME=VALUE` to define the variable
-    `NAME` with the value `VALUE` as string or `NAME:=VALUE` to set the variable `NAME`
-    to the YAML interpreted value `VALUE`.  When YAML support is not enabled, `:=`
-    only supports JSON.
-- `--strict`:
-    enables strict mode.  Undefined variables will then error upon rendering.
-- `--no-include`:
-    disallows including or extending of templates from the file system.
-- `--no-newline`:
-    Do not output a trailing newline
-- `--trim-blocks`:
-    Enable the trim_blocks flag
-- `--lstrip-blocks`:
-    Enable the lstrip_blocks flag
-- `--py-compat`:
-    Enables improved Python compatibility.  Enabling this adds methods such as
-    `dict.keys` and some others.
-- `-s`, `--syntax <PAIR>`:
-    Changes a syntax feature (feature=value) [possible features: `block-start`, `block-end`, `variable-start`, `variable-end`, `comment-start`, `comment-end`, `line-statement-prefix`, `line-statement-comment`]
-- `--safe-path <PATH>`:
-    Only allow includes from this path. Can be used multiple times.
-- `--env`:
-    passes the environment variables to the template in the variable `ENV`
-- `-E`, `--expr` `<EXPR>`:
-    rather than rendering a template, evaluates an expression instead.  What happens
-    with the result is determined by `--expr-out`.
-- `--expr-out` `<MODE>`:
-    sets the expression output mode.  The default is `print`.  `print` just prints
-    the expression output, `json` emits it as JSON serialized value and
-    `status` hides the output but reports it as exit status.  `true` converts to `0`
-    and `false` converts to `1`.  Numeric results are returned unchanged.
-- `--fuel` `<AMOUNT>`:
-    sets the maximum fuel for the engine.  When the engine runs out of fuel it will error.
-- `--repl`:
-    spawns an interactive read-eval print loop for MiniJinja expressions.
-- `--dump` `<KIND>`:
-    prints internals of the template.  Possible options are `tokens` to see the output
-    of the tokenizer, `ast` to see the AST after parsing, and `instructions` to inspect
-    the compiled bytecode.
-- `-o`, `--output` `<FILENAME>`:
-    writes the output to a filename rather than stdout.
-- `--select` `<SELECTOR>`:
-    select a path of the input data.
-- `--generate-completion` `<SHELL>`:
-    generate the completions for the given shell.
-- `--version`:
-    prints the version.
-- `--help`:
-    prints the help.
+MiniJinja supports a wide range of options, too long to mention here.  For the full help
+use `--long-help` or `--help` for a brief summary.
 
 ## Formats
 
@@ -146,6 +86,15 @@ minijinja-cli template.j2 input.ini --section default
 Note that not all formats support all input types.  For instance querystring
 and INI will only support strings for the most part.
 
+## Config File
+
+The config file is in TOML format.  By default the file in `~/.minijinja.toml` is loaded
+but an alternative path can be supplied with the `--config-file` command line argument
+or the `MINIJINJA_CONFIG_FILE` environment variable.
+
+To see what the config file looks like, invoke `minijinja-cli --print-config` which will
+print out the current loaded config as TOML (including defaults).
+
 ## Selecting
 
 By default the input file is fed directly as context.  You can however also
@@ -216,7 +165,7 @@ By default all features are enabled.  The following features can be explicitly
 selected when the defaults are turned off:
 
 * `yaml`: enables YAML support
-* `toml`: enables TOML support
+* `toml`: enables TOML support (required for `--config-file` support)
 * `cbor`: enables CBOR support
 * `json5`: enables JSON5 support (instead of JSON)
 * `querystring`: enables querystring support
diff --git a/minijinja-cli/build.rs b/minijinja-cli/build.rs
index d1429dc1..496e8189 100644
--- a/minijinja-cli/build.rs
+++ b/minijinja-cli/build.rs
@@ -1,7 +1,7 @@
 use std::fs::create_dir_all;
 
 pub mod cli {
-    include!("src/cli.rs");
+    include!("src/command.rs");
 }
 
 fn main() -> std::io::Result<()> {
diff --git a/minijinja-cli/src/cli.rs b/minijinja-cli/src/cli.rs
index 9f560dda..0fbca1fa 100644
--- a/minijinja-cli/src/cli.rs
+++ b/minijinja-cli/src/cli.rs
@@ -1,73 +1,419 @@
-use std::path::PathBuf;
-
-use clap::{arg, command, value_parser, ArgAction, Command};
-
-pub(super) fn make_command() -> Command {
-    command!()
-        .args([
-            arg!(-f --format <FORMAT> "the format of the input data")
-                .value_parser([
-                    "auto",
-                    "json",
-                    #[cfg(feature = "querystring")]
-                    "querystring",
-                    #[cfg(feature = "yaml")]
-                    "yaml",
-                    #[cfg(feature = "toml")]
-                    "toml",
-                    #[cfg(feature = "cbor")]
-                    "cbor",
-                ])
-                .default_value("auto"),
-            arg!(-a --autoescape <MODE> "reconfigures autoescape behavior")
-                .value_parser(["auto", "html", "json", "none"])
-                .default_value("auto"),
-            arg!(-D --define <EXPR> "defines an input variable (key=value)")
-                .action(ArgAction::Append),
-            arg!(--strict "disallow undefined variables in templates"),
-            arg!(--"no-include" "Disallow includes and extending"),
-            arg!(--"no-newline" "Do not output a trailing newline"),
-            arg!(--"trim-blocks" "Enable the trim_blocks flag"),
-            arg!(--"lstrip-blocks" "Enable the lstrip_blocks flag"),
-            #[cfg(feature = "contrib")]
-            arg!(--"py-compat" "Enables improved Python compatibility.  Enabling \
-                this adds methods such as dict.keys and some others."),
-            arg!(-s --syntax <PAIR>... "Changes a syntax feature (feature=value) \
-                [possible features: block-start, block-end, variable-start, variable-end, \
-                comment-start, comment-end, line-statement-prefix, \
-                line-statement-comment]"),
-            arg!(--"safe-path" <PATH>... "Only allow includes from this path. Can be used multiple times.")
-                .conflicts_with("no-include")
-                .value_parser(value_parser!(PathBuf)),
-            arg!(--env "Pass environment variables as ENV to the template"),
-            arg!(-E --expr <EXPR> "Evaluates an expression instead"),
-            arg!(--"expr-out" <MODE> "Sets the expression output mode")
-                .value_parser(["print", "json", "json-pretty", "status"])
-                .default_value("print")
-                .requires("expr"),
-            arg!(--fuel <AMOUNT> "configures the maximum fuel").value_parser(value_parser!(u64)),
-            arg!(--dump <KIND> "dump internals of a template").value_parser(["instructions", "ast", "tokens"]),
-            #[cfg(feature = "repl")]
-            arg!(--repl "starts the repl with the given data")
-                .conflicts_with_all(["expr", "template"]),
-            #[cfg(feature = "completions")]
-            arg!(--"generate-completion" <SHELL> "generate a completion script for the given shell")
-                .value_parser([
-                    "bash",
-                    "elvish",
-                    "fig",
-                    "fish",
-                    "nushell",
-                    "powershell",
-                    "zsh",
-                ]),
-            arg!(-o --output <FILENAME> "path to the output file")
-                .default_value("-")
-                .value_parser(value_parser!(PathBuf)),
-            arg!(--select <SELECTOR> "select a path of the input data"),
-            arg!(template: [TEMPLATE] "path to the input template").default_value("-"),
-            arg!(data: [DATA] "path to the data file").value_parser(value_parser!(PathBuf)),
-        ])
-        .about("minijinja-cli is a command line tool to render or evaluate jinja2 templates.")
-        .after_help("For more information see https://github.com/mitsuhiko/minijinja/tree/main/minijinja-cli/README.md")
+use std::borrow::Cow;
+use std::collections::BTreeMap;
+use std::io::Write;
+use std::path::{Path, PathBuf};
+use std::sync::Mutex;
+use std::{fs, io};
+
+use anyhow::{bail, Context, Error};
+use clap::ArgMatches;
+use minijinja::machinery::{
+    get_compiled_template, parse, tokenize, Instructions, WhitespaceConfig,
+};
+use minijinja::{context, Environment, Error as MError, ErrorKind, Value};
+use serde::Deserialize;
+
+#[cfg(not(feature = "json5"))]
+use serde_json as preferred_json;
+#[cfg(feature = "json5")]
+use serde_json5 as preferred_json;
+
+use crate::command::{make_command, SUPPORTED_FORMATS};
+use crate::config::Config;
+use crate::output::{Output, STDIN_STDOUT};
+
+fn load_config(matches: &ArgMatches) -> Result<Config, Error> {
+    #[allow(unused_mut)]
+    let mut config = None::<Config>;
+    #[cfg(feature = "toml")]
+    {
+        let config_path = if let Some(path) = matches.get_one::<PathBuf>("config-file") {
+            Some(Cow::Borrowed(path.as_path()))
+        } else if let Some(var) = std::env::var_os("MINIJINJA_CONFIG_FILE") {
+            Some(Cow::Owned(PathBuf::from(var)))
+        } else {
+            home::home_dir().map(|home_dir| Cow::Owned(home_dir.join(".minijinja.toml")))
+        };
+
+        if let Some(config_path) = config_path {
+            if config_path.is_file() {
+                config = Some(
+                    Config::load_from_toml(&config_path)
+                        .with_context(|| format!("unable to load '{}'", config_path.display()))?,
+                );
+            }
+        }
+    }
+    let mut config = config.unwrap_or_default();
+    config.update_from_env()?;
+    config.update_from_matches(matches)?;
+    Ok(config)
+}
+
+fn detect_format_from_path(path: &Path) -> Result<&'static str, Error> {
+    if let Some(ext) = path.extension().and_then(|x| x.to_str()) {
+        for (fmt, _, exts) in SUPPORTED_FORMATS {
+            if exts.contains(&ext.to_ascii_lowercase().as_str()) {
+                return Ok(fmt);
+            }
+        }
+    }
+    bail!("cannot auto detect format from extension");
+}
+
+fn load_data(
+    format: &str,
+    path: &Path,
+    selector: Option<&str>,
+) -> Result<(BTreeMap<String, Value>, bool), Error> {
+    let (contents, stdin_used) = if path == Path::new(STDIN_STDOUT) {
+        (
+            io::read_to_string(io::stdin()).context("unable to read data from stdin")?,
+            true,
+        )
+    } else {
+        (
+            fs::read_to_string(path)
+                .with_context(|| format!("unable to read data file '{}'", path.display()))?,
+            false,
+        )
+    };
+    let format = if format == "auto" {
+        if stdin_used {
+            bail!("auto detection does not work with data from stdin");
+        } else {
+            detect_format_from_path(path)?
+        }
+    } else {
+        format
+    };
+
+    let mut data: Value = match format {
+        "json" => preferred_json::from_str(&contents)?,
+        #[cfg(feature = "querystring")]
+        "querystring" => Value::from(serde_qs::from_str::<BTreeMap<String, Value>>(&contents)?),
+        #[cfg(feature = "yaml")]
+        "yaml" => {
+            // for merge keys to work we need to manually call `apply_merge`.
+            // For this reason we need to deserialize into a serde_yml::Value
+            // before converting it into a final value.
+            let mut v: serde_yml::Value = serde_yml::from_str(&contents)?;
+            v.apply_merge()?;
+            Value::from_serialize(v)
+        }
+        #[cfg(feature = "toml")]
+        "toml" => toml::from_str(&contents)?,
+        #[cfg(feature = "cbor")]
+        "cbor" => ciborium::from_reader(contents.as_bytes())?,
+        #[cfg(feature = "ini")]
+        "ini" => {
+            let mut config = configparser::ini::Ini::new();
+            config
+                .read(contents)
+                .map_err(|msg| anyhow::anyhow!("could not load ini: {}", msg))?;
+            Value::from_serialize(config.get_map_ref())
+        }
+        other => bail!("Unknown format '{}'", other),
+    };
+
+    if let Some(selector) = selector {
+        for part in selector.split('.') {
+            data = if let Ok(idx) = part.parse::<usize>() {
+                data.get_item_by_index(idx)
+            } else {
+                data.get_attr(part)
+            }
+            .with_context(|| {
+                format!(
+                    "unable to select {:?} in {:?} (value was {})",
+                    part,
+                    selector,
+                    data.kind()
+                )
+            })?
+            .clone();
+        }
+    }
+
+    Ok((
+        Deserialize::deserialize(data).context("failed to interpret input data as object")?,
+        stdin_used,
+    ))
+}
+
+fn create_env(
+    config: &Config,
+    cwd: PathBuf,
+    template_name: &str,
+    stdin_used_for_data: bool,
+) -> Result<Environment<'static>, Error> {
+    let mut env = Environment::new();
+    env.set_debug(true);
+    config.apply_to_env(&mut env)?;
+
+    env.set_path_join_callback(move |name, parent| {
+        let p = if parent == STDIN_STDOUT {
+            cwd.join(name)
+        } else {
+            Path::new(parent)
+                .parent()
+                .unwrap_or(Path::new(""))
+                .join(name)
+        };
+        dunce::canonicalize(&p)
+            .unwrap_or(p)
+            .to_string_lossy()
+            .to_string()
+            .into()
+    });
+
+    let cached_stdin = Mutex::new(None);
+    let safe_paths = config.safe_paths();
+    let allow_include = config.allow_include();
+    let template_name = template_name.to_string();
+    env.set_loader(move |name| -> Result<Option<String>, MError> {
+        if !allow_include && name != template_name {
+            return Ok(None);
+        }
+
+        if name == STDIN_STDOUT {
+            if stdin_used_for_data {
+                return Err(MError::new(
+                    ErrorKind::InvalidOperation,
+                    "cannot load template from stdin when data is from stdin",
+                ));
+            }
+
+            let mut stdin = cached_stdin.lock().unwrap();
+            if stdin.is_none() {
+                *stdin = Some(io::read_to_string(io::stdin()).map_err(|err| {
+                    MError::new(
+                        ErrorKind::InvalidOperation,
+                        "failed to read template from stdin",
+                    )
+                    .with_source(err)
+                })?);
+            }
+            return Ok(stdin.clone());
+        }
+
+        let fs_name = Path::new(name);
+        if !safe_paths.is_empty() && !safe_paths.iter().any(|x| fs_name.starts_with(x)) {
+            return Err(MError::new(
+                ErrorKind::InvalidOperation,
+                "Cannot include template from non-trusted path",
+            ));
+        }
+
+        match fs::read_to_string(name) {
+            Ok(contents) => Ok(Some(contents)),
+            Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
+            Err(err) => Err(
+                MError::new(ErrorKind::TemplateNotFound, "cannot find template").with_source(err),
+            ),
+        }
+    });
+
+    Ok(env)
+}
+
+#[cfg(feature = "completions")]
+fn generate_completions(shell: &str) -> Result<i32, Error> {
+    macro_rules! gen {
+        ($shell:expr) => {
+            clap_complete::generate(
+                $shell,
+                &mut make_command(),
+                "minijinja-cli",
+                &mut std::io::stdout(),
+            )
+        };
+    }
+
+    match shell {
+        "bash" => gen!(clap_complete::Shell::Bash),
+        "zsh" => gen!(clap_complete::Shell::Zsh),
+        "elvish" => gen!(clap_complete::Shell::Elvish),
+        "fish" => gen!(clap_complete::Shell::Fish),
+        "powershell" => gen!(clap_complete::Shell::PowerShell),
+        "nushell" => gen!(clap_complete_nushell::Nushell),
+        "fig" => gen!(clap_complete_fig::Fig),
+        _ => unreachable!(),
+    };
+
+    Ok(0)
+}
+
+fn dump_info(
+    dump: &str,
+    env: &Environment<'_>,
+    template: &str,
+    output: &mut Output,
+    config: &Config,
+) -> Result<(), Error> {
+    match dump {
+        "ast" => {
+            let tmpl = env.get_template(template)?;
+            writeln!(
+                output,
+                "{:#?}",
+                parse(
+                    tmpl.source(),
+                    tmpl.name(),
+                    Default::default(),
+                    Default::default()
+                )?
+            )?;
+        }
+        "tokens" => {
+            let tmpl = env.get_template(template)?;
+            let tokens: Result<Vec<_>, _> = tokenize(
+                tmpl.source(),
+                false,
+                Default::default(),
+                WhitespaceConfig {
+                    lstrip_blocks: config.lstrip_blocks(),
+                    trim_blocks: config.trim_blocks(),
+                    ..Default::default()
+                },
+            )
+            .collect();
+            for (token, _) in tokens? {
+                writeln!(output, "{:?}", token)?;
+            }
+        }
+        "instructions" => {
+            let tmpl = env.get_template(template)?;
+            let ctmpl = get_compiled_template(&tmpl);
+            for (block_name, instructions) in ctmpl.blocks.iter() {
+                print_instructions(output, instructions, block_name)?;
+            }
+            print_instructions(output, &ctmpl.instructions, "<root>")?;
+        }
+        _ => unreachable!(),
+    }
+    Ok(())
+}
+
+fn print_instructions(
+    output: &mut Output,
+    instructions: &Instructions,
+    block_name: &str,
+) -> Result<(), Error> {
+    writeln!(output, "Block: {block_name:?}")?;
+    for idx in 0.. {
+        if let Some(instruction) = instructions.get(idx) {
+            writeln!(output, "  {idx:4}: {instruction:?}")?;
+        } else {
+            break;
+        }
+    }
+    Ok(())
+}
+
+fn print_expr_out(rv: Value, config: &Config, output: &mut Output) -> Result<i32, Error> {
+    match config.expr_out() {
+        "print" => writeln!(output, "{}", rv)?,
+        "json" => writeln!(output, "{}", serde_json::to_string(&rv)?)?,
+        "json-pretty" => writeln!(output, "{}", serde_json::to_string_pretty(&rv)?)?,
+        "status" => {
+            return Ok(if let Ok(n) = i32::try_from(rv.clone()) {
+                n
+            } else if rv.is_true() {
+                0
+            } else {
+                1
+            });
+        }
+        other => bail!("unknown expr-out '{}'", other),
+    }
+    Ok(0)
+}
+
+pub fn print_error(err: &Error) {
+    eprintln!("error: {err}");
+    if let Some(err) = err.downcast_ref::<MError>() {
+        if err.name().is_some() {
+            eprintln!("{}", err.display_debug_info());
+        }
+    }
+    let mut source_opt = err.source();
+    while let Some(source) = source_opt {
+        eprintln!();
+        eprintln!("caused by: {source}");
+        if let Some(source) = source.downcast_ref::<MError>() {
+            if source.name().is_some() {
+                eprintln!("{}", source.display_debug_info());
+            }
+        }
+        source_opt = source.source();
+    }
+}
+
+#[cfg(feature = "toml")]
+fn print_config(config: &Config) -> Result<i32, Error> {
+    let out = toml::to_string_pretty(config)?;
+    println!("{}", out);
+    Ok(0)
+}
+
+pub fn execute() -> Result<i32, Error> {
+    let matches = make_command().get_matches();
+    let config = load_config(&matches)?;
+
+    #[cfg(feature = "completions")]
+    {
+        if let Some(shell) = matches.get_one::<String>("generate-completion") {
+            return generate_completions(shell);
+        }
+    }
+    #[cfg(feature = "toml")]
+    {
+        if matches.get_flag("print-config") {
+            return print_config(&config);
+        }
+    }
+
+    let (base_ctx, stdin_used) = if let Some(data) = matches.get_one::<PathBuf>("data") {
+        load_data(
+            config.format(),
+            data,
+            matches.get_one::<String>("select").map(|x| x.as_str()),
+        )?
+    } else {
+        (Default::default(), false)
+    };
+
+    let cwd = std::env::current_dir()?;
+    let ctx = context!(..config.defines(), ..base_ctx);
+    let template_name = match matches.get_one::<String>("template").unwrap().as_str() {
+        STDIN_STDOUT => Cow::Borrowed(STDIN_STDOUT),
+        rel_name => Cow::Owned(cwd.join(rel_name).to_string_lossy().to_string()),
+    };
+    let mut output = Output::new(matches.get_one::<PathBuf>("output").unwrap())?;
+
+    let env = create_env(&config, cwd, &template_name, stdin_used)?;
+    let mut exit_code = 0;
+
+    if let Some(expr) = matches.get_one::<String>("expr") {
+        let rv = env.compile_expression(expr)?.eval(ctx)?;
+        exit_code = print_expr_out(rv, &config, &mut output)?;
+    } else if let Some(dump) = matches.get_one::<String>("dump") {
+        dump_info(dump, &env, &template_name, &mut output, &config)?;
+    } else if cfg!(feature = "repl") && matches.get_flag("repl") {
+        #[cfg(feature = "repl")]
+        {
+            crate::repl::run(env, ctx)?;
+        }
+    } else {
+        let result = env.get_template(&template_name)?.render(ctx)?;
+        if !config.newline() {
+            write!(&mut output, "{result}")?;
+        } else {
+            writeln!(&mut output, "{result}")?;
+        }
+    }
+
+    output.commit()?;
+    Ok(exit_code)
 }
diff --git a/minijinja-cli/src/command.rs b/minijinja-cli/src/command.rs
new file mode 100644
index 00000000..9b63a094
--- /dev/null
+++ b/minijinja-cli/src/command.rs
@@ -0,0 +1,349 @@
+/// This module defines the command-line interface for the CLI.
+/// It is separated into its own file because it is used both by the main
+/// application and by build.rs to generate shell completions.
+use std::path::PathBuf;
+
+use clap::{arg, command, value_parser, ArgAction, Command};
+
+const ADVANCED: &str = "Advanced";
+const BEHAVIOR: &str = "Template Behavior";
+const SECURITY: &str = "Security";
+
+/// Supported formats
+pub static SUPPORTED_FORMATS: &[(&str, &str, &[&str])] = &[
+    #[cfg(feature = "cbor")]
+    ("cbor", "CBOR", &["cbor"]),
+    #[cfg(feature = "ini")]
+    ("ini", "INI / Config", &["ini", "config", "properties"]),
+    #[cfg(not(feature = "json5"))]
+    ("json", "JSON", &["json"]),
+    #[cfg(feature = "json5")]
+    ("json", "JSON / JSON5", &["json", "json5"]),
+    #[cfg(feature = "querystring")]
+    ("querystring", "Query String / Form Encoded", &["qs"]),
+    #[cfg(feature = "toml")]
+    ("toml", "TOML", &["toml"]),
+    #[cfg(feature = "yaml")]
+    ("yaml", "YAML 1.2", &["yaml", "yml"]),
+];
+
+fn format_formats(s: &str) -> String {
+    use std::fmt::Write;
+    let mut formats = String::new();
+
+    for (fmt, title, exts) in SUPPORTED_FORMATS.iter() {
+        write!(formats, "- {} ({}): ", fmt, title).ok();
+        for (idx, ext) in exts.iter().enumerate() {
+            if idx > 0 {
+                formats.push_str(", ");
+            }
+            formats.push_str("*.");
+            formats.push_str(ext);
+        }
+        formats.push('\n');
+    }
+
+    s.replace("###FORMATS###", &formats)
+}
+
+pub(super) fn make_command() -> Command {
+    command!()
+        .disable_help_flag(true)
+        .max_term_width(120)
+        .args([
+            #[cfg(feature = "toml")]
+            arg!(--"config-file" <PATH> "Alternative path to the config file.")
+                .value_parser(value_parser!(PathBuf))
+                .long_help("\
+                    Sets an alternative path to the config file.  By default the config file \
+                    is loaded from $HOME/.minijinja.toml.\n\n\
+                    \
+                    To see the possible config values use --print-config which will print the \
+                    current state of the config.\n\n\
+                    [env var: MINIJINJA_CONFIG_FILE]
+                    "),
+            arg!(-f --format <FORMAT> "The format of the input data")
+                .long_help(format_formats("\
+                    Sets the format of the input data.\n\n\
+                    \
+                    The following formats are supported (and the default detected file extensions):\n\n\
+                    - auto\n\
+                    ###FORMATS###\n\
+                    Auto detection (auto) is unavailable when stdin is used as input format.\n\n\
+                    \
+                    For most formats the mapping is pretty straight forward as you expect.  The \
+                    only format worth calling out is INI where the unnamed section is always \
+                    called 'default' instead (in contrast to TOML which leaves it toplevel).\n\n\
+                    \
+                    [env var: MINIJINJA_FORMAT]"))
+                .value_parser([
+                    "auto",
+                    "json",
+                    #[cfg(feature = "querystring")]
+                    "querystring",
+                    #[cfg(feature = "yaml")]
+                    "yaml",
+                    #[cfg(feature = "toml")]
+                    "toml",
+                    #[cfg(feature = "cbor")]
+                    "cbor",
+                ]),
+            arg!(-a --autoescape <MODE> "Reconfigures autoescape behavior")
+                .long_help("\
+                    Reconfigures autoescape behavior.  The default is 'auto' which means that \
+                    the file extension sets the auto escaping mode.\n\n\
+                    \
+                    html means that variables are escaped to HTML5 and XML rules.  json means \
+                    that output is safe for both JSON and YAML rules (eg: strings are formatted \
+                    as JSON strings etc.).  none disables escaping entirely.\n\n\
+                    \
+                    [env var: MINIJINJA_AUTOESCAPE]")
+                .value_parser(["auto", "html", "json", "none"])
+                .help_heading(BEHAVIOR),
+            arg!(-D --define <EXPR> "Defines an input variable (key=value / key:=json_value)")
+                .long_help("\
+                    This defines an input variable for the template.  This is used in addition \
+                    to the input data file.  It supports three forms: key defines a single bool, \
+                    key=value defines a string value, key:=json_value defines a JSON/YAML value.  \
+                    The latter is useful to define strings, integers or simple array literals.  \
+                    It can be supplied multiple times to set more than one value.\n\n\
+                    \
+                    Examples:\n\
+                    -D name=Peter       defines a basic string\n\
+                    -D user_id:=42      defines an integer\n\
+                    -D is_active:=true  defines a boolean\n\
+                    -D is_true          shortform to define true boolean")
+                .action(ArgAction::Append),
+            arg!(--strict "Disallow undefined variables in templates")
+                .long_help("\
+                    Disallow undefined variables in templates instead of rendering empty strings.\n\n\
+                    \
+                    By default a template will allow a singular undefined access.  This means that \
+                    for instance an unknown attribute to an object will render an empty string.  To \
+                    disable that you can use the strict mode in which case all undefined attributes \
+                    will error instead.\n\n\
+                    \
+                    [env var: MINIJINJA_STRICT]")
+                .help_heading(BEHAVIOR),
+            arg!(--"no-include" "Disallow includes and extending")
+                .long_help("\
+                    Disallow includes and extending for security reasons.\n\n\
+                    \
+                    When this is enabled all inclusions and template extension features are disabled \
+                    entirely.  An alternative to disabling includes is to use the --safe-path feature \
+                    which allows white listing individual folders instead.\n\n\
+                    \
+                    [env var: MINIJINJA_INCLUDE]")
+                .help_heading(SECURITY),
+            arg!(--"safe-path" <PATH>... "Only allow includes from this path")
+                .long_help("\
+                    Only allow includes from this path.\n\n\
+                    \
+                    This can be used to better control where includes and layout extensions can load \
+                    templates from.  This can be supplied multiple times.\n\n\
+                    \
+                    When the environment variable is used to control this, use ':' to split multiple \
+                    paths on Unix and ';' on Windows (analog to the PATH environment variable).\n\n\
+                    \
+                    [env var: MINIJINJA_SAFE_PATH]
+                    ")
+                .conflicts_with("no-include")
+                .value_parser(value_parser!(PathBuf))
+                .help_heading(SECURITY),
+            arg!(--fuel <AMOUNT> "Configures the maximum fuel")
+                .long_help("\
+                    Sets the maximum fuel a template can consume.\n\n\
+                    \
+                    When fuel is set, every instruction consumes a certain amount of fuel. Usually 1, \
+                    some will consume no fuel. By default the engine has the fuel feature disabled (0). \
+                    To turn on fuel set something like 50000 which will allow 50.000 instructions to \
+                    execute before running out of fuel.\n\n\
+                    \
+                    This is useful as a basic security feature in CI pipelines or similar.\n\n\
+                    \
+                    [env var: MINIJINJA_FUEL]")
+                .value_parser(value_parser!(u64))
+                .help_heading(SECURITY),
+            arg!(-n --"no-newline" "Do not output a trailing newline")
+                .long_help("\
+                    Do not output a trailing newline after template evaluation.\n\n\
+                    \
+                    By default minijinja-cli will render a trailing newline when rendering.  This \
+                    flag can be used to disable that.\n\n\
+                    \
+                    [env var: MINIJINJA_NEWLINE]")
+                .help_heading(BEHAVIOR),
+            arg!(--"trim-blocks" "Enable the trim-blocks flag")
+                .long_help("\
+                    Enable the trim-blocks flag.\n\n\
+                    \
+                    This flag controls the trim-blocks template syntax feature.  When enabled trailing \
+                    whitespace including one newline is removed after a block tag.\n\n\
+                    \
+                    [env var: MINIJINJA_TRIM_BLOCKS]")
+                .help_heading(BEHAVIOR),
+            arg!(--"lstrip-blocks" "Enable the lstrip-blocks flag")
+                .long_help("\
+                    Enable the lstrip-blocks flag.\n\n\
+                    \
+                    This flag controls the lstrip-blocks template syntax feature.  When enabled leading \
+                    whitespace is removed before a block tag.\n\n\
+                    \
+                    [env var: MINIJINJA_LSTRIP_BLOCKS]")
+                .help_heading(BEHAVIOR),
+            #[cfg(feature = "contrib")]
+            arg!(--"py-compat" "Enables improved Python compatibility")
+                .long_help("\
+                    Enables improved Python compatibility for templates.\n\n\
+                    \
+                    Enabling this adds methods such as dict.keys and some common others.  This is useful \
+                    when rendering templates that should be shared with Jinja2.\n\n\
+                    \
+                    [env var: MINIJINJA_PY_COMPAT]")
+                .help_heading(BEHAVIOR),
+            arg!(-s --syntax <PAIR>... "Changes a syntax feature (feature=value) \
+                [possible features: block-start, block-end, variable-start, variable-end, \
+                comment-start, comment-end, line-statement-prefix, \
+                line-statement-comment]")
+                .long_help("\
+                    Changes a syntax feature.\n\n\
+                    \
+                    This allows reconfiguring syntax delimiters.  The flag can be provided multiple \
+                    times.  Each time it's feature=value where feature is the name of the syntax \
+                    delimiter to change.  The following list is the full list of syntax features \
+                    that can be reconfigured and the default value:\n\n\
+                    \
+                    block-start={%\n\
+                    block-end=%}\n\
+                    variable-start={{\n\
+                    variable-end=}}\n\
+                    comment-start={#\n\
+                    comment-end=%}\n\
+                    line-statement-prefix=\n\
+                    line-statement-comment=\n\n\
+                    \
+                    Example: -sline-statement-prefix='#' -svariable-start='${' -svariable-end='}'\n\n\
+                    \
+                    For environment variable usage split multiple config strings with whitespace.\n\n\
+                    \
+                    [env var: MINIJINJA_SYNTAX]")
+                .help_heading(BEHAVIOR),
+            arg!(--env "Pass environment variables as ENV to the template")
+                .long_help("\
+                    Pass environment variables to the template and make them available under the ENV \
+                    variable within the template.\n\n\
+                    \
+                    [env var: MINIJINJA_ENV]")
+                .help_heading(BEHAVIOR),
+            arg!(-E --expr <EXPR> "Evaluates an template expression")
+                .long_help("\
+                    Evalues a template expression instead of rendering a template.\n\n\
+                    \
+                    The value to the parameter is a template expression that is evaluated with the \
+                    context of the template and the result is emitted according to --expr-out.  The \
+                    default output mode is to print the result of the expression to stdout.\n\n\
+                    \
+                    Example: minijinja-cli --expr='1 < 10'")
+                .help_heading(ADVANCED),
+            arg!(--"expr-out" <MODE> "The expression output mode")
+                .long_help("\
+                    Sets the expression output mode for --expr.\n\n\
+                    \
+                    This defaults to 'print' which means that the expression's result is written to \
+                    stdout.  'json' (and 'json-pretty') does mostly the same but writes the result as \
+                    JSON result instead with one as a one-liner, the second in prett printing. 'status' \
+                    exits the program with the result as a status code.  If the result is not a number it \
+                    will first convert the result into a bool and then exits as 0 if it was true, 1 \
+                    otherwise.\n\n\
+                    \
+                    [env var: MINIJINJA_EXPR_OUT]")
+                .value_parser(["print", "json", "json-pretty", "status"])
+                .requires("expr")
+                .help_heading(ADVANCED),
+            arg!(--dump <KIND> "Dump internals of a template")
+                .long_help("\
+                    Dump internals of a template to stdout.\n\n\
+                    \
+                    This feature is primarily useful to debug what is going on in a MiniJinja tempalte. \
+                    'instructions' will dump out the bytecode that the engine generated, 'ast' dumps out \
+                    the AST in a text only format and 'tokens' will print a line per token of the template \
+                    after lexing.")
+                .value_parser(["instructions", "ast", "tokens"])
+                .help_heading(ADVANCED),
+            #[cfg(feature = "repl")]
+            arg!(--repl "Starts the repl with the given data")
+                .long_help("\
+                    Starts the read-eval loop with the given input data.\n\n\
+                    \
+                    This allows basic experimentation of MiniJinja expressions with some input data.")
+                .conflicts_with_all(["expr", "template"])
+                .help_heading(ADVANCED),
+            arg!(-o --output <FILENAME> "Path to the output file")
+                .long_help("\
+                    Path to the output file instead of stdout.\n\n\
+                    \
+                    By default templates will be rendered to stdout, but this can be used to directly write \
+                    into a target file instead.  The --no-newline flag can be used to disable the printing \
+                    of the trailing newline.  Files will be written atomically.  This means that if template \
+                    evaluation fails the original file remains.")
+                .default_value("-")
+                .value_parser(value_parser!(PathBuf)),
+            arg!(--select <SELECTOR> "Select a subset of the input data")
+                .long_help("\
+                    Select a subset of the input data with a path expression.\n\n\
+                    \
+                    By default the input file is fed directly as context.  You can however also select a \
+                    sub-section of this file.  For instance if you have a TOML file where all variables \
+                    are placed in the values section you normally need to reference the values like so:\n\n\
+                    \
+                    {{ values.key }}\n\n\
+                    \
+                    If you however invoke minijinja-cli with --select=values you can directly reference \
+                    the keys:\n\n\
+                    \
+                    {{ key }}\n\n\
+                    \
+                    You can use dotted paths to select into sub sections (eg: --select=values.0.box)."),
+            arg!(--"print-config" "Print out the loaded config"),
+            arg!(-h --help "Print short help (short texts)")
+                .action(ArgAction::HelpShort),
+            arg!(--"long-help" "Print long help (extended, long explanation texts)")
+                .action(ArgAction::HelpLong),
+            arg!(template: [TEMPLATE] "Path to the input template")
+                .long_help("\
+                    This is the path to the input template in MiniJinja/Jinja2 syntax.  \
+                    If not provided this defaults to '-' which means the template is \
+                    loaded from stdin.  When the format is set to 'auto' which is the \
+                    default, the extension of the filename is used to detect the format.")
+                .default_value("-"),
+            arg!(data: [DATA] "Path to the data file")
+                .long_help("\
+                    Path to the data file in the given format.\n\n\
+                    \
+                    The data file is used to supply the context (variables) to the template. \
+                    Various file formats are supported.  When data is read from stdin (by using '-' \
+                    as file name), --format must be specified as auto detection is based on \
+                    file extensions.")
+                .value_parser(value_parser!(PathBuf)),
+            #[cfg(feature = "completions")]
+            arg!(--"generate-completion" <SH> "Generate a completion script for the given shell")
+                .long_help("\
+                    Generate a completion script for the given shell and print it to stdout.\n\n\
+                    \
+                    This completion script can be added to your shell startup to provide completions \
+                    for the minijinja-cli command.")
+                .value_parser([
+                    "bash",
+                    "elvish",
+                    "fig",
+                    "fish",
+                    "nushell",
+                    "powershell",
+                    "zsh",
+                ]).help_heading("Shell Support"),
+        ])
+        .before_help("minijinja-cli is a command line tool to render or evaluate jinja2 templates.")
+        .after_help("For extended help use --long-help, for short help --help.")
+        .about("Pass a template and optionally a file with template variables to render it to stdout.")
+        .long_about(include_str!("long_help.txt"))
+}
diff --git a/minijinja-cli/src/config.rs b/minijinja-cli/src/config.rs
new file mode 100644
index 00000000..24e3cc51
--- /dev/null
+++ b/minijinja-cli/src/config.rs
@@ -0,0 +1,350 @@
+use std::collections::BTreeMap;
+use std::env;
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use anyhow::{anyhow, bail, Context, Error};
+use clap::ArgMatches;
+use minijinja::syntax::SyntaxConfig;
+use minijinja::{AutoEscape, Environment, UndefinedBehavior, Value};
+use serde::{Deserialize, Serialize};
+
+/// Overrides specific syntax settings.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct SyntaxElements {
+    block_start: String,
+    block_end: String,
+    variable_start: String,
+    variable_end: String,
+    comment_start: String,
+    comment_end: String,
+    line_statement_prefix: String,
+    line_comment_prefix: String,
+}
+
+impl Default for SyntaxElements {
+    fn default() -> Self {
+        SyntaxElements {
+            block_start: "{%".to_string(),
+            block_end: "%}".to_string(),
+            variable_start: "{{".to_string(),
+            variable_end: "}}".to_string(),
+            comment_start: "{#".to_string(),
+            comment_end: "#}".to_string(),
+            line_statement_prefix: "".to_string(),
+            line_comment_prefix: "".to_string(),
+        }
+    }
+}
+
+/// Holds in-memory config state for the execution.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct Config {
+    format: String,
+    autoescape: String,
+    include: bool,
+    newline: bool,
+    trim_blocks: bool,
+    lstrip_blocks: bool,
+    py_compat: bool,
+    env: bool,
+    strict: bool,
+    safe_paths: Vec<PathBuf>,
+    expr_out: String,
+    fuel: u64,
+    syntax: SyntaxElements,
+    defines: Arc<BTreeMap<String, Value>>,
+}
+
+impl Default for Config {
+    fn default() -> Self {
+        Self {
+            format: "auto".to_string(),
+            autoescape: "auto".to_string(),
+            include: true,
+            newline: true,
+            trim_blocks: false,
+            lstrip_blocks: false,
+            py_compat: false,
+            env: Default::default(),
+            strict: false,
+            defines: Default::default(),
+            syntax: Default::default(),
+            safe_paths: Default::default(),
+            expr_out: "print".to_string(),
+            fuel: 0,
+        }
+    }
+}
+
+impl Config {
+    pub fn update_from_matches(&mut self, matches: &ArgMatches) -> Result<(), Error> {
+        if let Some(format) = matches.get_one::<String>("format") {
+            self.format = format.clone();
+        }
+        if let Some(autoescape) = matches.get_one::<String>("autoescape") {
+            self.autoescape = autoescape.clone();
+        }
+        if let Some(expr_out) = matches.get_one::<String>("expr-out") {
+            self.expr_out = expr_out.clone();
+        }
+        if matches.get_flag("no-include") {
+            self.include = false;
+        }
+        if matches.get_flag("no-newline") {
+            self.newline = false;
+        }
+        if matches.get_flag("trim-blocks") {
+            self.trim_blocks = true;
+        }
+        if matches.get_flag("lstrip-blocks") {
+            self.lstrip_blocks = true;
+        }
+        #[cfg(feature = "contrib")]
+        {
+            if matches.get_flag("py-compat") {
+                self.py_compat = true;
+            }
+        }
+        if matches.get_flag("env") {
+            self.env = true;
+        }
+        if matches.get_flag("strict") {
+            self.strict = true;
+        }
+        if let Some(fuel) = matches.get_one::<u64>("fuel") {
+            if *fuel > 0 {
+                self.fuel = *fuel;
+            }
+        }
+
+        self.safe_paths.extend(
+            matches
+                .get_many::<PathBuf>("safe-path")
+                .unwrap_or_default()
+                .map(|x| x.canonicalize().unwrap_or_else(|_| x.clone())),
+        );
+
+        self.update_syntax_from_matches(matches)?;
+        self.add_defines_from_matches(matches)?;
+        Ok(())
+    }
+
+    #[cfg(feature = "toml")]
+    pub fn load_from_toml(p: &std::path::Path) -> Result<Config, Error> {
+        let contents = std::fs::read_to_string(p)?;
+        let cfg: Config = toml::from_str(&contents)?;
+        Ok(cfg)
+    }
+
+    pub fn update_from_env(&mut self) -> Result<(), Error> {
+        if let Ok(format) = env::var("MINIJINJA_FORMAT") {
+            self.format = format;
+        }
+        if let Ok(autoescape) = env::var("MINIJINJA_AUTOESCAPE") {
+            self.autoescape = autoescape;
+        }
+        if let Ok(include) = env::var("MINIJINJA_INCLUDE") {
+            self.include = parse_env_bool(&include, "MINIJINJA_INCLUDE")?;
+        }
+        if let Ok(newline) = env::var("MINIJINJA_NEWLINE") {
+            self.newline = parse_env_bool(&newline, "MINIJINJA_NEWLINE")?;
+        }
+        if let Ok(trim_blocks) = env::var("MINIJINJA_TRIM_BLOCKS") {
+            self.trim_blocks = parse_env_bool(&trim_blocks, "MINIJINJA_TRIM_BLOCKS")?;
+        }
+        if let Ok(lstrip_blocks) = env::var("MINIJINJA_LSTRIP_BLOCKS") {
+            self.lstrip_blocks = parse_env_bool(&lstrip_blocks, "MINIJINJA_LSTRIP_BLOCKS")?;
+        }
+        if let Ok(py_compat) = env::var("MINIJINJA_PY_COMPAT") {
+            self.py_compat = parse_env_bool(&py_compat, "MINIJINJA_PY_COMPAT")?;
+        }
+        if let Ok(env_flag) = env::var("MINIJINJA_ENV") {
+            self.env = parse_env_bool(&env_flag, "MINIJINJA_ENV")?;
+        }
+        if let Ok(strict) = env::var("MINIJINJA_STRICT") {
+            self.strict = parse_env_bool(&strict, "MINIJINJA_STRICT")?;
+        }
+        if let Ok(expr_out) = env::var("MINIJINJA_EXPR_OUT") {
+            self.expr_out = expr_out;
+        }
+        if let Ok(fuel) = env::var("MINIJINJA_FUEL") {
+            if let Ok(fuel_value) = fuel.parse::<u64>() {
+                if fuel_value > 0 {
+                    self.fuel = fuel_value;
+                }
+            }
+        }
+        if let Ok(safe_paths) = env::var("MINIJINJA_SAFE_PATHS") {
+            self.safe_paths = safe_paths
+                .split(if cfg!(windows) { ';' } else { ':' })
+                .map(PathBuf::from)
+                .collect();
+        }
+        if let Ok(syntax) = env::var("MINIJINJA_SYNTAX") {
+            self.update_syntax_from_pairs(syntax.split_whitespace())?;
+        }
+        Ok(())
+    }
+
+    pub fn allow_include(&self) -> bool {
+        self.include
+    }
+
+    pub fn trim_blocks(&self) -> bool {
+        self.trim_blocks
+    }
+
+    pub fn lstrip_blocks(&self) -> bool {
+        self.lstrip_blocks
+    }
+
+    pub fn newline(&self) -> bool {
+        self.newline
+    }
+
+    pub fn format(&self) -> &str {
+        &self.format
+    }
+
+    pub fn expr_out(&self) -> &str {
+        &self.expr_out
+    }
+
+    pub fn defines(&self) -> Value {
+        Value::from_dyn_object(self.defines.clone())
+    }
+
+    pub fn safe_paths(&self) -> Vec<PathBuf> {
+        self.safe_paths.clone()
+    }
+
+    pub fn apply_to_env(&self, env: &mut Environment) -> Result<(), Error> {
+        if self.env {
+            env.add_global("ENV", Value::from_iter(std::env::vars()));
+        }
+        env.set_trim_blocks(self.trim_blocks);
+        env.set_lstrip_blocks(self.lstrip_blocks);
+        if self.fuel > 0 {
+            env.set_fuel(Some(self.fuel));
+        }
+
+        #[cfg(feature = "contrib")]
+        {
+            minijinja_contrib::add_to_environment(env);
+            if self.py_compat {
+                env.set_unknown_method_callback(
+                    minijinja_contrib::pycompat::unknown_method_callback,
+                );
+            }
+        }
+
+        let autoescape = self.autoescape.clone();
+        env.set_auto_escape_callback(move |name| match autoescape.as_str() {
+            "none" => AutoEscape::None,
+            "html" => AutoEscape::Html,
+            "json" => AutoEscape::Json,
+            "auto" => match name.strip_suffix(".j2").unwrap_or(name).rsplit('.').next() {
+                Some("htm" | "html" | "xml" | "xhtml") => AutoEscape::Html,
+                Some("json" | "json5" | "yml" | "yaml") => AutoEscape::Json,
+                _ => AutoEscape::None,
+            },
+            _ => unreachable!(),
+        });
+        env.set_undefined_behavior(if self.strict {
+            UndefinedBehavior::Strict
+        } else {
+            UndefinedBehavior::Lenient
+        });
+        env.set_syntax(self.make_syntax()?);
+        Ok(())
+    }
+
+    fn make_syntax(&self) -> Result<SyntaxConfig, Error> {
+        let s = &self.syntax;
+        SyntaxConfig::builder()
+            .block_delimiters(s.block_start.clone(), s.block_end.clone())
+            .variable_delimiters(s.variable_start.clone(), s.variable_end.clone())
+            .comment_delimiters(s.comment_start.clone(), s.comment_end.clone())
+            .line_statement_prefix(s.line_statement_prefix.clone())
+            .line_comment_prefix(s.line_comment_prefix.clone())
+            .build()
+            .context("could not configure syntax")
+    }
+
+    fn update_syntax_from_pairs<'a, I>(&mut self, iter: I) -> Result<(), Error>
+    where
+        I: Iterator<Item = &'a str>,
+    {
+        let s = &mut self.syntax;
+
+        for pair in iter {
+            let (key, value) = pair
+                .split_once('=')
+                .ok_or_else(|| anyhow!("syntax feature needs to be a key=value pair"))?;
+
+            *match key {
+                "block-start" => &mut s.block_start,
+                "block-end" => &mut s.block_end,
+                "variable-start" => &mut s.variable_start,
+                "variable-end" => &mut s.variable_end,
+                "comment-start" => &mut s.comment_start,
+                "comment-end" => &mut s.comment_end,
+                "line-statement-prefix" => &mut s.line_statement_prefix,
+                "line-comment-prefix" => &mut s.line_comment_prefix,
+                _ => bail!("unknown syntax feature '{}'", key),
+            } = value.to_string();
+        }
+
+        Ok(())
+    }
+
+    fn update_syntax_from_matches(&mut self, matches: &ArgMatches) -> Result<(), Error> {
+        let mut iter = matches.get_many::<String>("syntax");
+        if let Some(ref mut iter) = iter {
+            self.update_syntax_from_pairs(iter.map(|x| x.as_str()))?;
+        }
+        Ok(())
+    }
+
+    fn add_defines_from_matches(&mut self, matches: &ArgMatches) -> Result<(), Error> {
+        let defines = Arc::make_mut(&mut self.defines);
+        if let Some(items) = matches.get_many::<String>("define") {
+            for item in items {
+                if let Some((key, raw_value)) = item.split_once(":=") {
+                    defines.insert(key.to_string(), interpret_raw_value(raw_value)?);
+                } else if let Some((key, string_value)) = item.split_once('=') {
+                    defines.insert(key.to_string(), Value::from(string_value));
+                } else {
+                    defines.insert(item.to_string(), Value::from(true));
+                }
+            }
+        }
+        Ok(())
+    }
+}
+
+fn interpret_raw_value(s: &str) -> Result<Value, Error> {
+    #[cfg(not(feature = "yaml"))]
+    mod imp {
+        pub use serde_json::from_str;
+        pub const FMT: &str = "JSON/YAML";
+    }
+    #[cfg(feature = "yaml")]
+    mod imp {
+        pub use serde_yml::from_str;
+        pub const FMT: &str = "JSON";
+    }
+    imp::from_str::<Value>(s)
+        .with_context(|| format!("invalid raw value '{}' (not valid {})", s, imp::FMT))
+}
+
+fn parse_env_bool(s: &str, var_name: &str) -> Result<bool, Error> {
+    match s.to_lowercase().as_str() {
+        "0" | "false" | "no" | "off" => Ok(false),
+        "1" | "true" | "yes" | "on" => Ok(true),
+        _ => bail!("Invalid boolean value for {}: {}", var_name, s),
+    }
+}
diff --git a/minijinja-cli/src/long_help.txt b/minijinja-cli/src/long_help.txt
new file mode 100644
index 00000000..f95e64d7
--- /dev/null
+++ b/minijinja-cli/src/long_help.txt
@@ -0,0 +1,13 @@
+Most of the functionality is handled via options, but there are two positional arguments that refer to files.  The first is the path to the template, the second to the data file (template context).  Either one of them can be set to '-' to read from stdin.  Reading from stdin is the default for the template, but only one (template or data file) can be set to stdin at simultaneously.
+
+Various file formats are supported for the template context, the exact formats depend on the features enabled at compilation time.
+
+Configuration is loaded from $HOME/minijinja.toml and environment variables, before being overridden by command line options.  The environment variables are documented with the options that they correspond to.  Note that flags (boolen values) are reconfigured with true/false or 1/0 respectively.  For instance --no-include corresponds to MINIJINJA_INCLUDE=false.  Not all options can be configured from environment variables or config options.
+
+Examples:
+
+    minijinja-cli hello.j2 hello.json
+
+    minijinja-cli -Dvariable=value hello.j2
+
+    minijinja-cli --strict --env hello.j2
\ No newline at end of file
diff --git a/minijinja-cli/src/main.rs b/minijinja-cli/src/main.rs
index bba6c706..4265a591 100644
--- a/minijinja-cli/src/main.rs
+++ b/minijinja-cli/src/main.rs
@@ -1,553 +1,15 @@
-use std::borrow::Cow;
-use std::collections::{BTreeMap, HashMap};
-use std::io::Write;
-use std::path::{Path, PathBuf};
-use std::sync::Mutex;
-use std::{fs, io};
-
-use anyhow::{anyhow, bail, Context, Error};
-use clap::ArgMatches;
-use minijinja::machinery::{
-    get_compiled_template, parse, tokenize, Instructions, WhitespaceConfig,
-};
-use minijinja::syntax::SyntaxConfig;
-use minijinja::{
-    context, AutoEscape, Environment, Error as MError, ErrorKind, UndefinedBehavior, Value,
-};
-use serde::Deserialize;
-
+mod cli;
+mod command;
+mod config;
+mod output;
 #[cfg(feature = "repl")]
 mod repl;
 
-const STDIN_STDOUT: &str = "-";
-mod cli;
-
-#[cfg(not(feature = "json5"))]
-use serde_json as preferred_json;
-#[cfg(feature = "json5")]
-use serde_json5 as preferred_json;
-
-struct Output {
-    temp: Option<(PathBuf, tempfile::NamedTempFile)>,
-}
-
-impl Output {
-    pub fn new(filename: &Path) -> Result<Output, Error> {
-        Ok(Output {
-            temp: if filename == Path::new(STDIN_STDOUT) {
-                None
-            } else {
-                let filename = std::env::current_dir()?.join(filename);
-                let ntf = tempfile::NamedTempFile::new_in(
-                    filename
-                        .parent()
-                        .ok_or_else(|| anyhow!("cannot write to root"))?,
-                )?;
-                Some((filename.to_path_buf(), ntf))
-            },
-        })
-    }
-
-    pub fn commit(&mut self) -> Result<(), Error> {
-        if let Some((filename, temp)) = self.temp.take() {
-            temp.persist(filename)?;
-        }
-        Ok(())
-    }
-}
-
-impl Write for Output {
-    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
-        match self.temp {
-            Some((_, ref mut out)) => out.write(buf),
-            None => std::io::stdout().write(buf),
-        }
-    }
-
-    fn flush(&mut self) -> io::Result<()> {
-        match self.temp {
-            Some((_, ref mut out)) => out.flush(),
-            None => std::io::stdout().flush(),
-        }
-    }
-}
-
-fn load_data(
-    format: &str,
-    path: &Path,
-    selector: Option<&str>,
-) -> Result<(BTreeMap<String, Value>, bool), Error> {
-    let (contents, stdin_used) = if path == Path::new(STDIN_STDOUT) {
-        (
-            io::read_to_string(io::stdin()).context("unable to read data from stdin")?,
-            true,
-        )
-    } else {
-        (
-            fs::read_to_string(path)
-                .with_context(|| format!("unable to read data file '{}'", path.display()))?,
-            false,
-        )
-    };
-    let format = if format == "auto" {
-        if stdin_used {
-            bail!("auto detection does not work with data from stdin");
-        }
-        match path.extension().and_then(|x| x.to_str()) {
-            Some("json") => "json",
-            #[cfg(feature = "json5")]
-            Some("json5") => "json",
-            #[cfg(feature = "querystring")]
-            Some("qs") => "querystring",
-            #[cfg(feature = "yaml")]
-            Some("yaml" | "yml") => "yaml",
-            #[cfg(feature = "toml")]
-            Some("toml") => "toml",
-            #[cfg(feature = "cbor")]
-            Some("cbor") => "cbor",
-            #[cfg(feature = "ini")]
-            Some("ini" | "config" | "properties") => "ini",
-            _ => bail!("cannot auto detect format from extension"),
-        }
-    } else {
-        format
-    };
-
-    let mut data: Value = match format {
-        "json" => preferred_json::from_str(&contents)?,
-        #[cfg(feature = "querystring")]
-        "querystring" => Value::from(serde_qs::from_str::<HashMap<String, Value>>(&contents)?),
-        #[cfg(feature = "yaml")]
-        "yaml" => {
-            // for merge keys to work we need to manually call `apply_merge`.
-            // For this reason we need to deserialize into a serde_yml::Value
-            // before converting it into a final value.
-            let mut v: serde_yml::Value = serde_yml::from_str(&contents)?;
-            v.apply_merge()?;
-            Value::from_serialize(v)
-        }
-        #[cfg(feature = "toml")]
-        "toml" => toml::from_str(&contents)?,
-        #[cfg(feature = "cbor")]
-        "cbor" => ciborium::from_reader(contents.as_bytes())?,
-        #[cfg(feature = "ini")]
-        "ini" => {
-            let mut config = configparser::ini::Ini::new();
-            config
-                .read(contents)
-                .map_err(|msg| anyhow!("could not load ini: {}", msg))?;
-            Value::from_serialize(config.get_map_ref())
-        }
-        _ => unreachable!(),
-    };
-
-    if let Some(selector) = selector {
-        for part in selector.split('.') {
-            data = if let Ok(idx) = part.parse::<usize>() {
-                data.get_item_by_index(idx)
-            } else {
-                data.get_attr(part)
-            }
-            .with_context(|| {
-                format!(
-                    "unable to select {:?} in {:?} (value was {})",
-                    part,
-                    selector,
-                    data.kind()
-                )
-            })?
-            .clone();
-        }
-    }
-
-    Ok((
-        Deserialize::deserialize(data).context("failed to interpret input data as object")?,
-        stdin_used,
-    ))
-}
-
-fn interpret_raw_value(s: &str) -> Result<Value, Error> {
-    #[cfg(not(feature = "yaml"))]
-    mod imp {
-        pub use serde_json::from_str;
-        pub const FMT: &str = "JSON/YAML";
-    }
-    #[cfg(feature = "yaml")]
-    mod imp {
-        pub use serde_yml::from_str;
-        pub const FMT: &str = "JSON";
-    }
-    imp::from_str::<Value>(s)
-        .with_context(|| format!("invalid raw value '{}' (not valid {})", s, imp::FMT))
-}
-
-fn make_syntax(matches: &ArgMatches) -> Result<SyntaxConfig, Error> {
-    let mut iter = matches.get_many::<String>("syntax");
-
-    let mut f_block_start = "{%".to_string();
-    let mut f_block_end = "%}".to_string();
-    let mut f_variable_start = "{{".to_string();
-    let mut f_variable_end = "}}".to_string();
-    let mut f_comment_start = "{#".to_string();
-    let mut f_comment_end = "#}".to_string();
-    let mut f_line_statement_prefix = "".to_string();
-    let mut f_line_comment_prefix = "".to_string();
-
-    if let Some(ref mut iter) = iter {
-        for pair in iter {
-            let (key, value) = pair
-                .split_once('=')
-                .ok_or_else(|| anyhow!("syntax feature needs to be a key=value pair"))?;
-
-            *match key {
-                "block-start" => &mut f_block_start,
-                "block-end" => &mut f_block_end,
-                "variable-start" => &mut f_variable_start,
-                "variable-end" => &mut f_variable_end,
-                "comment-start" => &mut f_comment_start,
-                "comment-end" => &mut f_comment_end,
-                "line-statement-prefix" => &mut f_line_statement_prefix,
-                "line-comment-prefix" => &mut f_line_comment_prefix,
-                _ => bail!("unknown syntax feature '{}'", key),
-            } = value.to_string();
-        }
-    }
-
-    SyntaxConfig::builder()
-        .block_delimiters(f_block_start, f_block_end)
-        .variable_delimiters(f_variable_start, f_variable_end)
-        .comment_delimiters(f_comment_start, f_comment_end)
-        .line_statement_prefix(f_line_statement_prefix)
-        .line_comment_prefix(f_line_comment_prefix)
-        .build()
-        .context("could not configure syntax")
-}
-
-fn create_env(
-    matches: &ArgMatches,
-    cwd: PathBuf,
-    allowed_template: Option<String>,
-    safe_paths: Vec<PathBuf>,
-    stdin_used_for_data: bool,
-    syntax: SyntaxConfig,
-) -> Environment<'static> {
-    let mut env = Environment::new();
-    env.set_debug(true);
-    env.set_syntax(syntax);
-
-    if let Some(fuel) = matches.get_one::<u64>("fuel") {
-        if *fuel > 0 {
-            env.set_fuel(Some(*fuel));
-        }
-    }
-
-    #[cfg(feature = "contrib")]
-    {
-        minijinja_contrib::add_to_environment(&mut env);
-        if matches.get_flag("py-compat") {
-            env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
-        }
-    }
-
-    if matches.get_flag("env") {
-        env.add_global("ENV", Value::from_iter(std::env::vars()));
-    }
-    if matches.get_flag("trim-blocks") {
-        env.set_trim_blocks(true);
-    }
-    if matches.get_flag("lstrip-blocks") {
-        env.set_lstrip_blocks(true);
-    }
-
-    let autoescape = matches.get_one::<String>("autoescape").unwrap().clone();
-    env.set_auto_escape_callback(move |name| match autoescape.as_str() {
-        "none" => AutoEscape::None,
-        "html" => AutoEscape::Html,
-        "json" => AutoEscape::Json,
-        "auto" => match name.strip_suffix(".j2").unwrap_or(name).rsplit('.').next() {
-            Some("htm" | "html" | "xml" | "xhtml") => AutoEscape::Html,
-            Some("json" | "json5" | "yml" | "yaml") => AutoEscape::Json,
-            _ => AutoEscape::None,
-        },
-        _ => unreachable!(),
-    });
-    env.set_undefined_behavior(if matches.get_flag("strict") {
-        UndefinedBehavior::Strict
-    } else {
-        UndefinedBehavior::Lenient
-    });
-    env.set_path_join_callback(move |name, parent| {
-        let p = if parent == STDIN_STDOUT {
-            cwd.join(name)
-        } else {
-            Path::new(parent)
-                .parent()
-                .unwrap_or(Path::new(""))
-                .join(name)
-        };
-        dunce::canonicalize(&p)
-            .unwrap_or(p)
-            .to_string_lossy()
-            .to_string()
-            .into()
-    });
-    let cached_stdin = Mutex::new(None);
-    env.set_loader(move |name| -> Result<Option<String>, MError> {
-        if let Some(ref allowed_template) = allowed_template {
-            if name != allowed_template {
-                return Ok(None);
-            }
-        }
-
-        if name == STDIN_STDOUT {
-            if stdin_used_for_data {
-                return Err(MError::new(
-                    ErrorKind::InvalidOperation,
-                    "cannot load template from stdin when data is from stdin",
-                ));
-            }
-
-            let mut stdin = cached_stdin.lock().unwrap();
-            if stdin.is_none() {
-                *stdin = Some(io::read_to_string(io::stdin()).map_err(|err| {
-                    MError::new(
-                        ErrorKind::InvalidOperation,
-                        "failed to read template from stdin",
-                    )
-                    .with_source(err)
-                })?);
-            }
-            return Ok(stdin.clone());
-        }
-
-        let fs_name = Path::new(name);
-        if !safe_paths.is_empty() && !safe_paths.iter().any(|x| fs_name.starts_with(x)) {
-            return Err(MError::new(
-                ErrorKind::InvalidOperation,
-                "Cannot include template from non-trusted path",
-            ));
-        }
-
-        match fs::read_to_string(name) {
-            Ok(contents) => Ok(Some(contents)),
-            Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
-            Err(err) => Err(
-                MError::new(ErrorKind::TemplateNotFound, "cannot find template").with_source(err),
-            ),
-        }
-    });
-    env
-}
-
-#[cfg(feature = "completions")]
-fn generate_completions(shell: &str) -> Result<i32, Error> {
-    macro_rules! gen {
-        ($shell:expr) => {
-            clap_complete::generate(
-                $shell,
-                &mut cli::make_command(),
-                "minijinja-cli",
-                &mut std::io::stdout(),
-            )
-        };
-    }
-
-    match shell {
-        "bash" => gen!(clap_complete::Shell::Bash),
-        "zsh" => gen!(clap_complete::Shell::Zsh),
-        "elvish" => gen!(clap_complete::Shell::Elvish),
-        "fish" => gen!(clap_complete::Shell::Fish),
-        "powershell" => gen!(clap_complete::Shell::PowerShell),
-        "nushell" => gen!(clap_complete_nushell::Nushell),
-        "fig" => gen!(clap_complete_fig::Fig),
-        _ => unreachable!(),
-    };
-
-    Ok(0)
-}
-
-fn execute() -> Result<i32, Error> {
-    let matches = cli::make_command().get_matches();
-
-    #[cfg(feature = "completions")]
-    {
-        if let Some(shell) = matches.get_one::<String>("generate-completion") {
-            return generate_completions(shell);
-        }
-    }
-
-    let format = matches.get_one::<String>("format").unwrap();
-    let (base, stdin_used) = if let Some(data) = matches.get_one::<PathBuf>("data") {
-        load_data(
-            format,
-            data,
-            matches.get_one::<String>("select").map(|x| x.as_str()),
-        )?
-    } else {
-        (Default::default(), false)
-    };
-
-    let mut defines = BTreeMap::new();
-    if let Some(items) = matches.get_many::<String>("define") {
-        for item in items {
-            if let Some((key, raw_value)) = item.split_once(":=") {
-                defines.insert(key, interpret_raw_value(raw_value)?);
-            } else if let Some((key, string_value)) = item.split_once('=') {
-                defines.insert(key, Value::from(string_value));
-            } else {
-                defines.insert(item, Value::from(true));
-            }
-        }
-    }
-
-    let cwd = std::env::current_dir()?;
-    let ctx = context!(..defines, ..base);
-    let template = match matches.get_one::<String>("template").unwrap().as_str() {
-        STDIN_STDOUT => Cow::Borrowed(STDIN_STDOUT),
-        rel_name => Cow::Owned(cwd.join(rel_name).to_string_lossy().to_string()),
-    };
-    let allowed_template = if matches.get_flag("no-include") {
-        Some(template.to_string())
-    } else {
-        None
-    };
-    let safe_paths = matches
-        .get_many::<PathBuf>("safe-path")
-        .unwrap_or_default()
-        .map(|x| x.canonicalize().unwrap_or_else(|_| x.clone()))
-        .collect::<Vec<_>>();
-    let mut output = Output::new(matches.get_one::<PathBuf>("output").unwrap())?;
-
-    let no_newline = matches.get_flag("no-newline");
-
-    let syntax = make_syntax(&matches)?;
-    let env = create_env(
-        &matches,
-        cwd,
-        allowed_template,
-        safe_paths,
-        stdin_used,
-        syntax,
-    );
-
-    if let Some(expr) = matches.get_one::<String>("expr") {
-        let rv = env.compile_expression(expr)?.eval(ctx)?;
-        match matches.get_one::<String>("expr-out").unwrap().as_str() {
-            "print" => writeln!(&mut output, "{}", rv)?,
-            "json" => writeln!(&mut output, "{}", serde_json::to_string(&rv)?)?,
-            "json-pretty" => writeln!(&mut output, "{}", serde_json::to_string_pretty(&rv)?)?,
-            "status" => {
-                return Ok(if let Ok(n) = i32::try_from(rv.clone()) {
-                    n
-                } else if rv.is_true() {
-                    0
-                } else {
-                    1
-                });
-            }
-            _ => unreachable!(),
-        }
-    } else if let Some(dump) = matches.get_one::<String>("dump") {
-        match dump.as_str() {
-            "ast" => {
-                let tmpl = env.get_template(&template)?;
-                writeln!(
-                    &mut output,
-                    "{:#?}",
-                    parse(
-                        tmpl.source(),
-                        tmpl.name(),
-                        Default::default(),
-                        Default::default()
-                    )?
-                )?;
-            }
-            "tokens" => {
-                let tmpl = env.get_template(&template)?;
-                let tokens: Result<Vec<_>, _> = tokenize(
-                    tmpl.source(),
-                    false,
-                    Default::default(),
-                    WhitespaceConfig {
-                        lstrip_blocks: matches.get_flag("lstrip-blocks"),
-                        trim_blocks: matches.get_flag("trim-blocks"),
-                        ..Default::default()
-                    },
-                )
-                .collect();
-                for (token, _) in tokens? {
-                    writeln!(&mut output, "{:?}", token)?;
-                }
-            }
-            "instructions" => {
-                let tmpl = env.get_template(&template)?;
-                let ctmpl = get_compiled_template(&tmpl);
-                for (block_name, instructions) in ctmpl.blocks.iter() {
-                    print_instructions(&mut output, instructions, block_name)?;
-                }
-                print_instructions(&mut output, &ctmpl.instructions, "<root>")?;
-            }
-            _ => unreachable!(),
-        }
-    } else if cfg!(feature = "repl") && matches.get_flag("repl") {
-        #[cfg(feature = "repl")]
-        {
-            repl::run(env, ctx)?;
-        }
-    } else {
-        let result = env.get_template(&template)?.render(ctx)?;
-        if no_newline {
-            write!(&mut output, "{result}")?;
-        } else {
-            writeln!(&mut output, "{result}")?;
-        }
-    }
-
-    output.commit()?;
-    Ok(0)
-}
-
-fn print_instructions(
-    output: &mut Output,
-    instructions: &Instructions,
-    block_name: &str,
-) -> Result<(), Error> {
-    writeln!(output, "Block: {block_name:?}")?;
-    for idx in 0.. {
-        if let Some(instruction) = instructions.get(idx) {
-            writeln!(output, "  {idx:4}: {instruction:?}")?;
-        } else {
-            break;
-        }
-    }
-    Ok(())
-}
-
-pub fn print_error(err: &Error) {
-    eprintln!("error: {err}");
-    if let Some(err) = err.downcast_ref::<MError>() {
-        if err.name().is_some() {
-            eprintln!("{}", err.display_debug_info());
-        }
-    }
-    let mut source_opt = err.source();
-    while let Some(source) = source_opt {
-        eprintln!();
-        eprintln!("caused by: {source}");
-        if let Some(source) = source.downcast_ref::<MError>() {
-            if source.name().is_some() {
-                eprintln!("{}", source.display_debug_info());
-            }
-        }
-        source_opt = source.source();
-    }
-}
-
 fn main() {
-    match execute() {
+    match cli::execute() {
         Ok(code) => std::process::exit(code),
         Err(err) => {
-            print_error(&err);
+            cli::print_error(&err);
             std::process::exit(1);
         }
     }
diff --git a/minijinja-cli/src/output.rs b/minijinja-cli/src/output.rs
new file mode 100644
index 00000000..9c8b96ee
--- /dev/null
+++ b/minijinja-cli/src/output.rs
@@ -0,0 +1,52 @@
+use std::io::{self, Write};
+use std::path::{Path, PathBuf};
+
+use anyhow::{anyhow, Error};
+use tempfile::NamedTempFile;
+
+pub const STDIN_STDOUT: &str = "-";
+
+pub struct Output {
+    temp: Option<(PathBuf, NamedTempFile)>,
+}
+
+impl Output {
+    pub fn new(filename: &Path) -> Result<Output, Error> {
+        Ok(Output {
+            temp: if filename == Path::new(STDIN_STDOUT) {
+                None
+            } else {
+                let filename = std::env::current_dir()?.join(filename);
+                let ntf = NamedTempFile::new_in(
+                    filename
+                        .parent()
+                        .ok_or_else(|| anyhow!("cannot write to root"))?,
+                )?;
+                Some((filename.to_path_buf(), ntf))
+            },
+        })
+    }
+
+    pub fn commit(&mut self) -> Result<(), Error> {
+        if let Some((filename, temp)) = self.temp.take() {
+            temp.persist(filename)?;
+        }
+        Ok(())
+    }
+}
+
+impl Write for Output {
+    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
+        match self.temp {
+            Some((_, ref mut out)) => out.write(buf),
+            None => std::io::stdout().write(buf),
+        }
+    }
+
+    fn flush(&mut self) -> io::Result<()> {
+        match self.temp {
+            Some((_, ref mut out)) => out.flush(),
+            None => std::io::stdout().flush(),
+        }
+    }
+}
diff --git a/minijinja-cli/src/repl.rs b/minijinja-cli/src/repl.rs
index 0a1366ec..14740bef 100644
--- a/minijinja-cli/src/repl.rs
+++ b/minijinja-cli/src/repl.rs
@@ -6,7 +6,7 @@ use minijinja::{context, Environment, Value};
 use rustyline::error::ReadlineError;
 use rustyline::DefaultEditor;
 
-use crate::print_error;
+use crate::cli::print_error;
 
 pub fn run(mut env: Environment, ctx: Value) -> Result<(), Error> {
     let mut editor = DefaultEditor::new()?;


Test patch:
diff --git a/minijinja-cli/tests/snapshots/test_basic__long_help.snap b/minijinja-cli/tests/snapshots/test_basic__long_help.snap
new file mode 100644
index 00000000..81032825
--- /dev/null
+++ b/minijinja-cli/tests/snapshots/test_basic__long_help.snap
@@ -0,0 +1,301 @@
+---
+source: minijinja-cli/tests/test_basic.rs
+info:
+  program: minijinja-cli
+  args:
+    - "--long-help"
+---
+success: true
+exit_code: 0
+----- stdout -----
+minijinja-cli is a command line tool to render or evaluate jinja2 templates.
+
+Most of the functionality is handled via options, but there are two positional arguments that refer
+to files.  The first is the path to the template, the second to the data file (template context).
+Either one of them can be set to '-' to read from stdin.  Reading from stdin is the default for the
+template, but only one (template or data file) can be set to stdin at simultaneously.
+
+Various file formats are supported for the template context, the exact formats depend on the
+features enabled at compilation time.
+
+Configuration is loaded from $HOME/minijinja.toml and environment variables, before being overridden
+by command line options.  The environment variables are documented with the options that they
+correspond to.  Note that flags (boolen values) are reconfigured with true/false or 1/0
+respectively.  For instance --no-include corresponds to MINIJINJA_INCLUDE=false.  Not all options
+can be configured from environment variables or config options.
+
+Examples:
+
+    minijinja-cli hello.j2 hello.json
+
+    minijinja-cli -Dvariable=value hello.j2
+
+    minijinja-cli --strict --env hello.j2
+
+Usage: minijinja-cli [OPTIONS] [TEMPLATE] [DATA]
+
+Arguments:
+  [TEMPLATE]
+          This is the path to the input template in MiniJinja/Jinja2 syntax.  If not provided this
+          defaults to '-' which means the template is loaded from stdin.  When the format is set to
+          'auto' which is the default, the extension of the filename is used to detect the format.
+          
+          [default: -]
+
+  [DATA]
+          Path to the data file in the given format.
+          
+          The data file is used to supply the context (variables) to the template. Various file
+          formats are supported.  When data is read from stdin (by using '-' as file name), --format
+          must be specified as auto detection is based on file extensions.
+
+Options:
+      --config-file <PATH>
+          Sets an alternative path to the config file.  By default the config file is loaded from
+          $HOME/.minijinja.toml.
+          
+          To see the possible config values use --print-config which will print the current state of
+          the config.
+          
+          [env var: MINIJINJA_CONFIG_FILE]
+
+  -f, --format <FORMAT>
+          Sets the format of the input data.
+          
+          The following formats are supported (and the default detected file extensions):
+          
+          - auto
+          - cbor (CBOR): *.cbor
+          - ini (INI / Config): *.ini, *.config, *.properties
+          - json (JSON / JSON5): *.json, *.json5
+          - querystring (Query String / Form Encoded): *.qs
+          - toml (TOML): *.toml
+          - yaml (YAML 1.2): *.yaml, *.yml
+          
+          Auto detection (auto) is unavailable when stdin is used as input format.
+          
+          For most formats the mapping is pretty straight forward as you expect.  The only format
+          worth calling out is INI where the unnamed section is always called 'default' instead (in
+          contrast to TOML which leaves it toplevel).
+          
+          [env var: MINIJINJA_FORMAT]
+          
+          [possible values: auto, json, querystring, yaml, toml, cbor]
+
+  -D, --define <EXPR>
+          This defines an input variable for the template.  This is used in addition to the input
+          data file.  It supports three forms: key defines a single bool, key=value defines a string
+          value, key:=json_value defines a JSON/YAML value.  The latter is useful to define strings,
+          integers or simple array literals.  It can be supplied multiple times to set more than one
+          value.
+          
+          Examples:
+          -D name=Peter       defines a basic string
+          -D user_id:=42      defines an integer
+          -D is_active:=true  defines a boolean
+          -D is_true          shortform to define true boolean
+
+  -o, --output <FILENAME>
+          Path to the output file instead of stdout.
+          
+          By default templates will be rendered to stdout, but this can be used to directly write
+          into a target file instead.  The --no-newline flag can be used to disable the printing of
+          the trailing newline.  Files will be written atomically.  This means that if template
+          evaluation fails the original file remains.
+          
+          [default: -]
+
+      --select <SELECTOR>
+          Select a subset of the input data with a path expression.
+          
+          By default the input file is fed directly as context.  You can however also select a
+          sub-section of this file.  For instance if you have a TOML file where all variables are
+          placed in the values section you normally need to reference the values like so:
+          
+          {{ values.key }}
+          
+          If you however invoke minijinja-cli with --select=values you can directly reference the
+          keys:
+          
+          {{ key }}
+          
+          You can use dotted paths to select into sub sections (eg: --select=values.0.box).
+
+      --print-config
+          Print out the loaded config
+
+  -h, --help
+          Print short help (short texts)
+
+      --long-help
+          Print long help (extended, long explanation texts)
+
+  -V, --version
+          Print version
+
+Template Behavior:
+  -a, --autoescape <MODE>
+          Reconfigures autoescape behavior.  The default is 'auto' which means that the file
+          extension sets the auto escaping mode.
+          
+          html means that variables are escaped to HTML5 and XML rules.  json means that output is
+          safe for both JSON and YAML rules (eg: strings are formatted as JSON strings etc.).  none
+          disables escaping entirely.
+          
+          [env var: MINIJINJA_AUTOESCAPE]
+          
+          [possible values: auto, html, json, none]
+
+      --strict
+          Disallow undefined variables in templates instead of rendering empty strings.
+          
+          By default a template will allow a singular undefined access.  This means that for
+          instance an unknown attribute to an object will render an empty string.  To disable that
+          you can use the strict mode in which case all undefined attributes will error instead.
+          
+          [env var: MINIJINJA_STRICT]
+
+  -n, --no-newline
+          Do not output a trailing newline after template evaluation.
+          
+          By default minijinja-cli will render a trailing newline when rendering.  This flag can be
+          used to disable that.
+          
+          [env var: MINIJINJA_NEWLINE]
+
+      --trim-blocks
+          Enable the trim-blocks flag.
+          
+          This flag controls the trim-blocks template syntax feature.  When enabled trailing
+          whitespace including one newline is removed after a block tag.
+          
+          [env var: MINIJINJA_TRIM_BLOCKS]
+
+      --lstrip-blocks
+          Enable the lstrip-blocks flag.
+          
+          This flag controls the lstrip-blocks template syntax feature.  When enabled leading
+          whitespace is removed before a block tag.
+          
+          [env var: MINIJINJA_LSTRIP_BLOCKS]
+
+      --py-compat
+          Enables improved Python compatibility for templates.
+          
+          Enabling this adds methods such as dict.keys and some common others.  This is useful when
+          rendering templates that should be shared with Jinja2.
+          
+          [env var: MINIJINJA_PY_COMPAT]
+
+  -s, --syntax <PAIR>
+          Changes a syntax feature.
+          
+          This allows reconfiguring syntax delimiters.  The flag can be provided multiple times.
+          Each time it's feature=value where feature is the name of the syntax delimiter to change.
+          The following list is the full list of syntax features that can be reconfigured and the
+          default value:
+          
+          block-start={%
+          block-end=%}
+          variable-start={{
+          variable-end=}}
+          comment-start={#
+          comment-end=%}
+          line-statement-prefix=
+          line-statement-comment=
+          
+          Example: -sline-statement-prefix='#' -svariable-start='${' -svariable-end='}'
+          
+          For environment variable usage split multiple config strings with whitespace.
+          
+          [env var: MINIJINJA_SYNTAX]
+
+      --env
+          Pass environment variables to the template and make them available under the ENV variable
+          within the template.
+          
+          [env var: MINIJINJA_ENV]
+
+Security:
+      --no-include
+          Disallow includes and extending for security reasons.
+          
+          When this is enabled all inclusions and template extension features are disabled entirely.
+          An alternative to disabling includes is to use the --safe-path feature which allows white
+          listing individual folders instead.
+          
+          [env var: MINIJINJA_INCLUDE]
+
+      --safe-path <PATH>
+          Only allow includes from this path.
+          
+          This can be used to better control where includes and layout extensions can load templates
+          from.  This can be supplied multiple times.
+          
+          When the environment variable is used to control this, use ':' to split multiple paths on
+          Unix and ';' on Windows (analog to the PATH environment variable).
+          
+          [env var: MINIJINJA_SAFE_PATH]
+
+      --fuel <AMOUNT>
+          Sets the maximum fuel a template can consume.
+          
+          When fuel is set, every instruction consumes a certain amount of fuel. Usually 1, some
+          will consume no fuel. By default the engine has the fuel feature disabled (0). To turn on
+          fuel set something like 50000 which will allow 50.000 instructions to execute before
+          running out of fuel.
+          
+          This is useful as a basic security feature in CI pipelines or similar.
+          
+          [env var: MINIJINJA_FUEL]
+
+Advanced:
+  -E, --expr <EXPR>
+          Evalues a template expression instead of rendering a template.
+          
+          The value to the parameter is a template expression that is evaluated with the context of
+          the template and the result is emitted according to --expr-out.  The default output mode
+          is to print the result of the expression to stdout.
+          
+          Example: minijinja-cli --expr='1 < 10'
+
+      --expr-out <MODE>
+          Sets the expression output mode for --expr.
+          
+          This defaults to 'print' which means that the expression's result is written to stdout.
+          'json' (and 'json-pretty') does mostly the same but writes the result as JSON result
+          instead with one as a one-liner, the second in prett printing. 'status' exits the program
+          with the result as a status code.  If the result is not a number it will first convert the
+          result into a bool and then exits as 0 if it was true, 1 otherwise.
+          
+          [env var: MINIJINJA_EXPR_OUT]
+          
+          [possible values: print, json, json-pretty, status]
+
+      --dump <KIND>
+          Dump internals of a template to stdout.
+          
+          This feature is primarily useful to debug what is going on in a MiniJinja tempalte.
+          'instructions' will dump out the bytecode that the engine generated, 'ast' dumps out the
+          AST in a text only format and 'tokens' will print a line per token of the template after
+          lexing.
+          
+          [possible values: instructions, ast, tokens]
+
+      --repl
+          Starts the read-eval loop with the given input data.
+          
+          This allows basic experimentation of MiniJinja expressions with some input data.
+
+Shell Support:
+      --generate-completion <SH>
+          Generate a completion script for the given shell and print it to stdout.
+          
+          This completion script can be added to your shell startup to provide completions for the
+          minijinja-cli command.
+          
+          [possible values: bash, elvish, fig, fish, nushell, powershell, zsh]
+
+For extended help use --long-help, for short help --help.
+
+----- stderr -----
diff --git a/minijinja-cli/tests/snapshots/test_basic__short_help.snap b/minijinja-cli/tests/snapshots/test_basic__short_help.snap
new file mode 100644
index 00000000..a207bfba
--- /dev/null
+++ b/minijinja-cli/tests/snapshots/test_basic__short_help.snap
@@ -0,0 +1,64 @@
+---
+source: minijinja-cli/tests/test_basic.rs
+info:
+  program: minijinja-cli
+  args:
+    - "--help"
+---
+success: true
+exit_code: 0
+----- stdout -----
+minijinja-cli is a command line tool to render or evaluate jinja2 templates.
+
+Pass a template and optionally a file with template variables to render it to stdout.
+
+Usage: minijinja-cli [OPTIONS] [TEMPLATE] [DATA]
+
+Arguments:
+  [TEMPLATE]  Path to the input template [default: -]
+  [DATA]      Path to the data file
+
+Options:
+      --config-file <PATH>  Alternative path to the config file.
+  -f, --format <FORMAT>     The format of the input data [possible values: auto, json, querystring,
+                            yaml, toml, cbor]
+  -D, --define <EXPR>       Defines an input variable (key=value / key:=json_value)
+  -o, --output <FILENAME>   Path to the output file [default: -]
+      --select <SELECTOR>   Select a subset of the input data
+      --print-config        Print out the loaded config
+  -h, --help                Print short help (short texts)
+      --long-help           Print long help (extended, long explanation texts)
+  -V, --version             Print version
+
+Template Behavior:
+  -a, --autoescape <MODE>  Reconfigures autoescape behavior [possible values: auto, html, json,
+                           none]
+      --strict             Disallow undefined variables in templates
+  -n, --no-newline         Do not output a trailing newline
+      --trim-blocks        Enable the trim-blocks flag
+      --lstrip-blocks      Enable the lstrip-blocks flag
+      --py-compat          Enables improved Python compatibility
+  -s, --syntax <PAIR>      Changes a syntax feature (feature=value) [possible features: block-start,
+                           block-end, variable-start, variable-end, comment-start, comment-end,
+                           line-statement-prefix, line-statement-comment]
+      --env                Pass environment variables as ENV to the template
+
+Security:
+      --no-include        Disallow includes and extending
+      --safe-path <PATH>  Only allow includes from this path
+      --fuel <AMOUNT>     Configures the maximum fuel
+
+Advanced:
+  -E, --expr <EXPR>      Evaluates an template expression
+      --expr-out <MODE>  The expression output mode [possible values: print, json, json-pretty,
+                         status]
+      --dump <KIND>      Dump internals of a template [possible values: instructions, ast, tokens]
+      --repl             Starts the repl with the given data
+
+Shell Support:
+      --generate-completion <SH>  Generate a completion script for the given shell [possible values:
+                                  bash, elvish, fig, fish, nushell, powershell, zsh]
+
+For extended help use --long-help, for short help --help.
+
+----- stderr -----
diff --git a/minijinja-cli/tests/test_basic.rs b/minijinja-cli/tests/test_basic.rs
index 723fa01a..eb031697 100644
--- a/minijinja-cli/tests/test_basic.rs
+++ b/minijinja-cli/tests/test_basic.rs
@@ -493,3 +493,17 @@ fn test_line_statement() {
     ----- stderr -----
     "###);
 }
+
+#[test]
+#[cfg(all(
+    feature = "cbor",
+    feature = "ini",
+    feature = "json5",
+    feature = "querystring",
+    feature = "toml",
+    feature = "yaml",
+))]
+fn test_help() {
+    assert_cmd_snapshot!("short_help", cli().arg("--help"));
+    assert_cmd_snapshot!("long_help", cli().arg("--long-help"));
+}