Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Deep Nesting

Reading, writing and walking maps and lists that are many levels deep.

Overview

Configuration files, JSON documents and API responses all arrive as maps containing lists containing maps. Suji handles these with two postfix operators that chain freely:

  • : reads a map key written as a bare identifier — config:server:port
  • [] indexes a list, or reads a map key given by any expression — rows[0], m["a b"]

Key Characteristics

  • Chains mix both operators - data[0]:users[1]:config:preferences:email
  • Chains are assignable - the same expression works on the left of =
  • Depth is not a problem - ten levels behave exactly like two
  • Missing keys are fatal - reading an absent key aborts the program
  • ::get(key, default) is the safe alternative

Reading Nested Values

Maps

import std:println

config = {
    user: {
        profile: {
            settings: {
                display: {
                    theme: "light",
                    layout: "grid"
                },
                notifications: true
            },
            avatar: "user.png"
        },
        name: "Alice"
    },
    version: "1.0"
}

println(config:user:profile:settings:display:theme)    # light
println(config:user:name)                              # Alice
println(config:version)                                # 1.0

Bracket notation does the same job and is required when a key is not a bare identifier — because it contains a space or a dash, or because it is computed:

import std:println

m = { "content-type": "text/plain", "max size": 1024 }
key = "content-type"

println(m["content-type"])    # text/plain
println(m[key])               # text/plain
println(m["max size"])        # 1024

Lists

Indices chain the same way:

import std:println

matrix = [[[[1, 2, 3, 4], [5, 6, 7, 8]], [[9, 10, 11, 12], [13, 14, 15, 16]]]]

println(matrix[0][1][1][3])    # 16
println(matrix[0][0][0][0])    # 1

Mixed chains

Real data alternates between the two. Read it in one expression:

import std:println

data = [{
    users: [
        { name: "Alice", config: { preferences: { notifications: { email: false } } } },
        { name: "Bob", config: { preferences: { notifications: { email: true } } } }
    ],
    count: 2
}]

println(data[0]:users[1]:name)                                    # Bob
println(data[0]:users[1]:config:preferences:notifications:email)  # true
println(data[0]:count)                                            # 2

Depth really is unlimited in practice:

import std:println

deep = { l1: { l2: { l3: { l4: { l5: { l6: { l7: { l8: { l9: { l10: "bottom" } } } } } } } } } }

println(deep:l1:l2:l3:l4:l5:l6:l7:l8:l9:l10)    # bottom

Assigning Through a Chain

Any chain you can read, you can also assign to. The intermediate containers must already exist; only the final key may be new.

import std:println

config = { server: { http: { port: 8080 } } }

# Update an existing leaf
config:server:http:port = 9090
println(config:server:http:port)    # 9090

# Add a new leaf to an existing map
config:server:http:tls = true
println(config:server:http:tls)     # true

# Add a whole new subtree
config:server:grpc = { port: 50051 }
println(config:server:grpc:port)    # 50051

Lists work identically, including nested ones:

import std:println

matrix = [[[[1, 2, 3, 4], [5, 6, 7, 8]], [[9, 10, 11, 12], [13, 14, 15, 16]]]]

matrix[0][1][1][3] = 99
println(matrix[0][1][1][3])    # 99

And mixed chains:

import std:println

data = [{ users: [{ name: "Alice", tags: ["a"] }] }]

data[0]:users[0]:name = "Ada"
data[0]:users[0]:tags[0] = "b"

println(data[0]:users[0]:name)       # Ada
println(data[0]:users[0]:tags[0])    # b

Creating a missing level

Assigning through a level that does not exist yet fails, so create it first:

import std:println

config = { server: {} }

match config:server::contains("http") {
    false => { config:server:http = {} }
    _ => { nil }
}

config:server:http:port = 8080
println(config:server:http:port)    # 8080

Missing Keys Are Fatal

Reading a key that is not present raises [404] Key not found and terminates the program — and in a long chain, the failure happens at the first missing link:

[404] Error: Key not found
   ╭─[ script.si:4:9 ]
   │
 4 │ println(config:server:grpc:port)
   │         ─────────┬────────
   │                  ╰────────── Key 'grpc' not found in map
   │
   │ Note 1: Check array/map bounds and key existence
───╯
Error: Key not found: Key 'grpc' not found in map

::get(key, default) never fails, so it is the right tool at every level where the key is optional:

import std:println

config = { server: { http: { port: 8080 } } }

println(config:server::get("http")::get("port", 80))    # 8080
println(config:server::get("grpc", {})::get("port", 0))  # 0

Note the {} default in the second call: it keeps the chain going by supplying an empty map to the next ::get().

A reusable safe reader

For deep or variable paths, walk a list of keys and bail out at the first miss:

import std:println

get_path = |root, path| {
    node = root
    loop through path with key {
        !node::is_map() && return nil
        !node::contains(key) && return nil
        node = node::get(key)
    }
    return node
}

config = { server: { http: { port: 8080 } } }

println(get_path(config, ["server", "http", "port"]))    # 8080
println(get_path(config, ["server", "grpc", "port"]))    # nil
println(get_path(config, ["nope"]))                      # nil

