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
108 changes: 108 additions & 0 deletions Dev diary/2026-07-06-github-issues-batch-583-582-566-557-567.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# GitHub Issues Batch: String/Param/Operator/Scope/Typing Fixes

**Date:** 2026-07-06

## Overview

An open-issues review turned up five genuine, still-reproducible bugs on
`main` (verified against a fresh `cargo build --release`) alongside several
issues that had already been fixed by earlier PRs but never closed. This
entry covers the five fixes landed here; the already-resolved issues are
noted at the end for the record.

## Fixed here

### #583 — a quoted `"[]"` string was coerced to an empty list

The `VariableDeclaration` execution arm hard-coded a special case that turned
any `Text` value equal to the two characters `[]` into an empty `List`. A
quoted string's characters must never decide its type, so the coercion block
was deleted (`src/interpreter/mod.rs`). `store a as "[]"` now keeps `a` as
`Text`, including when the value is built at runtime (`substring`, `with`
concatenation, an action return) and flows through a `store`. No
`TestPrograms` relied on the old behavior.

### #582 — an action parameter was overridden by a same-named global

Parameters were bound with `Environment::define()`, which *rejects* a name
already present in a parent scope. The call site discarded that error, so
when a global of the same name existed the parameter was never bound and the
body resolved to the global instead — a silent correctness bug (e.g. a
templating helper with a `t`/`v` parameter clobbered by a caller's `t`/`v`
globals). Parameters are now bound with `define_direct()` (current-scope
only), so a parameter unconditionally shadows any outer/global binding. Fixed
for both the action-call path (`call_function`) and event-handler params.

### #566 — `X starts with Y` / `X ends with Y` swallowed as identifiers

There were no lexer tokens for `starts`/`ends`, so the lexer's multi-word
identifier accumulator absorbed `path ends` into a single identifier and the
trailing `with "…"` dangled, failing semantic analysis with
`Variable 'path ends' is not defined`. This silently broke prefix/suffix
matching in web-server routing (and several demo programs "passed" only
under `--analyze`, which is lenient here).

Fix: added `KeywordStarts` / `KeywordEnds` tokens (contextual, so they can
still be used as ordinary names) and an infix parse in `binary.rs` at
comparison precedence that desugars to the existing `starts_with` /
`ends_with` builtins — no new interpreter or type-checker operator needed.
The `route` construct's `when starts with` / `when ends with` arms were
updated to match the new tokens. All existing uses in the codebase were
already the operator form, so this is backward compatible and fixes the
previously-broken demos.

### #557 — date-unit locals were fatal inside included files

The six singular date-unit words (`year`, `month`, `day`, `hour`, `minute`,
`second`) are registered as global native functions. When an *included* file
was analyzed, the interpreter seeded **all** global env values — including
these native functions — as parent "variables" for the include analyzer, so
an action-local `store year as …` fatally conflicted with the builtin's
outer-scope binding. The same code in the main file only warns and runs,
because the main-file analyzer never has builtins as symbols.

Fix: `extract_parent_variables` now skips `Value::NativeFunction` entries.
Builtins are already resolved through `is_builtin_function`, so seeding them
as shadowable variables only made includes stricter than main. Included
files now behave like the main file: an action-local shadows the builtin
(non-fatal), matching the documented main-file behavior.

### #567 — `Any` / `Unknown` values rejected by strict type-checker rules

Values whose static type is `Any` (list-index results) or `Unknown` (untyped
parameters) were rejected by several ERROR-level rules even though the
program runs correctly. Under gradual typing these mean "statically
unknown", not "known incompatible". Fixed the type checker to accept them:

- `add X to <number>` and `add X to <list>` accept `Any`/`Unknown`.
- Binary arithmetic on an `Any` operand degrades gracefully (mirrors the
existing `Unknown` handling) instead of erroring; comparisons still yield
`Boolean`, `Plus` with a `Text` operand still yields `Text`.
- `split X by Y` accepts `Any`/`Unknown` for both operands.
- Referencing a binding with no recorded type (most commonly an untyped
parameter) now yields `Unknown` silently instead of raising
`Cannot determine type of variable`.

