ADR-0073: Field and Method Visibility
Status
Implemented and stabilized. The field_method_visibility preview gate has been retired; pub on fields and methods ships as part of the language proper, and the ad-hoc BuiltinField::private flag from ADR-0072 §2 is replaced by the unified module-equivalence check.
Summary
Extend Gruel's existing item-level pub (ADR-0026) to struct/enum fields and methods. A field or method without pub is visible only inside its own module (intra-module access, exactly like today's private items); a pub field or method is visible across module boundaries. The same rule applies to synthetic built-in types: each built-in lives in a sentinel "builtin module" that user code is never part of, so an unmarked built-in field or method is unreachable from user code by the same mechanism that hides user-defined private fields.
This subsumes and retires the ad-hoc BuiltinField::private: bool flag introduced in ADR-0072 §2 — String::bytes becomes "a non-pub field in a module the user can't reach," with no special-case sema code. Built-in methods gain the same visibility knob (default pub for everything currently exposed), which gives future built-ins room to express internal helpers without being forced to exist outside the type.
The change is structural, not representational: the visibility check that already exists for items (SemaContext::is_accessible, ADR-0026) is reused verbatim for fields and methods.
Context
What exists today
ADR-0026 established Gruel's module system and visibility model:
- Items (functions, structs, enums, interfaces, constants) carry an
ast::Visibility { Private, Public }, parsed from an optionalpubkeyword. - Default is
Private. Cross-module access requirespub. Intra-module (same-directory) access is always permitted, regardless ofpub. - The check is centralized in
SemaContext::is_accessible(accessing_file_id, target_file_id, is_pub) -> bool.
This visibility never reached fields or methods:
parser::ast::FieldDeclhasname,ty,span— novisibility.parser::ast::Methodhasdirectives,name,receiver,params,return_type,body,span— novisibility.- Sema treats every field and method as if it were
pub. A struct exported withpub struct Foo { x: i32 }exposes itsxto every module that can nameFoo.
When ADR-0072 needed String::bytes to be inaccessible from user code, it took a deliberately narrower path: add BuiltinField::private: bool (default false) in gruel-builtins, mirror it to StructField::is_private in AIR, and reject expr.field / field writes / construction-syntax for any field where is_private == true. ADR-0072 §2 explicitly called the flag a placeholder:
When the broader visibility / module story arrives,
private: boolis replaced by whatever visibility model lands.
That broader story is this ADR.
Why now
Built-in types are about to multiply. ADR-0066 (
Vec(T)), ADR-0070 (Result(T,E)), ADR-0071 (char), ADR-0072 (String) all ship synthetic structs. Each has natural candidates for hidden state (Vec'scapis meaningful only via methods; Result's discriminant should be unobservable except through pattern matching). The ad-hocprivateflag works for one field on one type; it does not scale.User-defined structs are an asymmetric trap. A user can write
pub struct Account { balance: i64 }today and watch every consumer reach in and mutatebalancedirectly. Modules without field visibility cannot express invariants — exactly the gap ADR-0072 closed forStringbut only forString.The infrastructure already exists. ADR-0026 has a working
is_accessiblecheck tied to file IDs; adding two new call sites (one for field access, one for method dispatch) and one new parse path (pubon field/method) is a much smaller change than designing a fresh visibility model.
What this ADR does not attempt
- Visibility levels beyond
pub/ module-private. Nopub(crate),pub(super),pub(read), or friend-style exemptions. The cost/benefit argument from ADR-0026 ("simple pub/private covers 99% of use cases") still holds. - Visibility on enum variants. Enum variants are nominally part of the enum's public interface; gating individual variants is a separate question about pattern-matching ergonomics that we can revisit when there's demand.
- Visibility on associated constants (when those land — not yet in the language). Will get the same
pubtreatment by default. - Field-level read/write asymmetry. A field is either reachable or it isn't.
- Re-exports of fields/methods. ADR-0026's
pub const x = m.yre-export pattern operates on items, not on field projections. No change here.
Decision
1. Surface syntax
A pub keyword may precede a field declaration or a method definition. It is optional; absence means "module-private."
pub struct Account {
pub id: u64, // pub field — readable/writable from any module
balance: i64, // module-private — only this module can touch it
pub fn balance(self) -> i64 { self.balance } // pub method
pub fn deposit(inout self, n: i64) { self.balance = self.balance + n }
fn validate(self) -> bool { self.balance >= 0 } // module-private helper
}
Grammar additions (spec §6.2 and §6.4):
struct_field = [ "pub" ] IDENT ":" type ;
method_def = [ directives ] [ "pub" ] "fn" IDENT "(" [ method_params ] ")"
[ "->" type ] block ;
The pub token already exists (TokenKind::Pub); no lexer change is needed.
2. AST changes
Add visibility: Visibility to:
parser::ast::FieldDeclparser::ast::Method
Both default to Visibility::Private at parse time when pub is absent, exactly as for items today.
MethodSig (interface methods) does not gain visibility — interface methods are inherently part of the interface contract and are publicly callable wherever the interface is in scope. (An interface itself has its own pub-ness via ADR-0026.)
EnumVariantField (named fields inside struct-style enum variants) inherits the rule from struct fields and gains the same visibility field.
3. Sema rule
For any field access — expr.field (read), lhs.field = rhs (write), or construction T { field: ... } — sema computes:
accessing_file_id: the file containing the access site.target_file_id: the file containing the type definition ofT.is_pub: the resolved field's visibility.
Then:
if !ctx.is_accessible
Identical logic for method calls and method-pointer references, against the method's is_pub.
The check is invoked from the existing field- and method-resolution paths (analyze_ops.rs for FieldGet / FieldSet, the struct-literal analyzer, and the method-dispatch path in analysis.rs::analyze_method_call).
4. Built-in types: visibility, not privacy
gruel-builtins mirrors the user-facing model. Two tiny renames replace the ad-hoc flag:
(Default polarity flips compared to the old private flag: explicitly listing is_pub: true on every existing entry surfaces the audit, and the "hide-by-default" preference for new internal fields is the right ergonomic.)
5. The "builtin module" identity
Built-ins do not live in a .gruel source file, but they need a stable "home module" so the unified is_accessible check has a target_file_id to compare against. Mechanism:
- Each
BuiltinTypeDefis assigned a syntheticFileIdwhen it is injected byinject_builtin_types()(one shared sentinelFileIdfor all built-ins is sufficient — there's no "intra-builtins" cross-access need). - That sentinel
FileIdis registered inSemaContext::file_pathswith a reserved path string (e.g.,"<builtin>"). SemaContext::get_module_identityreturns a distinct sentinel for the builtin path, ensuring no user file ever resolves to the same module.
User code, by construction, lives in a real file with a real path. It can never share a module identity with <builtin>. So is_accessible(user_file, builtin_sentinel, is_pub=false) always returns false, and the non-pub fields/methods of every built-in are automatically inaccessible from user code.
Built-in methods that are themselves Gruel-language method bodies (none today — all built-in methods currently lower to runtime FFI calls — but the path ADR-0072 §4 envisions for thinning the String runtime) will be sema-analyzed with their accessing_file_id set to the builtin sentinel, giving them unrestricted access to the type's own non-pub fields. This is identical to how a regular Gruel function in a private module accesses other items in the same module today.
The BuiltinField::is_pub and BuiltinMethod::is_pub flags carry through to AIR's StructField::is_pub (renamed from is_private, polarity flipped).
6. Migration of String
STRING_TYPE.fields[0]:
- Before:
BuiltinField { name: "bytes", ty: BuiltinType("Vec(u8)"), private: true } - After:
BuiltinField { name: "bytes", ty: BuiltinType("Vec(u8)"), is_pub: false }
Every BuiltinMethod and BuiltinAssociatedFn on String (and on every other existing built-in: Vec, Ptr, Slice, etc.) gets is_pub: true — nothing changes about which methods are callable.
The ErrorKind::PrivateField variant is reused (rename to InaccessibleField is out of scope — it's the same error, the wording can stay). Its message is generalized from "private" to "not accessible from this module"; the existing message ("field 'bytes' of 'String' is private") is already module-correct since <builtin> is a different module.
Sema code paths that today branch on struct_field.is_private switch to calling is_accessible(...) with the type's home file id, deleting the hardcoded private-field arm. The result for user code targeting String.bytes is the same error at the same site; the result for any future internal built-in field is a one-line declaration in BUILTIN_TYPES.
7. Migration of user-defined structs
This is the source-breaking part: today's user-defined fields/methods are implicitly public; after this ADR, they are implicitly module-private and require pub to remain reachable cross-module.
The breakage is bounded:
- Single-module programs (every file in the same directory) see no change — intra-module access is permitted regardless of
pub. - Multi-module programs crossing directory boundaries that read or write fields of imported structs need
pubon those fields. - Spec/UI tests at
crates/gruel-spec/cases/andcrates/gruel-ui-tests/cases/will be audited andpub-ified where they assert cross-module field access.
Migration is gated by --preview field_method_visibility until Phase 6. During preview, the new check fires only when the gate is on, so existing programs are unaffected unless they opt in. At stabilization, the gate is removed and the audit must be complete.
8. Construction and pattern matching
A struct literal T { f: ..., g: ... } mentions every field by name. A pattern T { f, g } (or T { f: pat, g: pat }) likewise mentions fields by name. Both are field references, so both are subject to the same access check as expr.field. Mentioning a non-pub field of T from outside T's module is rejected.
Practical consequence: a struct with any non-pub field cannot be constructed cross-module by literal syntax — you must call a pub associated function. This matches Rust's exact rule and is the load-bearing mechanism that lets String enforce its UTF-8 invariant: user code cannot build String { bytes: arbitrary_vec }, only String::from_utf8(v) (which validates) or checked { String::from_utf8_unchecked(v) }.
The wildcard T { f, .. } pattern remains the way to ignore unmentioned fields, and crucially it does not require those fields to be pub — the .. doesn't reference any field by name.
9. Diagnostics
Errors reuse the existing PrivateField and PrivateItem infrastructure but gain a "did you mean a pub accessor?" help line for fields whose owning type also defines a pub getter/setter with a name resembling the field (balance field → balance() method). This is purely a hint; not adding it is non-blocking.
Implementation Phases
Each phase is independently committable.
Phase 1: Preview gate + spec scaffolding
- Add
PreviewFeature::FieldMethodVisibilitytogruel-error(andname(),adr(),all(),FromStr). - Draft spec §6.2 and §6.4 deltas with rule IDs (no implementation yet):
pubonstruct_fieldandmethod_def, dynamic-semantics rules tying field/method access to the same module-equivalence rule used by items.
- Add
Phase 2: Parser
- Accept optional
pubinfield_decl_parserandmethod_parser_with_expr. - Add
visibility: VisibilitytoFieldDecl,Method, andEnumVariantField. DefaultPrivatewhen absent. - Snapshot tests for both presence and absence; no behavior change yet.
- Accept optional
Phase 3: User-defined sema check (gated)
- In sema, propagate field/method visibility into
StructField/StructDef::methods(or wherever method visibility lives in AIR). - At every field-access and method-call site, call
ctx.is_accessible(accessing_file_id, type_home_file_id, is_pub)and error withPrivateField/PrivateMethod(new variant) on failure. Gate the check onPreviewFeature::FieldMethodVisibilityso existing programs continue to compile. - Spec tests under
cases/visibility/: cross-module pub field accessible, cross-module non-pub field rejected, intra-module non-pub field accessible, struct literal across modules rejected, struct literal intra-module accepted.
- In sema, propagate field/method visibility into
Phase 4: Built-in unification
- Add
is_pub: booltoBuiltinField,BuiltinMethod,BuiltinAssociatedFn. ReplaceBuiltinField::privatewithis_pub(inverted) at every declaration site ingruel-builtins. Remove the field. - Allocate the
<builtin>sentinelFileIdand register it inSemaContext::file_pathsand the module-identity helper at the same initialization point that injects builtins. - Tag every existing built-in field/method with the right
is_pub.String::bytes→false. Everything else →true(audit + flip). - In sema's field-access and method-dispatch paths, replace the
if struct_field.is_private { reject }arm with the unifiedis_accessible(...)call. Both built-in and user-defined types route through the same code. - Verify ADR-0072's existing
String::bytesprivacy test still passes unchanged.
- Add
Phase 5: Stdlib audit
crates/gruel-spec/cases/,crates/gruel-ui-tests/cases/, and any in-tree examples that perform cross-module field access addpubwhere needed.make testclean with--preview field_method_visibilityenabled by default in the spec runner (to catch missed audit cases before stabilization).
Phase 6: Stabilize
- Remove the preview gate; the new behavior becomes the default and the only behavior. Remove
PreviewFeature::FieldMethodVisibility. - Update ADR-0072's status note: §2 (the ad-hoc
privateflag) is superseded by this ADR; the structural and invariant claims remain. - Spec sections 6.2 and 6.4 finalized.
- The
BuiltinField::privatefield name is gone; downstream documentation ingruel-builtins/src/lib.rs(e.g., the "Adding a new built-in type" walkthrough) updated.
- Remove the preview gate; the new behavior becomes the default and the only behavior. Remove
Consequences
Positive
- Single visibility model across items, fields, methods, and built-in members — one mental concept, one sema check, one error path.
- The
BuiltinField::privatead-hoc flag and its dedicated sema branch are retired; no compiler-internal special case forString::bytes. - New built-in types get field/method visibility for free — adding a Vec internal helper or a Result discriminant getter is a one-line declaration.
- User-defined structs gain the ability to enforce invariants for the first time, closing the asymmetry where built-ins (post-ADR-0072) had a capability user types lacked.
- The visibility check is the same one used for items, so any future refinement of
is_accessible(e.g., when a package system arrives) lifts field and method visibility along with it.
Negative
- Source-breaking for multi-module programs that read fields of imported structs without
pub. Mitigated by the preview gate (Phase 3 onward) and by intra-module being unchanged. The audit fits in one phase (Phase 5). - Polarity flip on the built-in flag (
private→is_pub) is mechanical but touches every built-in declaration. The compile errors guide the migration; risk is low. - Adds a small amount of state to the AST (one
Visibilityfield per field/method). Negligible. - The
<builtin>sentinelFileIdis a synthetic concept that future package-system work will need to remain aware of. Documented in §5; ADR-0026'sis_accessiblealready permits unknown paths to fall through permissively, so the failure mode is "too lenient" rather than "ICE."
Neutral
- Compile-error messages for accessing a non-
pubfield from another module reuse thePrivateFieldmachinery from ADR-0072. Wording will be generalized but no new diagnostic infrastructure is needed.
Open Questions
- Should non-
pubmethods participate in interface conformance checks? Lean: no — a non-pubmethod is, by definition, not part of the type's public surface, and an interface is a cross-module contract. Open to revisiting if a same-module use case emerges. - Should
pub fn drop(self)be required or implicit? Adropmethod's visibility is observable only through the implicit drop path (which the compiler synthesizes). Lean: nopubrequired fordrop; the destructor is always callable by the language. - Sentinel
FileIdfor built-ins — one shared, or one per built-in type? §5 picks one shared. If future built-ins need to access each other's non-pubmembers (e.g.,String::from_utf8reaching intoVec(u8)'s internals), one shared sentinel makes that trivial; one per type would forcepub-ing those internals. The shared model is the cheaper default. - Should
pubon a tuple-struct field have a position-based syntax? E.g.,struct Pair(pub i32, i32). Tuple structs aren't yet a first-class thing in Gruel (ADR-0048 covers tuples but tuple-shaped structs use named fields); revisit if/when we add tuple-struct sugar.
Future Work
- Coarser visibility tiers (
pub(crate),pub(super)) — the ADR-0026 rationale rejected these for items; same rationale applies here. - Cross-module struct construction shortcuts (e.g., a
pub"all-pub" associatednewsynthesized for structs whose every field ispub). Not needed; users can write the constructor. - When a real package boundary lands (one beyond directory modules), the
is_accessiblecheck will be the single place that learns about it; field and method visibility ride along for free.
References
- ADR-0023: Multi-file compilation (the flat-namespace predecessor).
- ADR-0026: Module system — establishes the
pub/ module-private model this ADR extends. - ADR-0072: String as a newtype wrapper over Vec(u8) — introduced the ad-hoc
BuiltinField::privateflag this ADR retires. - Rust's field/method visibility (
pub, default-private) — same model. - Hylo's intra-module-public, cross-module-
pubposture — same model (cited in ADR-0026 as the reference design).