Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -559,12 +559,17 @@ impl Analyzer {
self.push_evicted_paths(evicted, invalidated_paths);
}

/// Append the wire path of every file whose cached query was evicted.
/// Append the wire path or virtual module ID of every file whose cached query was evicted.
fn push_evicted_paths(&self, evicted: Vec<QueryKey>, invalidated_paths: &mut Vec<String>) {
let mut evicted: Vec<String> = evicted
.into_iter()
.filter_map(|key| key.file_id())
.map(|file_id| crate::fs::path_to_string(self.engine.lookup_path(file_id)))
.map(|file_id| match self.engine.lookup_module(file_id) {
(id, crate::host::IdKind::Virtual) => id.as_str().to_owned(),
(_, crate::host::IdKind::Path) => {
crate::fs::path_to_string(self.engine.lookup_path(file_id))
}
})
.collect();
evicted.sort_unstable();
evicted.dedup();
Expand Down Expand Up @@ -665,6 +670,10 @@ impl Analyzer {
self.resource_registry.unregister_ts_file(&path);
ts_paths_to_invalidate.push(path.to_string_lossy().into_owned());
}
if matches!(update.update_type, crate::FileUpdateType::Deleted) {
let evicted = self.engine.invalidate_deleted_file(&path);
self.push_evicted_paths(evicted, &mut ts_paths_to_invalidate);
}
self.invalidate_changed_path(&path, &mut ts_paths_to_invalidate);
}

Expand Down Expand Up @@ -736,6 +745,16 @@ impl Analyzer {
Ok(dedup_preserving_order(invalidated_paths))
}

/// Flush every cached module resolution and return every module ID whose cached analysis was
/// evicted.
pub fn clear_resolution_cache_core(&self) -> Result<Vec<String>, String> {
self.cancel_current_analysis();
let mut invalidated_paths = Vec::new();
let evicted = self.engine.clear_resolution_cache();
self.push_evicted_paths(evicted, &mut invalidated_paths);
Ok(dedup_preserving_order(invalidated_paths))
}

/// Install the given compilation roots and the tsconfig's `.d.ts` files (`None` keeps the
/// current list), evicting every query that read the previous lists when either differs, and
/// every query that read which root is `r3_symbols.ts` when that changes too.
Expand Down Expand Up @@ -876,6 +895,20 @@ impl Analyzer {
self.set_entry_points_core(entry_points)
}

/// Flush every cached module resolution and return every module ID whose cached analysis was
/// evicted.
#[cfg(feature = "napi")]
#[napi]
pub fn clear_resolution_cache(&self) -> napi::Result<Vec<String>> {
self.clear_resolution_cache_core()
.map_err(napi::Error::from_reason)
}

#[cfg(not(feature = "napi"))]
pub fn clear_resolution_cache(&self) -> Result<Vec<String>, String> {
self.clear_resolution_cache_core()
}

#[cfg(feature = "napi")]
#[napi]
pub fn analyze(&self) -> napi::Result<crate::AnalysisIterator> {
Expand Down
227 changes: 227 additions & 0 deletions packages/compiler-cli/preprocessor/ng-analyze/src/compiler/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5466,6 +5466,233 @@ fn test_update_file_content_created_file_resolves_failed_import() {
);
}

/// Virtual files of a minimal `@angular/core` package under `root`. A fixture without one fails
/// to resolve `@angular/core`, which alone re-analyzes `app.ts` on every file creation or
/// deletion and hides whether the failed import a test is about is tracked.
fn angular_core_stub(root: &str) -> HashMap<String, String> {
HashMap::from([
(
format!("{root}/node_modules/@angular/core/package.json"),
r#"{"name": "@angular/core", "types": "./index.d.ts"}"#.to_string(),
),
(
format!("{root}/node_modules/@angular/core/index.d.ts"),
r#"
export declare const Component: any;
export declare const Directive: any;
"#
.to_string(),
),
])
}

/// Whether `app.ts` lists a file whose path ends in the components `suffix` among its resolved
/// static imports (`AnalysisResult::static_dependencies`).
fn app_statically_imports(analyzer: &Analyzer, app_path: &str, suffix: &str) -> bool {
analyzer
.get_metadata_for_file(app_path.to_string())
.is_some_and(|meta| {
meta.static_dependencies
.iter()
.any(|dep| Path::new(dep).ends_with(suffix))
})
}

#[test]
fn test_invalidate_files_created_file_resolves_failed_barrel_reexport() {
let dir = tempfile::TempDir::new().unwrap();
let root = dir.path().canonicalize().unwrap();
let root_str = root.to_string_lossy().into_owned();
std::fs::write(root.join("barrel.ts"), "export * from './lib';\n").unwrap();
let lib_path = root.join("lib.ts");
let app_path = format!("{root_str}/app.ts");

let analyzer = consumer_analyzer(&root_str, "./barrel", angular_core_stub(&root_str));
assert_eq!(imported_lib_dir_selector(&analyzer, &app_path), None);
let app_wire_path = analyzer
.get_metadata_for_file(app_path.clone())
.unwrap()
.file_path;

std::fs::write(&lib_path, lib_source("[libNew]")).unwrap();
let invalidated = analyzer
.invalidate_files(vec![FileInvalidation {
file_path: lib_path.to_string_lossy().into_owned(),
update_type: FileUpdateType::Created,
}])
.unwrap();

assert!(invalidated.contains(&app_wire_path), "{invalidated:?}");
assert_eq!(
imported_lib_dir_selector(&analyzer, &app_path).as_deref(),
Some("[libNew]"),
"transitive re-export of the created file must resolve"
);
}

