Skip to content

Namespaces and Source Loading

Accepted

Accepted for V1 root module resolution, source loading, namespace identity, and dependency-graph boundaries. Full package-manager resolution is deferred to CEP-0003: Package Manager and Manifest Evolution.

Catalyst modules provide simple namespacing, deterministic builds, first-class tooling support, and separate compilation where practical. The design avoids header-style dependency complexity and avoids APIs leaking into unqualified scope by accident.

Namespaces

A namespace is a comptime-only lookup value. Declarations inside a namespace are addressed by qualified paths:

std.math.min
std.math.max
std.math.clamp
std.testing.assert

Qualified names are the canonical spelling for standard-library APIs in documentation. Namespace aliases use ordinary const bindings:

const std = module("std")

std.math.min(a, b)
std.testing.assert(x > 0)

This avoids a flat global standard library where names like min, max, clamp, assert, print, or open compete with user code.

Namespace path segments use lower_snake_case. Root modules are ordinary lowercase names such as std, json, and image_v1. Type-like declarations inside namespaces still use UpperCamelCase.

Namespaces are instances of the prelude Namespace type:

const Namespace: Type
fn module(comptime name: []const u8) Namespace
fn include(comptime path: []const u8) Namespace

Namespace is not a runtime value. It can be bound, passed to comptime code, accessed with namespace member syntax, destructured, imported, and stored inside comptime-only data:

const ModuleInfo = struct {
  ns: Namespace,
  name: []const u8,
}

It is invalid in runtime storage, runtime fields, or non-comptime parameters.

Namespace member syntax such as std.math.min is a narrow compiler-defined operation for values of type Namespace. It is not user-overloadable dot lookup, does not imply arbitrary member lookup for other comptime values, and does not participate in ordinary receiver/method lookup. It also does not imply public namespace member enumeration reflection in V1.

Namespaces do not participate in ordinary runtime semantic contracts such as PartialEq, Hash, PartialOrd, or Ord. They are lookup values, not semantic data. Tooling or compiler APIs may expose namespace identity through reflection later, but ordinary source equality/comparison is not available for namespaces in V1.

Detailed namespace reflection methods are deferred. V1 keeps Namespace minimal: namespace member syntax, destructuring, import, and comptime passing.

V1 does not support inline namespace expressions. Small helper namespaces should use a separate .ct source file and include(...); future inline namespace syntax is tracked in CEP-0064: Inline Namespace Expressions.

Root Modules

The module(name) comptime function resolves a root module name and returns that module's main namespace:

const std = module("std")
const json = module("json")

module is a prelude function written in Catalyst surface syntax, but its implementation uses a compiler intrinsic to resolve module names. It is not a keyword and not ordinary runtime code.

module(...) accepts any comptime-known []const u8 value whose evaluated value is one valid Catalyst identifier:

const name = "std"
const std = module(name)

Computed names should lint unless they are obvious constants. The evaluated name must be independent of generic parameters, function parameters, declaration guards, target-conditioned branches, and other per-instantiation comptime inputs. Module resolution is part of declaration-scope dependency wiring, not a per-call or per-instantiation operation. Direct declaration-scope module(...) calls eagerly demand the comptime constants needed to evaluate the module name.

V1 module names are root names, not dotted module paths and not source-file paths:

module("std")
module("json")
module("image_v1")
module("std.math")      // error: subnamespaces are reached after resolving `std`
module("my_pkg.parser") // error: not a root module name
module("../parser")     // error
module("std/math")      // error

Subnamespaces such as std.math are public declarations reachable through the root module namespace. They are not resolver path segments:

const std = module("std")

std.math.min(a, b)
std.testing.assert(ok)

The source-level grammar is intentionally narrow:

module_name = identifier

Module-name rules:

  • the name follows the same grammar as ordinary identifiers.
  • the name follows lower_snake_case as lint policy.
  • names are case-sensitive.
  • external ecosystem identities that do not fit Catalyst identifier syntax are mapped to Catalyst-facing aliases in module.toml or future package-manager metadata.
  • future provider identities such as npm-style @vendor/pkgname are not spelled directly in module(...) in V1.

module("prelude") is invalid. The prelude is compiler-owned and not a module. Diagnostics should explain that prelude is available through the compiler-provided initial scope and qualified prelude.* binding, not through the module resolver.

Source Loading

Path-based source loading uses include(path), a separate V1 prelude function:

const parser_impl = include("parser_impl.ct")

include(path) loads one Catalyst source file by path and returns that file namespace. It is not textual inclusion. In V1, include accepts only explicit .ct source-file paths.

