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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,9 +389,13 @@ The lexical context used to resolve bindings and contextual values such as `this
_Avoid_: Object, namespace.

**Binding**:
A name-to-value association in a scope.
A named association in a scope. Most bindings hold a value directly; an import binding indirectly names an exported binding in another module.
_Avoid_: Raw variable.

**Import binding**:
An immutable indirect binding created by a module import that refers to an exported binding in another module. Each read observes the target binding's current value and initialization state.
_Avoid_: Imported value, import snapshot.

**Define**:
Create a new binding in the current scope.
_Avoid_: Assign.
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Source -> Preprocessors (optional, e.g. JSX) -> Lexer -> Parser -> Compiler -> G
| Bytecode compiler | `Goccia.Compiler*` | AST to bytecode templates/modules |
| Bytecode format | `Goccia.Bytecode*` | Opcodes, templates, modules, binary I/O, debug info |
| Bytecode VM | `Goccia.VM*` | Register execution, closures, upvalues, handlers |
| Shared value system | `Goccia.Values.*`, `Goccia.Scope` | Objects, classes, arrays, promises, scopes, and shared value behavior |
| Shared value system | `Goccia.Values.*`, `Goccia.Scope`, `Goccia.Modules` | Objects, classes, arrays, promises, scopes, live module import/export bindings, and shared value behavior |
| Temporal semantics | `Goccia.Temporal.AbstractOperations`, `Goccia.Temporal.Options`, `Goccia.Temporal.Utils`, `Goccia.Temporal.TimeZone` | Shared specification operations for calendar coercion, options, rounding, RFC 9557 parsing, and time-zone offsets; Temporal built-ins and values delegate here instead of duplicating policy |
| Realm | `Goccia.Realm` | Per-engine container for mutable intrinsic prototypes |
| GC | `Goccia.GarbageCollector` | Mark-and-sweep garbage collection |
Expand Down
1 change: 1 addition & 0 deletions docs/core-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ The canonical project glossary lives in [GocciaScript Context](../CONTEXT.md). T
This distinction is critical in the codebase:

- `DefineLexicalBinding` — Creates a **new** variable in the current scope. Used for `let`/`const` declarations, function parameters, and built-in registration. Built-ins are registered using `DefineLexicalBinding(..., dtLet)` — there is no separate `DefineBuiltin` method.
- `CreateImportBinding` — Creates an immutable **indirect** binding in a module scope. Reads resolve the target module's current exported binding value instead of copying a value into the importing scope.
- `AssignLexicalBinding` — Changes the value of an **existing** variable, walking up the scope chain. Throws `ReferenceError` if not found, `TypeError` if `const`.

## Design Rationale
Expand Down
5 changes: 3 additions & 2 deletions docs/interpreter.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,9 @@ Scopes form a tree with parent pointers, implementing lexical scoping:
- **Temporal Dead Zone** — `let`/`const` bindings are registered before initialization, enforcing TDZ semantics (accessing before `=` throws `ReferenceError`).
- **Module scope isolation** — Modules execute in `skModule` scopes (children of the global scope), preventing module-internal variables from leaking into the global scope.
- **Module path resolution** — `TGocciaModuleResolver` handles alias expansion via its inherited `Resolve` method using import-map semantics (exact match for keys without `/`, prefix match for keys with `/`, longest matching key wins), resolves `./` and `../` paths relative to the importing file's directory, tries the shared module import extensions (`.js`, `.jsx`, `.ts`, `.tsx`, `.mjs`, `.json`, `.json5`, `.jsonl`, `.toml`, `.yaml`, `.yml`, `.txt`, `.md`) and index files for extensionless imports, then expands to an absolute path. `TGocciaModuleResolver.LoadImportMap` resolves import-map values relative to the map file and `DiscoverProjectConfig` walks parent directories looking for `goccia.json`. CLI applications also discover a project-level `goccia.toml`, `goccia.json5`, or `goccia.json` (in that priority order) starting from the entry file's directory via `CLI.ConfigFile.DiscoverConfigFile`. Config keys mirror CLI option names (for example, `mode`, `compat-asi`, `timeout`), and CLI options override config values. Config files support `"extends"` to inherit from a base config, enabling per-directory overrides (e.g. `tests/language/asi/goccia.json` enables ASI for that subtree). Absolute paths are used as cache keys to prevent loading the same file via different relative paths. Loader-owned virtual modules are checked before filesystem and import-map resolution; host modules registered with `Engine.RegisterHostModule` are checked before the resolver. Custom resolvers can be injected by subclassing `TGocciaModuleResolver` and overriding the `Resolve` method.
- **Circular dependency handling** — Modules are added to the cache (`FModules`) before execution and to a loading set (`FLoadingModules`) for the duration. If a circular import is encountered, the partially-populated module is returned from cache. Inline exports (`export const`) that execute before the circular import point are available; `export { x }` declarations processed post-execution are not. This matches the observable behavior of ES module live bindings for the common case.
- **Namespace imports** — `import * as ns from "./module.js"` now materializes a namespace object from `TGocciaModule.ExportsTable` instead of being skipped by the parser. The namespace object is created with a null prototype, populated with enumerable read-only data properties for each export, then frozen before binding. The result is an import-time snapshot rather than a live export mirror, which keeps namespace imports lightweight while matching the expected read-only shape for both JavaScript modules and structured-data modules.
- **Live import bindings** — Named imports are initialized as immutable indirect bindings in the importing module scope. Reads dereference the resolved export binding each time, so mutations, imported-name exports, and transitive function captures observe the current value in interpreter mode and bytecode mode.
- **Circular dependency handling** — Modules are added to the cache (`FModules`) and register their local export bindings before requested modules are evaluated. If a circular import encounters a partially linked module, its import binding can refer to that module without reading the target early; an actual read still observes the target binding's temporal dead zone until its declaration is evaluated.
- **Namespace imports** — `import * as ns from "./module.js"` binds the module's reusable namespace exotic object. The object has a null prototype, sorted enumerable read-only export properties, and live `[[Get]]` behavior that resolves the current exported binding value for JavaScript modules and forwarded exports; structured-data module values remain stable by construction.
- **JSON module imports** — Files ending in `.json` are handled by `LoadJsonModule`, which parses the file via `TGocciaJSONParser` (`Goccia.JSON` unit) and exposes each top-level object key as a named export. JSON modules bypass the lexer/parser/evaluator pipeline entirely, keeping the import path unified (`import { key } from "./file.json"`). Non-object JSON root values (arrays, primitives) produce a module with no exports. JSON modules participate in the same caching and path resolution as JS modules.
- **Standalone JSON utilities** — `Goccia.JSON` provides `TGocciaJSONParser` and `TGocciaJSONStringifier` as dependency-free utility classes that convert between JSON text and `TGocciaValue` types. `Goccia.Builtins.JSON` (the `JSON.parse`/`JSON.stringify` built-in) delegates to these, keeping the built-in a thin adapter. This separation allows the interpreter and any other component to parse JSON without instantiating a built-in.
- **Capability-driven JSON parsing** — `JSONParser.pas` now owns one event-driven parser core plus a `TJSONParserCapabilities` set. Strict JSON uses the empty capability set, while JSON5 opts into comments, trailing commas, single-quoted strings, identifier keys, hexadecimal numbers, signed numbers, `Infinity` / `NaN`, line continuations, and ECMAScript whitespace extensions. This keeps JSON and JSON5 behavior aligned on the shared grammar machinery instead of maintaining two diverging parser implementations.
Expand Down
4 changes: 4 additions & 0 deletions fixtures/modules/live-bindings-link-child.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { readLinkedCycleReady } from "./live-bindings-link-parent.js";

export const linkedCycleReady = "linked";
export const observedLinkedCycle = readLinkedCycleReady();
11 changes: 11 additions & 0 deletions fixtures/modules/live-bindings-link-parent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {
linkedCycleReady,
observedLinkedCycle,
} from "./live-bindings-link-child.js";
import * as linkedCycleNamespace from "./live-bindings-link-child.js";

export function readLinkedCycleReady() {
return linkedCycleReady + ":" + linkedCycleNamespace.linkedCycleReady;
}

export { observedLinkedCycle };
5 changes: 3 additions & 2 deletions source/units/Goccia.AST.Statements.pas
Original file line number Diff line number Diff line change
Expand Up @@ -1605,8 +1605,9 @@ constructor TGocciaForInStatement.Create(const AIsConst: Boolean;
AttributeType), AContext.CurrentFilePath);
for ImportPair in Imports do
begin
if Module.TryGetExportValue(ImportPair.Value, Value) then
BindImportValue(ImportPair.Key, Value)
if Module.CanResolveExport(ImportPair.Value) then
AContext.Scope.CreateImportBinding(ImportPair.Key, Module,
ImportPair.Value)
else
begin
AContext.OnError(Format('Module "%s" has no export named "%s"',
Expand Down
108 changes: 108 additions & 0 deletions source/units/Goccia.Modules.ContentProvider.Test.pas
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,11 @@
Goccia.Token,
Goccia.TOML,
Goccia.Values.ArrayValue,
Goccia.Values.Error,
Goccia.Values.FunctionBase,
Goccia.Values.ObjectValue,
Goccia.Values.Primitives,
Goccia.VM.Exception,
Goccia.YAML;

type
Expand Down Expand Up @@ -78,9 +81,12 @@ TModuleContentProviderTests = class(TTestSuite)
procedure WriteTextFile(const APath, AText: string);
procedure AssertEngineModuleManifestDefaultsToEntryPath(
const AExecutor: TGocciaExecutor);
procedure AssertFailedCycleKeepsLiveBindings(
const AExecutor: TGocciaExecutor);

procedure TestEngineLoadsInMemoryModuleWithCustomProvider;
procedure TestEngineRetriesModuleAfterFailedLoad;
procedure TestFailedCycleKeepsLiveBindings;
procedure TestEngineReportsJSONLModuleLineNumbers;
procedure TestEngineReportsTOMLModuleSyntaxErrors;
procedure TestFileSystemContentProviderPreservesUTF8JSONLText;
Expand Down Expand Up @@ -169,6 +175,8 @@ procedure TModuleContentProviderTests.SetupTests;
TestEngineLoadsInMemoryModuleWithCustomProvider);
Test('Engine retries module load after previous failure',
TestEngineRetriesModuleAfterFailedLoad);
Test('Failed module cycles keep surviving live bindings valid',
TestFailedCycleKeepsLiveBindings);
Test('Engine reports JSONL module parse line numbers',
TestEngineReportsJSONLModuleLineNumbers);
Test('Engine reports TOML module syntax errors',
Expand Down Expand Up @@ -487,6 +495,106 @@ procedure TModuleContentProviderTests.TestEngineRetriesModuleAfterFailedLoad;
end;
end;

procedure TModuleContentProviderTests.AssertFailedCycleKeepsLiveBindings(
const AExecutor: TGocciaExecutor);
const
ENTRY_PATH = 'memory:/app.mjs';
FAILED_PATH = 'memory:/failed-cycle.js';
SURVIVOR_PATH = 'memory:/failed-cycle-survivor.js';
var
Engine: TGocciaEngine;
ExportValue: TGocciaValue;
FailureMessage: string;
HasExpectedFailureMessage: Boolean;
ModuleLoader: TGocciaModuleLoader;
Provider: TMemoryModuleContentProvider;
RaisedExpected: Boolean;
Resolver: TInMemoryModuleResolver;
Source: TStringList;
SurvivorModule: TGocciaModule;
ThrownValue: TGocciaValue;
begin
Provider := TMemoryModuleContentProvider.Create;
Resolver := TInMemoryModuleResolver.Create;
Source := TStringList.Create;
try
Provider.AddModule(FAILED_PATH,
'import "' + SURVIVOR_PATH + '";' + sLineBreak +
'export const value = "still live";' + sLineBreak +
'throw new Error("failed cycle");');
Provider.AddModule(SURVIVOR_PATH,
'import { value } from "' + FAILED_PATH + '";' + sLineBreak +
'export const readImportedValue = () => value;' + sLineBreak +
'export const evaluated = true;');
Source.Text := '1;';

ModuleLoader := TGocciaModuleLoader.Create(ENTRY_PATH, Resolver, Provider);
try
Engine := TGocciaEngine.Create(ENTRY_PATH, Source, ModuleLoader,
AExecutor);
try
RaisedExpected := False;
try
ModuleLoader.LoadModule(FAILED_PATH, ENTRY_PATH);
except
on E: Exception do
begin
RaisedExpected := True;
FailureMessage := E.Message;
ThrownValue := nil;
if E is TGocciaThrowValue then
ThrownValue := TGocciaThrowValue(E).Value
else if E is EGocciaBytecodeThrow then
ThrownValue := EGocciaBytecodeThrow(E).ThrownValue;
if ThrownValue is TGocciaObjectValue then
FailureMessage := TGocciaObjectValue(ThrownValue)
.GetProperty(PROP_MESSAGE).ToStringLiteral.Value;
HasExpectedFailureMessage :=
Pos('failed cycle', FailureMessage) > 0;
if not HasExpectedFailureMessage then
Fail('Expected cyclic module evaluation failure message.');
end;
end;
if not RaisedExpected then
Fail('Expected the cyclic parent module to fail evaluation.');

SurvivorModule := ModuleLoader.LoadModule(SURVIVOR_PATH, ENTRY_PATH);
Expect<Boolean>(SurvivorModule.TryGetExportValue('readImportedValue',
ExportValue)).ToBe(True);
Expect<Boolean>(ExportValue is TGocciaFunctionBase).ToBe(True);
SurvivorModule.MarkExportReferences;
finally
Engine.Free;
end;
finally
ModuleLoader.Free;
end;
finally
Source.Free;
Resolver.Free;
Provider.Free;
end;
end;

procedure TModuleContentProviderTests.TestFailedCycleKeepsLiveBindings;
var
Executor: TGocciaExecutor;
begin
Executor := TGocciaInterpreterExecutor.Create;
try
AssertFailedCycleKeepsLiveBindings(Executor);
finally
Executor.Free;
end;

Executor := TGocciaBytecodeExecutor.Create;
try
AssertFailedCycleKeepsLiveBindings(Executor);
finally
Executor.Free;
end;
end;

procedure TModuleContentProviderTests.TestEngineReportsJSONLModuleLineNumbers;
const
ENTRY_PATH = 'memory:/app.mjs';
Expand Down
Loading
Loading