Something went wrong. Try again.
A history browser for git, a replacement for gitk, drawn with Vulkan on Wayland.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! grebe's text in the user's language, through Project Fluent.//!//! Every translation is a `.ftl` file in `locales/`, embedded at build time//! (see `build.zig`), so there is no data directory to find. One bundle is//! chosen at startup from what the user asked for — `LANGUAGE`, `LC_ALL`,//! `LC_MESSAGES` and `LANG` — by the//! closest match: the same tag, then the same language and script, then//! the same language. A message the chosen translation lacks comes from//! English, which is complete by construction.//!//! The text goes to grebe's own renderer, which does not reorder//! bidirectional text, so the isolation marks Fluent puts around arguments//! would only show as boxes; they are off.//!//! Only what users read is translated. Logs stay in English, for whoever//! reads a bug report.
const std = @import("std");const Allocator = std.mem.Allocator;const fluent = @import("fluent");const locales = @import("locales");
const log = std.log.scoped(.l10n);
pub const Args = fluent.Args;pub const Argument = fluent.Argument;
/// The bundles, one per translation, and the one chosen; the source/// language's is first.var bundles: []fluent.Bundle = &.{};var chosen: usize = 0;var state_gpa: Allocator = undefined;/// Formatted text of argument-less messages, kept for `text`.var cache: std.StringHashMapUnmanaged([]const u8) = .empty;var cache_arena: ?std.heap.ArenaAllocator = null;
/// Builds a bundle for every translation and chooses one, for `language`/// (a list such as `"de:en"`, as `LANGUAGE` takes) or else the environment.pub fn init(gpa: Allocator, environ: *const std.process.Environ.Map, language: ?[]const u8) Allocator.Error!void { deinit(); state_gpa = gpa; const built = try gpa.alloc(fluent.Bundle, locales.all.len); var n: usize = 0; errdefer { for (built[0..n]) |*b| b.deinit(); gpa.free(built); } for (locales.all) |entry| { const tag = fluent.Locale.parse(entry.tag) catch { log.err("{s}: not a language tag", .{entry.tag}); continue; }; built[n] = try .init(gpa, tag); built[n].use_isolating = false; n += 1; var errors: fluent.Errors = .empty; defer errors.deinit(gpa); try built[n - 1].addResource(entry.source, .{}, &errors); for (errors.items) |err| log.err("{s}.ftl: {f}", .{ entry.tag, err }); } bundles = built[0..n];
var buffer: [8]fluent.Locale = undefined; const wanted = if (language) |l| fluent.posix.fromList(&buffer, l) else fluent.system.preferredLocales(&buffer, environ); chosen = negotiate(bundles, wanted); if (bundles.len > 0) fluent.system.applyCategories(&bundles[chosen], fluent.system.categories(environ)); if (bundles.len > 0) log.debug("text in {f}", .{bundles[chosen].locale});}
pub fn deinit() void { if (cache_arena) |*a| a.deinit(); cache_arena = null; cache = .empty; for (bundles) |*b| b.deinit(); if (bundles.len > 0) state_gpa.free(bundles.ptr[0..locales.all.len]); bundles = &.{}; chosen = 0;}
/// The index of the bundle that best serves `wanted`, in its order; the/// first, the source language, when none does.fn negotiate(have: []const fluent.Bundle, wanted: []const fluent.Locale) usize { for (wanted) |want| { for (0..3) |pass| { for (have, 0..) |*b, i| if (matches(&b.locale, &want, pass)) return i; } } return 0;}
fn matches(have: *const fluent.Locale, want: *const fluent.Locale, pass: usize) bool { if (!std.mem.eql(u8, have.language(), want.language())) return false; return switch (pass) { 0 => have.eql(want), 1 => if (have.script()) |a| (if (want.script()) |b| std.mem.eql(u8, a, b) else true) else true, else => true, };}
/// Makes sure there are bundles: in a test, or anywhere `init` has not/// run, the source language alone.fn ensure() void { if (bundles.len > 0) return; const env: std.process.Environ.Map = .init(std.heap.page_allocator); init(std.heap.page_allocator, &env, "en-US") catch {};}
/// `id` with `args`, in the chosen language, or English where that lacks/// it, or the id itself where both do. The caller frees it.pub fn format(gpa: Allocator, id: []const u8, args: Args) Allocator.Error![]u8 { ensure(); if (bundles.len > 0) { if (try bundles[chosen].format(gpa, id, args, null)) |t| return t; if (chosen != 0) if (try bundles[0].format(gpa, id, args, null)) |t| return t; } return gpa.dupe(u8, id);}
/// `id` with `args`, written to `w`.pub fn write(w: *std.Io.Writer, id: []const u8, args: Args) std.Io.Writer.Error!void { var buf: [1024]u8 = undefined; var fba: std.heap.FixedBufferAllocator = .init(&buf); const t = format(fba.allocator(), id, args) catch return w.writeAll(id); try w.writeAll(t);}
/// `id`, which takes no arguments, kept once formatted: for text drawn every/// frame.pub fn text(id: []const u8) []const u8 { ensure(); if (cache.get(id)) |t| return t; if (cache_arena == null) cache_arena = .init(std.heap.page_allocator); const a = cache_arena.?.allocator(); const t = format(a, id, &.{}) catch return id; const key = a.dupe(u8, id) catch return t; cache.put(a, key, t) catch {}; return t;}
/// `id` with one string argument, `name` = `value`, kept once formatted:/// for a message that picks among a few fixed variants.pub fn choice(id: []const u8, name: []const u8, value: []const u8) []const u8 { ensure(); var key_buf: [256]u8 = undefined; const key = std.fmt.bufPrint(&key_buf, "{s}\x00{s}\x00{s}", .{ id, name, value }) catch return id; if (cache.get(key)) |t| return t; if (cache_arena == null) cache_arena = .init(std.heap.page_allocator); const a = cache_arena.?.allocator(); const t = format(a, id, &.{str(name, value)}) catch return id; const owned = a.dupe(u8, key) catch return t; cache.put(a, owned, t) catch {}; return t;}
/// A string argument.pub fn str(name: []const u8, value: []const u8) fluent.Argument { return .{ .name = name, .value = .{ .string = value } };}
/// A number argument.pub fn num(name: []const u8, value: anytype) fluent.Argument { return .{ .name = name, .value = .num(switch (@typeInfo(@TypeOf(value))) { .float, .comptime_float => value, else => @floatFromInt(value), }) };}
test "every English message formats, and its arguments go in" { const gpa = std.testing.allocator; const t = try format(gpa, "status-commits", &.{num("count", 1)}); defer gpa.free(t); try std.testing.expectEqualStrings("1 commit", t); const u = try format(gpa, "find-matches", &.{num("count", 12)}); defer gpa.free(u); try std.testing.expectEqualStrings("12 matches", u); try std.testing.expectEqualStrings("Copy commit ID", text("menu-copy-id")); // Numbers are grouped as the language groups them. const big = try format(gpa, "status-commits", &.{num("count", 18962)}); defer gpa.free(big); try std.testing.expectEqualStrings("18,962 commits", big); const d = try format(gpa, "diff-between", &.{ str("from", "abc"), str("to", "def") }); defer gpa.free(d); try std.testing.expectEqualStrings("Diff from abc to def", d);}
test "a missing message is its id" { const gpa = std.testing.allocator; const t = try format(gpa, "no-such-message", &.{}); defer gpa.free(t); try std.testing.expectEqualStrings("no-such-message", t);}
test "every resource parses cleanly" { const gpa = std.testing.allocator; for (locales.all) |entry| { var b: fluent.Bundle = try .init(gpa, try .parse(entry.tag)); defer b.deinit(); var errors: fluent.Errors = .empty; defer errors.deinit(gpa); try b.addResource(entry.source, .{}, &errors); try std.testing.expectEqual(0, errors.items.len); }}
test "the closest translation is chosen" { var a: fluent.Bundle = try .init(std.testing.allocator, try .parse("en-US")); defer a.deinit(); var b: fluent.Bundle = try .init(std.testing.allocator, try .parse("de")); defer b.deinit(); const have = [_]fluent.Bundle{ a, b }; try std.testing.expectEqual(1, negotiate(&have, &.{try .parse("de-AT")})); try std.testing.expectEqual(0, negotiate(&have, &.{try .parse("fr")})); try std.testing.expectEqual(1, negotiate(&have, &.{ try .parse("fr"), try .parse("de") }));}