Structuring imports inside a project

I can’t decide on what’s the best way to structure imports within my project.

A) I could directly import files I need, similarly to how I’d have done in in C, for example.

const Foo = @import("Foo.zig");
const Bar = @import("bar/Bar.zig");

B) Or I could just import the root file and use that.

const lib = @import("root.zig");

and then either use lib.Foo or alias it locally.

Which option to do you prefer in your projects?

I prefer A, it has the benefit of allowing you to have public declarations in the files without having to re-export them from the root, which you may not want for various things.

3 Likes

I have the same opinion and prefer A, but then I see the large amount of imports and I wonder, shouldn’t I just import a single namespace?

you’d still have a bunch of aliases (an import is an alias to a file), unless you access the full path root.foo.bar.baz.buz, which I doubt.

You don’t change the amount of aliases, you just change how you get them.

2 Likes

Currently, in practice, I only use pattern B) when importing modules.

But actually, I use B) quite a lot. I’m used to importing the main module and then using B). (not the "root" module!)

If I’m directly importing a file, I would just choose A).

1 Like

In my chipz emulator toy project I’ve split the project into a handful modules, mirrored in the directory structure, e.g.:

src/
    chips/           => chip emulators
    common/          => shared helpers
    host/            => host system glue (gfx, audio, input...)
    systems/         => computer system emulators

Each of those directories has multiple source files, and a single re-export file, e.g. the chips directory has a re-export file chips.zig:

pub const z80 = @import("z80.zig");
pub const z80ctc = @import("z80ctc.zig");
pub const z80pio = @import("z80pio.zig");
pub const ay3891 = @import("ay3891.zig");
pub const intel8255 = @import("intel8255.zig");

…or the common/common.zig file looks like this:

pub const bitutils = @import("bitutils.zig");
pub const memory = @import("memory.zig");
pub const glue = @import("glue.zig");
pub const clock = @import("clock.zig");
pub const utils = @import("utils.zig");
pub const keybuf = @import("keybuf.zig");
pub const audio = @import("audio.zig");
pub const Beeper = @import("Beeper.zig");
pub const BoundedArray = @import("bounded_array.zig").BoundedArray;

In addition there is a top-level chipz.zig re-export file, this is for when the project is used as dependency for another project:

pub const chips = @import("chips");
pub const common = @import("common");
pub const systems = @import("systems");
pub const host = @import("host");

…up in the build.zig, each of those reexport files corresponds to one module:

    const mod_common = b.addModule("common", .{
        .root_source_file = b.path("src/common/common.zig"),
        .target = target,
        .optimize = optimize,
    });
    const mod_chips = b.addModule("chips", .{
        .root_source_file = b.path("src/chips/chips.zig"),
        .target = target,
        .optimize = optimize,
        .imports = &.{
            .{ .name = "common", .module = mod_common },
        },
    });
    const mod_systems = b.addModule("systems", .{
        .root_source_file = b.path("src/systems/systems.zig"),
        .target = target,
        .optimize = optimize,
        .imports = &.{
            .{ .name = "common", .module = mod_common },
            .{ .name = "chips", .module = mod_chips },
        },
    });
    const mod_host = b.addModule("host", .{
        .root_source_file = b.path("src/host/host.zig"),
        .target = target,
        .optimize = optimize,
        .imports = &.{
            .{ .name = "sokol", .module = dep_sokol.module("sokol") },
            .{ .name = "common", .module = mod_common },
            .{ .name = "shaders", .module = mod_shaders },
        },
    });

    // top-level modules
    const mod_chipz = b.addModule("chipz", .{
        .root_source_file = b.path("src/chipz.zig"),
        .target = target,
        .optimize = optimize,
        .imports = &.{
            .{ .name = "common", .module = mod_common },
            .{ .name = "chips", .module = mod_chips },
            .{ .name = "systems", .module = mod_systems },
            .{ .name = "host", .module = mod_host },
        },
    });

…and finally the import code for an emulated computer system looks like this, e.g. typically I import the top-level module and then ‘drill down’ to the submodule or even individual symbols:

