lib/sys/src/font/roots.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const builtin = @import("builtin");
3 const sys = @import("../root.zig");
4
5 const env = sys.env;
6
7 const native_os = builtin.os.tag;
8
9 /// The largest number of roots a root set retains, at 24. Past it the set
10 /// refuses roots and counts them in `dropped`.
11 pub const roots_max: u8 = 24;
12
13 /// The largest total byte count a root set retains for its root paths, at 4096.
14 /// Past it the set refuses roots and counts them in `dropped`.
15 pub const root_bytes_max: usize = 4096;
16
17 /// The reason a platform reports no font roots, read by a caller that got none.
18 pub const Unsupported = enum {
19 /// `lib/sys` carries no list of font directories for this operating system.
20 platform,
21 };
22
23 /// The outcome of resolving the platform's default roots, either the count of
24 /// roots written or the reason none were, so a caller can tell an empty
25 /// platform from an empty search.
26 pub const Support = union(enum) {
27 supported: u8,
28 unsupported: Unsupported,
29 };
30
31 /// Holds the three environment values that shape the Linux list: `HOME`,
32 /// `XDG_DATA_HOME`, and `XDG_DATA_DIRS`. A program fills it from the running
33 /// process with `hostEnvironment`, while a test fills it with literals so the
34 /// search order can be checked on any machine without touching the process
35 /// environment.
36 pub const Environment = struct {
37 home: ?[]const u8 = null,
38 xdg_data_home: ?[]const u8 = null,
39 xdg_data_dirs: ?[]const u8 = null,
40 };
41
42 const Span = struct {
43 offset: u16,
44 len: u16,
45 };
46
47 /// Keeps a list of absolute font directories in inline storage, ordered as the
48 /// search visits them, which a caller holds across a resolve and a walk with no
49 /// allocation anywhere in between. Once either bound is reached the set refuses
50 /// the next directory and adds one to `dropped`, which leaves a caller whose
51 /// machine lists more directories than the bound with the prefix that fits and
52 /// a count of what fell away.
53 pub const RootSet = struct {
54 bytes: [root_bytes_max]u8 = undefined,
55 spans: [roots_max]Span = @splat(.{ .offset = 0, .len = 0 }),
56 count: u8 = 0,
57 dropped: u16 = 0,
58 used: u16 = 0,
59
60 pub fn clear(self: *RootSet) void {
61 self.count = 0;
62 self.dropped = 0;
63 self.used = 0;
64 }
65
66 pub fn path(self: *const RootSet, index: u8) []const u8 {
67 std.debug.assert(index < self.count);
68 const span = self.spans[index];
69 std.debug.assert(span.len != 0);
70 std.debug.assert(span.offset + span.len <= self.used);
71 return self.bytes[span.offset..][0..span.len];
72 }
73
74 /// Adds `directory` to the end of the list, when the path is absolute, the
75 /// list lacks it, and the bytes fit, and reports whether it went in. A
76 /// caller uses it to add a root of its own to the platform's list. Trailing
77 /// separators are trimmed before the comparison and the copy, while a
78 /// relative, empty, or duplicate path returns false and leaves `dropped`
79 /// alone so `dropped` counts capacity refusals only.
80 pub fn append(self: *RootSet, directory: []const u8) bool {
81 const trimmed = sys.path.trimTrailingSeparators(directory);
82 if (trimmed.len == 0 or !sys.path.isAbsolute(trimmed)) return false;
83 for (0..self.count) |index| {
84 if (std.mem.eql(u8, self.path(@intCast(index)), trimmed)) return false;
85 }
86 if (self.count == roots_max or trimmed.len > root_bytes_max - self.used) {
87 self.dropped +|= 1;
88 return false;
89 }
90 const offset = self.used;
91 @memcpy(self.bytes[offset..][0..trimmed.len], trimmed);
92 self.spans[self.count] = .{ .offset = offset, .len = @intCast(trimmed.len) };
93 self.count += 1;
94 self.used = offset + @as(u16, @intCast(trimmed.len));
95 std.debug.assert(self.count <= roots_max);
96 std.debug.assert(self.used <= root_bytes_max);
97 return true;
98 }
99
100 /// Joins `parent` and `child` with a single separator and adds the result.
101 /// Platform lists build most of their roots this way, joining a directory
102 /// from the environment to a fixed suffix. A join longer than the path
103 /// buffer counts a drop and returns false.
104 pub fn appendJoined(self: *RootSet, parent: []const u8, child: []const u8) bool {
105 var buffer: [std.Io.Dir.max_path_bytes]u8 = undefined;
106 const trimmed = sys.path.trimTrailingSeparators(parent);
107 const joined = std.fmt.bufPrint(&buffer, "{s}/{s}", .{ trimmed, child }) catch {
108 self.dropped +|= 1;
109 return false;
110 };
111 return self.append(joined);
112 }
113 };
114
115 /// Looks up `HOME`, `XDG_DATA_HOME`, and `XDG_DATA_DIRS` in the running
116 /// process, for a caller running against the real host.
117 pub fn hostEnvironment() Environment {
118 return .{
119 .home = env.get("HOME"),
120 .xdg_data_home = env.get("XDG_DATA_HOME"),
121 .xdg_data_dirs = env.get("XDG_DATA_DIRS"),
122 };
123 }
124
125 /// Fills `set` with the directories this machine's operating system keeps fonts
126 /// in, ordered as the search visits them. A caller enumerating host fonts
127 /// starts here.
128 pub fn defaultRoots(set: *RootSet, environment: Environment) Support {
129 return rootsFor(set, native_os, environment);
130 }
131
132 /// Fills `set` for the operating system named by `os_tag` after clearing the
133 /// set first, so a test on one host can check another platform's search order
134 /// and the refusal an unlisted one produces. Linux and the BSD targets take the
135 /// XDG order, the Apple targets take the Library order, and every other target
136 /// reports itself unsupported.
137 pub fn rootsFor(
138 set: *RootSet,
139 os_tag: std.Target.Os.Tag,
140 environment: Environment,
141 ) Support {
142 set.clear();
143 return switch (os_tag) {
144 .linux, .freebsd, .netbsd, .openbsd, .dragonfly, .illumos, .hurd => .{
145 .supported = linuxRoots(set, environment),
146 },
147 .macos, .maccatalyst, .ios, .tvos, .watchos, .visionos => .{
148 .supported = appleRoots(set, environment),
149 },
150 else => .{ .unsupported = .platform },
151 };
152 }
153
154 fn linuxRoots(set: *RootSet, environment: Environment) u8 {
155 std.debug.assert(set.count == 0);
156 appendDataHome(set, environment);
157 appendDataDirs(set, environment);
158 _ = set.append("/usr/local/share/fonts");
159 _ = set.append("/usr/share/fonts");
160 if (nonEmpty(environment.home)) |home| {
161 _ = set.appendJoined(home, ".local/share/fonts");
162 _ = set.appendJoined(home, ".fonts");
163 }
164 std.debug.assert(set.count <= roots_max);
165 return set.count;
166 }
167
168 fn appendDataHome(set: *RootSet, environment: Environment) void {
169 if (nonEmpty(environment.xdg_data_home)) |directory| {
170 _ = set.appendJoined(directory, "fonts");
171 return;
172 }
173 if (nonEmpty(environment.home)) |home| {
174 _ = set.appendJoined(home, ".local/share/fonts");
175 }
176 }
177
178 fn appendDataDirs(set: *RootSet, environment: Environment) void {
179 const configured = nonEmpty(environment.xdg_data_dirs) orelse
180 "/usr/local/share:/usr/share";
181 var entries = std.mem.splitScalar(u8, configured, ':');
182 var visited: usize = 0;
183 while (entries.next()) |entry| : (visited += 1) {
184 std.debug.assert(visited <= configured.len + 1);
185 if (entry.len == 0) continue;
186 _ = set.appendJoined(entry, "fonts");
187 }
188 }
189
190 fn appleRoots(set: *RootSet, environment: Environment) u8 {
191 std.debug.assert(set.count == 0);
192 if (nonEmpty(environment.home)) |home| {
193 _ = set.appendJoined(home, "Library/Fonts");
194 }
195 _ = set.append("/Library/Fonts");
196 _ = set.append("/System/Library/Fonts");
197 _ = set.append("/System/Library/Fonts/Supplemental");
198 _ = set.append("/Network/Library/Fonts");
199 std.debug.assert(set.count <= roots_max);
200 return set.count;
201 }
202
203 fn nonEmpty(value: ?[]const u8) ?[]const u8 {
204 const text = value orelse return null;
205 if (text.len == 0) return null;
206 return text;
207 }
208
209 fn collect(set: *const RootSet, output: [][]const u8) [][]const u8 {
210 std.debug.assert(output.len >= set.count);
211 for (0..set.count) |index| output[index] = set.path(@intCast(index));
212 return output[0..set.count];
213 }
214
215 test "linux font roots follow the XDG search order and drop duplicates" {
216 var set: RootSet = .{};
217 const support = rootsFor(&set, .linux, .{
218 .home = "/home/u",
219 .xdg_data_dirs = "/a:/b",
220 });
221 try std.testing.expectEqual(@as(u8, 6), support.supported);
222 var buffer: [roots_max][]const u8 = undefined;
223 try std.testing.expectEqualDeep(@as([]const []const u8, &.{
224 "/home/u/.local/share/fonts",
225 "/a/fonts",
226 "/b/fonts",
227 "/usr/local/share/fonts",
228 "/usr/share/fonts",
229 "/home/u/.fonts",
230 }), collect(&set, &buffer));
231 try std.testing.expectEqual(@as(u16, 0), set.dropped);
232 }
233
234 test "an empty XDG data directory list falls back to the specified default" {
235 var set: RootSet = .{};
236 const support = rootsFor(&set, .linux, .{ .xdg_data_dirs = "" });
237 try std.testing.expectEqual(@as(u8, 2), support.supported);
238 var buffer: [roots_max][]const u8 = undefined;
239 try std.testing.expectEqualDeep(@as([]const []const u8, &.{
240 "/usr/local/share/fonts",
241 "/usr/share/fonts",
242 }), collect(&set, &buffer));
243 }
244
245 test "an explicit XDG data home replaces the home relative default" {
246 var set: RootSet = .{};
247 const support = rootsFor(&set, .linux, .{
248 .home = "/home/u",
249 .xdg_data_home = "/x/",
250 .xdg_data_dirs = "/usr/share",
251 });
252 try std.testing.expectEqual(@as(u8, 5), support.supported);
253 try std.testing.expectEqualStrings("/x/fonts", set.path(0));
254 try std.testing.expectEqualStrings("/usr/share/fonts", set.path(1));
255 try std.testing.expectEqualStrings("/home/u/.local/share/fonts", set.path(3));
256 }
257
258 test "apple font roots follow the library search order" {
259 var set: RootSet = .{};
260 const support = rootsFor(&set, .macos, .{ .home = "/Users/u" });
261 try std.testing.expectEqual(@as(u8, 5), support.supported);
262 var buffer: [roots_max][]const u8 = undefined;
263 try std.testing.expectEqualDeep(@as([]const []const u8, &.{
264 "/Users/u/Library/Fonts",
265 "/Library/Fonts",
266 "/System/Library/Fonts",
267 "/System/Library/Fonts/Supplemental",
268 "/Network/Library/Fonts",
269 }), collect(&set, &buffer));
270 }
271
272 test "a platform without a font directory protocol reports it as unsupported" {
273 var set: RootSet = .{};
274 const support = rootsFor(&set, .windows, .{ .home = "C:/Users/u" });
275 try std.testing.expectEqual(Unsupported.platform, support.unsupported);
276 try std.testing.expectEqual(@as(u8, 0), set.count);
277 const freestanding = rootsFor(&set, .freestanding, .{});
278 try std.testing.expectEqual(Unsupported.platform, freestanding.unsupported);
279 }
280
281 test "the root set rejects relative roots and counts capacity drops" {
282 var set: RootSet = .{};
283 try std.testing.expect(!set.append("relative/fonts"));
284 try std.testing.expect(!set.append(""));
285 try std.testing.expectEqual(@as(u8, 0), set.count);
286
287 var long: [2001]u8 = @splat('a');
288 long[0] = '/';
289 try std.testing.expect(set.append(&long));
290 long[1] = 'b';
291 try std.testing.expect(set.append(&long));
292 long[1] = 'c';
293 try std.testing.expect(!set.append(&long));
294 try std.testing.expectEqual(@as(u8, 2), set.count);
295 try std.testing.expectEqual(@as(u16, 1), set.dropped);
296
297 set.clear();
298 var name: [8]u8 = undefined;
299 for (0..roots_max + 1) |index| {
300 const text = try std.fmt.bufPrint(&name, "/r{d}", .{index});
301 _ = set.append(text);
302 }
303 try std.testing.expectEqual(roots_max, set.count);
304 try std.testing.expectEqual(@as(u16, 1), set.dropped);
305 }
306
307 test "the host environment reports the process XDG values" {
308 const environment = hostEnvironment();
309 var set: RootSet = .{};
310 const support = defaultRoots(&set, environment);
311 switch (support) {
312 .supported => |count| try std.testing.expectEqual(count, set.count),
313 .unsupported => |reason| try std.testing.expectEqual(Unsupported.platform, reason),
314 }
315 }