use std::fmt::{self, Write as _};
use quick_xml::XmlVersion;
use quick_xml::escape::resolve_predefined_entity;
use quick_xml::events::{BytesStart, Event};
use quick_xml::name::{PrefixDeclaration, ResolveResult};
use quick_xml::reader::NsReader;
use super::{
ANDROID_NAMESPACE, AttrName, Attribute, Document, Element, MAX_DEPTH, Namespace, Node, Step, XmlError, walk,
};
use crate::android::{self, AttrDef, AttrFormat};
use crate::res::{ResourceRef, ResourceType, Value};
const TOOLS_NAMESPACE: &str = "http://schemas.android.com/tools";
impl Document {
pub fn parse(text: &str) -> Result<Self, XmlError> {
let mut reader = NsReader::from_str(text);
reader.config_mut().expand_empty_elements = true;
let mut open: Vec<Element> = Vec::new();
let mut root = None;
let mut skipping = 0usize;
loop {
let (namespace, event) = reader.read_resolved_event()?;
let namespace = match namespace {
ResolveResult::Bound(namespace) => Some(namespace.0),
ResolveResult::Unbound => None,
ResolveResult::Unknown(prefix) => return Err(XmlError::UndeclaredPrefix { prefix }),
};
match event {
Event::Start(start) => {
if skipping > 0 || namespace == Some(TOOLS_NAMESPACE) {
skipping += 1;
} else if open.len() >= MAX_DEPTH {
return Err(XmlError::TooDeep);
} else if root.is_some() && open.is_empty() {
return Err(XmlError::MultipleRoots);
} else {
let namespace = namespace.map(str::to_owned);
open.push(shell(&reader, &start, namespace)?);
}
}
Event::End(_) if skipping > 0 => skipping -= 1,
Event::End(end) => {
let found = || end.local_name().as_ref().to_owned();
let mut element = open.pop().ok_or_else(|| XmlError::UnexpectedEnd { found: found() })?;
element.children.retain(|child| !matches!(child, Node::Text(text) if text.trim().is_empty()));
match open.last_mut() {
Some(parent) => parent.children.push(Node::Element(element)),
None => root = Some(element),
}
}
Event::Text(text) if skipping == 0 => push_text(&mut open, &text.xml10_content()),
Event::CData(data) if skipping == 0 => push_text(&mut open, &data.xml10_content()),
Event::GeneralRef(reference) if skipping == 0 => match reference.resolve_char_ref()? {
Some(character) => push_text(&mut open, character.encode_utf8(&mut [0; 4])),
None => {
let name = reference.xml10_content();
let text = resolve_predefined_entity(&name);
push_text(&mut open, text.ok_or_else(|| XmlError::UnknownEntity { name: name.into_owned() })?);
}
},
Event::Eof => break,
_ => {}
}
}
if !open.is_empty() {
return Err(XmlError::Unclosed { count: open.len() });
}
root.map(Self::new).ok_or(XmlError::NoRoot)
}
}
fn push_text(open: &mut [Element], text: &str) {
let Some(element) = open.last_mut() else { return };
match element.children.last_mut() {
Some(Node::Text(previous)) => previous.push_str(text),
_ => element.children.push(Node::Text(text.to_owned())),
}
}
fn shell(reader: &NsReader<&[u8]>, start: &BytesStart<'_>, namespace: Option<String>) -> Result<Element, XmlError> {
let name = start.local_name().as_ref().to_owned();
let mut element = Element { name, namespace, ..Element::default() };
for attribute in start.attributes() {
let attribute = attribute.map_err(quick_xml::Error::from)?;
let text = attribute.normalized_value(XmlVersion::Implicit1_0)?;
if let Some(binding) = attribute.key.as_namespace_binding() {
let prefix = match binding {
PrefixDeclaration::Default => None,
PrefixDeclaration::Named(prefix) => Some(prefix.to_owned()),
};
if text != TOOLS_NAMESPACE && prefix.as_deref() != Some("xml") && !(prefix.is_none() && text.is_empty()) {
element.namespaces.push(Namespace { prefix, uri: text.into_owned() });
}
continue;
}
let (resolved, local) = reader.resolver().resolve_attribute(attribute.key);
let local = local.as_ref().to_owned();
let name = match resolved {
ResolveResult::Unknown(prefix) => return Err(XmlError::UndeclaredPrefix { prefix }),
ResolveResult::Bound(namespace) if namespace.as_ref() == TOOLS_NAMESPACE => continue,
ResolveResult::Bound(namespace) if namespace.as_ref() == ANDROID_NAMESPACE => {
let def = android::find_attr(&local).ok_or(XmlError::UnknownAttribute { name: local })?;
element.set_attr(def, def.parse(&text)?);
continue;
}
ResolveResult::Bound(namespace) => {
AttrName { namespace: Some(namespace.as_ref().to_owned()), name: local, id: None }
}
ResolveResult::Unbound => AttrName::plain(local),
};
let value = match is_style(&name) {
true if text.trim_start().starts_with(['@', '?']) => reference(&text)?,
true => Value::String(text.strip_prefix('\\').unwrap_or(&text).to_owned()),
false => Value::String(text.into_owned()),
};
element.set_attr(name, value);
}
Ok(element)
}
fn is_style(name: &AttrName) -> bool {
name.namespace.is_none() && name.name == "style"
}
fn reference(text: &str) -> Result<Value, XmlError> {
let trimmed = text.trim();
Ok(match trimmed {
"@null" => Value::Null,
"@empty" => Value::Empty,
_ => match trimmed.strip_prefix('?') {
Some(body) => Value::Attribute(ResourceRef::parse_body(body, Some(ResourceType::Attr))?),
None => {
let body = trimmed.strip_prefix('@').unwrap_or(trimmed).trim_start_matches('+');
Value::Reference(ResourceRef::parse_body(body, None)?)
}
},
})
}
impl AttrDef {
pub fn parse(&self, text: &str) -> Result<Value, XmlError> {
let trimmed = text.trim();
if trimmed.starts_with(['@', '?']) {
return reference(text);
}
let format = self.format();
if let Some(value) = self.parse_typed(trimmed, format) {
return Ok(value);
}
if format.contains(AttrFormat::STRING) {
return Ok(Value::String(text.strip_prefix('\\').unwrap_or(text).to_owned()));
}
Err(XmlError::InvalidAttributeValue { attribute: self.name(), value: text.to_owned(), format })
}
fn parse_typed(&self, text: &str, format: AttrFormat) -> Option<Value> {
let number = || match text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
Some(hex) => u32::from_str_radix(hex, 16).ok().map(Value::Hex),
None => text.parse().ok().map(Value::Int),
};
if format.contains(AttrFormat::ENUM)
&& let Some(value) = self.symbol(text)
{
return Some(Value::Int(value as i32));
}
if format.contains(AttrFormat::FLAGS)
&& let Some(bits) = text.split('|').try_fold(0, |bits, flag| Some(bits | self.symbol(flag.trim())?))
{
return Some(Value::Hex(bits));
}
if format.intersects(AttrFormat::ENUM | AttrFormat::FLAGS)
&& let Some(value) = number()
{
return Some(match value {
Value::Int(value) if format.contains(AttrFormat::FLAGS) => Value::Hex(value as u32),
value => value,
});
}
if format.contains(AttrFormat::BOOLEAN) {
match text {
"true" | "True" | "TRUE" => return Some(Value::Bool(true)),
"false" | "False" | "FALSE" => return Some(Value::Bool(false)),
_ => {}
}
}
if format.contains(AttrFormat::INTEGER)
&& let Some(value) = number()
{
return Some(value);
}
if format.contains(AttrFormat::COLOR)
&& let Ok(color) = text.parse()
{
return Some(Value::Color(color));
}
if format.contains(AttrFormat::DIMENSION)
&& let Ok(dimension) = text.parse()
{
return Some(Value::Dimension(dimension));
}
if format.contains(AttrFormat::FRACTION)
&& let Ok(fraction) = text.parse()
{
return Some(Value::Fraction(fraction));
}
if format.contains(AttrFormat::FLOAT)
&& let Ok(float) = text.parse()
{
return Some(Value::Float(float));
}
None
}
}
impl fmt::Display for Document {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, r#"<?xml version="1.0" encoding="utf-8"?>"#)?;
let mut printer = Printer { f, scope: Vec::new(), marks: Vec::new(), inline_from: None };
walk(&self.root).try_for_each(|(depth, step)| printer.step(depth, step))
}
}
struct Binding<'a> {
prefix: Option<std::borrow::Cow<'a, str>>,
uri: &'a str,
}
struct Printer<'a, 'f, 'g> {
f: &'f mut fmt::Formatter<'g>,
scope: Vec<Binding<'a>>,
marks: Vec<usize>,
inline_from: Option<usize>,
}
impl<'a> Printer<'a, '_, '_> {
fn step(&mut self, depth: usize, step: Step<'a>) -> fmt::Result {
match step {
Step::Open(element) => self.open(depth, element),
Step::Text(text) => write!(self.f, "{}", Escaped { text, attribute: false }),
Step::Close(element) => self.close(depth, element),
}
}
fn open(&mut self, depth: usize, element: &'a Element) -> fmt::Result {
let inline = self.inline_from.is_some();
if !inline {
indent(self.f, depth)?;
}
self.marks.push(self.scope.len());
let declared = self.scope.len();
self.scope.extend(
element.namespaces.iter().map(|ns| Binding { prefix: ns.prefix.as_deref().map(Into::into), uri: &ns.uri }),
);
let default = self.scope.iter().rev().find(|b| b.prefix.is_none());
if element.namespace.is_none() && default.is_some_and(|b| !b.uri.is_empty()) {
self.scope.push(Binding { prefix: None, uri: "" });
}
let attributes = element.attributes.iter().filter_map(|a| Some((a.name.namespace.as_deref()?, true)));
for (uri, attribute) in element.namespace.as_deref().map(|uri| (uri, false)).into_iter().chain(attributes) {
if prefix(&self.scope, uri, attribute).is_none() {
let bound = |name: &str| self.scope.iter().any(|b| b.prefix.as_deref() == Some(name));
let prefix = match uri == ANDROID_NAMESPACE && !bound("android") {
true => "android".into(),
false => (0..).map(|n| format!("ns{n}")).find(|name| !bound(name)).expect("unbounded").into(),
};
self.scope.push(Binding { prefix: Some(prefix), uri });
}
}
write!(self.f, "<{}", qualified(&self.scope, element.namespace.as_deref(), &element.name, false))?;
let own_lines = !inline && self.scope.len() - declared + element.attributes.len() > 1;
let separator = |f: &mut fmt::Formatter<'_>| match own_lines {
true => f.write_char('\n').and_then(|()| indent(f, depth + 1)),
false => f.write_char(' '),
};
for binding in &self.scope[declared..] {
separator(self.f)?;
let uri = Escaped { text: binding.uri, attribute: true };
match &binding.prefix {
Some(prefix) => write!(self.f, "xmlns:{prefix}=\"{uri}\"")?,
None => write!(self.f, "xmlns=\"{uri}\"")?,
}
}
for attribute in &element.attributes {
separator(self.f)?;
let name = qualified(&self.scope, attribute.name.namespace.as_deref(), &attribute.name.name, true);
write!(self.f, "{name}=\"")?;
write_value(self.f, attribute)?;
self.f.write_char('"')?;
}
if element.children.is_empty() {
self.f.write_str(" />")?;
self.scope.truncate(self.marks.pop().expect("pushed above"));
return if inline { Ok(()) } else { self.f.write_char('\n') };
}
self.f.write_char('>')?;
if inline {
return Ok(());
}
if element.children.iter().any(|child| matches!(child, Node::Text(_))) {
self.inline_from = Some(depth);
return Ok(());
}
self.f.write_char('\n')
}
fn close(&mut self, depth: usize, element: &'a Element) -> fmt::Result {
if element.children.is_empty() {
return Ok(());
}
if self.inline_from.is_none() {
indent(self.f, depth)?;
}
write!(self.f, "</{}>", qualified(&self.scope, element.namespace.as_deref(), &element.name, false))?;
self.scope.truncate(self.marks.pop().expect("opened before"));
if self.inline_from.is_some_and(|from| from < depth) {
return Ok(());
}
self.inline_from = None;
self.f.write_char('\n')
}
}
fn indent(f: &mut fmt::Formatter<'_>, depth: usize) -> fmt::Result {
(0..depth).try_for_each(|_| f.write_str(" "))
}
fn prefix<'s>(scope: &'s [Binding<'_>], uri: &str, attribute: bool) -> Option<Option<&'s str>> {
let current = |prefix: Option<&str>| scope.iter().rev().find(|b| b.prefix.as_deref() == prefix).map(|b| b.uri);
scope
.iter()
.rev()
.filter(|b| b.uri == uri && !(attribute && b.prefix.is_none()))
.map(|b| b.prefix.as_deref())
.find(|&prefix| current(prefix) == Some(uri))
}
fn qualified<'s, 'n>(
scope: &'s [Binding<'_>],
namespace: Option<&str>,
name: &'n str,
attribute: bool,
) -> Qualified<'s, 'n> {
let prefix = namespace.and_then(|uri| prefix(scope, uri, attribute)).flatten();
Qualified { prefix, name }
}
struct Qualified<'p, 'n> {
prefix: Option<&'p str>,
name: &'n str,
}
impl fmt::Display for Qualified<'_, '_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.prefix {
Some(prefix) => write!(f, "{prefix}:{}", self.name),
None => f.write_str(self.name),
}
}
}
fn write_value(f: &mut fmt::Formatter<'_>, attribute: &Attribute) -> fmt::Result {
let escaped = |text| Escaped { text, attribute: true };
let def = attribute.name.android_def();
let typed = |s: &str| {
(s.trim_start().starts_with(['@', '?', '\\']) && (def.is_some() || is_style(&attribute.name)))
|| def.is_some_and(|def| def.parse_typed(s.trim(), def.format()).is_some())
};
match (&attribute.value, def) {
(Value::Int(value), Some(def)) if def.format().contains(AttrFormat::ENUM) => match def.enum_name(*value as u32)
{
Some(name) => f.write_str(name),
None => write!(f, "{value}"),
},
(Value::Hex(value), Some(def)) if def.format().contains(AttrFormat::FLAGS) => match def.flag_names(*value) {
Some(names) if !names.is_empty() => f.write_str(&names.join("|")),
_ => match def.enum_name(*value) {
Some(name) => f.write_str(name),
None => write!(f, "{}", attribute.value),
},
},
(Value::Reference(ResourceRef::Id(id)), _) => match android::resource_name(*id) {
Some((ty, name)) => write!(f, "@android:{ty}/{name}"),
None => write!(f, "{}", attribute.value),
},
(Value::String(s), _) if typed(s) => write!(f, "\\{}", escaped(s)),
(Value::String(s), _) => write!(f, "{}", escaped(s)),
(value, _) => write!(f, "{}", escaped(&value.to_string())),
}
}
struct Escaped<'a> {
text: &'a str,
attribute: bool,
}
impl fmt::Display for Escaped<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut rest = self.text;
while let Some(at) = rest.find(|c: char| {
matches!(c, '&' | '<' | '>' | '"')
|| (self.attribute && c.is_ascii_whitespace() && c != ' ')
|| (c.is_control() && !matches!(c, '\t' | '\n'))
}) {
f.write_str(&rest[..at])?;
let c = rest[at..].chars().next().expect("found a character");
match c {
'&' => f.write_str("&")?,
'<' => f.write_str("<")?,
'>' => f.write_str(">")?,
'"' => f.write_str(""")?,
c => write!(f, "&#x{:x};", u32::from(c))?,
}
rest = &rest[at + c.len_utf8()..];
}
f.write_str(rest)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::android::attr;
const MANIFEST: &str = r#"<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="org.example.game"
android:versionCode="0x10"
android:versionName="1.0">
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="35" />
<application android:label="@string/app_name"
android:hasCode="false"
android:theme="@android:style/Theme.Material.NoActionBar"
tools:ignore="GoogleAppIndexingWarning">
<activity android:name="android.app.NativeActivity"
android:screenOrientation="landscape"
android:configChanges="orientation|keyboardHidden|screenSize"
android:windowSoftInputMode="stateHidden|adjustResize"
android:exported="true">
<meta-data android:name="android.app.lib_name" android:value="\@game" />
</activity>
</application>
</manifest>"#;
#[test]
fn types_framework_attributes_like_aapt2() {
let document = Document::parse(MANIFEST).unwrap();
let root = &document.root;
assert_eq!(root.attr(None, "package"), Some(&Value::String("org.example.game".into())));
assert_eq!(root.android_attr(attr::VERSION_CODE), Some(&Value::Hex(0x10)));
let application = root.element("application").unwrap();
assert_eq!(application.android_attr(attr::HAS_CODE), Some(&Value::Bool(false)));
assert!(application.attributes.iter().all(|a| a.name.namespace.as_deref() != Some(TOOLS_NAMESPACE)));
let activity = application.element("activity").unwrap();
assert_eq!(activity.android_attr(attr::SCREEN_ORIENTATION), Some(&Value::Int(0)));
assert_eq!(activity.android_attr(attr::CONFIG_CHANGES), Some(&Value::Hex(0x80 | 0x20 | 0x400)));
assert_eq!(activity.android_attr(attr::WINDOW_SOFT_INPUT_MODE), Some(&Value::Hex(0x12)));
let meta = activity.element("meta-data").unwrap();
assert_eq!(meta.android_attr(attr::VALUE), Some(&Value::String("@game".into())));
}
#[test]
fn printing_and_reparsing_is_stable() {
let document = Document::parse(MANIFEST).unwrap();
let printed = document.to_string();
assert!(printed.contains(r#"android:screenOrientation="landscape""#), "{printed}");
assert!(printed.contains(r#"android:windowSoftInputMode="adjustResize|stateHidden""#), "{printed}");
assert_eq!(Document::parse(&printed).unwrap(), document);
}
#[test]
fn mixed_content_and_literals_survive_printing() {
for text in [
"<a>Hello <b>world</b>!</a>",
r#"<a src="@literal" note="tab	and
newline"/>"#,
r#"<View xmlns:android="http://schemas.android.com/apk/res/android" style="@android:style/Theme.Material"/>"#,
r#"<a xmlns:x="urn:one"><b xmlns:x="urn:two" xmlns:y="urn:one" y:attr="1"/></a>"#,
] {
let document = Document::parse(text).unwrap();
let printed = document.to_string();
assert_eq!(Document::parse(&printed).unwrap(), document, "{printed}");
}
let style = Document::parse(r#"<View style="@android:style/Theme.Material"/>"#).unwrap();
assert!(matches!(style.root.attr(None, "style"), Some(Value::Reference(_))));
}
#[test]
fn printed_literals_keep_their_types() {
let document = Document::new(
Element::new("meta-data")
.with_android_namespace()
.with_attr(attr::VALUE, "123")
.with_attr(attr::LABEL, " @x")
.with_attr("style", "@literal")
.with_attr("note", "carriage\rreturn"),
);
let printed = document.to_string();
assert!(printed.contains(r#"android:value="\123""#), "{printed}");
assert_eq!(Document::parse(&printed).unwrap(), document, "{printed}");
let float =
Document::new(Element::new("meta-data").with_android_namespace().with_attr(attr::VALUE, Value::Float(2.0)));
assert_eq!(Document::parse(&float.to_string()).unwrap(), float);
let text = Document::new(Element::new("a").with_child(Element::new("b")).with_attr("x", "y"));
let mut text = text;
text.root.children.push(Node::Text("line\r\n".into()));
assert_eq!(Document::parse(&text.to_string()).unwrap(), text);
}
#[test]
fn printed_colors_preserve_their_literal_type() {
for literal in ["#f00", "#8f00", "#123456", "#80123456"] {
let color = literal.parse::<crate::res::Color>().unwrap();
assert_eq!(color.to_string(), literal);
let document =
Document::new(Element::new("meta-data").with_android_namespace().with_attr(attr::VALUE, color));
let printed = document.to_string();
assert_eq!(Document::parse(&printed).unwrap(), document, "{printed}");
}
}
#[test]
fn unqualified_children_leave_a_default_namespace() {
let mut root = Element::new("a");
root.namespace = Some("urn:default".into());
root.namespaces.push(Namespace { prefix: None, uri: "urn:default".into() });
let document = Document::new(root.with_child(Element::new("b")));
let printed = document.to_string();
assert!(printed.contains(r#"<b xmlns="" />"#), "{printed}");
assert_eq!(Document::parse(&printed).unwrap(), document);
}
#[test]
fn attributes_parse_into_binary_order() {
let text = r#"<a xmlns:android="http://schemas.android.com/apk/res/android" z="1" android:name="n" android:label="l"/>"#;
let document = Document::parse(text).unwrap();
let names: Vec<_> = document.root.attributes.iter().map(|a| a.name.name.as_str()).collect();
assert_eq!(names, ["label", "name", "z"]);
assert_eq!(Document::decode(&document.encode(&()).unwrap()).unwrap(), document);
}
#[test]
fn undeclared_namespaces_get_unused_prefixes() {
let mut element = Element::new("a");
element.namespaces.push(Namespace { prefix: Some("ns0".into()), uri: "urn:user".into() });
element.set_attr(AttrName { namespace: Some("urn:user".into()), name: "x".into(), id: None }, "1");
element.set_attr(AttrName { namespace: Some("urn:other".into()), name: "y".into(), id: None }, "2");
let printed = Document::new(element).to_string();
let reparsed = Document::parse(&printed).unwrap();
assert_eq!(reparsed.root.attr(Some("urn:user"), "x"), Some(&Value::String("1".into())), "{printed}");
assert_eq!(reparsed.root.attr(Some("urn:other"), "y"), Some(&Value::String("2".into())), "{printed}");
}
#[test]
fn handles_the_full_depth_limit_without_recursion() {
let depth = MAX_DEPTH;
let text = format!("{}{}", "<a>".repeat(depth), "</a>".repeat(depth));
let document = Document::parse(&text).unwrap();
let decoded = Document::decode(&document.encode(&()).unwrap()).unwrap();
assert_eq!(decoded, document);
assert_eq!(document.to_string().matches("<a").count(), depth);
let too_deep = format!("{}{}", "<a>".repeat(MAX_DEPTH + 1), "</a>".repeat(MAX_DEPTH + 1));
assert!(matches!(Document::parse(&too_deep), Err(XmlError::TooDeep)));
}
#[test]
fn rejects_unknown_entities_and_prefixes() {
assert!(
matches!(Document::parse("<a>&custom;</a>"), Err(XmlError::UnknownEntity { name }) if name == "custom")
);
assert!(matches!(Document::parse(r#"<a x:y="1"/>"#), Err(XmlError::UndeclaredPrefix { .. })));
assert_eq!(Document::parse("<a><A&</a>").unwrap().root.children, [Node::Text("<A&".into())]);
}
#[test]
fn values_without_symbols_print_and_parse_back() {
let document = Document::new(
Element::new("activity")
.with_android_namespace()
.with_attr(attr::CONFIG_CHANGES, Value::Hex(0))
.with_attr(attr::LAUNCH_MODE, Value::Int(42)),
);
let printed = document.to_string();
assert!(printed.contains(r#"android:configChanges="0x00000000""#), "{printed}");
assert_eq!(Document::parse(&printed).unwrap(), document);
}
#[test]
fn rejects_values_outside_the_attribute_format() {
let parse = |attribute: &str| {
Document::parse(&format!(r#"<a xmlns:android="http://schemas.android.com/apk/res/android" {attribute}/>"#))
};
assert!(matches!(
parse(r#"android:exported="yes""#),
Err(XmlError::InvalidAttributeValue { attribute: "exported", .. })
));
assert!(matches!(parse(r#"android:launchMode="sometimes""#), Err(XmlError::InvalidAttributeValue { .. })));
assert!(matches!(parse(r#"android:notReal="1""#), Err(XmlError::UnknownAttribute { .. })));
}
}