Skip to main content

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