The Language

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: 1
mod.psy:3:6 error PSY3001: duplicate symbol `Thing` in the module namespace.
    mod.psy:1:7: `Thing` is also declared here

I 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 PSY2018
mod.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: 1
mod.psy:3:6 error PSY3001: duplicate symbol `Thing` in the module namespace.
    mod.psy:1:10: `Thing` is imported here

Renaming 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: 3

No 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.psy

A 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.psy

A 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 keywords

All 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.psy

That 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

On this page