Skip to content

Commit 90e165d

Browse files
Restore not-in-INSTALLED_APPS diagnostics from static facts (#672)
1 parent 58d5ead commit 90e165d

13 files changed

Lines changed: 900 additions & 55 deletions

File tree

Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
use std::cmp::Ordering;
2+
use std::collections::BTreeMap;
3+
use std::collections::BTreeSet;
4+
use std::sync::LazyLock;
5+
6+
use camino::Utf8Path;
7+
use camino::Utf8PathBuf;
8+
use djls_source::FileRootKind;
9+
use djls_source::FileSystem;
10+
use djls_source::WalkEntryKind;
11+
use djls_source::WalkOptions;
12+
use rustc_hash::FxHashMap;
13+
14+
use crate::db::Db as ProjectDb;
15+
use crate::names::LibraryName;
16+
use crate::names::PyModuleName;
17+
use crate::project::Project;
18+
use crate::settings::TemplateLibraryAnalysis;
19+
use crate::settings::template_libraries;
20+
use crate::symbols::TemplateSymbolKind;
21+
22+
#[derive(Clone, Debug, PartialEq, Eq)]
23+
pub struct InactiveLibrary {
24+
pub name: LibraryName,
25+
pub app: PyModuleName,
26+
pub module: PyModuleName,
27+
pub tags: Vec<String>,
28+
pub filters: Vec<String>,
29+
}
30+
31+
impl InactiveLibrary {
32+
fn from_candidate(
33+
db: &dyn ProjectDb,
34+
candidate: TemplateTagCandidate,
35+
active_modules: &BTreeSet<PyModuleName>,
36+
) -> Option<Self> {
37+
if active_modules.contains(&candidate.module) {
38+
return None;
39+
}
40+
41+
let file = db.get_or_create_file(&candidate.path);
42+
let analysis = TemplateLibraryAnalysis::from_file(db, file);
43+
if !analysis.defines_library && analysis.symbols.is_empty() {
44+
return None;
45+
}
46+
47+
let mut tags = Vec::new();
48+
let mut filters = Vec::new();
49+
for symbol in analysis.symbols {
50+
match symbol.kind {
51+
TemplateSymbolKind::Tag => tags.push(symbol.name.as_str().to_string()),
52+
TemplateSymbolKind::Filter => filters.push(symbol.name.as_str().to_string()),
53+
}
54+
}
55+
tags.sort();
56+
tags.dedup();
57+
filters.sort();
58+
filters.dedup();
59+
60+
Some(Self {
61+
name: candidate.name,
62+
app: candidate.app,
63+
module: candidate.module,
64+
tags,
65+
filters,
66+
})
67+
}
68+
69+
fn cmp_by_app_name_module(&self, other: &Self) -> Ordering {
70+
self.app
71+
.cmp(&other.app)
72+
.then_with(|| self.name.cmp(&other.name))
73+
.then_with(|| self.module.cmp(&other.module))
74+
}
75+
}
76+
77+
#[derive(Clone, Debug, Default, PartialEq, Eq)]
78+
pub struct InactiveLibraries {
79+
pub by_name: BTreeMap<LibraryName, Vec<InactiveLibrary>>,
80+
}
81+
82+
impl InactiveLibraries {
83+
#[must_use]
84+
pub fn empty_ref() -> &'static Self {
85+
static EMPTY: LazyLock<InactiveLibraries> = LazyLock::new(InactiveLibraries::default);
86+
&EMPTY
87+
}
88+
89+
fn push(&mut self, library: InactiveLibrary) {
90+
self.by_name
91+
.entry(library.name.clone())
92+
.or_default()
93+
.push(library);
94+
}
95+
96+
fn sort_and_dedup(&mut self) {
97+
for libraries in self.by_name.values_mut() {
98+
libraries.sort_by(InactiveLibrary::cmp_by_app_name_module);
99+
libraries.dedup_by(|left, right| left.app == right.app && left.module == right.module);
100+
}
101+
}
102+
103+
#[must_use]
104+
pub fn library_candidates(&self, name: &LibraryName) -> &[InactiveLibrary] {
105+
self.by_name.get(name).map_or(&[], Vec::as_slice)
106+
}
107+
108+
#[must_use]
109+
pub fn tag_candidates(&self, tag: &str) -> Vec<&InactiveLibrary> {
110+
let mut candidates: Vec<_> = self
111+
.by_name
112+
.values()
113+
.flat_map(|libraries| libraries.iter())
114+
.filter(|library| library.tags.iter().any(|candidate| candidate == tag))
115+
.collect();
116+
candidates.sort_by(|left, right| left.cmp_by_app_name_module(right));
117+
candidates
118+
}
119+
120+
#[must_use]
121+
pub fn filter_candidates(&self, filter: &str) -> Vec<&InactiveLibrary> {
122+
let mut candidates: Vec<_> = self
123+
.by_name
124+
.values()
125+
.flat_map(|libraries| libraries.iter())
126+
.filter(|library| library.filters.iter().any(|candidate| candidate == filter))
127+
.collect();
128+
candidates.sort_by(|left, right| left.cmp_by_app_name_module(right));
129+
candidates
130+
}
131+
}
132+
133+
#[derive(Clone, Debug, PartialEq, Eq)]
134+
struct TemplateTagCandidate {
135+
app: PyModuleName,
136+
name: LibraryName,
137+
module: PyModuleName,
138+
path: Utf8PathBuf,
139+
}
140+
141+
impl TemplateTagCandidate {
142+
fn new(app: PyModuleName, name: LibraryName, path: Utf8PathBuf) -> Option<Self> {
143+
let module =
144+
PyModuleName::parse(&format!("{}.templatetags.{}", app.as_str(), name.as_str()))
145+
.ok()?;
146+
147+
Some(Self {
148+
app,
149+
name,
150+
module,
151+
path,
152+
})
153+
}
154+
155+
fn into_path(self) -> Utf8PathBuf {
156+
self.path
157+
}
158+
159+
fn cmp_by_app_name_path(&self, other: &Self) -> Ordering {
160+
self.app
161+
.cmp(&other.app)
162+
.then_with(|| self.name.cmp(&other.name))
163+
.then_with(|| self.path.cmp(&other.path))
164+
}
165+
}
166+
167+
#[salsa::tracked(returns(ref))]
168+
pub fn inactive_template_libraries(db: &dyn ProjectDb, project: Project) -> InactiveLibraries {
169+
project.touch_search_path_roots(db);
170+
171+
let template_libraries = template_libraries(db, project);
172+
let active_modules: BTreeSet<_> = template_libraries
173+
.loadable
174+
.values()
175+
.map(|library| library.module().clone())
176+
.chain(template_libraries.builtin_modules().cloned())
177+
.collect();
178+
179+
let mut inactive = InactiveLibraries::default();
180+
for candidate in discover_project_templatetag_candidates(db, project) {
181+
if let Some(library) = InactiveLibrary::from_candidate(db, candidate, &active_modules) {
182+
inactive.push(library);
183+
}
184+
}
185+
186+
inactive.sort_and_dedup();
187+
inactive
188+
}
189+
190+
pub(crate) fn templatetag_candidate_paths(
191+
db: &dyn ProjectDb,
192+
project: Project,
193+
) -> Vec<Utf8PathBuf> {
194+
discover_project_templatetag_candidates(db, project)
195+
.into_iter()
196+
.map(TemplateTagCandidate::into_path)
197+
.collect()
198+
}
199+
200+
fn discover_project_templatetag_candidates(
201+
db: &dyn ProjectDb,
202+
project: Project,
203+
) -> Vec<TemplateTagCandidate> {
204+
let search_paths = project.search_paths(db);
205+
let mut candidates_by_path: Vec<(usize, TemplateTagCandidate)> = Vec::new();
206+
let mut path_indexes: FxHashMap<Utf8PathBuf, usize> = FxHashMap::default();
207+
208+
for search_path in search_paths.iter() {
209+
let excluded_paths = if search_path.is_first_party() {
210+
search_paths
211+
.iter()
212+
.filter(|other| {
213+
!other.is_first_party() && other.path().starts_with(search_path.path())
214+
})
215+
.map(|other| other.path().to_path_buf())
216+
.collect()
217+
} else {
218+
Vec::new()
219+
};
220+
let search_path_len = search_path.path().as_str().len();
221+
222+
for candidate in discover_templatetag_candidates(
223+
db.file_system(),
224+
search_path.path(),
225+
search_path.root_kind(),
226+
&excluded_paths,
227+
) {
228+
let path = candidate.path.clone();
229+
if let Some(index) = path_indexes.get(&path).copied() {
230+
let (existing_search_path_len, existing) = &mut candidates_by_path[index];
231+
if search_path_len > *existing_search_path_len {
232+
*existing_search_path_len = search_path_len;
233+
*existing = candidate;
234+
}
235+
} else {
236+
path_indexes.insert(path, candidates_by_path.len());
237+
candidates_by_path.push((search_path_len, candidate));
238+
}
239+
}
240+
}
241+
242+
candidates_by_path
243+
.into_iter()
244+
.map(|(_search_path_len, candidate)| candidate)
245+
.collect()
246+
}
247+
248+
fn discover_templatetag_candidates(
249+
fs: &dyn FileSystem,
250+
base_dir: &Utf8Path,
251+
root_kind: FileRootKind,
252+
excluded_roots: &[Utf8PathBuf],
253+
) -> Vec<TemplateTagCandidate> {
254+
let options = match root_kind {
255+
FileRootKind::Project => WalkOptions::project(),
256+
FileRootKind::SearchPath => WalkOptions::library_search_path(),
257+
};
258+
259+
let mut results = Vec::new();
260+
261+
let entries = match fs.walk_entries(base_dir, &options) {
262+
Ok(entries) => entries,
263+
Err(err) => {
264+
tracing::warn!("Failed to walk Python source root {}: {}", base_dir, err);
265+
return results;
266+
}
267+
};
268+
269+
for entry in entries {
270+
if entry.kind != WalkEntryKind::File {
271+
continue;
272+
}
273+
let path = entry.path;
274+
275+
if path.extension() != Some("py") {
276+
continue;
277+
}
278+
let Some(stem) = path.file_stem() else {
279+
continue;
280+
};
281+
if stem.starts_with('_') {
282+
continue;
283+
}
284+
285+
if excluded_roots
286+
.iter()
287+
.any(|excluded| path.starts_with(excluded))
288+
{
289+
continue;
290+
}
291+
292+
let Some(templatetags_dir) = path.parent() else {
293+
continue;
294+
};
295+
if templatetags_dir.file_name() != Some("templatetags") {
296+
continue;
297+
}
298+
if !fs.exists(&templatetags_dir.join("__init__.py")) {
299+
continue;
300+
}
301+
302+
let Some(app_dir) = templatetags_dir.parent() else {
303+
continue;
304+
};
305+
if app_dir == base_dir || !fs.exists(&app_dir.join("__init__.py")) {
306+
continue;
307+
}
308+
309+
let Some(app_rel) = app_dir.strip_prefix(base_dir).ok() else {
310+
continue;
311+
};
312+
let Ok(app) = PyModuleName::from_relative_package(app_rel) else {
313+
continue;
314+
};
315+
let Ok(name) = LibraryName::parse(stem) else {
316+
continue;
317+
};
318+
let Some(candidate) = TemplateTagCandidate::new(app, name, path) else {
319+
continue;
320+
};
321+
322+
results.push(candidate);
323+
}
324+
325+
results.sort_by(TemplateTagCandidate::cmp_by_app_name_path);
326+
results
327+
}
328+
329+
#[cfg(test)]
330+
mod tests {
331+
use camino::Utf8Path;
332+
use djls_source::InMemoryFileSystem;
333+
334+
use super::*;
335+
336+
#[test]
337+
fn discover_templatetag_candidates_requires_django_package_shape() {
338+
let mut fs = InMemoryFileSystem::new();
339+
fs.add_file("/root/pkg_a/__init__.py".into(), String::new());
340+
fs.add_file("/root/pkg_a/templatetags/__init__.py".into(), String::new());
341+
fs.add_file("/root/pkg_a/templatetags/foo.py".into(), String::new());
342+
fs.add_file("/root/pkg_a/templatetags/_private.py".into(), String::new());
343+
fs.add_file("/root/pkg_b/__init__.py".into(), String::new());
344+
fs.add_file("/root/pkg_b/templatetags/bar.py".into(), String::new());
345+
fs.add_file("/root/loose/templatetags/__init__.py".into(), String::new());
346+
fs.add_file("/root/loose/templatetags/baz.py".into(), String::new());
347+
348+
let discovered = discover_templatetag_candidates(
349+
&fs,
350+
Utf8Path::new("/root"),
351+
FileRootKind::Project,
352+
&[],
353+
);
354+
355+
assert_eq!(discovered.len(), 1);
356+
let candidate = &discovered[0];
357+
assert_eq!(candidate.app.as_str(), "pkg_a");
358+
assert_eq!(candidate.name.as_str(), "foo");
359+
assert_eq!(candidate.module.as_str(), "pkg_a.templatetags.foo");
360+
assert_eq!(candidate.path.as_str(), "/root/pkg_a/templatetags/foo.py");
361+
}
362+
}

crates/djls-project/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
mod db;
2+
mod environment;
23
mod extraction;
34
mod names;
45
mod parse;
@@ -13,6 +14,9 @@ mod system;
1314
mod templates;
1415

1516
pub use db::Db;
17+
pub use environment::InactiveLibraries;
18+
pub use environment::InactiveLibrary;
19+
pub use environment::inactive_template_libraries;
1620
pub use extraction::DjangoSettings;
1721
pub use extraction::ExprExt;
1822
pub use extraction::InstalledAppsSetting;

crates/djls-project/src/resolve.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ impl SearchPath {
6262
matches!(self, Self::FirstParty(_))
6363
}
6464

65-
fn root_kind(&self) -> FileRootKind {
65+
pub(crate) fn root_kind(&self) -> FileRootKind {
6666
match self {
6767
// Extra pythonpath entries are user-edited code, so they get the
6868
// same low-durability treatment as project files.

0 commit comments

Comments
 (0)