Skip to content

Implement for/while/do-while/try-catch, braceless if-bodies, array comprehensions - #78

Merged
vantreeseba merged 5 commits into
vantreeseba:mainfrom
masquepublishing:fix/for-while-trycatch-comprehensions
Jul 8, 2026
Merged

Implement for/while/do-while/try-catch, braceless if-bodies, array comprehensions#78
vantreeseba merged 5 commits into
vantreeseba:mainfrom
masquepublishing:fix/for-while-trycatch-comprehensions

Conversation

@mallyskies

@mallyskies mallyskies commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Fixes the underlying gap #60 (closed, unmerged WIP) attempted to address, with a from-scratch implementation against the Haxe language reference (haxe.org/manual/expression-*.html, block through inline), not a port of that PR's approach. Also fixes #62 (nested for loops sometimes fail to highlight) as a side effect of the underlying grammar fix -- the highlighter relies on real for_statement/while_statement/etc. nodes, which this PR adds for the first time.

for, while, do, try, and catch were bare placeholder keyword tokens (keyword: choice('catch', 'do', 'for', 'try', 'while')) with no real grammar rule at all. A statement like for (i in 0...10) trace(i); parsed with no ERROR node — but only because for, (i in 0...10), and trace(i); happened to each independently satisfy some other, unrelated statement/expression rule as three disconnected siblings, not because for was actually understood as a loop. Same story for while, do ... while, and try ... catch. This is a "wrong tree, not a broken one" bug — has_error-based checks are blind to it.

