Skip to content

feat(modules): ESM-style import/export syntax with compile-time resolution (#43)#44

Open
attitude wants to merge 1 commit into
mainfrom
feat/import-export
Open

feat(modules): ESM-style import/export syntax with compile-time resolution (#43)#44
attitude wants to merge 1 commit into
mainfrom
feat/import-export

Conversation

@attitude

@attitude attitude commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Implements #43 — ESM-style import/export statements as a syntax plugin built entirely on the released seams (#42 SyntaxRecognizer, #30 NodeVisitor). No parser or compiler changes — the diff is new files under src/modules/ + two composer autoload lines.

What's in the box

Piece Seam Lines
ImportExportRecognizer SyntaxRecognizer (#42) 348
ImportExportVisitor (resolution + lowering) NodeVisitor (#30) 240
Modules\load() / module() / resolver() + lazy Module new, tiny, runtime 86 + 36
Modules\compile() facade wires the above into the stock Compiler

Supported syntax

import { TodoItem, TodoList } from './components';   // named
import { Store as TodoStore } from './store';        // aliased
import * as todo from './todo';                      // namespace (lazy Module wrapper)
import viteLogo from '#/assets/vite.svg';            // default / asset
import { Suspense } from 'attitude/phpx-server';     // bare specifier
export $TodoItem;                                    // named export
export default $Root;                               // default export
export { Store } from './store';                     // re-export

Key design decisions (per the issue)

  • Specifiers resolve at compile time, against the source file.//../ against the importing file, #/ via the alias map, candidates xx.phpxx.php. "Module not found" is a compile-time error with file:line. Baked output is a canonical root-relative module id: \Attitude\PHPX\Modules\load('src/components.phpx') — the runtime never guesses paths, which kills the debug_backtrace() and cache-dir failure modes of the runtime prototype by construction.
  • Closures stay closures. Named imports lower to list-destructuring assignments; exports collect into a single appended return ['Name' => $value, ...];, so compiled modules stay consumable by plain require. The only class is the lazy read-only Module ArrayAccess wrapper for import * as.
  • Plain require, never require_once — the registry is the only dedup layer, so the "returns true instead of exports" failure mode is gone. Circular imports are detected mid-cycle and reported with the full chain: Circular import: a → b → a.
  • Dynamic import(...) stays unclaimed — the claims lookahead only takes import followed by {, *, or an identifier + from, so the runtime prototype coexists during migration.
  • One deviation from the issue text: bare specifiers lower to a first-class-callable assignment $Suspense = \Attitude\PHPX\Server\Suspense(...); instead of use function. A use function import would not produce the $Suspense variable that <Suspense> compiles to (formatElementType emits capitalized tags as variables); the FCC assignment keeps the same zero-runtime-cost intent and keeps components closures.
  • Keyword-cased component names (List, Switch, …) are handled by word-matching identifiers the same way the core parser matches JSX names — PHP tokenizes them as keywords, not T_STRING.

Tests

616 passing (575 pre-existing + 41 new): byte-for-byte pinned lowered output for every syntax form, resolution/nesting/duplicate-export error paths with file:line, loader semantics (evaluate-once, cycle chain, lazy Module, read-only exports), and a compile → write → load() integration round-trip.

Scope / non-goals (as proposed)

No live bindings, no side-effect-only import './x';, no dynamic import() expression (the prototype covers it). Asset imports beyond the default id-string handler are pluggable via $assetHandler.

Closes #43

🤖 Generated with Claude Code

…ution (#43)

A plugin built entirely on the released seams — no parser or compiler changes:
- ImportExportRecognizer (SyntaxRecognizer): claims import/export statements,
  leaves the runtime import(...) call form unclaimed
- ImportExportVisitor (NodeVisitor): resolves specifiers at compile time against
  the source file, bakes root-relative module ids, lowers to plain PHP; exports
  collected for the module return
- Modules\load()/module()/resolver(): plain-require registry with circular-import
  detection; lazy read-only Module wrapper for namespace imports
- Modules\compile(): wires recognizer + visitor and appends the module's
  return ['Name' => $value, ...]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

Proposal: ESM-style import/export syntax via SyntaxRecognizer, with compile-time resolution

1 participant