Hoping to get feedback from veteran zig developers(for educational reasons)

Hey everyone,

I’m showcasing shellu, a POSIX compatible shell that I’ve been working on to improve my zig skills. I’ve worked on smaller zig projects before, but this is my first attempt at a mid sized project.

I’d really love to get feedback on my coding skills. It’s become pretty hard to get useful code reviews these days, and I’m a bit skeptical of AI feedback because I can’t fully verify it. I don’t have a good way to judge the experience or reputation behind the advice, so feedback from experienced zig developers would mean a lot.

Feel free to be as nitpicky as you want, I’m here to learn.

Not really a review, just some things I’ve noticed while skimming the code:

    var shell: Shell = try .init(init.io, init.gpa, init.environ_map);

I personally have a fairly strong preference to write this as = try Shell.init. I often want to grep for all of the places where a thing is initialized, and, if you use .init everywhere, you can’t grep trivially. In other words, I tend to use .name syntax only when the name is sufficiently unique.

Global vars over here looks a bit iffy: shellu/src/shell.zig at 48c495fe37a61dfdf870e274ea1c1681a6c58b30 · nathanielketema/shellu · GitHub

I understand that this is due to using readline, but I’d personally be motivated to find a C-less solution here. I also don’t like how redline doesn’t use arena in the main loop.

        const history_file = env.get("HISTFILE") orelse try Io.Dir.path.join(gpa, &.{
            home_path,
            ".shellu_history",
        });

On the orelse branch, we leak the path.

        {
            var key_it = shell.variables.keyIterator();
            while (key_it.next()) |key| shell.gpa.free(key.*);
            var val_it = shell.variables.valueIterator();
            while (val_it.next()) |val| shell.gpa.free(val.*);
            shell.variables.deinit();
        }

This kind of loop in deinit always smells to me — why are we managing individual memories one at a time? Can all keys&values share the same storage perhaps? This is the kind of code that RAII writes for you, and this is precisely why RAII tends to have negative effect on the program design with respect to memory.

This I think is the only usage of arena inside Shell, and we never reset this arena, so, the pipes array list effectively leaks.

std.mem.eql(u8, ctx.command.args[ctx.command.args.len - 1], "&")

this is ctx.command.args[ctx.command.args.len - 1] == '&'

Do we close this file? Do we close this file if we get an error down the line?

In general, my main take away is that I don’t quite understand the resource management strategy inside Shell.zig, I see quite a few of individual allocations/deallocations, and such code tends to get rather tricky to get right in Zig.

4 Likes

A bit off topic but anyway…

About the initialization. I am often in doubt what to do! When ‘grepping’ .init() in my code I get too much.

Mostly i dislike it when I read type infered variables / consts:

const thing = initializeSomeThing(); // what is this thing?

So I prefer heavily:

const thing: Thing = initializeSomeThing();  // ok now we know: it's a Thing.

For structs there are basically 3 options:

var shell: Shell = try .init(); // not 'greppable'.
var shell = try Shell.init(); // ok in this case, we know what it is.
var shell: Shell = try Shell.init(); // maybe too much.

In longer statements the .init() can become kinda unreadable as well:

const a: Thing = switch (...) {
    a => .init(7),
    b => .init(8)
}

Curious how other people think about it. But maybe that does not belong in this thread.

Thank you @matklad for taking the time. I’m a big fan of your blog posts and your Ironbeetle show. I have to say, I was genuinely surprised and excited when I saw that you replied to my post :slight_smile:

Yeah, I think “greppability” should be one metric for evaluating code quality. I’ll keep that in mind.

Global variables, like you mentioned, came from not finding a better way to interact with the GNU readline library, especially when I needed to pass shell state to the library. I could’ve gone with a different native zig completion library, but I wanted to learn more about zig’s interoperability with C. In fact, this led me down the rabbit hole of building C libraries in zig, and I ended up writing Readline packaged for zig.

Regarding the orelse branch, I need to be extra cautious when it comes to dealing with borrowed memory and owned memory.

std.mem.eql(u8, ctx.command.args[ctx.command.args.len - 1], "&")

I have to use std.mem.eql in this case because ctx.command.args is of type []const []const u8

Yes, file will be closed.

var ctx: CommandContext = .init(cmd);
defer {
    ctx.flush() catch {};
    ctx.close(shell.io);
}

I agree, I should be using shell.arena more to get the benefits of bulk allocation/deallocation. It’s very tricky indeed to get memory lifetimes right.

Question: how do you guys deal with resource management in zig? Any patterns you use to avoid these kinds of memory bugs?

Thanks again for the feedback!

I don’t think he was necessarily referring to arena allocation. If you haven’t seen it Andrew had an awesome talk on this topic: Video: Programming without pointers

I have also written some of the things matklad mentioned hehe.

I’ll give it a watch, thanks!

I have to say, this was what I was looking for. It’s eye opening, thanks again!

1 Like

if we are talking about education - most interesting part of such projects is ‘tests’

this argument doesn’t make any sense to me, given that you can just trivially grep for Shell = try .init instead.

2 Likes

it wont work in case of = .{ .field = .init() };

But at the same time you can always ask zls about it


which is worse than grep, in a sense of it not working with same editing pipelines as grep does, but its not nothing

I’m skeptical about the value of ‘grep searchability’. There can already be a lot of types with the same name in different namespaces, and Zig allows a type to be renamed when imported into another file. Relying on grep to figure out where a type might be initialized could itself be a bad practice, and we shouldn’t encourage that by writing code that’s just easier to grep.

1 Like

Of course, you can search for it: grep -rF "Shell = try .init" .
But suppose we don’t know if try was used.: grep -rE "Shell = (try )?\.init" .

In contrast, there is simply: grep -rF "Shell.init" .
Everyone can decide for themselves what makes more sense.