Skip to content

Add module resolver#17

Merged
frostney merged 4 commits into
mainfrom
feat-module-resolution
Feb 19, 2026
Merged

Add module resolver#17
frostney merged 4 commits into
mainfrom
feat-module-resolution

Conversation

@frostney
Copy link
Copy Markdown
Owner

@frostney frostney commented Feb 19, 2026

Summary by CodeRabbit

  • New Features

    • Adds .mjs support, extension-less imports, path-alias resolution, directory/index resolution, pluggable module resolver, and global module registration.
  • Code-style

    • Centralized file-extension and JSX-detection guidance to avoid duplicated extension lists.
  • Documentation

    • Expanded architecture, embedding, design, language-restrictions, code-style, and testing docs with resolver examples and alias semantics.
  • Tests

    • Added tests for extensionless imports, .mjs imports/re-exports, and index-resolution; test discovery includes additional script extensions.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 19, 2026

No actionable comments were generated in the recent review. 🎉


📝 Walkthrough

Walkthrough

This PR adds a centralized FileExtensions unit and a pluggable TGocciaModuleResolver, integrates resolver support into Engine and Interpreter (aliasing, extension probing, index resolution, global modules), extends extension handling to include .mjs, and adds tests and docs for extensionless imports and index resolution.

Changes

