Best way to avoid @import("../../../thing.zig")

I’m making a small gui library and I’ve put all the elements into a subdirectory within src, resulting in a directory structure like this:

src
├── elements
│   ├── buttons.zig
│   ├── containers.zig
│   ├── dropdowns.zig
│   ├── inputs.zig
│   └── sliders.zig
├── gui-grabbing.zig
├── main.zig
├── root.zig
└── utils
    └── parsing.zig

The issue I’m having is that every .zig file in the elements directory needs to use some functions from the gui-grabbing.zig file. Although writing @import("../gui-grabbing.zig") is not too much of an issue currently, I feel it is rather likely I will eventually end up with more nesting in the directory, making different files require a different amount of ../s prefixed before the actual name of the imported file.

As such, I would really like to eliminate it somehow. For a while I’ve been using @import("root") and had const grabbing = @import("gui-grabbing.zig") in root.zig, however I’ve since gathered that @import("root") imports the root file of the user of the library, rather than the library’s root.
The only other idea I’ve had for solving the issue would’ve been making a module with gui-grabbing.zig as its root in build.zig, but I’m still rather unfamiliar with the build system and am not sure if that approach might have some other less obvious issues.

Basically, what’s the best way to deal with this sort of “un-nesting”?

As far as I know with the build system, it seems like you’re onto the right idea already.
You can make root.zig a module with std.Build.addModule(), give the elements folder its own root.zig which, again, becomes a module, and then make both modules aware of each other with std.Build.Module.addImport().
Then they can more or less freely import each other.

The reason why I’m suggesting making root.zig a module instead of making gui-grabbing.zig a module is because it doesn’t really make much of a difference - root.zig can have pub const grabbing = @import("gui-grabbing.zig");, and the elements module can freely access that with const gui_root = @import("gui_root"); and const grabbing = gui_root.grabbing;

A pattern I like to use is defining a “submodules” anonymous struct in build.zig which has a bunch of members of type *std.Build.Module created via addModule(), and then using a loop to make each of these submodules aware of the “main module” and the main module aware of each of these submodules.
Here’s how it might look in your case:

const submodules = .{
	.utils = b.addModule("utils", .{
		.root_source_file = b.path("src/utils/root.zig"),
		.target = target,
		.optimize = optimize,
	}),
	.elements = b.addModule("elems", .{
		.root_source_file = b.path("src/elements/root.zig"),
		.target = target,
		.optimize = optimize,
	}),
};

inline for(@typeInfo(@TypeOf(submodules)).@"struct".fields) |field| {
	g_root.addImport(field.name, @field(submodules, field.name));
	@field(submodules, field.name).addImport("gui_root", g_root);
}

This is very extensible and powerful; any new module you add to the submodules struct is made available automatically as an import, and all submodules are aware of each other by proxy through their awareness of the main module - for instance, you can define pub const elements = @import("elements"); in the main module’s root.zig, then utils/root.zig could say const elements = @import("gui_root").elements; - in this way, utils can access elements even though it isn’t actually aware of elements as an import.

4 Likes

If I go with this approach, would it be safe to import the module in some files, and the .zig file in others? In particular, the file in question has some global variables defined in it, would those be the same when importing the module vs the file, or would each of the two end up with a different instance of the variables?

This is a good question, so I tested it out.
Getting and setting module root-level variables seems to work fine regardless of whether you import as a module, import as a file or do a mixture of both.

Huh, that’s weird, I also decided to test it and got a compilation error!

src/main.zig:1:1: error: file exists in modules 'thing' and 'root'
src/main.zig:1:1: note: files must belong to only one module
src/main.zig:1:1: note: file is the root of module 'thing'
src/main.zig:3:28: note: file is imported here by the root of module 'root'
const thing_file = @import("thing.zig");
                           ^~~~~~~~~~~

Could you send the code from your test, I’m really curious where the difference comes from?
Here’s the relevant snippet from my attempt:

// build.zig
const mod = b.addModule("zig_import_test", .{
    .root_source_file = b.path("src/thing.zig"),
    .target = target,
});

const exe = b.addExecutable(.{
    .name = "zig_import_test",
    .root_module = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
        .imports = &.{
            .{ .name = "thing", .module = mod },
        },
    }),
});

// main.zig
const thing_mod = @import("thing");
const thing_file = @import("thing.zig");

If gui-grabbing is self-contained (other things call it, it doesn’t call other things, or if it does, they only go through gui-grabbing or are themselves modules), making it into a module is the way to go.

The build system part is easy:

    const gui_grab_mod = b.createModule(.{
        .root_source_file = b.path("src/gui-grabbing.zig"),
        .target = target,
        .optimize = optimize,
    });

Use createModule because addModule will export the module, which is probably not what you want. createModule means you can only use it internally.

The ‘less obvious issue’ is that you can’t use two modules which both import the same file, whereas directly importing files can even be circular. So everything which is imported by gui-grabber in file format can’t be used elsewhere if you’re also importing a gui-grabber module, you’d have to forward the declarations from the module.

But it’s good to architect code so that it’s, well, modular. Tends to be easier to think about and work with.

Edit: Ah, I see you have discovered the “files must belong to one module” rule yourself.

2 Likes

Since main.zig is always a part of the module “root”, calling @import("thing.zig"); in main.zig implicitly makes thing.zig part of “root”.
This is disallowed, since thing.zig is also part of the module “thing”.

Not pointing this out in response to your question was my bad.
What I should say I meant is “modifying module root-level variables within the same module or from another module has the same behaviour”.

1 Like

When using createModule, is there a standard way to refer to “root.zig” or do you still need something like @import("../root.zig") in order to import symbols within the private module?

You can do something like

const root = @import("../root.zig");
const gui_grabbing = root.gui_grabbing;

That is, you can reduce the number of relative imports to one per file.

I don’t know if that’s a good idea or not, we use a mixture of both in TigerBeetle.

In the grand scheme of things, I think this doesn’t matter much. In TigerBeetle, our imports are all over the place, we don’t have any fixed conventions, and it works ok.

4 Likes