Skip to main content

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}