arity_parser/dcf/ast.rs
1//! Zero-cost typed wrappers over the DCF CST, in the same mould as
2//! [`crate::ast`].
3//!
4//! These are a read-only **navigation** view: they add no structure the CST
5//! does not already carry, and casting one costs nothing.
6//!
7//! **Never store a wrapper in salsa.** Every type here holds a red
8//! [`SyntaxNode`], which is neither `Send` nor `Eq`; store the green node and
9//! re-derive.
10
11use rowan::TextRange;
12use rowan::ast::AstNode;
13use smol_str::SmolStr;
14
15use crate::dcf::syntax::{DcfLanguage, SyntaxKind, SyntaxNode, SyntaxToken};
16
17macro_rules! ast_node {
18 ($name:ident, $kind:expr) => {
19 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
20 pub struct $name(SyntaxNode);
21
22 impl AstNode for $name {
23 type Language = DcfLanguage;
24
25 fn can_cast(kind: SyntaxKind) -> bool {
26 kind == $kind
27 }
28
29 fn cast(syntax: SyntaxNode) -> Option<Self> {
30 Self::can_cast(syntax.kind()).then(|| Self(syntax))
31 }
32
33 fn syntax(&self) -> &SyntaxNode {
34 &self.0
35 }
36 }
37 };
38}
39
40ast_node!(Document, SyntaxKind::ROOT);
41ast_node!(Record, SyntaxKind::RECORD);
42ast_node!(Field, SyntaxKind::FIELD);
43ast_node!(ValueLine, SyntaxKind::VALUE_LINE);
44ast_node!(CommentLine, SyntaxKind::COMMENT_LINE);
45ast_node!(MalformedLine, SyntaxKind::MALFORMED_LINE);
46
47/// The first direct-child token of `node` with the given kind.
48fn token(node: &SyntaxNode, kind: SyntaxKind) -> Option<SyntaxToken> {
49 node.children_with_tokens()
50 .filter_map(|el| el.into_token())
51 .find(|tok| tok.kind() == kind)
52}
53
54impl Document {
55 /// The document's records, in order. A `DESCRIPTION` has exactly one.
56 pub fn records(&self) -> impl Iterator<Item = Record> + use<> {
57 self.0.children().filter_map(Record::cast)
58 }
59
60 /// The first record, if any.
61 pub fn first_record(&self) -> Option<Record> {
62 self.records().next()
63 }
64
65 /// Every field of every record, in document order.
66 ///
67 /// This is the record-**blind** view, and it is deliberately what the
68 /// `DESCRIPTION` readers use: a stray blank line splits the file into two
69 /// records, and must not hide the fields after it from a caller that only
70 /// wants "the `Version` of this package".
71 pub fn fields(&self) -> impl Iterator<Item = Field> + use<> {
72 self.records().flat_map(|record| record.fields())
73 }
74
75 /// The first field named `name`, across all records.
76 ///
77 /// **First** occurrence wins, which is what every caller did before this
78 /// module existed. Note R's own `read.dcf` takes the **last**; closing that
79 /// divergence is a deliberate change, not a drive-by.
80 pub fn field(&self, name: &str) -> Option<Field> {
81 self.fields().find(|field| field.name() == name)
82 }
83}
84
85impl Record {
86 /// This record's fields, in order.
87 pub fn fields(&self) -> impl Iterator<Item = Field> + use<> {
88 self.0.children().filter_map(Field::cast)
89 }
90
91 /// The first field of this record named `name`.
92 pub fn field(&self, name: &str) -> Option<Field> {
93 self.fields().find(|field| field.name() == name)
94 }
95}
96
97impl Field {
98 /// The field's name, excluding any whitespace before the colon. Empty when
99 /// the source had no name at all (`: value`), which the parser also
100 /// diagnoses.
101 pub fn name(&self) -> SmolStr {
102 self.name_token()
103 .map(|tok| SmolStr::new(tok.text()))
104 .unwrap_or_default()
105 }
106
107 /// The `FIELD_NAME` token, absent for a nameless `: value`.
108 pub fn name_token(&self) -> Option<SyntaxToken> {
109 token(&self.0, SyntaxKind::FIELD_NAME)
110 }
111
112 /// The name's source range, collapsed to the colon's start when there is no
113 /// name token — so a diagnostic always has somewhere to point.
114 pub fn name_range(&self) -> TextRange {
115 match self.name_token() {
116 Some(tok) => tok.text_range(),
117 None => {
118 let start = self.0.text_range().start();
119 TextRange::empty(start)
120 }
121 }
122 }
123
124 /// The range spanning every value line, or an empty range at the field's
125 /// end when the field has no value at all (`Package:` at EOF).
126 pub fn value_range(&self) -> TextRange {
127 let mut lines = self.value_lines();
128 let Some(first) = lines.next() else {
129 return TextRange::empty(self.0.text_range().end());
130 };
131 let last = lines.last().unwrap_or_else(|| first.clone());
132 TextRange::new(
133 first.syntax().text_range().start(),
134 last.syntax().text_range().end(),
135 )
136 }
137
138 /// The field's value lines, starting with the field's own line (everything
139 /// after the colon) and continuing through its continuation lines.
140 pub fn value_lines(&self) -> impl Iterator<Item = ValueLine> + use<> {
141 self.0.children().filter_map(ValueLine::cast)
142 }
143
144 /// Comment lines interleaved into this field's continuation block. R's
145 /// `read.dcf` skips them and resumes the field.
146 pub fn comment_lines(&self) -> impl Iterator<Item = CommentLine> + use<> {
147 self.0.children().filter_map(CommentLine::cast)
148 }
149
150 /// The field's **logical** value: each value line trimmed, joined with
151 /// `\n`. This is what a caller wanting `Depends` or `Roxygen` reads.
152 ///
153 /// Because the field's own line is itself a value line, a field whose own
154 /// line is empty folds with a leading `\n`:
155 /// `Collate:\n a.R\n b.R` yields `"\na.R\nb.R"`. R's `read.dcf` drops that
156 /// empty leading segment and yields `"a.R\nb.R"`. arity keeps it because
157 /// every caller predating this module did; changing it belongs in its own
158 /// commit, not here.
159 pub fn folded_value(&self) -> String {
160 self.value_lines()
161 .map(|line| line.trimmed_text())
162 .collect::<Vec<_>>()
163 .join("\n")
164 }
165
166 /// The exact source bytes of everything after the colon, newlines and
167 /// interleaved comments included. For the formatter, which rewrites rather
168 /// than reads.
169 pub fn raw_value_text(&self) -> String {
170 let mut out = String::new();
171 let mut past_colon = false;
172 for tok in self
173 .0
174 .descendants_with_tokens()
175 .filter_map(|el| el.into_token())
176 {
177 if past_colon {
178 out.push_str(tok.text());
179 } else if tok.kind() == SyntaxKind::COLON {
180 past_colon = true;
181 }
182 }
183 out
184 }
185}
186
187impl ValueLine {
188 /// The line's leading whitespace — a continuation's indent, or the space
189 /// after the colon on a field's own line. The formatter's re-indent handle.
190 pub fn indent(&self) -> Option<SyntaxToken> {
191 self.0
192 .children_with_tokens()
193 .filter_map(|el| el.into_token())
194 .next()
195 .filter(|tok| tok.kind() == SyntaxKind::WHITESPACE)
196 }
197
198 /// The line's content run, absent on a line that is only whitespace.
199 pub fn content(&self) -> Option<SyntaxToken> {
200 token(&self.0, SyntaxKind::VALUE_TEXT)
201 }
202
203 /// The line's content, trimmed — the unit [`Field::folded_value`] joins.
204 pub fn trimmed_text(&self) -> String {
205 self.content()
206 .map(|tok| tok.text().trim().to_string())
207 .unwrap_or_default()
208 }
209
210 /// The content run's source range, collapsed to the line's start when the
211 /// line has no content.
212 pub fn content_range(&self) -> TextRange {
213 match self.content() {
214 Some(tok) => tok.text_range(),
215 None => TextRange::empty(self.0.text_range().start()),
216 }
217 }
218}
219
220impl CommentLine {
221 /// The comment's text, excluding the newline.
222 pub fn text(&self) -> Option<SyntaxToken> {
223 token(&self.0, SyntaxKind::COMMENT)
224 }
225}
226
227impl MalformedLine {
228 /// The line's raw content, excluding the newline.
229 pub fn text(&self) -> Option<SyntaxToken> {
230 token(&self.0, SyntaxKind::TEXT)
231 }
232}