Skip to content

Commit 0e3369d

Browse files
committed
Add explicit module export controls
1 parent 55f880a commit 0e3369d

12 files changed

Lines changed: 157 additions & 11 deletions

File tree

ROADMAP.md

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

250250
### Module System
251251

252-
- [ ] Add explicit export controls (beyond underscore naming).
252+
- [x] Add explicit export controls (beyond underscore naming).
253253
- [ ] Add import aliasing for module objects.
254254
- [ ] Define and enforce module namespace conflict behavior.
255255
- [ ] Improve cycle error diagnostics with concise chain rendering.

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)`
6262

63-
Loads a module from configured module search paths and returns an object containing the module's exported functions:
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:
6464

6565
```vibe
6666
def calculate_total(amount)

docs/examples/module_require.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,12 @@ Place `modules/fees.vibe` on disk:
1818
```vibe
1919
# modules/fees.vibe
2020
21-
def rate()
22-
1
21+
export def apply_fee(amount)
22+
amount + rate()
2323
end
2424
25-
def apply_fee(amount)
26-
amount + rate()
25+
def rate()
26+
1
2727
end
2828
2929
def _rounding_hint()
@@ -44,8 +44,9 @@ end
4444

4545
When `total_with_fee` runs, `require("fees")` resolves the module relative to
4646
`Config.ModulePaths`, compiles it once, and returns an object containing the
47-
module’s public exports. Function names starting with `_` stay private to the
48-
module and are not exposed on the returned object or injected globally.
47+
module’s exports. Use `export def` for explicit control; if no explicit exports
48+
are declared, public functions are exported by default and names starting with
49+
`_` stay private to the module.
4950

5051
Inside modules, explicit relative requires are supported:
5152

docs/integration.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,9 @@ and caches compiled modules so subsequent calls to `require` are inexpensive.
7575
Inside a module, use explicit relative paths (`./` or `../`) to load siblings
7676
or parent-local helpers. Relative requires are resolved from the calling
7777
module's directory and are rejected if they escape the module root. Functions
78-
whose names start with `_` are private and are not exported.
78+
can be exported explicitly with `export def ...`; if no explicit exports are
79+
declared, public names are exported by default and names starting with `_`
80+
remain private.
7981

8082
### Capability Adapters
8183

vibes/ast.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type FunctionStmt struct {
3131
ReturnTy *TypeExpr
3232
Body []Statement
3333
IsClassMethod bool
34+
Exported bool
3435
Private bool
3536
position Position
3637
}

vibes/execution.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type ScriptFunction struct {
2222
Body []Statement
2323
Pos Position
2424
Env *Env
25+
Exported bool
2526
Private bool
2627
owner *Script
2728
}
@@ -3502,7 +3503,7 @@ func (e *Engine) Compile(source string) (*Script, error) {
35023503
if _, exists := functions[s.Name]; exists {
35033504
return nil, fmt.Errorf("duplicate function %s", s.Name)
35043505
}
3505-
functions[s.Name] = &ScriptFunction{Name: s.Name, Params: s.Params, ReturnTy: s.ReturnTy, Body: s.Body, Pos: s.Pos()}
3506+
functions[s.Name] = &ScriptFunction{Name: s.Name, Params: s.Params, ReturnTy: s.ReturnTy, Body: s.Body, Pos: s.Pos(), Exported: s.Exported}
35063507
case *ClassStmt:
35073508
if _, exists := classes[s.Name]; exists {
35083509
return nil, fmt.Errorf("duplicate class %s", s.Name)

vibes/lexer.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,8 @@ func lookupIdent(ident string) TokenType {
395395
return tokenDef
396396
case "class":
397397
return tokenClass
398+
case "export":
399+
return tokenExport
398400
case "self":
399401
return tokenSelf
400402
case "private":

vibes/modules.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,22 @@ func isPublicModuleExport(name string) bool {
346346
return name != "" && !strings.HasPrefix(name, "_")
347347
}
348348

349+
func moduleHasExplicitExports(functions map[string]*ScriptFunction) bool {
350+
for _, fn := range functions {
351+
if fn != nil && fn.Exported {
352+
return true
353+
}
354+
}
355+
return false
356+
}
357+
358+
func shouldExportModuleFunction(name string, fn *ScriptFunction, hasExplicitExports bool) bool {
359+
if hasExplicitExports {
360+
return fn != nil && fn.Exported
361+
}
362+
return isPublicModuleExport(name)
363+
}
364+
349365
func builtinRequire(exec *Execution, receiver Value, args []Value, kwargs map[string]Value, block Value) (Value, error) {
350366
if exec.strictEffects && !exec.allowRequire {
351367
return NewNil(), fmt.Errorf("strict effects: require is disabled without CallOptions.AllowRequire")
@@ -399,11 +415,12 @@ func builtinRequire(exec *Execution, receiver Value, args []Value, kwargs map[st
399415

400416
moduleEnv := newEnv(exec.root)
401417
exports := make(map[string]Value, len(entry.script.functions))
418+
hasExplicitExports := moduleHasExplicitExports(entry.script.functions)
402419
for name, fn := range entry.script.functions {
403420
clone := cloneFunctionForEnv(fn, moduleEnv)
404421
fnVal := NewFunction(clone)
405422
moduleEnv.Define(name, fnVal)
406-
if isPublicModuleExport(name) {
423+
if shouldExportModuleFunction(name, fn, hasExplicitExports) {
407424
exports[name] = fnVal
408425
}
409426
}

vibes/modules_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,82 @@ end`)
406406
}
407407
}
408408

