Repository: tweag/nickel (rust)

Issue:
Record fields defined via interpolation are not recursive
**Describe the bug**
Declaring a record field using an interpolated expression makes the content of this field "non-recursive", meaning that it can't refer to other fields of the same records, as well as other fields can't refer to it.

**To Reproduce**
```
nickel> { x = 1; y = x + 1}.y
2

nickel> { x = 1; $"y" = x + 1}.y
error: Unbound identifier
  ┌─ repl-input-1:1:17
  │
1 │ { x = 1; $"y" = x + 1}.y
  │                 ^ this identifier is unbound
```

**Expected behavior**
Field defined via interpolation should have the same recursive behavior as other fields.

**Additional context**
This is due to the way such fields are elaborated: the previous example is rewritten as `{x = 1}$["y" = x + 1]` which makes clear why `y` doesn't have access to `x`.


Pull request description:
Fix dynamic fields not having access to recursive fields
Depend on #374. Fix #298.

Previously, the parser was elaborating a record with fields defined dynamically as a static record followed by a sequence of dynamic extension:

```
let x = "foo" in
let y = "bar" in
{ "#{x}" = 1, "#{y}" = 2, baz = 3}

// Rewritten at parsing time as
{baz = 3} $[ x = 1] $[ y = 2 ]
```

Where `$[ ]` is the dynamic extension operator. This causes #298: because dynamic fields are separated early from their original record, they don't have access to recursive fields like `baz`

This PR fixes the problem by changing the representation of recursive records to a set of static field and a set of dynamic fields. Doing so, the dynamic fields are kept together with their original record until the first evaluation of the `RecRecord` to a standard `Record`, and can thus have access to the other recursive fields.

## Recursive dynamic fields

Only field defined statically can be recursive, that is this PR doesn't support:

```
let x = "foo" in {"#{x}" = 0, bar = foo +1} //Error
```

Doing otherwise would require to pre-evaluate some terms before we can even typecheck, and would be dubious in any case, introducing a form of dynamic scoping.

## Typechecking

If a record has a dynamic field, then it is inferred to have a type of the form `{_ : _a}` for some unification variable `_a`, which is the only type that can handle statically unknown fields.

