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 last field named `name`, across all records.
76    ///
77    /// Last occurrence wins, matching [`read.dcf`](https://stat.ethz.ch/R-manual/R-devel/library/base/html/read.dcf.html).
78    pub fn field(&self, name: &str) -> Option<Field> {
79        self.fields().filter(|field| field.name() == name).last()
80    }
81}
82
83impl Record {
84    /// This record's fields, in order.
85    pub fn fields(&self) -> impl Iterator<Item = Field> + use<> {
86        self.0.children().filter_map(Field::cast)
87    }
88
89    /// The last field of this record named `name`.
90    pub fn field(&self, name: &str) -> Option<Field> {
91        self.fields().filter(|field| field.name() == name).last()
92    }
93}
94
95impl Field {
96    /// The field's name, excluding any whitespace before the colon. Empty when
97    /// the source had no name at all (`: value`), which the parser also
98    /// diagnoses.
99    pub fn name(&self) -> SmolStr {
100        self.name_token()
101            .map(|tok| SmolStr::new(tok.text()))
102            .unwrap_or_default()
103    }
104
105    /// The `FIELD_NAME` token, absent for a nameless `: value`.
106    pub fn name_token(&self) -> Option<SyntaxToken> {
107        token(&self.0, SyntaxKind::FIELD_NAME)
108    }
109
110    /// The name's source range, collapsed to the colon's start when there is no
111    /// name token — so a diagnostic always has somewhere to point.
112    pub fn name_range(&self) -> TextRange {
113        match self.name_token() {
114            Some(tok) => tok.text_range(),
115            None => {
116                let start = self.0.text_range().start();
117                TextRange::empty(start)
118            }
119        }
120    }
121
122    /// The range spanning every value line, or an empty range at the field's
123    /// end when the field has no value at all (`Package:` at EOF).
124    pub fn value_range(&self) -> TextRange {
125        let mut lines = self.value_lines();
126        let Some(first) = lines.next() else {
127            return TextRange::empty(self.0.text_range().end());
128        };
129        let last = lines.last().unwrap_or_else(|| first.clone());
130        TextRange::new(
131            first.syntax().text_range().start(),
132            last.syntax().text_range().end(),
133        )
134    }
135
136    /// The field's value lines, starting with the field's own line (everything
137    /// after the colon) and continuing through its continuation lines.
138    pub fn value_lines(&self) -> impl Iterator<Item = ValueLine> + use<> {
139        self.0.children().filter_map(ValueLine::cast)
140    }
141
142    /// Comment lines interleaved into this field's continuation block. R's
143    /// `read.dcf` skips them and resumes the field.
144    pub fn comment_lines(&self) -> impl Iterator<Item = CommentLine> + use<> {
145        self.0.children().filter_map(CommentLine::cast)
146    }
147
148    /// The field's **logical** value: each nonempty value line trimmed, joined
149    /// with `\n`. This is what a caller wanting `Depends` or `Roxygen` reads.
150    /// Empty value lines contribute no segment, matching R's `read.dcf`.
151    pub fn folded_value(&self) -> String {
152        self.value_lines()
153            .map(|line| line.trimmed_text())
154            .filter(|line| !line.is_empty())
155            .collect::<Vec<_>>()
156            .join("\n")
157    }
158
159    /// The exact source bytes of everything after the colon, newlines and
160    /// interleaved comments included. For the formatter, which rewrites rather
161    /// than reads.
162    pub fn raw_value_text(&self) -> String {
163        let mut out = String::new();
164        let mut past_colon = false;
165        for tok in self
166            .0
167            .descendants_with_tokens()
168            .filter_map(|el| el.into_token())
169        {
170            if past_colon {
171                out.push_str(tok.text());
172            } else if tok.kind() == SyntaxKind::COLON {
173                past_colon = true;
174            }
175        }
176        out
177    }
178}
179
180impl ValueLine {
181    /// The line's leading whitespace — a continuation's indent, or the space
182    /// after the colon on a field's own line. The formatter's re-indent handle.
183    pub fn indent(&self) -> Option<SyntaxToken> {
184        self.0
185            .children_with_tokens()
186            .filter_map(|el| el.into_token())
187            .next()
188            .filter(|tok| tok.kind() == SyntaxKind::WHITESPACE)
189    }
190
191    /// The line's content run, absent on a line that is only whitespace.
192    pub fn content(&self) -> Option<SyntaxToken> {
193        token(&self.0, SyntaxKind::VALUE_TEXT)
194    }
195
196    /// The line's content, trimmed — the unit [`Field::folded_value`] joins.
197    pub fn trimmed_text(&self) -> String {
198        self.content()
199            .map(|tok| tok.text().trim().to_string())
200            .unwrap_or_default()
201    }
202
203    /// The content run's source range, collapsed to the line's start when the
204    /// line has no content.
205    pub fn content_range(&self) -> TextRange {
206        match self.content() {
207            Some(tok) => tok.text_range(),
208            None => TextRange::empty(self.0.text_range().start()),
209        }
210    }
211}
212
213impl CommentLine {
214    /// The comment's text, excluding the newline.
215    pub fn text(&self) -> Option<SyntaxToken> {
216        token(&self.0, SyntaxKind::COMMENT)
217    }
218}
219
220impl MalformedLine {
221    /// The line's raw content, excluding the newline.
222    pub fn text(&self) -> Option<SyntaxToken> {
223        token(&self.0, SyntaxKind::TEXT)
224    }
225}