1use std::collections::BTreeMap;
2use std::fmt;
3use std::num::{NonZeroU16, NonZeroUsize};
4use std::ops::{RangeFrom, RangeInclusive};
5use std::str::FromStr;
6
7use ruff_db::vendored::VendoredFileSystem;
8use ruff_python_ast::{PythonVersion, PythonVersionDeserializationError};
9
10use crate::FxOrderMap;
11use crate::module_name::ModuleName;
12
13pub(crate) fn vendored_typeshed_versions(vendored: &VendoredFileSystem) -> TypeshedVersions {
14 TypeshedVersions::from_str(
15 &vendored
16 .read_to_string("stdlib/VERSIONS")
17 .expect("The vendored typeshed stubs should contain a VERSIONS file"),
18 )
19 .expect("The VERSIONS file in the vendored typeshed stubs should be well-formed")
20}
21
22#[derive(Debug, PartialEq, Eq, Clone)]
23pub struct TypeshedVersionsParseError {
24 line_number: Option<NonZeroU16>,
25 reason: TypeshedVersionsParseErrorKind,
26}
27
28impl fmt::Display for TypeshedVersionsParseError {
29 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30 let TypeshedVersionsParseError {
31 line_number,
32 reason,
33 } = self;
34 if let Some(line_number) = line_number {
35 write!(
36 f,
37 "Error while parsing line {line_number} of typeshed's VERSIONS file: {reason}"
38 )
39 } else {
40 write!(f, "Error while parsing typeshed's VERSIONS file: {reason}")
41 }
42 }
43}
44
45impl std::error::Error for TypeshedVersionsParseError {
46 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
47 if let TypeshedVersionsParseErrorKind::VersionParseError(err) = &self.reason {
48 err.source()
49 } else {
50 None
51 }
52 }
53}
54
55#[derive(Debug, PartialEq, Eq, Clone, thiserror::Error)]
56pub enum TypeshedVersionsParseErrorKind {
57 #[error("File has too many lines ({0}); maximum allowed is {max_allowed}", max_allowed = NonZeroU16::MAX)]
58 TooManyLines(NonZeroUsize),
59 #[error("Expected every non-comment line to have exactly one colon")]
60 UnexpectedNumberOfColons,
61 #[error("Expected all components of '{0}' to be valid Python identifiers")]
62 InvalidModuleName(String),
63 #[error("Expected every non-comment line to have exactly one '-' character")]
64 UnexpectedNumberOfHyphens,
65 #[error("{0}")]
66 VersionParseError(#[from] PythonVersionDeserializationError),
67}
68
69#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize)]
70pub struct TypeshedVersions(FxOrderMap<ModuleName, PyVersionRange>);
71
72impl TypeshedVersions {
73 #[must_use]
74 pub fn exact(&self, module_name: &ModuleName) -> Option<&PyVersionRange> {
75 self.0.get(module_name)
76 }
77
78 #[must_use]
79 pub(crate) fn query_module(
80 &self,
81 module: &ModuleName,
82 python_version: PythonVersion,
83 ) -> TypeshedVersionsQueryResult {
84 if let Some(range) = self.exact(module) {
85 if range.contains(python_version) {
86 TypeshedVersionsQueryResult::Exists
87 } else {
88 TypeshedVersionsQueryResult::DoesNotExist
89 }
90 } else {
91 let mut module = module.parent();
92 while let Some(module_to_try) = module {
93 if let Some(range) = self.exact(&module_to_try) {
94 return {
95 if range.contains(python_version) {
96 TypeshedVersionsQueryResult::MaybeExists
97 } else {
98 TypeshedVersionsQueryResult::DoesNotExist
99 }
100 };
101 }
102 module = module_to_try.parent();
103 }
104 TypeshedVersionsQueryResult::DoesNotExist
105 }
106 }
107}
108
109#[derive(Debug, Copy, PartialEq, Eq, Clone, Hash)]
112pub(crate) enum TypeshedVersionsQueryResult {
113 Exists,
120
121 DoesNotExist,
140
141 MaybeExists,
156}
157
158impl FromStr for TypeshedVersions {
159 type Err = TypeshedVersionsParseError;
160
161 fn from_str(s: &str) -> Result<Self, Self::Err> {
162 let mut map = FxOrderMap::default();
163
164 for (line_index, line) in s.lines().enumerate() {
165 let line_number = NonZeroUsize::new(line_index.saturating_add(1)).unwrap();
167
168 let Ok(line_number) = NonZeroU16::try_from(line_number) else {
169 return Err(TypeshedVersionsParseError {
170 line_number: None,
171 reason: TypeshedVersionsParseErrorKind::TooManyLines(line_number),
172 });
173 };
174
175 let Some(content) = line.split('#').map(str::trim).next() else {
176 continue;
177 };
178 if content.is_empty() {
179 continue;
180 }
181
182 let mut parts = content.split(':').map(str::trim);
183 let (Some(module_name), Some(rest), None) = (parts.next(), parts.next(), parts.next())
184 else {
185 return Err(TypeshedVersionsParseError {
186 line_number: Some(line_number),
187 reason: TypeshedVersionsParseErrorKind::UnexpectedNumberOfColons,
188 });
189 };
190
191 let Some(module_name) = ModuleName::new(module_name) else {
192 return Err(TypeshedVersionsParseError {
193 line_number: Some(line_number),
194 reason: TypeshedVersionsParseErrorKind::InvalidModuleName(
195 module_name.to_string(),
196 ),
197 });
198 };
199
200 match PyVersionRange::from_str(rest) {
201 Ok(version) => map.insert(module_name, version),
202 Err(reason) => {
203 return Err(TypeshedVersionsParseError {
204 line_number: Some(line_number),
205 reason,
206 });
207 }
208 };
209 }
210
211 Ok(Self(map))
212 }
213}
214
215impl fmt::Display for TypeshedVersions {
216 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217 let sorted_items: BTreeMap<&ModuleName, &PyVersionRange> = self.0.iter().collect();
218 for (module_name, range) in sorted_items {
219 writeln!(f, "{module_name}: {range}")?;
220 }
221 Ok(())
222 }
223}
224
225#[derive(Debug, Clone, Eq, PartialEq, Hash, get_size2::GetSize)]
226pub enum PyVersionRange {
227 AvailableFrom(RangeFrom<PythonVersion>),
228 AvailableWithin(RangeInclusive<PythonVersion>),
229}
230
231impl PyVersionRange {
232 #[must_use]
233 pub fn contains(&self, version: PythonVersion) -> bool {
234 match self {
235 Self::AvailableFrom(inner) => inner.contains(&version),
236 Self::AvailableWithin(inner) => inner.contains(&version),
237 }
238 }
239
240 pub fn diagnostic_display(&self) -> impl std::fmt::Display {
242 fmt::from_fn(|f| match self {
243 PyVersionRange::AvailableFrom(range_from) => write!(f, "{}+", range_from.start),
244 PyVersionRange::AvailableWithin(range_inclusive) => {
245 if range_inclusive.start() <= &(PythonVersion { major: 3, minor: 0 }) {
249 write!(f, "<={}", range_inclusive.end())
250 } else {
251 write!(f, "{}-{}", range_inclusive.start(), range_inclusive.end())
252 }
253 }
254 })
255 }
256}
257
258impl FromStr for PyVersionRange {
259 type Err = TypeshedVersionsParseErrorKind;
260
261 fn from_str(s: &str) -> Result<Self, Self::Err> {
262 let mut parts = s.split('-').map(str::trim);
263 match (parts.next(), parts.next(), parts.next()) {
264 (Some(lower), Some(""), None) => {
265 let lower = PythonVersion::from_str(lower)?;
266 Ok(Self::AvailableFrom(lower..))
267 }
268 (Some(lower), Some(upper), None) => {
269 let lower = PythonVersion::from_str(lower)?;
270 let upper = PythonVersion::from_str(upper)?;
271 Ok(Self::AvailableWithin(lower..=upper))
272 }
273 _ => Err(TypeshedVersionsParseErrorKind::UnexpectedNumberOfHyphens),
274 }
275 }
276}
277
278impl fmt::Display for PyVersionRange {
279 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
280 match self {
281 Self::AvailableFrom(range_from) => write!(f, "{}-", range_from.start),
282 Self::AvailableWithin(range_inclusive) => {
283 write!(f, "{}-{}", range_inclusive.start(), range_inclusive.end())
284 }
285 }
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 #![expect(
292 clippy::disallowed_methods,
293 reason = "These are tests, so it's fine to do I/O by-passing System."
294 )]
295
296 use std::fmt::Write as _;
297 use std::num::{IntErrorKind, NonZeroU16};
298 use std::path::Path;
299
300 use super::*;
301 use insta::assert_snapshot;
302
303 const TYPESHED_STDLIB_DIR: &str = "stdlib";
304
305 const ONE: Option<NonZeroU16> = Some(NonZeroU16::new(1).unwrap());
306
307 impl TypeshedVersions {
308 #[must_use]
309 fn contains_exact(&self, module: &ModuleName) -> bool {
310 self.exact(module).is_some()
311 }
312
313 #[must_use]
314 fn len(&self) -> usize {
315 self.0.len()
316 }
317 }
318
319 #[test]
320 fn can_parse_vendored_versions_file() {
321 let versions = vendored_typeshed_versions(ty_vendored::file_system());
322 assert!(versions.len() > 100);
323 assert!(versions.len() < 1000);
324
325 let asyncio = ModuleName::new_static("asyncio").unwrap();
326 let asyncio_staggered = ModuleName::new_static("asyncio.staggered").unwrap();
327 let audioop = ModuleName::new_static("audioop").unwrap();
328
329 assert!(versions.contains_exact(&asyncio));
330 assert_eq!(
331 versions.query_module(&asyncio, PythonVersion::PY310),
332 TypeshedVersionsQueryResult::Exists
333 );
334
335 assert!(versions.contains_exact(&asyncio_staggered));
336 assert_eq!(
337 versions.query_module(&asyncio_staggered, PythonVersion::PY38),
338 TypeshedVersionsQueryResult::Exists
339 );
340 assert_eq!(
341 versions.query_module(&asyncio_staggered, PythonVersion::PY37),
342 TypeshedVersionsQueryResult::DoesNotExist
343 );
344
345 assert!(versions.contains_exact(&audioop));
346 assert_eq!(
347 versions.query_module(&audioop, PythonVersion::PY312),
348 TypeshedVersionsQueryResult::Exists
349 );
350 assert_eq!(
351 versions.query_module(&audioop, PythonVersion::PY313),
352 TypeshedVersionsQueryResult::DoesNotExist
353 );
354 }
355
356 #[test]
357 fn typeshed_versions_consistent_with_vendored_stubs() {
358 let vendored_typeshed_versions = vendored_typeshed_versions(ty_vendored::file_system());
359 let vendored_typeshed_dir =
360 Path::new(env!("CARGO_MANIFEST_DIR")).join("../ty_vendored/vendor/typeshed");
361
362 let mut empty_iterator = true;
363
364 let stdlib_stubs_path = vendored_typeshed_dir.join(TYPESHED_STDLIB_DIR);
365
366 for entry in std::fs::read_dir(&stdlib_stubs_path).unwrap() {
367 empty_iterator = false;
368 let entry = entry.unwrap();
369 let absolute_path = entry.path();
370
371 let relative_path = absolute_path
372 .strip_prefix(&stdlib_stubs_path)
373 .unwrap_or_else(|_| {
374 panic!(
375 "Expected path to be a child of {stdlib_stubs_path:?} \
376 but found {absolute_path:?}"
377 )
378 });
379
380 let relative_path_str = relative_path.as_os_str().to_str().unwrap_or_else(|| {
381 panic!("Expected all typeshed paths to be valid UTF-8; got {relative_path:?}")
382 });
383 if relative_path_str == "VERSIONS" {
384 continue;
385 }
386
387 let top_level_module = if let Some(extension) = relative_path.extension() {
388 let extension = extension.to_str().unwrap_or_else(|| {
390 panic!(
391 "Expected all file extensions to be UTF-8; \
392 was not true for {relative_path:?}"
393 )
394 });
395
396 relative_path_str
397 .strip_suffix(extension)
398 .and_then(|string| string.strip_suffix('.'))
399 .unwrap_or_else(|| {
400 panic!(
401 "Expected path {relative_path_str:?} to end \
402 with computed extension {extension:?}"
403 )
404 })
405 } else {
406 relative_path_str
408 };
409
410 let top_level_module = ModuleName::new(top_level_module)
411 .unwrap_or_else(|| panic!("{top_level_module:?} was not a valid module name!"));
412
413 assert!(vendored_typeshed_versions.contains_exact(&top_level_module));
414 }
415
416 assert!(
417 !empty_iterator,
418 "Expected there to be at least one file or directory in the vendored typeshed stubs"
419 );
420 }
421
422 #[test]
423 fn can_parse_mock_versions_file() {
424 const VERSIONS: &str = "\
425# a comment
426 # some more comment
427# yet more comment
428
429
430# and some more comment
431
432bar: 2.7-3.10
433
434# more comment
435bar.baz: 3.1-3.9
436foo: 3.8- # trailing comment
437";
438 let parsed_versions = TypeshedVersions::from_str(VERSIONS).unwrap();
439 assert_eq!(parsed_versions.len(), 3);
440 assert_snapshot!(parsed_versions.to_string(), @"
441 bar: 2.7-3.10
442 bar.baz: 3.1-3.9
443 foo: 3.8-
444 "
445 );
446 }
447
448 #[test]
449 fn version_within_range_parsed_correctly() {
450 let parsed_versions = TypeshedVersions::from_str("bar: 2.7-3.10").unwrap();
451 let bar = ModuleName::new_static("bar").unwrap();
452
453 assert!(parsed_versions.contains_exact(&bar));
454 assert_eq!(
455 parsed_versions.query_module(&bar, PythonVersion::PY37),
456 TypeshedVersionsQueryResult::Exists
457 );
458 assert_eq!(
459 parsed_versions.query_module(&bar, PythonVersion::PY310),
460 TypeshedVersionsQueryResult::Exists
461 );
462 assert_eq!(
463 parsed_versions.query_module(&bar, PythonVersion::PY311),
464 TypeshedVersionsQueryResult::DoesNotExist
465 );
466 }
467
468 #[test]
469 fn version_from_range_parsed_correctly() {
470 let parsed_versions = TypeshedVersions::from_str("foo: 3.8-").unwrap();
471 let foo = ModuleName::new_static("foo").unwrap();
472
473 assert!(parsed_versions.contains_exact(&foo));
474 assert_eq!(
475 parsed_versions.query_module(&foo, PythonVersion::PY37),
476 TypeshedVersionsQueryResult::DoesNotExist
477 );
478 assert_eq!(
479 parsed_versions.query_module(&foo, PythonVersion::PY38),
480 TypeshedVersionsQueryResult::Exists
481 );
482 assert_eq!(
483 parsed_versions.query_module(&foo, PythonVersion::PY311),
484 TypeshedVersionsQueryResult::Exists
485 );
486 }
487
488 #[test]
489 fn explicit_submodule_parsed_correctly() {
490 let parsed_versions = TypeshedVersions::from_str("bar.baz: 3.1-3.9").unwrap();
491 let bar_baz = ModuleName::new_static("bar.baz").unwrap();
492
493 assert!(parsed_versions.contains_exact(&bar_baz));
494 assert_eq!(
495 parsed_versions.query_module(&bar_baz, PythonVersion::PY37),
496 TypeshedVersionsQueryResult::Exists
497 );
498 assert_eq!(
499 parsed_versions.query_module(&bar_baz, PythonVersion::PY39),
500 TypeshedVersionsQueryResult::Exists
501 );
502 assert_eq!(
503 parsed_versions.query_module(&bar_baz, PythonVersion::PY310),
504 TypeshedVersionsQueryResult::DoesNotExist
505 );
506 }
507
508 #[test]
509 fn implicit_submodule_queried_correctly() {
510 let parsed_versions = TypeshedVersions::from_str("bar: 2.7-3.10").unwrap();
511 let bar_eggs = ModuleName::new_static("bar.eggs").unwrap();
512
513 assert!(!parsed_versions.contains_exact(&bar_eggs));
514 assert_eq!(
515 parsed_versions.query_module(&bar_eggs, PythonVersion::PY37),
516 TypeshedVersionsQueryResult::MaybeExists
517 );
518 assert_eq!(
519 parsed_versions.query_module(&bar_eggs, PythonVersion::PY310),
520 TypeshedVersionsQueryResult::MaybeExists
521 );
522 assert_eq!(
523 parsed_versions.query_module(&bar_eggs, PythonVersion::PY311),
524 TypeshedVersionsQueryResult::DoesNotExist
525 );
526 }
527
528 #[test]
529 fn nonexistent_module_queried_correctly() {
530 let parsed_versions = TypeshedVersions::from_str("eggs: 3.8-").unwrap();
531 let spam = ModuleName::new_static("spam").unwrap();
532
533 assert!(!parsed_versions.contains_exact(&spam));
534 assert_eq!(
535 parsed_versions.query_module(&spam, PythonVersion::PY37),
536 TypeshedVersionsQueryResult::DoesNotExist
537 );
538 assert_eq!(
539 parsed_versions.query_module(&spam, PythonVersion::PY313),
540 TypeshedVersionsQueryResult::DoesNotExist
541 );
542 }
543
544 #[test]
545 fn invalid_huge_versions_file() {
546 let offset = 100;
547 let too_many = u16::MAX as usize + offset;
548
549 let mut massive_versions_file = String::new();
550 for i in 0..too_many {
551 let _ = writeln!(&mut massive_versions_file, "x{i}: 3.8-");
552 }
553
554 assert_eq!(
555 TypeshedVersions::from_str(&massive_versions_file),
556 Err(TypeshedVersionsParseError {
557 line_number: None,
558 reason: TypeshedVersionsParseErrorKind::TooManyLines(
559 NonZeroUsize::new(too_many + 1 - offset).unwrap()
560 )
561 })
562 );
563 }
564
565 #[test]
566 fn invalid_typeshed_versions_bad_colon_number() {
567 assert_eq!(
568 TypeshedVersions::from_str("foo 3.7"),
569 Err(TypeshedVersionsParseError {
570 line_number: ONE,
571 reason: TypeshedVersionsParseErrorKind::UnexpectedNumberOfColons
572 })
573 );
574 assert_eq!(
575 TypeshedVersions::from_str("foo:: 3.7"),
576 Err(TypeshedVersionsParseError {
577 line_number: ONE,
578 reason: TypeshedVersionsParseErrorKind::UnexpectedNumberOfColons
579 })
580 );
581 }
582
583 #[test]
584 fn invalid_typeshed_versions_non_identifier_modules() {
585 assert_eq!(
586 TypeshedVersions::from_str("not!an!identifier!: 3.7"),
587 Err(TypeshedVersionsParseError {
588 line_number: ONE,
589 reason: TypeshedVersionsParseErrorKind::InvalidModuleName(
590 "not!an!identifier!".to_string()
591 )
592 })
593 );
594 assert_eq!(
595 TypeshedVersions::from_str("(also_not).(an_identifier): 3.7"),
596 Err(TypeshedVersionsParseError {
597 line_number: ONE,
598 reason: TypeshedVersionsParseErrorKind::InvalidModuleName(
599 "(also_not).(an_identifier)".to_string()
600 )
601 })
602 );
603 }
604
605 #[test]
606 fn invalid_typeshed_versions_bad_hyphen_number() {
607 assert_eq!(
608 TypeshedVersions::from_str("foo: 3.8"),
609 Err(TypeshedVersionsParseError {
610 line_number: ONE,
611 reason: TypeshedVersionsParseErrorKind::UnexpectedNumberOfHyphens
612 })
613 );
614 assert_eq!(
615 TypeshedVersions::from_str("foo: 3.8--"),
616 Err(TypeshedVersionsParseError {
617 line_number: ONE,
618 reason: TypeshedVersionsParseErrorKind::UnexpectedNumberOfHyphens
619 })
620 );
621 assert_eq!(
622 TypeshedVersions::from_str("foo: 3.8--3.9"),
623 Err(TypeshedVersionsParseError {
624 line_number: ONE,
625 reason: TypeshedVersionsParseErrorKind::UnexpectedNumberOfHyphens
626 })
627 );
628 }
629
630 #[test]
631 fn invalid_typeshed_versions_bad_period_number() {
632 assert_eq!(
633 TypeshedVersions::from_str("foo: 38-"),
634 Err(TypeshedVersionsParseError {
635 line_number: ONE,
636 reason: TypeshedVersionsParseErrorKind::VersionParseError(
637 PythonVersionDeserializationError::WrongPeriodNumber(Box::from("38"))
638 )
639 })
640 );
641 assert_eq!(
642 TypeshedVersions::from_str("foo: 3..8-"),
643 Err(TypeshedVersionsParseError {
644 line_number: ONE,
645 reason: TypeshedVersionsParseErrorKind::VersionParseError(
646 PythonVersionDeserializationError::WrongPeriodNumber(Box::from("3..8"))
647 )
648 })
649 );
650 assert_eq!(
651 TypeshedVersions::from_str("foo: 3.8-3..11"),
652 Err(TypeshedVersionsParseError {
653 line_number: ONE,
654 reason: TypeshedVersionsParseErrorKind::VersionParseError(
655 PythonVersionDeserializationError::WrongPeriodNumber(Box::from("3..11"))
656 )
657 })
658 );
659 }
660
661 #[test]
662 fn invalid_typeshed_versions_non_digits() {
663 let err = TypeshedVersions::from_str("foo: 1.two-").unwrap_err();
664 assert_eq!(err.line_number, ONE);
665 let TypeshedVersionsParseErrorKind::VersionParseError(
666 PythonVersionDeserializationError::InvalidMinorVersion(invalid_minor, parse_error),
667 ) = err.reason
668 else {
669 panic!(
670 "Expected an invalid-minor-version parse error, got `{}`",
671 err.reason
672 )
673 };
674 assert_eq!(&*invalid_minor, "two");
675 assert_eq!(*parse_error.kind(), IntErrorKind::InvalidDigit);
676
677 let err = TypeshedVersions::from_str("foo: 3.8-four.9").unwrap_err();
678 assert_eq!(err.line_number, ONE);
679 let TypeshedVersionsParseErrorKind::VersionParseError(
680 PythonVersionDeserializationError::InvalidMajorVersion(invalid_major, parse_error),
681 ) = err.reason
682 else {
683 panic!(
684 "Expected an invalid-major-version parse error, got `{}`",
685 err.reason
686 )
687 };
688 assert_eq!(&*invalid_major, "four");
689 assert_eq!(*parse_error.kind(), IntErrorKind::InvalidDigit);
690 }
691}