ide_diagnostics/handlers/
unlinked_file.rs

1//! Diagnostic emitted for files that aren't part of any crate.
2
3use std::iter;
4
5use hir::crate_def_map;
6use hir::{InFile, ModuleSource};
7use ide_db::base_db::RootQueryDb;
8use ide_db::text_edit::TextEdit;
9use ide_db::{
10    FileId, FileRange, LineIndexDatabase, base_db::SourceDatabase, source_change::SourceChange,
11};
12use paths::Utf8Component;
13use syntax::{
14    AstNode, TextRange,
15    ast::{self, HasModuleItem, HasName, edit::IndentLevel},
16};
17
18use crate::{Assist, Diagnostic, DiagnosticCode, DiagnosticsContext, Severity, fix};
19
20// Diagnostic: unlinked-file
21//
22// This diagnostic is shown for files that are not included in any crate, or files that are part of
23// crates rust-analyzer failed to discover. The file will not have IDE features available.
24pub(crate) fn unlinked_file(
25    ctx: &DiagnosticsContext<'_>,
26    acc: &mut Vec<Diagnostic>,
27    file_id: FileId,
28) {
29    let mut range = TextRange::up_to(ctx.sema.db.line_index(file_id).len());
30    let fixes = fixes(ctx, file_id, range);
31    // FIXME: This is a hack for the vscode extension to notice whether there is an autofix or not before having to resolve diagnostics.
32    // This is to prevent project linking popups from appearing when there is an autofix. https://github.com/rust-lang/rust-analyzer/issues/14523
33    let message = if fixes.is_none() {
34        "This file is not included in any crates, so rust-analyzer can't offer IDE services."
35    } else {
36        "This file is not included anywhere in the module tree, so rust-analyzer can't offer IDE services."
37    };
38
39    let message = format!(
40        "{message}\n\nIf you're intentionally working on unowned files, you can silence this warning by adding \"unlinked-file\" to rust-analyzer.diagnostics.disabled in your settings."
41    );
42
43    let mut unused = true;
44
45    if fixes.is_none() {
46        // If we don't have a fix, the unlinked-file diagnostic is not
47        // actionable. This generally means that rust-analyzer hasn't
48        // finished startup, or we couldn't find the Cargo.toml.
49        //
50        // Only show this diagnostic on the first three characters of
51        // the file, to avoid overwhelming the user during startup.
52        range = SourceDatabase::file_text(ctx.sema.db, file_id)
53            .text(ctx.sema.db)
54            .char_indices()
55            .take(3)
56            .last()
57            .map(|(i, _)| i)
58            .map(|i| TextRange::up_to(i.try_into().unwrap()))
59            .unwrap_or(range);
60        // Prefer a diagnostic underline over graying out the text,
61        // since we're only highlighting a small region.
62        unused = false;
63    }
64
65    acc.push(
66        Diagnostic::new(
67            DiagnosticCode::Ra("unlinked-file", Severity::WeakWarning),
68            message,
69            FileRange { file_id, range },
70        )
71        .with_unused(unused)
72        .stable()
73        .with_fixes(fixes),
74    );
75}
76
77fn fixes(
78    ctx: &DiagnosticsContext<'_>,
79    file_id: FileId,
80    trigger_range: TextRange,
81) -> Option<Vec<Assist>> {
82    // If there's an existing module that could add `mod` or `pub mod` items to include the unlinked file,
83    // suggest that as a fix.
84
85    let db = ctx.sema.db;
86
87    let source_root = ctx.sema.db.file_source_root(file_id).source_root_id(db);
88    let source_root = ctx.sema.db.source_root(source_root).source_root(db);
89
90    let our_path = source_root.path_for_file(&file_id)?;
91    let parent = our_path.parent()?;
92    let (module_name, _) = our_path.name_and_extension()?;
93    let (parent, module_name) = match module_name {
94        // for mod.rs we need to actually look up one higher
95        // and take the parent as our to be module name
96        "mod" => {
97            let (name, _) = parent.name_and_extension()?;
98            (parent.parent()?, name.to_owned())
99        }
100        _ => (parent, module_name.to_owned()),
101    };
102
103    // check crate roots, i.e. main.rs, lib.rs, ...
104    let relevant_crates = db.relevant_crates(file_id);
105    'crates: for &krate in &*relevant_crates {
106        // FIXME: This shouldnt need to access the crate def map directly
107        let crate_def_map = crate_def_map(ctx.sema.db, krate);
108
109        let root_module = &crate_def_map[crate_def_map.root_module_id()];
110        let Some(root_file_id) = root_module.origin.file_id() else { continue };
111        let Some(crate_root_path) = source_root.path_for_file(&root_file_id.file_id(ctx.sema.db))
112        else {
113            continue;
114        };
115        let Some(rel) = parent.strip_prefix(&crate_root_path.parent()?) else { continue };
116
117        // try resolving the relative difference of the paths as inline modules
118        let mut current = root_module;
119        for ele in rel.as_utf8_path().components() {
120            let seg = match ele {
121                Utf8Component::Normal(seg) => seg,
122                Utf8Component::RootDir => continue,
123                // shouldn't occur
124                _ => continue 'crates,
125            };
126            match current.children.iter().find(|(name, _)| name.as_str() == seg) {
127                Some((_, &child)) => current = &crate_def_map[child],
128                None => continue 'crates,
129            }
130            if !current.origin.is_inline() {
131                continue 'crates;
132            }
133        }
134
135        let InFile { file_id: parent_file_id, value: source } =
136            current.definition_source(ctx.sema.db);
137        let parent_file_id = parent_file_id.file_id()?;
138        return make_fixes(
139            parent_file_id.file_id(ctx.sema.db),
140            source,
141            &module_name,
142            trigger_range,
143        );
144    }
145
146    // if we aren't adding to a crate root, walk backwards such that we support `#[path = ...]` overrides if possible
147
148    // build all parent paths of the form `../module_name/mod.rs` and `../module_name.rs`
149    let paths = iter::successors(Some(parent), |prev| prev.parent()).filter_map(|path| {
150        let parent = path.parent()?;
151        let (name, _) = path.name_and_extension()?;
152        Some(([parent.join(&format!("{name}.rs"))?, path.join("mod.rs")?], name.to_owned()))
153    });
154    let mut stack = vec![];
155    let &parent_id =
156        paths.inspect(|(_, name)| stack.push(name.clone())).find_map(|(paths, _)| {
157            paths.into_iter().find_map(|path| source_root.file_for_path(&path))
158        })?;
159    stack.pop();
160    let relevant_crates = db.relevant_crates(parent_id);
161    'crates: for &krate in relevant_crates.iter() {
162        let crate_def_map = crate_def_map(ctx.sema.db, krate);
163        let Some((_, module)) = crate_def_map.modules().find(|(_, module)| {
164            module.origin.file_id().map(|file_id| file_id.file_id(ctx.sema.db)) == Some(parent_id)
165                && !module.origin.is_inline()
166        }) else {
167            continue;
168        };
169
170        if stack.is_empty() {
171            return make_fixes(
172                parent_id,
173                module.definition_source(ctx.sema.db).value,
174                &module_name,
175                trigger_range,
176            );
177        } else {
178            // direct parent file is missing,
179            // try finding a parent that has an inline tree from here on
180            let mut current = module;
181            for s in stack.iter().rev() {
182                match module.children.iter().find(|(name, _)| name.as_str() == s) {
183                    Some((_, child)) => {
184                        current = &crate_def_map[*child];
185                    }
186                    None => continue 'crates,
187                }
188                if !current.origin.is_inline() {
189                    continue 'crates;
190                }
191            }
192            let InFile { file_id: parent_file_id, value: source } =
193                current.definition_source(ctx.sema.db);
194            let parent_file_id = parent_file_id.file_id()?;
195            return make_fixes(
196                parent_file_id.file_id(ctx.sema.db),
197                source,
198                &module_name,
199                trigger_range,
200            );
201        }
202    }
203
204    None
205}
206
207fn make_fixes(
208    parent_file_id: FileId,
209    source: ModuleSource,
210    new_mod_name: &str,
211    trigger_range: TextRange,
212) -> Option<Vec<Assist>> {
213    fn is_outline_mod(item: &ast::Item) -> bool {
214        matches!(item, ast::Item::Module(m) if m.item_list().is_none())
215    }
216
217    let mod_decl = format!("mod {new_mod_name};");
218    let pub_mod_decl = format!("pub mod {new_mod_name};");
219    let pub_crate_mod_decl = format!("pub(crate) mod {new_mod_name};");
220
221    let mut mod_decl_builder = TextEdit::builder();
222    let mut pub_mod_decl_builder = TextEdit::builder();
223    let mut pub_crate_mod_decl_builder = TextEdit::builder();
224
225    let mut items = match &source {
226        ModuleSource::SourceFile(it) => it.items(),
227        ModuleSource::Module(it) => it.item_list()?.items(),
228        ModuleSource::BlockExpr(_) => return None,
229    };
230
231    // If there's an existing `mod m;` statement matching the new one, don't emit a fix (it's
232    // probably `#[cfg]`d out).
233    for item in items.clone() {
234        if let ast::Item::Module(m) = item
235            && let Some(name) = m.name()
236            && m.item_list().is_none()
237            && name.to_string() == new_mod_name
238        {
239            cov_mark::hit!(unlinked_file_skip_fix_when_mod_already_exists);
240            return None;
241        }
242    }
243
244    // If there are existing `mod m;` items, append after them (after the first group of them, rather).
245    match items.clone().skip_while(|item| !is_outline_mod(item)).take_while(is_outline_mod).last() {
246        Some(last) => {
247            cov_mark::hit!(unlinked_file_append_to_existing_mods);
248            let offset = last.syntax().text_range().end();
249            let indent = IndentLevel::from_node(last.syntax());
250            mod_decl_builder.insert(offset, format!("\n{indent}{mod_decl}"));
251            pub_mod_decl_builder.insert(offset, format!("\n{indent}{pub_mod_decl}"));
252            pub_crate_mod_decl_builder.insert(offset, format!("\n{indent}{pub_crate_mod_decl}"));
253        }
254        None => {
255            // Prepend before the first item in the file.
256            match items.next() {
257                Some(first) => {
258                    cov_mark::hit!(unlinked_file_prepend_before_first_item);
259                    let offset = first.syntax().text_range().start();
260                    let indent = IndentLevel::from_node(first.syntax());
261                    mod_decl_builder.insert(offset, format!("{mod_decl}\n\n{indent}"));
262                    pub_mod_decl_builder.insert(offset, format!("{pub_mod_decl}\n\n{indent}"));
263                    pub_crate_mod_decl_builder
264                        .insert(offset, format!("{pub_crate_mod_decl}\n\n{indent}"));
265                }
266                None => {
267                    // No items in the file, so just append at the end.
268                    cov_mark::hit!(unlinked_file_empty_file);
269                    let mut indent = IndentLevel::from(0);
270                    let offset = match &source {
271                        ModuleSource::SourceFile(it) => it.syntax().text_range().end(),
272                        ModuleSource::Module(it) => {
273                            indent = IndentLevel::from_node(it.syntax()) + 1;
274                            it.item_list()?.r_curly_token()?.text_range().start()
275                        }
276                        ModuleSource::BlockExpr(it) => {
277                            it.stmt_list()?.r_curly_token()?.text_range().start()
278                        }
279                    };
280                    mod_decl_builder.insert(offset, format!("{indent}{mod_decl}\n"));
281                    pub_mod_decl_builder.insert(offset, format!("{indent}{pub_mod_decl}\n"));
282                    pub_crate_mod_decl_builder
283                        .insert(offset, format!("{indent}{pub_crate_mod_decl}\n"));
284                }
285            }
286        }
287    }
288
289    Some(vec![
290        fix(
291            "add_mod_declaration",
292            &format!("Insert `{mod_decl}`"),
293            SourceChange::from_text_edit(parent_file_id, mod_decl_builder.finish()),
294            trigger_range,
295        ),
296        fix(
297            "add_pub_mod_declaration",
298            &format!("Insert `{pub_mod_decl}`"),
299            SourceChange::from_text_edit(parent_file_id, pub_mod_decl_builder.finish()),
300            trigger_range,
301        ),
302        fix(
303            "add_pub_crate_mod_declaration",
304            &format!("Insert `{pub_crate_mod_decl}`"),
305            SourceChange::from_text_edit(parent_file_id, pub_crate_mod_decl_builder.finish()),
306            trigger_range,
307        ),
308    ])
309}
310
311#[cfg(test)]
312mod tests {
313    use crate::tests::{check_diagnostics, check_fix, check_fixes, check_no_fix};
314
315    #[test]
316    fn unlinked_file_prepend_first_item() {
317        cov_mark::check!(unlinked_file_prepend_before_first_item);
318        // Only tests the first one for `pub mod` since the rest are the same
319        check_fixes(
320            r#"
321//- /main.rs
322fn f() {}
323//- /foo.rs
324$0
325"#,
326            vec![
327                r#"
328mod foo;
329
330fn f() {}
331"#,
332                r#"
333pub mod foo;
334
335fn f() {}
336"#,
337                r#"
338pub(crate) mod foo;
339
340fn f() {}
341"#,
342            ],
343        );
344    }
345
346    #[test]
347    fn unlinked_file_append_mod() {
348        cov_mark::check!(unlinked_file_append_to_existing_mods);
349        check_fix(
350            r#"
351//- /main.rs
352//! Comment on top
353
354mod preexisting;
355
356mod preexisting2;
357
358struct S;
359
360mod preexisting_bottom;)
361//- /foo.rs
362$0
363"#,
364            r#"
365//! Comment on top
366
367mod preexisting;
368
369mod preexisting2;
370mod foo;
371
372struct S;
373
374mod preexisting_bottom;)
375"#,
376        );
377    }
378
379    #[test]
380    fn unlinked_file_insert_in_empty_file() {
381        cov_mark::check!(unlinked_file_empty_file);
382        check_fix(
383            r#"
384//- /main.rs
385//- /foo.rs
386$0
387"#,
388            r#"
389mod foo;
390"#,
391        );
392    }
393
394    #[test]
395    fn unlinked_file_insert_in_empty_file_mod_file() {
396        check_fix(
397            r#"
398//- /main.rs
399//- /foo/mod.rs
400$0
401"#,
402            r#"
403mod foo;
404"#,
405        );
406        check_fix(
407            r#"
408//- /main.rs
409mod bar;
410//- /bar.rs
411// bar module
412//- /bar/foo/mod.rs
413$0
414"#,
415            r#"
416// bar module
417mod foo;
418"#,
419        );
420    }
421
422    #[test]
423    fn unlinked_file_old_style_modrs() {
424        check_fix(
425            r#"
426//- /main.rs
427mod submod;
428//- /submod/mod.rs
429// in mod.rs
430//- /submod/foo.rs
431$0
432"#,
433            r#"
434// in mod.rs
435mod foo;
436"#,
437        );
438    }
439
440    #[test]
441    fn unlinked_file_new_style_mod() {
442        check_fix(
443            r#"
444//- /main.rs
445mod submod;
446//- /submod.rs
447//- /submod/foo.rs
448$0
449"#,
450            r#"
451mod foo;
452"#,
453        );
454    }
455
456    #[test]
457    fn unlinked_file_with_cfg_off() {
458        cov_mark::check!(unlinked_file_skip_fix_when_mod_already_exists);
459        check_no_fix(
460            r#"
461//- /main.rs
462#[cfg(never)]
463mod foo;
464
465//- /foo.rs
466$0
467"#,
468        );
469    }
470
471    #[test]
472    fn unlinked_file_with_cfg_on() {
473        check_diagnostics(
474            r#"
475//- /main.rs
476#[cfg(not(never))]
477mod foo;
478
479//- /foo.rs
480"#,
481        );
482    }
483
484    #[test]
485    fn unlinked_file_insert_into_inline_simple() {
486        check_fix(
487            r#"
488//- /main.rs
489mod bar;
490//- /bar.rs
491mod foo {
492}
493//- /bar/foo/baz.rs
494$0
495"#,
496            r#"
497mod foo {
498    mod baz;
499}
500"#,
501        );
502    }
503
504    #[test]
505    fn unlinked_file_insert_into_inline_simple_modrs() {
506        check_fix(
507            r#"
508//- /main.rs
509mod bar;
510//- /bar.rs
511mod baz {
512}
513//- /bar/baz/foo/mod.rs
514$0
515"#,
516            r#"
517mod baz {
518    mod foo;
519}
520"#,
521        );
522    }
523
524    #[test]
525    fn unlinked_file_insert_into_inline_simple_modrs_main() {
526        check_fix(
527            r#"
528//- /main.rs
529mod bar {
530}
531//- /bar/foo/mod.rs
532$0
533"#,
534            r#"
535mod bar {
536    mod foo;
537}
538"#,
539        );
540    }
541
542    #[test]
543    fn include_macro_works() {
544        check_diagnostics(
545            r#"
546//- minicore: include
547//- /main.rs
548include!("bar/foo/mod.rs");
549//- /bar/foo/mod.rs
550"#,
551        );
552    }
553}