More Than One File
Modules, named imports, explicit exports, and how a specifier gets resolved.
A Psy module is a single .psy file. That is the entire module system, and
everything below is consequences of it.
One namespace, no shadowing
Local specs, local constants and imported names all share one namespace per module. Duplicates are a compile error, and there is no local shadowing anywhere in the language.
const Thing = 1
spec Thing: // error PSY3001
foo: 1mod.psy:3:6 error PSY3001: duplicate symbol `Thing` in the module namespace.
mod.psy:1:7: `Thing` is also declared hereI would rather you renamed something than spent an afternoon establishing which
Thing a line refers to. In a language where a bare identifier might be a string
or a reference, shadowing would have been genuinely dangerous rather than merely
annoying.
Imports are named, and only named
import { Foo } from "./Foo"
import { Foo, Bar } from "./shared"
import { Foo as RenamedFoo } from "./shared"These do not exist:
import Foo from "./Foo" // error PSY2017
import * as Shared from "./shared" // error PSY2018mod.psy:1:8 error PSY2017: default imports are not supported; use `import { Foo }`.
mod.psy:2:8 error PSY2018: namespace imports are not supported.No default exports. No namespace imports. Every name that enters a module is written down at the top of it, and you can always find where something came from by reading fifteen lines.
An import puts its local name into that single shared namespace, so this collides:
import { Thing } from "./shared"
spec Thing: // error PSY3001
foo: 1mod.psy:3:6 error PSY3001: duplicate symbol `Thing` in the module namespace.
mod.psy:1:10: `Thing` is imported hereRenaming with as is how you get out of it.
Long import lists
They may span lines. The formatter breaks them automatically when the one-line form exceeds your print width, adds a trailing comma, and sorts the specifiers by imported name:
import {
ImplementCompiler,
ReadPsySpec,
RunTests,
UnderstandArchitecture,
WriteTests,
} from "../skills"You never have to think about it, which is the entire justification for having a formatter.
Exports are always explicit
export spec Build:
action: build
export const Defaults:
retries: 3No default exports here either. If it is not marked export, it is private to its
file.
Re-exports
export { Build, Review } from "./agents"A re-export makes those names part of this module's public surface. It does not put them in the local namespace — a re-exported name cannot be referenced locally. Import it separately if you need it here as well.
Re-export chains are followed transitively when a name is resolved. Exporting the
same name twice is PSY3001.
Index modules
Because a directory resolves to its index.psy, a module of pure re-exports is
the idiomatic entry point for a package:
// spec/index.psy
export { PsyLanguage, LanguageInvariants } from "./language"
export { Lexer, Parser, SemanticAnalyzer, IRBuilder } from "./compiler"That is not just a convenience. Framework keyword discovery follows the same
chains, which is what lets one use "./framework" activate a vocabulary that is
actually spread across nine files. See Your Own Keywords.
How a specifier resolves
A module specifier is quoted, and is either relative or a package.
Relative — anything starting with ./ or ../. Resolved against the
importing file, trying in order:
./Foo.psy
./Foo/index.psyA package — anything else. Resolved through node_modules:
use "@psy/morphic"
import { Skill } from "@psy/morphic/framework"Psy looks for node_modules/<name> in the directory of the importing file, then
in each directory above it, taking the first it finds. That is the same walk your
package manager's own resolution does, so a nested dependency gets its own copy
and nothing surprising happens.
With a subpath it resolves <subpath>.psy and then <subpath>/index.psy inside
the package. Without one it reads the psy field of the package's
package.json, falling back to index.psy at the package root:
{
"name": "@psy/morphic",
"psy": "psy/index.psy"
}mod.psy:1:5 error PSY3003: cannot resolve module "./missing".
tried ./missing.psy
tried ./missing/index.psyA malformed specifier is PSY2016. One that resolves to nothing is PSY3003.
The built-in fallback
If nothing is installed under that name, the compiler falls back to a package built into the binary it is running.
That is how a standalone psy resolves use "@psy/morphic" in a project with an
empty directory and no npm install anywhere. Built-ins are only ever a
fallback — an installed package always wins, so a project that pins a version
gets the version it pinned.
External modules
A module resolved from a package is external. It is parsed, bound and checked
exactly like your own — its specs are real, and you may inherit from them — but
it is not your source, so psy lint and psy format leave it alone and the file
counts those commands report exclude it.
Nobody wants their formatter reformatting a dependency.
Three ways to depend on a file
import { Foo } from "./shared" // bring in a name
export { Foo } from "./shared" // re-export a name
use "./framework" // activate declaration keywordsAll three create a module dependency. Cycles among them are rejected — PSY3005
— whichever statement formed the loop.
error PSY3005: circular module dependency.
a.psy imports b.psy
b.psy imports c.psy
c.psy imports a.psyThat is a real constraint and it is load-bearing. Cycles are what make provenance unstatable and determinism unprovable, and both of those are things I would rather have than circular imports. v1 Non-Goals has the full argument.
use is the one you have not properly met yet. It is the entire subject of the
next chapter.
Next: Your Own Keywords