#[test]
fn test_update_file_content_created_file_resolves_failed_dts_reexport() {
let mut virtual_files = angular_core_stub("/project");
virtual_files.insert(
"/project/barrel.d.ts".to_string(),
"export * from './lib';\n".to_string(),
);
let analyzer = consumer_analyzer("/project", "./barrel", virtual_files);
assert_eq!(
imported_lib_dir_selector(&analyzer, "/project/app.ts"),
None
);

let invalidated = analyzer
.update_file_content(vec![FileUpdate {
file_path: "/project/lib.d.ts".to_string(),
content: lib_dts("[libFromDts]"),
}])
.unwrap();

assert!(
invalidated.contains(&"/project/app.ts".to_string()),
"{invalidated:?}"
);
assert_eq!(
imported_lib_dir_selector(&analyzer, "/project/app.ts").as_deref(),
Some("[libFromDts]"),
"re-export from .d.ts to newly created .d.ts must resolve"
);
}

#[test]
fn test_invalidate_files_deleted_file_re_resolves_importer_to_fallback() {
let dir = tempfile::TempDir::new().unwrap();
let root = dir.path().canonicalize().unwrap();
let root_str = root.to_string_lossy().into_owned();
let lib_file = root.join("lib.ts");
let lib_dir = root.join("lib");
std::fs::create_dir(&lib_dir).unwrap();
std::fs::write(&lib_file, lib_source("[libDirect]")).unwrap();
std::fs::write(lib_dir.join("index.ts"), lib_source("[libIndex]")).unwrap();
let app_path = format!("{root_str}/app.ts");

let analyzer = consumer_analyzer(&root_str, "./lib", angular_core_stub(&root_str));
assert_eq!(
imported_lib_dir_selector(&analyzer, &app_path).as_deref(),
Some("[libDirect]")
);
assert!(app_statically_imports(&analyzer, &app_path, "lib.ts"));
let app_wire_path = analyzer
.get_metadata_for_file(app_path.clone())
.unwrap()
.file_path;

std::fs::remove_file(&lib_file).unwrap();
let invalidated = analyzer
.invalidate_files(vec![FileInvalidation {
file_path: lib_file.to_string_lossy().into_owned(),
update_type: FileUpdateType::Deleted,
}])
.unwrap();

assert!(invalidated.contains(&app_wire_path), "{invalidated:?}");
assert_eq!(
imported_lib_dir_selector(&analyzer, &app_path).as_deref(),
Some("[libIndex]"),
"deleting the resolved target must evict importer resolution so fallback resolves"
);
assert!(
app_statically_imports(&analyzer, &app_path, "lib/index.ts"),
"the importer's static dependency edge must follow the fallback"
);
assert!(
!app_statically_imports(&analyzer, &app_path, "lib.ts"),
"the importer must not keep a static dependency edge to the deleted file"
);
}

/// A file creation or deletion stops the engine serving its cached resolutions, but an importer
/// whose queries stay cached does not resolve again. Deleting the file it resolved to must still
/// find and re-analyze it.
#[test]
fn test_invalidate_files_deleted_file_evicts_importer_after_unrelated_file_created() {
let dir = tempfile::TempDir::new().unwrap();
let root = dir.path().canonicalize().unwrap();
let root_str = root.to_string_lossy().into_owned();
let lib_file = root.join("lib.ts");
let lib_dir = root.join("lib");
std::fs::create_dir(&lib_dir).unwrap();
std::fs::write(&lib_file, lib_source("[libDirect]")).unwrap();
std::fs::write(lib_dir.join("index.ts"), lib_source("[libIndex]")).unwrap();
let app_path = format!("{root_str}/app.ts");

let analyzer = consumer_analyzer(&root_str, "./lib", angular_core_stub(&root_str));
assert!(app_statically_imports(&analyzer, &app_path, "lib.ts"));

let unrelated = root.join("unrelated.ts");
std::fs::write(&unrelated, "export const unrelated = 1;\n").unwrap();
let invalidated = analyzer
.invalidate_files(vec![FileInvalidation {
file_path: unrelated.to_string_lossy().into_owned(),
update_type: FileUpdateType::Created,
}])
.unwrap();
assert!(
!invalidated
.iter()
.any(|path| Path::new(path).ends_with("app.ts")),
"app.ts has no unresolved import, so an unrelated file must leave it cached: \
{invalidated:?}"
);

std::fs::remove_file(&lib_file).unwrap();
analyzer
.invalidate_files(vec![FileInvalidation {
file_path: lib_file.to_string_lossy().into_owned(),
update_type: FileUpdateType::Deleted,
}])
.unwrap();

assert!(
app_statically_imports(&analyzer, &app_path, "lib/index.ts"),
"the importer's static dependency edge must follow the fallback"
);
assert!(
!app_statically_imports(&analyzer, &app_path, "lib.ts"),
"the importer must not keep a static dependency edge to the deleted file"
);
}

