Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
331 changes: 331 additions & 0 deletions Dev diary/2026-07-18-display-multiple-values.md

Large diffs are not rendered by default.

62 changes: 62 additions & 0 deletions Docs/02-getting-started/hello-world.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,68 @@ display "Hello, " with name with "!"
Hello, Bob!
```

## Display Several Values at Once

You don't have to write `with` between every piece. A `display` can list
several values separated by spaces — quoted text is shown as-is, and each other
item is evaluated first: a variable, a number, an action call, or an expression
like `age plus 10`:

```wfl
store name as "Alice"
display "Hello, " name "!"
```

**Output:**
```
Hello, Alice!
```

This is just a shorthand: `display "Hello, " name "!"` means exactly the same
thing as `display "Hello, " with name with "!"` — not just the same result,
but the same order of evaluation, so a value that changes as a side effect of
a later item (e.g. popping from a list) behaves identically either way.

Because the values are joined directly (no space is added for you), put any
spaces you want inside the quotes:

```wfl
store age as 25
display "I am " age " years old" // I am 25 years old
display "I am" age "years old" // I am25years old ← note the missing spaces
```

> **Tip:** `with` and the space-separated form do the same job — use whichever
> reads more clearly. Just pick *one* form within a single `display`: mixing
> them in the same statement (like `display a with b c`) can group the values
> differently and change the order they're evaluated, so a run of pure `with`
> or pure spaces stays predictable while a mix may not.

A run of plain words with nothing between them (no quotes, numbers, or
keywords) is a single multi-word variable name, not several values — `display
a b c` looks for one variable literally named `a b c`, the same as it would
outside a `display`. Space-separated values only split apart where the grammar
already has a boundary: a quote, a number, a parenthesis, or one of the
keywords that begins a value on its own (such as `not`, `file exists`, or an
action `call`).

Two kinds of tokens do *not* start a new value, for different reasons:

- Words joined by an operator like `plus` or `minus` stay part of the *same*
value. `display numbers 0` stays a single value — a direct index into
`numbers` — and `display total -5` stays a single value — `total` *minus*
`5` — because both `0` and `-5` attach to the item right before them the
same way they would anywhere else in WFL.
- A keyword that starts a *different kind of statement* — `count from ...`, a
loop, `create ...`, `change ... to ...`, and a few others — ends the
`display` right where it is, exactly as a line break would, and begins its
own statement immediately after. `display "start" count from 1 to 3:` still
displays `start` and then opens a count loop, just as it did before
`display` accepted multiple values.

When you want two values that would otherwise merge like the first case, use
`with` to make the boundary explicit.

## Experiment!

WFL is designed for experimentation. Try these:
Expand Down
76 changes: 76 additions & 0 deletions TestPrograms/display_multiple_values.wfl
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Multi-value `display`: a display can list several space-separated values.
// Quoted text is shown as-is; each other item (a variable, a number, an action
// call, or an expression) is evaluated first. The values are joined exactly
// like `with` — see the bottom of this file for where that "space-separated"
// framing breaks down (bare multi-word identifiers, indexing, and arithmetic
// all claim the space before a value-form fold ever gets a chance to run).

store user age as 28
display "user age is " user age // user age is 28

change user age to 9
display "user age is " user age // user age is 9

// Equivalent, written with `with`:
display "user age is " with user age // user age is 9

// More than two values, mixing text, variables and an expression:
store name as "Alice"
display name " is " user age " years old" // Alice is 9 years old
display "in ten years: " user age plus 10 // in ten years: 19

// A single value still behaves exactly as before:
display user age // 9

// Direct index access is unchanged: `list index` is one value, not two.
create list scores:
add 100
add 200
add 300
end list
display "first score: " scores 0 // first score: 100

// Keyword-led values fold too. A `call` to a user-defined action:
define action called doubled with parameters n:
give back n times 2
end action
display "doubled: " call doubled with 21 // doubled: 42

// The count-loop variable `count` as a trailing value:
count from 1 to 3:
display "count is " count // count is 1 / 2 / 3
end count

// The following condition from the original report now sees the right value:
check if user age is greater than 18:
display "Access granted"
otherwise:
display "Must be 18 or older"
end check

// Values are folded right-associatively, exactly matching `with`'s evaluation
// order — not just its final text. Popping "after" off a list changes what
// the list looks like when it gets stringified; both forms below stringify
// the list *after* the pop runs, so both print "[before]after":
create list left_items:
add "before"
add "after"
end list
display left_items "" pop of left_items // [before]after

create list right_items:
add "before"
add "after"
end list
display right_items with "" with pop of right_items // [before]after

// A few more keyword-led values that now fold safely (see is_value_start in
// src/parser/helpers.rs for the full list and why each is unambiguous):
store is_admin as no
display "is admin: " not is_admin // is admin: yes
display "exists: " file exists at "does-not-exist.txt" // exists: no

// A run of plain words with nothing between them is ONE multi-word variable,
// not several values — this looks up a variable literally named "a b c",
// the same as it would anywhere else in WFL:
// display a b c ← NOT three values; would need `a with b with c`
19 changes: 19 additions & 0 deletions TestPrograms/docs_examples/_meta/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,25 @@
],
"doc_purpose": "Demonstrates the simplest WFL program"
},
"docs_examples/basic_syntax/display_multiple_01.wfl": {
"doc_section": "Docs/02-getting-started/hello-world.md#display-several-values-at-once",
"type": "executable",
"validate_layers": [
1,
2,
3,
4,
5
],
"expected_exit_code": 0,
"tags": [
"beginner",
"getting-started",
"display",
"concatenation"
],
"doc_purpose": "Shows that display accepts multiple space-separated values, equivalent to joining them with 'with'"
},
"docs_examples/basic_syntax/variables_01.wfl": {
"doc_section": "Docs/03-language-basics/variables-and-types.md",
"type": "executable",
Expand Down
13 changes: 13 additions & 0 deletions TestPrograms/docs_examples/basic_syntax/display_multiple_01.wfl
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Display several values at once (Docs/02-getting-started/hello-world.md).
// Quoted text is shown as-is; a variable or expression is evaluated first.
// The space-separated form is shorthand for joining values with `with`.

store name as "Alice"
display "Hello, " name "!" // Hello, Alice!

// Exactly the same result, written with `with`:
display "Hello, " with name with "!" // Hello, Alice!

// Spaces come from the quotes, not from the join:
store age as 25
display "I am " age " years old" // I am 25 years old
81 changes: 75 additions & 6 deletions src/parser/expr/primary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,81 @@ pub(crate) trait PrimaryExprParser<'a> {

impl<'a> PrimaryExprParser<'a> for Parser<'a> {
fn parse_primary_expression(&mut self) -> Result<Expression, ParseError> {
#[cfg(debug_assertions)]
let leading = self.cursor.peek().cloned();
let result = self.parse_primary_expression_dispatch();

// Runtime coupling check between `can_start_primary_expression` (the
// predicate `display`'s multi-value fold is built on, in
// `src/parser/helpers.rs`) and this function's *actual* dispatch
// below. Unlike a hand-picked sample test, this runs on every
// primary-expression parse — every parser test, every
// `TestPrograms/*.wfl` run, every program compiled in a debug build —
// so a new dispatch arm added below without updating
// `can_start_primary_expression` (or the reverse) panics the first
// time anything exercises that token. Compiled out entirely in
// release builds, like the rest of this crate's `debug_assert!`
// invariants.
//
// Only the "predicted true" direction also checks position, not just
// message text: an arm that recurses (e.g. `file size of <expr>`) can
// propagate a nested failure's "Unexpected token in expression" text
// up through `?`, but that nested error's position belongs to
// whatever token deep inside actually failed, not to `leading` — so
// requiring both the message *and* the position to match tells "this
// token itself has no arm" apart from "this token's arm recursed into
// something else that failed". The "predicted false" direction needs
// no such check: every token classified as a non-starter always
// dispatches to an arm that errors unconditionally.
//
// The whole check — the `leading` capture above included — is behind
// `#[cfg(debug_assertions)]`, so release builds pay nothing for it (no
// token clone, no reclassification, no error inspection), not merely a
// stripped `debug_assert!`.
#[cfg(debug_assertions)]
if let Some(leading) = leading {
let predicted_can_start = Self::can_start_primary_expression(&leading.token);
if !predicted_can_start {
debug_assert!(
result.is_err(),
"can_start_primary_expression predicted {:?} could not start an \
expression, but parse_primary_expression succeeded",
leading.token
);
} else {
let fell_through_to_fallback = if let Err(error) = &result {
error.message.contains("Unexpected token in expression")
&& error.line == leading.line
&& error.column == leading.column
} else {
false
};
debug_assert!(
!fell_through_to_fallback,
"can_start_primary_expression predicted {:?} could start an \
expression, but parse_primary_expression had no dedicated arm for it",
leading.token
);
}
}

result
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

fn parse_list_element(&mut self) -> Result<Expression, ParseError> {
// Parse a single list element without parsing binary operators
// This prevents "and" from being interpreted as a boolean operator
self.parse_primary_expression()
}
}

impl<'a> Parser<'a> {
/// The actual primary-expression dispatch. Call `parse_primary_expression`
/// (the trait method above), not this directly — it wraps this function
/// with a debug-only check that keeps `can_start_primary_expression` from
/// silently drifting away from what this dispatch really accepts, and
/// recursive calls from within the arms below go through that wrapper too.
fn parse_primary_expression_dispatch(&mut self) -> Result<Expression, ParseError> {
// Strided run-budget checkpoint. Every operand (list element, operator-
// chain term, call argument) routes through here, so this bounds a single
// huge expression that the statement-boundary checkpoint would miss.
Expand Down Expand Up @@ -1294,10 +1369,4 @@ impl<'a> PrimaryExprParser<'a> for Parser<'a> {
))
}
}

fn parse_list_element(&mut self) -> Result<Expression, ParseError> {
// Parse a single list element without parsing binary operators
// This prevents "and" from being interpreted as a boolean operator
self.parse_primary_expression()
}
}
Loading
Loading