include(...) accepts any comptime-known []const u8 path whose evaluated value is independent of generic parameters, function parameters, declaration guards, target-conditioned branches, and other per-instantiation comptime inputs. Computed include paths should lint unless they are obvious constants. Direct declaration-scope include(...) calls eagerly demand the comptime constants needed to evaluate the source path.

V1 include paths use Unix-style / separators:

include("path.ct")                 // relative to the current source file
include("./path.ct")               // same
include("../path.ct")              // normal relative path
include("/path.ct")                // module-root relative
include("module://std/main.ct")    // file in resolved module `std`
include("file:///home/me/file.ct") // absolute file URL, build-policy gated

Include scheme rules:

  • backslashes are legal string characters but are not path separators and should produce portability lints.
  • module://name/path.ct first resolves name exactly as module("name") would, then resolves path.ct inside that module root.
  • module:// is an escape hatch, not the canonical way to use another module's API. Canonical code depends on module("name") and public exports.
  • module://prelude/... is invalid because prelude is not a module.
  • file:// is accepted in V1 because compilers, build tools, and resolver internals may use it as a concrete resolved path representation.
  • source-written file:// includes are build-policy gated and default-denied for normal reproducible module builds.

For module://name/path.ct, the authority name is a root module name visible in the current module context:

include("module://std/main.ct")
include("module://json/internal/parser.ct")

The compiler allows module:// access when the root module resolves. Lints should discourage bypassing a dependency's main exported namespace or exposing internal-looking paths in public APIs.

Module-root boundary

Relative paths, module-root paths, and module:// paths may use .., but normalized and canonicalized paths must not escape their module root. Use an explicitly permitted file:// include when a host path outside a module root is intended.

For normal module files, a leading / is relative to the root of the module that contains the file with the include expression. This applies inside dependency modules too; it is not the root module of the whole build and not the host filesystem root.

For files sourced through a permitted file:// include, build policy must assign a root boundary. The default loose boundary is the file's containing directory unless the tool supplies an explicit virtual root.

The resolver normalizes . / .. / duplicate separators and canonicalizes through the build filesystem abstraction before identity and root-boundary checks. Symlinks or equivalent filesystem indirections are resolved before deciding whether a path escaped its root. Diagnostics preserve original source spelling and should also show the resolved canonical path when useful.

Deferred Include Forms

V1 does not support:

  • remote source schemes such as http:// or https://
  • query or fragment syntax in include paths
  • full URL percent-encoding semantics
  • directory index inference
  • extension inference
  • raw file embedding
  • C header inclusion
  • non-source resources

Include strings use Catalyst string parsing plus the small scheme/path grammar documented here. Future include schemes need explicit resolver, caching, security, and reproducibility rules. See CEP-0003: Package Manager and Manifest Evolution.

Identity and Caching

Every manifest-backed module has a resolved module identity. In V1, that identity includes the manifest name, exact version string, provider/root identity, active build context, and resolved direct dependency identities.

Source-file namespace identity for normal module files is:

(resolved_module_identity, canonical_module_relative_path)

Host absolute paths are provider/cache details and are not source identity for ordinary module files. A permitted file:// include uses canonical host-file identity plus active build context because it intentionally leaves module-root resolution.

Repeated include(...) calls are idempotent by source-file namespace identity. The same source file is one build-graph node and produces one namespace; repeated includes return that namespace rather than re-parsing or re-evaluating the file as a fresh module instance.

module("name") returns the namespace of that module's manifest-defined main .ct file. Including that exact file through module://name/<main>.ct returns the same namespace identity as module("name").

Aliases do not clone namespaces. If the resolver maps two aliases to the same resolved module identity, they return the same namespace identity with different source spellings for diagnostics. If aliases map to different versions or module instances, their declarations remain distinct even when bytes or paths happen to match.

The root-module resolver cache key includes the current module context, requested root name, and active build context. The source-file cache key adds canonical module-relative path or canonical host-file identity for permitted file:// files.

Dependency Graph

module(...) and include(...) create dependency graph edges only in direct declaration-scope forms:

const std = module("std")
const parser_impl = include("parser_impl.ct")
pub const parser = include("parser.ct")
import module("json")
import include("adapters.ct")

Function-local and block-local module(...) / include(...) calls are hard errors in V1. module(...) and include(...) are also hard errors in declaration guards.

Dependency resolution may not be hidden inside arbitrary comptime helper calls:

const std = resolve_std()

comptime fn resolve_std() Namespace {
  return module("std")
}

Helpers may accept, return, and transform already-resolved namespace values:

