diff --git a/Cargo.lock b/Cargo.lock index 9301aa0..db936e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -381,6 +381,27 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "currust" version = "2.0.1" @@ -390,6 +411,7 @@ dependencies = [ "bytemuck", "clap", "configparser", + "csv", "dialoguer", "documented", "fast_image_resize", @@ -649,6 +671,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jobserver" version = "0.1.35" @@ -1174,6 +1202,32 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "shell-words" version = "1.1.1" diff --git a/Cargo.toml b/Cargo.toml index c50108f..12412fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ binrw = "0.15.0" bytemuck = "1.25.0" clap = { version = "4.5.53", features = ["derive"] } configparser = "3.1.0" +csv = "1.4.0" dialoguer = "0.12.0" documented = "0.9.2" fast_image_resize = { version = "6.0.0", features = ["rayon"] } diff --git a/src/formats/inf.rs b/src/formats/inf.rs index bf4102f..3f10828 100644 --- a/src/formats/inf.rs +++ b/src/formats/inf.rs @@ -8,7 +8,60 @@ use crate::{ use std::{collections::HashMap, fs, path::Path}; use anyhow::{Context, Result, anyhow, bail}; -use configparser::ini::Ini; // inf is an "ini-like" format +use configparser::ini::{Ini, IniDefault}; +use csv::{ReaderBuilder, StringRecord}; // inf is an "ini-like" format + +fn inf_new() -> Ini { + let mut defaults = IniDefault::default(); + + // non-exhaustive so we gotta do this + defaults.comment_symbols = vec![';']; + defaults.delimiters = vec!['=']; + + Ini::new_from_defaults(defaults) +} + +/// [`fs::read_to_string`] with UTF16 considerations. +/// +/// Follows the BOM if present, otherwise, parses as +/// lossy UTF-8 to account for ASCII and ANSI code pages. +fn read_to_string_utf16(path: &Path) -> Result { + let mut bytes = fs::read(path)?; + + Ok(match bytes.as_slice() { + [0xff, 0xfe, rest @ ..] => String::from_utf16le(rest)?, + [0xfe, 0xff, rest @ ..] => String::from_utf16be(rest)?, + [0xef, 0xbb, 0xbf, ..] => { + bytes.drain(0..3); + String::from_utf8(bytes)? + } + _ => String::from_utf8_lossy_owned(bytes), + }) +} + +/// Reads a single record and splits fields following CSV behaviour. +/// +/// Also trims. +fn split_csv(record_str: &str) -> Result { + // TODO: Consider handling cases such as `a , "b"` becoming ["a", "\"b\""] + + let mut rdr = ReaderBuilder::new() + .has_headers(false) + .from_reader(record_str.as_bytes()); + + let Some(record) = rdr.records().next() else { + bail!("no records found") + }; + + let mut record = record?; + record.trim(); + + if rdr.records().next().is_some() { + warn!("more than one record found, returning first"); + } + + Ok(record) +} /// Attempts to parse `inf_path` as an installer file for a cursor theme. /// @@ -49,74 +102,82 @@ use configparser::ini::Ini; // inf is an "ini-like" format /// ; they're variables (in the `Strings` section), sometimes not /// ``` pub fn parse_inf_installer(inf_path: &Path) -> Result<(String, Vec)> { - let inf_string = fs::read_to_string(inf_path)?; + // TODO: Handle line continuation. Or just don't and delete this TODO. + + let inf_string = read_to_string_utf16(inf_path)?; let parent = inf_path .parent() .ok_or_else(|| anyhow!("no parent for inf_path={}", inf_path.display()))?; - let inf: HashMap>> = Ini::new() + let inf = inf_new() .read(inf_string) .map_err(|e| anyhow!("failed to read inf, error e={e}"))?; - let defaultinstall: &HashMap> = inf + let defaultinstall = inf .get("defaultinstall") .ok_or_else(|| anyhow!("no defaultinstall section found"))?; - let addreg = defaultinstall + let addreg_sections_string = defaultinstall .get("addreg") - .ok_or_else(|| anyhow!("no addreg key found in defaultinstall"))? - .as_ref() - .ok_or_else(|| anyhow!("no value for addreg key"))?; + .and_then(|v| v.as_ref()) + .ok_or_else(|| anyhow!("no addreg found in defaultinstall"))?; + + let addreg_sections = split_csv(addreg_sections_string)?; // find the right registry entries (the ones we can parse) + // // https://github.com/quantum5/win2xcur/blob/c8a390b79456a45104fe42133b9d7eb4ce7c8638/win2xcur/parser/inf.py#L47-L50 - let scheme = addreg - .split(',') + let scheme: Vec<_> = addreg_sections + .iter() .filter_map(|k| inf.get(&k.to_ascii_lowercase())) .flat_map(|v| v.keys()) - .find(|k| k.contains(r#""control panel\cursors\schemes","#)) - .ok_or_else(|| anyhow!("couldn't find cursor mappings"))?; + .filter(|k| k.contains("control panel\\cursors\\schemes")) + .collect(); + + let scheme = match scheme.as_slice() { + [] => bail!("couldn't find any cursor mappings"), + [entry] => entry, + _ => bail!("more than one cursor mapping found: {scheme:?}"), + }; let subs = inf.get("strings"); let expanded_reg = expand_scheme(scheme, subs)?; - let mut reg_info = expanded_reg.split(','); - - reg_info.next(); // root key, e.g., hkcu, hklm - reg_info.next(); // subkey - - let name = reg_info - .next() - .ok_or_else(|| anyhow!("couldn't parse theme name; reg_info doesn't have enough info"))? - .strip_prefix('"') // refrain from trim_matches; only one quote should be removed - .and_then(|n| n.strip_suffix('"')) - .ok_or_else(|| anyhow!("expected theme name to be quoted"))? - .to_string(); - - reg_info.next(); // flags - - let mut paths: Vec<_> = reg_info - .map(|s| { - s.rsplit_once('\\') - .ok_or_else(|| anyhow!("failed to extract filename from path, s={s}")) - .map(|s| s.1.to_ascii_lowercase()) + + // reg-root,[subkey],[value-entry-name],[flags],[value][,[value]] + let reg_info = split_csv(&expanded_reg)?; + + let (Some(name), Some(paths)) = (reg_info.get(2), reg_info.get(4)) else { + bail!("expected cursor registry entry to have at least five fields, reg_info={reg_info:?}"); + }; + + let name = name.to_string(); + let paths = split_csv(paths)?; + + // get filenames + let dst_filenames: Vec<_> = paths + .iter() + .map(|p| { + if p.is_empty() { + return Ok(None); + } + + p.rsplit_once('\\') + .map(|(_, filename)| Some(filename.to_ascii_lowercase())) + .ok_or_else(|| anyhow!("failed to extract filename from path, p={p}")) }) .collect::>()?; - let end = paths.len() - 1; - paths[end] = paths[paths.len() - 1] - .strip_suffix('"') - .ok_or_else(|| anyhow!("expected closing quotation for paths, didn't find it"))? - .to_string(); - - let paths = resolve_paths(&inf, defaultinstall, &paths)?; + let src_paths = resolve_paths(&inf, defaultinstall, &dst_filenames)?; - let mappings: Vec<_> = paths + let mappings: Vec<_> = src_paths .into_iter() .zip(0..15) - .map(|(p, i)| CursorMapping { - r#type: index_to_cursor_type(i), - path: parent.join(p), + .filter_map(|(path, i)| { + path.map(|p| CursorMapping { + r#type: index_to_cursor_type(i), + path: parent.join(p), + }) }) .collect(); @@ -140,8 +201,7 @@ const fn index_to_cursor_type(index: usize) -> CursorType { 12 => Move, 13 => CenterPtr, 14 => Hand, _ => unreachable!(), - // 15/16 are person and pin, which do not - // have (commonly-used) xcursor equivalents + // 15/16 are pin and person, which do not have (commonly-used) Xcursor equivalents } } @@ -149,43 +209,47 @@ const fn index_to_cursor_type(index: usize) -> CursorType { fn resolve_paths( inf: &HashMap>>, defaultinstall: &HashMap>, - paths: &[String], -) -> Result> { + paths: &[Option], +) -> Result>> { let copyfiles = defaultinstall .get("copyfiles") .cloned() .flatten() .ok_or_else(|| anyhow!("no copyfiles section"))?; + let fields = split_csv(©files)?; + // paths are coerced to lowercase because they're "keys" (from configparser's perspective). // this most likely causes some extra lookups, since the initial path most likely has // the correct casing. could be solved with Ini::new_cs() but probably isn't worth it. let mut mappings = HashMap::with_capacity(paths.len()); - for field in copyfiles.split(',') { + for field in &fields { // TODO: Implement this later. - if matches!(copyfiles.chars().next(), Some('@')) { + if matches!(field.chars().next(), Some('@')) { bail!("unsupported '@' syntax in copyfiles"); } - let field = field.trim(); - - let section = inf - .get(&field.to_ascii_lowercase()) - .ok_or_else(|| anyhow!("copyfiles specifies '{field}' should exist, but doesn't"))?; + let Some(section) = inf.get(&field.to_ascii_lowercase()) else { + warn!("copyfiles refers to section '{field}', but the section is missing"); + continue; + }; for k in section.keys() { // destination-file-name[,[source-file-name][,[unused][,flag]]] - let entry: Vec<_> = k.split(',').map(|f| f.replace('\\', "/")).collect(); + let entry = split_csv(k)?; + let mut entry = entry.iter().map(|f| f.replace('\\', "/")); - if entry.is_empty() { - bail!("empty entry in section={field}"); - } + let Some(dst) = entry.next() else { + bail!("empty entry in section={field}") + }; - if entry.len() == 1 { - mappings.insert(dequote(&entry[0]), dequote(&entry[0])); + if let Some(src) = entry.next() + && !src.is_empty() + { + mappings.insert(dst, src); } else { - mappings.insert(dequote(&entry[0]), dequote(&entry[1])); + mappings.insert(dst.clone(), dst); } } } @@ -193,12 +257,17 @@ fn resolve_paths( let mut new = Vec::with_capacity(paths.len()); for p in paths { - new.push( + let Some(p) = p else { + new.push(None); + continue; + }; + + new.push(Some( mappings - .get(p.as_str()) + .get(p) .ok_or_else(|| anyhow!("missing mapping for {p}"))? .clone(), - ); + )); } Ok(new) @@ -222,30 +291,23 @@ fn expand_scheme(reg: &str, subs: Option<&HashMap>>) -> R expand(reg, &subs).with_context(|| format!("for input reg={reg}")) } -/// Dequotes following INF spec. -/// -/// The INF parser not only discards the outermost pair of enclosing double quotation -/// marks for any "quoted string" in this section, but also condenses each subsequent -/// sequential pair of double quotation marks into a single double quotation marks character. -/// -/// For example, """some string""" also becomes "some string" when it is parsed. -fn dequote(input: &str) -> String { - let mut input = input.trim(); - - if input.starts_with('"') && input.ends_with('"') && input.len() >= 2 { - input = &input[1..(input.len() - 1)]; - } - - input.replace("\"\"", "\"") -} - /// Helper function for [`expand_scheme`] to remove the outer pair of quotes. /// /// This is because [`configparser`] takes _everything_ as a string, /// for example: `key = "value"` means `config["key"] == "\"value\""`. fn dequote_value(entry: (&String, &Option)) -> Option<(String, String)> { match entry { - (k, Some(v)) => Some((k.clone(), dequote(v))), + (k, Some(v)) => { + let value = v.trim(); + + let value = value + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .unwrap_or(value) + .replace("\"\"", "\""); + + Some((k.clone(), value)) + } (k, None) => { // side effect but shhh warn!("key={k} has value None"); @@ -282,7 +344,7 @@ fn expand(input: &str, subs: &HashMap) -> Result { .or_else(|| (key == "%%").then_some("%")) .or_else(|| { if key.chars().all(|c| c.is_ascii_digit() || c == '%') { - // let's just assume it's a DIRID and leave it :) + // let's just assume it's a DIRID and leave it, ok? Some(key) } else { None