//! Working with the fixtures in r-a tests, and providing IDE services for them. use std::hash::{BuildHasher, Hash}; use hir::{CfgExpr, FilePositionWrapper, FileRangeWrapper, Semantics, Symbol}; use smallvec::SmallVec; use span::{TextRange, TextSize}; use syntax::{ AstToken, SmolStr, ast::{self, IsString}, }; use crate::{ MiniCore, RootDatabase, SymbolKind, active_parameter::ActiveParameter, documentation::Documentation, range_mapper::RangeMapper, search::ReferenceCategory, }; pub use span::FileId; impl RootDatabase { fn from_ra_fixture( text: &str, minicore: MiniCore<'_>, ) -> Result<(RootDatabase, Vec<(FileId, usize)>, Vec), ()> { // We don't want a mistake in the fixture to crash r-a, so we wrap this in `catch_unwind()`. std::panic::catch_unwind(|| { let mut db = RootDatabase::default(); let fixture = test_fixture::ChangeFixture::parse_with_proc_macros(text, minicore.0, Vec::new()); db.apply_change(fixture.change); let files = fixture .files .into_iter() .zip(fixture.file_lines) .map(|(file_id, range)| (file_id.file_id(), range)) .collect(); (db, files, fixture.sysroot_files) }) .map_err(|error| { tracing::error!( "cannot crate the crate graph: {}\nCrate graph:\n{}\n", if let Some(&s) = error.downcast_ref::<&'static str>() { s } else if let Some(s) = error.downcast_ref::() { s.as_str() } else { "Box" }, text, ); }) } } pub struct RaFixtureAnalysis { pub db: RootDatabase, tmp_file_ids: Vec<(FileId, usize)>, line_offsets: Vec, virtual_file_id_to_line: Vec, mapper: RangeMapper, literal: ast::String, // `minicore` etc.. sysroot_files: Vec, combined_len: TextSize, } impl RaFixtureAnalysis { pub fn analyze_ra_fixture( sema: &Semantics<'_, RootDatabase>, literal: ast::String, expanded: &ast::String, minicore: MiniCore<'_>, on_cursor: &mut dyn FnMut(TextRange), ) -> Option { if !literal.is_raw() { return None; } let active_parameter = ActiveParameter::at_token(sema, expanded.syntax().clone())?; let has_rust_fixture_attr = active_parameter.attrs().is_some_and(|attrs| { attrs.filter_map(|attr| attr.as_simple_path()).any(|path| { path.segments() .zip(["rust_analyzer", "rust_fixture"]) .all(|(seg, name)| seg.name_ref().map_or(false, |nr| nr.text() == name)) }) }); if !has_rust_fixture_attr { return None; } let value = literal.value().ok()?; let mut mapper = RangeMapper::default(); // This is used for the `Injector`, to resolve precise location in the string literal, // which will then be used to resolve precise location in the enclosing file. let mut offset_with_indent = TextSize::new(0); // This is used to resolve the location relative to the virtual file into a location // relative to the indentation-trimmed file which will then (by the `Injector`) used // to resolve to a location in the actual file. // Besides indentation, we also skip `$0` cursors for this, since they are not included // in the virtual files. let mut offset_without_indent = TextSize::new(0); let mut text = &*value; if let Some(t) = text.strip_prefix('\n') { offset_with_indent += TextSize::of("\n"); text = t; } // This stores the offsets of each line, **after we remove indentation**. let mut line_offsets = Vec::new(); for mut line in text.split_inclusive('\n') { line_offsets.push(offset_without_indent); if line.starts_with("@@") { // Introducing `//` into a fixture inside fixture causes all sorts of problems, // so for testing purposes we escape it as `@@` and replace it here. mapper.add("//", TextRange::at(offset_with_indent, TextSize::of("@@"))); line = &line["@@".len()..]; offset_with_indent += TextSize::of("@@"); offset_without_indent += TextSize::of("@@"); } // Remove indentation to simplify the mapping with fixture (which de-indents). // Removing indentation shouldn't affect highlighting. let mut unindented_line = line.trim_start(); if unindented_line.is_empty() { // The whole line was whitespaces, but we need the newline. unindented_line = "\n"; } offset_with_indent += TextSize::of(line) - TextSize::of(unindented_line); let marker = "$0"; match unindented_line.find(marker) { Some(marker_pos) => { let (before_marker, after_marker) = unindented_line.split_at(marker_pos); let after_marker = &after_marker[marker.len()..]; mapper.add( before_marker, TextRange::at(offset_with_indent, TextSize::of(before_marker)), ); offset_with_indent += TextSize::of(before_marker); offset_without_indent += TextSize::of(before_marker); if let Some(marker_range) = literal .map_range_up(TextRange::at(offset_with_indent, TextSize::of(marker))) { on_cursor(marker_range); } offset_with_indent += TextSize::of(marker); mapper.add( after_marker, TextRange::at(offset_with_indent, TextSize::of(after_marker)), ); offset_with_indent += TextSize::of(after_marker); offset_without_indent += TextSize::of(after_marker); } None => { mapper.add( unindented_line, TextRange::at(offset_with_indent, TextSize::of(unindented_line)), ); offset_with_indent += TextSize::of(unindented_line); offset_without_indent += TextSize::of(unindented_line); } } } let combined = mapper.take_text(); let combined_len = TextSize::of(&combined); let (analysis, tmp_file_ids, sysroot_files) = RootDatabase::from_ra_fixture(&combined, minicore).ok()?; // We use a `Vec` because we know the `FileId`s will always be close. let mut virtual_file_id_to_line = Vec::new(); for &(file_id, line) in &tmp_file_ids { virtual_file_id_to_line.resize(file_id.index() as usize + 1, usize::MAX); virtual_file_id_to_line[file_id.index() as usize] = line; } Some(RaFixtureAnalysis { db: analysis, tmp_file_ids, line_offsets, virtual_file_id_to_line, mapper, literal, sysroot_files, combined_len, }) } pub fn files(&self) -> impl Iterator { self.tmp_file_ids.iter().map(|(file, _)| *file) } /// This returns `None` for minicore or other sysroot files. fn virtual_file_id_to_line(&self, file_id: FileId) -> Option { if self.is_sysroot_file(file_id) { None } else { Some(self.virtual_file_id_to_line[file_id.index() as usize]) } } pub fn map_offset_down(&self, offset: TextSize) -> Option<(FileId, TextSize)> { let inside_literal_range = self.literal.map_offset_down(offset)?; let combined_offset = self.mapper.map_offset_down(inside_literal_range)?; // There is usually a small number of files, so a linear search is smaller and faster. let (_, &(file_id, file_line)) = self.tmp_file_ids.iter().enumerate().find(|&(idx, &(_, file_line))| { let file_start = self.line_offsets[file_line]; let file_end = self .tmp_file_ids .get(idx + 1) .map(|&(_, next_file_line)| self.line_offsets[next_file_line]) .unwrap_or_else(|| self.combined_len); TextRange::new(file_start, file_end).contains(combined_offset) })?; let file_line_offset = self.line_offsets[file_line]; let file_offset = combined_offset - file_line_offset; Some((file_id, file_offset)) } pub fn map_range_down(&self, range: TextRange) -> Option<(FileId, TextRange)> { let (start_file_id, start_offset) = self.map_offset_down(range.start())?; let (end_file_id, end_offset) = self.map_offset_down(range.end())?; if start_file_id != end_file_id { None } else { Some((start_file_id, TextRange::new(start_offset, end_offset))) } } pub fn map_range_up( &self, virtual_file: FileId, range: TextRange, ) -> impl Iterator { // This could be `None` if the file is empty. self.virtual_file_id_to_line(virtual_file) .and_then(|line| self.line_offsets.get(line)) .into_iter() .flat_map(move |&tmp_file_offset| { // Resolve the offset relative to the virtual file to an offset relative to the combined indentation-trimmed file let range = range + tmp_file_offset; // Then resolve that to an offset relative to the real file. self.mapper.map_range_up(range) }) // And finally resolve the offset relative to the literal to relative to the file. .filter_map(|range| self.literal.map_range_up(range)) } pub fn map_offset_up(&self, virtual_file: FileId, offset: TextSize) -> Option { self.map_range_up(virtual_file, TextRange::empty(offset)).next().map(|range| range.start()) } pub fn is_sysroot_file(&self, file_id: FileId) -> bool { self.sysroot_files.contains(&file_id) } } pub trait UpmapFromRaFixture: Sized { fn upmap_from_ra_fixture( self, analysis: &RaFixtureAnalysis, virtual_file_id: FileId, real_file_id: FileId, ) -> Result; } trait IsEmpty { fn is_empty(&self) -> bool; } impl IsEmpty for Vec { fn is_empty(&self) -> bool { self.is_empty() } } impl IsEmpty for SmallVec<[T; N]> { fn is_empty(&self) -> bool { self.is_empty() } } #[allow(clippy::disallowed_types)] impl IsEmpty for std::collections::HashMap { fn is_empty(&self) -> bool { self.is_empty() } } fn upmap_collection( collection: Collection, analysis: &RaFixtureAnalysis, virtual_file_id: FileId, real_file_id: FileId, ) -> Result where T: UpmapFromRaFixture, Collection: IntoIterator + FromIterator + IsEmpty, { if collection.is_empty() { // The collection was already empty, don't mark it as failing just because of that. return Ok(collection); } let result = collection .into_iter() .filter_map(|item| item.upmap_from_ra_fixture(analysis, virtual_file_id, real_file_id).ok()) .collect::(); if result.is_empty() { // The collection was emptied by the upmapping - all items errored, therefore mark it as erroring as well. Err(()) } else { Ok(result) } } impl UpmapFromRaFixture for Option { fn upmap_from_ra_fixture( self, analysis: &RaFixtureAnalysis, virtual_file_id: FileId, real_file_id: FileId, ) -> Result { Ok(match self { Some(it) => Some(it.upmap_from_ra_fixture(analysis, virtual_file_id, real_file_id)?), None => None, }) } } impl UpmapFromRaFixture for Vec { fn upmap_from_ra_fixture( self, analysis: &RaFixtureAnalysis, virtual_file_id: FileId, real_file_id: FileId, ) -> Result { upmap_collection(self, analysis, virtual_file_id, real_file_id) } } impl UpmapFromRaFixture for SmallVec<[T; N]> { fn upmap_from_ra_fixture( self, analysis: &RaFixtureAnalysis, virtual_file_id: FileId, real_file_id: FileId, ) -> Result { upmap_collection(self, analysis, virtual_file_id, real_file_id) } } #[allow(clippy::disallowed_types)] impl UpmapFromRaFixture for std::collections::HashMap { fn upmap_from_ra_fixture( self, analysis: &RaFixtureAnalysis, virtual_file_id: FileId, real_file_id: FileId, ) -> Result { upmap_collection(self, analysis, virtual_file_id, real_file_id) } } // A map of `FileId`s is treated as associating the ranges in the values with the keys. #[allow(clippy::disallowed_types)] impl UpmapFromRaFixture for std::collections::HashMap { fn upmap_from_ra_fixture( self, analysis: &RaFixtureAnalysis, _virtual_file_id: FileId, real_file_id: FileId, ) -> Result { if self.is_empty() { return Ok(self); } let result = self .into_iter() .filter_map(|(virtual_file_id, value)| { Some(( real_file_id, value.upmap_from_ra_fixture(analysis, virtual_file_id, real_file_id).ok()?, )) }) .collect::>(); if result.is_empty() { Err(()) } else { Ok(result) } } } macro_rules! impl_tuple { () => {}; // Base case. ( $first:ident, $( $rest:ident, )* ) => { impl< $first: UpmapFromRaFixture, $( $rest: UpmapFromRaFixture, )* > UpmapFromRaFixture for ( $first, $( $rest, )* ) { fn upmap_from_ra_fixture( self, analysis: &RaFixtureAnalysis, virtual_file_id: FileId, real_file_id: FileId, ) -> Result { #[allow(non_snake_case)] let ( $first, $($rest,)* ) = self; Ok(( $first.upmap_from_ra_fixture(analysis, virtual_file_id, real_file_id)?, $( $rest.upmap_from_ra_fixture(analysis, virtual_file_id, real_file_id)?, )* )) } } impl_tuple!( $($rest,)* ); }; } impl_tuple!(A, B, C, D, E,); impl UpmapFromRaFixture for TextSize { fn upmap_from_ra_fixture( self, analysis: &RaFixtureAnalysis, virtual_file_id: FileId, _real_file_id: FileId, ) -> Result { analysis.map_offset_up(virtual_file_id, self).ok_or(()) } } impl UpmapFromRaFixture for TextRange { fn upmap_from_ra_fixture( self, analysis: &RaFixtureAnalysis, virtual_file_id: FileId, _real_file_id: FileId, ) -> Result { analysis.map_range_up(virtual_file_id, self).next().ok_or(()) } } // Deliberately do not implement that, as it's easy to get things misbehave and be treated with the wrong FileId: // // impl UpmapFromRaFixture for FileId { // fn upmap_from_ra_fixture( // self, // _analysis: &RaFixtureAnalysis, // _virtual_file_id: FileId, // real_file_id: FileId, // ) -> Result { // Ok(real_file_id) // } // } impl UpmapFromRaFixture for FilePositionWrapper { fn upmap_from_ra_fixture( self, analysis: &RaFixtureAnalysis, _virtual_file_id: FileId, real_file_id: FileId, ) -> Result { Ok(FilePositionWrapper { file_id: real_file_id, offset: self.offset.upmap_from_ra_fixture(analysis, self.file_id, real_file_id)?, }) } } impl UpmapFromRaFixture for FileRangeWrapper { fn upmap_from_ra_fixture( self, analysis: &RaFixtureAnalysis, _virtual_file_id: FileId, real_file_id: FileId, ) -> Result { Ok(FileRangeWrapper { file_id: real_file_id, range: self.range.upmap_from_ra_fixture(analysis, self.file_id, real_file_id)?, }) } } #[macro_export] macro_rules! impl_empty_upmap_from_ra_fixture { ( $( $ty:ty ),* $(,)? ) => { $( impl $crate::ra_fixture::UpmapFromRaFixture for $ty { fn upmap_from_ra_fixture( self, _analysis: &$crate::ra_fixture::RaFixtureAnalysis, _virtual_file_id: $crate::ra_fixture::FileId, _real_file_id: $crate::ra_fixture::FileId, ) -> Result { Ok(self) } } )* }; } impl_empty_upmap_from_ra_fixture!( bool, i8, i16, i32, i64, i128, u8, u16, u32, u64, u128, f32, f64, &str, String, Symbol, SmolStr, Documentation<'_>, SymbolKind, CfgExpr, ReferenceCategory, );