Something went wrong. Try again.
A Zig library that finds fonts as fontconfig does, without it: the same configuration, OpenType and bitmap fonts, and the same answers.
Something went wrong. Try again.
34 kB · 799 lines
Zig
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! A cache of scanned fonts, so that a scan reads only the font files that//! are new or have changed since the last one. It is this library's own and//! shares nothing with fontconfig's caches.//!//! There is one cache file for each font directory, named for the//! fingerprint below and a hash of the directory's path. It holds, for every file in the directory, the file's//! size, modification time and inode, and the patterns reading it gave://! the patterns as they come out of the font, *before* the configuration's//! scan rules, so that one cache serves every configuration. A file whose//! size, time and inode all match is not read; one where any differs is//! read again, and the directory's cache file is rewritten. A file that//! gave no patterns is recorded too, so that it is not read every time.//!//! A cache file starts with a fingerprint of the code that reads fonts, so//! that a cache written by a different version is ignored rather than//! trusted, and ends with a checksum of everything before it. All integers//! are little-endian. The fingerprint is in the name as well, so that two//! versions sharing a cache directory each keep their own files rather than//! replacing each other's on every scan.//!//! A scan touches each cache file it reads, at most once a day, and//! deletes any other cache file not touched for `max_age`: those of//! versions no longer run, and of directories no longer scanned.//!//! "ZFCC" u32 version, u64 fingerprint//! string directory//! u32 files, then for each://! string name, u64 size, i128 mtime, u64 inode//! u32 length, then that many bytes of patterns (`encode`)//! u64 checksum//!//! A string is a `u32` length and that many bytes.
const std = @import("std");const builtin = @import("builtin");const Io = std.Io;const Allocator = std.mem.Allocator;const tables = @import("tables");const object = @import("object.zig");const value = @import("value.zig");const CharSet = @import("CharSet.zig");const LangSet = @import("LangSet.zig");const Pattern = @import("Pattern.zig");const Object = object.Object;const Builtin = object.Builtin;const Value = value.Value;
const magic = "ZFCC";
/// Bump when the layout changes, or when anything that reads fonts changes/// outside the files the fingerprint covers — zig-font, most of all.pub const version: u32 = 1;
/// What a cache was written by: the reading code itself, and the tables/// whose order a pattern's values are encoded against — the object/// numbers, and the bits of a language set.pub const fingerprint: u64 = blk: { @setEvalBranchQuota(100_000_000); var h: std.hash.Wyhash = .init(version); for ([_][]const u8{ @embedFile("scan/query.zig"), @embedFile("scan/Face.zig"), @embedFile("scan/bitmap.zig"), @embedFile("scan/legacy.zig"), @embedFile("sfnt_tables.zig"), @embedFile("CharSet.zig"), @embedFile("LangSet.zig"), @embedFile("str.zig"), }) |source| h.update(source); for (std.enums.values(Builtin)) |b| h.update(@tagName(b)); for (std.enums.values(object.Type)) |t| h.update(@tagName(t)); for (tables.orths) |o| h.update(o.lang); break :blk h.final();};
/// The name a directory's cache file has in the cache directory.pub const extension = ".zfc-cache";
/// Cache files larger than this are ignored rather than read.const max_cache_size = 256 * 1024 * 1024;
/// What a file is recognized by: when any of these changes, it is read/// again.pub const Stamp = struct { size: u64, mtime: i128, inode: u64,
pub fn of(st: Io.File.Stat) Stamp { return .{ .size = st.size, .mtime = st.mtime.nanoseconds, .inode = if (@TypeOf(st.inode) == void) 0 else @intCast(st.inode), }; }
pub fn eql(a: Stamp, b: Stamp) bool { return a.size == b.size and a.mtime == b.mtime and a.inode == b.inode; }};
pub const DecodeError = error{Corrupt} || Allocator.Error;
/// A cache file not used for this long is deleted by the next scan.pub const max_age: Io.Duration = .fromSeconds(30 * std.time.s_per_day);
/// A cache file read is touched, so that it does not reach `max_age`, when/// it was last touched longer ago than this.const touch_after: Io.Duration = .fromSeconds(std.time.s_per_day);
/// The name of `dir`'s cache file.pub fn fileName(dir: []const u8) [33 + extension.len]u8 { var out: [33 + extension.len]u8 = undefined; _ = std.fmt.bufPrint(&out, "{x:0>16}-{x:0>16}" ++ extension, .{ fingerprint, std.hash.Wyhash.hash(0, dir) }) catch unreachable; return out;}
/// One directory's cache file, as read: its files and where each one's/// patterns are.pub const Contents = struct { bytes: []u8, files: std.StringHashMapUnmanaged(Record) = .empty,
pub const Record = struct { stamp: Stamp, /// The patterns, as `encode` wrote them. body: []const u8, };
pub fn deinit(c: *Contents, gpa: Allocator) void { c.files.deinit(gpa); gpa.free(c.bytes); }
/// The record for `name`, if its stamp is still `stamp`. pub fn find(c: *const Contents, name: []const u8, stamp: Stamp) ?Record { const r = c.files.get(name) orelse return null; return if (r.stamp.eql(stamp)) r else null; }
/// Parses a cache file, taking ownership of `bytes`. Null, with `bytes` /// freed, if it is not a cache of `dir` written by this version. pub fn parse(gpa: Allocator, bytes: []u8, dir: []const u8) Allocator.Error!?Contents { var c: Contents = .{ .bytes = bytes }; errdefer c.deinit(gpa); if (!try c.parseInner(gpa, dir)) { c.deinit(gpa); return null; } return c; }
fn parseInner(c: *Contents, gpa: Allocator, dir: []const u8) Allocator.Error!bool { if (c.bytes.len < 8) return false; const body = c.bytes[0 .. c.bytes.len - 8]; const sum = std.mem.readInt(u64, c.bytes[body.len..][0..8], .little); if (sum != std.hash.Wyhash.hash(fingerprint, body)) return false;
var r: Io.Reader = .fixed(body); const head = r.takeArray(magic.len) catch return false; if (!std.mem.eql(u8, head, magic)) return false; if ((r.takeInt(u32, .little) catch return false) != version) return false; if ((r.takeInt(u64, .little) catch return false) != fingerprint) return false; if (!std.mem.eql(u8, takeString(&r) catch return false, dir)) return false; const n = r.takeInt(u32, .little) catch return false; for (0..n) |_| { const name = takeString(&r) catch return false; const stamp: Stamp = .{ .size = r.takeInt(u64, .little) catch return false, .mtime = r.takeInt(i128, .little) catch return false, .inode = r.takeInt(u64, .little) catch return false, }; const len = r.takeInt(u32, .little) catch return false; const record_body = r.take(len) catch return false; try c.files.put(gpa, name, .{ .stamp = stamp, .body = record_body }); } return r.bufferedLen() == 0; }};
/// A directory's cache file, being written.pub const Builder = struct { out: Io.Writer.Allocating, count: u32 = 0, /// Where the file count goes, once it is known. count_at: usize,
pub fn init(gpa: Allocator, dir: []const u8) Allocator.Error!Builder { var b: Builder = .{ .out = .init(gpa), .count_at = undefined }; errdefer b.out.deinit(); const w = &b.out.writer; w.writeAll(magic) catch return error.OutOfMemory; w.writeInt(u32, version, .little) catch return error.OutOfMemory; w.writeInt(u64, fingerprint, .little) catch return error.OutOfMemory; writeString(w, dir) catch return error.OutOfMemory; b.count_at = w.end; w.writeInt(u32, 0, .little) catch return error.OutOfMemory; return b; }
pub fn deinit(b: *Builder) void { b.out.deinit(); }
/// Adds a file whose patterns are already encoded. pub fn addEncoded(b: *Builder, name: []const u8, stamp: Stamp, body: []const u8) Allocator.Error!void { const len = std.math.cast(u32, body.len) orelse return error.OutOfMemory; const w = &b.out.writer; writeString(w, name) catch return error.OutOfMemory; w.writeInt(u64, stamp.size, .little) catch return error.OutOfMemory; w.writeInt(i128, stamp.mtime, .little) catch return error.OutOfMemory; w.writeInt(u64, stamp.inode, .little) catch return error.OutOfMemory; w.writeInt(u32, len, .little) catch return error.OutOfMemory; w.writeAll(body) catch return error.OutOfMemory; b.count += 1; }
/// Adds a file and the patterns reading it gave. pub fn add(b: *Builder, name: []const u8, stamp: Stamp, patterns: []const *Pattern) Allocator.Error!void { var body: Io.Writer.Allocating = .init(b.out.allocator); defer body.deinit(); try encode(b.out.allocator, &body.writer, patterns); try b.addEncoded(name, stamp, body.written()); }
/// The finished file. pub fn finish(b: *Builder) Allocator.Error![]const u8 { const w = &b.out.writer; std.mem.writeInt(u32, w.buffer[b.count_at..][0..4], b.count, .little); const sum = std.hash.Wyhash.hash(fingerprint, w.buffered()); w.writeInt(u64, sum, .little) catch return error.OutOfMemory; return b.out.written(); }};
fn writeString(w: *Io.Writer, s: []const u8) Io.Writer.Error!void { try w.writeInt(u32, @intCast(s.len), .little); try w.writeAll(s);}
fn takeString(r: *Io.Reader) Io.Reader.Error![]const u8 { const len = try r.takeInt(u32, .little); return r.take(len);}
const ValueTag = std.meta.Tag(Value);
/// Writes the patterns of one font file. The charsets and language sets/// its faces share are written once and referred to by number, as they are/// shared in memory.////// u32 charsets, then for each: u32 pages, the pages, the leaves/// u32 langsets, then for each: the map, u32 extras, the extras/// u32 patterns, then for each:/// u32 objects, then for each:/// u8 0 and a builtin's number, or u8 1 and a custom name/// u32 values, then for each: u8 binding, u8 type, the value////// A charset or langset value is a `u32` number in its table.pub fn encode(gpa: Allocator, w: *Io.Writer, patterns: []const *Pattern) Allocator.Error!void { encodeInner(gpa, w, patterns) catch |err| switch (err) { error.WriteFailed => return error.OutOfMemory, error.OutOfMemory => return error.OutOfMemory, };}
fn encodeInner(gpa: Allocator, w: *Io.Writer, patterns: []const *Pattern) (Io.Writer.Error || Allocator.Error)!void { var charsets: std.AutoArrayHashMapUnmanaged(*const CharSet, void) = .empty; defer charsets.deinit(gpa); var langsets: std.AutoArrayHashMapUnmanaged(*const LangSet, void) = .empty; defer langsets.deinit(gpa); for (patterns) |p| for (p.elts.items) |e| { var it = e.iterator(); while (it.next()) |l| switch (l.value) { .charset => |c| try charsets.put(gpa, c, {}), .langset => |s| try langsets.put(gpa, s, {}), else => {}, }; };
try w.writeInt(u32, @intCast(charsets.count()), .little); for (charsets.keys()) |c| { try w.writeInt(u32, @intCast(c.pages.len), .little); for (c.pages) |page| try w.writeInt(u32, page, .little); for (c.leaves) |leaf| for (leaf) |word| try w.writeInt(u32, word, .little); } try w.writeInt(u32, @intCast(langsets.count()), .little); for (langsets.keys()) |s| { for (s.map) |word| try w.writeInt(u32, word, .little); try w.writeInt(u32, @intCast(s.extra.len), .little); for (s.extra) |x| try writeString(w, x); }
try w.writeInt(u32, @intCast(patterns.len), .little); for (patterns) |p| { try w.writeInt(u32, @intCast(p.elts.items.len), .little); for (p.elts.items) |e| { switch (e.object) { .builtin => |b| { try w.writeByte(0); try w.writeByte(@backingInt(b)); }, .custom => |name| { try w.writeByte(1); try writeString(w, name); }, } try w.writeInt(u32, @intCast(e.count()), .little); var it = e.iterator(); while (it.next()) |l| { try w.writeByte(@backingInt(l.binding)); try w.writeByte(@backingInt(l.value.typ())); switch (l.value) { .unknown, .void => {}, .integer => |i| try w.writeInt(i32, i, .little), .double => |d| try writeDouble(w, d), .string => |s| try writeString(w, s), .bool => |b| try w.writeByte(@backingInt(b)), .matrix => |m| for ([_]f64{ m.xx, m.xy, m.yx, m.yy }) |d| try writeDouble(w, d), .charset => |c| try w.writeInt(u32, @intCast(charsets.getIndex(c).?), .little), .langset => |s| try w.writeInt(u32, @intCast(langsets.getIndex(s).?), .little), .range => |r| { try writeDouble(w, r.begin); try writeDouble(w, r.end); }, } } } }}
fn writeDouble(w: *Io.Writer, d: f64) Io.Writer.Error!void { try w.writeInt(u64, @bitCast(d), .little);}
/// Reads the patterns `encode` wrote, appending them to `out` as/// `scan.queryAll` would: each pattern allocated with `gpa` and owned by/// the caller, the charsets and language sets they share with `keep`. On/// an error nothing is appended.pub fn decode(gpa: Allocator, keep: Allocator, bytes: []const u8, out: *std.ArrayList(*Pattern)) DecodeError!void { const start = out.items.len; errdefer { for (out.items[start..]) |p| { p.deinit(); gpa.destroy(p); } out.shrinkRetainingCapacity(start); } var r: Io.Reader = .fixed(bytes); decodeInner(gpa, keep, &r, out) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.Corrupt, error.EndOfStream, error.ReadFailed => return error.Corrupt, }; if (r.bufferedLen() != 0) return error.Corrupt;}
fn decodeInner(gpa: Allocator, keep: Allocator, r: *Io.Reader, out: *std.ArrayList(*Pattern)) (DecodeError || Io.Reader.Error)!void { var scratch_state: std.heap.ArenaAllocator = .init(gpa); defer scratch_state.deinit(); const scratch = scratch_state.allocator();
// The tables are read into scratch and copied to `keep` only once all // of them have been read, so that a corrupt table leaves nothing behind // there. Corrupt patterns after them can, but `keep` is an arena. // Counts are checked against what is left before anything is // allocated for them: a charset takes at least its page count, and a // language set its map and a count of extra languages. const n_charsets = try r.takeInt(u32, .little); if (n_charsets > r.bufferedLen() / 4) return error.Corrupt; const charsets = try scratch.alloc(CharSet, n_charsets); for (charsets) |*c| { const n = try r.takeInt(u32, .little); const page_bytes = try r.take(std.math.mul(usize, n, 4) catch return error.Corrupt); const pages = try scratch.alloc(u32, n); for (pages, 0..) |*page, i| { page.* = std.mem.readInt(u32, page_bytes[i * 4 ..][0..4], .little); if (i > 0 and pages[i - 1] >= page.*) return error.Corrupt; } const leaf_bytes = try r.take(std.math.mul(usize, n, 32) catch return error.Corrupt); const leaves = try scratch.alloc(CharSet.Leaf, n); for (leaves, 0..) |*leaf, i| for (leaf, 0..) |*word, j| { word.* = std.mem.readInt(u32, leaf_bytes[i * 32 + j * 4 ..][0..4], .little); }; c.* = .{ .pages = pages, .leaves = leaves }; }
const n_langsets = try r.takeInt(u32, .little); if (n_langsets > r.bufferedLen() / ((LangSet.words + 1) * 4)) return error.Corrupt; const langsets = try scratch.alloc(LangSet, n_langsets); for (langsets) |*s| { var map: [LangSet.words]u32 = undefined; for (&map) |*word| word.* = try r.takeInt(u32, .little); const n = try r.takeInt(u32, .little); if (n > r.bufferedLen() / 4) return error.Corrupt; const extra = try scratch.alloc([]const u8, n); for (extra) |*x| x.* = try takeString(r); s.* = .{ .map = map, .extra = extra }; }
const kept_charsets = try keep.alloc(*const CharSet, charsets.len); for (charsets, kept_charsets) |c, *k| { const p = try keep.create(CharSet); p.* = try c.dupe(keep); k.* = p; } const kept_langsets = try keep.alloc(*const LangSet, langsets.len); for (langsets, kept_langsets) |s, *k| { const p = try keep.create(LangSet); p.* = try s.dupe(keep); k.* = p; }
const n_patterns = try r.takeInt(u32, .little); // A pattern takes several times its encoded size once decoded, and // the patterns of one font file are much alike, so their share of // what is left is a fair guess at what each needs. const share = if (n_patterns == 0) 0 else r.bufferedLen() / n_patterns; for (0..n_patterns) |_| { const p = try gpa.create(Pattern); p.* = .init(gpa); out.append(gpa, p) catch |err| { p.deinit(); gpa.destroy(p); return err; }; const a = p.allocator(); const n_elts = try r.takeInt(u32, .little); // Every object takes at least nine bytes: its number, a count, and // one value of at least three. if (n_elts > r.bufferedLen() / 9) return error.Corrupt; try p.preheat(share * preheat_ratio + n_elts * (@sizeOf(Pattern.Elt) + @sizeOf(*Pattern.Elt))); try p.elts.ensureTotalCapacityPrecise(a, n_elts); for (0..n_elts) |_| { const o: Object = switch (try r.takeByte()) { 0 => .{ .builtin = std.enums.fromInt(Builtin, try r.takeByte()) orelse return error.Corrupt }, 1 => .{ .custom = try takeString(r) }, else => return error.Corrupt, }; const n_values = try r.takeInt(u32, .little); if (n_values == 0) return error.Corrupt; for (0..n_values) |_| { const binding = std.enums.fromInt(Pattern.Binding, try r.takeByte()) orelse return error.Corrupt; const tag = std.enums.fromInt(ValueTag, try r.takeByte()) orelse return error.Corrupt; const v: Value = switch (tag) { .unknown, .void => return error.Corrupt, .integer => .{ .integer = try r.takeInt(i32, .little) }, .double => .{ .double = try takeDouble(r) }, .string => .{ .string = try a.dupe(u8, try takeString(r)) }, .bool => .{ .bool = std.enums.fromInt(value.Bool, try r.takeByte()) orelse return error.Corrupt }, .matrix => .{ .matrix = .{ .xx = try takeDouble(r), .xy = try takeDouble(r), .yx = try takeDouble(r), .yy = try takeDouble(r), } }, .charset => .{ .charset = try indexInto(*const CharSet, kept_charsets, r) }, .langset => .{ .langset = try indexInto(*const LangSet, kept_langsets, r) }, .range => .{ .range = .{ .begin = try takeDouble(r), .end = try takeDouble(r) } }, }; // The custom name is copied by `insertElt`; the value was // copied above or belongs to `keep`. if (!try p.addBorrowed(o, v, binding, true)) return error.Corrupt; } } }}
/// How many times its encoded size a decoded pattern is given to start/// with; see `decodeInner`.const preheat_ratio = 4;
fn takeDouble(r: *Io.Reader) Io.Reader.Error!f64 { return @bitCast(try r.takeInt(u64, .little));}
fn indexInto(comptime T: type, items: []const T, r: *Io.Reader) (DecodeError || Io.Reader.Error)!T { const i = try r.takeInt(u32, .little); if (i >= items.len) return error.Corrupt; return items[i];}
/// A cache directory in use for one scan.pub const Session = struct { gpa: Allocator, dir: Io.Dir, /// Whether to ignore what is cached and read every font again. refresh: bool, /// Cleared after the first write that fails, so that a cache that /// cannot be written is only read. writable: bool = true, report: *Report, /// The names of the cache files this scan has read or written, which /// `prune` leaves alone whatever their age. used: std.AutoHashMapUnmanaged([fileName("").len]u8, void) = .empty,
pub fn deinit(s: *Session, io: Io) void { s.used.deinit(s.gpa); s.dir.close(io); }
/// The cached contents of `dir`, if there are any current ones. pub fn load(s: *Session, io: Io, dir: []const u8) Allocator.Error!?Contents { if (s.refresh) return null; const name = fileName(dir); try s.used.put(s.gpa, name, {}); const bytes = s.dir.readFileAlloc(io, &name, s.gpa, .limited(max_cache_size)) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return null, }; const contents = try Contents.parse(s.gpa, bytes, dir); if (contents != null) s.touch(io, &name); return contents; }
/// Marks a cache file as still in use, if it was last marked more than /// `touch_after` ago. Whether that works is no matter: at worst the /// file is pruned, and read again from the fonts, a month from now. fn touch(s: *Session, io: Io, name: []const u8) void { if (!s.writable) return; const st = s.dir.statFile(io, name, .{}) catch return; if (st.mtime.durationTo(Io.Clock.real.now(io)).nanoseconds < touch_after.nanoseconds) return; s.dir.setTimestampsNow(io, name, .{}) catch {}; }
/// Deletes every cache file this scan did not use and nothing has used /// for `max_age`. Anything that goes wrong only leaves a file in place. pub fn prune(s: *Session, io: Io) Allocator.Error!void { if (!s.writable) return; const now = Io.Clock.real.now(io); var doomed: std.ArrayList([]u8) = .empty; defer { for (doomed.items) |n| s.gpa.free(n); doomed.deinit(s.gpa); } var it = s.dir.iterate(); while (it.next(io) catch null) |entry| { if (entry.kind != .file or !std.mem.endsWith(u8, entry.name, extension)) continue; if (entry.name.len == fileName("").len and s.used.contains(entry.name[0..fileName("").len].*)) continue; const st = s.dir.statFile(io, entry.name, .{}) catch continue; if (st.mtime.durationTo(now).nanoseconds < max_age.nanoseconds) continue; // Deleted afterwards, since deleting what is being iterated // over can make the iteration skip a file. try doomed.append(s.gpa, try s.gpa.dupe(u8, entry.name)); } for (doomed.items) |n| { s.dir.deleteFile(io, n) catch continue; s.report.files_pruned += 1; } }
/// Replaces `dir`'s cache file with `data`, atomically, so that a /// reader never sees half of one. pub fn store(s: *Session, io: Io, dir: []const u8, data: []const u8) void { const name = fileName(dir); s.used.put(s.gpa, name, {}) catch {}; if (!s.writable) return; s.storeInner(io, &name, data) catch { s.writable = false; s.report.write_failed = true; return; }; s.report.dirs_written += 1; }
fn storeInner(s: *Session, io: Io, name: []const u8, data: []const u8) !void { var af = try s.dir.createFileAtomic(io, name, .{ .replace = true }); defer af.deinit(io); try af.file.writeStreamingAll(io, data); try af.replace(io); }};
/// What a scan did with the cache.pub const Report = struct { /// The cache directory, or null if there was none to use: caching was /// turned off, or the directory could not be found or made. path: ?[]const u8 = null, /// Font files whose patterns came from the cache. files_cached: usize = 0, /// Font files read, because the cache had nothing current for them. files_read: usize = 0, /// Directories whose cache file was written. dirs_written: usize = 0, /// Whether writing a cache file failed, after which none was written. write_failed: bool = false, /// Cache files deleted because nothing had used them for `max_age`. files_pruned: usize = 0,};
/// Opens the cache directory at `path`, making it and any parent that is/// missing.////// Not `Io.Dir.createDirPathOpen`: in Zig 0.17 that loops forever when/// making a directory fails with `FileNotFound` although its parent exists,/// which is what `/proc` answers, and so what an `XDG_CACHE_HOME` pointing/// into it would get. This makes each component once, from the top.pub fn openDir(io: Io, path: []const u8) !Io.Dir { const cwd = Io.Dir.cwd(); const options: Io.Dir.OpenOptions = .{ .iterate = true }; if (cwd.openDir(io, path, options)) |dir| return dir else |err| switch (err) { error.FileNotFound => {}, else => return err, } var it = std.fs.path.componentIterator(path); while (it.next()) |component| { cwd.createDir(io, component.path, .default_dir) catch |err| switch (err) { error.PathAlreadyExists => {}, else => return err, }; } return cwd.openDir(io, path, options);}
/// Deletes every cache file in `dir`, leaving anything else alone.pub fn clear(io: Io, dir: Io.Dir) !void { var it = dir.iterate(); while (try it.next(io)) |entry| { if (entry.kind != .file or !std.mem.endsWith(u8, entry.name, extension)) continue; dir.deleteFile(io, entry.name) catch |err| switch (err) { error.FileNotFound => {}, else => return err, }; }}
const testing = std.testing;
fn testPatterns(gpa: Allocator, keep: Allocator) !std.ArrayList(*Pattern) { var out: std.ArrayList(*Pattern) = .empty; errdefer { for (out.items) |p| { p.deinit(); gpa.destroy(p); } out.deinit(gpa); } var b: CharSet.Builder = .{}; defer b.deinit(gpa); try b.addRange(gpa, 0x20, 0x7e); try b.add(gpa, 0x4e00); const built = try b.finish(gpa); defer built.deinit(gpa); const cs = try keep.create(CharSet); cs.* = try built.dupe(keep); const ls = try keep.create(LangSet); ls.* = try LangSet.fromLang(keep, "en"); ls.* = try ls.add(keep, "x-klingon");
for (0..2) |i| { const p = try gpa.create(Pattern); p.* = .init(gpa); try out.append(gpa, p); try p.addString(.family, "Fixture Sans"); _ = try p.addWithBinding(.{ .builtin = .family }, .{ .string = "Weak" }, .weak, true); try p.addInteger(.index, @intCast(i)); try p.addDouble(.pixelsize, 12.5); try p.addBool(.outline, true); try p.addRange(.weight, 100, 900); _ = try p.addBuiltin(.matrix, .{ .matrix = .{ .xy = 0.25 } }); _ = try p.add(.{ .custom = "frob" }, .{ .string = "nicate" }); _ = try p.addBorrowed(.{ .builtin = .charset }, .{ .charset = cs }, .strong, true); _ = try p.addBorrowed(.{ .builtin = .lang }, .{ .langset = ls }, .strong, true); } return out;}
test "patterns survive a round trip" { const gpa = testing.allocator; var keep_state: std.heap.ArenaAllocator = .init(gpa); defer keep_state.deinit(); const keep = keep_state.allocator(); var patterns = try testPatterns(gpa, keep); defer { for (patterns.items) |p| { p.deinit(); gpa.destroy(p); } patterns.deinit(gpa); }
var buf: Io.Writer.Allocating = .init(gpa); defer buf.deinit(); try encode(gpa, &buf.writer, patterns.items);
var decoded: std.ArrayList(*Pattern) = .empty; defer { for (decoded.items) |p| { p.deinit(); gpa.destroy(p); } decoded.deinit(gpa); } try decode(gpa, keep, buf.written(), &decoded); try testing.expectEqual(patterns.items.len, decoded.items.len); for (patterns.items, decoded.items) |a, b| { try testing.expect(a.eql(b)); // Bindings, which `eql` does not look at. try testing.expectEqual(Pattern.Binding.weak, b.getList(.{ .builtin = .family }, 1).?.binding); } // Still shared, as they were. try testing.expectEqual( decoded.items[0].getBuiltin(.charset, 0).?.charset, decoded.items[1].getBuiltin(.charset, 0).?.charset, );
// Every truncation is refused, and leaves nothing behind. for (0..buf.written().len) |n| { try testing.expectError(error.Corrupt, decode(gpa, keep, buf.written()[0..n], &decoded)); try testing.expectEqual(patterns.items.len, decoded.items.len); }}
test "a count larger than what follows it is corrupt, not an allocation" { const gpa = testing.allocator; var keep_state: std.heap.ArenaAllocator = .init(gpa); defer keep_state.deinit(); var out: std.ArrayList(*Pattern) = .empty; defer out.deinit(gpa); // Four billion charsets, then four billion language sets. for ([_][]const u8{ "\xff\xff\xff\xff", "\x00\x00\x00\x00\xff\xff\xff\xff" }) |body| { try testing.expectError(error.Corrupt, decode(gpa, keep_state.allocator(), body, &out)); }}
test "a cache file is read back, and only for its own directory" { const gpa = testing.allocator; var keep_state: std.heap.ArenaAllocator = .init(gpa); defer keep_state.deinit(); var patterns = try testPatterns(gpa, keep_state.allocator()); defer { for (patterns.items) |p| { p.deinit(); gpa.destroy(p); } patterns.deinit(gpa); }
const stamp: Stamp = .{ .size = 1234, .mtime = -5, .inode = 99 }; var b: Builder = try .init(gpa, "/usr/share/fonts"); defer b.deinit(); try b.add("a.ttf", stamp, patterns.items); try b.add("README", stamp, &.{}); const data = try b.finish();
var c = (try Contents.parse(gpa, try gpa.dupe(u8, data), "/usr/share/fonts")).?; defer c.deinit(gpa); try testing.expectEqual(@as(u32, 2), c.files.count()); try testing.expect(c.find("a.ttf", stamp) != null); try testing.expect(c.find("a.ttf", .{ .size = 1234, .mtime = -4, .inode = 99 }) == null); try testing.expect(c.find("b.ttf", stamp) == null);
try testing.expect(try Contents.parse(gpa, try gpa.dupe(u8, data), "/usr/local/share/fonts") == null); // A single flipped bit anywhere and the file is not trusted. const flipped = try gpa.dupe(u8, data); flipped[flipped.len / 2] ^= 0x10; try testing.expect(try Contents.parse(gpa, flipped, "/usr/share/fonts") == null);}
test "a cache directory that cannot be made is an error, not a hang" { if (builtin.os.tag != .linux) return error.SkipZigTest; try testing.expectError(error.FileNotFound, openDir(testing.io, "/proc/nope/zig-font-config"));}
test "a scan prunes the cache files nothing has used for a month" { const io = testing.io; var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); const old: Io.File.SetTimestamp = .{ .new = Io.Clock.real.now(io).subDuration(max_age).subDuration(.fromSeconds(60)) }; const used = fileName("/usr/share/fonts"); for ([_][]const u8{ &used, &fileName("/nix/store/gone"), "0123456789abcdef.zfc-cache", "fresh.zfc-cache", "notes.txt" }) |name| { try tmp.dir.writeFile(io, .{ .sub_path = name, .data = "" }); if (!std.mem.eql(u8, name, "fresh.zfc-cache")) try tmp.dir.setTimestamps(io, name, .{ .modify_timestamp = old }); }
var report: Report = .{}; var s: Session = .{ .gpa = testing.allocator, .dir = tmp.dir, .refresh = false, .report = &report }; defer s.used.deinit(s.gpa); // Not a cache file, so this only marks it used. try testing.expect(try s.load(io, "/usr/share/fonts") == null); try s.prune(io);
try testing.expectEqual(@as(usize, 2), report.files_pruned); for ([_][]const u8{ &used, "fresh.zfc-cache", "notes.txt" }) |name| _ = try tmp.dir.statFile(io, name, .{}); for ([_][]const u8{ &fileName("/nix/store/gone"), "0123456789abcdef.zfc-cache" }) |name| try testing.expectError(error.FileNotFound, tmp.dir.statFile(io, name, .{}));}
test "cache file names differ by directory and carry the fingerprint" { const a = fileName("/usr/share/fonts"); const b = fileName("/usr/local/share/fonts"); try testing.expect(!std.mem.eql(u8, &a, &b)); var buf: [16]u8 = undefined; try testing.expectEqualStrings(try std.fmt.bufPrint(&buf, "{x:0>16}", .{fingerprint}), a[0..16]); try testing.expect(std.mem.endsWith(u8, &a, extension));}