const std = @import("std");
const assert = std.debug.assert;
const chips = @import("chips");
const z80 = chips.z80;
const ay3891 = chips.ay3891;
const common = @import("common");
const memory = common.memory;
const clock = common.clock;
const cp = common.utils.cp;
const audio = common.audio;
const DisplayInfo = common.glue.DisplayInfo;

…I think this sort of hierarchical project structure makes sense for most not-entirely-trivial projects, but may admittedly be overkill for simple cmdline tools…

12 Likes

I prefer option A too, because in my opinion it is more consistent.

Say we have foo.zig, foo/bar.zig, bar.zig and root.zig.
root.zig exposes foo.zig and bar.zig as bar. foo.zig exposes foo/bar.zig as foo.bar.

So then I can directly use bar within foo.zig AND expose it (using pub const).
Otherwise, the convention would have been to first expose bar.zig, then use root.bar.
And when I might have a foo/baz.zig which is NOT publicly exposed, I’ll just use const baz = @import(“foo/baz.zig”);.

This inconsistency (when not publicly exposed file, use const, else import root and use from there) doesn’t please me.

But actually, I am one of the (weird) people who don’t import foo.zig or foo/bar.zig (instead using ./foo.zig and ./foo/bar.zig). This makes it even more consistent when you access a parent folder (say, from foo/bar.zig you want to access foo.zig; that would result in ../foo.zig).

For project-specific types I prefer A, and even if I have a module with a number of smaller ubiquitous types, I tend to only import the actual types.

With dependencies, I tend to import the module, unless I have a fairly strong reason to only have to import one type/function ever.

Especially with std, I always use std. everywhere, even if I’m only going to use one thing. I feel that this makes it easy to avoid ambiguity, eg. if I se mem it’s always “my” mem, not std.mem.

I usually have a util and tutil file with assorted test) utilities, this is also one that I tend to be most relaxed (i either import one thing or whole file).

I also like to group things loosely Python PEP8 style: 1. standard lib, 2. dependencies, 3. internal stuff (often further grouped as I feel fit).

const std = @import("std");

const zeit = @import("zeit");

const Key = @import("core.zig").Key;
const Line = @import("core.zig").Line;
const Keypath = @import("core.zig").Keypath;
const Section = @import("core.zig").Section;
const TokenId = @import("core.zig").TokenId;
const FileIdx = @import("core.zig").FileIdx;
const Newline = @import("core.zig").Newline;

pub const Location = @import("Location.zig");
pub const QueryErrorDetail = @import("result.zig").ErrorDetail;
pub const QueryResult = @import("result.zig").Result;
pub const ParsedQueryResult = @import("result.zig").ParsedResult;

const TokenTree = @import("TokenTree.zig");
const Fileset = @import("Fileset.zig");
const Index = @import("Index.zig");

const tutil = @import("tutil.zig");  // my test utils

Edit: I learned to avoid aliasing locally (eg. adding extra line const Foo = bar.Foo), because it muddies up the structure and adds hiccups when refactoring.

I also like to treat tests specially: things that only exist because of tests, I just import in-place within the test block, eg. try @import("tutil.zig").expectFooBar(foo, bar). But I don’t do this consistently, just an idea I tried in few places and found interesting.

I’m mixing stuff. If the line length get’s too long, over 100, I’m pushing it at the top as import in my own files. In main.zig I import parts from my code as parts for long lines but I tend to use option B where it suits. std in main is not touched. I always use std.foo.something.bar in main but in my own files if the line get’s too long, I’m pushing the structs at the top and using import for them.

I strongly prefer option A, as it is the simplest, most direct way possible to connect two files. I am often forced to go for option B when the code ends up living in different modules (this strongly pushes me to try to have fewer modules in Zig). So, on balance, I just don’t care about the right way to structure Zig imports yet, and write whatever that gives the required names into the scope.

So this seems pretty universal. I’m often tempted to use Zig modules more, but in practice, dealing with modules just makes things more complicated for no good reason.