diff --git a/.agents/skills/gstack-investigate/SKILL.md b/.agents/skills/gstack-investigate/SKILL.md new file mode 100644 index 00000000..15f98dd7 --- /dev/null +++ b/.agents/skills/gstack-investigate/SKILL.md @@ -0,0 +1,69 @@ +--- +name: investigate +description: Investigate and fix Peeper compiler bugs, incorrect diagnostics, crashes, and regressions. Use when asked to debug, reproduce, find root cause, or fix broken compiler behavior, including parser, semantics, IR, backend, LSP, and package tooling. Prove the failure before implementing the smallest verified fix. +--- + +# Compiler investigation + +Portable adaptation of the locally installed gstack investigation workflow. +Read `AGENTS.md` and `RULES.md` for edit, validation, review, and delivery gates. +This skill needs only repository tools; no global gstack scripts or services. + +## 1. Pin the failure + +- Read the issue, explicit user requirements, and relevant local plan. +- Check branch, status, and recent changes in the affected files. Distinguish + pre-existing user work from the requested change. +- Record exact input, expected behavior, actual diagnostics/output, and the + command that reproduces it. Reduce to the smallest source or test that still fails. +- For a plan-only request, investigate and write the plan without implementing. + +## 2. Trace the owner + +- Search symbols and their callers before reading broad directories. +- Follow input through the affected producers and consumers. For compiler bugs, + inspect parsing, binding/type evidence, lowering, and runtime only as relevant. +- Compare the failing path with a working neighboring case. Identify which + owner lost, duplicated, or incorrectly reconstructed a fact. +- Inspect diagnostics and source locations, node/symbol identity, evaluation + order, mutation, caching, and phase invariants before replacing behavior. +- State one specific, testable root-cause hypothesis. + +## 3. Prove the hypothesis + +- Prefer a focused regression that fails on the baseline and positive controls + that pass. Reuse existing test harnesses; do not add production APIs for tests. +- If it fails for a different reason, gather more evidence before patching. +- Use temporary probes outside tracked source. Follow the repository's experiment + lifecycle if production instrumentation is genuinely needed. +- After three rejected hypotheses, summarize evidence and ask for a decision + instead of stacking speculative fixes. + +## 4. Fix at the canonical boundary + +- Complete the repository pre-change and pre-patch gates. +- Change the existing owner rather than scattering caller workarounds or writing + a parallel implementation. Preserve each owned behavior or explain its change. +- Keep the diff scoped to the reproduced defect and necessary coverage. +- Language changes need positive and applicable negative `x_test/` coverage, + executed with a rebuilt `build/bin/peeper`. +- Stop at the approved step boundary unless multiple steps were explicitly requested. + +## 5. Verify and hand off + +- Run focused tests first, affected packages next, then required full checks once. + Reproduce the original source failure with the repaired compiler. +- Apply [Peeper code review](../peeper-code-review/SKILL.md) to the completed diff; + this investigation does not replace standards/spec review. +- Update the persistent local plan with red/green evidence, commands, outcomes, + remaining risks, and resume context. +- Report symptom, proven root cause, fix, validation, review findings, and the + required Rules check. Distinguish passed checks from checks not run. + +## Routing examples + +- "Fix #157: qualified range endpoints eat the loop body" → reproduce parser + failure, inspect context propagation, test preserved enum/index behavior. +- "LSP missed a file after reopening the workspace" → reproduce the state + sequence, inspect cache ownership/invalidation, add a focused regression. +- "Plan scoped allocators" → use `plan-eng-review`; no bug is established. diff --git a/.agents/skills/gstack-investigate/agents/openai.yaml b/.agents/skills/gstack-investigate/agents/openai.yaml new file mode 100644 index 00000000..7254f7f7 --- /dev/null +++ b/.agents/skills/gstack-investigate/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "gstack-investigate" + short_description: "Reproduce compiler bugs, prove root cause, fix and validate." + default_prompt: "Use $investigate to investigate and fix this Peeper compiler bug." +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/gstack-plan-eng-review/SKILL.md b/.agents/skills/gstack-plan-eng-review/SKILL.md new file mode 100644 index 00000000..c1aeb4d7 --- /dev/null +++ b/.agents/skills/gstack-plan-eng-review/SKILL.md @@ -0,0 +1,82 @@ +--- +name: plan-eng-review +description: Brainstorm implementation approaches, plan Peeper compiler features, and review architecture or execution plans before coding. Use for requests to plan, compare designs, decide compiler boundaries, or review an implementation approach. Ground options in current source, reuse, language semantics, invariants, and executable fixtures. +--- + +# Compiler engineering plan review + +Portable adaptation of the locally installed gstack engineering-plan workflow. +Read `AGENTS.md` and `RULES.md` for scope, local-plan, edit, and delivery gates. +This skill needs only repository tools; no global gstack scripts or services. + +## 1. Establish scope + +- Use the issue, user request, or named plan as the review target. Ask only when + the target or an implementation-critical requirement is actually ambiguous. +- Read relevant source, callers, tests, specs, and architecture documents. Verify + document claims against behavior; do not treat historical plans as task status. +- Record the current need, existing owner, canonical implementation to reuse, + proposed shape, and experimental exit condition. +- Planning does not authorize production edits. An explicit request to implement + permits execution after the repository gates and necessary decisions are met. + +## 2. Compare approaches + +- For unsettled design, propose two or three meaningfully different approaches. + Include direct reuse or deletion when it can meet the requirement. +- Explain behavior, affected owners, invariant risks, migration cost, and test + obligations. Recommend the smallest complete solution, not the smallest shortcut. +- For a proven defect with a clear owner, do not invent competing designs to fill + a quota. Use `investigate` to establish root cause and evaluate the focused fix. +- Ask for a decision when language semantics, ownership, ABI, or incompatible + architecture choices remain open. Separate recommendations from approved choices. + +## 3. Review four areas + +### Architecture and data flow + +- Identify producers, consumers, and the fact crossing each affected boundary. +- Inventory validation, diagnostics/locations, identity, mutation, caching, + evaluation order, and ownership/lifetime obligations before moving responsibility. +- Use a compact ASCII diagram when it clarifies nontrivial flow. +- Name concrete failure modes and how the plan preserves or tests the boundary. + +### Code quality + +- Prefer canonical owners and direct calls. Check proposed functions/fields for + current production consumers and the exact helper allowance in `RULES.md`. +- Remove wrappers, stale aliases, ignored parameters, and duplicated decisions. + Similar-looking code with distinct semantics may correctly remain separate. +- Keep unrelated refactors outside the task. Do not add speculative frameworks. + +### Tests + +- Map acceptance and rejection cases to existing package tests and `x_test/`. +- Include a red baseline for bugs, preserved neighboring syntax/diagnostics, + source locations and bindings, and downstream lowerability/runtime evidence. +- Distinguish native execution from cross-target object validation. Cover affected + target widths where numeric, pointer, layout, or ABI behavior requires it. +- Specify focused, affected, full, bundle/fixture, and diff-check commands. + +### Performance + +- Check whether traversal, copying, allocations, cache lifetime, or invalidation + change. Measure only a concrete relevant cost; do not invent a benchmark project. +- Record no material performance change when that is the source-backed conclusion. + +## 4. Produce an executable plan + +- Use the persistent `*.localplan.md` format in `AGENTS.md`. +- Each step names goal, reason, files/owners, invariants, validation, and review stop. +- Preserve completed work and risks; do not reduce the plan to a scratch checklist. +- Report verified findings, recommendation, decisions still needed, and next step. +- During implementation, use [Peeper code review](../peeper-code-review/SKILL.md) + for completed source changes. Plan review does not substitute for diff review. + +## Routing examples + +- "Plan #157" → inspect parser owner, delimiter contexts, preserved enum syntax, + and existing range tests; produce focused fix/validation steps. +- "Brainstorm default-argument snapshot semantics" → compare evaluation-order + and ownership models, surface the language decision before implementation. +- "Review this PR diff" → use `peeper-code-review` for standards/spec findings. diff --git a/.agents/skills/gstack-plan-eng-review/agents/openai.yaml b/.agents/skills/gstack-plan-eng-review/agents/openai.yaml new file mode 100644 index 00000000..398c76d2 --- /dev/null +++ b/.agents/skills/gstack-plan-eng-review/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "gstack-plan-eng-review" + short_description: "Plan compiler changes around owners, invariants and fixtures." + default_prompt: "Use $plan-eng-review to plan or review this Peeper compiler change." +policy: + allow_implicit_invocation: true diff --git a/.gitignore b/.gitignore index a6a5f84f..503812a7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ _gen .tmpdebug _tmp *.localplan.md +backlog.md *.html playground installers/dist @@ -33,6 +34,8 @@ clean.md !.agents/skills/peeper-code-review/ .agents/skills/peeper-code-review/* !.agents/skills/peeper-code-review/SKILL.md +!.agents/skills/gstack-investigate/ +!.agents/skills/gstack-plan-eng-review/ !.gitignore !.github/ !.github/workflows/ diff --git a/AGENTS.md b/AGENTS.md index ab6eb94a..de9dd7f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,21 @@ For code-review requests, read [Peeper code review](.agents/skills/peeper-code-r It captures the review workflow and user's simplicity preferences; use `RULES.md` for durable engineering requirements. +### Automatic skill routing + +Read the matching repo-local skill before starting its workflow, even when the +user does not name it. Use the skill tool when available; otherwise read its file. + +| Task | Local skill | +| --- | --- | +| Investigate or fix compiler bugs, diagnostics, crashes, or regressions | [investigate](.agents/skills/gstack-investigate/SKILL.md) | +| Brainstorm implementation approaches, plan compiler features, or review architecture/execution plans | [plan-eng-review](.agents/skills/gstack-plan-eng-review/SKILL.md) | +| Review source changes, branches, or PRs; audit completed implementation | [peeper-code-review](.agents/skills/peeper-code-review/SKILL.md) | + +Use investigation and planning alongside Peeper code review when each phase is +needed. Skills do not authorize implementation during a plan-only request or +change the edit, validation, review, and delivery gates below. + --- ## 1) Required pre-change check diff --git a/RULES.md b/RULES.md index df7990e0..2ade9ebe 100644 --- a/RULES.md +++ b/RULES.md @@ -123,6 +123,8 @@ Fix defect at source layer when possible. Use workaround only with explicit appr Remove dead code and migration debris in same change. Do not trade correctness for fewer edited files. +Peeper is pre-stable and targets the current language grammar. Do not preserve deprecated syntax, compatibility paths, or migration-only parser behavior unless explicitly requested. Unsupported syntax must still be rejected through current grammar and validation. + ## 7. Errors, diagnostics, and panics Preserve root-cause context and error identity. When wrapping Go error, use `%w` so callers can use `errors.Is` and `errors.As`. diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..02606a6d --- /dev/null +++ b/TODO.md @@ -0,0 +1,250 @@ +# Peeper Backlog + +Baseline: `main` at `03a32726f8a356a2b61c53232f4fecb28adc3350` +Reviewed: 2026-10-06 +Source of truth: current source, tests, architecture docs, and GitHub issue state. + +This file is the durable, tracked backlog. Historical review journals and ignored +local plans are evidence, not task status. + +Status markers: + +- `[ ]` planned or open +- `[D]` decision required before implementation +- `[x]` complete and verified on current main +- `[H]` historical/reference only + +## Current state + +- `main` and `origin/main` match at `03a3272`. +- PR #156 merged at `05092be`. +- PR #158 merged at `57e7995`. +- Post-merge native-Clang ABI test fix is merged in `03a3272`. +- Latest main CI passes Unit, Race, Quality, and native target jobs. +- No open pull requests. +- Roadmap project read is blocked by the GitHub token's missing `read:project` scope. +- Existing untracked `.bugfix.md`, `.nightmare.md`, and `todo.txt` files are local + historical material. Do not treat them as canonical backlog or delete them without + explicit cleanup approval. + +## Now + +### [ ] #157 — Qualified range endpoints before loop bodies + +Type: implementation +Owner: `internal/frontend/parser` and affected range semantic tests +Issue: https://github.com/PeeperLanguage/compiler/issues/157 +Evidence: `internal/frontend/parser/parse_stmt.go`, `parse_expr.go` + +Parse a bare qualified range endpoint such as `values::Zero..values::One` without +consuming the loop-body `{` as a legacy enum payload. + +Acceptance: + +- Parse bare qualified endpoints and retain endpoint bindings/source locations. +- Cover ascending, equal, reversed, and runtime ranges. +- Preserve explicit enum construction and malformed payload diagnostics outside + control headers. +- Add focused parser/semantic tests and applicable `x_test/` fixtures. +- Validate with rebuilt `build/bin/peeper`. + +### [ ] MIR typed-nil validation hardening + +Type: implementation; no GitHub issue currently linked +Owner: `internal/ir/mir/validate.go` +Evidence: `constant-binding-identity.localplan.md` Step 5; +`internal/ir/mir/validate.go:182-184,397-413,562-624` + +Replace nested plain-nil checks that call `TypeID()` on possible typed-nil MIR +values. Use the existing typed-nil contract at the canonical validator boundary. + +Acceptance: + +- Nested typed-nil branch conditions, call values, place roots/projections, + interface bases/arguments, and expression operands return validation errors. +- No validator path panics on typed-nil values. +- Valid MIR shapes retain current diagnostics and lowering behavior. +- Add focused malformed-MIR tests and positive controls. +- Confirm pipeline refuses emission after validation failure. + +### [ ] Backend-independent MIR invariant validation + +Type: implementation; no GitHub issue currently linked +Owner: `internal/ir/mir/validate.go` and pipeline validation boundary +Evidence: `internal/pipeline/pipeline.go:470-500`, +`internal/ir/mir/validate.go:501-516`, +`constant-binding-identity.localplan.md` Step 6 + +Complete validation before backend emission. Current gap: fixed-array literals +validate element types but do not compare value count with declared array length. +Decide explicitly whether direct `llvm.GenerateLLVMIR` callers also require a +validation boundary or whether pipeline ownership is sufficient. + +Acceptance: + +- Fixed-array cardinality mismatch is rejected before emission. +- Backend-independent type/reference/shape invariants are checked once at the + canonical boundary. +- Direct backend callers retain an explicit documented contract. +- Add malformed and valid MIR tests; preserve external/static legitimate cases. + +### [D] Default-argument snapshot semantics + +Type: language-design decision before implementation +Owner: `internal/semantics/typechecker/check_call.go` +Evidence: `check_call.go:525-612`, `docs/default-parameters.md:73-95,244-257` + +Decide whether omitted defaults observe staged, once-per-call values of earlier +arguments or continue using source-expression substitution. Current code rejects +effectful argument reuse and intentionally defers staged evaluation. + +Do not implement until argument order, mutation, ownership/borrow behavior, imported +defaults, and full-arity ABI semantics are explicitly selected and covered by tests. + +### [D] Installer publisher-authentication policy + +Type: release/security policy decision before implementation +Owner: release workflow, manifest, `scripts/install.sh`, `scripts/install.ps1` +Evidence: `review-followups.localplan.md:1782-1801`, +`.github/workflows/release.yml:123-180`, +`pkg/distribution/release.go`, installer scripts + +Choose checksum-only acceptance or signed release-manifest verification. Current +release behavior uses HTTPS plus SHA-256 checksums; no signing algorithm, trust-root +storage, rotation/revocation policy, or PowerShell-compatible verifier is selected. + +No signing implementation is authorized by this backlog item alone. + +### [ ] Documentation/source-contract reconciliation + +Type: documentation +Owner: each document's live source owner + +Correct only concrete contradictions found during review: + +- `docs/default-parameters.md` says compiler behavior is unimplemented while + trailing defaults already compile; retain explicit deferred staged semantics. +- `docs/architecture/cli-and-packages.md` describes signed release metadata while + current workflow publishes unsigned metadata plus `SHA256SUMS`. +- `docs/compiler-architecture.md:265-268` assigns usage state to `symbols.Symbol`, + while current owner is `symbols.Index`; align with + `docs/architecture/semantics-bindings.md`. +- `Code-tour.md` contains stale path/function references such as `cfg.BuildQueries`; + reconcile against current source before editing it. +- Mark `docs/end-to-end-code-review.md` as historical remediation scope rather than + a claim that every later audit finding is resolved. + +Each edit needs source/path verification and documentation diff validation. + +## Next + +### [ ] #133 — Explicit struct literals + +Issue: https://github.com/PeeperLanguage/compiler/issues/133 +Review grammar publication, migration status, parser/typechecker behavior, and +language-support documentation. Coordinate with #157 before changing overlapping +control-header or literal grammar. + +### [ ] #122 — Windows installer end-to-end validation + +Issue: https://github.com/PeeperLanguage/compiler/issues/122 +Run `install.ps1` on a Windows runner and record actual release/install behavior. + +### [ ] Remove redundant origin copies after measurement + +Type: measured cleanup +Evidence: `internal/semantics/analysis/module.go:179-220`, +`internal/semantics/analysis/flow_thir.go:238-242,567-574`, +`internal/semantics/analysis/ownership.go:604-608` + +Prove alias isolation first, measure allocation/copy cost, then remove only copies +that are not required by detached-storage ownership. + +### [ ] Simplify the `Check`/`runCheck` test boundary + +Type: test-boundary cleanup +Evidence: `internal/semantics/typechecker/typechecker.go:120-135`, +`internal/semantics/typechecker/typechecker_test.go:29-92` + +Remove the test-only evidence bridge only if tests can assert durable THIR/public +results without exposing private production evidence or creating a second checking +lifecycle. + +### [ ] #58 — Module version selection + +Issue: https://github.com/PeeperLanguage/compiler/issues/58 +Design graph-wide version selection before dependent package tooling such as #74. + +### [ ] #74 — Auto-import completion + +Issue: https://github.com/PeeperLanguage/compiler/issues/74 +Depends on stable module/package identity decisions. + +### [ ] #82 — String C/FFI bridge + +Issue: https://github.com/PeeperLanguage/compiler/issues/82 +Requires explicit ownership, lifetime, allocator, and ABI decisions. + +### [ ] #64 — Scoped/custom allocator behavior + +Issue: https://github.com/PeeperLanguage/compiler/issues/64 +Requires lifetime/region ownership design. + +### [ ] #134 — LSP struct-literal snippets + +Issue: https://github.com/PeeperLanguage/compiler/issues/134 +Coordinate with #133 grammar and type-shape decisions. + +## Later + +- [ ] #30 — Optional niche layouts. +- [ ] #89 — Value-producing match expressions. +- [ ] #90 — Advanced generic functions/methods, constraints, inference, and + monomorphization. +- [ ] #91 — Enum protocols and metadata operations. +- [ ] #92 — Compact tagged-variant payload storage. +- [ ] #93 — Location-sensitive loan analysis. +- [ ] #95 — Advanced enum match patterns. +- [ ] #96 — Explicit enum representation and foreign ABI. +- [ ] Incremental Stage C–F: function-owned evidence, measured function reuse, then + persistent cache. Do not add cache/remapping layers before measurement and stable + identity/lifetime design. Evidence: `docs/architecture/infrastructure.md:253-300`. +- [ ] Revisit deferred language-surface decisions from the old audit only when + product direction selects them: wider formatting, arbitrary-width floats, + compound bitwise assignment, optional convenience operations, and borrowed + `&str` comparison semantics. +- [ ] Decide interface-method default parameters and function-value preservation. + +## Completed + +- [x] #154 — Wide constant indexes without truncation. +- [x] #155 — Checked numeric conversion/publication. +- [x] PR #156 — Binding identity repair. +- [x] PR #158 — Numeric publication and fixed-array/GEP repairs. +- [x] Native-Clang system-ABI test fix (`03a3272`). +- [x] Workspace/LSP remediation recorded in `internal/lsp/workspace.go.bugfix.md`. +- [x] TypeTable detached-copy ownership boundary; historical finding no longer open. +- [x] Workspace rebuild-cost measurement; no function-level reuse justified by current + measurements. + +## Historical sources, not active status + +- `constant-binding-identity.localplan.md` +- `constant-numeric-publication.localplan.md` +- `peeper-code-review.localplan.md` +- `review-followups.localplan.md` +- `high-bit-gep.localplan.md` +- `task.md` +- `docs/end-to-end-code-review.md` +- `internal/semantics/typechecker/constant_eval.go.bugfix.md` +- `internal/semantics/typechecker/constant_eval.go.nightmare.md` + +When a historical file conflicts with this backlog, re-check current source and +GitHub metadata before changing status. + +## Fresh-session start + +1. Read `TODO.md`, `RULES.md`, `AGENTS.md`, and the selected issue. +2. Check `git status --short --branch`, `HEAD`, and `origin/main`. +3. Re-read source evidence named by selected item; do not trust old plan status. +4. Work one item only; record validation and stop at its review condition. diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index 7ce437b5..211847fc 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -156,7 +156,7 @@ ranges must retain the same one-based convention. ### Shared state: `parser/parser.go` `Parser` stores the file path, token stream, diagnostic bag, current token index, -next AST node ID, a context stack, and the `controlHeader` flag. `New` receives +next AST node ID, and a context stack. `New` receives the already-tokenized stream; the lexer is not called by the parser. `current`, `next`, and `prev` inspect the stream without advancing. `advance` @@ -255,9 +255,25 @@ first call argument and marks the call `Piped`; it rejects method targets. Identifier paths can represent scoped names, generic applications, composite literals, and enum variants. `parseVariantCasePath` requires a fully named path -for enum cases. Control-header parsing uses `controlHeader` to distinguish a -following block from a composite literal. Array literals parse a type and a -braced value list; `_` infers length from the number of values. +for enum cases. Enum payload construction requires `with`; named struct literals +require `Type.{...}` and anonymous struct literals use `.{...}`. A qualified name +followed by a valid body leaves that brace for the enclosing statement parser. +Array literals parse a type and a braced value list; `_` infers length from the +number of values. + +Expression parsing carries the caller's expected delimiter for recovery. +Operators inherit it; calls, grouping, indexes, and literal values establish +their own boundaries. `recoverBracedLiteral` diagnoses a missing literal +introducer, scans nested braces/parentheses/brackets, and returns a registered +`BadExpr` before normal postfix/operator parsing resumes. Qualified names receive +both struct and enum syntax suggestions because parsing cannot resolve their +meaning. Control-header recovery requires field-shaped contents without +top-level statement separators and a following body or expression continuation; +two adjacent blocks alone do not trigger it. Unclosed groups stop conservatively +at statement boundaries, mismatched closing delimiters, or a following argument. +No stored header flag or global diagnostic suppression is used. +Resolver leaves parser-owned `BadExpr` diagnostics intact; typechecking records +invalid type for these nodes rather than issuing another syntax error. Numeric interpretation is delegated to `numeric.ParseLiteral`. The parser adds an invalid-number diagnostic on failure and a leading-zero warning for decimal diff --git a/internal/frontend/parser/named_enums_test.go b/internal/frontend/parser/named_enums_test.go index 19dc79b9..827a23b7 100644 --- a/internal/frontend/parser/named_enums_test.go +++ b/internal/frontend/parser/named_enums_test.go @@ -224,23 +224,26 @@ func TestParseVariantLiteralInIfHeader(t *testing.T) { } } -func TestParseDiagnosesOldBraceOnlyVariantLiteral(t *testing.T) { - mod, diag := parseTestModule(`fn check(value: Result) { - if value == Result::Ok{ value = 1 } { println("ok"); } - if pkg::ready { println("ready"); } -}`) - if !diag.HasErrors() { - t.Fatal("expected old variant literal diagnostic") - } - if output := diag.EmitAllToString(); !strings.Contains(output, "enum variant payload requires 'with'") { - t.Fatalf("unexpected diagnostics:\n%s", output) - } - body := mod.Stmts[0].(*ast.FnDecl).Body - if len(body.Stmts) != 2 { - t.Fatalf("statements = %d, want recovery to retain both if statements", len(body.Stmts)) - } - ordinary := body.Stmts[1].(*ast.IfStmt) - if got := ast.ExprText(ordinary.Cond); got != "pkg::ready" { - t.Fatalf("ordinary qualified condition = %q", got) +func TestParseQualifiedControlHeadersBeforeStandaloneBlocks(t *testing.T) { + for _, statement := range []string{ + "if pkg::ready {}", + "for pkg::ready {}", + "for value in values::Zero..values::One {}", + "match Result::Pending { Result::Pending => {} }", + } { + t.Run(statement, func(t *testing.T) { + mod, diag := parseTestModule("fn check() { " + statement + " { println(1); } }") + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + body := mod.Stmts[0].(*ast.FnDecl).Body + if len(body.Stmts) != 2 { + t.Fatalf("statements = %d, want control statement and separate block", len(body.Stmts)) + } + block, ok := body.Stmts[1].(*ast.BlockStmt) + if !ok || len(block.Stmts) != 1 { + t.Fatalf("following statement = %#v, want separate block", body.Stmts[1]) + } + }) } } diff --git a/internal/frontend/parser/parse_expr.go b/internal/frontend/parser/parse_expr.go index 19265803..e6ebef3f 100644 --- a/internal/frontend/parser/parse_expr.go +++ b/internal/frontend/parser/parse_expr.go @@ -37,10 +37,10 @@ const ( ) // nudFunc parses a prefix (null-denotation) expression. -type nudFunc func(p *Parser) ast.Expr +type nudFunc func(p *Parser, boundary token.Kind) ast.Expr // ledFunc parses an infix (left-denotation) expression. -type ledFunc func(p *Parser, left ast.Expr, prec uint8) ast.Expr +type ledFunc func(p *Parser, left ast.Expr, prec uint8, boundary token.Kind) ast.Expr var ( nudLookup = map[token.Kind]nudFunc{} @@ -59,63 +59,61 @@ func led(kind token.Kind, prec uint8, handler ledFunc) { func init() { // literals & identifiers - nud(token.NUMBER, func(p *Parser) ast.Expr { + nud(token.NUMBER, func(p *Parser, _ token.Kind) ast.Expr { return p.parseNumberLit("") }) - nud(token.STRING, func(p *Parser) ast.Expr { + nud(token.STRING, func(p *Parser, _ token.Kind) ast.Expr { tok := p.advance() return reg(p, &ast.StringLit{Value: tok.Literal, Location: source.NewLocation(p.filePath, tok.Start, tok.End)}) }) - nud(token.CSTRING, func(p *Parser) ast.Expr { + nud(token.CSTRING, func(p *Parser, _ token.Kind) ast.Expr { tok := p.advance() return reg(p, &ast.StringLit{Value: tok.Literal, IsCString: true, Location: source.NewLocation(p.filePath, tok.Start, tok.End)}) }) - nud(token.BYTE_CHAR, func(p *Parser) ast.Expr { + nud(token.BYTE_CHAR, func(p *Parser, _ token.Kind) ast.Expr { tok := p.advance() return reg(p, &ast.ByteLit{Value: tok.Literal, Location: source.NewLocation(p.filePath, tok.Start, tok.End)}) }) - nud(token.CHAR, func(p *Parser) ast.Expr { + nud(token.CHAR, func(p *Parser, _ token.Kind) ast.Expr { tok := p.advance() return reg(p, &ast.CharLit{Value: tok.Literal, Location: source.NewLocation(p.filePath, tok.Start, tok.End)}) }) - nud(token.NONE, func(p *Parser) ast.Expr { + nud(token.NONE, func(p *Parser, _ token.Kind) ast.Expr { tok := p.advance() return reg(p, &ast.NoneLit{Location: source.NewLocation(p.filePath, tok.Start, tok.End)}) }) - nud(token.TRUE, func(p *Parser) ast.Expr { + nud(token.TRUE, func(p *Parser, _ token.Kind) ast.Expr { tok := p.advance() return reg(p, &ast.BoolLit{Value: true, Location: source.NewLocation(p.filePath, tok.Start, tok.End)}) }) - nud(token.FALSE, func(p *Parser) ast.Expr { + nud(token.FALSE, func(p *Parser, _ token.Kind) ast.Expr { tok := p.advance() return reg(p, &ast.BoolLit{Value: false, Location: source.NewLocation(p.filePath, tok.Start, tok.End)}) }) - nud(token.AT, func(p *Parser) ast.Expr { return p.parseAddressExpr(ast.AddressRaw) }) - nud(token.AMP, func(p *Parser) ast.Expr { return p.parseAddressExpr(ast.AddressShared) }) - nud(token.FREE, func(p *Parser) ast.Expr { return p.parseFreeExpr() }) - nud(token.PRINT, func(p *Parser) ast.Expr { return p.parsePrintExpr() }) - nud(token.PRINTLN, func(p *Parser) ast.Expr { return p.parsePrintExpr() }) - nud(token.IDENT, func(p *Parser) ast.Expr { return p.parseIdentExpr() }) + nud(token.AT, func(p *Parser, boundary token.Kind) ast.Expr { return p.parseAddressExpr(ast.AddressRaw, boundary) }) + nud(token.AMP, func(p *Parser, boundary token.Kind) ast.Expr { return p.parseAddressExpr(ast.AddressShared, boundary) }) + nud(token.FREE, func(p *Parser, _ token.Kind) ast.Expr { return p.parseFreeExpr() }) + nud(token.PRINT, func(p *Parser, _ token.Kind) ast.Expr { return p.parsePrintExpr() }) + nud(token.PRINTLN, func(p *Parser, _ token.Kind) ast.Expr { return p.parsePrintExpr() }) + nud(token.IDENT, func(p *Parser, boundary token.Kind) ast.Expr { return p.parseIdentExpr(boundary) }) // grouping - nud(token.LPAREN, func(p *Parser) ast.Expr { + nud(token.LPAREN, func(p *Parser, _ token.Kind) ast.Expr { p.advance() - inner := p.parseExprWithControlHeader(precLowest, false) - if p.consume(token.RPAREN, "expected ')'") == nil { - return inner - } + inner := p.parseExpr(precLowest, token.RPAREN) + p.consume(token.RPAREN, "expected ')'") return inner }) // prefix / unary - nud(token.PLUS, func(p *Parser) ast.Expr { return p.parseUnaryExpr() }) - nud(token.MINUS, func(p *Parser) ast.Expr { return p.parseUnaryExpr() }) - nud(token.BANG, func(p *Parser) ast.Expr { return p.parseUnaryExpr() }) - nud(token.TILDE, func(p *Parser) ast.Expr { return p.parseUnaryExpr() }) + nud(token.PLUS, func(p *Parser, boundary token.Kind) ast.Expr { return p.parseUnaryExpr(boundary) }) + nud(token.MINUS, func(p *Parser, boundary token.Kind) ast.Expr { return p.parseUnaryExpr(boundary) }) + nud(token.BANG, func(p *Parser, boundary token.Kind) ast.Expr { return p.parseUnaryExpr(boundary) }) + nud(token.TILDE, func(p *Parser, boundary token.Kind) ast.Expr { return p.parseUnaryExpr(boundary) }) // composite literal - nud(token.DOT, func(p *Parser) ast.Expr { return p.parseCompositeLiteral(nil) }) - nud(token.LBRACK, func(p *Parser) ast.Expr { return p.parseArrayLiteral() }) + nud(token.DOT, func(p *Parser, _ token.Kind) ast.Expr { return p.parseCompositeLiteral(nil) }) + nud(token.LBRACK, func(p *Parser, _ token.Kind) ast.Expr { return p.parseArrayLiteral() }) // logical led(token.PIPE_ARROW, precPipe, parsePipeExpr) @@ -152,25 +150,25 @@ func init() { led(token.PERCENT, precProduct, parseBinaryExpr) // cast - led(token.AS, precCast, func(p *Parser, left ast.Expr, _ uint8) ast.Expr { + led(token.AS, precCast, func(p *Parser, left ast.Expr, _ uint8, _ token.Kind) ast.Expr { return p.parseAsExpr(left) }) // call & member - led(token.LPAREN, precCall, func(p *Parser, left ast.Expr, _ uint8) ast.Expr { + led(token.LPAREN, precCall, func(p *Parser, left ast.Expr, _ uint8, _ token.Kind) ast.Expr { return p.parseCall(left) }) - led(token.LBRACK, precCall, func(p *Parser, left ast.Expr, _ uint8) ast.Expr { + led(token.LBRACK, precCall, func(p *Parser, left ast.Expr, _ uint8, _ token.Kind) ast.Expr { return p.parseIndexExpr(left) }) - led(token.DOT, precCall, func(p *Parser, left ast.Expr, _ uint8) ast.Expr { + led(token.DOT, precCall, func(p *Parser, left ast.Expr, _ uint8, _ token.Kind) ast.Expr { return p.parseSelector(left) }) } -func parsePipeExpr(p *Parser, left ast.Expr, _ uint8) ast.Expr { +func parsePipeExpr(p *Parser, left ast.Expr, _ uint8, boundary token.Kind) ast.Expr { op := p.advance() - right := p.parseExpr(precPrefix) + right := p.parseExpr(precPrefix, boundary) call, ok := right.(*ast.CallExpr) if !ok || call == nil || call.IsPiped { loc := source.NewLocation(p.filePath, op.Start, ast.EndOf(right)) @@ -192,24 +190,40 @@ func parsePipeExpr(p *Parser, left ast.Expr, _ uint8) ast.Expr { return call } -func (p *Parser) parseExpr(precedence uint8) ast.Expr { - nudHandler, ok := nudLookup[p.current().Kind] - if !ok { - loc := source.NewLocation(p.filePath, p.current().Start, p.current().End) - p.diag.Add(diagnostics.NewError("expected expression").WithCode(diagnostics.ErrInvalidExpression).WithPrimaryLabel(loc, fmt.Sprintf("found %s", p.current().Kind))) - return reg(p, &ast.BadExpr{Location: loc}) +func (p *Parser) parseExpr(precedence uint8, boundary token.Kind) ast.Expr { + var left ast.Expr + if p.at(token.LBRACE) { + left = p.recoverBracedLiteral(p.current().Start, boundary, false) } - left := nudHandler(p) if left == nil { - loc := source.NewLocation(p.filePath, p.current().Start, p.current().End) - return reg(p, &ast.BadExpr{Location: loc}) + nudHandler, ok := nudLookup[p.current().Kind] + if !ok { + loc := source.NewLocation(p.filePath, p.current().Start, p.current().End) + p.diag.Add(diagnostics.NewError("expected expression").WithCode(diagnostics.ErrInvalidExpression).WithPrimaryLabel(loc, fmt.Sprintf("found %s", p.current().Kind))) + return reg(p, &ast.BadExpr{Location: loc}) + } + left = nudHandler(p, boundary) + if left == nil { + loc := source.NewLocation(p.filePath, p.current().Start, p.current().End) + return reg(p, &ast.BadExpr{Location: loc}) + } } for !p.at(token.SEMICOLON) && !p.at(token.COMMA) && !p.at(token.RPAREN) && !p.at(token.RBRACE) { + if p.at(token.LBRACE) { + switch left.(type) { + case *ast.Ident, *ast.ScopeResolution: + _, scoped := left.(*ast.ScopeResolution) + if bad := p.recoverBracedLiteral(ast.StartOf(left), boundary, scoped); bad != nil { + left = bad + continue + } + } + } prec, ok := precTable[p.current().Kind] if !ok || prec <= precedence { break } - left = ledLookup[p.current().Kind](p, left, prec) + left = ledLookup[p.current().Kind](p, left, prec, boundary) if left == nil { break } @@ -217,22 +231,14 @@ func (p *Parser) parseExpr(precedence uint8) ast.Expr { return left } -func (p *Parser) parseExprWithControlHeader(precedence uint8, enabled bool) ast.Expr { - controlHeader := p.isControlHeader - p.isControlHeader = enabled - expr := p.parseExpr(precedence) - p.isControlHeader = controlHeader - return expr -} - -func (p *Parser) parseUnaryExpr() ast.Expr { +func (p *Parser) parseUnaryExpr(boundary token.Kind) ast.Expr { tok := p.advance() if (tok.Kind == token.PLUS || tok.Kind == token.MINUS) && p.at(token.NUMBER) { if literal, err := numeric.ParseLiteral(p.current().Literal); err == nil && literal.ExplicitType != "" { return p.parseNumberLit(tok.Literal) } } - expr := p.parseExpr(precPrefix) + expr := p.parseExpr(precPrefix, boundary) if expr == nil { expr = reg(p, &ast.BadExpr{Location: source.NewLocation(p.filePath, tok.Start, tok.End)}) } @@ -268,12 +274,12 @@ func (p *Parser) parseNumberLit(sign string) ast.Expr { return reg(p, &ast.NumberLit{Value: literal.Value, ExplicitType: literal.ExplicitType, Location: loc}) } -func (p *Parser) parseAddressExpr(mode ast.AddressMode) ast.Expr { +func (p *Parser) parseAddressExpr(mode ast.AddressMode, boundary token.Kind) ast.Expr { tok := p.advance() if mode == ast.AddressShared && p.match(token.MUT) { mode = ast.AddressMutable } - expr := p.parseExpr(precPrefix) + expr := p.parseExpr(precPrefix, boundary) if expr == nil { loc := source.NewLocation(p.filePath, tok.Start, tok.End) return reg(p, &ast.BadExpr{Location: loc}) @@ -290,7 +296,7 @@ func (p *Parser) parseFreeExpr() ast.Expr { if p.consume(token.LPAREN, "expected '(' after 'free'") == nil { return reg(p, &ast.BadExpr{Location: source.NewLocation(p.filePath, start.Start, start.End)}) } - expr := p.parseExpr(precLowest) + expr := p.parseExpr(precLowest, token.RPAREN) end := p.expectClose(start.Start, token.RPAREN, "(") endPos := ast.EndOf(expr) if end != nil { @@ -307,7 +313,7 @@ func (p *Parser) parsePrintExpr() ast.Expr { if p.consume(token.LPAREN, "expected '(' after 'print'") == nil { return reg(p, &ast.BadExpr{Location: source.NewLocation(p.filePath, start.Start, start.End)}) } - expr := p.parseExpr(precLowest) + expr := p.parseExpr(precLowest, token.RPAREN) end := p.expectClose(start.Start, token.RPAREN, "(") endPos := ast.EndOf(expr) if end != nil { @@ -316,12 +322,12 @@ func (p *Parser) parsePrintExpr() ast.Expr { return reg(p, &ast.PrintExpr{Expr: expr, AppendsNewline: start.Kind == token.PRINTLN, Location: source.NewLocation(p.filePath, start.Start, endPos)}) } -func parseBinaryExpr(p *Parser, left ast.Expr, prec uint8) ast.Expr { +func parseBinaryExpr(p *Parser, left ast.Expr, prec uint8, boundary token.Kind) ast.Expr { op := p.advance() if op == nil { return left } - right := p.parseExpr(prec) + right := p.parseExpr(prec, boundary) if right == nil { right = reg(p, &ast.BadExpr{Location: source.NewLocation(p.filePath, op.Start, op.End)}) } @@ -333,7 +339,7 @@ func parseBinaryExpr(p *Parser, left ast.Expr, prec uint8) ast.Expr { }) } -func parseIsExpr(p *Parser, left ast.Expr, _ uint8) ast.Expr { +func parseIsExpr(p *Parser, left ast.Expr, _ uint8, _ token.Kind) ast.Expr { p.advance() casePath := p.parseVariantCasePath() if casePath == nil { @@ -354,7 +360,7 @@ func (p *Parser) parseCall(callee ast.Expr) ast.Expr { var args []ast.Expr if !p.at(token.RPAREN) { for { - arg := p.parseExprWithControlHeader(precLowest, false) + arg := p.parseExpr(precLowest, token.RPAREN) if arg != nil { args = append(args, arg) } @@ -386,7 +392,7 @@ func (p *Parser) parseIndexExpr(left ast.Expr) ast.Expr { if start == nil { return left } - index := p.parseIndexOperand() + index := p.parseIndexOperand(token.RBRACK) end := p.expectClose(start.Start, token.RBRACK, "[") var fallbackEnd source.Position if end == nil { @@ -404,24 +410,24 @@ func (p *Parser) parseIndexExpr(left ast.Expr) ast.Expr { }) } -func (p *Parser) parseIndexOperand() ast.Expr { +func (p *Parser) parseIndexOperand(boundary token.Kind) ast.Expr { if p.at(token.DOTDOT) || p.at(token.DOTDOT_EQ) { - return p.parseRangeExpr(nil) + return p.parseRangeExpr(nil, boundary) } - start := p.parseExprWithControlHeader(precLowest, false) + start := p.parseExpr(precLowest, boundary) if p.at(token.DOTDOT) || p.at(token.DOTDOT_EQ) { - return p.parseRangeExpr(start) + return p.parseRangeExpr(start, boundary) } return start } -func (p *Parser) parseRangeExpr(start ast.Expr) ast.Expr { +func (p *Parser) parseRangeExpr(start ast.Expr, boundary token.Kind) ast.Expr { tok := p.current() isEndExclusive := tok.Kind == token.DOTDOT p.advance() var end ast.Expr if !p.at(token.RBRACK) { - end = p.parseExprWithControlHeader(precLowest, false) + end = p.parseExpr(precLowest, boundary) } else if tok.Kind == token.DOTDOT_EQ { loc := source.NewLocation(p.filePath, tok.Start, tok.End) p.diag.Add(diagnostics.NewError("inclusive range requires an end bound"). @@ -479,7 +485,7 @@ func (p *Parser) parseArrayLiteral() ast.Expr { } values, end, ok := parseBracedItemList(p, "expected '{' after array literal type", "expected '}' after array literal", func() (ast.Expr, bool) { - value := p.parseExprWithControlHeader(precLowest, false) + value := p.parseExpr(precLowest, token.RBRACE) return value, value != nil }) if !ok { @@ -509,35 +515,21 @@ func (p *Parser) parseArrayLiteral() ast.Expr { }) } -func (p *Parser) parseIdentExpr() ast.Expr { - expr := p.parseIdentPath() +func (p *Parser) parseIdentExpr(boundary token.Kind) ast.Expr { + expr := p.parseIdentPath(boundary) path, ok := expr.(*ast.ScopeResolution) - if !ok { + if !ok || !p.match(token.WITH) { return expr } - if p.match(token.WITH) { - payload := p.parseExpr(precLowest) - return reg(p, &ast.VariantLit{ - Case: path, - Payload: payload, - Location: source.NewLocation(p.filePath, ast.StartOf(path), ast.EndOf(payload)), - }) - } - if !p.at(token.LBRACE) { - return path - } - if p.isControlHeader && !p.variantLiteralPrecedesControlBody() { - return path - } - _, end, _ := p.parseStructLiteralFields("expected '{' after enum variant", "expected '}' after enum variant literal") - location := source.NewLocation(p.filePath, ast.StartOf(path), end.End) - p.diag.Add(diagnostics.NewError("enum variant payload requires 'with'"). - WithCode(diagnostics.ErrInvalidExpression). - WithPrimaryLabel(location, "write `Enum::Variant with .{ ... }`")) - return reg(p, &ast.BadExpr{Location: location}) + payload := p.parseExpr(precLowest, boundary) + return reg(p, &ast.VariantLit{ + Case: path, + Payload: payload, + Location: source.NewLocation(p.filePath, ast.StartOf(path), ast.EndOf(payload)), + }) } -func (p *Parser) parseIdentPath() ast.Expr { +func (p *Parser) parseIdentPath(boundary token.Kind) ast.Expr { next := p.next().Kind if next != token.DCOLON && !(next == token.LT && p.typeArgumentsPrecedePathOrLiteral()) && @@ -554,11 +546,25 @@ func (p *Parser) parseIdentPath() ast.Expr { if path, ok := typ.(*ast.ScopeResolution); ok { return path } + if p.at(token.LBRACE) { + if bad := p.recoverBracedLiteral(ast.StartOf(typ), boundary, false); bad != nil { + return bad + } + message := "expected value expression" + if applied, ok := typ.(*ast.AppliedType); ok && len(applied.TypeArgs) > 0 { + message += " after type arguments" + } + p.diag.Add(diagnostics.NewError(message). + WithCode(diagnostics.ErrInvalidExpression). + WithPrimaryLabel(ast.LocOf(typ), "expected a value here"). + WithHelp("if constructing a struct, insert '.' before '{'")) + return reg(p, &ast.BadExpr{Location: ast.LocOf(typ)}) + } return nil } func (p *Parser) parseVariantCasePath() *ast.ScopeResolution { - expr := p.parseIdentPath() + expr := p.parseIdentPath(token.FATARROW) path, ok := expr.(*ast.ScopeResolution) if ok { return path @@ -573,24 +579,6 @@ func (p *Parser) parseVariantCasePath() *ast.ScopeResolution { return nil } -func (p *Parser) variantLiteralPrecedesControlBody() bool { - depth := 0 - for index := p.pos; index < len(p.stream); index++ { - switch p.stream[index].Kind { - case token.LBRACE: - depth++ - case token.RBRACE: - depth-- - if depth == 0 { - return index+1 < len(p.stream) && p.stream[index+1].Kind == token.LBRACE - } - case token.EOF: - return false - } - } - return false -} - func (p *Parser) typeArgumentsPrecedePathOrLiteral() bool { depth := 0 for index := p.pos + 1; index < len(p.stream); index++ { @@ -609,6 +597,7 @@ func (p *Parser) typeArgumentsPrecedePathOrLiteral() bool { } if depth == 0 { return index+1 < len(p.stream) && (p.stream[index+1].Kind == token.DCOLON || + p.stream[index+1].Kind == token.LBRACE || (index+2 < len(p.stream) && p.stream[index+1].Kind == token.DOT && p.stream[index+2].Kind == token.LBRACE)) } } @@ -640,27 +629,7 @@ func (p *Parser) parseCompositeLiteral(typ ast.TypeExpr) ast.Expr { if typ != nil { startPos = ast.StartOf(typ) } - oldSpelling := typ == nil && p.at(token.IDENT) - if oldSpelling { - typ = p.parseTypeExpr() - } - fields, end, _ := p.parseStructLiteralFields("expected '{' after '.'", "expected '}' after composite literal") - location := source.NewLocation(p.filePath, startPos, end.End) - if oldSpelling { - p.diag.Add(diagnostics.NewError("named struct literals use Type.{...}, not .Type{...}"). - WithCode(diagnostics.ErrInvalidExpression). - WithPrimaryLabel(location, "write `Type.{...}`; keep fields as `x = value`")) - return reg(p, &ast.BadExpr{Location: location}) - } - return reg(p, &ast.StructLit{ - Type: typ, - Fields: fields, - Location: location, - }) -} - -func (p *Parser) parseStructLiteralFields(openerMsg, itemMsg string) ([]ast.StructLitField, *token.Token, bool) { - return parseBracedItemList(p, openerMsg, itemMsg, + fields, end, ok := parseBracedItemList(p, "expected '{' after '.'", "expected '}' after composite literal", func() (ast.StructLitField, bool) { name := p.parseIdent() if name == nil { @@ -669,7 +638,7 @@ func (p *Parser) parseStructLiteralFields(openerMsg, itemMsg string) ([]ast.Stru if p.consume(token.ASSIGN, "expected '=' after struct literal field name") == nil { return ast.StructLitField{}, false } - value := p.parseExprWithControlHeader(precLowest, false) + value := p.parseExpr(precLowest, token.RBRACE) if value == nil { return ast.StructLitField{}, false } @@ -679,6 +648,15 @@ func (p *Parser) parseStructLiteralFields(openerMsg, itemMsg string) ([]ast.Stru Location: source.NewLocation(p.filePath, ast.StartOf(name), ast.EndOf(value)), }, true }) + location := source.NewLocation(p.filePath, startPos, end.End) + if !ok { + return reg(p, &ast.BadExpr{Location: location}) + } + return reg(p, &ast.StructLit{ + Type: typ, + Fields: fields, + Location: location, + }) } func (p *Parser) parseIdent() *ast.Ident { diff --git a/internal/frontend/parser/parse_for_test.go b/internal/frontend/parser/parse_for_test.go index f910c9a4..8914a392 100644 --- a/internal/frontend/parser/parse_for_test.go +++ b/internal/frontend/parser/parse_for_test.go @@ -1,11 +1,11 @@ package parser import ( - "compiler/internal/source" "strings" "testing" "compiler/internal/frontend/ast" + "compiler/internal/source" ) func parseForBody(t *testing.T, src string) *ast.ForStmt { @@ -66,6 +66,84 @@ return 0; } } +func TestParseForInQualifiedRangeEndpoints(t *testing.T) { + for _, test := range []struct { + name, start, end string + }{ + {name: "ascending", start: "values::Zero", end: "values::One"}, + {name: "equal", start: "values::Zero", end: "values::Zero"}, + {name: "reversed", start: "values::One", end: "values::Zero"}, + {name: "runtime", start: "start", end: "values::One"}, + } { + for _, bindings := range []string{"value", "index, value"} { + t.Run(test.name+"/"+bindings, func(t *testing.T) { + src := "fn main() -> i32 { for " + bindings + " in " + test.start + ".." + test.end + " { return value; } return 0; }" + loop := parseForBody(t, src) + rangeExpr, ok := loop.Iterable.(*ast.RangeExpr) + if !ok || !rangeExpr.IsEndExclusive { + t.Fatalf("iterable = %#v, want exclusive range expression", loop.Iterable) + } + for _, endpoint := range []struct { + expr ast.Expr + text string + start int + }{ + {rangeExpr.Start, test.start, strings.Index(src, test.start)}, + {rangeExpr.End, test.end, strings.LastIndex(src, test.end)}, + } { + if endpoint.expr == nil || endpoint.expr.ID() == source.ParsedNodeID(0) { + t.Fatalf("endpoint %q lost registered node", endpoint.text) + } + if got := ast.ExprText(endpoint.expr); got != endpoint.text { + t.Fatalf("endpoint = %q, want %q", got, endpoint.text) + } + if strings.Contains(endpoint.text, "::") { + if _, ok := endpoint.expr.(*ast.ScopeResolution); !ok { + t.Fatalf("endpoint = %T, want qualified path", endpoint.expr) + } + } + if ast.StartOf(endpoint.expr).Index != endpoint.start || ast.EndOf(endpoint.expr).Index != endpoint.start+len(endpoint.text) { + t.Fatalf("endpoint %q location = %#v, want [%d, %d)", endpoint.text, ast.LocOf(endpoint.expr), endpoint.start, endpoint.start+len(endpoint.text)) + } + } + if loop.Body == nil || len(loop.Body.Stmts) != 1 || ast.StartOf(loop.Body).Index != strings.Index(src, "{ return") { + t.Fatalf("loop body = %#v, want separate return block", loop.Body) + } + }) + } + } +} + +func TestParseForInKeepsDelimitedExpressionContexts(t *testing.T) { + for _, iterable := range []string{ + "values::Items", + "values[values::Zero..values::One]", + "Result::Ok with .{ value = 1 }", + "values[Result::Ok with .{ value = 1 }]", + } { + t.Run(iterable, func(t *testing.T) { + loop := parseForBody(t, "fn main() { for value in "+iterable+" {} }") + if loop.Body == nil || len(loop.Body.Stmts) != 0 { + t.Fatalf("loop body = %#v, want separate empty block", loop.Body) + } + }) + } + for _, expr := range []string{ + "Result::Ok{ value = 1 }", + "values[Result::Ok{ value = 1 }]", + "values[0..Result::Ok{ value = 1 }]", + "accept(Result::Ok{ value = 1 })", + "(Result::Ok{ value = 1 })", + } { + t.Run("rejected/"+expr, func(t *testing.T) { + _, diag := parseTestModule("fn main() { for value in " + expr + " {} }") + if diag.ErrorCount() != 1 || !strings.Contains(diag.EmitAllToString(), "literal requires '.' or 'with'") { + t.Fatalf("expected one literal-introducer diagnostic:\n%s", diag.EmitAllToString()) + } + }) + } +} + func TestParseForInIndexValueBinding(t *testing.T) { src := `fn main() -> i32 { for index, value in 0..10 { diff --git a/internal/frontend/parser/parse_recovery_test.go b/internal/frontend/parser/parse_recovery_test.go new file mode 100644 index 00000000..51503bff --- /dev/null +++ b/internal/frontend/parser/parse_recovery_test.go @@ -0,0 +1,227 @@ +package parser + +import ( + "strings" + "testing" + + "compiler/internal/diagnostics" + "compiler/internal/frontend/ast" + "compiler/internal/source" +) + +func TestParseMissingLiteralIntroducerRecovery(t *testing.T) { + for _, statement := range []string{ + "let bad = Point{ x = 1 };", + "let bad = Point{};", + "let bad = Box>{ value = .{ value = 1 } };", + "let bad = pkg::Point{ x = 1 };", + "let bad = Result::Ok{ value = 1 };", + "let bad = Point{ x = .{ y = 1 }, z = accept(2, 3) }.x + 1;", + "let bad = 1 + Point{ x = 2 }.x;", + "let bad = -Point{ x = 2 }.x;", + "return Result::Ok{ value = 1 };", + "bad = Point{ x = 1 };", + "accept(Point{ x = 1 }, 2);", + "accept(Point{ x = 1, /// field documentation\n y = 2 }, 3);", + "accept(Point{ x = 1, /// trailing documentation\n }, 2);", + "accept({ x = 1 }.x, 2);", + "println(Point{ x = 1 }.x);", + "free(Point{ x = 1 });", + "let bad = (Point{ x = 1 });", + "let bad = items[Point{ x = 1 }.x];", + "let bad = items[0..Point{ x = 1 }.x];", + "let bad = [1]Point{Point{ x = 1 }};", + "let bad = .{ point = Point{ x = 1 }, good = 2 };", + "let bad = Result::Ok with { value = 1 };", + } { + t.Run(statement, func(t *testing.T) { + src := "fn main() { " + statement + " let good = Point.{ x = 2 }; return 0; } fn next() {}" + mod, diag := parseTestModule(src) + if diag.ErrorCount() != 1 { + t.Fatalf("errors = %d, want one literal diagnostic:\n%s", diag.ErrorCount(), diag.EmitAllToString()) + } + if got := diag.EmitAllToString(); !strings.Contains(got, "literal") || !strings.Contains(got, ".{") { + t.Fatalf("missing literal syntax explanation:\n%s", got) + } + if len(mod.Stmts) != 2 { + t.Fatalf("module statements = %d, want main and next", len(mod.Stmts)) + } + body := mod.Stmts[0].(*ast.FnDecl).Body + if len(body.Stmts) != 3 { + t.Fatalf("body statements = %d, want damaged statement, good declaration, return", len(body.Stmts)) + } + good, ok := body.Stmts[1].(*ast.LetDecl) + if !ok || good.Name.Name != "good" { + t.Fatalf("following statement = %#v, want good declaration", body.Stmts[1]) + } + if _, ok := good.Value.(*ast.StructLit); !ok { + t.Fatalf("following initializer = %T, want struct literal", good.Value) + } + badCount := 0 + ast.Inspect(body.Stmts[0], func(node ast.Node) bool { + if bad, ok := node.(*ast.BadExpr); ok { + badCount++ + if bad.ID() == source.ParsedNodeID(0) || ast.EndOf(bad).Index <= ast.StartOf(bad).Index { + t.Fatalf("bad expression lost identity/span: %#v", bad) + } + } + return true + }) + if badCount != 1 { + t.Fatalf("bad expressions = %d, want one damaged literal", badCount) + } + }) + } +} + +func TestParseMissingLiteralIntroducerInHeaders(t *testing.T) { + for _, statement := range []string{ + "if value == Point{ x = 1 } { println(1); } else { println(2); }", + "if value == Result::Ok{ value = 1 } { println(1); } else { println(2); }", + "if accept(Point{ x = 1 }) { println(1); }", + "for value != Point{ x = 1 } { break; }", + "for value in Items{ x = 1 } { println(value); }", + "for index, value in Items{ x = 1 } { println(value); }", + "match Result::Ok{ value = 1 } { Result::Pending => {} }", + } { + t.Run(statement, func(t *testing.T) { + mod, diag := parseTestModule("fn main() { " + statement + " let good = 2; } fn next() {}") + if diag.ErrorCount() != 1 { + t.Fatalf("errors = %d, want one literal diagnostic:\n%s", diag.ErrorCount(), diag.EmitAllToString()) + } + if len(mod.Stmts) != 2 { + t.Fatalf("module statements = %d, want main and next", len(mod.Stmts)) + } + body := mod.Stmts[0].(*ast.FnDecl).Body + if len(body.Stmts) != 2 { + t.Fatalf("body statements = %d, want control statement and good declaration", len(body.Stmts)) + } + switch node := body.Stmts[0].(type) { + case *ast.IfStmt: + if node.Then == nil || len(node.Then.Stmts) != 1 || strings.Contains(statement, "else") && node.Else == nil { + t.Fatalf("lost if body/else: %#v", node) + } + case *ast.ForStmt: + if node.Body == nil || len(node.Body.Stmts) != 1 { + t.Fatalf("lost for body: %#v", node) + } + case *ast.MatchStmt: + if len(node.Arms) != 1 { + t.Fatalf("lost match arms: %#v", node) + } + default: + t.Fatalf("header statement = %T", node) + } + if good, ok := body.Stmts[1].(*ast.LetDecl); !ok || good.Name.Name != "good" { + t.Fatalf("lost following declaration: %#v", body.Stmts[1]) + } + }) + } +} + +func TestParseLiteralRecoveryPreservesAssignmentBodies(t *testing.T) { + for _, statement := range []string{ + "if pkg::ready { x = 1; } { x = 2; }", + "if pkg::ready {} { x = 2; }", + "for pkg::ready { x = 1; } { x = 2; }", + "for value in values::Zero..values::One { x = value; } { x = 2; }", + "match pkg::value { Result::Pending => {} } { x = 2; }", + } { + t.Run(statement, func(t *testing.T) { + mod, diag := parseTestModule("fn main() { " + statement + " }") + if diag.HasErrors() { + t.Fatalf("valid body misdiagnosed:\n%s", diag.EmitAllToString()) + } + if body := mod.Stmts[0].(*ast.FnDecl).Body; len(body.Stmts) != 2 { + t.Fatalf("body statements = %d, want control statement and standalone block", len(body.Stmts)) + } + }) + } +} + +func TestParseLiteralRecoveryKeepsIndependentErrors(t *testing.T) { + mod, diag := parseTestModule("fn main() { let bad = Point{ x = 1 }; let another = ; let good = 2; } fn next() {}") + if diag.ErrorCount() != 2 { + t.Fatalf("errors = %d, want literal error and independent expression error:\n%s", diag.ErrorCount(), diag.EmitAllToString()) + } + if len(mod.Stmts) != 2 || len(mod.Stmts[0].(*ast.FnDecl).Body.Stmts) != 3 { + t.Fatalf("lost later statements/declaration: %#v", mod) + } + if !strings.Contains(diag.EmitAllToString(), "expected expression") { + t.Fatal("independent error was suppressed") + } +} + +func TestParseUnclosedLiteralRecovery(t *testing.T) { + mod, diag := parseTestModule("fn main() { let bad = Point{ x = 1; let good = 2; return 0; } fn next() {}") + if diag.ErrorCount() != 1 { + t.Fatalf("errors = %d, want one located literal diagnostic:\n%s", diag.ErrorCount(), diag.EmitAllToString()) + } + if len(mod.Stmts) != 2 || len(mod.Stmts[0].(*ast.FnDecl).Body.Stmts) != 3 { + t.Fatalf("unclosed literal swallowed function/following declaration: %#v", mod) + } + _, eofDiag := parseTestModule("fn main() { let bad = Point{ x = .{ y = 1 }") + if !eofDiag.HasErrors() || eofDiag.ErrorCount() > 3 { + t.Fatalf("unbounded EOF diagnostics:\n%s", eofDiag.EmitAllToString()) + } + if first := diag.Diagnostics()[0]; first.Code != diagnostics.ErrInvalidExpression { + t.Fatalf("first diagnostic = %s, want literal syntax error", first.Code) + } +} + +func TestParseUnclosedLiteralKeepsFollowingArgument(t *testing.T) { + mod, diag := parseTestModule("fn main() { accept(Point{ x = 1, 2); let good = 3; } fn next() {}") + if diag.ErrorCount() != 1 { + t.Fatalf("errors = %d, want one literal diagnostic:\n%s", diag.ErrorCount(), diag.EmitAllToString()) + } + if len(mod.Stmts) != 2 || len(mod.Stmts[0].(*ast.FnDecl).Body.Stmts) != 2 { + t.Fatalf("lost following declaration: %#v", mod) + } + call := mod.Stmts[0].(*ast.FnDecl).Body.Stmts[0].(*ast.ExprStmt).Expr.(*ast.CallExpr) + if len(call.Args) != 2 || ast.ExprText(call.Args[1]) != "2" { + t.Fatalf("following argument lost: %#v", call.Args) + } +} + +func TestParseLiteralRecoveryLocations(t *testing.T) { + src := "fn main() { let bad = Point{ x = .{ y = 1 } }; let good = 2; }" + mod, diag := parseTestModule(src) + if diag.ErrorCount() != 1 { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + bad := mod.Stmts[0].(*ast.FnDecl).Body.Stmts[0].(*ast.LetDecl).Value.(*ast.BadExpr) + if ast.StartOf(bad).Index != strings.Index(src, "Point{") || ast.EndOf(bad).Index != strings.Index(src, ";") { + t.Fatalf("bad expression span = %#v", ast.LocOf(bad)) + } + diagnostic := diag.Diagnostics()[0] + if diagnostic.Code != diagnostics.ErrInvalidExpression || len(diagnostic.Labels) != 1 || + diagnostic.Labels[0].Location.Start.Index != strings.Index(src, "{ x") { + t.Fatalf("missing-introducer diagnostic location = %#v", diagnostic) + } +} + +func TestParseLiteralDiagnosticUsesParsedTypeArguments(t *testing.T) { + for _, test := range []struct { + statement string + typeArgs bool + }{ + {"if Box {} { println(1); }", true}, + {"if Box> {} { println(1); }", true}, + {"let bad = Point{ x = 1 }; let good = 2;", false}, + {"let bad = pkg::Point{ x = 1 }; let good = 2;", false}, + } { + t.Run(test.statement, func(t *testing.T) { + mod, diag := parseTestModule("fn main() { " + test.statement + " }") + output := diag.EmitAllToString() + if diag.ErrorCount() != 1 || strings.Contains(output, "after type arguments") != test.typeArgs { + t.Fatalf("diagnostic must match parsed type arguments:\n%s", output) + } + if strings.Contains(output, "found generic type") || strings.Contains(output, "Type<...>") { + t.Fatalf("diagnostic claims unsupported semantic knowledge:\n%s", output) + } + if len(mod.Stmts[0].(*ast.FnDecl).Body.Stmts) != 2 { + t.Fatal("recovery swallowed body or following statement") + } + }) + } +} diff --git a/internal/frontend/parser/parse_stmt.go b/internal/frontend/parser/parse_stmt.go index 2b1fdc88..a07a6e65 100644 --- a/internal/frontend/parser/parse_stmt.go +++ b/internal/frontend/parser/parse_stmt.go @@ -123,7 +123,7 @@ func (p *Parser) parseIfStmt() ast.Stmt { if start == nil { return nil } - cond := p.parseExprWithControlHeader(precLowest, true) + cond := p.parseExpr(precLowest, token.LBRACE) if cond == nil { cond = reg(p, &ast.BadExpr{Location: source.NewLocation(p.filePath, start.Start, start.End)}) } @@ -187,11 +187,11 @@ func (p *Parser) parseForStmt() ast.Stmt { var index, value *ast.Ident var iterable, cond ast.Expr if !p.at(token.LBRACE) { - head := p.parseExprWithControlHeader(precLowest, true) + head := p.parseExpr(precLowest, token.LBRACE) switch { case head != nil && p.at(token.IN): p.advance() - iterable = p.parseIndexOperand() + iterable = p.parseIndexOperand(token.LBRACE) value = p.forInBindingName(head) case head != nil && p.at(token.COMMA): // `for i, v in expr` — comma commits to the two-binding form even @@ -204,7 +204,7 @@ func (p *Parser) parseForStmt() ast.Stmt { value = reg(p, &ast.Ident{Name: "", Location: source.NewLocation(p.filePath, current.Start, current.End)}) } if p.match(token.IN) { - iterable = p.parseIndexOperand() + iterable = p.parseIndexOperand(token.LBRACE) } else { p.consume(token.IN, "expected 'in' after loop variables") current := p.current() @@ -285,7 +285,7 @@ func (p *Parser) parseMatchStmt() ast.Stmt { if start == nil { return nil } - subject := p.parseExprWithControlHeader(precLowest, true) + subject := p.parseExpr(precLowest, token.LBRACE) open := p.consume(token.LBRACE, "expected '{' after match subject") if open == nil { return reg(p, &ast.MatchStmt{Subject: subject, Location: source.NewLocation(p.filePath, start.Start, ast.EndOf(subject))}) @@ -406,7 +406,7 @@ func (p *Parser) parseReturnStmt() ast.Stmt { } var value ast.Expr if !p.at(token.SEMICOLON) { - value = p.parseExpr(precLowest) + value = p.parseExpr(precLowest, token.SEMICOLON) } end := p.consume(token.SEMICOLON, "expected ';' after return") if end == nil { @@ -420,12 +420,12 @@ func (p *Parser) parseReturnStmt() ast.Stmt { } func (p *Parser) parseExprStmt() ast.Stmt { - expr := p.parseExpr(precLowest) + expr := p.parseExpr(precLowest, token.SEMICOLON) if expr == nil { return nil } if p.match(token.ASSIGN) { - value := p.parseExpr(precLowest) + value := p.parseExpr(precLowest, token.SEMICOLON) if value == nil { return nil } diff --git a/internal/frontend/parser/parse_types.go b/internal/frontend/parser/parse_types.go index fd4607ad..eb83df67 100644 --- a/internal/frontend/parser/parse_types.go +++ b/internal/frontend/parser/parse_types.go @@ -531,7 +531,7 @@ func (p *Parser) parseParam() (ast.Param, bool) { } var defaultValue ast.Expr if p.match(token.ASSIGN) { - defaultValue = p.parseExpr(precLowest) + defaultValue = p.parseExpr(precLowest, token.RPAREN) if defaultValue != nil { endPos = ast.EndOf(defaultValue) } diff --git a/internal/frontend/parser/parser.go b/internal/frontend/parser/parser.go index d2e57d7a..8588e14c 100644 --- a/internal/frontend/parser/parser.go +++ b/internal/frontend/parser/parser.go @@ -21,13 +21,12 @@ import ( ) type Parser struct { - filePath string - stream []token.Token - diag *diagnostics.DiagnosticBag - pos int - nodeID uint64 - context []string // parsing context stack for error messages - isControlHeader bool + filePath string + stream []token.Token + diag *diagnostics.DiagnosticBag + pos int + nodeID uint64 + context []string // parsing context stack for error messages } const ( @@ -363,7 +362,7 @@ func (p *Parser) parseBindingFields() (name *ast.Ident, ty ast.TypeExpr, value a // ty may be nil if type parsing failed; continue with name and value } if p.match(token.ASSIGN) { - value = p.parseExpr(precLowest) + value = p.parseExpr(precLowest, token.SEMICOLON) } end = p.consume(token.SEMICOLON, "expected ';' after statement") if end == nil { @@ -495,7 +494,7 @@ func (p *Parser) parseAttributes() []ast.Attribute { ) if p.match(token.LPAREN) { for !p.at(token.RPAREN) && !p.at(token.EOF) { - arg := p.parseExpr(precLowest) + arg := p.parseExpr(precLowest, token.RPAREN) if arg != nil { args = append(args, arg) } diff --git a/internal/frontend/parser/parser_test.go b/internal/frontend/parser/parser_test.go index 268ac1d9..bccdb815 100644 --- a/internal/frontend/parser/parser_test.go +++ b/internal/frontend/parser/parser_test.go @@ -1623,36 +1623,11 @@ func TestParseStructLiteralGenericComparisons(t *testing.T) { } } -func TestParseRejectsOldNamedStructLiteralsAndRecovers(t *testing.T) { - for _, expr := range []string{".Point{ x = 1 }", ".Box>{ x = 1 }", ".pkg::Box{ x = 1 }"} { - t.Run(expr, func(t *testing.T) { - src := "fn main() { let bad = " + expr + "; let good = Point.{}; if value == " + expr + " {} return good; }" - mod, diag := parseTestModule(src) - if !diag.HasErrors() || !strings.Contains(diag.EmitAllToString(), "Type.{...}") { - t.Fatalf("expected migration diagnostic: %s", diag.EmitAllToString()) - } - body := mod.Stmts[0].(*ast.FnDecl).Body.Stmts - if len(body) != 4 { - t.Fatalf("recovered statements = %d, want 4", len(body)) - } - bad, ok := body[0].(*ast.LetDecl).Value.(*ast.BadExpr) - if !ok || ast.StartOf(bad).Index != strings.Index(src, expr) || ast.EndOf(bad).Index != strings.Index(src, expr)+len(expr) { - t.Fatalf("bad literal = %#v", bad) - } - if _, ok := body[1].(*ast.LetDecl).Value.(*ast.StructLit); !ok { - t.Fatal("expected recovered named literal") - } - if _, ok := body[2].(*ast.IfStmt).Cond.(*ast.BinaryExpr).Right.(*ast.BadExpr); !ok { - t.Fatal("expected rejected old literal in control header") - } - }) - } -} - func TestParseRejectsStructLiteralExpressionTypesAndFields(t *testing.T) { for _, expr := range []string{ "make().{}", "value.field.{}", "values[0].{}", "(Point).{}", "(a + b).{}", "1.{}", "Point{ x = 1 }", "Point.{ .x = 1 }", "Point.{ x: 1 }", "Point.{ x = }", + ".Point{ x = 1 }", ".Box>{ x = 1 }", ".pkg::Box{ x = 1 }", "Box<>.{}", "Box<1>.{}", "Point.{ x = 1", } { t.Run(expr, func(t *testing.T) { diff --git a/internal/frontend/parser/recover_literal.go b/internal/frontend/parser/recover_literal.go new file mode 100644 index 00000000..f43a9638 --- /dev/null +++ b/internal/frontend/parser/recover_literal.go @@ -0,0 +1,83 @@ +package parser + +import ( + "compiler/internal/diagnostics" + "compiler/internal/frontend/ast" + "compiler/internal/frontend/token" + "compiler/internal/source" +) + +// recoverBracedLiteral owns missing literal introducers, not ordinary body braces. +// Scan before committing: a header's assignment statements must remain its body. +func (p *Parser) recoverBracedLiteral(start source.Position, boundary token.Kind, scoped bool) ast.Expr { + open := p.current() + if open.Kind != token.LBRACE { + return nil + } + if boundary == token.LBRACE && !p.literalFieldFollows(p.pos+1, false) { + return nil + } + closers := []token.Kind{token.RBRACE} + end := p.pos + 1 +scan: + for end < len(p.stream) { + kind := p.stream[end].Kind + if len(closers) == 1 { + switch kind { + case token.COMMA: + if (boundary == token.RPAREN || boundary == token.RBRACK) && !p.literalFieldFollows(end+1, true) { + break scan + } + case token.SEMICOLON, token.LET, token.CONST, token.FN, token.RETURN, + token.IF, token.FOR, token.MATCH, token.ELSE, token.STRUCT, token.ENUM, token.IFACE, token.TYPE: + break scan + } + } + switch kind { + case token.LBRACE: + closers = append(closers, token.RBRACE) + case token.LPAREN: + closers = append(closers, token.RPAREN) + case token.LBRACK: + closers = append(closers, token.RBRACK) + case token.RBRACE, token.RPAREN, token.RBRACK: + if kind != closers[len(closers)-1] { + break scan + } + closers = closers[:len(closers)-1] + if len(closers) == 0 { + end++ + break scan + } + case token.EOF: + break scan + } + end++ + } + if boundary == token.LBRACE && (len(closers) != 0 || end >= len(p.stream) || + (p.stream[end].Kind != token.LBRACE && precTable[p.stream[end].Kind] == 0)) { + return nil + } + message := "struct literal requires '.' before '{'" + help := "write `Type.{ ... }` for a named struct or `.{ ... }` for an anonymous struct" + if scoped { + message = "literal requires '.' or 'with' before '{'" + help = "write `Path.{ ... }` for a struct literal or `Enum::Variant with .{ ... }` for an enum payload" + } + p.diag.Add(diagnostics.NewError(message). + WithCode(diagnostics.ErrInvalidExpression). + WithPrimaryLabel(source.NewLocation(p.filePath, open.Start, open.End), "missing literal introducer"). + WithHelp(help)) + p.pos = end + return reg(p, &ast.BadExpr{Location: source.NewLocation(p.filePath, start, p.prev().End)}) +} + +func (p *Parser) literalFieldFollows(index int, allowClose bool) bool { + for index < len(p.stream) && p.stream[index].Kind == token.DOC_COMMENT { + index++ + } + if allowClose && index < len(p.stream) && p.stream[index].Kind == token.RBRACE { + return true + } + return index+1 < len(p.stream) && p.stream[index].Kind == token.IDENT && p.stream[index+1].Kind == token.ASSIGN +} diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 3d811e56..1ff8a8db 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -2887,13 +2887,29 @@ func TestPipelineRecordsQualifiedRangeEntryAcrossTargets(t *testing.T) { } { t.Run(arch+"/"+test.name, func(t *testing.T) { ctx, entry := importedPipelineContext(t, arch, map[string]string{ - peeper.MainFileName: "import \"app/values\"; fn Read(" + test.params + ") -> i64 { for value in " + test.start + "..(" + test.end + " as i64) { return value; } }", + peeper.MainFileName: "import \"app/values\"; fn Read(" + test.params + ") -> i64 { for value in " + test.start + ".." + test.end + " { return value; } }", "values" + peeper.SourceExt: `const Zero: i64 = 0i64; const One: i64 = 1i64;`, }) if err := Run(ctx, entry); err != nil { t.Fatal(err) } loop := entry.AST.Stmts[0].(*ast.FnDecl).Body.Stmts[0].(*ast.ForStmt) + rangeExpr := loop.Iterable.(*ast.RangeExpr) + for _, endpoint := range []struct { + expr ast.Expr + name string + }{ + {rangeExpr.Start, strings.TrimPrefix(test.start, "values::")}, + {rangeExpr.End, strings.TrimPrefix(test.end, "values::")}, + } { + binding := entry.SymbolIndex.Symbol(endpoint.expr) + if binding == nil || binding.Name != endpoint.name || typeinfo.TypeText(binding.Type) != "i64" { + t.Fatalf("endpoint %q lost resolved i64 binding: %#v", ast.ExprText(endpoint.expr), binding) + } + if _, qualified := endpoint.expr.(*ast.ScopeResolution); qualified && binding.DefiningModule != entry.Imports["values"].ID { + t.Fatalf("endpoint %q binding belongs to %v, want imported owner %v", ast.ExprText(endpoint.expr), binding.DefiningModule, entry.Imports["values"].ID) + } + } plan, ok := entry.THIR.Node(loop.ID()).(*thir.For).Iteration.(*thir.RangeIteration) if !ok { t.Fatalf("missing range plan for %T:\n%s", loop.Iterable, ctx.Diagnostics.EmitAllToString()) diff --git a/internal/semantics/resolver/resolver.go b/internal/semantics/resolver/resolver.go index 5064286e..c5ad937a 100644 --- a/internal/semantics/resolver/resolver.go +++ b/internal/semantics/resolver/resolver.go @@ -327,7 +327,7 @@ func (r *resolver) resolveExpr(scope *symbols.Scope, expr ast.Expr) { case *ast.AsExpr: r.resolveExpr(scope, node.Expr) case *ast.BadExpr: - r.ctx.Diagnostics.AddError(diagnostics.ErrInvalidExpression, "unsupported expression type", ast.LocOf(node), "") + return // Parser already diagnosed this error node. default: panic(fmt.Sprintf("resolver: unhandled expression %T", expr)) } diff --git a/internal/semantics/resolver/resolver_test.go b/internal/semantics/resolver/resolver_test.go index 2b71e1a8..78cfaa64 100644 --- a/internal/semantics/resolver/resolver_test.go +++ b/internal/semantics/resolver/resolver_test.go @@ -39,6 +39,22 @@ func checkResolveSource(t *testing.T, src string) (*module.Module, *diagnostics. return module, diag } +func TestResolveRecoveredLiteralKeepsParserDiagnostic(t *testing.T) { + mod, diag := checkResolveSource(t, `fn main() -> i32 { + let bad = Point{ x = 1 }; + let good = 2; + return good; + }`) + if diag.ErrorCount() != 1 || !strings.Contains(diag.EmitAllToString(), "struct literal requires '.'") { + t.Fatalf("recovered literal caused resolver cascade:\n%s", diag.EmitAllToString()) + } + body := mod.AST.Stmts[0].(*ast.FnDecl).Body + returned := body.Stmts[2].(*ast.ReturnStmt).Value + if sym := mod.SymbolIndex.Symbol(returned); sym == nil || sym.Name != "good" { + t.Fatalf("following valid expression lost binding: %#v", sym) + } +} + func TestRejectedPrivateImportDoesNotPublishUsage(t *testing.T) { diag := diagnostics.NewDiagnosticBag() ctx := project.New(".", peeper.SourceExt, diag) diff --git a/internal/semantics/typechecker/check_expr.go b/internal/semantics/typechecker/check_expr.go index 9d801cd8..e567092a 100644 --- a/internal/semantics/typechecker/check_expr.go +++ b/internal/semantics/typechecker/check_expr.go @@ -171,7 +171,7 @@ func (c *checker) typeExprBase(scope *symbols.Scope, expr ast.Expr, expected typ return c.typeAsExpr(scope, node) case *ast.BadExpr: - return nil // resolver already diagnosed unsupported expressions + return &typeinfo.InvalidType{} default: panic(fmt.Sprintf("typechecker: unhandled expression %T", expr)) diff --git a/internal/semantics/typechecker/typechecker_test.go b/internal/semantics/typechecker/typechecker_test.go index 8642b267..45ea193c 100644 --- a/internal/semantics/typechecker/typechecker_test.go +++ b/internal/semantics/typechecker/typechecker_test.go @@ -738,7 +738,7 @@ func TestWithVariantPayloadDiagnostics(t *testing.T) { {name: "missing payload", source: `enum Result { Failed: str } fn main() { let value = Result::Failed; }`, want: "requires"}, {name: "payloadless value", source: `enum Result { Pending } fn main() { let value = Result::Pending with 1; }`, want: "does not accept a payload"}, {name: "wrong direct type", source: `enum Result { Failed: str } fn main() { let value = Result::Failed with 404; }`, want: "cannot be used as str"}, - {name: "old braces", source: `enum Result { Ok: { value: i32 } } fn main() { let value = Result::Ok{ value = 1 }; }`, want: "requires 'with'"}, + {name: "brace-only payload", source: `enum Result { Ok: { value: i32 } } fn main() { let value = Result::Ok{ value = 1 }; }`, want: "literal requires '.' or 'with'"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -750,6 +750,26 @@ func TestWithVariantPayloadDiagnostics(t *testing.T) { } } +func TestRecoveredLiteralHasInvalidTypeWithoutCascade(t *testing.T) { + mod, diag := checkTypeModule(t, `fn main() -> i32 { + let bad = Point{ x = 1 }; + let good = 2; + return good; + }`) + if diag.ErrorCount() != 1 || !strings.Contains(diag.EmitAllToString(), "struct literal requires '.'") { + t.Fatalf("recovered literal caused semantic cascade:\n%s", diag.EmitAllToString()) + } + body := mod.AST.Stmts[0].(*ast.FnDecl).Body + bad := body.Stmts[0].(*ast.LetDecl).Value + if _, ok := testEvidence(mod).ExprType(bad.ID()).(*typeinfo.InvalidType); !ok { + t.Fatal("recovered expression must record invalid type") + } + good := body.Stmts[2].(*ast.ReturnStmt).Value + if got := typeinfo.TypeText(testEvidence(mod).ExprType(good.ID())); got != "i32" { + t.Fatalf("following valid expression type = %s, want i32", got) + } +} + func TestEnumConstructorDiagnostics(t *testing.T) { tests := []struct { name string @@ -761,6 +781,7 @@ func TestEnumConstructorDiagnostics(t *testing.T) { {name: "unknown field", source: `enum Result { Ok: { value: i32 } } fn main() { let value = Result::Ok with .{ item = 1 }; }`, want: "unknown struct literal field `item`"}, {name: "payloadless payload", source: `enum Result { Pending } fn main() { let value = Result::Pending with 1; }`, want: "payloadless enum variant `Pending` does not accept a payload"}, {name: "data without payload", source: `enum Result { Ok: { value: i32 } } fn main() { let value = Result::Ok; }`, want: "data enum variant `Ok` requires a payload"}, + {name: "data without payload before blocks", source: `enum Result { Ok: { value: i32 } } fn main(result: Result) { if result == Result::Ok { let value = 1; } {} }`, want: "data enum variant `Ok` requires a payload"}, {name: "missing generic arguments", source: `enum Result { Pending } fn main() { let value = Result::Pending; }`, want: "expects 1 type argument, got 0"}, {name: "variant call", source: `enum Result { Pending } fn main() { Result::Pending(); }`, want: "enum variants are not callable"}, {name: "variant type", source: `enum Result { Pending } fn Read(value: Result::Pending) {}`, want: "not lowerable"}, diff --git a/x_test/for_qualified_range_endpoints/peeper.toml b/x_test/for_qualified_range_endpoints/peeper.toml new file mode 100644 index 00000000..8e4faf53 --- /dev/null +++ b/x_test/for_qualified_range_endpoints/peeper.toml @@ -0,0 +1,7 @@ +name = "for_qualified_range_endpoints" +build = "program" + +[test] +mode = "run" +outcome = "exit_code" +exit_code = 4 diff --git a/x_test/for_qualified_range_endpoints/src/main.peep b/x_test/for_qualified_range_endpoints/src/main.peep new file mode 100644 index 00000000..af495f56 --- /dev/null +++ b/x_test/for_qualified_range_endpoints/src/main.peep @@ -0,0 +1,32 @@ +import "for_qualified_range_endpoints/values"; + +fn Read() -> i32 { + for value in values::Zero..values::One { + return value + 1; + } +} + +fn Sum(start: i32) -> i32 { + let mut total: i32 = 0; + for value in start..values::One { + total = total + value + 1; + } + return total; +} + +fn main() -> i32 { + let mut total: i32 = Read(); + for index, value in values::Zero..values::One { + total = total + index + value + 1; + } + { + total = total + 1; + } + for value in values::Zero..values::Zero { + total = total + 100; + } + for value in values::One..values::Zero { + total = total + 100; + } + return total + Sum(values::Zero) + Sum(values::One) + Sum(2); +} diff --git a/x_test/for_qualified_range_endpoints/src/values.peep b/x_test/for_qualified_range_endpoints/src/values.peep new file mode 100644 index 00000000..b1a2fc1e --- /dev/null +++ b/x_test/for_qualified_range_endpoints/src/values.peep @@ -0,0 +1,2 @@ +const Zero: i32 = 0; +const One: i32 = 1; diff --git a/x_test/negative_enum_constructors/src/main.peep b/x_test/negative_enum_constructors/src/main.peep index a25abf51..4fe837bd 100644 --- a/x_test/negative_enum_constructors/src/main.peep +++ b/x_test/negative_enum_constructors/src/main.peep @@ -13,3 +13,12 @@ fn main() { let bare = Result::Ok; Result::Pending(); } + +fn Check(value: Result) { + if value == Result::Ok { + println(1); + } + { + println(2); + } +} diff --git a/x_test/negative_literal_recovery/peeper.toml b/x_test/negative_literal_recovery/peeper.toml new file mode 100644 index 00000000..85c97930 --- /dev/null +++ b/x_test/negative_literal_recovery/peeper.toml @@ -0,0 +1,8 @@ +name = "negative_literal_recovery" +build = "program" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["P0003", "struct literal requires '.' before '{'", "expected value expression after type arguments", "if constructing a struct, insert '.' before '{'", "Compilation failed with 2 error(s)"] +stderr_excludes = ["P0002", "P0005", "found generic type", "Type<...>", "panic:"] diff --git a/x_test/negative_literal_recovery/src/main.peep b/x_test/negative_literal_recovery/src/main.peep new file mode 100644 index 00000000..6202b2d8 --- /dev/null +++ b/x_test/negative_literal_recovery/src/main.peep @@ -0,0 +1,18 @@ +struct Point { x: i32 } +struct Box { value: T } + +fn Accept(point: Point, count: i32) {} + +fn main() { + Accept(Point{ x = .{ nested = 1 } }.x, 2); + let good = Point.{ x = 3 }; + println(good.x); +} + +fn Next() -> Point { + return Point.{ x = 4 }; +} + +fn GenericHeader() { + if Box {} {} +} diff --git a/x_test/negative_qualified_range_return/peeper.toml b/x_test/negative_qualified_range_return/peeper.toml new file mode 100644 index 00000000..e355082a --- /dev/null +++ b/x_test/negative_qualified_range_return/peeper.toml @@ -0,0 +1,8 @@ +name = "negative_qualified_range_return" +build = "program" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["not all control paths return a value"] +stderr_excludes = ["P0002", "P0003"] diff --git a/x_test/negative_qualified_range_return/src/main.peep b/x_test/negative_qualified_range_return/src/main.peep new file mode 100644 index 00000000..477a548e --- /dev/null +++ b/x_test/negative_qualified_range_return/src/main.peep @@ -0,0 +1,19 @@ +import "negative_qualified_range_return/values"; + +fn Equal() -> i32 { + for value in values::One..values::One { + return value; + } +} + +fn Reversed() -> i32 { + for value in values::One..values::Zero { + return value; + } +} + +fn Runtime(start: i32) -> i32 { + for value in start..values::One { + return value; + } +} diff --git a/x_test/negative_qualified_range_return/src/values.peep b/x_test/negative_qualified_range_return/src/values.peep new file mode 100644 index 00000000..b1a2fc1e --- /dev/null +++ b/x_test/negative_qualified_range_return/src/values.peep @@ -0,0 +1,2 @@ +const Zero: i32 = 0; +const One: i32 = 1; diff --git a/x_test/negative_struct_literal_old_spelling/peeper.toml b/x_test/negative_struct_literal_old_spelling/peeper.toml deleted file mode 100644 index 4e225cd6..00000000 --- a/x_test/negative_struct_literal_old_spelling/peeper.toml +++ /dev/null @@ -1,8 +0,0 @@ -name = "negative_struct_literal_old_spelling" -build = "program" - -[test] -mode = "check" -outcome = "failure" -stderr_contains = ["named struct literals use Type.{...}, not .Type{...}"] -stderr_excludes = ["panic:"] diff --git a/x_test/negative_struct_literal_syntax/peeper.toml b/x_test/negative_struct_literal_syntax/peeper.toml new file mode 100644 index 00000000..e481a9f5 --- /dev/null +++ b/x_test/negative_struct_literal_syntax/peeper.toml @@ -0,0 +1,8 @@ +name = "negative_struct_literal_syntax" +build = "program" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["P0002", "expected '{' after '.'"] +stderr_excludes = ["panic:"] diff --git a/x_test/negative_struct_literal_old_spelling/src/main.peep b/x_test/negative_struct_literal_syntax/src/main.peep similarity index 100% rename from x_test/negative_struct_literal_old_spelling/src/main.peep rename to x_test/negative_struct_literal_syntax/src/main.peep diff --git a/x_test/negative_variant_literal_control_header/peeper.toml b/x_test/negative_variant_literal_control_header/peeper.toml index d03b6040..ee574a60 100644 --- a/x_test/negative_variant_literal_control_header/peeper.toml +++ b/x_test/negative_variant_literal_control_header/peeper.toml @@ -4,4 +4,5 @@ build = "lib" [test] mode = "check" outcome = "failure" -stderr_contains = ["P0003", "enum variant payload requires 'with'"] +stderr_contains = ["P0003", "literal requires '.' or 'with'", "Enum::Variant with .{ ... }", "Compilation failed with 2 error(s)"] +stderr_excludes = ["P0002", "P0005", "panic:"] diff --git a/x_test/negative_variant_literal_control_header/src/main.peep b/x_test/negative_variant_literal_control_header/src/main.peep index dedc8dd8..5ef7d5fe 100644 --- a/x_test/negative_variant_literal_control_header/src/main.peep +++ b/x_test/negative_variant_literal_control_header/src/main.peep @@ -11,3 +11,7 @@ fn Check(value: Result) -> bool { } return false; } + +fn InvalidPayload() { + let invalid = Result::Ok{ value = 1 }; +} diff --git a/x_test/runtime_struct_literal_dot/src/main.peep b/x_test/runtime_struct_literal_dot/src/main.peep index 3cbcb8a2..df7ea7b8 100644 --- a/x_test/runtime_struct_literal_dot/src/main.peep +++ b/x_test/runtime_struct_literal_dot/src/main.peep @@ -60,5 +60,11 @@ fn main() -> i32 { State::Ready => { println("struct literal dot ok"); } State::Waiting => { return 8; } } + { + sum = sum + 1; + } + if sum != 8 { + return 9; + } return 0; }