From 4197789ea33552ae671304747304eab6728467c8 Mon Sep 17 00:00:00 2001 From: "nico.burns" Date: Wed, 5 Aug 2026 09:47:31 +0000 Subject: [PATCH 1/2] fontique: use getattrlistbulk for directory scanning on macOS --- Cargo.lock | 11 +++ fontique/Cargo.toml | 5 +- fontique/src/backend/coretext.rs | 47 ++++-------- fontique/src/scan.rs | 127 ++++++++++++++++++++++++------- fontique/src/scan_cache.rs | 14 ++-- 5 files changed, 135 insertions(+), 69 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 068c73682..24174c313 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1216,6 +1216,7 @@ dependencies = [ name = "fontique" version = "0.11.0" dependencies = [ + "getattrlistbulk", "hashbrown 0.17.1", "linebender_resource_handle", "memmap2", @@ -1349,6 +1350,16 @@ dependencies = [ "slab", ] +[[package]] +name = "getattrlistbulk" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e65718dfbb192923baed0b86b0e5363103570fdc39d5a2f25c572aa75144b712" +dependencies = [ + "bitflags 2.13.0", + "libc", +] + [[package]] name = "gethostname" version = "1.1.0" diff --git a/fontique/Cargo.toml b/fontique/Cargo.toml index 91c4d8180..d16382aca 100644 --- a/fontique/Cargo.toml +++ b/fontique/Cargo.toml @@ -17,7 +17,7 @@ workspace = true [features] default = ["system"] -std = ["read-fonts/std", "dep:memmap2", "parlance/std"] +std = ["read-fonts/std", "dep:memmap2", "parlance/std", "dep:getattrlistbulk"] libm = ["read-fonts/libm"] bytemuck = ["parlance/bytemuck"] # Enables support for system font backends @@ -50,6 +50,9 @@ parlance = { workspace = true } windows = { version = "0.62.2", features = ["Win32_Graphics_DirectWrite"], optional = true } windows-core = { version = "0.62.2", optional = true } +[target.'cfg(target_os = "macos")'.dependencies] +getattrlistbulk = { version = "0.1.0", optional = true } + [target.'cfg(target_vendor = "apple")'.dependencies] # FIX: Enable relax-sign-encoding to prevent the bug described in this issue: https://github.com/madsmtm/objc2/issues/566 objc2 = { version = "0.6.4", optional = true, features = ["std", "relax-sign-encoding"] } diff --git a/fontique/src/backend/coretext.rs b/fontique/src/backend/coretext.rs index 5f3a9d0be..ca89dcb8d 100644 --- a/fontique/src/backend/coretext.rs +++ b/fontique/src/backend/coretext.rs @@ -21,7 +21,7 @@ use objc2_foundation::{ NSSearchPathDirectory, NSSearchPathDomainMask, NSSearchPathForDirectoriesInDomains, }; use parlance::Script; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; const DEFAULT_GENERIC_FAMILIES: &[(GenericFamily, &[&str])] = &[ (GenericFamily::Serif, &["Times", "Times New Roman"]), @@ -121,15 +121,14 @@ fn scan_system_fonts() -> Option { continue; }; - let path = PathBuf::from(path_cf.to_string()); - if path.exists() { - paths.insert(path); - } + // Missing files are skipped by the scan itself, so no existence + // check is needed here. + paths.insert(PathBuf::from(path_cf.to_string())); } // Apple hides certain fonts from CTFontCollection (notably SFNS.ttf, the San Francisco // system UI font). Scanning Library/Fonts directories catches what CoreText omits. - paths.extend(library_font_files()); + paths.extend(library_font_dirs()); if paths.is_empty() { return None; @@ -137,9 +136,9 @@ fn scan_system_fonts() -> Option { Some(match scan_cache_path() { Some(cache_path) => { - scan::ScannedCollection::from_paths_cached(paths.iter(), 0, &cache_path) + scan::ScannedCollection::from_paths_cached(paths.iter(), 8, &cache_path) } - None => scan::ScannedCollection::from_paths(paths.iter(), 0), + None => scan::ScannedCollection::from_paths(paths.iter(), 8), }) } @@ -151,35 +150,15 @@ fn scan_cache_path() -> Option { Some(path) } -fn library_font_files() -> Vec { - let mut files = Vec::new(); - for dir in NSSearchPathForDirectoriesInDomains( +fn library_font_dirs() -> Vec { + NSSearchPathForDirectoriesInDomains( NSSearchPathDirectory::LibraryDirectory, NSSearchPathDomainMask::AllDomainsMask, true, - ) { - let font_dir = PathBuf::from(format!("{dir}/Fonts")); - if font_dir.is_dir() { - collect_files(&font_dir, 8, 0, &mut files); - } - } - files -} - -fn collect_files(dir: &Path, max_depth: u32, depth: u32, out: &mut Vec) { - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - for entry in entries.filter_map(|e| e.ok()) { - let path = entry.path(); - if path.is_dir() { - if depth < max_depth { - collect_files(&path, max_depth, depth + 1, out); - } - } else { - out.push(path); - } - } + ) + .iter() + .map(|dir| PathBuf::from(format!("{dir}/Fonts"))) + .collect() } fn create_base_font(prefer_ui_font: bool) -> CFRetained { diff --git a/fontique/src/scan.rs b/fontique/src/scan.rs index 4255d1a72..ab1955f41 100644 --- a/fontique/src/scan.rs +++ b/fontique/src/scan.rs @@ -110,7 +110,10 @@ fn scan_collection( let files = collect_font_files(paths, max_depth); let records = match cache_path { Some(cache_path) => parse_files_cached(&files, cache_path), - None => parse_files(&files), + None => { + let paths: Vec = files.into_iter().map(|(path, _)| path).collect(); + parse_files(&paths) + } }; let mut collection = ScannedCollection::default(); let mut families: HashMap)> = HashMap::default(); @@ -147,36 +150,101 @@ pub(crate) struct FontRecord { pub(crate) font: FontInfo, } -/// Expands the given paths into a deduplicated list of files, walking -/// directories up to `max_depth`. +/// Expands the given paths into a deduplicated list of files (with their +/// modification stamps, where cheaply available), walking directories up +/// to `max_depth`. #[cfg(feature = "std")] fn collect_font_files( paths: impl IntoIterator>, max_depth: u32, -) -> Vec { - fn collect(path: &Path, max_depth: u32, depth: u32, seen: &mut HashSet) { - let Ok(metadata) = path.metadata() else { +) -> Vec<(PathBuf, Option)> { + let mut seen = HashMap::default(); + for path in paths { + collect_path(path.as_ref(), max_depth, 0, &mut seen); + } + seen.into_iter().collect() +} + +#[cfg(feature = "std")] +fn collect_path( + path: &Path, + max_depth: u32, + depth: u32, + seen: &mut HashMap>, +) { + let Ok(metadata) = path.metadata() else { + return; + }; + if metadata.is_dir() { + if depth > max_depth { return; - }; - if metadata.is_dir() { - if depth > max_depth { - return; + } + walk_dir(path, max_depth, depth, seen); + } else { + seen.entry(path.to_path_buf()) + .or_insert_with(|| scan_cache::FileStamp::from_metadata(&metadata)); + } +} + +/// Collects the files in the directory at `path` (which is at `depth`), +/// recursing into subdirectories up to `max_depth`. +/// +/// On macOS, `getattrlistbulk` retrieves each entry's name, type, and +/// modification stamp in a single batched syscall, avoiding a separate +/// `stat` for every file. +#[cfg(all(feature = "std", target_os = "macos"))] +fn walk_dir( + path: &Path, + max_depth: u32, + depth: u32, + seen: &mut HashMap>, +) { + use getattrlistbulk::{ObjectType, RequestedAttributes, read_dir}; + let attrs = RequestedAttributes { + name: true, + object_type: true, + modified_time: true, + size: true, + ..Default::default() + }; + let Ok(entries) = read_dir(path, attrs) else { + return; + }; + for entry in entries.filter_map(|entry| entry.ok()) { + let child = path.join(&entry.name); + match entry.object_type { + Some(ObjectType::Directory) => { + if depth < max_depth { + walk_dir(&child, max_depth, depth + 1, seen); + } } - let Ok(entries) = std::fs::read_dir(path) else { - return; - }; - for entry in entries.filter_map(|entry| entry.ok()) { - collect(entry.path().as_path(), max_depth, depth + 1, seen); + Some(ObjectType::Regular) => { + let stamp = entry + .modified_time + .zip(entry.size) + .and_then(|(modified, size)| scan_cache::FileStamp::new(modified, size)); + seen.entry(child).or_insert(stamp); } - } else { - seen.insert(path.to_path_buf()); + // Resolve symlinks (and anything unexpected) through the + // generic path, which follows them via `metadata`. + _ => collect_path(&child, max_depth, depth + 1, seen), } } - let mut seen = HashSet::default(); - for path in paths { - collect(path.as_ref(), max_depth, 0, &mut seen); +} + +#[cfg(all(feature = "std", not(target_os = "macos")))] +fn walk_dir( + path: &Path, + max_depth: u32, + depth: u32, + seen: &mut HashMap>, +) { + let Ok(entries) = std::fs::read_dir(path) else { + return; + }; + for entry in entries.filter_map(|entry| entry.ok()) { + collect_path(entry.path().as_path(), max_depth, depth + 1, seen); } - seen.into_iter().collect() } /// Reads and parses the given font files, distributing the work across @@ -192,7 +260,10 @@ fn parse_files(files: &[PathBuf]) -> Vec { /// As [`parse_files`], but reusing results from and refreshing the cache /// stored at `cache_path`. #[cfg(feature = "std")] -fn parse_files_cached(files: &[PathBuf], cache_path: &Path) -> Vec { +fn parse_files_cached( + files: &[(PathBuf, Option)], + cache_path: &Path, +) -> Vec { let mut cache = scan_cache::load(cache_path).unwrap_or_default(); let cached_file_count = cache.len(); @@ -200,11 +271,13 @@ fn parse_files_cached(files: &[PathBuf], cache_path: &Path) -> Vec { let mut results: Vec<(&Path, Option, Vec)> = Vec::with_capacity(files.len()); let mut misses: Vec<(&Path, Option)> = Vec::new(); - for path in files { - let stamp = std::fs::metadata(path) - .ok() - .as_ref() - .and_then(scan_cache::FileStamp::from_metadata); + for (path, stamp) in files { + let stamp = stamp.or_else(|| { + std::fs::metadata(path) + .ok() + .as_ref() + .and_then(scan_cache::FileStamp::from_metadata) + }); match cache.remove(path.as_path()) { Some(cached) if stamp == Some(cached.stamp) => { results.push((path, stamp, cached.records)); diff --git a/fontique/src/scan_cache.rs b/fontique/src/scan_cache.rs index b0881cf72..d2b7b832a 100644 --- a/fontique/src/scan_cache.rs +++ b/fontique/src/scan_cache.rs @@ -39,18 +39,18 @@ pub(crate) struct FileStamp { } impl FileStamp { - pub(crate) fn from_metadata(metadata: &std::fs::Metadata) -> Option { - let mtime = metadata - .modified() - .ok()? - .duration_since(SystemTime::UNIX_EPOCH) - .ok()?; + pub(crate) fn new(modified: SystemTime, size: u64) -> Option { + let mtime = modified.duration_since(SystemTime::UNIX_EPOCH).ok()?; Some(Self { mtime_secs: mtime.as_secs(), mtime_nanos: mtime.subsec_nanos(), - size: metadata.len(), + size, }) } + + pub(crate) fn from_metadata(metadata: &std::fs::Metadata) -> Option { + Self::new(metadata.modified().ok()?, metadata.len()) + } } /// Cached scan results for a single file. From 6c76f46f27b20de581c48ead99c08a36e83137c3 Mon Sep 17 00:00:00 2001 From: "nico.burns" Date: Wed, 5 Aug 2026 09:48:07 +0000 Subject: [PATCH 2/2] docs: changelog entry for getattrlistbulk directory scanning --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 881ffcdde..b1b2d20e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ This release has an [MSRV] of 1.88. - File system font scanning (used for system font enumeration on macOS and Android) now parses font files in parallel and reads only the metadata it needs instead of memory-mapping whole files. ([#3][] by [@nicoburns][]) - System font scans on macOS are now cached in `~/Library/Caches/fontique`, keyed by file modification time and size, so unchanged font files aren't re-parsed on subsequent runs. ([#4][] by [@nicoburns][]) +- Directory scanning on macOS now uses the `getattrlistbulk` syscall to list directories and retrieve file metadata in bulk. ([#6][] by [@nicoburns][]) ### Fixed @@ -601,6 +602,7 @@ This release has an [MSRV][] of 1.70. [#213]: https://github.com/linebender/parley/pull/213 [#3]: https://github.com/DioxusLabs/parley/pull/3 [#4]: https://github.com/DioxusLabs/parley/pull/4 +[#6]: https://github.com/DioxusLabs/parley/pull/6 [#215]: https://github.com/linebender/parley/pull/215 [#223]: https://github.com/linebender/parley/pull/223 [#224]: https://github.com/linebender/parley/pull/224