use std::collections::{BTreeMap, BTreeSet};
pub fn read_string_keys(text: &str) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
for (key, value, _, _) in scan(text) {
if let Some(v) = value {
out.insert(key, v);
}
}
out
}
type Entry = (String, Option<String>, usize, usize);
fn scan(text: &str) -> Vec<Entry> {
let mut out = Vec::new();
let bytes = text.as_bytes();
let mut i = 0usize;
let mut depth = 0usize;
let mut seen_root = false;
while i < bytes.len() {
let Some(lt) = text[i..].find('<').map(|p| i + p) else {
break;
};
let Some(gt) = text[lt..].find('>').map(|p| lt + p) else {
break;
};
let tag = &text[lt + 1..gt];
let name = tag
.trim_start_matches('/')
.split(|c: char| c.is_whitespace() || c == '/' || c == '>')
.next()
.unwrap_or("");
let closing = tag.starts_with('/');
let self_closing = tag.ends_with('/');
match name {
"dict" | "array" => {
if closing {
depth = depth.saturating_sub(1);
} else if !self_closing {
if !seen_root && name == "dict" {
seen_root = true;
}
depth += 1;
}
}
"key" if depth == 1 && !closing => {
let Some(key_end) = text[gt..].find("</key>").map(|p| gt + p) else {
break;
};
let key = unescape(text[gt + 1..key_end].trim());
let after = key_end + "</key>".len();
let (value, end) = read_value(text, after);
out.push((key, value, lt, end));
i = end;
continue;
}
_ => {}
}
i = gt + 1;
}
out
}
fn read_value(text: &str, from: usize) -> (Option<String>, usize) {
let Some(lt) = text[from..].find('<').map(|p| from + p) else {
return (None, from);
};
let Some(gt) = text[lt..].find('>').map(|p| lt + p) else {
return (None, from);
};
let tag = &text[lt + 1..gt];
let name = tag
.split(|c: char| c.is_whitespace() || c == '/' || c == '>')
.next()
.unwrap_or("");
if tag.ends_with('/') {
return (None, gt + 1); }
let close = format!("</{name}>");
let end = if name == "dict" || name == "array" {
match balanced_end(text, gt + 1, name) {
Some(e) => e,
None => return (None, gt + 1),
}
} else {
match text[gt..].find(&close).map(|p| gt + p + close.len()) {
Some(e) => e,
None => return (None, gt + 1),
}
};
if name == "string" {
let inner = &text[gt + 1..end - close.len()];
(Some(unescape(inner)), end)
} else {
(None, end)
}
}
fn balanced_end(text: &str, from: usize, name: &str) -> Option<usize> {
let open = format!("<{name}");
let close = format!("</{name}>");
let mut depth = 1usize;
let mut i = from;
while depth > 0 {
let next_open = text[i..].find(&open).map(|p| i + p);
let next_close = text[i..].find(&close).map(|p| i + p)?;
match next_open {
Some(o) if o < next_close => {
let tag_end = text[o..].find('>').map(|p| o + p)?;
if !text[o..=tag_end].ends_with("/>") {
depth += 1;
}
i = tag_end + 1;
}
_ => {
depth -= 1;
i = next_close + close.len();
}
}
}
Some(i)
}
pub fn apply_string_keys(
text: &str,
set: &BTreeMap<String, String>,
remove: &BTreeSet<String>,
) -> Result<String, String> {
if !text.trim_start().starts_with("<?xml") {
return Err(
"Info.plist is not XML (a binary or JSON plist). Convert it with \
`plutil -convert xml1 <path>`, or let `day new` regenerate the scaffold."
.to_string(),
);
}
let entries = scan(text);
if entries.is_empty() && !text.contains("<dict") {
return Err("Info.plist has no root <dict>".to_string());
}
let mut out = String::with_capacity(text.len() + 256);
let mut cursor = 0usize;
let mut written: BTreeSet<&str> = BTreeSet::new();
for (key, value, start, end) in &entries {
let manageable = value.is_some();
if !manageable {
continue;
}
let line_start = text[..*start].rfind('\n').map(|p| p + 1).unwrap_or(0);
if remove.contains(key.as_str()) {
out.push_str(&text[cursor..line_start]);
cursor = skip_to_next_line(text, *end);
continue;
}
if let Some(new) = set.get(key.as_str()) {
out.push_str(&text[cursor..line_start]);
out.push_str(&entry_xml(key, new, indent_of(text, *start)));
written.insert(key.as_str());
cursor = skip_to_next_line(text, *end);
}
}
out.push_str(&text[cursor..]);
let missing: Vec<(&String, &String)> = set
.iter()
.filter(|(k, _)| !written.contains(k.as_str()))
.collect();
if !missing.is_empty() {
let anchor = out
.rfind("</dict>")
.ok_or_else(|| "Info.plist has no closing </dict>".to_string())?;
let indent = indent_of(&out, anchor) + "\t";
let mut block = String::new();
for (k, v) in missing {
block.push_str(&entry_xml(k, v, indent.clone()));
}
let line_start = out[..anchor].rfind('\n').map(|p| p + 1).unwrap_or(0);
out.insert_str(line_start, &block);
}
Ok(out)
}
pub fn apply_array_key(text: &str, key: &str, values: Option<&[String]>) -> Result<String, String> {
if !text.trim_start().starts_with("<?xml") {
return Err("Info.plist is not XML".to_string());
}
let entry = scan(text).into_iter().find(|(k, ..)| k == key);
let rendered = values.map(|v| (v, ()));
match (entry, rendered) {
(Some((_, _, start, end)), Some((v, _))) => {
let line_start = text[..start].rfind('\n').map(|p| p + 1).unwrap_or(0);
let indent = indent_of(text, start);
let mut out = String::with_capacity(text.len() + 128);
out.push_str(&text[..line_start]);
out.push_str(&array_xml(key, v, &indent));
out.push_str(&text[skip_to_next_line(text, end)..]);
Ok(out)
}
(Some((_, _, start, end)), None) => {
let line_start = text[..start].rfind('\n').map(|p| p + 1).unwrap_or(0);
let mut out = String::with_capacity(text.len());
out.push_str(&text[..line_start]);
out.push_str(&text[skip_to_next_line(text, end)..]);
Ok(out)
}
(None, Some((v, _))) => {
let anchor = text
.rfind("</dict>")
.ok_or_else(|| "Info.plist has no closing </dict>".to_string())?;
let indent = indent_of(text, anchor) + "\t";
let line_start = text[..anchor].rfind('\n').map(|p| p + 1).unwrap_or(0);
let mut out = String::with_capacity(text.len() + 128);
out.push_str(&text[..line_start]);
out.push_str(&array_xml(key, v, &indent));
out.push_str(&text[line_start..]);
Ok(out)
}
(None, None) => Ok(text.to_string()),
}
}
fn array_xml(key: &str, values: &[String], indent: &str) -> String {
let mut s = format!("{indent}<key>{}</key>\n{indent}<array>\n", escape(key));
for v in values {
s.push_str(&format!("{indent}\t<string>{}</string>\n", escape(v)));
}
s.push_str(&format!("{indent}</array>\n"));
s
}
fn entry_xml(key: &str, value: &str, indent: String) -> String {
format!(
"{indent}<key>{}</key>\n{indent}<string>{}</string>\n",
escape(key),
escape(value)
)
}
fn indent_of(text: &str, pos: usize) -> String {
let line_start = text[..pos].rfind('\n').map(|p| p + 1).unwrap_or(0);
text[line_start..pos]
.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.collect()
}
fn skip_to_next_line(text: &str, from: usize) -> usize {
match text[from..].find('\n') {
Some(p) => from + p + 1,
None => text.len(),
}
}
fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
fn unescape(s: &str) -> String {
s.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("&", "&")
}
#[cfg(test)]
mod tests {
use super::*;
const SHOWCASE: &str = include_str!("../templates/app/platform/ios/Runner/Info.plist");
fn set(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect()
}
#[test]
fn reads_top_level_string_keys() {
let keys = read_string_keys(SHOWCASE);
assert_eq!(
keys.get("CFBundleDisplayName").map(String::as_str),
Some("{{title}}")
);
assert_eq!(
keys.get("CFBundlePackageType").map(String::as_str),
Some("APPL")
);
assert!(!keys.contains_key("CFBundleURLName"));
assert!(!keys.contains_key("CFBundleURLSchemes"));
assert!(!keys.contains_key("UIAppFonts"));
assert!(!keys.contains_key("LSRequiresIPhoneOS"));
}
#[test]
fn inserts_and_replaces() {
let once = apply_string_keys(
SHOWCASE,
&set(&[("NSCameraUsageDescription", "Scan.")]),
&BTreeSet::new(),
)
.expect("apply");
assert!(once.contains("<key>NSCameraUsageDescription</key>"));
assert_eq!(
read_string_keys(&once)
.get("NSCameraUsageDescription")
.map(String::as_str),
Some("Scan.")
);
assert!(
once.contains("<string>DayPieces_DayPieces.bundle/fonts/Pacifico-Regular.ttf</string>")
);
assert!(once.contains("<key>CFBundleURLName</key>"));
let twice = apply_string_keys(
&once,
&set(&[("NSCameraUsageDescription", "Different.")]),
&BTreeSet::new(),
)
.expect("apply");
assert_eq!(
read_string_keys(&twice)
.get("NSCameraUsageDescription")
.map(String::as_str),
Some("Different.")
);
assert_eq!(
twice.matches("<key>NSCameraUsageDescription</key>").count(),
1
);
}
#[test]
fn applying_twice_is_identical() {
let keys = set(&[
("NSCameraUsageDescription", "Scan a document."),
(
"NSLocationWhenInUseUsageDescription",
"Show nearby stations.",
),
]);
let once = apply_string_keys(SHOWCASE, &keys, &BTreeSet::new()).expect("first");
let twice = apply_string_keys(&once, &keys, &BTreeSet::new()).expect("second");
assert_eq!(once, twice);
}
#[test]
fn removes_only_what_it_is_told_to() {
let with = apply_string_keys(
SHOWCASE,
&set(&[("NSCameraUsageDescription", "Scan.")]),
&BTreeSet::new(),
)
.expect("apply");
let mut remove = BTreeSet::new();
remove.insert("NSCameraUsageDescription".to_string());
let without = apply_string_keys(&with, &BTreeMap::new(), &remove).expect("remove");
assert!(!without.contains("NSCameraUsageDescription"));
assert_eq!(without, SHOWCASE);
}
#[test]
fn unmanaged_keys_are_never_touched() {
let hand_edited = SHOWCASE.replace(
"\t<key>LSRequiresIPhoneOS</key>",
"\t<key>NSContactsUsageDescription</key>\n\t<string>Find friends.</string>\n\t<key>LSRequiresIPhoneOS</key>",
);
let out = apply_string_keys(
&hand_edited,
&set(&[("NSCameraUsageDescription", "Scan.")]),
&BTreeSet::new(),
)
.expect("apply");
assert_eq!(
read_string_keys(&out)
.get("NSContactsUsageDescription")
.map(String::as_str),
Some("Find friends.")
);
}
#[test]
fn escapes_xml_in_reasons() {
let out = apply_string_keys(
SHOWCASE,
&set(&[("NSCameraUsageDescription", "Scan Tom & Jerry's <docs>")]),
&BTreeSet::new(),
)
.expect("apply");
assert!(out.contains("Scan Tom & Jerry's <docs>"));
assert_eq!(
read_string_keys(&out)
.get("NSCameraUsageDescription")
.map(String::as_str),
Some("Scan Tom & Jerry's <docs>")
);
}
#[test]
fn refuses_a_file_it_does_not_understand() {
assert!(apply_string_keys("bplist00\u{0}", &BTreeMap::new(), &BTreeSet::new()).is_err());
assert!(apply_string_keys("{\"a\": 1}", &BTreeMap::new(), &BTreeSet::new()).is_err());
}
}