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 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 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 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 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 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 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 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 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 assert!(!entries("Imports: pkg\n")[0].malformed_constraint());
426 }
427
428 #[test]
429 fn entries_span_continuation_lines() {
430 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 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 assert!(entries[1].constraint_text.is_none());
473 }
474
475 #[test]
476 fn the_fold_matches_the_ast_wrapper() {
477 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}