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 value line's content
253/// run, joined by `\n` — which is what makes the map trivial: a `VALUE_TEXT`
254/// token is already the trimmed run, so each segment is a verbatim slice of the
255/// 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            if !first {
269                text.push('\n');
270            }
271            first = false;
272            if let Some(tok) = line.content() {
273                let content = tok.text();
274                segments.push((text.len(), tok.text_range().start(), content.len()));
275                text.push_str(content);
276            }
277        }
278        Folded { text, segments }
279    }
280
281    /// The source range of a folded span. A span crossing a fold join covers
282    /// the newline and indent in the source too, which is what a reader of the
283    /// diagnostic expects to see underlined.
284    fn source_range(&self, span: Span) -> TextRange {
285        let start = self.source_offset(span.start, false);
286        let end = self.source_offset(span.end, true);
287        TextRange::new(start, end.max(start))
288    }
289
290    /// The source offset of a folded offset. `at_end` breaks the tie when the
291    /// offset sits exactly on a segment boundary: a span's end belongs to the
292    /// segment it closes, its start to the segment it opens.
293    fn source_offset(&self, folded: usize, at_end: bool) -> TextSize {
294        let mut last = TextSize::from(0);
295        for &(fstart, sstart, len) in &self.segments {
296            if folded < fstart {
297                // Between two segments (inside the join) — clamp to whichever
298                // side the caller is anchored on.
299                return if at_end { last } else { sstart };
300            }
301            if folded < fstart + len || (folded == fstart + len && (at_end || len == 0)) {
302                return sstart + TextSize::from((folded - fstart) as u32);
303            }
304            last = sstart + TextSize::from(len as u32);
305        }
306        last
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use crate::dcf;
314
315    /// The single field of a one-field document.
316    fn field(text: &str) -> (String, dcf::Field) {
317        let output = dcf::parse(text);
318        let field = output
319            .document()
320            .fields()
321            .next()
322            .expect("the fixture declares one field");
323        (text.to_string(), field)
324    }
325
326    fn entries(text: &str) -> Vec<DependencyEntry> {
327        let (_, f) = field(text);
328        dependency_entries(&f)
329    }
330
331    fn names(text: &str) -> Vec<String> {
332        entries(text).iter().map(|e| e.name.to_string()).collect()
333    }
334
335    /// Every entry's name and whole-entry text, sliced out of the *source* by
336    /// the reported ranges. This is what proves the spans, not the values.
337    fn sliced(text: &str) -> Vec<(String, String)> {
338        entries(text)
339            .iter()
340            .map(|e| (text[e.name_range].to_string(), text[e.range].to_string()))
341            .collect()
342    }
343
344    #[test]
345    fn bare_names_split_on_commas() {
346        assert_eq!(
347            names("Imports: dplyr, rlang, vctrs\n"),
348            ["dplyr", "rlang", "vctrs"]
349        );
350    }
351
352    #[test]
353    fn empty_entries_are_dropped() {
354        // A trailing comma and a doubled comma name no package.
355        assert_eq!(names("Imports: dplyr, , rlang,\n"), ["dplyr", "rlang"]);
356        assert!(names("Imports:\n").is_empty());
357        assert!(names("Imports:   \n").is_empty());
358    }
359
360    #[test]
361    fn r_entry_carries_its_floor() {
362        let entries = entries("Depends: R (>= 4.1.0)\n");
363        assert_eq!(entries.len(), 1);
364        assert_eq!(entries[0].name, "R");
365        let bound = entries[0].lower_bound().expect("a lower bound");
366        assert_eq!(bound.op, VersionOp::Ge);
367        assert_eq!(bound.version, "4.1.0");
368    }
369
370    #[test]
371    fn every_operator_lexes() {
372        let cases = [
373            (">= 1", VersionOp::Ge),
374            ("> 1", VersionOp::Gt),
375            ("<= 1", VersionOp::Le),
376            ("< 1", VersionOp::Lt),
377            ("== 1", VersionOp::Eq),
378            ("!= 1", VersionOp::Ne),
379        ];
380        for (constraint, op) in cases {
381            let text = format!("Imports: pkg ({constraint})\n");
382            let entries = entries(&text);
383            assert_eq!(entries[0].constraints.len(), 1, "{constraint}");
384            assert_eq!(entries[0].constraints[0].op, op, "{constraint}");
385            assert_eq!(entries[0].constraints[0].version, "1", "{constraint}");
386        }
387    }
388
389    #[test]
390    fn whitespace_around_the_operator_is_optional() {
391        let entries = entries("Depends: R(>=3.5.0)\n");
392        assert_eq!(entries[0].name, "R");
393        assert_eq!(entries[0].lower_bound().expect("a bound").version, "3.5.0");
394    }
395
396    #[test]
397    fn a_comma_inside_parentheses_does_not_split_the_entry() {
398        // The reason splitting is paren-aware: naive splitting would invent a
399        // package named `< 2.0`.
400        let entries = entries("Imports: pkg (>= 1.0, < 2.0), other\n");
401        assert_eq!(
402            entries.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
403            ["pkg", "other"]
404        );
405        assert_eq!(entries[0].constraints.len(), 2);
406        assert_eq!(entries[0].constraints[1].op, VersionOp::Lt);
407        assert_eq!(entries[0].constraints[1].version, "2.0");
408    }
409
410    #[test]
411    fn an_unparseable_constraint_keeps_the_name() {
412        for text in [
413            "Imports: pkg (garbage)\n",
414            "Imports: pkg (>=)\n",
415            "Imports: pkg ()\n",
416        ] {
417            let entries = entries(text);
418            assert_eq!(entries.len(), 1, "{text}");
419            assert_eq!(entries[0].name, "pkg", "{text}");
420            assert!(entries[0].constraints.is_empty(), "{text}");
421            assert!(entries[0].malformed_constraint(), "{text}");
422        }
423        // No parenthesized part at all is not malformed — it is the common case.
424        assert!(!entries("Imports: pkg\n")[0].malformed_constraint());
425    }
426
427    #[test]
428    fn entries_span_continuation_lines() {
429        // The canonical one-per-line style, and an entry split across the fold.
430        let text = "Imports:\n    dplyr (>= 1.0.0),\n    rlang,\n    vctrs\n";
431        assert_eq!(names(text), ["dplyr", "rlang", "vctrs"]);
432
433        let text = "Depends:\n    R (>=\n    4.1.0)\n";
434        let entries = entries(text);
435        assert_eq!(entries[0].name, "R");
436        assert_eq!(entries[0].lower_bound().expect("a bound").version, "4.1.0");
437    }
438
439    #[test]
440    fn ranges_point_at_the_source_bytes() {
441        assert_eq!(
442            sliced("Imports: dplyr (>= 1.0), rlang\n"),
443            [
444                ("dplyr".to_string(), "dplyr (>= 1.0)".to_string()),
445                ("rlang".to_string(), "rlang".to_string()),
446            ]
447        );
448        // Across a continuation line the whole-entry range covers the newline
449        // and indent, and the name range stays tight.
450        assert_eq!(
451            sliced("Imports:\n    dplyr,\n    rlang\n"),
452            [
453                ("dplyr".to_string(), "dplyr".to_string()),
454                ("rlang".to_string(), "rlang".to_string()),
455            ]
456        );
457    }
458
459    #[test]
460    fn constraint_ranges_point_at_the_source_bytes() {
461        let text = "Depends: R (>= 4.1.0), stats\n";
462        let entries = entries(text);
463        let (raw, range) = entries[0]
464            .constraint_text
465            .clone()
466            .expect("a parenthesized part");
467        assert_eq!(raw, ">= 4.1.0");
468        assert_eq!(&text[range], ">= 4.1.0");
469        assert_eq!(&text[entries[0].constraints[0].range], ">= 4.1.0");
470        // The second entry has none.
471        assert!(entries[1].constraint_text.is_none());
472    }
473
474    #[test]
475    fn the_fold_matches_the_ast_wrapper() {
476        // `Folded` must reproduce `Field::folded_value` byte for byte, or every
477        // range it hands out is off. Includes the leading-`\n` divergence case.
478        for text in [
479            "Imports: a, b\n",
480            "Imports:\n    a,\n    b\n",
481            "Imports: a\n    b\n",
482            "Imports:\n",
483        ] {
484            let (_, f) = field(text);
485            assert_eq!(Folded::new(&f).text, f.folded_value(), "{text:?}");
486        }
487    }
488
489    #[test]
490    fn dependency_fields_are_recognized_by_name() {
491        assert!(DEPENDENCY_FIELDS.iter().all(|f| is_dependency_field(f)));
492        assert!(!is_dependency_field("Collate"));
493        assert!(!is_dependency_field("depends"));
494    }
495}