Patch:
diff --git a/src/cache.rs b/src/cache.rs
index b69678c5..42f2edea 100644
--- a/src/cache.rs
+++ b/src/cache.rs
@@ -391,7 +391,7 @@ impl Cache {
     /// # Preconditions
     ///
     /// - the entry must syntactically be a record (`Record` or `RecRecord`). Otherwise, this
-    /// function panic
+    /// function panics
     pub fn transform_inner(
         &mut self,
         file_id: FileId,
@@ -401,7 +401,7 @@ impl Cache {
             Some(_) => {
                 let (mut t, _) = self.terms.remove(&file_id).unwrap();
                 match t.term.as_mut() {
-                    Term::Record(ref mut map, _) | Term::RecRecord(ref mut map, _) => {
+                    Term::Record(ref mut map, _) => {
                         let map_res: Result<HashMap<Ident, RichTerm>, ImportError> =
                             std::mem::replace(map, HashMap::new())
                                 .into_iter()
@@ -411,6 +411,29 @@ impl Cache {
                                 .collect();
                         *map = map_res?;
                     }
+                    Term::RecRecord(ref mut map, ref mut dyn_fields, _) => {
+                        let map_res: Result<HashMap<Ident, RichTerm>, ImportError> =
+                            std::mem::replace(map, HashMap::new())
+                                .into_iter()
+                                .map(|(id, t)| {
+                                    transformations::transform(t).map(|t_ok| (id.clone(), t_ok))
+                                })
+                                .collect();
+
+                        let dyn_fields_res: Result<Vec<(RichTerm, RichTerm)>, ImportError> =
+                            std::mem::replace(dyn_fields, Vec::new())
+                                .into_iter()
+                                .map(|(id_t, t)| {
+                                    Ok((
+                                        transformations::transform(id_t)?,
+                                        transformations::transform(t)?,
+                                    ))
+                                })
+                                .collect();
+
+                        *map = map_res?;
+                        *dyn_fields = dyn_fields_res?;
+                    }
                     _ => panic!("cache::transform_inner(): not a record"),
                 }
 
diff --git a/src/eval.rs b/src/eval.rs
index 652dcd6a..0ddc1016 100644
--- a/src/eval.rs
+++ b/src/eval.rs
@@ -94,7 +94,7 @@ use crate::mk_app;
 use crate::operation::{continuate_operation, OperationCont};
 use crate::position::TermPos;
 use crate::stack::Stack;
-use crate::term::{make as mk_term, MetaValue, RichTerm, StrChunk, Term, UnaryOp};
+use crate::term::{make as mk_term, BinaryOp, MetaValue, RichTerm, StrChunk, Term, UnaryOp};
 use std::cell::{Ref, RefCell, RefMut};
 use std::rc::{Rc, Weak};
 
@@ -302,12 +302,12 @@ pub enum EnvBuildError {
     NotARecord(RichTerm),
 }
 
-/// Add the bindings of a record to an environment.
+/// Add the bindings of a record to an environment. Ignore the fields defined by interpolation.
 pub fn env_add_term(env: &mut Environment, rt: RichTerm) -> Result<(), EnvBuildError> {
     let RichTerm { term, pos } = rt;
 
     match *term {
-        Term::Record(bindings, _) | Term::RecRecord(bindings, _) => {
+        Term::Record(bindings, _) | Term::RecRecord(bindings, ..) => {
             let ext = bindings.into_iter().map(|(id, t)| {
                 (
                     id,
@@ -621,7 +621,7 @@ where
                     env,
                 }
             }
-            Term::RecRecord(ts, attrs) => {
+            Term::RecRecord(ts, dyn_fields, attrs) => {
                 // Thanks to the share normal form transformation, the content is either a constant or a
                 // variable.
                 let rec_env = ts.iter().try_fold::<_, _, Result<Environment, EvalError>>(
@@ -671,11 +671,48 @@ where
                         _ => (id, RichTerm { term, pos }),
                     }
                 });
+
+                let static_part = RichTerm::new(Term::Record(new_ts.collect(), attrs), pos);
+
+                // Transform the static part `{stat1 = val1, ..., statn = valn}` and the dynamic
+                // part `{exp1 = dyn_val1, ..., expm = dyn_valm}` to a sequence of extensions
+                // `{stat1 = val1, ..., statn = valn} $[ exp1 = dyn_val1] ... $[ expn = dyn_valn ]`
+                // The `dyn_val` are given access to the recursive environment, but not the dynamic
+                // field names.
+                let extended = dyn_fields
+                    .into_iter()
+                    .try_fold::<_, _, Result<RichTerm, EvalError>>(
+                        static_part,
+                        |acc, (id_t, t)| {
+                            let RichTerm { term, pos } = t;
+                            match *term {
+                                Term::Var(var_id) => {
+                                    let mut thunk = env.get(&var_id).ok_or_else(|| {
+                                        EvalError::UnboundIdentifier(var_id.clone(), pos)
+                                    })?;
+
+                                    thunk.borrow_mut().env.extend(
+                                        rec_env
+                                            .iter_elems()
+                                            .map(|(id, thunk)| (id.clone(), thunk.clone())),
+                                    );
+                                    Ok(Term::App(
+                                        mk_term::op2(BinaryOp::DynExtend(), id_t, acc),
+                                        mk_term::var(var_id).with_pos(pos),
+                                    )
+                                    .into())
+                                }
+                                _ => Ok(Term::App(
+                                    mk_term::op2(BinaryOp::DynExtend(), id_t, acc),
+                                    RichTerm { term, pos },
+                                )
+                                .into()),
+                            }
+                        },
+                    )?;
+
                 Closure {
-                    body: RichTerm {
-                        term: Box::new(Term::Record(new_ts.collect(), attrs)),
-                        pos,
-                    },
+                    body: extended.with_pos(pos),
                     env,
                 }
             }
@@ -888,7 +925,7 @@ pub fn subst(rt: RichTerm, global_env: &Environment, env: &Environment) -> RichT
 
                 RichTerm::new(Term::Record(map, attrs), pos)
             }
-            Term::RecRecord(map, attrs) => {
+            Term::RecRecord(map, dyn_fields, attrs) => {
                 let map = map
                     .into_iter()
                     .map(|(id, t)| {
@@ -899,7 +936,17 @@ pub fn subst(rt: RichTerm, global_env: &Environment, env: &Environment) -> RichT
                     })
                     .collect();
 
-                RichTerm::new(Term::RecRecord(map, attrs), pos)
+                let dyn_fields = dyn_fields
+                    .into_iter()
+                    .map(|(id_t, t)| {
+                        (
+                            subst_(id_t, global_env, env, Cow::Borrowed(bound.as_ref())),
+                            subst_(t, global_env, env, Cow::Borrowed(bound.as_ref())),
+                        )
+                    })
+                    .collect();
+
+                RichTerm::new(Term::RecRecord(map, dyn_fields, attrs), pos)
             }
             Term::List(ts) => {
                 let ts = ts
diff --git a/src/parser/utils.rs b/src/parser/utils.rs
index 4ea57a06..d47a3e3d 100644
--- a/src/parser/utils.rs
+++ b/src/parser/utils.rs
@@ -92,28 +92,59 @@ where
     let mut static_map = HashMap::new();
     let mut dynamic_fields = Vec::new();
 
+    fn insert_static_field(static_map: &mut HashMap<Ident, RichTerm>, id: Ident, t: RichTerm) {
+        match static_map.entry(id) {
+            Entry::Occupied(mut occpd) => {
+                // temporary putting null in the entry to take the previous value.
+                let prev = occpd.insert(Term::Null.into());
+                occpd.insert(mk_term::op2(BinaryOp::Merge(), prev, t));
+            }
+            Entry::Vacant(vac) => {
+                vac.insert(t);
+            }
+        }
+    }
+
     fields.into_iter().for_each(|field| match field {
-        (FieldPathElem::Ident(id), t) => {
-            match static_map.entry(id) {
-                Entry::Occupied(mut occpd) => {
-                    // temporary putting null in the entry to take the previous value.
-                    let prev = occpd.insert(Term::Null.into());
-                    occpd.insert(mk_term::op2(BinaryOp::Merge(), prev, t));
-                }
-                Entry::Vacant(vac) => {
-                    vac.insert(t);
+        (FieldPathElem::Ident(id), t) => insert_static_field(&mut static_map, id, t),
+        (FieldPathElem::Expr(e), t) => {
+            // Dynamic fields (whose name is defined by an interpolated string) have a different
+            // semantics than fields whose name can be determined statically. However, static
+            // fields with special characters are also parsed as an `Expr(e)`:
+            //
+            // ```
+            // let x = "dynamic" in {"I#am.static" = false, "#{x}" = true}
+            // ```
+            //
+            // Here, both fields are parsed as `Expr(e)`, but the first field is actually a static
+            // one, just with special characters. The following code determines which fields are
+            // actually static or not, and inserts them in the right location.
+            match e.term.as_ref() {
+                Term::StrChunks(chunks) => {
+                    let mut buffer = String::new();
+
+                    let is_static = chunks.iter().try_for_each(|chunk| match chunk {
+                        StrChunk::Literal(s) => {
+                            buffer.push_str(&s);
+                            Ok(())
+                        }
+                        StrChunk::Expr(..) => Err(()),
+                    });
+
+                    if is_static.is_ok() {
+                        insert_static_field(&mut static_map, Ident(buffer), t)
+                    } else {
+                        dynamic_fields.push((e, t));
+                    }
                 }
+                // Currently `e` can only be string chunks, and this case should be unreachable,
+                // but let's be future-proof
+                _ => dynamic_fields.push((e, t)),
             }
         }
-        (FieldPathElem::Expr(e), t) => dynamic_fields.push((e, t)),
     });
 
-    dynamic_fields
-        .into_iter()
-        .fold(Term::RecRecord(static_map, attrs), |rec, field| {
-            let (id_t, t) = field;
-            Term::App(mk_term::op2(BinaryOp::DynExtend(), id_t, rec), t)
-        })
+    Term::RecRecord(static_map, dynamic_fields, attrs)
 }
 
 /// Make a span from parser byte offsets.
diff --git a/src/repl/query_print.rs b/src/repl/query_print.rs
index 716aafce..85e246e1 100644
--- a/src/repl/query_print.rs
+++ b/src/repl/query_print.rs
@@ -187,11 +187,18 @@ fn write_query_result_<R: QueryPrinter>(
     ) -> io::Result<()> {
         writeln!(out)?;
         match t {
-            Term::Record(map, _) | Term::RecRecord(map, _) if !map.is_empty() => {
+            Term::Record(map, _) if !map.is_empty() => {
                 let mut fields: Vec<_> = map.keys().collect();
                 fields.sort();
                 renderer.write_fields(out, fields.into_iter())
             }
+            Term::RecRecord(map, dyn_fields, _) if !map.is_empty() => {
+                let mut fields: Vec<_> = map.keys().collect();
+                fields.sort();
+                let dynamic = Ident::from("<dynamic>");
+                fields.extend(dyn_fields.iter().map(|_| &dynamic));
+                renderer.write_fields(out, fields.into_iter())
+            }
             Term::Record(..) | Term::RecRecord(..) => renderer.write_metadata(out, "value", "{}"),
             _ => Ok(()),
         }
diff --git a/src/repl/rustyline_frontend.rs b/src/repl/rustyline_frontend.rs
index 74139400..b32872ab 100644
--- a/src/repl/rustyline_frontend.rs
+++ b/src/repl/rustyline_frontend.rs
@@ -50,7 +50,14 @@ pub fn repl() -> Result<(), InitError> {
                 let cmd = line.chars().skip(1).collect::<String>().parse::<Command>();
                 let result = match cmd {
                     Ok(Command::Load(path)) => repl.load(&path).map(|term| match term.as_ref() {
-                        Term::Record(map, _) | Term::RecRecord(map, _) => {
+                        Term::Record(map, _) => {
+                             println!("Loaded {} symbol(s) in the environment.", map.len())
+                        }
+                        Term::RecRecord(map, dyn_fields, _) => {
+                            if !dyn_fields.is_empty() {
+                                println!("Warning: loading dynamic fields is currently not supported. {} symbols ignored", dyn_fields.len());
+                            }
+
                             println!("Loaded {} symbol(s) in the environment.", map.len())
                         }
                         _ => (),
diff --git a/src/term.rs b/src/term.rs
index aba3c666..da56fdb6 100644
--- a/src/term.rs
+++ b/src/term.rs
@@ -79,7 +79,11 @@ pub enum Term {
     Record(HashMap<Ident, RichTerm>, RecordAttrs),
     /// A recursive record, where the fields can reference each others.
     #[serde(skip)]
-    RecRecord(HashMap<Ident, RichTerm>, RecordAttrs),
+    RecRecord(
+        HashMap<Ident, RichTerm>,
+        Vec<(RichTerm, RichTerm)>, /* field whose name is defined by interpolation */
+        RecordAttrs,
+    ),
     /// A switch construct. The evaluation is done by the corresponding unary operator, but we
     /// still need this one for typechecking.
     Switch(
@@ -300,13 +304,16 @@ impl Term {
                     func(def)
                 }
             }
-            Record(ref mut static_map, _) | RecRecord(ref mut static_map, _) => {
-                static_map.iter_mut().for_each(|e| {
-                    let (_, t) = e;
-                    func(t);
+            Record(ref mut static_map, _) => {
+                static_map.iter_mut().for_each(|(_, t)| func(t));
+            }
+            RecRecord(ref mut static_map, ref mut dyn_fields, _) => {
+                static_map.iter_mut().for_each(|(_, t)| func(t));
+                dyn_fields.iter_mut().for_each(|(t1, t2)| {
+                    func(t1);
+                    func(t2);
                 });
             }
-
             Bool(_) | Num(_) | Str(_) | Lbl(_) | Var(_) | Sym(_) | Enum(_) | Import(_)
             | ResolvedImport(_) => {}
             Fun(_, ref mut t)
@@ -441,12 +448,18 @@ impl Term {
     /// Return a deep string representation of a term, used for printing in the REPL
     pub fn deep_repr(&self) -> String {
         match self {
-            Term::Record(fields, _) | Term::RecRecord(fields, _) => {
+            Term::Record(fields, _) | Term::RecRecord(fields, _, _) => {
                 let fields_str: Vec<String> = fields
                     .iter()
                     .map(|(ident, term)| format!("{} = {}", ident, term.as_ref().deep_repr()))
                     .collect();
-                format!("{{ {} }}", fields_str.join(", "))
+
+                let suffix = match self {
+                    Term::RecRecord(_, dyn_fields, _) if !dyn_fields.is_empty() => ", ..",
+                    _ => "",
+                };
+
+                format!("{{ {}{}}}", fields_str.join(", "), suffix)
             }
             Term::List(elements) => {
                 let elements_str: Vec<String> = elements
@@ -993,17 +1006,21 @@ impl RichTerm {
                     state,
                 )
             }
-            Term::RecRecord(map, attrs) => {
+            Term::RecRecord(map, dyn_fields, attrs) => {
                 // The annotation on `map_res` uses Result's corresponding trait to convert from
                 // Iterator<Result> to a Result<Iterator>
                 let map_res: Result<HashMap<Ident, RichTerm>, E> = map
                     .into_iter()
                     // For the conversion to work, note that we need a Result<(Ident,RichTerm), E>
-                    .map(|(id, t)| t.traverse(f, state).map(|t_ok| (id.clone(), t_ok)))
+                    .map(|(id, t)| Ok((id, t.traverse(f, state)?)))
+                    .collect();
+                let dyn_fields_res: Result<Vec<(RichTerm, RichTerm)>, E> = dyn_fields
+                    .into_iter()
+                    .map(|(id_t, t)| Ok((id_t.traverse(f, state)?, t.traverse(f, state)?)))
                     .collect();
                 f(
                     RichTerm {
-                        term: Box::new(Term::RecRecord(map_res?, attrs)),
+                        term: Box::new(Term::RecRecord(map_res?, dyn_fields_res?, attrs)),
                         pos,
                     },
                     state,
diff --git a/src/transformations.rs b/src/transformations.rs
index 9db0f517..3d046202 100644
--- a/src/transformations.rs
+++ b/src/transformations.rs
@@ -77,7 +77,7 @@ pub mod share_normal_form {
 
                 with_bindings(Term::Record(map, attrs), bindings, pos)
             }
-            Term::RecRecord(map, attrs) => {
+            Term::RecRecord(map, dyn_fields, attrs) => {
                 // When a recursive record is evaluated, all fields need to be turned to closures
                 // anyway (see the corresponding case in `eval::eval()`), which is what the share
                 // normal form transformation does. This is why the test is more lax here than for
@@ -100,7 +100,21 @@ pub mod share_normal_form {
                     })
                     .collect();
 
-                with_bindings(Term::RecRecord(map, attrs), bindings, pos)
+                let dyn_fields = dyn_fields
+                    .into_iter()
+                    .map(|(id_t, t)| {
+                        if !t.as_ref().is_constant() {
+                            let fresh_var = fresh_var();
+                            let pos_t = t.pos;
+                            bindings.push((fresh_var.clone(), t));
+                            (id_t, RichTerm::new(Term::Var(fresh_var), pos_t))
+                        } else {
+                            (id_t, t)
+                        }
+                    })
+                    .collect();
+
+                with_bindings(Term::RecRecord(map, dyn_fields, attrs), bindings, pos)
             }
             Term::List(ts) => {
                 let mut bindings = Vec::with_capacity(ts.len());
diff --git a/src/typecheck.rs b/src/typecheck.rs
index 2e9d9202..ab370bee 100644
--- a/src/typecheck.rs
+++ b/src/typecheck.rs
@@ -376,13 +376,14 @@ impl<'a> Envs<'a> {
             .collect()
     }
 
-    /// Add the bindings of a record to a typing environment.
+    /// Add the bindings of a record to a typing environment. Ignore fields whose name are defined
+    /// through interpolation.
     //TODO: support the case of a record with a type annotation.
     pub fn env_add_term(env: &mut Environment, rt: &RichTerm) -> Result<(), eval::EnvBuildError> {
         let RichTerm { term, pos } = rt;
 
         match term.as_ref() {
-            Term::Record(bindings, _) | Term::RecRecord(bindings, _) => {
+            Term::Record(bindings, _) | Term::RecRecord(bindings, ..) => {
                 for (id, t) in bindings {
                     let tyw: TypeWrapper =
                         apparent_type(t.as_ref(), Some(&Envs::from_global(env))).into();
@@ -612,9 +613,28 @@ fn type_check_(
             unify(state, strict, ty, mk_tyw_enum!(id.clone(), row))
                 .map_err(|err| err.into_typecheck_err(state, rt.pos))
         }
-        Term::Record(stat_map, _) | Term::RecRecord(stat_map, _) => {
+        // If some fields are defined dynamically, the only potential type that works is `{_ : a}`
+        // for some `a`
+        Term::RecRecord(stat_map, dynamic, _) if !dynamic.is_empty() => {
+            let ty_dyn = TypeWrapper::Ptr(new_var(state.table));
+
+            for (id, _) in stat_map {
+                envs.insert(id.clone(), ty_dyn.clone());
+            }
+
+            stat_map
+                .iter()
+                .try_for_each(|(_, t)| -> Result<(), TypecheckError> {
+                    type_check_(state, envs.clone(), strict, t, ty_dyn.clone())
+                })?;
+
+            unify(state, strict, ty, mk_typewrapper::dyn_record(ty_dyn))
+                    .map_err(|err| err.into_typecheck_err(state, rt.pos))
+        }
+        Term::Record(stat_map, _) | Term::RecRecord(stat_map, ..) => {
             // For recursive records, we look at the apparent type of each field and bind it in
-            // env before actually typechecking the content of fields
+            // env before actually typechecking the content of fields.
+            // Fields defined by interpolation are ignored.
             if let Term::RecRecord(..) = t.as_ref() {
                 for (id, rt) in stat_map {
                     let tyw = binding_type(rt.as_ref(), &envs, state.table, strict);


Test patch:
diff --git a/src/parser/tests.rs b/src/parser/tests.rs
index e5139240..0666a8ac 100644
--- a/src/parser/tests.rs
+++ b/src/parser/tests.rs
@@ -173,29 +173,43 @@ fn record_terms() {
             ]
             .into_iter()
             .collect(),
-            Default::default(),
+            Vec::new(),
+            Default::default()
         )
         .into()
     );
 
     assert_eq!(
         parse_without_pos("{ a = 1, \"#{123}\" = (if 4 then 5 else 6), d = 42}"),
-        mk_app!(
-            mk_term::op2(
-                BinaryOp::DynExtend(),
-                StrChunks(vec![StrChunk::expr(RichTerm::from(Num(123.)))]),
-                RecRecord(
-                    vec![
-                        (Ident::from("a"), Num(1.).into()),
-                        (Ident::from("d"), Num(42.).into()),
-                    ]
-                    .into_iter()
-                    .collect(),
-                    Default::default(),
-                )
-            ),
-            mk_app!(mk_term::op1(UnaryOp::Ite(), Num(4.)), Num(5.), Num(6.))
+        RecRecord(
+            vec![
+                (Ident::from("a"), Num(1.).into()),
+                (Ident::from("d"), Num(42.).into()),
+            ]
+            .into_iter()
+            .collect(),
+            vec![(
+                StrChunks(vec![StrChunk::expr(RichTerm::from(Num(123.)))]).into(),
+                mk_app!(mk_term::op1(UnaryOp::Ite(), Num(4.)), Num(5.), Num(6.))
+            )],
+            Default::default(),
+        )
+        .into()
+    );
+
+    assert_eq!(
+        parse_without_pos("{ a = 1, \"\\\"#}#\" = 2}"),
+        RecRecord(
+            vec![
+                (Ident::from("a"), Num(1.).into()),
+                (Ident::from("\"#}#"), Num(2.).into()),
+            ]
+            .into_iter()
+            .collect(),
+            Vec::new(),
+            Default::default(),
         )
+        .into()
     );
 }
 
diff --git a/tests/pass/record-defs.ncl b/tests/pass/record-defs.ncl
index 5a2a3ec2..a71d6c1c 100644
--- a/tests/pass/record-defs.ncl
+++ b/tests/pass/record-defs.ncl
@@ -29,6 +29,10 @@ let Assert = fun l x => x || %blame% l in
 ({foo.bar.baz = 1, bar.baz.foo = foo.bar.baz + 1}
   == {foo = {bar = {baz = 1}}, bar = {baz = {foo = 2}}}
   | #Assert) &&
+({foo."bar"."baz" = 1, "bar"."baz"."foo" = foo.bar."baz" + 1}
+  == {foo = {bar = {baz = 1}}, bar = {baz = {foo = 2}}}
+  | #Assert) &&
+
 
 // piecewise_annotations
 ({foo.bar | default = 1, foo.baz = 2}
@@ -37,5 +41,10 @@ let Assert = fun l x => x || %blame% l in
 ({foo.bar | default = 1, foo.bar = 2} == {foo = {bar = 2}} | #Assert) &&
 ({foo.bar.baz | Bool = true}.foo.bar.baz | #Assert) &&
 
+// recursive_dynamic_fields
+(let x = "foo" in
+  {"#{x}" = bar, bar = 1} == {foo = 1, bar = 1} | #Assert) &&
+({"#foo"."#bar".baz = other + 1, other = 0}."#foo"."#bar".baz == 1) &&
+
 
 true
diff --git a/tests/pass/typechecking.ncl b/tests/pass/typechecking.ncl
index 5211d704..b6debd92 100644
--- a/tests/pass/typechecking.ncl
+++ b/tests/pass/typechecking.ncl
@@ -167,6 +167,9 @@ let typecheck = [
 
   // Typed import
   import "typed-import.ncl" : Num,
+
+  // recursive_records_quoted
+  {"foo" = 1} : {foo : Num},
 ] in
 
 
diff --git a/tests/records_fail.rs b/tests/records_fail.rs
index 2394cba4..b24e6fc6 100644
--- a/tests/records_fail.rs
+++ b/tests/records_fail.rs
@@ -1,5 +1,5 @@
 use assert_matches::assert_matches;
-use nickel::error::{Error, EvalError};
+use nickel::error::{Error, EvalError, TypecheckError};
 
 mod common;
 use common::eval;
@@ -42,3 +42,11 @@ fn non_mergeable_piecewise() {
         Err(Error::EvalError(EvalError::MergeIncompatibleArgs(..)))
     );
 }
+
+#[test]
+fn dynamic_not_recursive() {
+    assert_matches!(
+        eval("let x = \"foo\" in {\"#{x}\" = 1, bar = foo}.bar"),
+        Err(Error::TypecheckError(TypecheckError::UnboundIdentifier(..)))
+    );
+}
diff --git a/tests/typecheck_fail.rs b/tests/typecheck_fail.rs
index 815a479a..14d7919c 100644
--- a/tests/typecheck_fail.rs
+++ b/tests/typecheck_fail.rs
@@ -262,3 +262,11 @@ fn shallow_type_inference() {
         Err(TypecheckError::TypeMismatch(..))
     );
 }
+
+#[test]
+fn dynamic_record_field() {
+    assert_matches!(
+        type_check_expr("let x = \"foo\" in {\"#{x}\" = 1} : {foo: Num}"),
+        Err(TypecheckError::TypeMismatch(..))
+    );
+}