From a78e6b3cc956cd428680a566daced3ab8ff48db2 Mon Sep 17 00:00:00 2001 From: "Lucas F." Date: Thu, 25 Dec 2025 15:00:11 -0300 Subject: [PATCH] initial --- .gitignore | 53 ++++++++++++++++ LICENSE | 21 +++++++ README.md | 86 ++++++++++++++++++++++++++ build.zig | 52 ++++++++++++++++ build.zig.zon | 19 ++++++ src/dotenv.zig | 161 +++++++++++++++++++++++++++++++++++++++++++++++++ src/main.zig | 2 + 7 files changed, 394 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build.zig create mode 100644 build.zig.zon create mode 100644 src/dotenv.zig create mode 100644 src/main.zig diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b49bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# Zig build artifacts +zig-out/ +zig-cache/ +.zig-cache/ + +# Generated PDFs (test outputs) +*.pdf + +# Test data files +*.json +!build.json +*.pdm +*.bin +*.png +*.sh +*.ndjson +# HTML test files +*.html + +# Build binaries +*.o +*.so +*.dylib +*.dll +*.exe + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +.claude/ + +# Temporary files +*.tmp +*.temp +*.log + +# Test directories +data/ +output/ +services/ +template_configs/ + +# Backup files +*.bak +*.backup + +# OS specific +Thumbs.db + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5d29dbf --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Lucas F. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..87399df --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# zig-dotenv + +A simple and lightweight library for loading `.env` files in Zig projects. + +- **Zero dependencies** — only the Zig standard library +- **Safe** — proper error handling and automatic memory cleanup +- **Convenient** — `get`, `getOr`, and `require` methods for easy access +- **Well-tested** — full unit test coverage + +## Installation + +Add the package to your project with `zig fetch`: + +```bash +zig fetch --save https://git.lucasf.xyz/public/zig-dotenv/archive/0.1.0.tar.gz +``` + +Then, in your `build.zig`: + +```zig +const std = @import("std"); + +pub fn build(b: *std.Build) void { + ... + const exe = b.addExecutable(.{ ... }); + ... + + const dotenv_dep = b.dependency("zigdotenv", .{}); + exe.root_module.addImport("zigdotenv", dotenv_dep.module("zigdotenv")); +} +``` +## Basic Usage + +```zig +const std = @import("std"); +const dotenv = @import("zigdotenv"); + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + // Load the .env file (fails on unexpected errors) + var env = try dotenv.DotEnv.load(allocator, ".env"); + defer env.deinit(); + + // Simple access + if (env.get("API_KEY")) |key| { + std.debug.print("API_KEY = {s}\n", .{key}); + } + + // With fallback default + const debug = env.getOr("DEBUG", "false"); + std.debug.print("DEBUG = {s}\n", .{debug}); + + // Required variable (fails if missing) + const database_url = try env.require("DATABASE_URL"); + std.debug.print("DB URL = {s}\n", .{database_url}); +} +``` +## Supported format + +The parser supports the standard `.env` format: + +```env +# Comments are ignored +API_KEY=supersecret123 +DATABASE_URL=postgres://localhost:5432/myapp + +# Spaces are trimmed +DEBUG = true + +# Single or double quotes are stripped +QUOTED="value with spaces" +SINGLE='another value' + +# Empty value +EMPTY= +``` + +## License + +MIT © Lucas F. + +___ +Feito com ❤️ em Zig. Enjoy! 🚀 diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..72ed657 --- /dev/null +++ b/build.zig @@ -0,0 +1,52 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const mod = b.addModule("zigdotenv", .{ + .root_source_file = b.path("src/dotenv.zig"), + .target = target, + }); + + const exe = b.addExecutable(.{ + .name = "zigdotenv", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "zigdotenv", .module = mod }, + }, + }), + }); + + b.installArtifact(exe); + + const run_step = b.step("run", "Run the app"); + + const run_cmd = b.addRunArtifact(exe); + run_step.dependOn(&run_cmd.step); + + run_cmd.step.dependOn(b.getInstallStep()); + + if (b.args) |args| { + run_cmd.addArgs(args); + } + + const mod_tests = b.addTest(.{ + .root_module = mod, + }); + + const run_mod_tests = b.addRunArtifact(mod_tests); + + const exe_tests = b.addTest(.{ + .root_module = exe.root_module, + }); + + const run_exe_tests = b.addRunArtifact(exe_tests); + + const test_step = b.step("test", "Run tests"); + test_step.dependOn(&run_mod_tests.step); + test_step.dependOn(&run_exe_tests.step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..cc55d0f --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,19 @@ +.{ + .name = .zigdotenv, + .version = "0.0.0", + .fingerprint = 0x626f95eeb8bf233a, // Changing this has security and trust implications. + .minimum_zig_version = "0.15.2", + .dependencies = .{ + .zigdotenv = .{ + .url = "git+https://git.lucasf.xyz/public/zig-dotenv.git?ref=0.1.0#dca4323e56b705264233336af6b886d20b259532", + .hash = "zigdotenv-0.0.0-OiO_uOEpAACmZDzDibmfis8H5n39abPf4vQy5pNGu6OX", + }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + "LICENSE", + "README.md", + }, +} diff --git a/src/dotenv.zig b/src/dotenv.zig new file mode 100644 index 0000000..75931f9 --- /dev/null +++ b/src/dotenv.zig @@ -0,0 +1,161 @@ +const std = @import("std"); + +/// Structure that holds environment variables loaded from .env file +pub const DotEnv = struct { + map: std.StringHashMap([]const u8), + allocator: std.mem.Allocator, + + /// Loads the .env file from the given path and returns a DotEnv instance + pub fn load(allocator: std.mem.Allocator, path: []const u8) !DotEnv { + var map = std.StringHashMap([]const u8).init(allocator); + + const file = std.fs.cwd().openFile(path, .{}) catch |err| switch (err) { + error.FileNotFound => return error.DotEnvFileNotFound, + else => return err, + }; + defer file.close(); + + const max_size = 10 * 1024 * 1024; // 10MB max + const content = try file.readToEndAlloc(allocator, max_size); + defer allocator.free(content); + + var lines = std.mem.splitScalar(u8, content, '\n'); + while (lines.next()) |raw_line| { + var line = std.mem.trim(u8, raw_line, " \t\r\n"); + + // skip empty lines and comments + if (line.len == 0 or line[0] == '#') continue; + + // find the first '=' character + if (std.mem.indexOfScalar(u8, line, '=')) |eq_pos| { + const key = std.mem.trim(u8, line[0..eq_pos], " \t"); + var value = std.mem.trim(u8, line[eq_pos + 1 ..], " \t\r\n"); + + // optionally remove surrounding single or double quotes + if (value.len >= 2 and ((value[0] == '"' and value[value.len - 1] == '"') or + (value[0] == '\'' and value[value.len - 1] == '\''))) + { + value = value[1 .. value.len - 1]; + } + + // Duplicate key and value to own them in the map + const key_dup = try allocator.dupe(u8, key); + errdefer allocator.free(key_dup); + + const value_dup = try allocator.dupe(u8, value); + errdefer allocator.free(value_dup); + + try map.put(key_dup, value_dup); + } + } + return DotEnv{ + .map = map, + .allocator = allocator, + }; + } + + /// Frees all allocated memory (keys and values) + pub fn deinit(self: *DotEnv) void { + var it = self.map.iterator(); + while (it.next()) |entry| { + self.allocator.free(entry.key_ptr.*); + self.allocator.free(entry.value_ptr.*); + } + self.map.deinit(); + } + + /// Returns the value for key, or null if not present + pub fn get(self: DotEnv, key: []const u8) ?[]const u8 { + return self.map.get(key); + } + + /// Returns the value for key, or a default value if not present + pub fn getOr(self: DotEnv, key: []const u8, default: []const u8) []const u8 { + return self.get(key) orelse default; + } + + /// Returns the value for a key, or errors if the required variable is missing + pub fn require(self: DotEnv, key: []const u8) ![]const u8 { + return self.get(key) orelse error.MissingRequiredEnvVar; + } +}; + +pub const Error = error{ DotEnvFileNotFound, MissingRequiredEnvVar }; + +// ╔══════════════════════════════════════════════════════════╗ +// ║ UNIT TESTS ║ +// ╚══════════════════════════════════════════════════════════╝ + +test "load .env file - basic functionality" { + const allocator = std.testing.allocator; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const content = + \\API_KEY=12345 + \\DEBUG=true + \\NAME=zig tester + \\#COMMENT=ignored + \\ SPACED = value with spaces + \\QUOTED="quoted value" + \\EMPTY= + ; + + try tmp.dir.writeFile(.{ .data = content, .sub_path = "test.env" }); + + const tmp_path = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(tmp_path); + + const full_path = try std.fs.path.join(allocator, &[_][]const u8{ + tmp_path, + "test.env", + }); + defer allocator.free(full_path); + + var env = try DotEnv.load(allocator, full_path); + defer env.deinit(); + + try std.testing.expectEqualStrings("12345", env.require("API_KEY") catch unreachable); + try std.testing.expectEqualStrings("true", env.get("DEBUG").?); + try std.testing.expectEqualStrings("zig tester", env.get("NAME").?); + try std.testing.expectEqualStrings("value with spaces", env.get("SPACED").?); + try std.testing.expectEqualStrings("quoted value", env.get("QUOTED").?); + try std.testing.expect(env.get("COMMENT") == null); + try std.testing.expectEqualStrings("", env.get("EMPTY").?); +} + +test "require() errors on missing required var" { + const allocator = std.testing.allocator; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.writeFile(.{ .data = "OTHER=123", .sub_path = "empty.env" }); + + const tmp_path = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(tmp_path); + + const full_path = try std.fs.path.join(allocator, &[_][]const u8{ tmp_path, "empty.env" }); + defer allocator.free(full_path); + + var env = try DotEnv.load(allocator, full_path); + defer env.deinit(); + + try std.testing.expectError(error.MissingRequiredEnvVar, env.require("MISSING")); +} + +test "missing file should return error" { + const allocator = std.testing.allocator; + + try std.testing.expectError(error.DotEnvFileNotFound, DotEnv.load(allocator, "this-file-not-exists.env")); +} + +test "getOr() returns default on missing key" { + const allocator = std.testing.allocator; + + var env: DotEnv = DotEnv{ .allocator = allocator, .map = std.StringHashMap([]const u8).init(allocator) }; + + try std.testing.expectEqualStrings("default-value", env.getOr("NOT EXIST", "default-value")); + try std.testing.expect(env.get("NOT EXIST") == null); +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..ba81518 --- /dev/null +++ b/src/main.zig @@ -0,0 +1,2 @@ +const std = @import("std"); +const zigdotenv = @import("zigdotenv");