1use rowan::{TextRange, TextSize};
24use smol_str::SmolStr;
25
26use crate::dcf::ast::Field;
27
28pub const DEPENDENCY_FIELDS: [&str; 5] =
30 ["Depends", "Imports", "Suggests", "LinkingTo", "Enhances"];
31
32pub fn is_dependency_field(name: &str) -> bool {
34 DEPENDENCY_FIELDS.contains(&name)
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum VersionOp {
40 Ge,
42 Gt,
44 Le,
46 Lt,
48 Eq,
50 Ne,
52}
53
54impl VersionOp {
55 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 pub fn is_lower_bound(self) -> bool {
78 matches!(self, VersionOp::Ge | VersionOp::Gt)
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Hash)]
84pub struct VersionConstraint {
85 pub op: VersionOp,
86 pub version: SmolStr,
89 pub range: TextRange,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Hash)]
95pub struct DependencyEntry {
96 pub name: SmolStr,
98 pub name_range: TextRange,
99 pub constraint_text: Option<(SmolStr, TextRange)>,
102 pub constraints: Vec<VersionConstraint>,
106 pub range: TextRange,
108}
109
110impl DependencyEntry {
111 pub fn malformed_constraint(&self) -> bool {
115 self.constraint_text.is_some() && self.constraints.is_empty()
116 }
117
118 pub fn lower_bound(&self) -> Option<&VersionConstraint> {
121 self.constraints.iter().find(|c| c.op.is_lower_bound())
122 }
123}
124
125pub 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
137fn entry(folded: &Folded, span: Span) -> Option<DependencyEntry> {
139 let text = &folded.text[span.start..span.end];
140 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
176fn 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
184fn 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#[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
216fn 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
227fn 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
249struct Folded {
257 text: String,
258 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 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 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 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 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 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 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 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 assert!(!entries("Imports: pkg\n")[0].malformed_constraint());
425 }
426
427 #[test]
428 fn entries_span_continuation_lines() {
429 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 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 assert!(entries[1].constraint_text.is_none());
472 }
473
474 #[test]
475 fn the_fold_matches_the_ast_wrapper() {
476 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}