RFC-0022: Cross-file @@import for multi-system Frame projects

Superseded: This RFC introduced the @@import directive for cross-file composition. RFC-0024 removes @@import from the language entirely; cross-file dependencies are now expressed using the target host language’s native import syntax as Oceans Model pass-through.

Summary

Frame source today compiles one .fgd (or .fpy, .frs, etc.) at a time. A @@SystemName() reference resolves only against systems declared in the same file. There is no syntax for the importer to say “the system I’m referencing lives over there”, and the codegen emits no import / preload / package-include in the generated target file.

This RFC introduces a module-scope directive — @@import "path/to/other.fgd" — that records a cross-file dependency at parse time and translates into the target language’s native import form at codegen time (preload(...) for GDScript, import for Python, use crate::...; for Rust, and so on per backend).

Motivation

A multi-system Frame project has nowhere to put its second system. The natural shape — one .fgd per system, composed via domain: foo = @@Foo() from a top-level orchestrator — fails at target-language load time: the generated code references Foo by bare identifier, but nothing in the generated file tells the host how to find Foo’s definition.

The compiler accepts the reference silently. There is no warning at compile time that Foo is undefined in the current compilation unit; the failure surfaces only when the user tries to load the generated code:

SCRIPT ERROR: Parse Error: Identifier "Foo" not declared in the current scope.

The result is that any project beyond a single system is forced to put every system in one source file. Real Frame projects today contain single .fgd files of several thousand lines, holding 20+ systems that compose with one another, because there is no other option. Conventional decomposition (NPCs in one file, puzzles in another, an orchestrator that composes both) isn’t available.

A working multi-file model needs three properties:

  1. Source-level: a way for the importer to declare the dependency. The compiler needs to know which other files contain the systems referenced here.
  2. Target-level: the generated code carries the host language’s native import / preload / package include, so the reference resolves at runtime without any project-wide scan or implicit cache.
  3. Validation: the compiler can optionally check that named cross-file references resolve. The strict mode catches typos at compile time; a lightweight mode trusts the names and defers verification to the host.

The contract

The key words MUST, MUST NOT, SHOULD, SHOULD NOT, MAY are to be interpreted as in RFC 2119.

Syntax

A new module-scope directive:

@@import "<path>"
  • The <path> is a string literal, relative to the importing file’s directory.
  • @@import directives MUST appear at module scope, before any @@system declaration.
  • A file MAY carry zero or more @@import directives. Order does not matter.
  • The path SHOULD name another Frame source file (.fgd, .fpy, .frs, etc. — the canonical Frame extension; the codegen translates to the target’s extension).
  • A circular import (file A imports file B, B imports A) is well-defined and MUST be accepted: the directive records a dependency, not a parse-time inclusion. Each file compiles independently.

Semantics

@@import "other.fgd" at module scope of the importer adds other.fgd to the importer’s import set. At codegen time the backend emits the target’s native import statement that brings every @@system from other.fgd into scope in the generated importer file.

Whether the compiler verifies that the named import resolves (validation mode) is a framec flag, not a source-level choice — the syntax is the same.

Codegen per backend

Each backend translates @@import "other.fgd" to its native form:

Target Emission
GDScript const SystemName = preload("res://path/to/other.gd") per imported @@system, OR a top-of-file const Mod = preload("res://path/to/other.gd") and qualified references Mod.SystemName (backend chooses; default: per-system)
Python from .other import SystemName1, SystemName2
Rust use crate::other::{SystemName1, SystemName2};
TypeScript / JavaScript import { SystemName1, SystemName2 } from "./other";
Java import com.example.other.SystemName1; per imported system (package-derived)
Kotlin import com.example.other.SystemName1 per imported system
Swift (target-uniform: same module, no import emission required)
C# using Example.Other;
Go import "./other" (path resolved by go.mod)
Dart import 'other.dart';
C / C++ #include "other.h" (header)
PHP require_once __DIR__ . '/other.php';
Ruby require_relative 'other'
Lua local Other = require 'other'
Erlang (link via OTP application; same compilation unit)
GraphViz (rendering-only target; ignored)

Each backend already knows how to emit its preferred form; this RFC says when to emit, not what.

Validation mode

framec compile --import-mode <lax|strict>:

  • lax (default): trust the import path. Parse the importer, record the import set, emit the target import. No cross-file analysis happens. The importer compiles standalone.
  • strict: parse every file in the import set transitively; build a symbol table of declared @@system names; reject the compilation if any cross-file @@SystemName() reference in the importer does not resolve to a system declared in some imported file (or in the importer itself).

Strict mode is opt-in to keep the per-file compilation model simple. A project that wants compile-time guarantees turns it on in CI; a project that prefers fast incremental builds leaves it off.

Scope

@@import brings every @@system from the imported file into the importer’s scope. There is no per-symbol form (@@import { Foo, Bar } from "...") in this RFC. If a finer grain is needed, a future RFC may extend the syntax; the present form is the minimum useful primitive.