const linux = include("platform/linux.ct")
const generic = include("platform/generic.ct")
const platform = if use_linux { linux } else { generic }

Conditional namespace aliases are allowed when all candidate namespaces are resolved by unconditional direct declarations. The alias has the active selected namespace identity for the current build context. Public API or import behavior that depends on target/build facts should lint, but the graph remains explicit because every candidate is an unconditional edge.

Imports are also graph edges because they change name and impl visibility. import module("json") and import include("adapters.ct") are valid declaration-scope imports.

Namespace/include/import cycles are rejected in V1, even if a cycle might be lazily resolvable in principle. Rejection keeps module evaluation deterministic and diagnostics direct. Diagnostics should report a deterministic cycle path using canonical module/source identities plus the source locations that introduced the edges.

Manifest dependency cycles are resolver/module-graph errors and source namespace cycles are source dependency graph errors. They should share cycle-path formatting but use distinct diagnostic categories.

Source Files

The canonical file extension for Catalyst source files is .ct.

Catalyst source filenames should normally use lower_snake_case.ct, matching namespace naming conventions:

parser_impl.ct
audio_buffer.ct

Valid paths with other filename casing may still compile, but should produce naming-convention lints unless generated/tooling policy suppresses them.

Directory segments that participate in Catalyst source/module layout should also use lower_snake_case, as lint policy:

std/math/vector_ops.ct
audio/dsp/filter_bank.ct

A .ct source file is the V1 privacy boundary. include(...) returns the included file namespace's public surface; it does not splice private declarations into the including file.

module(...) and include(...) expose public declarations only. Private declarations are accessible by normal lexical scope inside their defining file, not through namespace values. Namespace contents are not caller-sensitive; the same resolved namespace exposes the same public surface from every external caller.

Private declarations in an included file are still parsed, name-collected, and represented in that file's namespace node. Eager header checks still apply, but declaration bodies and initializers follow the normal lazy top-level analysis rule. Private declarations are not reachable through namespace member access or destructuring.

Namespace Selection

Selective binding uses ordinary destructuring:

const { min, max } = std.math
const { maximum = max } = std.math

This follows normal Catalyst destructuring direction: local = source_member.

Destructuring a namespace can bind only public declarations visible through that namespace value, the same way struct destructuring can bind only visible fields. Private declarations are not reachable through namespace destructuring.

Namespace destructuring follows the general destructuring rules, including partial destructuring and _ ignore bindings:

const { min } = std.math
const { min, _ = max } = std.math

Unmentioned public declarations are ignored. _ is usually unnecessary for namespaces, but it remains valid because destructuring should not have namespace-specific pattern exceptions.

Namespace destructuring may appear in local/block scope when the namespace value is already available. It may not call module(...) or include(...) directly in local/block scope.

When namespace destructuring binds a type-like declaration or a public alias that resolves to a type-like declaration, public impls in that namespace whose implementing type is exactly that selected type identity also become visible. This lets a selected type bring its canonical impls without importing the entire namespace:

const json = module("json")
const { JsonValue } = json

This brings public impls for json.JsonValue from json into visibility. It does not import unrelated adapter impls in the namespace, such as impls for external types. Use import json.adapters to bring arbitrary adapter impls into scope.

Impl declarations are not ordinary namespace members that can be named in a destructuring pattern. They are conformance visibility facts. Namespace destructuring can bring impls into visibility only through the selected-type rule above.

Selecting a generic type factory also brings public generic impls in that namespace whose implementing type pattern is rooted in the selected factory:

const { ArrayList } = std.collections

This brings public impls declared in std.collections for types such as ArrayList(T). It does not import impls for unrelated types or adapter impls declared elsewhere.

Selecting a contract also brings public impls in that namespace whose contract side is rooted in the selected contract:

const { Debug } = std.debug

This brings public impls declared in std.debug for Debug(...) into visibility. It does not import Debug impls declared in other namespaces; adapter namespaces remain explicit.

Renaming the selected type-like declaration keeps the same impl visibility behavior:

const { Value = JsonValue } = json

The local name Value refers to the selected declaration json.JsonValue, so public impls for json.JsonValue from json become visible. Impl identity follows the resolved type identity, not the local alias spelling.

Impl visibility introduced by namespace destructuring is scoped like the binding itself. If namespace destructuring appears in a local/block scope, any impl visibility introduced by selected types or contracts is local to that scope.

Adapter impls in another namespace remain explicit:

const json = module("json")
const { JsonValue } = json

import json.adapters

The first destructuring brings public impls for json.JsonValue declared in json. The import json.adapters declaration brings public adapter impls declared in json.adapters, such as impls for external contracts or external types.