(The sibling issues #560 and #569 — unannotated action return types typed as
`Nothing` — were already fixed by PR #575; verified with fresh repros.)

## Tests

`tests/github_issues_batch_test.rs` — 13 self-contained regression tests
covering all five issues (literal and runtime-built `"[]"`, single- and
multi-parameter shadowing, `starts`/`ends with` positive/negative/stored-
boolean forms, date-unit locals across all six words in an include, and
`Any`/`Unknown` flowing into `add`/arithmetic/`split`). All `TestPrograms`
continue to pass (no regressions), including the `route` and web-server
programs that exercise the new `starts`/`ends with` tokens.

## Already resolved on `main` (verified, no code change)

- **#580** — `of` form for include-exposed actions: fixed by PR #581.
- **#560 / #569** — action return-type inference: fixed by PR #575.
- **#573** — binary web content + MIME: fixed by PR #574.
- **#571** (core items: precedence, `/`, `finally`, `between`,
`is above`/`is below`, `modulo`, error binding): fixed by PR #577. The
remaining #571 items overlap with the #578 follow-up basket
(`repeat N times`, text→number conversion, pattern-VM and filesystem-glob
gaps) and are left for dedicated work.
34 changes: 23 additions & 11 deletions src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1560,6 +1560,16 @@ impl Interpreter {
let env_borrowed = env.borrow();

for (name, value) in &env_borrowed.values {
// Skip native builtins (e.g. `year`, `month`, `day`, `length`, ...).
// The analyzer already resolves these through `is_builtin_function`,
// so seeding them as parent *variables* only makes an included file
// stricter than the main file: an action-local `store year as ...`
// would fatally conflict with the builtin's outer-scope binding even
// though the same code runs fine in a main program (#557). Leaving
// them out lets locals shadow builtins consistently in both paths.
if matches!(value, Value::NativeFunction(_, _)) {
continue;
}
let inferred_type = Self::infer_type_from_value(value);
// Check if this variable is a constant (immutable)
let is_mutable = !env_borrowed.constants.contains(name);
Expand Down Expand Up @@ -2264,13 +2274,7 @@ impl Interpreter {
line: _line,
column: _column,
} => {
let mut evaluated_value = self.evaluate_expression(value, Rc::clone(&env)).await?;

if let Value::Text(text) = &evaluated_value
&& text.as_ref() == "[]"
{
evaluated_value = Value::List(Rc::new(RefCell::new(Vec::new())));
}
let evaluated_value = self.evaluate_expression(value, Rc::clone(&env)).await?;

#[cfg(debug_assertions)]
exec_var_declare!(name, &evaluated_value);
Expand Down Expand Up @@ -4882,14 +4886,18 @@ impl Interpreter {
// Create a new environment for the handler
let handler_env = Environment::new_child_env(&env);

// Bind arguments to parameters
// Bind arguments to parameters. Use define_direct so a
// parameter shadows any same-named global rather than being
// rejected as already-defined-in-outer-scope (#582).
for (i, param_name) in event.params.iter().enumerate() {
if i < arg_values.len() {
let _ = handler_env
.borrow_mut()
.define(param_name, arg_values[i].clone());
.define_direct(param_name, arg_values[i].clone());
} else {
let _ = handler_env.borrow_mut().define(param_name, Value::Null);
let _ = handler_env
.borrow_mut()
.define_direct(param_name, Value::Null);
}
}

Expand Down Expand Up @@ -8478,7 +8486,11 @@ impl Interpreter {

#[cfg(debug_assertions)]
exec_var_declare!(param, &arg);
let _ = call_env.borrow_mut().define(param, arg.clone());
// Bind parameters directly in the call scope so they shadow any
// same-named global/outer binding. `define` (which rejects names
// present in a parent scope) would otherwise leave the parameter
// unbound and let the body resolve to the global instead (#582).
let _ = call_env.borrow_mut().define_direct(param, arg.clone());
}

let frame = CallFrame::new(
Expand Down
6 changes: 6 additions & 0 deletions src/lexer/token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,10 @@ pub enum Token {
KeywordModulo, // word form of the '%' operator
#[token("contains")]
KeywordContains,
#[token("starts")]
KeywordStarts, // e.g., "path starts with \"/api\""
#[token("ends")]
KeywordEnds, // e.g., "file ends with \".css\""
#[token("pattern")]
KeywordPattern,
#[token("matches")]
Expand Down Expand Up @@ -643,6 +647,8 @@ impl Token {
| Token::KeywordExtension
| Token::KeywordExtensions
| Token::KeywordContains // Can be a function name
| Token::KeywordStarts // Operator in 'X starts with Y'; else a name
| Token::KeywordEnds // Operator in 'X ends with Y'; else a name
| Token::KeywordList // Only reserved in type/create context
| Token::KeywordMap // Only reserved in type/create context
| Token::KeywordText // Only reserved in type context
Expand Down
47 changes: 47 additions & 0 deletions src/parser/expr/binary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,53 @@ impl<'a> BinaryExprParser<'a> for Parser<'a> {
));
}
}
Token::KeywordStarts | Token::KeywordEnds => {
// `X starts with Y` / `X ends with Y` are substring predicates
// at comparison precedence (1). They desugar to the
// `starts_with` / `ends_with` builtins so no new interpreter
// or type-checker operator is needed (#566). Before this,
// `starts`/`ends` had no token and the lexer's multi-word
// identifier accumulator swallowed `path ends` into one name.
if 1 < precedence {
break;
}
let is_starts = matches!(token, Token::KeywordStarts);
// Only an operator when directly followed by `with`; otherwise
// it is a plain (contextual) identifier — leave it in place.
if !self
.cursor
.peek_next()
.is_some_and(|t| t.token == Token::KeywordWith)
{
break;
}
self.bump_sync(); // Consume "starts"/"ends"
self.bump_sync(); // Consume "with"
// RHS binds at precedence 2 (tighter than comparison), matching
// how `contains`/`is` parse their right-hand side.
let right = self.parse_binary_expression(2)?;
let fn_name = if is_starts {
"starts_with"
} else {
"ends_with"
};
left = Expression::FunctionCall {
function: Box::new(Expression::Variable(fn_name.to_string(), line, column)),
arguments: vec![
Argument {
name: None,
value: left,
},
Argument {
name: None,
value: right,
},
],
line,
column,
};
continue;
}
Token::KeywordContains => {
// 'contains' is a comparison operator at precedence 1.
if 1 < precedence {
Expand Down
23 changes: 11 additions & 12 deletions src/parser/stmt/route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,12 +257,12 @@ impl<'a> Parser<'a> {
))
}
// `when starts with V` → starts_with of subject and V
Token::Identifier(id)
if id == "starts"
&& self
.cursor
.peek_next()
.is_some_and(|t| t.token == Token::KeywordWith) =>
// (`starts`/`ends` are now KeywordStarts/KeywordEnds tokens — #566.)
Token::KeywordStarts
if self
.cursor
.peek_next()
.is_some_and(|t| t.token == Token::KeywordWith) =>
{
self.bump_sync(); // Consume "starts"
self.bump_sync(); // Consume "with"
Expand All @@ -276,12 +276,11 @@ impl<'a> Parser<'a> {
))
}
// `when ends with V` → ends_with of subject and V
Token::Identifier(id)
if id == "ends"
&& self
.cursor
.peek_next()
.is_some_and(|t| t.token == Token::KeywordWith) =>
Token::KeywordEnds
if self
.cursor
.peek_next()
.is_some_and(|t| t.token == Token::KeywordWith) =>
{
self.bump_sync(); // Consume "ends"
self.bump_sync(); // Consume "with"
Expand Down
Loading
Loading