Skip to content

Commit 59bd3e2

Browse files
committed
Define module namespace conflict behavior
1 parent 6da45bc commit 59bd3e2

7 files changed

Lines changed: 94 additions & 10 deletions

File tree

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ Goal: make multi-file script projects easier to compose and maintain.
251251

252252
- [x] Add explicit export controls (beyond underscore naming).
253253
- [x] Add import aliasing for module objects.
254-
- [ ] Define and enforce module namespace conflict behavior.
254+
- [x] Define and enforce module namespace conflict behavior.
255255
- [ ] Improve cycle error diagnostics with concise chain rendering.
256256
- [ ] Add module cache invalidation policy for long-running hosts.
257257

docs/builtins.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ For time manipulation in VibeScript, use the `Time` object (`Time.now`, `Time.pa
6060

6161
### `require(module_name, as: alias?)`
6262

63-
Loads a module from configured module search paths and returns an object containing the module's exported functions. Modules can mark exports explicitly with `export def ...`; when no explicit exports are declared, public (non-underscore) functions are exported by default:
63+
Loads a module from configured module search paths and returns an object containing the module's exported functions. Modules can mark exports explicitly with `export def ...`; when no explicit exports are declared, public (non-underscore) functions are exported by default. Exported functions are injected into globals only when the name is still free (existing globals keep precedence), and `as:` can be used to bind the module object explicitly:
6464

6565
```vibe
6666
def calculate_total(amount)

docs/examples/module_require.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ When `total_with_fee` runs, `require("fees")` resolves the module relative to
4646
`Config.ModulePaths`, compiles it once, and returns an object containing the
4747
module’s exports. Use `export def` for explicit control; if no explicit exports
4848
are declared, public functions are exported by default and names starting with
49-
`_` stay private to the module.
49+
`_` stay private to the module. When an exported name conflicts with an
50+
existing global, the existing binding keeps precedence and the module object
51+
remains the conflict-free access path.
5052

5153
Inside modules, explicit relative requires are supported:
5254

docs/integration.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ or parent-local helpers. Relative requires are resolved from the calling
7979
module's directory and are rejected if they escape the module root. Functions
8080
can be exported explicitly with `export def ...`; if no explicit exports are
8181
declared, public names are exported by default and names starting with `_`
82-
remain private.
82+
remain private. Exported names are only injected into globals when no binding
83+
already exists, so existing host/script globals keep precedence.
8384

8485
### Capability Adapters
8586

vibes/modules.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,15 @@ func bindRequireAlias(root *Env, alias string, module Value) error {
429429
return nil
430430
}
431431

432+
func bindModuleExportsWithoutOverwrite(root *Env, exports map[string]Value) {
433+
for name, fnVal := range exports {
434+
if _, exists := root.Get(name); exists {
435+
continue
436+
}
437+
root.Define(name, fnVal)
438+
}
439+
}
440+
432441
func builtinRequire(exec *Execution, receiver Value, args []Value, kwargs map[string]Value, block Value) (Value, error) {
433442
if exec.strictEffects && !exec.allowRequire {
434443
return NewNil(), fmt.Errorf("strict effects: require is disabled without CallOptions.AllowRequire")
@@ -499,12 +508,7 @@ func builtinRequire(exec *Execution, receiver Value, args []Value, kwargs map[st
499508
}
500509
}
501510

502-
for name, fnVal := range exports {
503-
if _, exists := exec.root.Get(name); exists {
504-
continue
505-
}
506-
exec.root.Define(name, fnVal)
507-
}
511+
bindModuleExportsWithoutOverwrite(exec.root, exports)
508512

509513
exportsVal := NewObject(exports)
510514
exec.modules[entry.key] = exportsVal

vibes/modules_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,80 @@ end`)
149149
}
150150
}
151151

152+
func TestRequireNamespaceConflictKeepsExistingGlobalBinding(t *testing.T) {
153+
engine := MustNewEngine(Config{ModulePaths: []string{filepath.Join("testdata", "modules")}})
154+
155+
script, err := engine.Compile(`def double(value)
156+
value + 1
157+
end
158+
159+
def run(value)
160+
mod = require("helper")
161+
{
162+
global: double(value),
163+
module: mod.double(value)
164+
}
165+
end`)
166+
if err != nil {
167+
t.Fatalf("compile failed: %v", err)
168+
}
169+
170+
result, err := script.Call(context.Background(), "run", []Value{NewInt(3)}, CallOptions{})
171+
if err != nil {
172+
t.Fatalf("call failed: %v", err)
173+
}
174+
if result.Kind() != KindHash {
175+
t.Fatalf("expected hash result, got %#v", result)
176+
}
177+
out := result.Hash()
178+
if out["global"].Kind() != KindInt || out["global"].Int() != 4 {
179+
t.Fatalf("expected global binding to stay at 4, got %#v", out["global"])
180+
}
181+
if out["module"].Kind() != KindInt || out["module"].Int() != 6 {
182+
t.Fatalf("expected module object function to return 6, got %#v", out["module"])
183+
}
184+
}
185+
186+
func TestRequireNamespaceConflictKeepsFirstModuleBinding(t *testing.T) {
187+
engine := MustNewEngine(Config{ModulePaths: []string{filepath.Join("testdata", "modules")}})
188+
189+
script, err := engine.Compile(`def run(value)
190+
first = require("helper")
191+
second = require("helper_alt")
192+
require("helper_alt", as: "alt")
193+
{
194+
global: double(value),
195+
first: first.double(value),
196+
second: second.double(value),
197+
alias: alt.double(value)
198+
}
199+
end`)
200+
if err != nil {
201+
t.Fatalf("compile failed: %v", err)
202+
}
203+
204+
result, err := script.Call(context.Background(), "run", []Value{NewInt(3)}, CallOptions{})
205+
if err != nil {
206+
t.Fatalf("call failed: %v", err)
207+
}
208+
if result.Kind() != KindHash {
209+
t.Fatalf("expected hash result, got %#v", result)
210+
}
211+
out := result.Hash()
212+
if out["global"].Kind() != KindInt || out["global"].Int() != 6 {
213+
t.Fatalf("expected first module binding to stay global at 6, got %#v", out["global"])
214+
}
215+
if out["first"].Kind() != KindInt || out["first"].Int() != 6 {
216+
t.Fatalf("expected first module object to return 6, got %#v", out["first"])
217+
}
218+
if out["second"].Kind() != KindInt || out["second"].Int() != 30 {
219+
t.Fatalf("expected second module object to return 30, got %#v", out["second"])
220+
}
221+
if out["alias"].Kind() != KindInt || out["alias"].Int() != 30 {
222+
t.Fatalf("expected alias module object to return 30, got %#v", out["alias"])
223+
}
224+
}
225+
152226
func TestRequireMissingModule(t *testing.T) {
153227
engine := MustNewEngine(Config{ModulePaths: []string{filepath.Join("testdata", "modules")}})
154228

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
def double(value)
2+
value * 10
3+
end

0 commit comments

Comments
 (0)