Cohort / File(s) Summary
File-extensions & Resolver core
units/Goccia.FileExtensions.pas, units/Goccia.Modules.Resolver.pas
New FileExtensions unit with EXT_* constants, ScriptExtensions/JSXNative arrays and helpers; new TGocciaModuleResolver with aliasing (longest-prefix), TryResolveWithExtensions (exact, extensions, index), BaseDirectory, and EGocciaModuleNotFound.
Engine integration
units/Goccia.Engine.pas
Engine gains FResolver/FOwnsResolver, constructor overload accepting a resolver, Resolver property, AddAlias and RegisterGlobalModule APIs, and wires resolver into the interpreter; lifecycle management added.
Interpreter changes
units/Goccia.Interpreter.pas
Interpreter now holds FResolver and FGlobalModules, exposes Resolver and GlobalModules, consults global modules before resolver.Resolve, delegates resolution to resolver, and wraps resolver errors into runtime errors; JSON detection uses EXT_JSON.
File utilities & JSX
units/FileUtils.pas, units/Goccia.JSX.Transformer.pas
Removed DefaultScriptExtensions and parameterless FindAllFiles; FileUtils path construction normalized; JSX transformer uses IsJSXNativeExtension and centralized constants.
Runners & discovery
ScriptLoader.dpr, TestRunner.dpr, BenchmarkRunner.dpr
Updated uses to include Goccia.FileExtensions and updated FindAllFiles calls to pass ScriptExtensions; CLI/help messages updated to include .mjs.
Tests & helpers
tests/language/modules/*.js, tests/language/modules/helpers/*
Added tests for extensionless imports, .mjs imports/re-exports, and index resolution; added helper modules (.mjs, index.js/.ts, multi-index) to exercise extension probing and index preference.
Docs & style
docs/architecture.md, docs/embedding.md, docs/language-restrictions.md, docs/testing.md, docs/code-style.md, docs/design-decisions.md, AGENTS.md
Documentation expanded to describe resolver pipeline (aliasing, extension, index), global modules, extensionless imports, longest-prefix alias semantics, centralized FileExtensions guidance, and .mjs inclusion.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client Code
    participant Engine as TGocciaEngine
    participant Interpreter as TGocciaInterpreter
    participant Resolver as TGocciaModuleResolver
    participant FS as File System

    Client->>Engine: Create(entry, source, globals, resolver?)
    Engine->>Interpreter: Construct + assign Resolver
    Client->>Interpreter: Request module(specifier, importerPath)
    Interpreter->>Interpreter: Check GlobalModules cache
    alt Found in GlobalModules
        Interpreter-->>Client: Return cached global module
    else
        Interpreter->>Resolver: Resolve(specifier, importerPath)
        Resolver->>Resolver: ApplyAliases(specifier)
        Resolver->>FS: Probe exact path -> extensions -> index files
        FS-->>Resolver: Resolved path / not found
        Resolver-->>Interpreter: ResolvedPath or raise EGocciaModuleNotFound
        Interpreter->>FS: Read ResolvedPath
        FS-->>Interpreter: File contents
        Interpreter->>Interpreter: Parse/transform/execute & cache
        Interpreter-->>Client: Module exports
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 I dug some aliases in the ground,
I hopped through extensions newly found,
From .js to .mjs I dance and play,
Resolver lights each tidy way,
Hooray for paths that lead my day! 🥕

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add module resolver' accurately reflects the primary change—introduction of TGocciaModuleResolver with comprehensive module resolution capabilities, aliasing, extension handling, and related architecture updates throughout the codebase.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat-module-resolution

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
tests/language/modules/mjs-import.js (1)

1-11: LGTM!

Well-structured test file covering the essential happy paths for .mjs module imports. Tests both value and function exports appropriately.

Consider adding edge case tests in the future, such as:

  • Importing from .mjs with extensionless path (e.g., "./helpers/mjs-module")
  • Re-exporting from .mjs modules
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/language/modules/mjs-import.js` around lines 1 - 11, Add two additional
edge-case tests to the existing suite in the mjs module tests: one that imports
from the same module using an extensionless path (use the existing test file
mjs-import.js and the module name "./helpers/mjs-module" to verify mjsValue and
mjsAdd still work) and another that exercises re-exports (create or import a
re-exporting module that re-exports the .mjs exports and add tests asserting the
re-exported value and function behave identically); update or add test cases
near the existing describe("mjs module import") block referencing the symbols
mjsValue and mjsAdd to validate these scenarios.
units/Goccia.Modules.Resolver.pas (1)

57-87: Alias matching uses first-match semantics.

The alias matching iterates through the dictionary and exits on the first match. Since TDictionary iteration order is not guaranteed, if multiple aliases could match the same prefix (e.g., @/ and @/sub/), the behavior is nondeterministic.

This is likely fine if aliases are intended to be non-overlapping prefixes, but worth documenting or considering longest-prefix-match if overlapping aliases are a use case.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@units/Goccia.Modules.Resolver.pas` around lines 57 - 87, The current HasAlias
and ApplyAliases use first-match semantics over FAliases which is
nondeterministic because TDictionary iteration order is not guaranteed; modify
both functions to perform a longest-prefix match instead: iterate all pairs in
FAliases, track the Pair with the longest Pair.Key that matches the start of
AModulePath (or Result), then after the loop apply the replacement using that
best match (using FBaseDirectory the same way as now) and return accordingly;
update HasAlias to return True only if any match was found (based on the
longest-match check) so behavior is deterministic when overlapping prefixes
(e.g., '@/' vs '@/sub/') exist.
units/Goccia.Interpreter.pas (1)

142-154: Good defensive error handling with one potential gap.

The global module lookup and resolver integration is well implemented. The check for an unassigned resolver (Lines 148-150) provides a clear error message.

However, the exception handler at Lines 151-154 only catches EGocciaModuleNotFound. If FResolver.Resolve throws a different exception type (e.g., I/O errors from FileExists), it would propagate unwrapped. Consider whether a broader catch is needed or if this is intentional (letting I/O errors bubble up directly).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@units/Goccia.Interpreter.pas` around lines 142 - 154, The except block
currently only handles EGocciaModuleNotFound, so other exceptions thrown by
FResolver.Resolve (e.g., IO errors) will escape unwrapped; update the handler
around the FResolver.Resolve call so it catches Exception (or adds a separate
except for Exception) and rethrows a TGocciaRuntimeError with the original
exception's message and context (preserving AImportingFilePath) rather than
letting non-EGocciaModuleNotFound exceptions propagate; reference
FGlobalModules, FResolver.Resolve, EGocciaModuleNotFound and TGocciaRuntimeError
when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/language/modules/index-import.js`:
- Around line 1-11: Add two new tests inside the existing "index file
resolution" describe: one edge-case that imports from a directory containing
multiple index candidates (e.g., a ./helpers/multi-index fixture) and asserts
the resolver picks the correct index file by expecting a specific exported value
(similar to how indexValue/indexDouble are used), and one error-case that
attempts to import from a directory with no index file (e.g.,
./helpers/missing-index) and asserts the import/require throws a resolver error
(use expect(() => require(...)).toThrow() or the equivalent dynamic-import
rejection). Ensure you add or update helper fixtures under ./helpers
(multi-index and missing-index) to reproduce the scenarios and keep the tests
focused only on index resolution.

---

Nitpick comments:
In `@tests/language/modules/mjs-import.js`:
- Around line 1-11: Add two additional edge-case tests to the existing suite in
the mjs module tests: one that imports from the same module using an
extensionless path (use the existing test file mjs-import.js and the module name
"./helpers/mjs-module" to verify mjsValue and mjsAdd still work) and another
that exercises re-exports (create or import a re-exporting module that
re-exports the .mjs exports and add tests asserting the re-exported value and
function behave identically); update or add test cases near the existing
describe("mjs module import") block referencing the symbols mjsValue and mjsAdd
to validate these scenarios.

In `@units/Goccia.Interpreter.pas`:
- Around line 142-154: The except block currently only handles
EGocciaModuleNotFound, so other exceptions thrown by FResolver.Resolve (e.g., IO
errors) will escape unwrapped; update the handler around the FResolver.Resolve
call so it catches Exception (or adds a separate except for Exception) and
rethrows a TGocciaRuntimeError with the original exception's message and context
(preserving AImportingFilePath) rather than letting non-EGocciaModuleNotFound
exceptions propagate; reference FGlobalModules, FResolver.Resolve,
EGocciaModuleNotFound and TGocciaRuntimeError when making the change.

In `@units/Goccia.Modules.Resolver.pas`:
- Around line 57-87: The current HasAlias and ApplyAliases use first-match
semantics over FAliases which is nondeterministic because TDictionary iteration
order is not guaranteed; modify both functions to perform a longest-prefix match
instead: iterate all pairs in FAliases, track the Pair with the longest Pair.Key
that matches the start of AModulePath (or Result), then after the loop apply the
replacement using that best match (using FBaseDirectory the same way as now) and
return accordingly; update HasAlias to return True only if any match was found
(based on the longest-match check) so behavior is deterministic when overlapping
prefixes (e.g., '@/' vs '@/sub/') exist.

Comment on lines +1 to +11
import { indexValue, indexDouble } from "./helpers/indexed-module";

describe("index file resolution", () => {
test("import from directory resolves to index.js", () => {
expect(indexValue).toBe("from-index");
});

test("import function from index module", () => {
expect(indexDouble(21)).toBe(42);
});
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add edge/error coverage for index resolution.
This file only tests happy paths. Please add at least one edge case (e.g., directory with multiple candidate index extensions) and one error case (e.g., missing index file should raise a resolver error) while keeping the focus on index resolution. As per coding guidelines, "Each test file should focus on a single concern. Tests should cover happy paths, edge cases, and error cases."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/language/modules/index-import.js` around lines 1 - 11, Add two new
tests inside the existing "index file resolution" describe: one edge-case that
imports from a directory containing multiple index candidates (e.g., a
./helpers/multi-index fixture) and asserts the resolver picks the correct index
file by expecting a specific exported value (similar to how
indexValue/indexDouble are used), and one error-case that attempts to import
from a directory with no index file (e.g., ./helpers/missing-index) and asserts
the import/require throws a resolver error (use expect(() =>
require(...)).toThrow() or the equivalent dynamic-import rejection). Ensure you
add or update helper fixtures under ./helpers (multi-index and missing-index) to
reproduce the scenarios and keep the tests focused only on index resolution.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@units/Goccia.Modules.Resolver.pas`:
- Around line 93-99: The code treats only '/' as an absolute path, which breaks
on Windows; in ApplyAliases and Resolve replace the single-char '/' test with a
platform-aware absolute path check that (1) ensures Replacement is long enough
then tests for Windows drive-letter pattern (e.g., Replacement[2] = ':') and (2)
tests for UNC paths (Copy(Replacement,1,2) = '\\') in addition to the existing
Unix '/' check; update the conditional around Result assignment in ApplyAliases
(function ApplyAliases, variable Replacement) and the similar conditional in
Resolve so both branches correctly detect absolute paths across platforms
without adding external dependencies.

Comment thread units/Goccia.Modules.Resolver.pas
@github-actions
Copy link
Copy Markdown
Contributor

Benchmark Results

134 benchmarks · 134 unchanged · avg +2.3%

arrays.js — 11 unchanged · avg +2.0%
Benchmark Base (ops/sec) PR (ops/sec) Change
Array.from length 100 140,644 144,363 +2.6%
Array.of 10 elements 155,697 158,698 +1.9%
spread into new array 162,074 165,770 +2.3%
map over 50 elements 88,443 89,948 +1.7%
filter over 50 elements 87,843 89,757 +2.2%
reduce sum 50 elements 91,995 94,437 +2.7%
forEach over 50 elements 93,026 94,025 +1.1%
find in 50 elements 97,552 99,084 +1.6%
sort 20 elements 3,978 4,035 +1.4%
flat nested array 78,016 79,318 +1.7%
flatMap 38,140 39,345 +3.2%
classes.js — 10 unchanged · avg +3.3%
Benchmark Base (ops/sec) PR (ops/sec) Change
simple class new 71,226 74,080 +4.0%
class with defaults 53,701 55,815 +3.9%
50 instances via Array.from 77,551 79,231 +2.2%
instance method call 33,090 34,315 +3.7%
static method call 58,624 59,840 +2.1%
single-level inheritance 27,894 28,931 +3.7%
two-level inheritance 26,330 27,166 +3.2%
private field access 34,931 36,218 +3.7%
private methods 41,655 42,223 +1.4%
getter/setter access 37,086 38,888 +4.9%
closures.js — 11 unchanged · avg +2.6%
Benchmark Base (ops/sec) PR (ops/sec) Change
closure over single variable 55,759 57,410 +3.0%
closure over multiple variables 54,470 56,063 +2.9%
nested closures 59,687 61,733 +3.4%
function as argument 41,529 42,157 +1.5%
function returning function 55,079 56,087 +1.8%
compose two functions 33,957 34,717 +2.2%
fn.call 81,204 82,752 +1.9%
fn.apply 58,778 61,023 +3.8%
fn.bind 69,898 72,027 +3.0%
recursive sum to 50 4,762 4,918 +3.3%
recursive tree traversal 8,454 8,596 +1.7%
collections.js — 12 unchanged · avg +2.0%
Benchmark Base (ops/sec) PR (ops/sec) Change
add 50 elements 81,480 82,997 +1.9%
has lookup (50 elements) 63,530 65,587 +3.2%
delete elements 64,107 64,657 +0.9%
forEach iteration 78,268 80,794 +3.2%
spread to array 83,970 85,801 +2.2%
deduplicate array 53,896 54,851 +1.8%
set 50 entries 80,369 82,209 +2.3%
get lookup (50 entries) 61,333 62,989 +2.7%
has check 67,682 68,558 +1.3%
delete entries 62,178 62,562 +0.6%
forEach iteration 74,146 75,571 +1.9%
keys/values/entries 49,396 50,200 +1.6%
destructuring.js — 14 unchanged · avg +3.4%
Benchmark Base (ops/sec) PR (ops/sec) Change
simple array destructuring 176,658 182,129 +3.1%
with rest element 131,567 135,659 +3.1%
with defaults 180,838 184,603 +2.1%
skip elements 188,403 199,478 +5.9%
nested array destructuring 104,547 108,289 +3.6%
swap variables 202,830 212,923 +5.0%
simple object destructuring 129,983 135,057 +3.9%
with defaults 156,702 162,740 +3.9%
with renaming 151,146 157,975 +4.5%
nested object destructuring 79,059 81,887 +3.6%
rest properties 82,219 84,458 +2.7%
object parameter 49,245 50,146 +1.8%
array parameter 63,881 65,570 +2.6%
mixed destructuring in map 84,962 86,926 +2.3%
fibonacci.js — 3 unchanged · avg +2.3%
Benchmark Base (ops/sec) PR (ops/sec) Change
recursive fib(15) 131 134 +2.2%
recursive fib(20) 12 12 +2.8%
iterative fib(20) via reduce 63,966 65,140 +1.8%
json.js — 11 unchanged · avg -0.1%
Benchmark Base (ops/sec) PR (ops/sec) Change
parse simple object 109,938 108,092 -1.7%
parse nested object 69,998 69,344 -0.9%
parse array of objects 40,232 39,231 -2.5%
parse large flat object 34,667 33,656 -2.9%
parse mixed types 50,724 50,318 -0.8%
stringify simple object 96,597 97,814 +1.3%
stringify nested object 55,812 56,139 +0.6%
stringify array of objects 105,432 107,124 +1.6%
stringify mixed types 46,797 47,572 +1.7%
parse then stringify 33,777 33,926 +0.4%
stringify then parse 44,127 45,011 +2.0%
jsx.jsx — 21 unchanged · avg +2.0%
Benchmark Base (ops/sec) PR (ops/sec) Change
simple element 109,424 112,208 +2.5%
self-closing element 112,801 114,307 +1.3%
element with string attribute 94,490 96,506 +2.1%
element with multiple attributes 81,824 83,901 +2.5%
element with expression attribute 84,876 86,444 +1.8%
text child 108,942 111,916 +2.7%
expression child 103,677 105,894 +2.1%
mixed text and expression 98,985 101,457 +2.5%
nested elements (3 levels) 42,086 43,304 +2.9%
sibling children 31,435 32,278 +2.7%
component element 78,986 80,745 +2.2%
component with children 49,323 50,608 +2.6%
dotted component 67,086 68,327 +1.8%
empty fragment 107,399 109,178 +1.7%
fragment with children 31,210 31,526 +1.0%
spread attributes 60,051 61,168 +1.9%
spread with overrides 52,230 53,263 +2.0%
shorthand props 81,492 82,619 +1.4%
nav bar structure 15,227 15,511 +1.9%
card component tree 17,903 18,165 +1.5%
10 list items via Array.from 137,323 138,463 +0.8%
numbers.js — 11 unchanged · avg +1.8%
Benchmark Base (ops/sec) PR (ops/sec) Change
integer arithmetic 173,311 177,349 +2.3%
floating point arithmetic 193,058 197,635 +2.4%
number coercion 101,636 102,266 +0.6%
toFixed 69,391 71,298 +2.7%
toString 94,857 97,938 +3.2%
valueOf 126,409 130,064 +2.9%
toPrecision 89,716 91,479 +2.0%
Number.isNaN 150,707 153,233 +1.7%
Number.isFinite 145,131 146,385 +0.9%
Number.isInteger 150,028 150,704 +0.5%
Number.parseInt and parseFloat 137,032 137,935 +0.7%
objects.js — 7 unchanged · avg +2.1%
Benchmark Base (ops/sec) PR (ops/sec) Change
create simple object 215,586 226,490 +5.1%
create nested object 115,790 118,568 +2.4%
create 50 objects via Array.from 136,895 138,795 +1.4%
property read 97,295 100,287 +3.1%
Object.keys 69,777 70,773 +1.4%
Object.entries 46,275 45,849 -0.9%
spread operator 88,315 90,307 +2.3%
promises.js — 12 unchanged · avg +2.0%
Benchmark Base (ops/sec) PR (ops/sec) Change
Promise.resolve(value) 294,684 300,722 +2.0%
new Promise(resolve => resolve(value)) 115,454 117,998 +2.2%
Promise.reject(reason) 306,902 311,247 +1.4%
resolve + then (1 handler) 99,317 102,259 +3.0%
resolve + then chain (3 deep) 41,609 42,476 +2.1%
resolve + then chain (10 deep) 13,751 14,033 +2.1%
reject + catch + then 61,831 62,873 +1.7%
resolve + finally + then 54,370 55,537 +2.1%
Promise.all (5 resolved) 21,846 22,238 +1.8%
Promise.race (5 resolved) 22,943 23,351 +1.8%
Promise.allSettled (5 mixed) 17,805 18,116 +1.7%
Promise.any (5 mixed) 21,956 22,397 +2.0%
strings.js — 11 unchanged · avg +3.9%
Benchmark Base (ops/sec) PR (ops/sec) Change
string concatenation 243,087 246,376 +1.4%
template literal 243,317 249,080 +2.4%
string repeat 226,701 235,129 +3.7%
split and join 98,188 100,188 +2.0%
indexOf and includes 100,931 106,203 +5.2%
toUpperCase and toLowerCase 145,752 152,986 +5.0%
slice and substring 90,251 94,649 +4.9%
trim operations 108,723 113,630 +4.5%
replace and replaceAll 127,289 132,470 +4.1%
startsWith and endsWith 79,951 84,505 +5.7%
padStart and padEnd 116,430 121,019 +3.9%

Measured on ubuntu-latest x64. Changes within ±7% are considered insignificant.

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

Labels

new feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant