1use rowan::GreenNodeBuilder;
16
17use crate::dcf::ast::Document;
18use crate::dcf::syntax::{SyntaxKind, SyntaxNode};
19use crate::parser::diagnostics::push_diagnostic;
20
21pub use crate::parser::ParseDiagnostic;
22
23pub(crate) const MALFORMED_LINE: &str =
26 "malformed line: expected 'Field: value' or an indented continuation line";
27
28pub(crate) const ORPHAN_CONTINUATION: &str = "continuation line at the start of a record";
31
32pub(crate) const EMPTY_FIELD_NAME: &str = "empty field name";
35
36#[derive(Debug, Clone)]
37pub struct ParseOutput {
38 pub cst: SyntaxNode,
39 pub diagnostics: Vec<ParseDiagnostic>,
40}
41
42impl ParseOutput {
43 pub fn document(&self) -> Document {
45 use rowan::ast::AstNode as _;
46 Document::cast(self.cst.clone()).expect("the DCF parser always roots the tree at ROOT")
47 }
48}
49
50pub fn parse(text: &str) -> ParseOutput {
56 Scanner::new(text).run()
57}
58
59pub fn reconstruct(text: &str) -> String {
61 parse(text)
62 .cst
63 .descendants_with_tokens()
64 .filter_map(|el| el.into_token())
65 .map(|tok| tok.text().to_string())
66 .collect::<String>()
67}
68
69struct Scanner<'a> {
70 text: &'a str,
71 builder: GreenNodeBuilder<'static>,
72 diagnostics: Vec<ParseDiagnostic>,
73 record_open: bool,
74 field_open: bool,
75}
76
77impl<'a> Scanner<'a> {
78 fn new(text: &'a str) -> Self {
79 Self {
80 text,
81 builder: GreenNodeBuilder::new(),
82 diagnostics: Vec::new(),
83 record_open: false,
84 field_open: false,
85 }
86 }
87
88 fn run(mut self) -> ParseOutput {
89 self.builder.start_node(SyntaxKind::ROOT.into());
90
91 let mut pos = 0;
92 while pos < self.text.len() {
93 pos = self.line(pos);
94 }
95
96 self.close_record();
97 self.builder.finish_node();
98
99 let green = self.builder.finish();
100 ParseOutput {
101 cst: SyntaxNode::new_root(green),
102 diagnostics: self.diagnostics,
103 }
104 }
105
106 fn close_field(&mut self) {
109 if self.field_open {
110 self.builder.finish_node();
111 self.field_open = false;
112 }
113 }
114
115 fn close_record(&mut self) {
116 self.close_field();
117 if self.record_open {
118 self.builder.finish_node();
119 self.record_open = false;
120 }
121 }
122
123 fn open_record(&mut self) {
124 if !self.record_open {
125 self.builder.start_node(SyntaxKind::RECORD.into());
126 self.record_open = true;
127 }
128 }
129
130 fn token(&mut self, kind: SyntaxKind, start: usize, end: usize) {
131 if start < end {
134 self.builder.token(kind.into(), &self.text[start..end]);
135 }
136 }
137
138 fn line(&mut self, pos: usize) -> usize {
142 let (content_end, line_end) = self.line_bounds(pos);
143 let content = &self.text[pos..content_end];
144
145 if content.trim().is_empty() {
146 self.close_record();
149 self.builder.start_node(SyntaxKind::BLANK_LINE.into());
150 self.token(SyntaxKind::WHITESPACE, pos, content_end);
151 self.token(SyntaxKind::NEWLINE, content_end, line_end);
152 self.builder.finish_node();
153 } else if content.starts_with('#') {
154 self.builder.start_node(SyntaxKind::COMMENT_LINE.into());
159 self.token(SyntaxKind::COMMENT, pos, content_end);
160 self.token(SyntaxKind::NEWLINE, content_end, line_end);
161 self.builder.finish_node();
162 } else if content.starts_with([' ', '\t']) {
163 if self.field_open {
164 self.value_line(pos, content_end, line_end);
165 } else {
166 push_diagnostic(&mut self.diagnostics, ORPHAN_CONTINUATION, pos, content_end);
167 self.open_record();
168 self.malformed_line(pos, content_end, line_end);
169 }
170 } else {
171 match content.find(':') {
172 Some(offset) => self.field(pos, pos + offset, content_end, line_end),
173 None => {
174 push_diagnostic(&mut self.diagnostics, MALFORMED_LINE, pos, content_end);
175 self.close_field();
177 self.open_record();
178 self.malformed_line(pos, content_end, line_end);
179 }
180 }
181 }
182
183 line_end
184 }
185
186 fn line_bounds(&self, pos: usize) -> (usize, usize) {
190 match self.text[pos..].find('\n') {
191 Some(offset) => {
192 let line_end = pos + offset + 1;
193 let mut content_end = line_end - 1;
194 if content_end > pos && self.text.as_bytes()[content_end - 1] == b'\r' {
195 content_end -= 1;
196 }
197 (content_end, line_end)
198 }
199 None => (self.text.len(), self.text.len()),
200 }
201 }
202
203 fn malformed_line(&mut self, start: usize, content_end: usize, line_end: usize) {
204 self.builder.start_node(SyntaxKind::MALFORMED_LINE.into());
205 self.token(SyntaxKind::TEXT, start, content_end);
206 self.token(SyntaxKind::NEWLINE, content_end, line_end);
207 self.builder.finish_node();
208 }
209
210 fn field(&mut self, start: usize, colon: usize, content_end: usize, line_end: usize) {
213 let name = &self.text[start..colon];
214 let name_end = start + name.trim_end().len();
215 if name_end == start {
216 push_diagnostic(&mut self.diagnostics, EMPTY_FIELD_NAME, colon, colon + 1);
217 }
218
219 self.close_field();
220 self.open_record();
221 self.builder.start_node(SyntaxKind::FIELD.into());
222 self.field_open = true;
223
224 self.token(SyntaxKind::FIELD_NAME, start, name_end);
225 self.token(SyntaxKind::WHITESPACE, name_end, colon);
229 self.token(SyntaxKind::COLON, colon, colon + 1);
230
231 self.value_line(colon + 1, content_end, line_end);
232 }
233
234 fn value_line(&mut self, start: usize, content_end: usize, line_end: usize) {
237 if start == line_end {
238 return;
239 }
240 let slice = &self.text[start..content_end];
241 let trimmed = slice.trim();
242 let (lead, trail) = if trimmed.is_empty() {
246 (content_end, content_end)
247 } else {
248 let lead = start + (slice.len() - slice.trim_start().len());
249 (lead, lead + trimmed.len())
250 };
251
252 self.builder.start_node(SyntaxKind::VALUE_LINE.into());
253 self.token(SyntaxKind::WHITESPACE, start, lead);
254 self.token(SyntaxKind::VALUE_TEXT, lead, trail);
255 self.token(SyntaxKind::WHITESPACE, trail, content_end);
256 self.token(SyntaxKind::NEWLINE, content_end, line_end);
257 self.builder.finish_node();
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use crate::dcf::syntax::SyntaxKind;
265
266 fn doc(text: &str) -> ParseOutput {
267 parse(text)
268 }
269
270 fn folded(text: &str, name: &str) -> Option<String> {
271 doc(text).document().field(name).map(|f| f.folded_value())
272 }
273
274 fn assert_tiles(text: &str) {
278 let output = parse(text);
279 let mut rebuilt = String::new();
280 let mut newlines = 0;
281
282 for token in output
283 .cst
284 .descendants_with_tokens()
285 .filter_map(|el| el.into_token())
286 {
287 assert!(
288 !token.text().is_empty(),
289 "empty {:?} token in {text:?}",
290 token.kind()
291 );
292 rebuilt.push_str(token.text());
293
294 if token.kind() == SyntaxKind::NEWLINE {
295 newlines += 1;
296 let parent = token.parent().expect("a token always has a parent");
297 assert!(
298 parent.kind().is_line(),
299 "NEWLINE parented by {:?}, not a line node, in {text:?}",
300 parent.kind()
301 );
302 }
303 }
304
305 assert_eq!(rebuilt, text, "leaves do not tile the source");
306 assert_eq!(
307 newlines,
308 text.matches('\n').count(),
309 "newline count mismatch in {text:?}"
310 );
311
312 for node in output.cst.descendants().filter(|n| n.kind().is_line()) {
313 let count = node
314 .children_with_tokens()
315 .filter_map(|el| el.into_token())
316 .filter(|t| t.kind() == SyntaxKind::NEWLINE)
317 .count();
318 assert!(
319 count <= 1,
320 "{:?} holds {count} newlines in {text:?}",
321 node.kind()
322 );
323 }
324 }
325
326 const ADVERSARIAL: &[&str] = &[
330 "",
331 "\n",
332 "\r\n",
333 "\n\n\n",
334 "Package: p",
335 "Package: p\n",
336 "Package:\n",
337 "Package:",
338 "Package: ",
339 ":",
340 ": v\n",
341 " \n",
342 "\t\n",
343 " ",
344 "Package: p\r\nVersion: 1\r\n",
345 "Package: p\r\n cont\r\n",
346 "a\rb\n",
347 "\u{FEFF}Package: p\n",
348 "# c",
349 "# c\n",
350 "#\n",
351 " # c\n",
352 "Collate:\n a.R\n# c\n b.R\n",
353 "garbage\n",
354 "garbage",
355 " orphan\n",
356 "Package: p\n\n orphan\n",
357 "Package: p\n\nVersion: 1\n",
358 "Date/Publication: 2025-09-12 07:20:14 UTC\n",
359 "Authors@R: c(person(\"A\", \"B\", role = c(\"aut\", \"cre\")))\n",
360 "Collate:\n 'a.R'\n 'b.R'\n",
361 "Package: p\n \nVersion: 1\n",
362 "Package : p\n",
363 "Built: R 4.5.3; ; 2025-01-01 00:00:00 UTC; unix\n",
364 ];
365
366 #[test]
367 fn round_trips_every_adversarial_input() {
368 for input in ADVERSARIAL {
369 assert_eq!(
370 &reconstruct(input),
371 input,
372 "round-trip failed for {input:?}"
373 );
374 assert_tiles(input);
375 }
376 }
377
378 #[test]
379 fn folds_continuation_lines() {
380 assert_eq!(folded("Package: p\n", "Package").as_deref(), Some("p"));
381 assert_eq!(
382 folded("Depends:\n a,\n b\n", "Depends").as_deref(),
383 Some("a,\nb")
384 );
385 }
386
387 #[test]
390 fn folded_value_drops_empty_first_line() {
391 let text = "Package: testpkg\nCollate:\n a.R\n b.R\nVersion: 1.0\n";
392 assert_eq!(folded(text, "Collate").as_deref(), Some("a.R\nb.R"));
393 assert_eq!(folded(text, "Package").as_deref(), Some("testpkg"));
394 assert_eq!(folded(text, "Version").as_deref(), Some("1.0"));
395 }
396
397 #[test]
401 fn folded_roxygen_field_parses_as_r() {
402 let text = "Package: p\nRoxygen: list(load = \"installed\",\n markdown = TRUE)\n";
403 let value = folded(text, "Roxygen").expect("Roxygen field");
404 assert_eq!(value, "list(load = \"installed\",\nmarkdown = TRUE)");
405 let r = crate::parser::parse(&value);
406 assert!(r.diagnostics.is_empty(), "folded value is not valid R");
407 }
408
409 #[test]
412 fn blank_line_starts_a_new_record_without_hiding_fields() {
413 let output = doc("Package: p\n\nVersion: 1\n");
414 assert_eq!(output.document().records().count(), 2);
415 assert_eq!(
416 folded("Package: p\n\nVersion: 1\n", "Version").as_deref(),
417 Some("1")
418 );
419 }
420
421 #[test]
425 fn whitespace_only_line_is_blank_not_a_continuation() {
426 let text = "Package: p\n \nVersion: 1\n";
427 assert_eq!(folded(text, "Package").as_deref(), Some("p"));
428 assert_eq!(doc(text).document().records().count(), 2);
429 }
430
431 #[test]
432 fn comment_at_column_zero_is_skipped() {
433 let text = "Package: p\n# a comment\nVersion: 1\n";
434 assert_eq!(doc(text).document().fields().count(), 2);
435 assert_eq!(folded(text, "Version").as_deref(), Some("1"));
436 }
437
438 #[test]
441 fn comment_between_continuations_keeps_the_field() {
442 let text = "Collate:\n a.R\n# skip me\n b.R\n";
443 assert_eq!(folded(text, "Collate").as_deref(), Some("a.R\nb.R"));
444 assert_eq!(doc(text).document().fields().count(), 1);
445 }
446
447 #[test]
448 fn indented_hash_is_a_continuation() {
449 let text = "Description: one\n # two\n";
450 assert_eq!(folded(text, "Description").as_deref(), Some("one\n# two"));
451 }
452
453 #[test]
455 fn commented_field_is_not_a_field() {
456 let text = "Package: p\n# Depends: R (>= 4.0)\n";
457 assert!(folded(text, "Depends").is_none());
458 assert!(folded(text, "# Depends").is_none());
459 }
460
461 #[test]
462 fn malformed_line_is_diagnosed_and_preserved() {
463 let output = doc("Package: p\ngarbage\nVersion: 1\n");
464 assert_eq!(output.diagnostics.len(), 1);
465 assert_eq!(output.diagnostics[0].message, MALFORMED_LINE);
466 assert_eq!(
467 &"Package: p\ngarbage\nVersion: 1\n"
468 [output.diagnostics[0].start..output.diagnostics[0].end],
469 "garbage"
470 );
471 assert_eq!(output.document().fields().count(), 2);
473 }
474
475 #[test]
476 fn orphan_continuation_is_diagnosed() {
477 let output = doc("Package: p\n\n orphan\n");
478 assert_eq!(output.diagnostics.len(), 1);
479 assert_eq!(output.diagnostics[0].message, ORPHAN_CONTINUATION);
480 assert_eq!(
482 folded("Package: p\n\n orphan\n", "Package").as_deref(),
483 Some("p")
484 );
485 }
486
487 #[test]
488 fn empty_field_name_is_diagnosed() {
489 let output = doc(": value\n");
490 assert_eq!(output.diagnostics.len(), 1);
491 assert_eq!(output.diagnostics[0].message, EMPTY_FIELD_NAME);
492 let field = output.document().fields().next().expect("a field");
493 assert_eq!(field.name(), "");
494 assert_eq!(field.folded_value(), "value");
495 }
496
497 #[test]
500 fn last_duplicate_wins() {
501 assert_eq!(
502 folded("Package: first\n\nPackage: second\n", "Package").as_deref(),
503 Some("second")
504 );
505 }
506
507 #[test]
509 fn last_duplicate_in_record_wins() {
510 let output = doc("Package: first\nPackage: second\n");
511 let record = output.document().first_record().expect("a record");
512 assert_eq!(
513 record.field("Package").map(|field| field.folded_value()),
514 Some("second".to_string())
515 );
516 }
517
518 #[test]
523 fn field_name_excludes_trailing_space() {
524 let text = "Package : p\n";
525 let field = doc(text).document().fields().next().expect("a field");
526 assert_eq!(field.name(), "Package");
527 assert_eq!(field.folded_value(), "p");
528 }
529
530 #[test]
533 fn crlf_newline_is_one_token() {
534 let output = doc("Package: p\r\n cont\r\n");
535 let newlines: Vec<String> = output
536 .cst
537 .descendants_with_tokens()
538 .filter_map(|el| el.into_token())
539 .filter(|t| t.kind() == SyntaxKind::NEWLINE)
540 .map(|t| t.text().to_string())
541 .collect();
542 assert_eq!(newlines, vec!["\r\n", "\r\n"]);
543
544 for token in output
545 .cst
546 .descendants_with_tokens()
547 .filter_map(|el| el.into_token())
548 .filter(|t| t.kind() == SyntaxKind::VALUE_TEXT)
549 {
550 assert!(!token.text().contains('\r'), "\\r leaked into VALUE_TEXT");
551 }
552 }
553
554 #[test]
555 fn value_keeps_colons_after_the_first() {
556 assert_eq!(
557 folded("Built: R 4.5.3; ; 2025-01-01 00:00:00 UTC; unix\n", "Built").as_deref(),
558 Some("R 4.5.3; ; 2025-01-01 00:00:00 UTC; unix")
559 );
560 assert_eq!(
561 folded(
562 "Date/Publication: 2025-09-12 07:20:14 UTC\n",
563 "Date/Publication"
564 )
565 .as_deref(),
566 Some("2025-09-12 07:20:14 UTC")
567 );
568 }
569
570 #[test]
572 fn field_without_a_value() {
573 let field = doc("Package:").document().fields().next().expect("a field");
574 assert_eq!(field.value_lines().count(), 0);
575 assert_eq!(field.folded_value(), "");
576 }
577
578 #[test]
579 fn raw_value_text_keeps_every_byte_after_the_colon() {
580 let field = doc("Collate:\n a.R\n b.R\n")
581 .document()
582 .fields()
583 .next()
584 .expect("a field");
585 assert_eq!(field.raw_value_text(), "\n a.R\n b.R\n");
586 }
587
588 #[test]
589 fn clean_input_reports_no_diagnostics() {
590 for input in [
591 "Package: p\n",
592 "Package: p\n\nVersion: 1\n",
593 "Collate:\n a.R\n# c\n b.R\n",
594 " \n",
595 "# only a comment\n",
596 ] {
597 assert!(
598 doc(input).diagnostics.is_empty(),
599 "unexpected diagnostics for {input:?}: {:?}",
600 doc(input).diagnostics
601 );
602 }
603 }
604}