Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1 | //! Just enough XML for package files: a Maven POM and a NuGet `.nuspec` |
| 2 | //! are read into a tree of elements (names without their namespace | |
| 3 | //! prefix), and the metadata Maven reads is written with `escape`. | |
| 4 | //! Declarations, comments, doctypes and processing instructions are | |
| 5 | //! skipped; CDATA and the standard entities are read. | |
| 6 | ||
| 7 | #[derive(Clone, Debug, Default, PartialEq, Eq)] | |
| 8 | pub struct Element { | |
| 9 | /// The local name: `package` for `<ns:package>`. | |
| 10 | pub name: String, | |
| 11 | pub attributes: Vec<(String, String)>, | |
| 12 | pub children: Vec<Element>, | |
| 13 | /// The element's own text, its parts joined. | |
| 14 | pub text: String, | |
| 15 | } | |
| 16 | ||
| 17 | impl Element { | |
| 18 | /// The first child named `name`. | |
| 19 | pub fn child(&self, name: &str) -> Option<&Element> { | |
| 20 | self.children.iter().find(|c| c.name == name) | |
| 21 | } | |
| 22 | ||
| 23 | pub fn children_named<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a Element> + 'a { | |
| 24 | self.children.iter().filter(move |c| c.name == name) | |
| 25 | } | |
| 26 | ||
| 27 | /// The trimmed text of the first child named `name`, when it has some. | |
| 28 | pub fn child_text(&self, name: &str) -> Option<String> { | |
| 29 | self.child(name).map(|c| c.text.trim().to_owned()).filter(|t| !t.is_empty()) | |
| 30 | } | |
| 31 | ||
| 32 | pub fn attribute(&self, name: &str) -> Option<&str> { | |
| 33 | self.attributes.iter().find(|(key, _)| key == name).map(|(_, value)| value.as_str()) | |
| 34 | } | |
| 35 | } | |
| 36 | ||
| 37 | fn local(name: &str) -> String { | |
| 38 | name.rsplit(':').next().unwrap_or(name).to_owned() | |
| 39 | } | |
| 40 | ||
| 41 | /// Text with its entities read: `&` is `&`, `A` is `A`. | |
| 42 | fn unescape(text: &str) -> String { | |
| 43 | let mut out = String::with_capacity(text.len()); | |
| 44 | let mut rest = text; | |
| 45 | while let Some(at) = rest.find('&') { | |
| 46 | out.push_str(&rest[..at]); | |
| 47 | rest = &rest[at..]; | |
| 48 | let Some(end) = rest.find(';').filter(|end| *end <= 12) else { | |
| 49 | out.push('&'); | |
| 50 | rest = &rest[1..]; | |
| 51 | continue; | |
| 52 | }; | |
| 53 | let entity = &rest[1..end]; | |
| 54 | let read = match entity { | |
| 55 | "amp" => Some('&'), | |
| 56 | "lt" => Some('<'), | |
| 57 | "gt" => Some('>'), | |
| 58 | "quot" => Some('"'), | |
| 59 | "apos" => Some('\''), | |
| 60 | _ => entity | |
| 61 | .strip_prefix("#x") | |
| 62 | .or_else(|| entity.strip_prefix("#X")) | |
| 63 | .and_then(|hex| u32::from_str_radix(hex, 16).ok()) | |
| 64 | .or_else(|| entity.strip_prefix('#').and_then(|n| n.parse().ok())) | |
| 65 | .and_then(char::from_u32), | |
| 66 | }; | |
| 67 | match read { | |
| 68 | Some(c) => { | |
| 69 | out.push(c); | |
| 70 | rest = &rest[end + 1..]; | |
| 71 | } | |
| 72 | None => { | |
| 73 | out.push('&'); | |
| 74 | rest = &rest[1..]; | |
| 75 | } | |
| 76 | } | |
| 77 | } | |
| 78 | out.push_str(rest); | |
| 79 | out | |
| 80 | } | |
| 81 | ||
| 82 | /// Text made safe to put between tags or in an attribute. | |
| 83 | pub fn escape(text: &str) -> String { | |
| 84 | text.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) | |
| 85 | } | |
| 86 | ||
| 87 | fn attributes(text: &str) -> Result<Vec<(String, String)>, String> { | |
| 88 | let mut list = Vec::new(); | |
| 89 | let mut rest = text.trim(); | |
| 90 | while !rest.is_empty() { | |
| 91 | let eq = rest.find('=').ok_or("An attribute has no value.")?; | |
| 92 | let key = rest[..eq].trim(); | |
| 93 | let after = rest[eq + 1..].trim_start(); | |
| 94 | let quote = after.chars().next().filter(|c| *c == '"' || *c == '\'').ok_or("An attribute's value is not quoted.")?; | |
| 95 | let close = after[1..].find(quote).ok_or("An attribute's value is not closed.")? + 1; | |
| 96 | list.push((local(key), unescape(&after[1..close]))); | |
| 97 | rest = after[close + 1..].trim_start(); | |
| 98 | } | |
| 99 | Ok(list) | |
| 100 | } | |
| 101 | ||
| 102 | /// Reads a document into its root element. | |
| 103 | pub fn parse(text: &str) -> Result<Element, String> { | |
| 104 | let text = text.trim_start_matches('\u{feff}'); | |
| 105 | let mut stack: Vec<Element> = Vec::new(); | |
| 106 | let mut root = None; | |
| 107 | let mut rest = text; | |
| 108 | while !rest.is_empty() { | |
| 109 | let Some(open) = rest.find('<') else { | |
| 110 | if let Some(top) = stack.last_mut() { | |
| 111 | top.text.push_str(&unescape(rest)); | |
| 112 | } | |
| 113 | break; | |
| 114 | }; | |
| 115 | if open > 0 | |
| 116 | && let Some(top) = stack.last_mut() { | |
| 117 | top.text.push_str(&unescape(&rest[..open])); | |
| 118 | } | |
| 119 | rest = &rest[open..]; | |
| 120 | let skip = |rest: &str, end: &str| rest.find(end).map(|at| at + end.len()).ok_or_else(|| "The XML ends early.".to_owned()); | |
| 121 | if rest.starts_with("<?") { | |
| 122 | rest = &rest[skip(rest, "?>")?..]; | |
| 123 | } else if rest.starts_with("<!--") { | |
| 124 | rest = &rest[skip(rest, "-->")?..]; | |
| 125 | } else if let Some(cdata) = rest.strip_prefix("<![CDATA[") { | |
| 126 | let end = cdata.find("]]>").ok_or("The XML ends early.")?; | |
| 127 | if let Some(top) = stack.last_mut() { | |
| 128 | top.text.push_str(&cdata[..end]); | |
| 129 | } | |
| 130 | rest = &cdata[end + 3..]; | |
| 131 | } else if rest.starts_with("<!") { | |
| 132 | rest = &rest[skip(rest, ">")?..]; | |
| 133 | } else if let Some(closing) = rest.strip_prefix("</") { | |
| 134 | let end = closing.find('>').ok_or("The XML ends early.")?; | |
| 135 | let name = local(closing[..end].trim()); | |
| 136 | let element = stack.pop().ok_or("A tag is closed that was not opened.")?; | |
| 137 | if element.name != name { | |
| 138 | return Err(format!("<{}> is closed by </{name}>.", element.name)); | |
| 139 | } | |
| 140 | match stack.last_mut() { | |
| 141 | Some(parent) => parent.children.push(element), | |
| 142 | None => root = Some(element), | |
| 143 | } | |
| 144 | rest = &closing[end + 1..]; | |
| 145 | } else { | |
| 146 | // An opening tag; `>` inside a quoted attribute does not end it. | |
| 147 | let mut quote = None; | |
| 148 | let end = rest | |
| 149 | .char_indices() | |
| 150 | .find(|&(_, c)| { | |
| 151 | match quote { | |
| 152 | Some(q) if c == q => quote = None, | |
| 153 | None if c == '"' || c == '\'' => quote = Some(c), | |
| 154 | None if c == '>' => return true, | |
| 155 | _ => {} | |
| 156 | } | |
| 157 | false | |
| 158 | }) | |
| 159 | .map(|(at, _)| at) | |
| 160 | .ok_or("The XML ends early.")?; | |
| 161 | let inner = &rest[1..end]; | |
| 162 | let empty = inner.ends_with('/'); | |
| 163 | let inner = inner.trim_end_matches('/'); | |
| 164 | let (name, attrs) = inner.split_once(char::is_whitespace).unwrap_or((inner, "")); | |
| 165 | if name.is_empty() { | |
| 166 | return Err("A tag has no name.".to_owned()); | |
| 167 | } | |
| 168 | let element = Element { name: local(name), attributes: attributes(attrs)?, ..Element::default() }; | |
| 169 | if empty { | |
| 170 | match stack.last_mut() { | |
| 171 | Some(parent) => parent.children.push(element), | |
| 172 | None => root = Some(element), | |
| 173 | } | |
| 174 | } else { | |
| 175 | stack.push(element); | |
| 176 | } | |
| 177 | rest = &rest[end + 1..]; | |
| 178 | } | |
| 179 | if root.is_some() && stack.is_empty() { | |
| 180 | break; | |
| 181 | } | |
| 182 | } | |
| 183 | if !stack.is_empty() { | |
| 184 | return Err("The XML ends early.".to_owned()); | |
| 185 | } | |
| 186 | root.ok_or_else(|| "There is no XML in it.".to_owned()) | |
| 187 | } | |
| 188 | ||
| 189 | #[cfg(test)] | |
| 190 | mod tests { | |
| 191 | use super::*; | |
| 192 | ||
| 193 | #[test] | |
| 194 | fn documents_read_into_elements() { | |
| 195 | let doc = parse( | |
| 196 | r#"<?xml version="1.0" encoding="utf-8"?> | |
| 197 | <!-- a comment --> | |
| 198 | <package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd"> | |
| 199 | <metadata minClientVersion="4.0"> | |
| 200 | <id>Acme.Web</id> | |
| 201 | <description><![CDATA[Fast & <small>]]></description> | |
| 202 | <authors>Ada & Bo</authors> | |
| 203 | <repository type="git" url="https://g1t.sh/acme/web.git" /> | |
| 204 | <dependencies> | |
| 205 | <group targetFramework="net8.0"><dependency id="Newtonsoft.Json" version="13.0.1" exclude="Build,Analyzers" /></group> | |
| 206 | </dependencies> | |
| 207 | <x:other xmlns:x="urn:x" note='a > b'>AB</x:other> | |
| 208 | </metadata> | |
| 209 | </package>"#, | |
| 210 | ) | |
| 211 | .unwrap(); | |
| 212 | assert_eq!(doc.name, "package"); | |
| 213 | let metadata = doc.child("metadata").unwrap(); | |
| 214 | assert_eq!(metadata.attribute("minClientVersion"), Some("4.0")); | |
| 215 | assert_eq!(metadata.child_text("id").as_deref(), Some("Acme.Web")); | |
| 216 | assert_eq!(metadata.child_text("description").as_deref(), Some("Fast & <small>")); | |
| 217 | assert_eq!(metadata.child_text("authors").as_deref(), Some("Ada & Bo")); | |
| 218 | assert_eq!(metadata.child("repository").unwrap().attribute("url"), Some("https://g1t.sh/acme/web.git")); | |
| 219 | let dep = metadata.child("dependencies").unwrap().child("group").unwrap().child("dependency").unwrap(); | |
| 220 | assert_eq!(dep.attribute("version"), Some("13.0.1")); | |
| 221 | let other = metadata.child("other").unwrap(); | |
| 222 | assert_eq!(other.text, "AB"); | |
| 223 | assert_eq!(other.attribute("note"), Some("a > b")); | |
| 224 | assert_eq!(metadata.child_text("missing"), None); | |
| 225 | } | |
| 226 | ||
| 227 | #[test] | |
| 228 | fn broken_documents_are_refused() { | |
| 229 | assert!(parse("<a><b></a>").is_err()); | |
| 230 | assert!(parse("<a>").is_err()); | |
| 231 | assert!(parse("just text").is_err()); | |
| 232 | assert!(parse("<a x=1/>").is_err()); | |
| 233 | assert_eq!(escape("a<b & \"c\""), "a<b & "c""); | |
| 234 | } | |
| 235 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.