Skip to content

Commit 181431e

Browse files
authored
Merge pull request #22847 from github/rust/inconsistent-feature
Rust: Prefer richer feature configurations when extracting dependencies
2 parents f8a5772 + 977d09a commit 181431e

27 files changed

Lines changed: 688 additions & 79 deletions
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
use crate::util::cmp_option_by;
2+
use itertools::Itertools;
3+
use ra_ap_base_db::{SourceDatabase, SourceRootId, all_crates};
4+
use ra_ap_cfg::{CfgAtom, CfgOptions};
5+
use ra_ap_hir::Semantics;
6+
use ra_ap_ide_db::RootDatabase;
7+
use ra_ap_project_model::ProjectManifest;
8+
use ra_ap_vfs::{FileId, Vfs};
9+
use std::cmp::Ordering;
10+
use std::collections::{BTreeMap, HashMap, HashSet, btree_map::Entry};
11+
use std::path::{Path, PathBuf};
12+
13+
/// Returns the enclosing directory of the manifest file.
14+
pub fn manifest_parent(manifest: &ProjectManifest) -> &Path {
15+
manifest.manifest_path().parent().as_ref()
16+
}
17+
18+
/// Reads the file's source-root ID in `db`.
19+
///
20+
/// A source root is rust-analyzer's group of files within which relative paths
21+
/// are resolved. It need not correspond to one crate: a package's library and
22+
/// build script can share a source root.
23+
fn source_root_id(db: &RootDatabase, file_id: FileId) -> SourceRootId {
24+
db.file_source_root(file_id).source_root_id(db)
25+
}
26+
27+
/// Maps source root IDs to cfg options shared by all their crates, using only
28+
/// metadata.
29+
///
30+
/// In order for a file to be included in a crate the crate's root file and the
31+
/// file must share the same source root. However, this condition is not
32+
/// sufficient. The construction here relies on this to identify the cases where
33+
/// this requirement _is_ sufficient to determine the cfg options of any crate
34+
/// that could contain the file. Either because only one crate can contain it or
35+
/// because all such crates share the same cfg options.
36+
///
37+
/// Roots whose crates disagree map to `None` and need per-file semantic lookup.
38+
fn root_configurations(db: &RootDatabase) -> HashMap<SourceRootId, Option<&CfgOptions>> {
39+
let mut configurations = HashMap::new();
40+
for &krate in all_crates(db).iter() {
41+
let root = source_root_id(db, krate.data(db).root_file_id);
42+
configurations
43+
.entry(root)
44+
.and_modify(|configuration: &mut Option<&CfgOptions>| {
45+
// We're in the case where multiple crates share a source root,
46+
// for example a library and its build script. If their cfg
47+
// options differ, we mark the configuration as `None`.
48+
if configuration.is_some_and(|cfg| cfg != krate.cfg_options(db)) {
49+
*configuration = None;
50+
}
51+
})
52+
.or_insert_with(|| Some(krate.cfg_options(db)));
53+
}
54+
configurations
55+
}
56+
57+
/// Returns an iterator over the dependency files in `vfs`.
58+
///
59+
/// These are files that correspond to libraries and that are not in `source_files`.
60+
pub fn dependency_files<'a>(
61+
db: &'a RootDatabase,
62+
vfs: &'a Vfs,
63+
source_files: &'a HashSet<PathBuf>,
64+
) -> impl Iterator<Item = (FileId, &'a Path)> {
65+
vfs.iter().filter_map(|(file_id, file)| {
66+
let file = file.as_path().map(<_ as AsRef<Path>>::as_ref)?;
67+
(file.extension().is_some_and(|ext| ext == "rs")
68+
&& !source_files.contains(file)
69+
&& db
70+
.source_root(source_root_id(db, file_id))
71+
.source_root(db)
72+
.is_library)
73+
.then_some((file_id, file))
74+
})
75+
}
76+
77+
/// Compare two `CfgOptions`. Feature count wins, sorted cfg options break ties.
78+
fn compare_cfg_options(left: &CfgOptions, right: &CfgOptions) -> Ordering {
79+
let feature_count = |cfg_options: &CfgOptions| {
80+
cfg_options
81+
.into_iter()
82+
.filter(|cfg| matches!(cfg, CfgAtom::KeyValue { key, .. } if key.as_str() == "feature"))
83+
.count()
84+
};
85+
feature_count(left)
86+
.cmp(&feature_count(right))
87+
.then_with(|| left.into_iter().sorted().cmp(right.into_iter().sorted()))
88+
}
89+
90+
#[derive(PartialEq, Eq)]
91+
struct DependencySource<'a> {
92+
cfg_options: Option<CfgOptions>,
93+
manifest: &'a ProjectManifest,
94+
}
95+
96+
impl Ord for DependencySource<'_> {
97+
fn cmp(&self, other: &Self) -> Ordering {
98+
// Compare by cfg options first, then by manifest parent directory
99+
// (arbitrary but deterministic).
100+
cmp_option_by(&self.cfg_options, &other.cfg_options, compare_cfg_options)
101+
.then_with(|| manifest_parent(self.manifest).cmp(manifest_parent(other.manifest)))
102+
}
103+
}
104+
105+
impl PartialOrd for DependencySource<'_> {
106+
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
107+
Some(self.cmp(other))
108+
}
109+
}
110+
111+
/// Tracks extraction candidates for each dependency file. Keeps one preferred
112+
/// manifest per distinct set of cfg options, allowing lower-priority
113+
/// configurations to be tried if the preferred one disables a file.
114+
#[derive(Default)]
115+
pub struct DependencyFiles<'a> {
116+
files: BTreeMap<PathBuf, Vec<DependencySource<'a>>>,
117+
}
118+
119+
impl<'a> DependencyFiles<'a> {
120+
/// Considers dependency files loaded from this manifest as extraction
121+
/// candidates. The lexicographically largest manifest directory wins for
122+
/// each configuration.
123+
pub fn add_manifest(
124+
&mut self,
125+
manifest: &'a ProjectManifest,
126+
semantics: &Semantics<'_, RootDatabase>,
127+
vfs: &Vfs,
128+
source_files: &HashSet<PathBuf>,
129+
) {
130+
let db = semantics.db;
131+
let root_configurations = root_configurations(db);
132+
for (file_id, file) in dependency_files(db, vfs, source_files) {
133+
let root = source_root_id(db, file_id);
134+
let cfg_options = match root_configurations.get(&root) {
135+
// There's a unique cfg configuration for this file.
136+
Some(Some(configuration)) => Some((*configuration).clone()),
137+
// There's no unique cfg configuration for this file. In this
138+
// case we fall back to the expensive `file_to_module_def`
139+
// approach which requires rust_analyzer to calculate semantic
140+
// information.
141+
Some(None) => semantics
142+
.file_to_module_def(file_id)
143+
.map(|module| module.krate(db).cfg(db).clone()),
144+
None => None,
145+
};
146+
self.add_candidate(file, cfg_options, manifest);
147+
}
148+
}
149+
150+
/// Adds `cfg_options` and `manifest` as an extraction candidate for the
151+
/// given file unless a higher-priority candidate with the same cfg options
152+
/// already exists.
153+
fn add_candidate(
154+
&mut self,
155+
file: &Path,
156+
cfg_options: Option<CfgOptions>,
157+
manifest: &'a ProjectManifest,
158+
) {
159+
let candidate = DependencySource {
160+
cfg_options,
161+
manifest,
162+
};
163+
match self.files.entry(file.to_owned()) {
164+
Entry::Occupied(mut entry) => {
165+
let sources = entry.get_mut();
166+
if let Some(source) = sources
167+
.iter_mut()
168+
.find(|source| source.cfg_options == candidate.cfg_options)
169+
{
170+
if manifest_parent(manifest) > manifest_parent(source.manifest) {
171+
source.manifest = manifest;
172+
}
173+
} else {
174+
sources.push(candidate);
175+
}
176+
}
177+
Entry::Vacant(entry) => {
178+
entry.insert(vec![candidate]);
179+
}
180+
}
181+
}
182+
183+
/// Takes the highest-priority remaining context for each file, grouped by
184+
/// manifest. Lower-priority configurations remain available if the selected
185+
/// one disables the file.
186+
pub fn by_manifests(&mut self) -> HashMap<&'a ProjectManifest, Vec<PathBuf>> {
187+
self.files
188+
.iter_mut()
189+
.filter_map(|(file, sources)| {
190+
sources.sort_unstable();
191+
sources.pop().map(|source| (source.manifest, file.clone()))
192+
})
193+
.into_group_map()
194+
}
195+
196+
/// Returns `true` if there are alternative configurations left for the
197+
/// given file.
198+
pub fn has_alternative(&self, file: &Path) -> bool {
199+
self.files
200+
.get(file)
201+
.is_some_and(|sources| !sources.is_empty())
202+
}
203+
204+
/// Marks a file as extracted, removing it from the map of files.
205+
pub fn extracted(&mut self, file: &Path) {
206+
self.files.remove(file);
207+
}
208+
}
209+
210+
#[cfg(test)]
211+
mod tests {
212+
use super::*;
213+
use ra_ap_base_db::{
214+
CrateGraphBuilder, CrateOrigin, CrateWorkspaceData, FileChange, FileSet, SourceRoot,
215+
};
216+
use ra_ap_intern::Symbol;
217+
use ra_ap_span::Edition;
218+
use ra_ap_vfs::VfsPath;
219+
use triomphe::Arc;
220+
221+
fn cfg(features: &[&str]) -> CfgOptions {
222+
let mut cfg = CfgOptions::default();
223+
for feature in features {
224+
cfg.insert_key_value(Symbol::intern("feature"), Symbol::intern(feature));
225+
}
226+
cfg
227+
}
228+
229+
fn workspace(files: &[&str], crates: &[(&str, &[&str])]) -> (RootDatabase, Vfs) {
230+
let root = crate::cwd().unwrap();
231+
let mut vfs = Vfs::default();
232+
let mut file_set = FileSet::default();
233+
let mut change = FileChange::default();
234+
for &name in files {
235+
let path = VfsPath::from(root.join(name));
236+
vfs.set_file_contents(path.clone(), Some(Vec::new()));
237+
let (id, _) = vfs.file_id(&path).unwrap();
238+
file_set.insert(id, path);
239+
}
240+
let mut graph = CrateGraphBuilder::default();
241+
for &(name, features) in crates {
242+
let (id, _) = vfs.file_id(&VfsPath::from(root.join(name))).unwrap();
243+
graph.add_crate_root(
244+
id,
245+
Edition::Edition2024,
246+
None,
247+
None,
248+
cfg(features),
249+
None,
250+
Default::default(),
251+
CrateOrigin::Library {
252+
repo: None,
253+
name: Symbol::intern(name),
254+
},
255+
Vec::new(),
256+
false,
257+
Arc::new(root.clone()),
258+
Arc::new(CrateWorkspaceData {
259+
target: Err("unused in these tests".into()),
260+
toolchain: None,
261+
}),
262+
);
263+
}
264+
change.set_roots(vec![SourceRoot::new_library(file_set)]);
265+
change.set_crate_graph(graph);
266+
let mut db = RootDatabase::default();
267+
change.apply(&mut db);
268+
(db, vfs)
269+
}
270+
271+
#[test]
272+
fn selects_configuration_without_reading_dependency_sources() {
273+
for crates in [
274+
vec![("lib.rs", &["a"][..])],
275+
vec![("lib.rs", &["a"][..]), ("build.rs", &["a"][..])],
276+
] {
277+
let (db, vfs) = workspace(&["lib.rs", "build.rs", "module.rs"], &crates);
278+
let root = crate::cwd().unwrap();
279+
let manifest = ProjectManifest::from_manifest_file(root.join("Cargo.toml")).unwrap();
280+
let mut plan = DependencyFiles::default();
281+
plan.add_manifest(&manifest, &Semantics::new(&db), &vfs, &HashSet::new());
282+
assert_eq!(plan.files.len(), 3);
283+
for sources in plan.files.values() {
284+
assert_eq!(sources[0].cfg_options, Some(cfg(&["a"])));
285+
}
286+
}
287+
}
288+
289+
#[test]
290+
fn selects_highest_priority_in_either_order() {
291+
let root = crate::cwd().unwrap();
292+
let manifest = |path| ProjectManifest::from_manifest_file(root.join(path)).unwrap();
293+
let candidates = [
294+
(manifest("z/Cargo.toml"), cfg(&["z"])),
295+
(manifest("a/Cargo.toml"), cfg(&["a", "b"])),
296+
(manifest("b/Cargo.toml"), cfg(&["a", "b"])),
297+
];
298+
for order in [[0, 1, 2], [2, 1, 0]] {
299+
let mut plan = DependencyFiles::default();
300+
for (manifest, cfg) in order.map(|index| &candidates[index]) {
301+
plan.add_candidate(Path::new("lib.rs"), Some(cfg.clone()), manifest);
302+
}
303+
let expected = HashMap::from([(&candidates[2].0, vec![PathBuf::from("lib.rs")])]);
304+
assert_eq!(plan.by_manifests(), expected);
305+
}
306+
}
307+
}

