Repository: tweag/nickel
Pull request #1562 (2023-09-01 13:13:56)

Normalize line endings in string literals during parsing
With this change, line endings are normalized to `\n` (instead of `\r\n` on some systems) as soon as a string literal enters the parser. This fixes a problem noticed in #1559 where documentation strings weren't correctly stripped of their common indentation prefix on Windows.

The tradeoff with the simplicity of this patch is that now post-processing is necessary to produce `\r\n` line endings as output. This is in line with [precedent set by Rust](https://github.com/rust-lang/rust/issues/62865).

vkleen: I've moved the line ending normalizing into the lexer. This also makes sure that we reject lone carriage returns in the source file. The code has changed significantly, so this may warrant another review.

Patch:
diff --git a/Cargo.toml b/Cargo.toml
index 8fc24b81..24fab02a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,6 +8,7 @@ members = [
     "wasm-repl",
     "pyckel",
 ]
+resolver = "2"
 
 [workspace.package]
 version = "1.1.1"
diff --git a/cli/src/export.rs b/cli/src/export.rs
index b329f448..4c13af5c 100644
--- a/cli/src/export.rs
+++ b/cli/src/export.rs
@@ -36,6 +36,9 @@ const OVERRIDES_LIST_MAX_COUNT: usize = 15;
 /// a value.
 const NICKEL_VALUE_NAME: &str = "NICKEL EXPRESSION";
 
+const EXPERIMENTAL_MSG: &str =
+    "[WARNING] Customize mode is experimental. Its interface is subject to breaking changes.";
+
 #[derive(clap::Parser, Debug)]
 pub struct ExportCommand {
     #[arg(long, value_enum, default_value_t)]
@@ -48,10 +51,11 @@ pub struct ExportCommand {
     #[command(flatten)]
     pub evaluation: EvalCommand,
 
-    /// Enters customize mode. This turns the nickel invocation into a new CLI based on the
-    /// configuration to be exported, where the value of fields can be set directly through CLI
-    /// arguments. Print the corresponding help (`nickel export -f <file.ncl> -- --help`) to see
-    /// available options.
+    /// \[WARNING\] Customize mode is experimental. Its interface is subject to breaking changes.
+    ///
+    /// Customize mode turns the nickel invocation into a new CLI based on the configuration to be
+    /// exported, where the value of fields can be set directly through CLI arguments. Print the
+    /// corresponding help (`nickel export -f <file.ncl> -- --help`) to see available options.
     #[arg(last = true)]
     pub customize_mode: Vec<OsString>,
 }
@@ -114,32 +118,37 @@ impl TermInterface {
     /// In addition to the updated command, `build_cmd` returns a mapping from clap argument ids to
     /// their corresponding full field path as an array of fields.
     fn build_cmd(&self) -> TermCommand {
-        let mut paths = HashMap::new();
-        let mut ovd_interfaces = BTreeMap::new();
+        let clap_cmd = Command::new("customize-mode")
+            .about("Customize a Nickel configuration through the command line before exporting")
+            .after_help(EXPERIMENTAL_MSG)
+            .no_binary_name(true);
 
-        let mut cmd = Command::new("customize-mode").no_binary_name(true);
+        let mut term_cmd = TermCommand::new(clap_cmd);
 
         for (id, field) in &self.fields {
-            cmd = field.add_args(cmd, vec![id.to_string()], &mut paths, &mut ovd_interfaces)
+            term_cmd = field.add_args(term_cmd, vec![id.to_string()])
         }
 
-        let mut overrides_list: Vec<String> = ovd_interfaces
+        let mut overrides_list: Vec<String> = term_cmd
+            .overrides
             .keys()
             .take(OVERRIDES_LIST_MAX_COUNT)
             .map(|field_path| format!("- {field_path}"))
             .collect();
 
-        if ovd_interfaces.len() > OVERRIDES_LIST_MAX_COUNT {
+        if term_cmd.overrides.len() > OVERRIDES_LIST_MAX_COUNT {
             overrides_list.push("- ...".into());
         }
 
-        //TODO: what happens if there's a field named override?
+        let has_override = term_cmd.inputs.contains_key("override");
+        let has_help = term_cmd.inputs.contains_key("help");
+
         let override_arg_label = "override";
         let override_help = format!(
             "Override any field of the configuration with a valid Nickel expression provided as \
             a string. The new value will be merged with the configuration with a `force` \
-            priority.\
-            \n\nOverridable fields:\n{}\n",
+            priority.\n\n\
+            Overridable fields:\n{}\n\n",
             overrides_list.join("\n")
         );
         let override_arg = clap::Arg::new(override_arg_label)
@@ -151,13 +160,35 @@ impl TermInterface {
             .required(false)
             .help(override_help);
 
-        cmd = cmd.arg(override_arg);
+        term_cmd.clap_cmd = term_cmd.clap_cmd.arg(override_arg);
 
-        TermCommand {
-            clap_cmd: cmd,
-            args: paths,
-            overrides: ovd_interfaces,
+        if has_help || has_override {
+            let conflict_field = if has_override {
+                "override"
+            } else if has_help {
+                "help"
+            } else {
+                // We tested in the parent `if` that one of those two booleans must be set
+                unreachable!()
+            };
+
+            let extra = if has_help && has_override {
+                ". The same applies to the conflicting `help` field"
+            } else {
+                ""
+            };
+
+            term_cmd.clap_cmd = term_cmd.clap_cmd.after_long_help(format!(
+                "\
+            [CONFLICT] This configuration has a field named `{conflict_field}` which conflicts \
+            with the built-in `--{conflict_field}` argument. To set this field to e.g. \"some_value\", \
+            use `--override {conflict_field} \"some_value\"` instead of `--{conflict_field} \"some_value\"`\
+            {extra}\n\n\
+            {EXPERIMENTAL_MSG}"
+            ));
         }
+
+        term_cmd
     }
 }
 
@@ -314,39 +345,32 @@ impl FieldInterface {
     ///
     /// `add_args` updates `paths` as well, which maps clap argument ids to the corresponding field
     /// path represented as an array of string names.
-    fn add_args(
-        &self,
-        mut cmd: clap::Command,
-        path: Vec<String>,
-        paths: &mut HashMap<clap::Id, Vec<String>>,
-        ovd_interfaces: &mut BTreeMap<String, OverrideInterface>,
-    ) -> clap::Command {
-        let id = path.join(".");
-        let prev = paths.insert(clap::Id::from(&id), path.clone());
-        debug_assert!(prev.is_none());
-
-        // this is a terminal field, which gives rise to an argument or an overridable value.
+    fn add_args(&self, mut term_cmd: TermCommand, path: Vec<String>) -> TermCommand {
+        // This is a terminal field, which gives rise to an argument or an overridable value.
         if !self.has_subfields() {
             if self.is_input() {
-                cmd = self.add_arg(cmd, id);
+                match path.as_slice() {
+                    [ref field] if field == "override" || field == "help" => {
+                        term_cmd.register_input(path.clone())
+                    }
+                    _ => {
+                        term_cmd.register_arg(path.clone());
+                        term_cmd.clap_cmd = self.add_arg(term_cmd.clap_cmd, path.join("."))
+                    }
+                };
             } else {
-                ovd_interfaces.insert(
-                    id,
-                    OverrideInterface {
-                        path: path.clone(),
-                        _help: self.help(),
-                    },
-                );
+                term_cmd.register_override(path.clone(), self.help());
             }
         }
 
         for (id, field) in self.subfields.iter().flat_map(|intf| intf.fields.iter()) {
             let mut path = path.clone();
             path.push(id.to_string());
-            cmd = field.add_args(cmd, path, paths, ovd_interfaces);
+
+            term_cmd = field.add_args(term_cmd, path);
         }
 
-        cmd
+        term_cmd
     }
 
     /// Add a single argument to the CLI `cmd`, based on the interface of this field, and return
@@ -454,10 +478,56 @@ struct TermCommand {
     clap_cmd: clap::Command,
     /// A mapping between clap argument ids and the corresponding field path (for inputs).
     args: HashMap<clap::Id, Vec<String>>,
+    /// Mapping between existing input fields (as a dot-separated path string) and their path
+    /// represented as a vector. This is used both as a `HashSet` to check the existence of an
+    /// input field, and to sidestep the issue of parsing a path again when reading an input
+    /// argument.
+    inputs: HashMap<String, Vec<String>>,
     /// A mapping between a field path and the corresponding override data.
     overrides: BTreeMap<String, OverrideInterface>,
 }
 
+impl TermCommand {
+    fn new(command: clap::Command) -> Self {
+        TermCommand {
+            clap_cmd: command,
+            overrides: BTreeMap::new(),
+            inputs: HashMap::new(),
+            args: HashMap::new(),
+        }
+    }
+
+    /// Register a new argument corresponding to an input. Add the paths and the argument's id to
+    /// the relevant maps.
+    fn register_arg(&mut self, path: Vec<String>) {
+        let path_string = path.join(".");
+        let arg_id = clap::Id::from(path_string.clone());
+
+        debug_assert!(!self.args.contains_key(&arg_id));
+        debug_assert!(!self.inputs.contains_key(&path_string));
+
+        self.args.insert(arg_id, path.clone());
+        self.inputs.insert(path_string, path);
+    }
+
+    /// Register an input, but don't register the corresponding CLI argument. Used for `override`
+    /// and `help` which conflict with the built-in `--override` and `--help` arguments.
+    fn register_input(&mut self, path: Vec<String>) {
+        let path_string = path.join(".");
+
+        debug_assert!(!self.inputs.contains_key(&path_string));
+
+        self.inputs.insert(path_string, path);
+    }
+
+    fn register_override(&mut self, path: Vec<String>, help: Option<String>) {
+        let path_string = path.join(".");
+
+        self.overrides
+            .insert(path_string, OverrideInterface { path, _help: help });
+    }
+}
+
 impl ExportCommand {
     pub fn run(mut self, global: GlobalOptions) -> CliResult<()> {
         let mut program = self.evaluation.prepare(&global)?;
@@ -488,8 +558,13 @@ impl ExportCommand {
 
                         cmd.overrides
                             .get(path)
-                            .map(|intf| FieldOverride {
-                                path: intf.path.clone(),
+                            .map(|intf| intf.path.clone())
+                            // We allow --override to set inputs as well, in particular as an
+                            // escape mechanism to set an input field named `override` or `help`,
+                            // which would conflict with the corresponding builtin flags.
+                            .or(cmd.inputs.get(path).cloned())
+                            .map(|path| FieldOverride {
+                                path,
                                 value: value.clone(),
                                 priority: MergePriority::Top,
                             })
diff --git a/core/src/parser/grammar.lalrpop b/core/src/parser/grammar.lalrpop
index c5d487c5..bdaff9cb 100644
--- a/core/src/parser/grammar.lalrpop
+++ b/core/src/parser/grammar.lalrpop
@@ -583,7 +583,7 @@ ChunkLiteral : String =
     <parts: ChunkLiteralPart+> => {
         parts.into_iter().fold(String::new(), |mut acc, part| {
             match part {
-                ChunkLiteralPart::Str(s) => acc.push_str(s),
+                ChunkLiteralPart::Str(s) => acc.push_str(&s),
                 ChunkLiteralPart::Char(c) => acc.push(c),
             };
 
@@ -623,7 +623,7 @@ EnumTag: LocIdent = {
   <StringEnumTag> => <>.into(),
 };
 
-ChunkLiteralPart: ChunkLiteralPart<'input> = {
+ChunkLiteralPart: ChunkLiteralPart = {
     "str literal" => ChunkLiteralPart::Str(<>),
     "multstr literal" => ChunkLiteralPart::Str(<>),
     "str esc char" => ChunkLiteralPart::Char(<>),
@@ -946,9 +946,9 @@ extern {
 
     enum Token<'input> {
         "identifier" => Token::Normal(NormalToken::Identifier(<&'input str>)),
-        "str literal" => Token::Str(StringToken::Literal(<&'input str>)),
+        "str literal" => Token::Str(StringToken::Literal(<String>)),
         "str esc char" => Token::Str(StringToken::EscapedChar(<char>)),
-        "multstr literal" => Token::MultiStr(MultiStringToken::Literal(<&'input str>)),
+        "multstr literal" => Token::MultiStr(MultiStringToken::Literal(<String>)),
         "num literal" => Token::Normal(NormalToken::NumLiteral(<Number>)),
 
         "raw enum tag" => Token::Normal(NormalToken::RawEnumTag(<&'input str>)),
diff --git a/core/src/parser/lexer.rs b/core/src/parser/lexer.rs
index c9e1e2e6..490e4379 100644
--- a/core/src/parser/lexer.rs
+++ b/core/src/parser/lexer.rs
@@ -56,10 +56,12 @@ fn symbolic_string_prefix_and_length<'input>(
 /// The tokens in normal mode.
 #[derive(Logos, Debug, PartialEq, Clone)]
 pub enum NormalToken<'input> {
-    #[regex("[ \r\t\n]+", logos::skip)]
+    #[regex("((\r\n)+|[ \t\n]+)", logos::skip)]
     // multiline strings cannot be used as enum tags, so we explicitly
     // disallow that pattern.
     #[regex("'m(%)+\"")]
+    // We forbid lone carriage returns for sanity
+    #[regex("\r[^\n]")]
     #[error]
     Error,
 
@@ -389,13 +391,15 @@ pub struct SymbolicStringStart<'input> {
 /// The tokens in string mode.
 #[derive(Logos, Debug, PartialEq, Eq, Clone)]
 pub enum StringToken<'input> {
+    // We forbid lone carriage returns for sanity
+    #[regex("\r[^\n]")]
     #[error]
     Error,
 
-    #[regex("[^\"%\\\\]+")]
+    #[regex("[^\"%\\\\]+", |lex| normalize_line_endings(lex.slice()))]
     // Has lower matching priority than `Interpolation` according to Logos' rules.
-    #[token("%")]
-    Literal(&'input str),
+    #[token("%", |lex| String::from(lex.slice()))]
+    Literal(String),
 
     #[token("\"")]
     DoubleQuote,
@@ -411,16 +415,18 @@ pub enum StringToken<'input> {
 /// The tokens in multiline string mode.
 #[derive(Logos, Debug, PartialEq, Eq, Clone)]
 pub enum MultiStringToken<'input> {
+    // We forbid lone carriage returns for sanity
+    #[regex("\r[^\n]")]
     #[error]
     Error,
 
-    #[regex("[^\"%]+")]
+    #[regex("[^\"%]+", |lex| normalize_line_endings(lex.slice()))]
     // A token that starts as a multiline end delimiter or an interpolation sequence but is not
     // one.  These ones should have lowest matching priority according to Logos' rules, and
     // CandidateEnd and CandidateInterpolation should be matched first.
-    #[token("\"")]
-    #[regex("%+")]
-    Literal(&'input str),
+    #[token("\"", |lex| String::from(lex.slice()))]
+    #[regex("%+", |lex| String::from(lex.slice()))]
+    Literal(String),
 
     /// A candidate end. A multiline string starting delimiter `MultiStringStart` can have a
     /// variable number of `%` character, so the lexer matches candidate end delimiter, compare the
@@ -692,7 +698,7 @@ impl<'input> Lexer<'input> {
         };
         self.bufferize(next_token, next_span);
 
-        let token = Token::MultiStr(MultiStringToken::Literal(&s[0..split_at]));
+        let token = Token::MultiStr(MultiStringToken::Literal(s[0..split_at].to_owned()));
         let span = Range {
             start: span.start,
             end: span.start + split_at,
@@ -850,7 +856,7 @@ impl<'input> Lexer<'input> {
             // `Literal` one
             MultiStringToken::CandidateInterpolation(s)
             | MultiStringToken::QuotesCandidateInterpolation(s) => {
-                Token::MultiStr(MultiStringToken::Literal(s))
+                Token::MultiStr(MultiStringToken::Literal(s.to_owned()))
             }
             // Strictly speaking, a candidate end delimiter with more than the required count of
             // `%` should be split between multistring end token, plus a variable number of `%`
@@ -876,7 +882,9 @@ impl<'input> Lexer<'input> {
             }
             // Otherwise, it is just part of the string, so we transform the token into a
             // `Literal` one
-            MultiStringToken::CandidateEnd(s) => Token::MultiStr(MultiStringToken::Literal(s)),
+            MultiStringToken::CandidateEnd(s) => {
+                Token::MultiStr(MultiStringToken::Literal(s.to_owned()))
+            }
             // Early report errors for now. This could change in the future
             MultiStringToken::Error => {
                 return Some(Err(ParseError::Lexical(LexicalError::Generic(span))))
@@ -978,3 +986,14 @@ fn escape_ascii(code: &str) -> Option<char> {
         Some(code as char)
     }
 }
+
+/// Normalize the line endings in `s` to only `\n` and, in debug mode, check
+/// for lone `\r` without an accompanying `\n`.
+pub fn normalize_line_endings(s: impl AsRef<str>) -> String {
+    let normalized = s.as_ref().replace("\r\n", "\n");
+    debug_assert!(
+        normalized.find('\r').is_none(),
+        "The lexer throws an error when it finds a lone carriage return"
+    );
+    normalized
+}
diff --git a/core/src/parser/utils.rs b/core/src/parser/utils.rs
index 48341e58..445a01d0 100644
--- a/core/src/parser/utils.rs
+++ b/core/src/parser/utils.rs
@@ -96,8 +96,8 @@ pub type FieldPath = Vec<FieldPathElem>;
 /// Because of the way the lexer handles escaping and interpolation, a contiguous static string
 /// `"Some \\ \%{escaped} string"` will be lexed as a sequence of such atoms.
 #[derive(Clone, Debug, Eq, PartialEq)]
-pub enum ChunkLiteralPart<'input> {
-    Str(&'input str),
+pub enum ChunkLiteralPart {
+    Str(String),
     Char(char),
 }
 
@@ -839,20 +839,6 @@ pub fn strip_indent(mut chunks: Vec<StrChunk<RichTerm>>) -> Vec<StrChunk<RichTer
     chunks
 }
 
-/// Strip the indentation of a doc metadata. Wrap it as a literal string chunk and call
-/// [`strip_indent`].
-pub fn strip_indent_doc(doc: String) -> String {
-    let chunk = vec![StrChunk::Literal(doc)];
-    strip_indent(chunk)
-        .into_iter()
-        .map(|chunk| match chunk {
-            StrChunk::Literal(s) => s,
-            _ => panic!("expected literal string after indentation of documentation"),
-        })
-        .next()
-        .expect("expected non-empty chunks after indentation of documentation")
-}
-
 #[cfg(test)]
 mod tests {
     use crate::typ::TypeF;
diff --git a/core/src/pretty.rs b/core/src/pretty.rs
index 217f5880..7cfad8a5 100644
--- a/core/src/pretty.rs
+++ b/core/src/pretty.rs
@@ -55,6 +55,7 @@ fn escape(s: &str) -> String {
         .replace("%{", "\\%{")
         .replace('\"', "\\\"")
         .replace('\n', "\\n")
+        .replace('\r', "\\r")
 }
 
 static QUOTING_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new("^_?[a-zA-Z][_a-zA-Z0-9-]*$").unwrap());
@@ -116,6 +117,15 @@ fn contains_newline<T>(chunks: &[StrChunk<T>]) -> bool {
     })
 }
 
+/// Does a sequence of `StrChunk`s contain a carriage return? Lone carriage
+/// returns are forbidden in Nickel's surface syntax.
+fn contains_carriage_return<T>(chunks: &[StrChunk<T>]) -> bool {
+    chunks.iter().any(|chunk| match chunk {
+        StrChunk::Literal(str) => str.contains('\r'),
+        StrChunk::Expr(_, _) => false,
+    })
+}
+
 pub fn fmt_pretty<'a, T>(value: &T, f: &mut fmt::Formatter) -> fmt::Result
 where
     T: Pretty<'a, pretty::BoxAllocator, ()> + Clone,
@@ -143,7 +153,9 @@ where
         chunks: &[StrChunk<RichTerm>],
         string_style: StringRenderStyle,
     ) -> DocBuilder<'a, Self, A> {
-        let multiline = string_style == StringRenderStyle::Multiline && contains_newline(chunks);
+        let multiline = string_style == StringRenderStyle::Multiline
+            && contains_newline(chunks)
+            && !contains_carriage_return(chunks);
 
         let nb_perc = if multiline {
             chunks
@@ -195,9 +207,7 @@ where
                                 // about whether a trailing newline appears at
                                 // the end of the last line.
                                 s.split_inclusive('\n').map(|line| {
-                                    if let Some(s) = line.strip_suffix("\r\n") {
-                                        self.text(s.to_owned()).append(self.hardline())
-                                    } else if let Some(s) = line.strip_suffix('\n') {
+                                    if let Some(s) = line.strip_suffix('\n') {
                                         self.text(s.to_owned()).append(self.hardline())
                                     } else {
                                         self.text(line.to_owned())


Test patch:
diff --git a/cli/tests/snapshot/inputs/customize-mode/has_help.ncl b/cli/tests/snapshot/inputs/customize-mode/has_help.ncl
new file mode 100644
index 00000000..289a4dcb
--- /dev/null
+++ b/cli/tests/snapshot/inputs/customize-mode/has_help.ncl
@@ -0,0 +1,8 @@
+# capture = 'stdout'
+# command = ['export', '--', '--help']
+{
+  help | String,
+  input | Number,
+
+  output = if input == 0 then help else "",
+}
diff --git a/cli/tests/snapshot/inputs/customize-mode/has_override.ncl b/cli/tests/snapshot/inputs/customize-mode/has_override.ncl
new file mode 100644
index 00000000..832cbc2d
--- /dev/null
+++ b/cli/tests/snapshot/inputs/customize-mode/has_override.ncl
@@ -0,0 +1,8 @@
+# capture = 'stdout'
+# command = ['export', '--', '--help']
+{
+  override | String,
+  input | Number,
+
+  output = if input == 0 then override else "",
+}
diff --git a/cli/tests/snapshot/inputs/freeform/help.ncl b/cli/tests/snapshot/inputs/customize-mode/help.ncl
similarity index 100%
rename from cli/tests/snapshot/inputs/freeform/help.ncl
rename to cli/tests/snapshot/inputs/customize-mode/help.ncl
diff --git a/cli/tests/snapshot/inputs/freeform/not_an_input.ncl b/cli/tests/snapshot/inputs/customize-mode/not_an_input.ncl
similarity index 100%
rename from cli/tests/snapshot/inputs/freeform/not_an_input.ncl
rename to cli/tests/snapshot/inputs/customize-mode/not_an_input.ncl
diff --git a/cli/tests/snapshot/inputs/freeform/simple_adder.ncl b/cli/tests/snapshot/inputs/customize-mode/simple_adder.ncl
similarity index 100%
rename from cli/tests/snapshot/inputs/freeform/simple_adder.ncl
rename to cli/tests/snapshot/inputs/customize-mode/simple_adder.ncl
diff --git a/cli/tests/snapshot/inputs/freeform/unknown_override.ncl b/cli/tests/snapshot/inputs/customize-mode/unknown_override.ncl
similarity index 100%
rename from cli/tests/snapshot/inputs/freeform/unknown_override.ncl
rename to cli/tests/snapshot/inputs/customize-mode/unknown_override.ncl
diff --git a/cli/tests/snapshot/snapshots/snapshot__export_stdout_has_help.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__export_stdout_has_help.ncl.snap
new file mode 100644
index 00000000..feaf5025
--- /dev/null
+++ b/cli/tests/snapshot/snapshots/snapshot__export_stdout_has_help.ncl.snap
@@ -0,0 +1,28 @@
+---
+source: cli/tests/snapshot/main.rs
+expression: out
+---
+Customize a Nickel configuration through the command line before exporting
+
+Usage: customize-mode [OPTIONS] --input <Number>
+
+Options:
+      --input <Number>
+          
+
+      --override <field> <value>
+          Override any field of the configuration with a valid Nickel expression provided as a
+          string. The new value will be merged with the configuration with a `force` priority.
+          
+          Overridable fields:
+          - output
+
+  -h, --help
+          Print help (see a summary with '-h')
+
+[CONFLICT] This configuration has a field named `help` which conflicts with the built-in `--help`
+argument. To set this field to e.g. "some_value", use `--override help "some_value"` instead of
+`--help "some_value"`
+
+[WARNING] Customize mode is experimental. Its interface is subject to breaking changes.
+
diff --git a/cli/tests/snapshot/snapshots/snapshot__export_stdout_has_override.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__export_stdout_has_override.ncl.snap
new file mode 100644
index 00000000..78ef1191
--- /dev/null
+++ b/cli/tests/snapshot/snapshots/snapshot__export_stdout_has_override.ncl.snap
@@ -0,0 +1,28 @@
+---
+source: cli/tests/snapshot/main.rs
+expression: out
+---
+Customize a Nickel configuration through the command line before exporting
+
+Usage: customize-mode [OPTIONS] --input <Number>
+
+Options:
+      --input <Number>
+          
+
+      --override <field> <value>
+          Override any field of the configuration with a valid Nickel expression provided as a
+          string. The new value will be merged with the configuration with a `force` priority.
+          
+          Overridable fields:
+          - output
+
+  -h, --help
+          Print help (see a summary with '-h')
+
+[CONFLICT] This configuration has a field named `override` which conflicts with the built-in
+`--override` argument. To set this field to e.g. "some_value", use `--override override
+"some_value"` instead of `--override "some_value"`
+
+[WARNING] Customize mode is experimental. Its interface is subject to breaking changes.
+
diff --git a/cli/tests/snapshot/snapshots/snapshot__export_stdout_help.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__export_stdout_help.ncl.snap
index b8d65b4b..34a3453b 100644
--- a/cli/tests/snapshot/snapshots/snapshot__export_stdout_help.ncl.snap
+++ b/cli/tests/snapshot/snapshots/snapshot__export_stdout_help.ncl.snap
@@ -2,6 +2,8 @@
 source: cli/tests/snapshot/main.rs
 expression: out
 ---
+Customize a Nickel configuration through the command line before exporting
+
 Usage: customize-mode [OPTIONS] --input.foo.bar <String> --input.foo.baz <Array Number>
 
 Options:
@@ -23,3 +25,5 @@ Options:
   -h, --help
           Print help
 
+[WARNING] Customize mode is experimental. Its interface is subject to breaking changes.
+
diff --git a/core/src/parser/tests.rs b/core/src/parser/tests.rs
index b7980fb1..77b93d00 100644
--- a/core/src/parser/tests.rs
+++ b/core/src/parser/tests.rs
@@ -347,10 +347,10 @@ fn string_lexing() {
             r#""Good" "strings""#,
             vec![
                 Token::Normal(NormalToken::DoubleQuote),
-                Token::Str(StringToken::Literal("Good")),
+                Token::Str(StringToken::Literal("Good".to_owned())),
                 Token::Normal(NormalToken::DoubleQuote),
                 Token::Normal(NormalToken::DoubleQuote),
-                Token::Str(StringToken::Literal("strings")),
+                Token::Str(StringToken::Literal("strings".to_owned())),
                 Token::Normal(NormalToken::DoubleQuote),
             ],
         ),
@@ -359,9 +359,9 @@ fn string_lexing() {
             r#""Good\nEscape\t\"""#,
             vec![
                 Token::Normal(NormalToken::DoubleQuote),
-                Token::Str(StringToken::Literal("Good")),
+                Token::Str(StringToken::Literal("Good".to_owned())),
                 Token::Str(StringToken::EscapedChar('\n')),
-                Token::Str(StringToken::Literal("Escape")),
+                Token::Str(StringToken::Literal("Escape".to_owned())),
                 Token::Str(StringToken::EscapedChar('\t')),
                 Token::Str(StringToken::EscapedChar('\"')),
                 Token::Normal(NormalToken::DoubleQuote),
@@ -372,11 +372,11 @@ fn string_lexing() {
             r#""1 + %{ 1 } + 2""#,
             vec![
                 Token::Normal(NormalToken::DoubleQuote),
-                Token::Str(StringToken::Literal("1 + ")),
+                Token::Str(StringToken::Literal("1 + ".to_owned())),
                 Token::Str(StringToken::Interpolation),
                 Token::Normal(NormalToken::NumLiteral(Number::from(1))),
                 Token::Normal(NormalToken::RBrace),
-                Token::Str(StringToken::Literal(" + 2")),
+                Token::Str(StringToken::Literal(" + 2".to_owned())),
                 Token::Normal(NormalToken::DoubleQuote),
             ],
         ),
@@ -385,7 +385,7 @@ fn string_lexing() {
             r#""1 + %{ "%{ 1 }" } + 2""#,
             vec![
                 Token::Normal(NormalToken::DoubleQuote),
-                Token::Str(StringToken::Literal("1 + ")),
+                Token::Str(StringToken::Literal("1 + ".to_owned())),
                 Token::Str(StringToken::Interpolation),
                 Token::Normal(NormalToken::DoubleQuote),
                 Token::Str(StringToken::Interpolation),
@@ -393,7 +393,7 @@ fn string_lexing() {
                 Token::Normal(NormalToken::RBrace),
                 Token::Normal(NormalToken::DoubleQuote),
                 Token::Normal(NormalToken::RBrace),
-                Token::Str(StringToken::Literal(" + 2")),
+                Token::Str(StringToken::Literal(" + 2".to_owned())),
                 Token::Normal(NormalToken::DoubleQuote),
             ],
         ),
@@ -402,7 +402,7 @@ fn string_lexing() {
             r#"m%%""%"%%"#,
             vec![
                 Token::Normal(NormalToken::MultiStringStart(4)),
-                Token::MultiStr(MultiStringToken::Literal("\"%")),
+                Token::MultiStr(MultiStringToken::Literal("\"%".to_owned())),
                 Token::MultiStr(MultiStringToken::End),
             ],
         ),
@@ -425,11 +425,11 @@ fn string_lexing() {
                     prefix: "foo",
                     length: 3,
                 })),
-                Token::MultiStr(MultiStringToken::Literal("text ")),
+                Token::MultiStr(MultiStringToken::Literal("text ".to_owned())),
                 Token::MultiStr(MultiStringToken::Interpolation),
                 Token::Normal(NormalToken::NumLiteral(Number::from(1))),
                 Token::Normal(NormalToken::RBrace),
-                Token::MultiStr(MultiStringToken::Literal(" etc.")),
+                Token::MultiStr(MultiStringToken::Literal(" etc.".to_owned())),
                 Token::MultiStr(MultiStringToken::End),
             ],
         ),
@@ -469,6 +469,12 @@ fn str_escape() {
     );
 }
 
+#[test]
+fn carriage_returns() {
+    assert_eq!(parse_without_pos("\"\\r\""), mk_single_chunk("\r"),);
+    assert_matches!(parse("foo\rbar"), Err(ParseError::UnexpectedToken(..)))
+}
+
 #[test]
 fn ascii_escape() {
     assert_matches!(