#[test]
fn test_clear_resolution_cache_re_resolves_cached_importers() {
let dir = tempfile::TempDir::new().unwrap();
let root = dir.path().canonicalize().unwrap();
let root_str = root.to_string_lossy().into_owned();
let lib_dir = root.join("lib");
std::fs::create_dir(&lib_dir).unwrap();
std::fs::write(lib_dir.join("index.ts"), lib_source("[libIndex]")).unwrap();
let app_path = format!("{root_str}/app.ts");

let analyzer = consumer_analyzer(&root_str, "./lib", HashMap::new());
assert_eq!(
imported_lib_dir_selector(&analyzer, &app_path).as_deref(),
Some("[libIndex]")
);
let app_wire_path = analyzer
.get_metadata_for_file(app_path.clone())
.unwrap()
.file_path;

// Write `lib.ts` shadowing `lib/index.ts` without reporting `FileUpdateType::Created`, then
// explicitly flush cached resolutions via `clear_resolution_cache`.
std::fs::write(root.join("lib.ts"), lib_source("[libDirect]")).unwrap();
let invalidated = analyzer.clear_resolution_cache().unwrap();

assert!(invalidated.contains(&app_wire_path), "{invalidated:?}");
assert_eq!(
imported_lib_dir_selector(&analyzer, &app_path).as_deref(),
Some("[libDirect]"),
"clear_resolution_cache must evict cached positive resolutions so ./lib re-resolves"
);
}

#[cfg(unix)]
#[test]
fn test_canonical_paths_resolves_symlinked_host_paths() {
Expand Down
12 changes: 12 additions & 0 deletions packages/compiler-cli/preprocessor/ng-analyze/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,18 @@ fn handle_rpc_request(req: RpcRequest, analyzer: &mut Option<Analyzer>) -> RpcRe
},
}
}
"clearResolutionCache" => match a.clear_resolution_cache_core() {
Ok(res) => RpcResponse {
id: req.id,
result: Some(serde_json::to_value(&res).unwrap()),
error: None,
},
Err(e) => RpcResponse {
id: req.id,
result: None,
error: Some(e),
},
},
"getTsFileForTemplate" => {
let Ok(param) = serde_json::from_value::<TemplatePathParam>(req.params) else {
return RpcResponse {
Expand Down
45 changes: 45 additions & 0 deletions packages/compiler-cli/preprocessor/ng-analyze/src/query/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,51 @@ impl QueryContext {
}
}

/// Resolve `req` from `importer` through the engine's resolution cache
/// ([`QueryEngine::resolve_request_cached`]), recording both `importer` and (when resolution
/// fails) `UNRESOLVED_IMPORTS_FILE_ID` on this query context.
///
/// The cache is keyed by `importer` while the host is asked about `req.importer`, so both must
/// name the same module. Debug builds assert it.
pub async fn resolve_request(
&self,
importer: FileId,
req: crate::host::ResolveRequest,
) -> Result<Option<(FileId, crate::host::ResolvedModule)>, crate::host::HostError> {
self.record_file(importer);
let outcome = self.engine.resolve_request_cached(importer, req).await;
if !matches!(outcome, Ok(Some(_))) {
self.record_file(crate::query::engine::UNRESOLVED_IMPORTS_FILE_ID);
}
outcome
}

/// [`Self::resolve_request`] for a plain `specifier` lookup of the given `kind` from
/// `importer`, without import attributes. A host failure is reported as unresolved.
pub async fn resolve_from_id(
&self,
importer: FileId,
specifier: &str,
kind: crate::host::ImportKind,
) -> Option<(FileId, crate::host::ResolvedModule)> {
let importer_id = self.engine.lookup_id(importer);
let req = crate::host::ResolveRequest::new(importer_id, specifier, kind);
self.resolve_request(importer, req).await.ok().flatten()
}

/// [`Self::resolve_from_id`] for an importer and a result named by filesystem path, interning
/// `importer_path` if it wasn't already.
pub async fn resolve_from_path(
&self,
importer_path: &std::path::Path,
specifier: &str,
kind: crate::host::ImportKind,
) -> Option<(FileId, std::path::PathBuf)> {
let importer = self.engine.intern_path(importer_path);
let (target_id, _) = self.resolve_from_id(importer, specifier, kind).await?;
Some((target_id, self.engine.lookup_path(target_id)))
}

async fn query_with_deps(&self, key: QueryKey) -> Arc<QueryValue> {
let cached = self.engine.query(key).await;
self.extend_dependencies(cached.dependencies.iter().copied());
Expand Down
Loading
Loading