aboutsummaryrefslogtreecommitdiff
path: root/crates/ide_diagnostics/src/handlers/unlinked_file.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/ide_diagnostics/src/handlers/unlinked_file.rs')
-rw-r--r--crates/ide_diagnostics/src/handlers/unlinked_file.rs301
1 files changed, 301 insertions, 0 deletions
diff --git a/crates/ide_diagnostics/src/handlers/unlinked_file.rs b/crates/ide_diagnostics/src/handlers/unlinked_file.rs
new file mode 100644
index 000000000..8921ddde2
--- /dev/null
+++ b/crates/ide_diagnostics/src/handlers/unlinked_file.rs
@@ -0,0 +1,301 @@
1//! Diagnostic emitted for files that aren't part of any crate.
2
3use hir::db::DefDatabase;
4use ide_db::{
5 base_db::{FileId, FileLoader, SourceDatabase, SourceDatabaseExt},
6 source_change::SourceChange,
7 RootDatabase,
8};
9use syntax::{
10 ast::{self, ModuleItemOwner, NameOwner},
11 AstNode, TextRange, TextSize,
12};
13use text_edit::TextEdit;
14
15use crate::{fix, Assist, Diagnostic, DiagnosticsContext};
16
17#[derive(Debug)]
18pub(crate) struct UnlinkedFile {
19 pub(crate) file: FileId,
20}
21
22// Diagnostic: unlinked-file
23//
24// This diagnostic is shown for files that are not included in any crate, or files that are part of
25// crates rust-analyzer failed to discover. The file will not have IDE features available.
26pub(crate) fn unlinked_file(ctx: &DiagnosticsContext, d: &UnlinkedFile) -> Diagnostic {
27 // Limit diagnostic to the first few characters in the file. This matches how VS Code
28 // renders it with the full span, but on other editors, and is less invasive.
29 let range = ctx.sema.db.parse(d.file).syntax_node().text_range();
30 // FIXME: This is wrong if one of the first three characters is not ascii: `//Ы`.
31 let range = range.intersect(TextRange::up_to(TextSize::of("..."))).unwrap_or(range);
32
33 Diagnostic::new("unlinked-file", "file not included in module tree", range)
34 .with_fixes(fixes(ctx, d))
35}
36
37fn fixes(ctx: &DiagnosticsContext, d: &UnlinkedFile) -> Option<Vec<Assist>> {
38 // If there's an existing module that could add `mod` or `pub mod` items to include the unlinked file,
39 // suggest that as a fix.
40
41 let source_root = ctx.sema.db.source_root(ctx.sema.db.file_source_root(d.file));
42 let our_path = source_root.path_for_file(&d.file)?;
43 let module_name = our_path.name_and_extension()?.0;
44
45 // Candidates to look for:
46 // - `mod.rs` in the same folder
47 // - we also check `main.rs` and `lib.rs`
48 // - `$dir.rs` in the parent folder, where `$dir` is the directory containing `self.file_id`
49 let parent = our_path.parent()?;
50 let mut paths = vec![parent.join("mod.rs")?, parent.join("lib.rs")?, parent.join("main.rs")?];
51
52 // `submod/bla.rs` -> `submod.rs`
53 if let Some(newmod) = (|| {
54 let name = parent.name_and_extension()?.0;
55 parent.parent()?.join(&format!("{}.rs", name))
56 })() {
57 paths.push(newmod);
58 }
59
60 for path in &paths {
61 if let Some(parent_id) = source_root.file_for_path(path) {
62 for krate in ctx.sema.db.relevant_crates(*parent_id).iter() {
63 let crate_def_map = ctx.sema.db.crate_def_map(*krate);
64 for (_, module) in crate_def_map.modules() {
65 if module.origin.is_inline() {
66 // We don't handle inline `mod parent {}`s, they use different paths.
67 continue;
68 }
69
70 if module.origin.file_id() == Some(*parent_id) {
71 return make_fixes(ctx.sema.db, *parent_id, module_name, d.file);
72 }
73 }
74 }
75 }
76 }
77
78 None
79}
80
81fn make_fixes(
82 db: &RootDatabase,
83 parent_file_id: FileId,
84 new_mod_name: &str,
85 added_file_id: FileId,
86) -> Option<Vec<Assist>> {
87 fn is_outline_mod(item: &ast::Item) -> bool {
88 matches!(item, ast::Item::Module(m) if m.item_list().is_none())
89 }
90
91 let mod_decl = format!("mod {};", new_mod_name);
92 let pub_mod_decl = format!("pub mod {};", new_mod_name);
93
94 let ast: ast::SourceFile = db.parse(parent_file_id).tree();
95
96 let mut mod_decl_builder = TextEdit::builder();
97 let mut pub_mod_decl_builder = TextEdit::builder();
98
99 // If there's an existing `mod m;` statement matching the new one, don't emit a fix (it's
100 // probably `#[cfg]`d out).
101 for item in ast.items() {
102 if let ast::Item::Module(m) = item {
103 if let Some(name) = m.name() {
104 if m.item_list().is_none() && name.to_string() == new_mod_name {
105 cov_mark::hit!(unlinked_file_skip_fix_when_mod_already_exists);
106 return None;
107 }
108 }
109 }
110 }
111
112 // If there are existing `mod m;` items, append after them (after the first group of them, rather).
113 match ast
114 .items()
115 .skip_while(|item| !is_outline_mod(item))
116 .take_while(|item| is_outline_mod(item))
117 .last()
118 {
119 Some(last) => {
120 cov_mark::hit!(unlinked_file_append_to_existing_mods);
121 let offset = last.syntax().text_range().end();
122 mod_decl_builder.insert(offset, format!("\n{}", mod_decl));
123 pub_mod_decl_builder.insert(offset, format!("\n{}", pub_mod_decl));
124 }
125 None => {
126 // Prepend before the first item in the file.
127 match ast.items().next() {
128 Some(item) => {
129 cov_mark::hit!(unlinked_file_prepend_before_first_item);
130 let offset = item.syntax().text_range().start();
131 mod_decl_builder.insert(offset, format!("{}\n\n", mod_decl));
132 pub_mod_decl_builder.insert(offset, format!("{}\n\n", pub_mod_decl));
133 }
134 None => {
135 // No items in the file, so just append at the end.
136 cov_mark::hit!(unlinked_file_empty_file);
137 let offset = ast.syntax().text_range().end();
138 mod_decl_builder.insert(offset, format!("{}\n", mod_decl));
139 pub_mod_decl_builder.insert(offset, format!("{}\n", pub_mod_decl));
140 }
141 }
142 }
143 }
144
145 let trigger_range = db.parse(added_file_id).tree().syntax().text_range();
146 Some(vec![
147 fix(
148 "add_mod_declaration",
149 &format!("Insert `{}`", mod_decl),
150 SourceChange::from_text_edit(parent_file_id, mod_decl_builder.finish()),
151 trigger_range,
152 ),
153 fix(
154 "add_pub_mod_declaration",
155 &format!("Insert `{}`", pub_mod_decl),
156 SourceChange::from_text_edit(parent_file_id, pub_mod_decl_builder.finish()),
157 trigger_range,
158 ),
159 ])
160}
161
162#[cfg(test)]
163mod tests {
164 use crate::tests::{check_diagnostics, check_fix, check_fixes, check_no_fix};
165
166 #[test]
167 fn unlinked_file_prepend_first_item() {
168 cov_mark::check!(unlinked_file_prepend_before_first_item);
169 // Only tests the first one for `pub mod` since the rest are the same
170 check_fixes(
171 r#"
172//- /main.rs
173fn f() {}
174//- /foo.rs
175$0
176"#,
177 vec![
178 r#"
179mod foo;
180
181fn f() {}
182"#,
183 r#"
184pub mod foo;
185
186fn f() {}
187"#,
188 ],
189 );
190 }
191
192 #[test]
193 fn unlinked_file_append_mod() {
194 cov_mark::check!(unlinked_file_append_to_existing_mods);
195 check_fix(
196 r#"
197//- /main.rs
198//! Comment on top
199
200mod preexisting;
201
202mod preexisting2;
203
204struct S;
205
206mod preexisting_bottom;)
207//- /foo.rs
208$0
209"#,
210 r#"
211//! Comment on top
212
213mod preexisting;
214
215mod preexisting2;
216mod foo;
217
218struct S;
219
220mod preexisting_bottom;)
221"#,
222 );
223 }
224
225 #[test]
226 fn unlinked_file_insert_in_empty_file() {
227 cov_mark::check!(unlinked_file_empty_file);
228 check_fix(
229 r#"
230//- /main.rs
231//- /foo.rs
232$0
233"#,
234 r#"
235mod foo;
236"#,
237 );
238 }
239
240 #[test]
241 fn unlinked_file_old_style_modrs() {
242 check_fix(
243 r#"
244//- /main.rs
245mod submod;
246//- /submod/mod.rs
247// in mod.rs
248//- /submod/foo.rs
249$0
250"#,
251 r#"
252// in mod.rs
253mod foo;
254"#,
255 );
256 }
257
258 #[test]
259 fn unlinked_file_new_style_mod() {
260 check_fix(
261 r#"
262//- /main.rs
263mod submod;
264//- /submod.rs
265//- /submod/foo.rs
266$0
267"#,
268 r#"
269mod foo;
270"#,
271 );
272 }
273
274 #[test]
275 fn unlinked_file_with_cfg_off() {
276 cov_mark::check!(unlinked_file_skip_fix_when_mod_already_exists);
277 check_no_fix(
278 r#"
279//- /main.rs
280#[cfg(never)]
281mod foo;
282
283//- /foo.rs
284$0
285"#,
286 );
287 }
288
289 #[test]
290 fn unlinked_file_with_cfg_on() {
291 check_diagnostics(
292 r#"
293//- /main.rs
294#[cfg(not(never))]
295mod foo;
296
297//- /foo.rs
298"#,
299 );
300 }
301}