Skip to main content

Multi-file projects

Large programs span multiple files using import and namespace.

import

import "relative/path.xi" at the top level splices another file's declarations into the compilation unit. Imports are resolved recursively and de-duplicated by path, so a diamond of imports includes each file once. Paths are relative to the importing file.

examples/proj/math.xi
namespace math
mapper add(a: Number, b: Number) -> Number { return a + b }
mapper square(x: Number) -> Number { return x * x }
examples/proj/text.xi
namespace text
mapper shout(s: String) -> String { return s + "!" }
examples/proj/main.xi
import "math.xi"
import "text.xi"
import "std/log.xi"

async entry (logger: Logger) main(args: String[]) -> Integer {
logger.info(text.shout("hello multi-file"))
logger.info("2 + 3 = " + math.add(2, 3))
logger.info("4^2 = " + math.square(4))
return 0
}
$ xc main.xi && ./build/main
hello multi-file!
2 + 3 = 5
4^2 = 16

namespace

namespace a.b prefixes a file's top-level names (e.g. math.add becomes the symbol math__add) so independently authored files can reuse short names without colliding. Reference a namespaced name from another file with its qualified form a.b.Name, which the compiler resolves to the prefixed symbol.

  • Method names and field accesses are not namespaced (so interface/vtable dispatch is unaffected) — only top-level declarations are.
  • Two files can each define a fmt without conflict:
// a.xi namespace a mapper fmt(s: String) -> String { return "[A]" + s }
// b.xi namespace b mapper fmt(s: String) -> String { return "[B]" + s }
// main.xi import "a.xi" import "b.xi"
// a.fmt("one") -> [A]one
// b.fmt("two") -> [B]two

A manifest entry file

A common layout is one small entry file that imports the rest of the project:

app.xi
import "models.xi"
import "repository.xi"
import "service.xi"

async entry (svc: Service) main(args: String[]) -> Integer {
svc.run()
return 0
}

module App {}
$ xc app.xi && ./build/app

import merges all the parts into one compilation unit (recursively, with duplicates resolved once), so you compile just the entry file.

Module source sets (includes / excludes)

Instead of listing every import, a module can declare which files belong to it with includes / excludes globs. When set, xc <entry.xi> gathers every matching .xi file under the entry's directory and compiles them as one unit. Each module owns its entry main, so several modules can live in one folder and build separately:

server.xi
import "std/log.xi"
async entry (logger: Logger) main(args: String[]) -> Integer {
logger.info(banner("server")) // banner() comes from shared.xi, auto-gathered
return 0
}
module App {
id = "server"
includes = ["./**"] // default: every .xi under this dir
excludes = ["client.xi"] // ...but not the other module's entry
}
client.xi
import "std/log.xi"
async entry (logger: Logger) main(args: String[]) -> Integer {
logger.info(banner("client"))
return 0
}
module App { id = "client" includes = ["./**"] excludes = ["server.xi"] }
$ xc server.xi && ./build/server # gathers shared.xi, not client.xi
$ xc client.xi && ./build/client
  • includes defaults to ["./**"] (the whole directory tree) and excludes to []. Globs: **/dir/** (subtree), dir/* (one level), *.ext, or an exact file/basename.
  • The feature is opt-in: a module with neither field keeps the classic "entry file + its explicit imports" behavior.
  • Combine with id (see DI › module metadata) to name each module's binary.

Build every module in a tree at once with xc --all — it finds each buildable module (a file with an entry + a module) and builds it separately.

Module fields

Everything a module block can contain:

FieldTypeDefaultPurpose
idstringsource filenamename of the compiled binary
namestringdisplay name (metadata)
descriptionstringdescription (metadata)
versionstringversion (metadata)
licensestringlicense (metadata)
includesstring[]["./**"] when setglobs of files that belong to this module
excludesstring[][]globs to drop from the include set
bind I -> Impl [as singleton]autoDI override / scope (DI)
bind I -> readConfig("file")config-backed binding (config)
[async] entry [(deps)] main(...) { … }the module's entry point (may also be top-level)
module App {
id = "billing"
name = "Billing Service"
version = "1.4.0"
license = "MIT"
includes = ["./**"]
excludes = ["scratch/**"]
bind Clock -> SystemClock as singleton

async entry (logger: Logger) main(args: String[]) -> Integer {
logger.info("billing up")
return 0
}
}

The entry may live inside the module (as above) or stay at the top level with a separate module App { … } block — both are supported. Putting it inside keeps each module self-contained, which is what makes xc --all build a folder of modules into one binary each.

The block may be named (module App { … }), anonymous (module { … }), or module Test { … } (whose binds win under xi test).