‎rust/extractor/src/diagnostics.rs‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use chrono::{DateTime, Utc};
55
use ra_ap_project_model::ProjectManifest;
66
use serde::Serialize;
77
use serde::ser::SerializeMap;
8-
use std::collections::HashMap;
8+
use std::collections::{HashMap, HashSet};
99
use std::fmt::Display;
1010
use std::fs::File;
1111
use std::path::{Path, PathBuf};
@@ -220,13 +220,15 @@ struct ExtractionSummary {
220220
type ExtractionDiagnostics = Diagnostics<ExtractionSummary>;
221221

222222
fn summary(start: Instant, steps: &[ExtractionStep]) -> ExtractionSummary {
223-
let mut number_of_manifests = 0;
223+
// The manifests loaded during extraction. We might load the same manifest
224+
// multiple times, so we use a set to keep track of unique manifests.
225+
let mut manifests = HashSet::new();
224226
let mut number_of_files = 0;
225227
let mut durations = HashMap::new();
226228
for step in steps {
227229
match &step.action {
228230
ExtractionStepKind::LoadManifest => {
229-
number_of_manifests += 1;
231+
manifests.extend(step.file.as_ref());
230232
}
231233
ExtractionStepKind::Parse => {
232234
number_of_files += 1;
@@ -244,7 +246,7 @@ fn summary(start: Instant, steps: &[ExtractionStep]) -> ExtractionSummary {
244246
}
245247
info!("total duration: {total}");
246248
ExtractionSummary {
247-
number_of_manifests,
249+
number_of_manifests: manifests.len(),
248250
number_of_files,
249251
durations: DurationsSummary { durations, total },
250252
}
@@ -284,3 +286,22 @@ pub fn emit_extraction_diagnostics(
284286
.with_context(|| format!("writing to diagnostics file {}", target.display()))?;
285287
Ok(())
286288
}
289+
290+
#[cfg(test)]
291+
mod tests {
292+
use super::*;
293+
294+
#[test]
295+
fn counts_unique_manifests_but_includes_reload_duration() {
296+
let steps: Vec<_> = ["a/Cargo.toml", "b/Cargo.toml", "a/Cargo.toml"]
297+
.into_iter()
298+
.map(|path| ExtractionStep {
299+
action: ExtractionStepKind::LoadManifest,
300+
file: Some(PathBuf::from(path)),
301+
ms: 1,
302+
})
303+
.collect();
304+
let summary = summary(Instant::now(), &steps);
305+
assert_eq!(summary.number_of_manifests, 2);
306+
}
307+
}

0 commit comments

Comments
 (0)