Skip to main content

arity_parser/dcf/
deps.rs

1//! The dependency-field grammar: `name (>= version), name, ...`.
2//!
3//! `Depends`, `Imports`, `Suggests`, `LinkingTo`, and `Enhances` all carry a
4//! comma-separated list of package names, each optionally followed by a
5//! parenthesized version constraint. That shape is *grammar*, so it lives here
6//! next to the DCF parser rather than in a consumer.
7//!
8//! What is deliberately **not** here is R semantics: that `R` names the
9//! language and not a package, that `Depends` attaches to the search path while
10//! `Imports` does not, and how two version strings order. Those are the
11//! consumer's, and the version is handed back as text for exactly that reason.
12//!
13//! Every range is a **source** range, into the same buffer the field was parsed
14//! from — so a diagnostic or a hover can point at one entry without a second
15//! mapping layer. Entries are reported across continuation lines: the field's
16//! logical value is what R splits, and a list wrapped one-per-line is the
17//! canonical style.
18//!
19//! Parsing never fails. An entry whose parenthesized part does not yield a
20//! constraint keeps its name and reports [`DependencyEntry::malformed_constraint`],
21//! because a broken constraint must never hide a package name from resolution.
22
23use rowan::{TextRange, TextSize};
24use smol_str::SmolStr;
25
26use crate::dcf::ast::Field;
27
28/// The DCF fields that carry a dependency list, in R's canonical order.
29pub const DEPENDENCY_FIELDS: [&str; 5] =
30    ["Depends", "Imports", "Suggests", "LinkingTo", "Enhances"];
31
32/// Whether `name` is one of [`DEPENDENCY_FIELDS`].
33pub fn is_dependency_field(name: &str) -> bool {
34    DEPENDENCY_FIELDS.contains(&name)
35}
36
37/// The comparison operator of a version constraint.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum VersionOp {
40    /// `>=`
41    Ge,
42    /// `>`
43    Gt,
44    /// `<=`
45    Le,
46    /// `<`
47    Lt,
48    /// `==`
49    Eq,
50    /// `!=`
51    Ne,
52}
53
54impl VersionOp {
55    /// The operator this text starts with, and its length in bytes. Two-byte
56    /// operators are tried first, so `>=` never lexes as `>` followed by `=`.
57    fn lex(text: &str) -> Option<(VersionOp, usize)> {
58        const TWO: [(&str, VersionOp); 4] = [
59            (">=", VersionOp::Ge),
60            ("<=", VersionOp::Le),
61            ("==", VersionOp::Eq),
62            ("!=", VersionOp::Ne),
63        ];
64        const ONE: [(&str, VersionOp); 2] = [(">", VersionOp::Gt), ("<", VersionOp::Lt)];
65        TWO.iter()
66            .find(|(s, _)| text.starts_with(s))
67            .map(|(s, op)| (*op, s.len()))
68            .or_else(|| {
69                ONE.iter()
70                    .find(|(s, _)| text.starts_with(s))
71                    .map(|(s, op)| (*op, s.len()))
72            })
73    }
74
75    /// Whether this operator states a *lower* bound — the only kind a version
76    /// floor can be read from.
77    pub fn is_lower_bound(self) -> bool {
78        matches!(self, VersionOp::Ge | VersionOp::Gt)
79    }
80}
81
82/// One version constraint: an operator and the version it compares against.
83#[derive(Debug, Clone, PartialEq, Eq, Hash)]
84pub struct VersionConstraint {
85    pub op: VersionOp,
86    /// The version exactly as written, trimmed (`4.1.0`, `1.2-3`). Text, not a
87    /// parsed version: how two versions order is the consumer's policy.
88    pub version: SmolStr,
89    /// The whole constraint's source range, operator included.
90    pub range: TextRange,
91}
92
93/// One entry of a dependency field.
94#[derive(Debug, Clone, PartialEq, Eq, Hash)]
95pub struct DependencyEntry {
96    /// The entry's name as written: a package name, or the literal `R`.
97    pub name: SmolStr,
98    pub name_range: TextRange,
99    /// The text between the parentheses, verbatim, and its range — `None` when
100    /// the entry carries no parenthesized part at all.
101    pub constraint_text: Option<(SmolStr, TextRange)>,
102    /// The constraints parsed out of `constraint_text`, in source order. R
103    /// writes one, but a comma-separated pair (`(>= 1.0, < 2.0)`) is the reason
104    /// entry splitting has to be paren-aware, so both are supported.
105    pub constraints: Vec<VersionConstraint>,
106    /// The whole entry's source range.
107    pub range: TextRange,
108}
109
110impl DependencyEntry {
111    /// The entry declares a parenthesized part that yielded no constraint —
112    /// exactly the "unparseable version constraint" case, kept as a fact here
113    /// and reported as a diagnostic by the consumer.
114    pub fn malformed_constraint(&self) -> bool {
115        self.constraint_text.is_some() && self.constraints.is_empty()
116    }
117
118    /// The first constraint stating a lower bound, which is what a version
119    /// floor asks for.
120    pub fn lower_bound(&self) -> Option<&VersionConstraint> {
121        self.constraints.iter().find(|c| c.op.is_lower_bound())
122    }
123}
124
125/// Split a dependency field into its entries, in source order.
126///
127/// Empty entries (a trailing or doubled comma) are dropped — R tolerates them
128/// and they name no package.
129pub fn dependency_entries(field: &Field) -> Vec<DependencyEntry> {
130    let folded = Folded::new(field);
131    split_top_level(&folded.text, 0)
132        .into_iter()
133        .filter_map(|span| entry(&folded, span))
134        .collect()
135}
136
137/// One entry from its span in the folded text.
138fn entry(folded: &Folded, span: Span) -> Option<DependencyEntry> {
139    let text = &folded.text[span.start..span.end];
140    // The parenthesized part starts at the first `(`; everything before it is
141    // the name. An unclosed `(` still yields a name, which is the point.
142    let (name_span, paren) = match text.find('(') {
143        Some(open) => {
144            let close = text.rfind(')').filter(|c| *c > open).unwrap_or(text.len());
145            (
146                Span::new(span.start, span.start + open),
147                Some(Span::new(span.start + open + 1, span.start + close)),
148            )
149        }
150        None => (span, None),
151    };
152
153    let name_span = trim(&folded.text, name_span);
154    if name_span.is_empty() {
155        return None;
156    }
157
158    let constraint_text = paren.map(|p| {
159        let inner = trim(&folded.text, p);
160        (
161            SmolStr::new(&folded.text[inner.start..inner.end]),
162            folded.source_range(inner),
163        )
164    });
165    let constraints = paren.map(|p| constraints_in(folded, p)).unwrap_or_default();
166
167    Some(DependencyEntry {
168        name: SmolStr::new(&folded.text[name_span.start..name_span.end]),
169        name_range: folded.source_range(name_span),
170        constraint_text,
171        constraints,
172        range: folded.source_range(trim(&folded.text, span)),
173    })
174}
175
176/// The constraints inside a parenthesized span.
177fn constraints_in(folded: &Folded, paren: Span) -> Vec<VersionConstraint> {
178    split_top_level(&folded.text[paren.start..paren.end], paren.start)
179        .into_iter()
180        .filter_map(|span| constraint(folded, span))
181        .collect()
182}
183
184/// One `op version` constraint from its span in the folded text.
185fn constraint(folded: &Folded, span: Span) -> Option<VersionConstraint> {
186    let span = trim(&folded.text, span);
187    let (op, len) = VersionOp::lex(&folded.text[span.start..span.end])?;
188    let version = trim(&folded.text, Span::new(span.start + len, span.end));
189    if version.is_empty() {
190        return None;
191    }
192    Some(VersionConstraint {
193        op,
194        version: SmolStr::new(&folded.text[version.start..version.end]),
195        range: folded.source_range(span),
196    })
197}
198
199/// A half-open byte span of the folded text.
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201struct Span {
202    start: usize,
203    end: usize,
204}
205
206impl Span {
207    fn new(start: usize, end: usize) -> Self {
208        Span { start, end }
209    }
210
211    fn is_empty(&self) -> bool {
212        self.start >= self.end
213    }
214}
215
216/// `span` with leading and trailing ASCII whitespace removed.
217fn trim(text: &str, span: Span) -> Span {
218    let slice = &text[span.start..span.end];
219    let lead = slice.len() - slice.trim_start().len();
220    let trail = slice.len() - slice.trim_end().len();
221    if lead + trail >= slice.len() {
222        return Span::new(span.start, span.start);
223    }
224    Span::new(span.start + lead, span.end - trail)
225}
226
227/// Split `text` on commas that are not inside parentheses, returning spans
228/// offset by `base`. Paren-awareness is not optional: `pkg (>= 1.0, < 2.0)` is
229/// one entry, and splitting it naively would invent a package named `< 2.0`.
230fn split_top_level(text: &str, base: usize) -> Vec<Span> {
231    let mut spans = Vec::new();
232    let mut depth = 0usize;
233    let mut start = 0usize;
234    for (i, ch) in text.char_indices() {
235        match ch {
236            '(' => depth += 1,
237            ')' => depth = depth.saturating_sub(1),
238            ',' if depth == 0 => {
239                spans.push(Span::new(base + start, base + i));
240                start = i + 1;
241            }
242            _ => {}
243        }
244    }
245    spans.push(Span::new(base + start, base + text.len()));
246    spans
247}
248
249/// A field's folded value next to a map from folded offsets back to source
250/// offsets.
251///
252/// The fold is exactly [`Field::folded_value`]'s — each nonempty value line's
253/// content run, joined by `\n` — which is what makes the map trivial: a
254/// `VALUE_TEXT` token is already the trimmed run, so each segment is a verbatim
255/// slice of the source and offsets inside it shift by a constant.
256struct Folded {
257    text: String,
258    /// `(folded start, source start, byte length)` per contributing line.
259    segments: Vec<(usize, TextSize, usize)>,
260}
261
262impl Folded {
263    fn new(field: &Field) -> Self {
264        let mut text = String::new();
265        let mut segments = Vec::new();
266        let mut first = true;
267        for line in field.value_lines() {
268            let Some(tok) = line.content().filter(|tok| !tok.text().is_empty()) else {
269                continue;
270            };
271            if !first {
272                text.push('\n');
273            }
274            first = false;
275            let content = tok.text();
276            segments.push((text.len(), tok.text_range().start(), content.len()));
277            text.push_str(content);
278        }
279        Folded { text, segments }
280    }
281
282    /// The source range of a folded span. A span crossing a fold join covers
283    /// the newline and indent in the source too, which is what a reader of the
284    /// diagnostic expects to see underlined.
285    fn source_range(&self, span: Span) -> TextRange {
286        let start = self.source_offset(span.start, false);
287        let end = self.source_offset(span.end, true);
288        TextRange::new(start, end.max(start))
289    }
290
291    /// The source offset of a folded offset. `at_end` breaks the tie when the
292    /// offset sits exactly on a segment boundary: a span's end belongs to the
293    /// segment it closes, its start to the segment it opens.
294    fn source_offset(&self, folded: usize, at_end: bool) -> TextSize {
295        let mut last = TextSize::from(0);
296        for &(fstart, sstart, len) in &self.segments {
297            if folded < fstart {
298                // Between two segments (inside the join) — clamp to whichever
299                // side the caller is anchored on.
300                return if at_end { last } else { sstart };
301            }
302            if folded < fstart + len || (folded == fstart + len && (at_end || len == 0)) {
303                return sstart + TextSize::from((folded - fstart) as u32);
304            }
305            last = sstart + TextSize::from(len as u32);
306        }
307        last
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use crate::dcf;
315
316    /// The single field of a one-field document.
317    fn field(text: &str) -> (String, dcf::Field) {
318        let output = dcf::parse(text);
319        let field = output
320            .document()
321            .fields()
322            .next()
323            .expect("the fixture declares one field");
324        (text.to_string(), field)
325    }
326
327    fn entries(text: &str) -> Vec<DependencyEntry> {
328        let (_, f) = field(text);
329        dependency_entries(&f)
330    }
331
332    fn names(text: &str) -> Vec<String> {
333        entries(text).iter().map(|e| e.name.to_string()).collect()
334    }
335
336    /// Every entry's name and whole-entry text, sliced out of the *source* by
337    /// the reported ranges. This is what proves the spans, not the values.
338    fn sliced(text: &str) -> Vec<(String, String)> {
339        entries(text)
340            .iter()
341            .map(|e| (text[e.name_range].to_string(), text[e.range].to_string()))
342            .collect()
343    }
344
345    #[test]
346    fn bare_names_split_on_commas() {
347        assert_eq!(
348            names("Imports: dplyr, rlang, vctrs\n"),
349            ["dplyr", "rlang", "vctrs"]
350        );
351    }
352
353    #[test]
354    fn empty_entries_are_dropped() {
355        // A trailing comma and a doubled comma name no package.
356        assert_eq!(names("Imports: dplyr, , rlang,\n"), ["dplyr", "rlang"]);
357        assert!(names("Imports:\n").is_empty());
358        assert!(names("Imports:   \n").is_empty());
359    }
360
361    #[test]
362    fn r_entry_carries_its_floor() {
363        let entries = entries("Depends: R (>= 4.1.0)\n");
364        assert_eq!(entries.len(), 1);
365        assert_eq!(entries[0].name, "R");
366        let bound = entries[0].lower_bound().expect("a lower bound");
367        assert_eq!(bound.op, VersionOp::Ge);
368        assert_eq!(bound.version, "4.1.0");
369    }
370
371    #[test]
372    fn every_operator_lexes() {
373        let cases = [
374            (">= 1", VersionOp::Ge),
375            ("> 1", VersionOp::Gt),
376            ("<= 1", VersionOp::Le),
377            ("< 1", VersionOp::Lt),
378            ("== 1", VersionOp::Eq),
379            ("!= 1", VersionOp::Ne),
380        ];
381        for (constraint, op) in cases {
382            let text = format!("Imports: pkg ({constraint})\n");
383            let entries = entries(&text);
384            assert_eq!(entries[0].constraints.len(), 1, "{constraint}");
385            assert_eq!(entries[0].constraints[0].op, op, "{constraint}");
386            assert_eq!(entries[0].constraints[0].version, "1", "{constraint}");
387        }
388    }
389
390    #[test]
391    fn whitespace_around_the_operator_is_optional() {
392        let entries = entries("Depends: R(>=3.5.0)\n");
393        assert_eq!(entries[0].name, "R");
394        assert_eq!(entries[0].lower_bound().expect("a bound").version, "3.5.0");
395    }
396
397    #[test]
398    fn a_comma_inside_parentheses_does_not_split_the_entry() {
399        // The reason splitting is paren-aware: naive splitting would invent a
400        // package named `< 2.0`.
401        let entries = entries("Imports: pkg (>= 1.0, < 2.0), other\n");
402        assert_eq!(
403            entries.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
404            ["pkg", "other"]
405        );
406        assert_eq!(entries[0].constraints.len(), 2);
407        assert_eq!(entries[0].constraints[1].op, VersionOp::Lt);
408        assert_eq!(entries[0].constraints[1].version, "2.0");
409    }
410
411    #[test]
412    fn an_unparseable_constraint_keeps_the_name() {
413        for text in [
414            "Imports: pkg (garbage)\n",
415            "Imports: pkg (>=)\n",
416            "Imports: pkg ()\n",
417        ] {
418            let entries = entries(text);
419            assert_eq!(entries.len(), 1, "{text}");
420            assert_eq!(entries[0].name, "pkg", "{text}");
421            assert!(entries[0].constraints.is_empty(), "{text}");
422            assert!(entries[0].malformed_constraint(), "{text}");
423        }
424        // No parenthesized part at all is not malformed — it is the common case.
425        assert!(!entries("Imports: pkg\n")[0].malformed_constraint());
426    }
427
428    #[test]
429    fn entries_span_continuation_lines() {
430        // The canonical one-per-line style, and an entry split across the fold.
431        let text = "Imports:\n    dplyr (>= 1.0.0),\n    rlang,\n    vctrs\n";
432        assert_eq!(names(text), ["dplyr", "rlang", "vctrs"]);
433
434        let text = "Depends:\n    R (>=\n    4.1.0)\n";
435        let entries = entries(text);
436        assert_eq!(entries[0].name, "R");
437        assert_eq!(entries[0].lower_bound().expect("a bound").version, "4.1.0");
438    }
439
440    #[test]
441    fn ranges_point_at_the_source_bytes() {
442        assert_eq!(
443            sliced("Imports: dplyr (>= 1.0), rlang\n"),
444            [
445                ("dplyr".to_string(), "dplyr (>= 1.0)".to_string()),
446                ("rlang".to_string(), "rlang".to_string()),
447            ]
448        );
449        // Across a continuation line the whole-entry range covers the newline
450        // and indent, and the name range stays tight.
451        assert_eq!(
452            sliced("Imports:\n    dplyr,\n    rlang\n"),
453            [
454                ("dplyr".to_string(), "dplyr".to_string()),
455                ("rlang".to_string(), "rlang".to_string()),
456            ]
457        );
458    }
459
460    #[test]
461    fn constraint_ranges_point_at_the_source_bytes() {
462        let text = "Depends: R (>= 4.1.0), stats\n";
463        let entries = entries(text);
464        let (raw, range) = entries[0]
465            .constraint_text
466            .clone()
467            .expect("a parenthesized part");
468        assert_eq!(raw, ">= 4.1.0");
469        assert_eq!(&text[range], ">= 4.1.0");
470        assert_eq!(&text[entries[0].constraints[0].range], ">= 4.1.0");
471        // The second entry has none.
472        assert!(entries[1].constraint_text.is_none());
473    }
474
475    #[test]
476    fn the_fold_matches_the_ast_wrapper() {
477        // `Folded` must reproduce `Field::folded_value` byte for byte, or every
478        // range it hands out is off. Includes an empty field-header value.
479        for text in [
480            "Imports: a, b\n",
481            "Imports:\n    a,\n    b\n",
482            "Imports: a\n    b\n",
483            "Imports:\n",
484        ] {
485            let (_, f) = field(text);
486            assert_eq!(Folded::new(&f).text, f.folded_value(), "{text:?}");
487        }
488    }
489
490    #[test]
491    fn dependency_fields_are_recognized_by_name() {
492        assert!(DEPENDENCY_FIELDS.iter().all(|f| is_dependency_field(f)));
493        assert!(!is_dependency_field("Collate"));
494        assert!(!is_dependency_field("depends"));
495    }
496}