g1t/services/packages/src/yaml.rs

376 lines13,433 bytesCodeBlame
1//! Just enough YAML for a gem's `metadata.gz`: the `Gem::Specification`
2//! RubyGems writes with Psych. Block mappings and sequences (including a
3//! sequence at its key's indent, and `- - ">="` nested ones), plain and
4//! quoted scalars wrapped over lines, `|` and `>` block scalars, and empty
5//! flow collections. Tags (`!ruby/object:Gem::Version`) are dropped: the
6//! value is read as the plain mapping or scalar under them.
7
8use serde_json::{Map, Value};
9
10#[derive(Clone, Debug)]
11struct Line {
12 indent: usize,
13 text: String,
14}
15
16/// Reads a document into JSON values: mappings as objects, sequences as
17/// arrays, scalars as strings, and empty values as null.
18pub fn parse(text: &str) -> Result<Value, String> {
19 let mut lines = Vec::new();
20 for raw in text.lines() {
21 let trimmed = raw.trim_end();
22 let indent = trimmed.len() - trimmed.trim_start().len();
23 let content = trimmed.trim_start();
24 if content.starts_with('#') || content == "..." {
25 continue;
26 }
27 if indent == 0 && content.starts_with("---") {
28 let after = strip_tag(content[3..].trim());
29 if !after.is_empty() {
30 lines.push(Line { indent: 0, text: after.to_owned() });
31 }
32 continue;
33 }
34 lines.push(Line { indent, text: content.to_owned() });
35 }
36 let mut parser = Parser { lines, at: 0 };
37 parser.skip_blank();
38 if parser.at >= parser.lines.len() {
39 return Ok(Value::Null);
40 }
41 let indent = parser.lines[parser.at].indent;
42 parser.node(indent)
43}
44
45/// The text after a leading tag: `!ruby/object:Gem::Version` alone is empty.
46fn strip_tag(text: &str) -> &str {
47 if text.starts_with('!') {
48 text.split_once(' ').map_or("", |(_, rest)| rest.trim_start())
49 } else {
50 text
51 }
52}
53
54/// Where a mapping key ends: the `:` followed by a space or the end of the
55/// line, outside quotes.
56fn key_end(text: &str) -> Option<usize> {
57 if text.starts_with('"') || text.starts_with('\'') {
58 let quote = text.as_bytes()[0] as char;
59 let close = text[1..].find(quote)? + 1;
60 return text[close + 1..].starts_with(':').then_some(close + 1).filter(|at| {
61 let after = &text[at + 1..];
62 after.is_empty() || after.starts_with(' ')
63 });
64 }
65 if text.starts_with('-') && (text.len() == 1 || text.as_bytes()[1] == b' ') {
66 return None;
67 }
68 let bytes = text.as_bytes();
69 (0..bytes.len()).find(|&i| bytes[i] == b':' && (i + 1 == bytes.len() || bytes[i + 1] == b' '))
70}
71
72fn unquote_key(key: &str) -> String {
73 let key = key.trim();
74 match scalar(key) {
75 Value::String(s) => s,
76 _ => key.to_owned(),
77 }
78}
79
80/// A scalar as written on one line (continuations already joined).
81fn scalar(text: &str) -> Value {
82 let text = strip_tag(text.trim());
83 if text.is_empty() || text == "~" || text == "null" {
84 return Value::Null;
85 }
86 if text == "[]" {
87 return Value::Array(Vec::new());
88 }
89 if text == "{}" {
90 return Value::Object(Map::new());
91 }
92 if let Some(inner) = text.strip_prefix('[').and_then(|t| t.strip_suffix(']')) {
93 return Value::Array(inner.split(',').map(|item| scalar(item.trim())).collect());
94 }
95 if let Some(inner) = text.strip_prefix('\'').and_then(|t| t.strip_suffix('\'')) {
96 return Value::String(inner.replace("''", "'"));
97 }
98 if let Some(inner) = text.strip_prefix('"').and_then(|t| t.strip_suffix('"')) {
99 let mut out = String::new();
100 let mut chars = inner.chars();
101 while let Some(c) = chars.next() {
102 if c != '\\' {
103 out.push(c);
104 continue;
105 }
106 match chars.next() {
107 Some('n') => out.push('\n'),
108 Some('t') => out.push('\t'),
109 Some('"') => out.push('"'),
110 Some('\\') => out.push('\\'),
111 Some('/') => out.push('/'),
112 Some('0') => out.push('\0'),
113 Some(' ') => out.push(' '),
114 Some('x') => {
115 let hex: String = chars.by_ref().take(2).collect();
116 out.extend(u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32));
117 }
118 Some('u') => {
119 let hex: String = chars.by_ref().take(4).collect();
120 out.extend(u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32));
121 }
122 Some(other) => {
123 out.push('\\');
124 out.push(other);
125 }
126 None => out.push('\\'),
127 }
128 }
129 return Value::String(out);
130 }
131 Value::String(text.to_owned())
132}
133
134struct Parser {
135 lines: Vec<Line>,
136 at: usize,
137}
138
139impl Parser {
140 fn skip_blank(&mut self) {
141 while self.at < self.lines.len() && self.lines[self.at].text.is_empty() {
142 self.at += 1;
143 }
144 }
145
146 fn peek(&mut self) -> Option<&Line> {
147 self.skip_blank();
148 self.lines.get(self.at)
149 }
150
151 /// The node starting at the current line, which is at `indent`.
152 fn node(&mut self, indent: usize) -> Result<Value, String> {
153 let Some(line) = self.peek().cloned() else {
154 return Ok(Value::Null);
155 };
156 if line.text == "-" || line.text.starts_with("- ") {
157 return self.sequence(line.indent);
158 }
159 if key_end(&line.text).is_some() {
160 return self.mapping(line.indent);
161 }
162 self.at += 1;
163 let text = self.continued(line.text.clone(), indent.saturating_sub(1));
164 Ok(scalar(&text))
165 }
166
167 /// A scalar with the lines that continue it: deeper than `parent`, and
168 /// not themselves a key or an item.
169 fn continued(&mut self, mut text: String, parent: usize) -> String {
170 let open_quote = |t: &str| {
171 let t = t.trim();
172 (t.starts_with('"') && (t.len() == 1 || !t.ends_with('"') || t.ends_with("\\\"")))
173 || (t.starts_with('\'') && (t.len() == 1 || !t.ends_with('\'')))
174 };
175 loop {
176 let quoted = open_quote(&text);
177 let Some(next) = self.lines.get(self.at) else { break };
178 if next.text.is_empty() {
179 if quoted {
180 text.push('\n');
181 self.at += 1;
182 continue;
183 }
184 break;
185 }
186 if next.indent <= parent || (!quoted && (key_end(&next.text).is_some() || next.text.starts_with("- "))) {
187 break;
188 }
189 text.push(' ');
190 text.push_str(&next.text);
191 self.at += 1;
192 }
193 text
194 }
195
196 fn sequence(&mut self, indent: usize) -> Result<Value, String> {
197 let mut items = Vec::new();
198 while let Some(line) = self.peek().cloned() {
199 if line.indent != indent || !(line.text == "-" || line.text.starts_with("- ")) {
200 break;
201 }
202 let rest = line.text[1..].trim_start();
203 let rest = strip_tag(rest).to_owned();
204 if rest.is_empty() {
205 self.at += 1;
206 match self.peek().cloned() {
207 Some(next) if next.indent > indent => items.push(self.node(next.indent)?),
208 _ => items.push(Value::Null),
209 }
210 continue;
211 }
212 // The item's content stands in for a line at its own column.
213 let column = indent + (line.text.len() - rest.len());
214 self.lines[self.at] = Line { indent: column, text: rest };
215 items.push(self.node(column)?);
216 }
217 Ok(Value::Array(items))
218 }
219
220 fn mapping(&mut self, indent: usize) -> Result<Value, String> {
221 let mut map = Map::new();
222 while let Some(line) = self.peek().cloned() {
223 if line.indent != indent {
224 break;
225 }
226 let Some(end) = key_end(&line.text) else { break };
227 let key = unquote_key(&line.text[..end]);
228 let rest = strip_tag(line.text[end + 1..].trim()).to_owned();
229 self.at += 1;
230 let value = if rest.is_empty() {
231 match self.peek().cloned() {
232 Some(next) if next.indent > indent => self.node(next.indent)?,
233 Some(next) if next.indent == indent && (next.text == "-" || next.text.starts_with("- ")) => self.sequence(indent)?,
234 _ => Value::Null,
235 }
236 } else if let Some(style) = rest.strip_prefix('|').map(|s| (true, s)).or_else(|| rest.strip_prefix('>').map(|s| (false, s))) {
237 self.block(indent, style.0, style.1)
238 } else {
239 let text = self.continued(rest, indent);
240 scalar(&text)
241 };
242 map.insert(key, value);
243 }
244 Ok(Value::Object(map))
245 }
246
247 /// A `|` (literal) or `>` (folded) block scalar under a key at `indent`.
248 fn block(&mut self, indent: usize, literal: bool, chomp: &str) -> Value {
249 let mut lines: Vec<(usize, String)> = Vec::new();
250 while let Some(next) = self.lines.get(self.at) {
251 if !next.text.is_empty() && next.indent <= indent {
252 break;
253 }
254 lines.push((next.indent, next.text.clone()));
255 self.at += 1;
256 }
257 while lines.last().is_some_and(|(_, t)| t.is_empty()) {
258 lines.pop();
259 }
260 let base = lines.iter().filter(|(_, t)| !t.is_empty()).map(|(i, _)| *i).min().unwrap_or(0);
261 let shown: Vec<String> = lines
262 .iter()
263 .map(|(i, t)| if t.is_empty() { String::new() } else { format!("{}{t}", " ".repeat(i - base)) })
264 .collect();
265 let mut text = if literal {
266 shown.join("\n")
267 } else {
268 let mut folded = String::new();
269 for (n, line) in shown.iter().enumerate() {
270 if n > 0 {
271 folded.push(if line.is_empty() || shown[n - 1].is_empty() { '\n' } else { ' ' });
272 }
273 folded.push_str(line);
274 }
275 folded
276 };
277 if !chomp.contains('-') && !text.is_empty() {
278 text.push('\n');
279 }
280 Value::String(text)
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use serde_json::json;
288
289 const SPEC: &str = r#"--- !ruby/object:Gem::Specification
290name: hello-world
291version: !ruby/object:Gem::Version
292 version: 0.1.0
293platform: ruby
294authors:
295- Ada Lovelace
296autorequire:
297bindir: exe
298cert_chain: []
299date: 2026-10-06 00:00:00.000000000 Z
300dependencies:
301- !ruby/object:Gem::Dependency
302 name: rack
303 requirement: !ruby/object:Gem::Requirement
304 requirements:
305 - - ">="
306 - !ruby/object:Gem::Version
307 version: '2.0'
308 - - "<"
309 - !ruby/object:Gem::Version
310 version: '4'
311 type: :runtime
312 prerelease: false
313- !ruby/object:Gem::Dependency
314 name: rspec
315 requirement: !ruby/object:Gem::Requirement
316 requirements:
317 - - "~>"
318 - !ruby/object:Gem::Version
319 version: '3.12'
320 type: :development
321description: |-
322 Says hello.
323
324 Then says it again.
325email:
326- ada@example.com
327homepage: https://g1t.sh/acme/hello-world
328licenses:
329- MIT
330metadata:
331 source_code_uri: https://g1t.sh/acme/hello-world
332 "quoted key": 'it''s'
333required_ruby_version: !ruby/object:Gem::Requirement
334 requirements:
335 - - ">="
336 - !ruby/object:Gem::Version
337 version: 3.0.0
338summary: A summary long enough that Psych wraps it onto a second line when it
339 writes the specification
340test_files: []
341"#;
342
343 #[test]
344 fn a_gemspec_reads_as_rubygems_wrote_it() {
345 let spec = parse(SPEC).unwrap();
346 assert_eq!(spec["name"], "hello-world");
347 assert_eq!(spec["version"]["version"], "0.1.0");
348 assert_eq!(spec["platform"], "ruby");
349 assert_eq!(spec["authors"], json!(["Ada Lovelace"]));
350 assert_eq!(spec["autorequire"], Value::Null);
351 assert_eq!(spec["cert_chain"], json!([]));
352 let deps = spec["dependencies"].as_array().unwrap();
353 assert_eq!(deps.len(), 2);
354 assert_eq!(deps[0]["name"], "rack");
355 assert_eq!(deps[0]["type"], ":runtime");
356 assert_eq!(deps[0]["requirement"]["requirements"], json!([[">=", { "version": "2.0" }], ["<", { "version": "4" }]]));
357 assert_eq!(deps[1]["type"], ":development");
358 assert_eq!(spec["description"], "Says hello.\n\nThen says it again.");
359 assert_eq!(spec["metadata"]["source_code_uri"], "https://g1t.sh/acme/hello-world");
360 assert_eq!(spec["metadata"]["quoted key"], "it's");
361 assert_eq!(spec["required_ruby_version"]["requirements"][0][1]["version"], "3.0.0");
362 assert_eq!(spec["summary"], "A summary long enough that Psych wraps it onto a second line when it writes the specification");
363 assert_eq!(spec["test_files"], json!([]));
364 }
365
366 #[test]
367 fn scalars_and_blocks() {
368 let doc = parse("a: \"line\\nnext\"\nb: >\n folded\n text\nc: [x, 'y']\nd: ~\n").unwrap();
369 assert_eq!(doc["a"], "line\nnext");
370 assert_eq!(doc["b"], "folded text\n");
371 assert_eq!(doc["c"], json!(["x", "y"]));
372 assert_eq!(doc["d"], Value::Null);
373 assert_eq!(parse("").unwrap(), Value::Null);
374 assert_eq!(parse("- a\n- b\n").unwrap(), json!(["a", "b"]));
375 }
376}