A @@system declared private in the imported file is NOT brought into scope by @@import. (The private modifier already exists for the one-file-per-multi-system case on backends that need it, e.g. Java’s one-public-class-per-file rule.)

Examples

Two-file composition

counter.fgd:

@@[target("python_3")]

@@system Counter {
    interface:
        bump()
        get(): int
    machine:
        $Active {
            bump()   { self.n = self.n + 1 }
            get(): int { @@:(self.n) }
        }
    domain:
        n: int = 0
}

app.fgd:

@@[target("python_3")]
@@import "counter.fgd"

@@system App {
    interface:
        run()
        total(): int
    machine:
        $Active {
            run()        { self.c.bump() }
            total(): int { @@:(self.c.get()) }
        }
    domain:
        c = @@Counter()
}

Generated Python from app.fgd:

from .counter import Counter

class App:
    # ...
    @classmethod
    def _create(cls):
        c = cls()
        c.c = Counter._create()
        # ...

Generated GDScript from app.fgd:

const Counter = preload("res://counter.gd")

class_name App
extends RefCounted

# ...

static func __create() -> App:
    var c = App.new()
    c.c = Counter.__create()
    # ...

Persisted sub-system

A sub-system composed via domain: foo = @@Foo() participates in @@[persist] round-trips. The import brings the type into scope for the generated save_state / restore_state calls; nothing else changes.

@@[target("gdscript")]
@@[persist(PackedByteArray)]
@@[save(save_state)]
@@[load(restore_state)]
@@import "counter.fgd"

@@system App : RefCounted {
    operations:
        @@[save] save_state(): PackedByteArray {}
        @@[load] restore_state(data: PackedByteArray) {}

    machine:
        $Active { }

    domain:
        c = @@Counter()
}

The generated GDScript carries both the preload and the persist machinery; the round-trip serialization recursively saves and restores c’s state via Counter’s own save_state / restore_state.

Alternatives

A. Status quo: duplicate the source

What projects do today. Every file that needs @@SystemX() contains its own copy of @@system SystemX. Works for one or two shared types; doesn’t scale.

Rejected: forces one-file projects beyond their natural size, and the duplicated declarations rot the moment one of them is edited without the other.

B. Implicit imports via a --source-set flag

framec compile foo.fgd --source-set bar.fgd,baz.fgd .... The compiler implicitly resolves cross-file references against the named files; no source syntax changes.

Rejected: the dependency is invisible in the source. A reader of foo.fgd cannot tell which other files it depends on, and a future build-system author has to discover the relationship from outside the source.

C. Per-symbol imports: @@import { Foo, Bar } from "other.fgd"

A finer-grained form that names only the systems imported.

Rejected for this RFC, not foreclosed. The whole-file form is the minimum useful primitive; per-symbol selection can be added later without breaking the whole-file form. Naming each system inflates the importer when the imported file has many useful systems and the importer needs most or all of them.

D. Editor-cache reliance

Tell users to “trust the editor cache” (GDScript’s global_script_class_cache.cfg, Python’s PYTHONPATH, etc.) and emit no explicit imports.

Rejected: deployment-time fragility. The cache only resolves if the editor has scanned the project; CI runners and headless builds may not have run an editor pass. Frame source should generate code that works without project-level setup.

E. Single-file build with an inliner

A pre-processor that concatenates the imported .fgd into the importer before passing to framec.

Rejected: defeats per-file compilation. Every change to a shared system invalidates the build artifacts of every file that depends on it; loses the incremental-compilation benefit; doesn’t help target-language tooling at all.

Migration

Source-additive. Existing files compile unchanged. Adding @@import to a file does not change the file’s semantics — the existing same-file @@SystemName() references continue to resolve against in-file declarations.

For projects that currently work around the absence of @@import by duplicating shared systems across files, migration is:

  1. Decide which file owns the canonical definition of each shared system.
  2. Delete the duplicates from the other files.
  3. Add @@import "<canonical-file>" to each file that previously held a duplicate.
  4. Re-build. The generated code carries the native import; the host language resolves the reference.

A migration tool is not specified by this RFC.

Drawbacks

  • A new module-scope syntax increases the surface area of the language. The benefit — making multi-file projects viable — is judged worth it.
  • Each backend gains a small amount of code to emit its native import form for the file’s import set. The complexity is local; the form is well-known per target.
  • The whole-file import grain may import names the user does not need. Tolerable because Frame’s @@system namespace is small per file; the per-symbol form can be added later if it becomes a real problem.

Unresolved questions

  • Diamond imports — A imports B and C, B imports D, C imports D. The generated target needs to handle each backend’s de-duplication rules. Most target languages handle this fine (each import / preload is idempotent); confirm for the targets that don’t.
  • Cyclic imports — Already specified as “well-defined: each file compiles independently.” Worth a test fixture in every backend to keep it honest.
  • Strict mode flag name--import-mode strict is one shape; alternatives worth considering: --check-imports, --strict-modules. Decide before shipping.
  • Per-symbol form — explicitly deferred. If demand surfaces, fold into a follow-up RFC.

References