409+
func TestRequireSupportsExplicitExportControls(t *testing.T) {
410+
engine := MustNewEngine(Config{ModulePaths: []string{filepath.Join("testdata", "modules")}})
411+
412+
script, err := engine.Compile(`def run(value)
413+
mod = require("explicit_exports")
414+
{
415+
has_exposed: mod["exposed"] != nil,
416+
has_explicit_hidden: mod["_explicit_hidden"] != nil,
417+
has_helper: mod["helper"] != nil,
418+
has_internal: mod["_internal"] != nil,
419+
exposed: mod.exposed(value),
420+
explicit_hidden: mod._explicit_hidden(value)
421+
}
422+
end`)
423+
if err != nil {
424+
t.Fatalf("compile failed: %v", err)
425+
}
426+
427+
result, err := script.Call(context.Background(), "run", []Value{NewInt(3)}, CallOptions{})
428+
if err != nil {
429+
t.Fatalf("call failed: %v", err)
430+
}
431+
if result.Kind() != KindHash {
432+
t.Fatalf("expected hash result, got %#v", result)
433+
}
434+
out := result.Hash()
435+
if !out["has_exposed"].Bool() || !out["has_explicit_hidden"].Bool() {
436+
t.Fatalf("expected explicit exports to be present, got %#v", out)
437+
}
438+
if out["has_helper"].Bool() || out["has_internal"].Bool() {
439+
t.Fatalf("expected non-exported helpers to be hidden, got %#v", out)
440+
}
441+
if out["exposed"].Kind() != KindInt || out["exposed"].Int() != 10 {
442+
t.Fatalf("expected exposed(3)=10, got %#v", out["exposed"])
443+
}
444+
if out["explicit_hidden"].Kind() != KindInt || out["explicit_hidden"].Int() != 103 {
445+
t.Fatalf("expected _explicit_hidden(3)=103, got %#v", out["explicit_hidden"])
446+
}
447+
}
448+
449+
func TestRequireNonExportedFunctionsAreNotInjectedAsGlobalsWhenUsingExplicitExports(t *testing.T) {
450+
engine := MustNewEngine(Config{ModulePaths: []string{filepath.Join("testdata", "modules")}})
451+
452+
script, err := engine.Compile(`def run(value)
453+
require("explicit_exports")
454+
helper(value)
455+
end`)
456+
if err != nil {
457+
t.Fatalf("compile failed: %v", err)
458+
}
459+
460+
if _, err := script.Call(context.Background(), "run", []Value{NewInt(2)}, CallOptions{}); err == nil {
461+
t.Fatalf("expected undefined helper error")
462+
} else if !strings.Contains(err.Error(), "undefined variable helper") {
463+
t.Fatalf("unexpected error: %v", err)
464+
}
465+
}
466+
467+
func TestExportKeywordValidation(t *testing.T) {
468+
engine := MustNewEngine(Config{})
469+
470+
_, err := engine.Compile(`export helper`)
471+
if err == nil || !strings.Contains(err.Error(), "expected 'def'") {
472+
t.Fatalf("expected export def parse error, got %v", err)
473+
}
474+
475+
_, err = engine.Compile(`class Example
476+
export def value()
477+
1
478+
end
479+
end`)
480+
if err == nil || !strings.Contains(err.Error(), "export is only supported for top-level functions") {
481+
t.Fatalf("expected top-level export parse error, got %v", err)
482+
}
483+
}
484+
409485
func TestRequirePrivateFunctionsAreNotInjectedAsGlobals(t *testing.T) {
410486
engine := MustNewEngine(Config{ModulePaths: []string{filepath.Join("testdata", "modules")}})
411487

vibes/parser.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ func (p *parser) parseStatement() Statement {
121121
return p.parseFunctionStatement()
122122
case tokenClass:
123123
return p.parseClassStatement()
124+
case tokenExport:
125+
return p.parseExportStatement()
124126
case tokenReturn:
125127
return p.parseReturnStatement()
126128
case tokenRaise:
@@ -223,6 +225,32 @@ func (p *parser) parseFunctionStatement() Statement {
223225
return &FunctionStmt{Name: name, Params: params, ReturnTy: returnTy, Body: body, IsClassMethod: isClassMethod, Private: private, position: pos}
224226
}
225227

228+
func (p *parser) parseExportStatement() Statement {
229+
pos := p.curToken.Pos
230+
if p.insideClass {
231+
p.addParseError(pos, "export is only supported for top-level functions")
232+
return nil
233+
}
234+
if !p.expectPeek(tokenDef) {
235+
return nil
236+
}
237+
fnStmt := p.parseFunctionStatement()
238+
if fnStmt == nil {
239+
return nil
240+
}
241+
fn, ok := fnStmt.(*FunctionStmt)
242+
if !ok {
243+
p.addParseError(pos, "export expects a function definition")
244+
return nil
245+
}
246+
if fn.IsClassMethod {
247+
p.addParseError(pos, "export cannot be used with class methods")
248+
return nil
249+
}
250+
fn.Exported = true
251+
return fn
252+
}
253+
226254
func (p *parser) parseParams() []Param {
227255
params := []Param{}
228256
for {
@@ -1181,6 +1209,8 @@ func tokenLabel(tt TokenType) string {
11811209
return "'def'"
11821210
case tokenClass:
11831211
return "'class'"
1212+
case tokenExport:
1213+
return "'export'"
11841214
case tokenSelf:
11851215
return "'self'"
11861216
case tokenPrivate:

0 commit comments

Comments
 (0)