Building Nested Structures

The literal form is usually clearest:

import std:println

app = {
    name: "demo",
    services: [
        { name: "api", port: 8080 },
        { name: "worker", port: 0 }
    ],
    limits: { memory_mb: 512, cpu: 2 }
}

println(app:services[0]:port)    # 8080
println(app:limits:memory_mb)    # 512

Building incrementally works too — assign an empty container, then fill it:

import std:println

report = {}
report:totals = {}
report:totals:count = 0

rows = []
loop through [3, 5, 7] with n {
    rows::push({ "value": n })
    report:totals:count = report:totals:count + n
}
report:rows = rows

println(report:totals:count)      # 15
println(report:rows::length())    # 3
println(report:rows[1]:value)     # 5

Note that rows is built as a plain variable and attached at the end. A mutating method cannot be called through a chain — report:rows::push(x) raises Cannot call mutating method on immutable value — so build the list first, or reassign the whole value:

import std:println

report = { rows: [1, 2] }

report:rows = report:rows + [3]
println(report:rows::length())    # 3

Maps and lists are copied into function parameters

This surprises people building nested data in helpers: arguments are passed by value. Mutating a parameter does not change the caller’s structure, but a variable captured from an enclosing scope is shared.

import std:println

m = { a: 1 }

# Parameter: the mutation is lost
by_param = |target| { target["b"] = 2 }
by_param(m)
println(m::contains("b"))    # false

# Capture: the mutation sticks
by_capture = || { m["c"] = 3 }
by_capture()
println(m::contains("c"))    # true

So a helper that adds to a nested structure should return the new value rather than mutate its argument.

Walking Nested Structures

Maps

loop through map with k, v gives you both halves of each entry — two bindings are allowed for maps only:

import std:println

settings = { theme: "dark", layout: "grid", zoom: 2 }

loop through settings with k, v {
    println("${k} = ${v}")
}
# theme = dark
# layout = grid
# zoom = 2

keys(), values() and to_list() give the same data as ordinary lists. to_list() yields (key, value) tuples, which you destructure:

import std:println

settings = { theme: "dark", zoom: 2 }

println(settings::keys()::join(", "))      # theme, zoom
println(settings::length())                # 2

loop through settings::to_list() with pair {
    k, v = pair
    println("${k} -> ${v}")
}
# theme -> dark
# zoom -> 2

Nested loops

Iterating a structure that alternates lists and maps is just nested loops:

import std:println

teams = [
    { name: "red", members: ["ann", "bo"] },
    { name: "blue", members: ["cy"] }
]

loop through teams with team {
    loop through team:members with member {
        println("${team:name}/${member}")
    }
}
# red/ann
# red/bo
# blue/cy

Recursive walks

A recursive helper flattens an arbitrarily nested map into path = value lines. Return a list and concatenate rather than accumulating into a parameter:

import std:println

flatten = |root, prefix| {
    out = []
    loop through root with k, v {
        full = match {
            prefix == "" => "${k}",
            _ => "${prefix}.${k}",
        }
        match v::is_map() {
            true => { out = out + flatten(v, full) }
            _ => { out::push("${full} = ${v}") }
        }
    }
    return out
}

config = { server: { http: { port: 8080, host: "localhost" } }, debug: false }

println(flatten(config, "")::join("\n"))
# server.http.port = 8080
# server.http.host = localhost
# debug = false

Recursion depth is limited — around 600–700 frames before the interpreter aborts — which is far more than any realistic document nesting, but it does rule out walking cyclic structures.

Common Patterns

Reading a parsed document. json:parse returns exactly the nested maps and lists described above:

import std:println
import std:json

text = '{"meta": {"page": 1}, "items": [{"id": 7, "tags": ["a", "b"]}]}'
doc = json:parse(text)

println(doc:meta:page)          # 1
println(doc:items[0]:id)        # 7
println(doc:items[0]:tags[1])   # b

Round-tripping a config file:

import std:println
import std:json
import std:io

path = `mktemp`
f = io:open(path, true, true)
f::write('{"server": {"http": {"port": 8080}}}')
f::close()

g = io:open(path)
config = json:parse(g::read_all())
g::close()

config:server:http:port = 9090
println(json:generate(config))    # {"server":{"http":{"port":9090}}}

Collecting a field from every record:

import std:println

users = [
    { profile: { email: "a@x" } },
    { profile: { email: "b@x" } }
]

emails = users::map(|u| u:profile:email)
println(emails::join(", "))    # a@x, b@x

Defaults for an optional subtree:

import std:println

settings = { display: { theme: "dark" } }

display = settings::get("display", {})
println(display::get("theme", "light"))     # dark
println(display::get("density", "cosy"))    # cosy

audio = settings::get("audio", {})
println(audio::get("volume", 50))           # 50

See Also

  • Maps - key access, ::get(), ::contains()
  • Lists - indexing and slices
  • Loops - loop through … with k, v
  • JSON - producing nested structures from text
  • Error Handling - why a missing key ends the program