This PR adds:

  • Real for_statement, while_statement, do_while_statement, try_statement (with typed/untyped/multiple catch clauses) rules.
  • Array comprehension ([for (...) ...], [while (...) ...]), including a dedicated comprehension_while node (upstream's own later while-comprehension test — merged via Include the tests from pr_60 to make sure we can close that. #77 — expected the old disconnected-siblings fallback shape; this PR's comprehension_while test corrects that expectation to match the real node).
  • Braceless if/else bodies (if (cond) expr;, no {}) — if/else/else if bodies now use $.statement (which already covers both the { ... } and bare-expr; shapes) instead of forcing $.block everywhere. This also folds in the already-merged multi-branch else if fix (Fix bare 'this', prefix nullable params, and multi-branch else-if chains #71) as a strict subset — same per-branch repeat(...) structure, just with wider body types.
  • A widened _chain_term (chain tail positions only) to also accept $.subscript_expression, so comparisons like while (null != m_timer[id]) { ... } parse as a real chain instead of erroring — found via a real codebase-wide sweep, rather than synthesized.

Verified against current main:

  • Full corpus suite passes (222/222, no regressions to existing tests).
  • Confirmed this is a genuine fix, not just conflict-free: parsing a snippet exercising every construct here (for/while/do-while/try-catch/both comprehension forms/braceless if-else) against unmodified current main still produces an ERROR node; the same snippet parses cleanly with this branch.

…mprehensions

Fixes github.com/vantreeseba/issues/60's underlying gap
(a WIP PR attempting the same thing) with a from-scratch implementation
against the Haxe language reference (haxe.org/manual/expression-*.html,
block through inline), not a port of that PR's approach.

`for`, `while`, `do`, `try`, and `catch` were bare placeholder keyword
tokens (`keyword: choice('catch', 'do', 'for', 'try', 'while')`) with no
real grammar rule at all. A statement like `for (i in 0...10) trace(i);`
parsed with NO error node -- but only because `for`, `(i in 0...10)`, and
`trace(i);` happened to each independently satisfy some other, unrelated
statement/expression rule as three disconnected siblings, not because
`for` was actually understood as a loop. Same story for `while`, `do
... while`, and `try ... catch`. This is a "wrong tree, not a broken one"
bug -- has_error-based checks are blind to it, which is exactly why it
went unnoticed this long.

Implemented for real, following the manual directly:

- `for_statement`: `for (binding in iterable) body`, where binding is
  either a plain identifier or a key => value pair (Haxe 4.0+,
  `for (k => v in map)`).
- `while_statement` / `do_while_statement`.
- `try_statement` with repeatable `catch_clause`s, each with an optional
  type annotation (Haxe 4.1+ wildcard catch: `catch (e) { ... }`).
- `conditional_statement` (if/else if/else) rewritten so bodies are
  $.statement instead of $.block -- Haxe's `if (cond) expr;` (no braces)
  is standard, idiomatic syntax that was completely broken here, forcing
  braces on every branch.
- Array comprehensions (`[for (i in 0...10) i * 2]`, `[while (...) ...]`,
  manual: lf-array-comprehension.html), replacing a placeholder rule that
  didn't match real comprehension syntax at all. Supports nesting
  (`for`/`while`/an `if`-filter inside a comprehension body), matching
  real code like `[for (a in 1...11) for (b in 2...4) if (a % b == 0)
  a + "/" + b]`.

All four statement bodies reuse $.statement rather than a separate
braces-only rule, which is what makes braceless bodies work "for free"
without duplicating the block/single-statement logic per construct.

**Found and fixed along the way, once real testing against a large
depot surfaced them:**

- The float literal regex allowed repeated dots (`[\.]+` instead of a
  single `\.`), so `0...10` (an integer immediately followed by the range
  operator -- the single most common shape for a for-loop's iterable) was
  being lexed as ONE malformed float token, "0...10", silently swallowing
  the range operator. Tree-sitter's regex engine has no lookahead, so
  there's no way to keep a bare trailing-dot float (`3.`) valid while
  still telling `0.` apart from `0...`; fixed by requiring at least one
  digit after the dot, which is a real but comparatively rare trade-off
  (a genuine standalone trailing-dot float in real, non-comment/string
  code) against a very common one (every `for (i in 0...N)` loop).
- `range_expression` previously baked `identifier 'in' ...` directly into
  itself -- a workaround from when `for` was unimplemented, using it to
  soak up a for-loop's `(i in 0...10)` content via the same kind of
  accidental-sibling trick. Investigated replacing it with a clean,
  standalone `min...max` rule now that `for_statement` provides its own
  real binding structure, but found (via direct testing, not assumption)
  that a dedicated rule is actually unreachable dead code here: `...` is
  already one of the generic `$.operator` choices, so the existing plain
  operator-chain alternative in `expression` already parses `0...10`,
  `arr.length - 1 ... arr.length + 5`, etc. correctly on its own with no
  dedicated rule at all. Removed rather than left in as dead code.
- `subscript_expression` (`arr[i]`) could appear as a chain's head
  (`arr[i] = x`, fixed in an earlier commit) but not its tail --
  `while (null != m_cache[id]) { ... }` still failed. Fixed by including
  it in `_chain_term`.
- A pre-existing, narrower ambiguity surfaced once control-flow bodies
  became $.statement instead of $.block: a genuinely EMPTY `{}` body
  resolves to an empty $.object (an object-literal expression statement)
  rather than $.block. Investigated at length -- declaring
  `[$.block, $.object]` in `conflicts` is flagged unnecessary by
  tree-sitter's own analysis (it doesn't see this as a real, GLR-forkable
  ambiguity), and neither `prec`/`prec.dynamic` on either rule (tried up
  to prec(1000)) nor an explicit `choice($.block, $.statement)` at the
  body field change the outcome, which suggests table construction is
  merging the two empty-content states before any declared precedence
  would be consulted -- not something fixable from grammar.js alone.
  Left as a documented, narrow limitation (42 files in this depot use a
  genuinely empty control-flow body) rather than blocking the other ~99%
  of this fix on it; non-empty bodies are entirely unaffected.

Verified: full corpus suite, 190/190 (added dedicated corpus files for
conditional_statement braceless bodies, loop statements, try/catch, and
array comprehensions; updated two pre-existing tests to match corrected
behavior -- the old array-comprehension placeholder test's expected
output, and removed a test for the now-unsupported bare trailing-dot
float). Depot-wide sweep of 5,332 real .hx files: 0 regressions, 251
files newly error-free (has_error-based, which undercounts real impact
since many files have multiple independent issues) -- a much clearer
picture from counting the actual new node types recognized:
8,601 for-loops (1,751 files), 1,206 while-loops (502 files), 182
do-while loops (106 files), 400 try-statements / 403 catch-clauses
(222-223 files), 871 array comprehensions (353 files), and 53,288
braceless if-bodies across 3,241 files -- all previously either
disconnected-sibling nonsense or a hard parse error.
…ted node

The while-comprehension test's expected tree still described upstream's
generic call_expression-shaped fallback (upstream has no dedicated
comprehension_while rule). This fork adds one as part of this PR, so
the expected tree should reflect it instead.
@mallyskies mallyskies changed the title Fix/for while try-catch comprehensions Implement for/while/do-while/try-catch, braceless if-bodies, array comprehensions Jul 8, 2026
@vantreeseba

vantreeseba commented Jul 8, 2026

Copy link
Copy Markdown
Owner

While I appreciate all the PRs, since these are all "stacked" (i.e. 80 contains 79 contains 78).

I would appreciate if you do a chunk of work locally, even if on seperate branches, and create a single branch with all the PRs merge into it, and make a PR from that one to here.

so workflow would look something like:
feat/feat1
feat/feat2
feat/feat3

local repo "dev" branch gets PR from each, you merge those into it.

single PR from dev/whatever/some "merged branch that is a set of features together" to here.
It would just make dealing with the merge conflicts and etc from the generated files and all easier.

I'm going through these today and will get them all in, so this is just for the future.

Lastly, I really prefer having semantic commit markers on each commit, I will update a contributing doc or something in the near future as well.

i.e. commit looks like

feat(grammar): added array parsing
test(grammar): improved coverage of some other thing

@vantreeseba
vantreeseba merged commit 0671782 into vantreeseba:main Jul 8, 2026
1 check passed
@mallyskies

Copy link
Copy Markdown
Contributor Author

No problem at all. Thanks for the feedback and guidance!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sometimes nested for loops fail to highlight.

2 participants