#![expect(
clippy::expect_used,
clippy::panic,
reason = "the lint's grant covers `#[test]` functions and `#[cfg(test)]` modules, and a \
helper in an integration-test crate is neither — see AGENTS.md"
)]
use std::fmt::Write as _;
use std::path::PathBuf;
use lanekeep_core::{AnalysisBudget, FileAccess, FilePath};
use lanekeep_lang_js::{Tsx, TypeScript};
use lanekeep_types::{Query, TypeProvider};
fn budget() -> AnalysisBudget {
AnalysisBudget::start(std::time::Duration::from_mins(10))
}
struct Project {
dir: PathBuf,
}
impl Project {
fn new(test: &str, files: &[(&str, &str)]) -> Self {
let dir =
std::env::temp_dir().join(format!("lanekeep-provider-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("creates the project directory");
let project = Self { dir };
for (path, contents) in files {
project.write(path, contents);
}
project
}
fn write(&self, path: &str, contents: &str) {
let full = self.dir.join(path);
if let Some(parent) = full.parent() {
std::fs::create_dir_all(parent).expect("creates the parent directory");
}
std::fs::write(full, contents).expect("writes the fixture file");
}
fn files(&self) -> FileAccess {
FileAccess::new(&self.dir)
}
}
impl Drop for Project {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn parse(source: &str) -> tree_sitter::Tree {
use lanekeep_lang::Language;
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&TypeScript.grammar())
.expect("the TypeScript grammar loads");
parser.parse(source, None).expect("the source parses")
}
fn last_of<'t>(tree: &'t tree_sitter::Tree, kind: &str) -> tree_sitter::Node<'t> {
let mut best: Option<tree_sitter::Node<'t>> = None;
let mut stack = vec![tree.root_node()];
while let Some(node) = stack.pop() {
if node.kind() == kind && best.is_none_or(|b| node.start_byte() > b.start_byte()) {
best = Some(node);
}
let mut cursor = node.walk();
let children: Vec<tree_sitter::Node<'t>> = node.children(&mut cursor).collect();
stack.extend(children);
}
best.unwrap_or_else(|| panic!("no `{kind}` node in the tree"))
}
#[test]
fn a_query_carries_one_calls_context() {
let project = Project::new("query-shape", &[("src/a.ts", "const x = 1;\n")]);
let files = project.files();
let source = "const x = 1;\n";
let tree = parse(source);
let file = FilePath::new("src/a.ts");
let query = Query {
file: &file,
tree: &tree,
source,
node: last_of(&tree, "number"),
files: &files,
};
assert_eq!(query.file.as_str(), "src/a.ts");
assert_eq!(query.node.kind(), "number");
assert_eq!(query.source, source);
let again: Query<'_> = query;
assert_eq!(again.node.kind(), "number");
}
#[test]
fn the_provider_trait_is_shareable_and_object_safe() {
const fn assert_shareable<T: Send + Sync + ?Sized>() {}
assert_shareable::<dyn TypeProvider>();
}
#[test]
fn the_default_begin_run_does_not_walk_the_corpus() {
struct Silent;
impl TypeProvider for Silent {
fn type_of(&self, _: Query<'_>) -> Option<lanekeep_types::Type> {
None
}
fn symbol_of(&self, _: Query<'_>) -> Option<lanekeep_types::Symbol> {
None
}
fn return_type_of(&self, _: Query<'_>) -> Option<lanekeep_types::Type> {
None
}
fn is_assignable_to(&self, _: Query<'_>, _: &str, _: &str) -> Option<bool> {
None
}
fn complete(&self, _: Query<'_>) -> bool {
true
}
fn identity(&self) -> Vec<u8> {
Vec::new()
}
}
let walked = std::cell::Cell::new(false);
let files = || {
walked.set(true);
Vec::new()
};
assert_eq!(Silent.begin_run(&files, budget()), Ok(Vec::new()));
assert!(
!walked.get(),
"the default body must not ask for a file list it does not read"
);
}
#[test]
fn a_path_that_was_absent_is_parsed_once_it_becomes_text() {
let project = Project::new("miss-becomes-text", &[]);
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let path = FilePath::new("lib.d.ts");
assert!(
provider.declaration(&project.files(), &path).is_none(),
"nothing is there yet"
);
project.write("lib.d.ts", "export declare class Big {}\n");
let parsed = provider
.declaration(&project.files(), &path)
.expect("a path that has become text is read rather than held at the old answer");
assert!(lanekeep_types::declared_here(&parsed, "Big").is_some());
provider
.begin_run(&Vec::new, budget())
.expect("a run begins");
assert!(
provider.declaration(&project.files(), &path).is_some(),
"and a held declaration answers a second run, whether by surviving begin_run or by \
being re-read cold — either way, nothing here can stay missing forever"
);
}
#[test]
fn begin_run_clears_a_held_providers_completeness_memo() {
let project = Project::new("begin-run-clears-completeness", &[]);
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let subject = "import { rate } from './money';\nconst x = rate;\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
let question = |files: &FileAccess| {
provider.complete(Query {
file: &file,
tree: &tree,
source: subject,
node: tree.root_node(),
files,
})
};
assert!(
!question(&project.files()),
"the import resolves to nothing"
);
project.write("src/money.d.ts", "export declare const rate: number;\n");
assert!(
!question(&project.files()),
"the answer is memoized per file within a run"
);
provider
.begin_run(&Vec::new, budget())
.expect("a run begins");
assert!(
question(&project.files()),
"a run starts cold, so completeness is decided again against the filesystem now"
);
}
#[test]
fn the_builtin_provider_answers_a_primitive_annotation() {
let project = Project::new("builtin-primitive", &[]);
let files = project.files();
let source = "function credit(amount: number) { return amount; }\n";
let tree = parse(source);
let file = FilePath::new("src/a.ts");
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let node = last_of(&tree, "identifier");
let query = Query {
file: &file,
tree: &tree,
source,
node,
files: &files,
};
assert_eq!(
provider.type_of(query),
Some(lanekeep_types::Type::Primitive(
lanekeep_types::Primitive::Number
))
);
}
#[test]
fn a_grammar_that_does_not_speak_typescript_yields_no_provider() {
assert!(lanekeep_types::BuiltinProvider::probe(&lanekeep_lang_python::Python).is_none());
}
#[test]
fn the_builtin_providers_identity_carries_the_oracles() {
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let identity = provider.identity();
assert!(identity.starts_with(b"builtin:"), "{identity:?}");
assert!(identity.ends_with(&lanekeep_types::oracle_identity()));
assert_eq!(identity, provider.identity());
}
use lanekeep_types::resolve_specifier;
#[test]
fn a_relative_specifier_finds_a_sibling_source_file() {
let project = Project::new(
"relative-source",
&[
("src/a.ts", ""),
("src/money.ts", "export const rate = 1;\n"),
],
);
let files = project.files();
assert_eq!(
resolve_specifier(&files, &FilePath::new("src/a.ts"), "./money"),
Some(FilePath::new("src/money.ts"))
);
}
#[test]
fn a_relative_specifier_falls_back_to_a_declaration_file() {
let project = Project::new(
"relative-declaration",
&[
("src/a.ts", ""),
("src/money.d.ts", "export declare const rate: number;\n"),
],
);
let files = project.files();
assert_eq!(
resolve_specifier(&files, &FilePath::new("src/a.ts"), "./money"),
Some(FilePath::new("src/money.d.ts"))
);
}
#[test]
fn a_relative_specifier_falls_back_to_a_directory_index() {
let project = Project::new(
"relative-index",
&[
("src/a.ts", ""),
("src/money/index.ts", "export const rate = 1;\n"),
],
);
let files = project.files();
assert_eq!(
resolve_specifier(&files, &FilePath::new("src/a.ts"), "./money"),
Some(FilePath::new("src/money/index.ts"))
);
}
#[test]
fn a_source_file_beats_the_declaration_file_beside_it() {
let project = Project::new(
"relative-order",
&[
("src/a.ts", ""),
("src/money.ts", "export const rate = 1;\n"),
("src/money.d.ts", "export declare const rate: string;\n"),
],
);
let files = project.files();
assert_eq!(
resolve_specifier(&files, &FilePath::new("src/a.ts"), "./money"),
Some(FilePath::new("src/money.ts"))
);
}
#[test]
fn a_specifier_naming_the_emitted_javascript_resolves_to_its_source() {
let project = Project::new(
"relative-js-suffix",
&[
("src/a.ts", ""),
("src/money.ts", "export const rate = 1;\n"),
],
);
let files = project.files();
assert_eq!(
resolve_specifier(&files, &FilePath::new("src/a.ts"), "./money.js"),
Some(FilePath::new("src/money.ts"))
);
}
#[test]
fn a_specifier_naming_the_emitted_jsx_resolves_to_its_tsx_source() {
let project = Project::new(
"relative-jsx-suffix",
&[
("src/a.ts", ""),
(
"src/Button.tsx",
"export const who: string = 'b';\nexport const B = () => <b/>;\n",
),
],
);
let files = project.files();
assert_eq!(
resolve_specifier(&files, &FilePath::new("src/a.ts"), "./Button.jsx"),
Some(FilePath::new("src/Button.tsx"))
);
let provider = lanekeep_types::BuiltinProvider::probe_with(&TypeScript, Some(&Tsx))
.expect("TypeScript and tsx");
let subject = "import { who } from './Button.jsx';\nlet w = who;\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
let node = last_of(&tree, "identifier");
assert_eq!(
provider.type_of(Query {
file: &file,
tree: &tree,
source: subject,
node,
files: &files,
}),
Some(lanekeep_types::Type::Primitive(
lanekeep_types::Primitive::String
)),
"the `.jsx` spelling reaches the `.tsx` source and its own annotation"
);
}
#[test]
fn a_tsx_sibling_is_resolved_and_parsed_with_the_tsx_grammar() {
let project = Project::new(
"relative-tsx-resolves",
&[("src/Button.tsx", "export const B = () => <b/>;\n")],
);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe_with(&TypeScript, Some(&Tsx))
.expect("TypeScript and tsx");
let subject = "import { B } from './Button';\nlet b = B;\n";
let tree = parse(subject);
let file = FilePath::new("src/app.tsx");
assert!(
provider.complete(Query {
file: &file,
tree: &tree,
source: subject,
node: tree.root_node(),
files: &files,
}),
"the .tsx sibling resolves and its JSX parses without an ERROR"
);
}
#[test]
fn a_tsx_sibling_stays_unresolved_without_a_tsx_grammar() {
let project = Project::new(
"relative-tsx-refuses",
&[(
"src/Button.tsx",
"export const label: string = 'hi';\nexport const B = () => <b/>;\n",
)],
);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let subject = "import { label } from './Button';\nlet l = label;\n";
let tree = parse(subject);
let file = FilePath::new("src/app.tsx");
assert!(
!provider.complete(Query {
file: &file,
tree: &tree,
source: subject,
node: tree.root_node(),
files: &files,
}),
"no tsx grammar, no honest read of the sibling"
);
}
#[test]
fn an_uppercase_tsx_extension_is_parsed_with_the_tsx_grammar() {
let project = Project::new(
"relative-tsx-uppercase",
&[
(
"node_modules/widgets/package.json",
r#"{"exports": {"./Button": "./src/Button.TSX"}}"#,
),
(
"node_modules/widgets/src/Button.TSX",
"export const B = () => <b/>;\n",
),
],
);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe_with(&TypeScript, Some(&Tsx))
.expect("TypeScript and tsx");
let subject = "import 'widgets/Button';\n";
let tree = parse(subject);
let file = FilePath::new("src/app.tsx");
assert!(
provider.complete(Query {
file: &file,
tree: &tree,
source: subject,
node: tree.root_node(),
files: &files,
}),
"the uppercase extension resolves, and its JSX parses with the TSX grammar"
);
}
#[test]
fn a_type_answer_crosses_into_a_tsx_sibling() {
let project = Project::new(
"tsx-sibling-type",
&[(
"src/Button.tsx",
"export const who: string = 'b';\nexport const B = () => <b/>;\n",
)],
);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe_with(&TypeScript, Some(&Tsx))
.expect("TypeScript and tsx");
let subject = "import { who } from './Button';\nlet w = who;\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
let node = last_of(&tree, "identifier");
assert_eq!(
provider.type_of(Query {
file: &file,
tree: &tree,
source: subject,
node,
files: &files,
}),
Some(lanekeep_types::Type::Primitive(
lanekeep_types::Primitive::String
)),
"`who` is typed by its own annotation in the .tsx sibling"
);
}
#[test]
fn a_relative_specifier_that_escapes_the_root_resolves_to_nothing() {
let project = Project::new("relative-escape", &[("a.ts", "")]);
let files = project.files();
assert_eq!(
resolve_specifier(&files, &FilePath::new("a.ts"), "../../secrets"),
None
);
assert!(
files.dependencies().is_empty(),
"nothing outside the root is probed"
);
}
#[test]
fn every_relative_probe_is_recorded_in_a_fixed_order() {
let project = Project::new("relative-probes", &[("src/a.ts", "")]);
let files = project.files();
assert_eq!(
resolve_specifier(&files, &FilePath::new("src/a.ts"), "./money"),
None
);
let recorded: Vec<(String, bool)> = files
.dependencies()
.into_iter()
.map(|read| (read.path.as_str().to_owned(), read.hash().is_some()))
.collect();
assert_eq!(
recorded,
vec![
("src/money.cts".to_owned(), false),
("src/money.d.ts".to_owned(), false),
("src/money.mts".to_owned(), false),
("src/money.ts".to_owned(), false),
("src/money.tsx".to_owned(), false),
("src/money/index.d.ts".to_owned(), false),
("src/money/index.ts".to_owned(), false),
("src/money/index.tsx".to_owned(), false),
],
"dependencies come back in path order, and every miss is one"
);
}
fn package(manifest: &str) -> Vec<(&'static str, String)> {
vec![
("src/a.ts", String::new()),
("node_modules/money/package.json", manifest.to_owned()),
(
"node_modules/money/build/index.d.ts",
"export declare const rate: number;\n".to_owned(),
),
]
}
fn project_with(test: &str, files: &[(&'static str, String)]) -> Project {
let borrowed: Vec<(&str, &str)> = files
.iter()
.map(|(path, contents)| (*path, contents.as_str()))
.collect();
Project::new(test, &borrowed)
}
#[test]
fn a_bare_specifier_resolves_through_the_exports_types_condition() {
let project = project_with(
"bare-exports",
&package(r#"{"exports": {".": {"types": "./build/index.d.ts", "default": "./x.js"}}}"#),
);
let files = project.files();
assert_eq!(
resolve_specifier(&files, &FilePath::new("src/a.ts"), "money"),
Some(FilePath::new("node_modules/money/build/index.d.ts"))
);
}
#[test]
fn a_types_condition_holding_conditions_resolves_to_a_string_leaf() {
let project = project_with(
"bare-nested-types",
&package(
r#"{"exports": {".": {"types": {"import": "./build/index.d.ts", "require": "./nope.d.ts"}}}}"#,
),
);
let files = project.files();
assert_eq!(
resolve_specifier(&files, &FilePath::new("src/a.ts"), "money"),
Some(FilePath::new("node_modules/money/build/index.d.ts")),
"the conditions under `types` are read in the fixed key order the whole function uses"
);
}
#[test]
fn conditions_with_no_types_condition_still_resolve_to_nothing() {
let project = project_with(
"bare-no-types-condition",
&package(r#"{"exports": {".": {"import": "./build/index.d.ts", "default": "./x.js"}}}"#),
);
let files = project.files();
assert_eq!(
resolve_specifier(&files, &FilePath::new("src/a.ts"), "money"),
None
);
}
#[test]
fn a_bare_specifier_resolves_through_a_types_field() {
let project = project_with("bare-types", &package(r#"{"types": "./build/index.d.ts"}"#));
let files = project.files();
assert_eq!(
resolve_specifier(&files, &FilePath::new("src/a.ts"), "money"),
Some(FilePath::new("node_modules/money/build/index.d.ts"))
);
}
#[test]
fn a_bare_specifier_resolves_through_a_typings_field() {
let project = project_with(
"bare-typings",
&package(r#"{"typings": "./build/index.d.ts"}"#),
);
let files = project.files();
assert_eq!(
resolve_specifier(&files, &FilePath::new("src/a.ts"), "money"),
Some(FilePath::new("node_modules/money/build/index.d.ts"))
);
}
#[test]
fn a_package_with_no_manifest_entry_falls_back_to_its_index() {
let project = Project::new(
"bare-index",
&[
("src/a.ts", ""),
("node_modules/money/package.json", "{}"),
(
"node_modules/money/index.d.ts",
"export declare const rate: number;\n",
),
],
);
let files = project.files();
assert_eq!(
resolve_specifier(&files, &FilePath::new("src/a.ts"), "money"),
Some(FilePath::new("node_modules/money/index.d.ts"))
);
}
#[test]
fn a_subpath_export_resolves_to_its_own_target() {
let project = Project::new(
"bare-subpath",
&[
("src/a.ts", ""),
(
"node_modules/money/package.json",
r#"{"exports": {".": {"types": "./build/index.d.ts"},
"./decimal": {"types": "./build/decimal.d.ts"}}}"#,
),
(
"node_modules/money/build/index.d.ts",
"export declare const rate: number;\n",
),
(
"node_modules/money/build/decimal.d.ts",
"export declare class Decimal {}\n",
),
],
);
let files = project.files();
assert_eq!(
resolve_specifier(&files, &FilePath::new("src/a.ts"), "money/decimal"),
Some(FilePath::new("node_modules/money/build/decimal.d.ts"))
);
}
#[test]
fn a_star_pattern_export_substitutes_the_matched_run_and_the_longest_prefix_wins() {
let project = Project::new(
"bare-star",
&[
("src/a.ts", ""),
(
"node_modules/money/package.json",
r#"{"exports": {"./*": {"types": "./build/*.d.ts"},
"./deep/*": {"types": "./build/deep/*.d.ts"}}}"#,
),
(
"node_modules/money/build/decimal.d.ts",
"export declare class Decimal {}\n",
),
(
"node_modules/money/build/deep/nested.d.ts",
"export declare class Nested {}\n",
),
],
);
let files = project.files();
assert_eq!(
resolve_specifier(&files, &FilePath::new("src/a.ts"), "money/decimal"),
Some(FilePath::new("node_modules/money/build/decimal.d.ts"))
);
assert_eq!(
resolve_specifier(&files, &FilePath::new("src/a.ts"), "money/deep/nested"),
Some(FilePath::new("node_modules/money/build/deep/nested.d.ts"))
);
}
#[test]
fn a_package_with_no_types_of_its_own_falls_back_to_at_types() {
let project = Project::new(
"bare-at-types",
&[
("src/a.ts", ""),
(
"node_modules/money/package.json",
r#"{"main": "./index.js"}"#,
),
(
"node_modules/@types/money/index.d.ts",
"export declare const rate: number;\n",
),
(
"node_modules/@types/acme__money/index.d.ts",
"export declare const other: number;\n",
),
],
);
let files = project.files();
assert_eq!(
resolve_specifier(&files, &FilePath::new("src/a.ts"), "money"),
Some(FilePath::new("node_modules/@types/money/index.d.ts"))
);
assert_eq!(
resolve_specifier(&files, &FilePath::new("src/a.ts"), "@acme/money"),
Some(FilePath::new("node_modules/@types/acme__money/index.d.ts"))
);
}
#[test]
fn the_walk_climbs_from_the_importing_directory_to_the_root() {
let project = Project::new(
"bare-walk",
&[
("apps/web/src/deep/a.ts", ""),
(
"node_modules/money/package.json",
r#"{"types": "./index.d.ts"}"#,
),
(
"node_modules/money/index.d.ts",
"export declare const rate: number;\n",
),
],
);
let files = project.files();
assert_eq!(
resolve_specifier(&files, &FilePath::new("apps/web/src/deep/a.ts"), "money"),
Some(FilePath::new("node_modules/money/index.d.ts"))
);
}
#[test]
fn a_nearer_node_modules_shadows_one_further_up() {
let project = Project::new(
"bare-shadow",
&[
("apps/web/a.ts", ""),
(
"apps/web/node_modules/money/package.json",
r#"{"types": "./index.d.ts"}"#,
),
(
"apps/web/node_modules/money/index.d.ts",
"export declare const near: number;\n",
),
(
"node_modules/money/package.json",
r#"{"types": "./index.d.ts"}"#,
),
(
"node_modules/money/index.d.ts",
"export declare const far: number;\n",
),
],
);
let files = project.files();
assert_eq!(
resolve_specifier(&files, &FilePath::new("apps/web/a.ts"), "money"),
Some(FilePath::new("apps/web/node_modules/money/index.d.ts"))
);
}
#[test]
fn a_hoisted_node_modules_is_unresolvable_and_probes_nothing_above_the_root() {
let outer = std::env::temp_dir().join(format!(
"lanekeep-provider-hoisted-outer-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&outer);
std::fs::create_dir_all(outer.join("node_modules/money")).expect("creates the hoisted tree");
std::fs::write(
outer.join("node_modules/money/index.d.ts"),
"export declare const rate: number;\n",
)
.expect("writes the hoisted declaration");
std::fs::create_dir_all(outer.join("app/src")).expect("creates the inner root");
std::fs::write(outer.join("app/src/a.ts"), "").expect("writes the importer");
let files = FileAccess::new(&outer.join("app"));
assert_eq!(
resolve_specifier(&files, &FilePath::new("src/a.ts"), "money"),
None,
"nothing above the project root is readable, ever"
);
for read in files.dependencies() {
assert!(
!read.path.as_str().starts_with(".."),
"probed outside the root: {}",
read.path
);
assert_eq!(read.hash(), None, "every in-root candidate is absent");
}
let _ = std::fs::remove_dir_all(&outer);
}
#[test]
fn an_earlier_suffix_beats_a_later_one_when_both_exist() {
let project = Project::new(
"relative-order-suffix",
&[
("src/a.ts", ""),
("src/x.ts", "export const rate = 1;\n"),
("src/x.mts", "export const rate = 2;\n"),
],
);
let files = project.files();
assert_eq!(
resolve_specifier(&files, &FilePath::new("src/a.ts"), "./x"),
Some(FilePath::new("src/x.ts"))
);
}
#[test]
fn an_extension_probe_beats_the_index_fallback_when_both_exist() {
let project = Project::new(
"relative-order-index",
&[
("src/a.ts", ""),
("src/x.ts", "export const rate = 1;\n"),
("src/x/index.ts", "export const rate = 2;\n"),
],
);
let files = project.files();
assert_eq!(
resolve_specifier(&files, &FilePath::new("src/a.ts"), "./x"),
Some(FilePath::new("src/x.ts"))
);
}
use lanekeep_types::{Declaration, Exported};
fn declaration(test: &str, source: &str) -> (Project, std::sync::Arc<Declaration>) {
let project = Project::new(test, &[("d.d.ts", source)]);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let decl = provider
.declaration(&files, &FilePath::new("d.d.ts"))
.expect("the fixture parses");
(project, decl)
}
#[test]
fn every_declaration_shape_is_found_by_name() {
for (index, (source, name)) in [
("export declare function credit(): number;\n", "credit"),
("export declare const rate: number;\n", "rate"),
("export type Amount = number;\n", "Amount"),
("export interface Order { amount: number }\n", "Order"),
("export declare class Decimal {}\n", "Decimal"),
("export declare abstract class Base {}\n", "Base"),
("declare class Big {}\nexport { Big };\n", "Big"),
("declare class Big {}\nexport { Big as Money };\n", "Money"),
("export declare enum Currency { USD }\n", "Currency"),
("export declare namespace Ns {}\n", "Ns"),
("export declare module \"money\" {}\n", "money"),
("export function* gen() {}\n", "gen"),
]
.into_iter()
.enumerate()
{
let (_project, decl) = declaration(&format!("shape-{index}"), source);
assert!(
matches!(
lanekeep_types::find_export(&decl, name),
Some(Exported::Here(_))
),
"`{name}` not declared here in: {source}"
);
}
}
#[test]
fn a_named_re_export_points_at_another_module() {
let (_project, decl) = declaration(
"named-re-export",
"export { Decimal as Money } from './core';\n",
);
let Some(Exported::From { specifier, name }) = lanekeep_types::find_export(&decl, "Money")
else {
panic!("a named re-export is not a local declaration");
};
assert_eq!(specifier, "./core");
assert_eq!(
name, "Decimal",
"the walk follows the exported name, not the alias"
);
}
#[test]
fn a_star_export_answers_only_a_name_this_file_does_not_declare() {
let (_project, decl) = declaration(
"star-export",
"export * from './a';\nexport * from './b';\nexport declare const rate: number;\n",
);
assert!(matches!(
lanekeep_types::find_export(&decl, "rate"),
Some(Exported::Here(_))
));
let Some(Exported::Star(sources)) = lanekeep_types::find_export(&decl, "other") else {
panic!("a name nothing here declares falls to the star sources");
};
assert_eq!(
sources,
vec!["./a".to_owned(), "./b".to_owned()],
"source order"
);
}
#[test]
fn a_namespace_re_export_is_recognized_and_has_no_declaration() {
let (_project, decl) = declaration("namespace-re-export", "export * as core from './core';\n");
assert!(matches!(
lanekeep_types::find_export(&decl, "core"),
Some(Exported::Namespace { .. })
));
}
#[test]
fn a_default_export_is_found_under_the_name_default() {
for (index, (source, declared)) in [
("export default class Big {}\n", Some("Big")),
("declare class Big {}\nexport default Big;\n", Some("Big")),
("declare class Big {}\nexport = Big;\n", Some("Big")),
("export default 1;\n", None),
]
.into_iter()
.enumerate()
{
let (_project, decl) = declaration(&format!("default-export-{index}"), source);
let Some(Exported::Here(node)) = lanekeep_types::find_export(&decl, "default") else {
panic!("no default export in: {source}");
};
assert_eq!(
lanekeep_types::declared_name(&decl, node),
declared.map(str::to_owned),
"{source}"
);
}
}
#[test]
fn declared_name_unquotes_a_string_named_module() {
let (_project, decl) =
declaration("string-module-name", "export declare module \"money\" {}\n");
let Some(Exported::Here(node)) = lanekeep_types::find_export(&decl, "money") else {
panic!("a string-named module is found by its unquoted name");
};
assert_eq!(
lanekeep_types::declared_name(&decl, node),
Some("money".to_owned()),
"the declared name must not carry the quotes `declares()` already stripped"
);
}
#[test]
fn a_re_export_before_a_local_declaration_does_not_hide_it() {
let (_project, decl) = declaration(
"barrel-re-export",
"export { X } from './other';\ndeclare class Big {}\nexport { Big };\n",
);
let Some(Exported::Here(node)) = lanekeep_types::find_export(&decl, "Big") else {
panic!("a local export after a re-export must still be found");
};
assert_eq!(
lanekeep_types::declared_name(&decl, node),
Some("Big".to_owned())
);
}
#[test]
fn a_re_export_before_a_default_export_by_identifier_does_not_hide_it() {
let (_project, decl) = declaration(
"barrel-re-export-default",
"export { X } from './other';\ndeclare class Big {}\nexport default Big;\n",
);
let Some(Exported::Here(node)) = lanekeep_types::find_export(&decl, "default") else {
panic!("a default export by identifier after a re-export must still be found");
};
assert_eq!(
lanekeep_types::declared_name(&decl, node),
Some("Big".to_owned())
);
}
#[test]
fn a_declaration_file_is_parsed_once_per_run() {
let project = Project::new(
"declaration-cache",
&[("lib.d.ts", "export declare const rate: number;\n")],
);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let path = FilePath::new("lib.d.ts");
let first = provider.declaration(&files, &path).expect("parses");
let second = provider.declaration(&files, &path).expect("cached");
assert!(std::sync::Arc::ptr_eq(&first, &second), "parsed twice");
assert_eq!(files.dependencies().len(), 1, "read twice");
}
#[test]
fn a_missing_declaration_file_is_memoized_as_a_miss() {
let project = Project::new("declaration-miss", &[]);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let path = FilePath::new("lib.d.ts");
assert!(provider.declaration(&files, &path).is_none());
assert!(provider.declaration(&files, &path).is_none());
let reads = files.dependencies();
assert_eq!(reads.len(), 1);
assert_eq!(
reads[0].hash(),
None,
"an absence is a dependency with a null hash"
);
}
use lanekeep_types::ExportTarget;
#[test]
fn a_re_export_chain_ends_at_the_declaring_file_and_name() {
let project = Project::new(
"chain",
&[
("src/a.ts", ""),
(
"node_modules/money/package.json",
r#"{"types": "./index.d.ts"}"#,
),
(
"node_modules/money/index.d.ts",
"export { Decimal as Big } from './core';\n",
),
(
"node_modules/money/core.d.ts",
"export declare class Decimal {}\n",
),
],
);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let entry = resolve_specifier(&files, &FilePath::new("src/a.ts"), "money").expect("resolves");
assert_eq!(
provider.export_target(&files, &entry, "Big"),
Some(ExportTarget {
file: FilePath::new("node_modules/money/core.d.ts"),
name: "Decimal".to_owned(),
})
);
}
#[test]
fn a_star_re_export_is_followed_in_source_order() {
let project = Project::new(
"star-chain",
&[
("src/a.ts", ""),
(
"node_modules/money/package.json",
r#"{"types": "./index.d.ts"}"#,
),
(
"node_modules/money/index.d.ts",
"export * from './a';\nexport * from './b';\n",
),
(
"node_modules/money/a.d.ts",
"export declare class Other {}\n",
),
(
"node_modules/money/b.d.ts",
"export declare class Decimal {}\n",
),
],
);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let entry = resolve_specifier(&files, &FilePath::new("src/a.ts"), "money").expect("resolves");
assert_eq!(
provider.export_target(&files, &entry, "Decimal"),
Some(ExportTarget {
file: FilePath::new("node_modules/money/b.d.ts"),
name: "Decimal".to_owned(),
})
);
}
#[test]
fn a_collision_between_two_star_sources_is_won_by_the_first_in_source_order() {
let project = Project::new(
"star-collision",
&[
("src/a.ts", ""),
(
"node_modules/money/package.json",
r#"{"types": "./index.d.ts"}"#,
),
(
"node_modules/money/index.d.ts",
"export * from './a';\nexport * from './b';\n",
),
(
"node_modules/money/a.d.ts",
"export declare class Decimal {}\n",
),
(
"node_modules/money/b.d.ts",
"export declare class Decimal {}\n",
),
],
);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let entry = resolve_specifier(&files, &FilePath::new("src/a.ts"), "money").expect("resolves");
assert_eq!(
provider.export_target(&files, &entry, "Decimal"),
Some(ExportTarget {
file: FilePath::new("node_modules/money/a.d.ts"),
name: "Decimal".to_owned(),
}),
"both files declare `Decimal`; the first `export *` clause in source order wins"
);
}
#[test]
fn a_star_re_export_cycle_terminates() {
let project = Project::new(
"star-cycle",
&[
("src/a.ts", ""),
(
"node_modules/money/package.json",
r#"{"types": "./index.d.ts"}"#,
),
("node_modules/money/index.d.ts", "export * from './a';\n"),
("node_modules/money/a.d.ts", "export * from './index';\n"),
],
);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let entry = resolve_specifier(&files, &FilePath::new("src/a.ts"), "money").expect("resolves");
assert_eq!(provider.export_target(&files, &entry, "Missing"), None);
}
#[test]
fn a_self_importing_declaration_file_terminates() {
let project = Project::new(
"self-import",
&[(
"src/a.d.ts",
"import { A } from './a';\nexport type A = number;\n",
)],
);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let file = FilePath::new("src/a.d.ts");
assert_eq!(
provider.export_target(&files, &file, "A"),
Some(ExportTarget {
file: FilePath::new("src/a.d.ts"),
name: "A".to_owned(),
})
);
}
#[test]
fn a_mutual_alias_cycle_across_two_files_terminates() {
let project = Project::new(
"mutual-alias-cycle",
&[
(
"src/a.d.ts",
"import { B } from './b';\nexport type A = B;\n",
),
(
"src/b.d.ts",
"import { A } from './a';\nexport type B = A;\n",
),
],
);
let subject = "import { A } from './a';\nlet x: A;\n";
let started = std::time::Instant::now();
let result = ask(&project, subject, TypeProvider::type_of);
assert!(
started.elapsed() < std::time::Duration::from_secs(2),
"`MAX_DEPTH` must stop the walk well inside the budget, not merely before it hangs"
);
assert_eq!(
result, None,
"the bound cuts the chain, and a cut answer is unknown rather than a guess"
);
}
#[test]
fn an_alias_chain_past_the_depth_bound_answers_nothing() {
const HOPS: usize = 20;
let mut files: Vec<(String, String)> = (0..HOPS)
.map(|i| {
(
format!("src/a{i}.d.ts"),
format!("import {{ X }} from './a{}';\nexport type X = X;\n", i + 1),
)
})
.collect();
files.push((
format!("src/a{HOPS}.d.ts"),
"export type X = number;\n".to_owned(),
));
let owned: Vec<(&str, &str)> = files
.iter()
.map(|(path, contents)| (path.as_str(), contents.as_str()))
.collect();
let project = Project::new("alias-chain-too-deep", &owned);
let subject = "import { X } from './a0';\nlet x: X;\n";
let started = std::time::Instant::now();
let result = ask(&project, subject, TypeProvider::type_of);
assert!(
started.elapsed() < std::time::Duration::from_secs(2),
"`MAX_DEPTH` must stop the walk well inside the budget, not merely before it hangs"
);
assert_eq!(
result, None,
"twenty hops exceeds `MAX_DEPTH`, so the concrete `number` at the end is never reached"
);
}
#[test]
fn a_chain_longer_than_the_export_depth_bound_answers_nothing() {
for (test, declared_at, expected) in [
("chain-depth-inside", 15, true),
("chain-depth-outside", 16, false),
] {
let mut files: Vec<(String, String)> = (0..declared_at)
.map(|hop| {
(
format!("src/hop{hop}.d.ts"),
format!("export {{ Decimal }} from './hop{}';\n", hop + 1),
)
})
.collect();
files.push((
format!("src/hop{declared_at}.d.ts"),
"export declare class Decimal {}\n".to_owned(),
));
let borrowed: Vec<(&str, &str)> = files
.iter()
.map(|(p, c)| (p.as_str(), c.as_str()))
.collect();
let project = Project::new(test, &borrowed);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
assert_eq!(
provider
.export_target(&files, &FilePath::new("src/hop0.d.ts"), "Decimal")
.is_some(),
expected,
"a chain of {declared_at} distinct hops, with no cycle in it anywhere"
);
}
}
#[test]
fn a_re_export_of_a_name_nothing_declares_answers_nothing() {
let project = Project::new(
"chain-dead-end",
&[
("lib.d.ts", "export { Gone } from './core';\n"),
("core.d.ts", "export declare class Decimal {}\n"),
],
);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
assert_eq!(
provider.export_target(&files, &FilePath::new("lib.d.ts"), "Gone"),
None
);
}
fn ask<T>(
project: &Project,
subject: &str,
ask: impl FnOnce(&lanekeep_types::BuiltinProvider, Query<'_>) -> T,
) -> T {
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
let node = last_of(&tree, "identifier");
ask(
&provider,
Query {
file: &file,
tree: &tree,
source: subject,
node,
files: &files,
},
)
}
fn ask_expr<T>(
project: &Project,
subject: &str,
text: &str,
ask: impl FnOnce(&lanekeep_types::BuiltinProvider, Query<'_>) -> T,
) -> T {
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
let mut stack = vec![tree.root_node()];
let mut found = None;
while let Some(node) = stack.pop() {
if subject.get(node.byte_range()) == Some(text) {
found = Some(node);
break;
}
let mut cursor = node.walk();
let children: Vec<tree_sitter::Node<'_>> = node.children(&mut cursor).collect();
stack.extend(children);
}
let node = found.unwrap_or_else(|| panic!("no node with text `{text}`"));
ask(
&provider,
Query {
file: &file,
tree: &tree,
source: subject,
node,
files: &files,
},
)
}
#[test]
fn a_member_off_an_imported_interface_is_typed() {
let project = Project::new(
"imported-interface-member",
&[(
"src/money.d.ts",
"export interface Order { amount: number }\n",
)],
);
let subject = "import { Order } from './money';\nfunction f(o: Order) { return o.amount; }\n";
assert_eq!(
ask_expr(&project, subject, "o.amount", TypeProvider::type_of),
Some(lanekeep_types::Type::Primitive(
lanekeep_types::Primitive::Number
))
);
}
#[test]
fn a_member_off_an_imported_object_alias_is_typed() {
let project = Project::new(
"imported-object-alias-member",
&[(
"src/money.d.ts",
"export type Order = { amount: bigint };\n",
)],
);
let subject = "import { Order } from './money';\nfunction f(o: Order) { return o.amount; }\n";
assert_eq!(
ask_expr(&project, subject, "o.amount", TypeProvider::type_of),
Some(lanekeep_types::Type::Primitive(
lanekeep_types::Primitive::BigInt
))
);
}
#[test]
fn a_member_off_an_imported_class_is_typed() {
let project = Project::new(
"imported-class-member",
&[(
"src/money.d.ts",
"export declare class Order { amount: number }\n",
)],
);
let subject = "import { Order } from './money';\nfunction f(o: Order) { return o.amount; }\n";
assert_eq!(
ask_expr(&project, subject, "o.amount", TypeProvider::type_of),
Some(lanekeep_types::Type::Primitive(
lanekeep_types::Primitive::Number
))
);
}
#[test]
fn a_cross_file_optional_member_is_typed_or_undefined() {
let project = Project::new(
"imported-optional-member",
&[(
"src/money.d.ts",
"export interface Order { amount?: number }\n",
)],
);
let subject = "import { Order } from './money';\nfunction f(o: Order) { return o.amount; }\n";
assert_eq!(
ask_expr(&project, subject, "o.amount", TypeProvider::type_of),
lanekeep_types::Type::union(vec![
lanekeep_types::Type::Primitive(lanekeep_types::Primitive::Number),
lanekeep_types::Type::Primitive(lanekeep_types::Primitive::Undefined),
])
);
}
#[test]
fn a_two_hop_chain_reads_within_the_imported_file() {
let project = Project::new(
"chain-within-imported-file",
&[(
"src/tx.d.ts",
"export interface Transaction { transfer: AssetTransfer }\n\
export interface AssetTransfer { amount: bigint }\n",
)],
);
let subject = "import { Transaction } from './tx';\nfunction f(t: Transaction) { return t.transfer.amount; }\n";
assert_eq!(
ask_expr(
&project,
subject,
"t.transfer.amount",
TypeProvider::type_of
),
Some(lanekeep_types::Type::Primitive(
lanekeep_types::Primitive::BigInt
))
);
}
#[test]
fn a_chain_crosses_a_second_file_relative_to_the_declaring_one() {
let project = Project::new(
"chain-across-two-files",
&[
(
"src/tx.d.ts",
"import { AssetTransfer } from './asset';\n\
export interface Transaction { transfer: AssetTransfer }\n",
),
(
"src/asset.d.ts",
"export interface AssetTransfer { amount: bigint }\n",
),
],
);
let subject = "import { Transaction } from './tx';\nfunction f(t: Transaction) { return t.transfer.amount; }\n";
assert_eq!(
ask_expr(
&project,
subject,
"t.transfer.amount",
TypeProvider::type_of
),
Some(lanekeep_types::Type::Primitive(
lanekeep_types::Primitive::BigInt
))
);
}
#[test]
fn the_motivating_optional_chain_is_typed_or_undefined() {
let project = Project::new(
"motivating-optional-chain",
&[(
"src/tx.d.ts",
"export interface Transaction { assetTransferTransaction?: AssetTransfer }\n\
export interface AssetTransfer { amount: bigint }\n",
)],
);
let subject = "import { Transaction } from './tx';\n\
function f(t: Transaction) { return t.assetTransferTransaction?.amount; }\n";
assert_eq!(
ask_expr(
&project,
subject,
"t.assetTransferTransaction?.amount",
TypeProvider::type_of
),
lanekeep_types::Type::union(vec![
lanekeep_types::Type::Primitive(lanekeep_types::Primitive::BigInt),
lanekeep_types::Type::Primitive(lanekeep_types::Primitive::Undefined),
])
);
}
#[test]
fn a_member_off_a_nullable_imported_receiver_is_or_undefined() {
let project = Project::new(
"nullable-imported-receiver",
&[(
"src/money.d.ts",
"export interface Order { amount: number }\n",
)],
);
let subject =
"import { Order } from './money';\nfunction f(o: Order | undefined) { return o.amount; }\n";
assert_eq!(
ask_expr(&project, subject, "o.amount", TypeProvider::type_of),
lanekeep_types::Type::union(vec![
lanekeep_types::Type::Primitive(lanekeep_types::Primitive::Number),
lanekeep_types::Type::Primitive(lanekeep_types::Primitive::Undefined),
])
);
}
#[test]
fn a_member_off_a_shadowed_base_reads_the_shadow() {
let project = Project::new("shadowed-base", &[]);
let subject = "interface Box { value: number }\n\
function f() {\n\
\x20 type Box = { value: string };\n\
\x20 const b: Box = { value: 's' };\n\
\x20 return b.value;\n\
}\n";
assert_eq!(
ask_expr(&project, subject, "b.value", TypeProvider::type_of),
Some(lanekeep_types::Type::Primitive(
lanekeep_types::Primitive::String
))
);
}
#[test]
fn a_member_off_an_unresolved_import_answers_nothing() {
let project = Project::new("member-unresolved-import", &[]);
let subject = "import { Order } from './missing';\nfunction f(o: Order) { return o.amount; }\n";
assert_eq!(
ask_expr(&project, subject, "o.amount", TypeProvider::type_of),
None
);
}
#[test]
fn a_string_literal_subscript_crosses_files() {
let project = Project::new(
"subscript-cross-file",
&[(
"src/money.d.ts",
"export interface Order { amount: number }\n",
)],
);
let subject =
"import { Order } from './money';\nfunction f(o: Order) { return o[\"amount\"]; }\n";
assert_eq!(
ask_expr(&project, subject, "o[\"amount\"]", TypeProvider::type_of),
Some(lanekeep_types::Type::Primitive(
lanekeep_types::Primitive::Number
))
);
}
#[test]
fn an_imported_value_is_typed_through_its_declaration_file() {
let project = Project::new(
"imported-value",
&[("src/money.d.ts", "export declare const rate: number;\n")],
);
let subject = "import { rate } from './money';\nconst y = rate;\n";
assert_eq!(
ask(&project, subject, TypeProvider::type_of),
Some(lanekeep_types::Type::Primitive(
lanekeep_types::Primitive::Number
))
);
}
#[test]
fn an_imported_type_alias_resolves_to_what_it_aliases() {
let project = Project::new(
"imported-alias",
&[("src/money.d.ts", "export type Amount = number;\n")],
);
let subject = "import { Amount } from './money';\nlet x: Amount;\n";
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
let node = last_of(&tree, "type_annotation");
assert_eq!(
provider.type_of(Query {
file: &file,
tree: &tree,
source: subject,
node,
files: &files
}),
Some(lanekeep_types::Type::Primitive(
lanekeep_types::Primitive::Number
))
);
}
#[test]
fn an_imported_class_keeps_its_module_and_gains_its_exported_name() {
let project = Project::new(
"imported-class",
&[
(
"node_modules/money/package.json",
r#"{"types": "./index.d.ts"}"#,
),
(
"node_modules/money/index.d.ts",
"export { Decimal as Big } from './core';\n",
),
(
"node_modules/money/core.d.ts",
"export declare class Decimal {}\n",
),
],
);
let subject = "import { Big } from 'money';\nlet x: Big;\n";
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
let node = last_of(&tree, "type_annotation");
assert_eq!(
provider.type_of(Query {
file: &file,
tree: &tree,
source: subject,
node,
files: &files
}),
Some(lanekeep_types::Type::Nominal {
name: "Big".to_owned(),
symbol: Some(lanekeep_types::Symbol {
name: "Big".to_owned(),
module: Some("money".to_owned()),
exported: Some("Decimal".to_owned()),
}),
})
);
}
#[test]
fn symbol_of_reports_the_declared_name_and_the_specifier_as_written() {
let project = Project::new(
"symbol-chain",
&[
(
"node_modules/money/package.json",
r#"{"types": "./index.d.ts"}"#,
),
(
"node_modules/money/index.d.ts",
"export { Decimal as Big } from './core';\n",
),
(
"node_modules/money/core.d.ts",
"export declare class Decimal {}\n",
),
],
);
let subject = "import { Big } from 'money';\nconst y = Big;\n";
assert_eq!(
ask(&project, subject, TypeProvider::symbol_of),
Some(lanekeep_types::Symbol {
name: "Big".to_owned(),
module: Some("money".to_owned()),
exported: Some("Decimal".to_owned()),
})
);
}
#[test]
fn an_unresolvable_import_falls_back_to_what_the_import_statement_says() {
let project = Project::new("symbol-unresolvable", &[]);
let subject = "import { Decimal } from 'money';\nconst y = Decimal;\n";
assert_eq!(
ask(&project, subject, TypeProvider::symbol_of),
Some(lanekeep_types::Symbol {
name: "Decimal".to_owned(),
module: Some("money".to_owned()),
exported: Some("Decimal".to_owned()),
})
);
}
#[test]
fn a_namespace_import_has_no_exported_name() {
let project = Project::new(
"symbol-namespace",
&[
(
"node_modules/money/package.json",
r#"{"types": "./index.d.ts"}"#,
),
(
"node_modules/money/index.d.ts",
"export declare class Decimal {}\n",
),
],
);
let subject = "import * as money from 'money';\nconst y = money;\n";
let symbol = ask(&project, subject, TypeProvider::symbol_of).expect("a symbol");
assert_eq!(symbol.module.as_deref(), Some("money"));
assert_eq!(symbol.exported, None);
}
#[test]
fn a_re_export_chain_past_the_bound_answers_nothing() {
let mut files: Vec<(String, String)> = Vec::new();
for hop in 0..19 {
files.push((
format!("src/hop{hop}.d.ts"),
format!("export {{ Decimal }} from './hop{}';\n", hop + 1),
));
}
files.push((
"src/hop19.d.ts".to_owned(),
"export { Real as Decimal } from './hop20';\n".to_owned(),
));
files.push((
"src/hop20.d.ts".to_owned(),
"export declare class Real {}\n".to_owned(),
));
let borrowed: Vec<(&str, &str)> = files
.iter()
.map(|(p, c)| (p.as_str(), c.as_str()))
.collect();
let project = Project::new("chain-bound", &borrowed);
let subject = "import { Decimal } from './hop0';\nconst y = Decimal;\n";
let symbol = ask(&project, subject, TypeProvider::symbol_of).expect("a symbol");
assert_eq!(
symbol.exported.as_deref(),
Some("Decimal"),
"the bound stops the walk before the rename, and the fallback is what the import \
statement says"
);
}
#[test]
fn a_re_export_chain_within_the_bound_answers_the_renamed_declaration() {
let mut files: Vec<(String, String)> = Vec::new();
for hop in 0..3 {
files.push((
format!("src/hop{hop}.d.ts"),
format!("export {{ Decimal }} from './hop{}';\n", hop + 1),
));
}
files.push((
"src/hop3.d.ts".to_owned(),
"export { Real as Decimal } from './hop4';\n".to_owned(),
));
files.push((
"src/hop4.d.ts".to_owned(),
"export declare class Real {}\n".to_owned(),
));
let borrowed: Vec<(&str, &str)> = files
.iter()
.map(|(p, c)| (p.as_str(), c.as_str()))
.collect();
let project = Project::new("chain-within-bound", &borrowed);
let subject = "import { Decimal } from './hop0';\nconst y = Decimal;\n";
let symbol = ask(&project, subject, TypeProvider::symbol_of).expect("a symbol");
assert_eq!(
symbol.exported.as_deref(),
Some("Real"),
"within the bound the walk crosses the rename and answers the real declaration"
);
}
#[test]
fn a_destructured_export_falls_back_to_the_asked_name() {
let project = Project::new(
"symbol-destructured",
&[("src/pieces.d.ts", "export const { e } = { e: 2 };\n")],
);
let subject = "import { e } from './pieces';\nconst y = e;\n";
let symbol = ask(&project, subject, TypeProvider::symbol_of).expect("a symbol");
assert_eq!(
symbol.exported.as_deref(),
Some("e"),
"the pattern's text is not a name; the asked spelling is"
);
}
#[test]
fn return_type_of_reads_a_signature_or_infers_one() {
for (subject, expected) in [
(
"function rate(): number { return compute(); }\nrate();\n",
Some(lanekeep_types::Type::Primitive(
lanekeep_types::Primitive::Number,
)),
),
(
"function rate() { return 1; }\nrate();\n",
Some(lanekeep_types::Type::Primitive(
lanekeep_types::Primitive::Number,
)),
),
(
"function rate(f) { if (f) { return 1; } return 'a'; }\nrate(1);\n",
lanekeep_types::Type::union(vec![
lanekeep_types::Type::Primitive(lanekeep_types::Primitive::Number),
lanekeep_types::Type::Primitive(lanekeep_types::Primitive::String),
]),
),
(
"function rate(f) { if (f) { return 1; } return f ?? 2; }\nrate(1);\n",
None,
),
("function rate() { compute(); }\nrate();\n", None),
(
"const rate = () => 1;\nrate();\n",
Some(lanekeep_types::Type::Primitive(
lanekeep_types::Primitive::Number,
)),
),
] {
let project = Project::new("return-local", &[]);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
let node = last_of(&tree, "call_expression");
assert_eq!(
provider.return_type_of(Query {
file: &file,
tree: &tree,
source: subject,
node,
files: &files
}),
expected,
"{subject}"
);
}
}
#[test]
fn a_generic_calls_return_type_is_not_instantiated() {
let project = Project::new("generic-return", &[]);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let subject = "declare function useMemo<T>(factory: () => T, deps: unknown[]): T;\n\
const amount = useMemo(() => 0n, []);\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
assert_eq!(
provider.return_type_of(Query {
file: &file,
tree: &tree,
source: subject,
node: last_of(&tree, "call_expression"),
files: &files,
}),
None,
);
let amount = last_of(&tree, "variable_declarator")
.child_by_field_name("name")
.expect("the declarator names `amount`");
assert_eq!(
provider.type_of(Query {
file: &file,
tree: &tree,
source: subject,
node: amount,
files: &files,
}),
None,
);
}
#[test]
fn return_type_of_reads_a_method_definitions_own_declaration() {
let project = Project::new("return-method-definition", &[]);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let subject = "class C { m(): number { return 1; } }\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
let node = last_of(&tree, "method_definition");
assert_eq!(
provider.return_type_of(Query {
file: &file,
tree: &tree,
source: subject,
node,
files: &files
}),
Some(lanekeep_types::Type::Primitive(
lanekeep_types::Primitive::Number
))
);
}
#[test]
fn return_type_of_reads_a_method_signature_inside_an_interface() {
let project = Project::new("return-method-signature", &[]);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let subject = "interface I { m(): number; }\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
let node = last_of(&tree, "method_signature");
assert_eq!(
provider.return_type_of(Query {
file: &file,
tree: &tree,
source: subject,
node,
files: &files
}),
Some(lanekeep_types::Type::Primitive(
lanekeep_types::Primitive::Number
))
);
}
#[test]
fn return_type_of_reads_an_abstract_method_signature() {
let project = Project::new("return-abstract-method-signature", &[]);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let subject = "abstract class A { abstract m(): number; }\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
let node = last_of(&tree, "abstract_method_signature");
assert_eq!(
provider.return_type_of(Query {
file: &file,
tree: &tree,
source: subject,
node,
files: &files
}),
Some(lanekeep_types::Type::Primitive(
lanekeep_types::Primitive::Number
))
);
}
#[test]
fn return_type_of_prefers_an_arrow_functions_own_annotation() {
let project = Project::new("return-arrow-annotation", &[]);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let subject = "const rate = (): number => 'a';\nrate();\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
let node = last_of(&tree, "call_expression");
assert_eq!(
provider.return_type_of(Query {
file: &file,
tree: &tree,
source: subject,
node,
files: &files
}),
Some(lanekeep_types::Type::Primitive(
lanekeep_types::Primitive::Number
))
);
}
#[test]
fn return_type_of_a_call_to_a_non_function_value_answers_nothing() {
let project = Project::new("return-non-function", &[]);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let subject = "const x = 5;\nx();\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
let node = last_of(&tree, "call_expression");
assert_eq!(
provider.return_type_of(Query {
file: &file,
tree: &tree,
source: subject,
node,
files: &files
}),
None
);
}
#[test]
fn return_type_of_reads_a_generator_function_expression() {
let project = Project::new("return-generator-expression", &[]);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let subject = "const g = function*() { return 1; };\ng();\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
let node = last_of(&tree, "call_expression");
assert_eq!(
provider.return_type_of(Query {
file: &file,
tree: &tree,
source: subject,
node,
files: &files
}),
None,
"calling a generator yields a generator object, not what its body returns"
);
}
#[test]
fn return_type_of_reads_an_annotated_generator_function_expression() {
let project = Project::new("return-generator-annotated", &[]);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let subject = "const g = function*(): number { return 1; };\ng();\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
let node = last_of(&tree, "call_expression");
assert_eq!(
provider.return_type_of(Query {
file: &file,
tree: &tree,
source: subject,
node,
files: &files
}),
Some(lanekeep_types::Type::Primitive(
lanekeep_types::Primitive::Number
)),
"the annotation is what the program means, generator or not"
);
}
#[test]
fn return_type_of_an_unannotated_async_function_answers_nothing() {
for subject in [
"async function rate() { return 1; }\nrate();\n",
"const rate = async () => 1;\nrate();\n",
"const rate = async function () { return 1; };\nrate();\n",
"async function* rate() { return 1; }\nrate();\n",
] {
let project = Project::new("return-async", &[]);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
let node = last_of(&tree, "call_expression");
assert_eq!(
provider.return_type_of(Query {
file: &file,
tree: &tree,
source: subject,
node,
files: &files
}),
None,
"{subject}"
);
}
}
#[test]
fn return_type_of_an_annotated_async_function_answers_the_annotation() {
let project = Project::new("return-async-annotated", &[]);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let subject = "async function rate(): number { return 1; }\nrate();\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
let node = last_of(&tree, "call_expression");
assert_eq!(
provider.return_type_of(Query {
file: &file,
tree: &tree,
source: subject,
node,
files: &files
}),
Some(lanekeep_types::Type::Primitive(
lanekeep_types::Primitive::Number
))
);
}
#[test]
fn return_type_of_resolves_an_imported_signature() {
let project = Project::new(
"return-imported",
&[
(
"node_modules/@tanstack/react-query/package.json",
r#"{"exports": {".": {"types": "./build/index.d.ts"}}}"#,
),
(
"node_modules/@tanstack/react-query/build/index.d.ts",
"export declare class UseQueryResult {}\n\
export declare function useQuery(o: unknown): UseQueryResult;\n",
),
],
);
let subject = "import { useQuery } from '@tanstack/react-query';\nuseQuery({});\n";
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
let node = last_of(&tree, "call_expression");
let Some(lanekeep_types::Type::Nominal { name, symbol }) = provider.return_type_of(Query {
file: &file,
tree: &tree,
source: subject,
node,
files: &files,
}) else {
panic!("the library's signature names a class");
};
assert_eq!(name, "UseQueryResult");
assert_eq!(symbol.and_then(|s| s.module), None);
}
const HERITAGE_PACKAGE: &[(&str, &str)] = &[
(
"node_modules/money/package.json",
r#"{"types": "./index.d.ts"}"#,
),
(
"node_modules/money/index.d.ts",
"export declare class Decimal {}\n\
export declare interface Amountish { amount: number }\n\
export type Money = Decimal;\n\
export type Amount = number;\n",
),
];
fn assignable(test: &str, extra: &[(&str, &str)], subject: &str) -> Option<bool> {
assignable_target(test, extra, subject, "money", "Decimal")
}
fn assignable_target(
test: &str,
extra: &[(&str, &str)],
subject: &str,
module: &str,
name: &str,
) -> Option<bool> {
let mut files: Vec<(&str, &str)> = HERITAGE_PACKAGE.to_vec();
files.extend_from_slice(extra);
let project = Project::new(test, &files);
let access = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
let node = last_of(&tree, "type_annotation");
provider.is_assignable_to(
Query {
file: &file,
tree: &tree,
source: subject,
node,
files: &access,
},
module,
name,
)
}
#[test]
fn a_reconverging_heritage_graph_is_walked_once_per_declaration() {
const WIDTH: usize = 4;
const DEPTH: usize = 12;
let mut source = String::from("import { Decimal } from 'money';\ninterface Base {}\n");
for level in 0..DEPTH {
for node in 0..WIDTH {
let parents: Vec<String> = if level + 1 == DEPTH {
vec!["Base".to_owned()]
} else {
(0..WIDTH).map(|p| format!("N{}_{p}", level + 1)).collect()
};
let _ = writeln!(
source,
"interface N{level}_{node} extends {} {{}}",
parents.join(", ")
);
}
}
source.push_str("let x: N0_0;\n");
let started = std::time::Instant::now();
let answer = assignable("assignable-reconverging", &[], &source);
let elapsed = started.elapsed();
assert_eq!(
answer,
Some(false),
"the graph is fully readable and reaches `Base` rather than the named type"
);
assert!(
elapsed < std::time::Duration::from_secs(1),
"the walk must be per declaration rather than per path: took {elapsed:?}"
);
}
#[test]
fn a_reconverging_heritage_graph_that_reaches_the_target_is_assignable() {
const WIDTH: usize = 4;
const DEPTH: usize = 6;
let mut source = String::from("import { Decimal } from 'money';\n");
for level in 0..DEPTH {
for node in 0..WIDTH {
let parents: Vec<String> = if level + 1 == DEPTH {
vec!["Decimal".to_owned()]
} else {
(0..WIDTH).map(|p| format!("N{}_{p}", level + 1)).collect()
};
let _ = writeln!(
source,
"interface N{level}_{node} extends {} {{}}",
parents.join(", ")
);
}
}
source.push_str("let x: N0_0;\n");
assert_eq!(
assignable("assignable-reconverging-true", &[], &source),
Some(true)
);
}
#[test]
fn a_type_imported_from_the_named_module_is_assignable() {
assert_eq!(
assignable(
"assignable-direct",
&[],
"import { Decimal } from 'money';\nlet x: Decimal;\n"
),
Some(true)
);
}
#[test]
fn a_subclass_of_the_named_type_is_assignable() {
assert_eq!(
assignable(
"assignable-subclass",
&[],
"import { Decimal } from 'money';\nclass Big extends Decimal {}\nlet x: Big;\n"
),
Some(true)
);
}
#[test]
fn an_interface_extending_several_parents_reaches_the_named_type() {
assert_eq!(
assignable(
"assignable-interface",
&[],
"import { Decimal, Amountish } from 'money';\n\
interface Both extends Amountish, Decimal {}\n\
let x: Both;\n"
),
Some(true)
);
}
#[test]
fn an_alias_of_the_named_type_is_assignable() {
assert_eq!(
assignable(
"assignable-alias",
&[],
"import { Money } from 'money';\nlet x: Money;\n"
),
Some(true)
);
}
#[test]
fn a_union_is_assignable_only_when_every_member_is() {
assert_eq!(
assignable(
"assignable-union-yes",
&[],
"import { Decimal, Money } from 'money';\nlet x: Decimal | Money;\n"
),
Some(true)
);
assert_eq!(
assignable(
"assignable-union-no",
&[],
"import { Decimal } from 'money';\nlet x: Decimal | number;\n"
),
Some(false)
);
}
#[test]
fn a_primitive_is_not_assignable_to_a_nominal_type() {
assert_eq!(
assignable("assignable-primitive", &[], "let x: number;\n"),
Some(false)
);
}
#[test]
fn a_type_the_oracle_could_not_read_answers_nothing() {
assert_eq!(
assignable("assignable-ambient", &[], "let x: Date;\n"),
None
);
assert_eq!(
assignable(
"assignable-unreadable",
&[],
"import { Gone } from 'not-installed';\nlet x: Gone;\n"
),
None
);
}
#[test]
fn a_heritage_cycle_terminates() {
assert_eq!(
assignable(
"assignable-cycle",
&[],
"interface A extends B {}\ninterface B extends A {}\nlet x: A;\n"
),
Some(false)
);
}
#[test]
fn a_union_whose_members_share_an_assignable_ancestor_is_assignable() {
assert_eq!(
assignable(
"assignable-union-shared-class",
&[],
"import { Decimal } from 'money';\n\
class Base extends Decimal {}\n\
class Cash extends Base {}\n\
class Coin extends Base {}\n\
let x: Cash | Coin;\n"
),
Some(true)
);
}
#[test]
fn a_union_of_interfaces_sharing_an_assignable_parent_is_assignable() {
assert_eq!(
assignable(
"assignable-union-shared-interface",
&[],
"import { Decimal } from 'money';\n\
interface Sh extends Decimal {}\n\
interface L extends Sh {}\n\
interface R2 extends Sh {}\n\
let x: L | R2;\n"
),
Some(true)
);
}
#[test]
fn a_local_class_with_the_targets_name_is_not_the_target() {
assert_eq!(
assignable(
"assignable-shadow-name",
&[],
"class Decimal {}\nlet x: Decimal;\n"
),
Some(false)
);
}
#[test]
fn the_targets_name_from_a_different_module_is_not_the_target() {
assert_eq!(
assignable(
"assignable-other-module",
&[
(
"node_modules/other/package.json",
r#"{"types": "./index.d.ts"}"#,
),
(
"node_modules/other/index.d.ts",
"export declare class Decimal {}\n",
),
],
"import { Decimal } from 'other';\nlet x: Decimal;\n"
),
Some(false)
);
}
#[test]
fn an_imported_alias_of_a_primitive_is_not_assignable() {
assert_eq!(
assignable(
"assignable-alias-primitive",
&[],
"import { Amount } from 'money';\nlet x: Amount;\n"
),
Some(false)
);
}
#[test]
fn a_class_implementing_the_named_interface_is_assignable() {
assert_eq!(
assignable_target(
"assignable-implements",
&[],
"import { Amountish } from 'money';\nclass C implements Amountish {}\nlet x: C;\n",
"money",
"Amountish"
),
Some(true)
);
}
#[test]
fn a_function_local_shadow_is_a_documented_limitation() {
assert_eq!(
assignable(
"assignable-local-shadow",
&[],
"import { Decimal } from 'money';\n\
interface Wrapper extends Decimal {}\n\
function f() {\n\
interface Wrapper { amount: number }\n\
let x: Wrapper;\n\
}\n"
),
Some(true)
);
}
#[test]
fn a_comment_inside_an_implements_clause_is_skipped() {
let fixtures = [
(
"node_modules/ab/package.json",
r#"{"types": "./index.d.ts"}"#,
),
(
"node_modules/ab/index.d.ts",
"export declare interface A { a: number }\n\
export declare interface B { b: number }\n\
export declare interface D { d: number }\n",
),
];
let subject = "import { A, B } from 'ab';\nclass C implements A, /* x */ B {}\nlet x: C;\n";
assert_eq!(
assignable_target(
"assignable-implements-comment",
&fixtures,
subject,
"ab",
"B"
),
Some(true)
);
assert_eq!(
assignable_target(
"assignable-implements-comment-unrelated",
&fixtures,
subject,
"ab",
"D"
),
Some(false),
"C implements neither A-via-comment nor D, and a comment leaking into the walk as a \
member would degrade this honest `false` to `None`"
);
}
#[test]
fn completeness_is_decided_by_whether_every_import_resolved() {
for (test, fixtures, subject, expected) in [
("complete-none", vec![], "const x = 1;\n", true),
(
"complete-all",
vec![("src/money.d.ts", "export declare const rate: number;\n")],
"import { rate } from './money';\nconst x = rate;\n",
true,
),
(
"complete-missing",
vec![],
"import { rate } from './dist/money';\nconst x = rate;\n",
false,
),
(
"complete-default",
vec![(
"src/money.d.ts",
"declare const rate: number;\nexport default rate;\n",
)],
"import rate from './money';\nconst x = rate;\n",
true,
),
(
"complete-default-missing",
vec![],
"import rate from './dist/money';\nconst x = rate;\n",
false,
),
(
"complete-namespace",
vec![("src/money.d.ts", "export declare const rate: number;\n")],
"import * as money from './money';\nconst x = money.rate;\n",
true,
),
(
"complete-namespace-missing",
vec![],
"import * as money from './dist/money';\nconst x = money.rate;\n",
false,
),
(
"complete-type",
vec![("src/money.d.ts", "export type Amount = number;\n")],
"import type { Amount } from './money';\nlet x: Amount;\n",
true,
),
(
"complete-type-missing",
vec![],
"import type { Amount } from './dist/money';\nlet x: Amount;\n",
false,
),
(
"complete-side-effect",
vec![("src/setup.d.ts", "export {};\n")],
"import './setup';\n",
true,
),
(
"complete-side-effect-missing",
vec![],
"import './dist/setup';\n",
false,
),
] {
let project = Project::new(test, &fixtures);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
assert_eq!(
provider.complete(Query {
file: &file,
tree: &tree,
source: subject,
node: tree.root_node(),
files: &files,
}),
expected,
"{subject}"
);
}
}
#[test]
fn completeness_with_two_imports_only_one_missing_records_both_probes() {
let project = Project::new(
"complete-two-imports-one-missing",
&[("src/money.d.ts", "export declare const rate: number;\n")],
);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let subject = "import { rate } from './money';\n\
import { gone } from './dist/absent';\n\
const x = rate;\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
assert!(!provider.complete(Query {
file: &file,
tree: &tree,
source: subject,
node: tree.root_node(),
files: &files,
}));
let reads = files.dependencies();
assert!(
reads
.iter()
.any(|read| read.path.as_str() == "src/money.ts"
|| read.path.as_str() == "src/money.d.ts"),
"the resolving import is still probed and recorded: {reads:?}"
);
assert!(
reads
.iter()
.any(|read| read.path.as_str() == "src/dist/absent.d.ts"),
"the missing import is recorded too, with a null hash: {reads:?}"
);
}
#[test]
fn completeness_counts_an_import_equals_require_clause() {
let project = Project::new("complete-import-require", &[]);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let subject = "import money = require('./dist/money');\nconst x = money;\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
assert!(
!provider.complete(Query {
file: &file,
tree: &tree,
source: subject,
node: tree.root_node(),
files: &files,
}),
"the only import is this one, and it resolves to nothing"
);
}
#[test]
fn completeness_counts_a_resolving_import_equals_require_clause() {
let project = Project::new(
"complete-import-require-resolving",
&[("src/money.d.ts", "export declare const rate: number;\n")],
);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let subject = "import money = require('./money');\nconst x = money;\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
assert!(
provider.complete(Query {
file: &file,
tree: &tree,
source: subject,
node: tree.root_node(),
files: &files,
}),
"the only import is this one, and it resolves to a declaration file"
);
assert!(
files
.dependencies()
.iter()
.any(|read| read.path.as_str() == "src/money.d.ts"),
"and it was really probed: {:?}",
files.dependencies()
);
}
#[test]
fn deciding_completeness_records_every_probe_including_the_misses() {
let project = Project::new("complete-records", &[]);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let subject = "import { rate } from './dist/money';\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
assert!(!provider.complete(Query {
file: &file,
tree: &tree,
source: subject,
node: tree.root_node(),
files: &files,
}));
let reads = files.dependencies();
assert!(!reads.is_empty(), "an absent import records nothing");
assert!(
reads.iter().all(|read| read.hash().is_none()),
"every candidate was absent, so every one is a null-hash dependency: {reads:?}"
);
assert!(
reads
.iter()
.any(|read| read.path.as_str() == "src/dist/money.d.ts"),
"the declaration spelling has to be among the probes: {reads:?}"
);
}
#[test]
fn a_declaration_rewritten_between_two_accesses_is_reparsed() {
let project = Project::new(
"declaration-rewritten",
&[("lib.d.ts", "export declare class Before {}\n")],
);
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let path = FilePath::new("lib.d.ts");
let first = project.files();
let before = provider.declaration(&first, &path).expect("reads");
assert!(
lanekeep_types::declared_here(&before, "Before").is_some(),
"the first access sees the first version"
);
project.write("lib.d.ts", "export declare class After {}\n");
let second = project.files();
let after = provider.declaration(&second, &path).expect("reads");
assert!(
lanekeep_types::declared_here(&after, "After").is_some(),
"the second access must see what its own read hashed"
);
assert_ne!(before.hash, after.hash);
}
#[test]
fn completeness_skips_a_specifier_that_is_not_code() {
for (test, subject) in [
("complete-css", "import './app.css';\n"),
("complete-json", "import data from './x.json';\n"),
("complete-svg", "import logo from './logo.svg';\n"),
("complete-css-package", "import 'bootstrap/dist/x.css';\n"),
] {
let project = Project::new(test, &[]);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
assert!(
provider.complete(Query {
file: &file,
tree: &tree,
source: subject,
node: tree.root_node(),
files: &files,
}),
"{subject}"
);
assert!(
files.dependencies().is_empty(),
"nothing was probed for it: {:?}",
files.dependencies()
);
}
}
#[test]
fn completeness_still_counts_a_package_whose_name_has_a_dot() {
let project = Project::new("complete-dotted-package", &[]);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let subject = "import { debounce } from 'lodash.debounce';\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
assert!(
!provider.complete(Query {
file: &file,
tree: &tree,
source: subject,
node: tree.root_node(),
files: &files,
}),
"the package is not installed, so the file is incomplete"
);
}
#[test]
fn completeness_is_false_only_when_an_error_overlaps_the_reached_declaration() {
for (test, declaration, expected) in [
(
"complete-covered-declaration",
"export declare class Big { m(: number }\n",
false,
),
(
"complete-unrelated-error",
"export declare class Big {}\ngarbage )(\n",
true,
),
(
"complete-sound-declaration",
"export declare class Big {}\n",
true,
),
] {
let project = Project::new(test, &[("src/big.d.ts", declaration)]);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let subject = "import { Big } from './big';\nlet x: Big;\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
assert_eq!(
provider.complete(Query {
file: &file,
tree: &tree,
source: subject,
node: tree.root_node(),
files: &files,
}),
expected,
"{declaration}"
);
}
}
#[test]
fn a_nameless_import_keeps_the_whole_file_verdict() {
let project = Project::new(
"complete-side-effect-error",
&[("src/big.d.ts", "export declare class Big {}\ngarbage )(\n")],
);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let subject = "import './big';\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
assert!(
!provider.complete(Query {
file: &file,
tree: &tree,
source: subject,
node: tree.root_node(),
files: &files,
}),
"a side-effect import asserts the module's shape; any ERROR counts"
);
}
#[test]
fn a_mixed_clause_keeps_the_whole_file_verdict() {
let project = Project::new(
"complete-mixed-clause-error",
&[(
"src/big.d.ts",
"export declare const ok: number;\nexport default ok;\ngarbage )(\n",
)],
);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
assert!(complete_of(
&project,
"import ok from './big';\nconst y = ok;\n"
));
let subject = "import ok, * as ns from './big';\nconst y = ok;\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
assert!(
!provider.complete(Query {
file: &file,
tree: &tree,
source: subject,
node: tree.root_node(),
files: &files,
}),
"the namespace binding reaches every member; any ERROR counts"
);
}
#[test]
fn an_error_covered_heritage_parent_is_unreadable_not_negative() {
let project = Project::new(
"heritage-error-covered",
&[(
"src/big.d.ts",
"export interface Root { r(): void }\nexport interface Mid extends Damaged { m(): void }\nexport interface Damaged { d: ;;; }\n",
)],
);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let subject = "import { Mid } from './big';\nclass X implements Mid {}\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
let node = last_of(&tree, "type_identifier");
assert_eq!(
provider.is_assignable_to(
Query {
file: &file,
tree: &tree,
source: subject,
node,
files: &files,
},
"./big",
"Root",
),
None,
"a damaged link in the chain is unreadable, never a negative"
);
}
#[test]
fn completeness_is_decided_once_per_file() {
let project = Project::new("complete-once", &[]);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let subject = "import { rate } from './dist/money';\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
let query = Query {
file: &file,
tree: &tree,
source: subject,
node: tree.root_node(),
files: &files,
};
assert!(!provider.complete(query));
let after_first = files.dependencies().len();
assert!(!provider.complete(query));
assert_eq!(
files.dependencies().len(),
after_first,
"the pass ran twice"
);
}
#[test]
fn the_builtin_providers_begin_run_does_not_walk_the_corpus() {
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let walked = std::cell::Cell::new(false);
let files = || {
walked.set(true);
Vec::new()
};
assert_eq!(provider.begin_run(&files, budget()), Ok(Vec::new()));
assert!(
!walked.get(),
"this provider's dependencies are the tracked reads on each entry, so there is \
nothing to build up front"
);
}
#[test]
fn completeness_probes_a_dotted_module_specifier() {
let project = Project::new(
"complete-dotted-module",
&[("src/user.service.ts", "export const user = 1;\n")],
);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let subject = "import { user } from './user.service';\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
assert!(
provider.complete(Query {
file: &file,
tree: &tree,
source: subject,
node: tree.root_node(),
files: &files,
}),
"the module is there, so the file is complete"
);
assert!(
files
.dependencies()
.iter()
.any(|read| read.path.as_str() == "src/user.service.ts"),
"and it was really probed rather than skipped: {:?}",
files.dependencies()
);
}
#[test]
fn completeness_is_false_when_a_dotted_module_is_missing() {
let project = Project::new("complete-dotted-module-missing", &[]);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let subject = "import { user } from './user.service';\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
assert!(
!provider.complete(Query {
file: &file,
tree: &tree,
source: subject,
node: tree.root_node(),
files: &files,
}),
"nothing answers the specifier, so the file is incomplete"
);
}
#[test]
fn a_subtree_cut_by_the_depth_bound_is_not_memoized() {
let mut source =
String::from("import { Decimal } from 'money';\ninterface D extends Decimal {}\n");
for level in (1..=14).rev() {
let parent = if level == 14 {
"D".to_owned()
} else {
format!("C{}", level + 1)
};
let _ = writeln!(source, "interface C{level} extends {parent} {{}}");
}
source.push_str("interface Root extends C1, D {}\nlet x: Root;\n");
assert_eq!(
assignable("assignable-depth-memo", &[], &source),
Some(true),
"`D` extends the named type, and Root extends `D` directly"
);
}
#[test]
fn a_subtree_the_oracle_truncated_is_not_memoized() {
let mut declarations =
String::from("export declare class Decimal {}\nexport type A4 = Decimal;\n");
for level in (0..4).rev() {
let _ = writeln!(declarations, "export type A{level} = A{};", level + 1);
}
let mut source = String::from("import { A0 } from 'money';\n");
for level in (1..=12).rev() {
let parent = if level == 12 {
"A0".to_owned()
} else {
format!("C{}", level + 1)
};
let _ = writeln!(source, "interface C{level} extends {parent} {{}}");
}
source.push_str("interface Root extends C1, A0 {}\nlet x: Root;\n");
let project = Project::new(
"assignable-oracle-depth-memo",
&[
(
"node_modules/money/package.json",
r#"{"types": "./index.d.ts"}"#,
),
("node_modules/money/index.d.ts", &declarations),
],
);
let access = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let tree = parse(&source);
let file = FilePath::new("src/a.ts");
let node = last_of(&tree, "type_annotation");
assert_eq!(
provider.is_assignable_to(
Query {
file: &file,
tree: &tree,
source: &source,
node,
files: &access,
},
"money",
"Decimal",
),
Some(true),
"`A0` aliases the named type, and Root extends `A0` directly"
);
}
#[test]
fn a_cycle_at_the_bottom_does_not_disable_the_memo_above_it() {
const WIDTH: usize = 4;
const DEPTH: usize = 12;
let mut source = String::from(
"import { Decimal } from 'money';\n\
interface L extends L2 {}\n\
interface L2 extends L {}\n",
);
for level in 0..DEPTH {
for node in 0..WIDTH {
let parents: Vec<String> = if level + 1 == DEPTH {
vec!["L".to_owned()]
} else {
(0..WIDTH).map(|p| format!("N{}_{p}", level + 1)).collect()
};
let _ = writeln!(
source,
"interface N{level}_{node} extends {} {{}}",
parents.join(", ")
);
}
}
source.push_str("let x: N0_0;\n");
let started = std::time::Instant::now();
let answer = assignable("assignable-cycle-below", &[], &source);
let elapsed = started.elapsed();
assert_eq!(
answer,
Some(false),
"the graph is fully readable and bottoms out in a cycle rather than the named type"
);
assert!(
elapsed < std::time::Duration::from_secs(1),
"one cycle must not cost the memo for everything above it: took {elapsed:?}"
);
}
fn complete_of(project: &Project, subject: &str) -> bool {
ask(project, subject, TypeProvider::complete)
}
#[test]
fn a_missing_token_in_the_reached_declaration_is_unread() {
let project = Project::new(
"complete-missing-token",
&[("src/big.d.ts", "export declare class Big { m(): void\n")],
);
assert!(
!complete_of(&project, "import { Big } from './big';\nconst y = Big;\n"),
"an unclosed class body is a declaration the parser did not finish reading"
);
assert!(
!complete_of(&project, "import './big';\nconst y = 1;\n"),
"and the nameless arm agrees with the named one about the same file"
);
}
#[test]
fn a_name_the_walk_cannot_model_leaves_a_clean_module_complete() {
let project = Project::new(
"complete-export-assignment",
&[(
"node_modules/@types/react/index.d.ts",
"export = React;\nexport as namespace React;\ndeclare namespace React {\n function useState(): void;\n}\n",
)],
);
assert!(complete_of(
&project,
"import { useState } from 'react';\nuseState();\n"
));
assert!(complete_of(
&project,
"import React from 'react';\nReact;\n"
));
assert!(complete_of(
&project,
"import * as React from 'react';\nReact;\n"
));
}
#[test]
fn a_two_statement_barrel_is_walked_through_its_import() {
let project = Project::new(
"barrel-two-statements",
&[
("src/index.ts", "import { A } from './a';\nexport { A };\n"),
("src/a.ts", "export const A: number = 1;\n"),
],
);
let subject = "import { A } from './index';\nconst y = A;\n";
assert!(complete_of(&project, subject));
assert_eq!(
ask(&project, subject, TypeProvider::type_of),
Some(lanekeep_types::Type::Primitive(
lanekeep_types::Primitive::Number
)),
"the chain continues into `./a`, so the value types from its declaration"
);
}
#[test]
fn a_two_statement_barrel_follows_the_import_alias() {
let project = Project::new(
"barrel-two-statements-alias",
&[
(
"src/index.ts",
"import { A as B } from './a';\nexport { B as C };\n",
),
("src/a.ts", "export const A: number = 1;\n"),
],
);
assert_eq!(
ask(
&project,
"import { C } from './index';\nconst y = C;\n",
TypeProvider::type_of
),
Some(lanekeep_types::Type::Primitive(
lanekeep_types::Primitive::Number
))
);
}
#[test]
fn a_namespace_re_export_leaves_the_importer_complete() {
let project = Project::new(
"barrel-namespace-reexport",
&[
("src/barrel.ts", "export * as utils from './utils';\n"),
("src/utils.ts", "export const u: number = 1;\n"),
],
);
assert!(complete_of(
&project,
"import { utils } from './barrel';\nconst y = utils;\n"
));
}
#[test]
fn a_dotted_namespace_declares_its_first_segment() {
let project = Project::new(
"dotted-namespace",
&[
(
"src/legacy.d.ts",
"export declare namespace A.B { const q: number }\n",
),
("src/index.ts", "export { A as Ns } from './legacy';\n"),
],
);
let subject = "import { Ns } from './index';\nconst y = Ns;\n";
assert!(
complete_of(&project, subject),
"the walk ends at the namespace `A` declares"
);
let symbol = ask(&project, subject, TypeProvider::symbol_of).expect("a symbol");
assert_eq!(
symbol.exported.as_deref(),
Some("A"),
"the declared name is the first segment, never `A.B`"
);
}
#[test]
fn a_damaged_link_in_a_re_export_chain_is_unread() {
let absent = Project::new(
"chain-absent-link",
&[("src/index.ts", "export { A } from './nowhere';\n")],
);
assert!(!complete_of(
&absent,
"import { A } from './index';\nconst y = A;\n"
));
let damaged = Project::new(
"chain-damaged-link",
&[
("src/index.ts", "export { A } from './a';\n"),
("src/a.d.ts", "export declare class A { m(: number }\n"),
],
);
assert!(!complete_of(
&damaged,
"import { A } from './index';\nconst y = A;\n"
));
}
#[test]
fn symbol_of_reads_nothing_from_a_damaged_declaration() {
let project = Project::new(
"symbol-damaged",
&[(
"src/big.d.ts",
"declare class Huge { m(: number }\nexport { Huge as Big };\n",
)],
);
let symbol = ask(
&project,
"import { Big } from './big';\nconst y = Big;\n",
TypeProvider::symbol_of,
)
.expect("the import's own spelling");
assert_eq!(symbol.exported.as_deref(), Some("Big"));
}
#[test]
fn a_damaged_target_is_unreadable_not_assignable() {
let project = Project::new(
"assignable-damaged-target",
&[("src/big.d.ts", "export declare class Big { m(: number }\n")],
);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let subject = "import { Big } from './big';\nlet b: Big;\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
let node = last_of(&tree, "type_identifier");
assert_eq!(
provider.is_assignable_to(
Query {
file: &file,
tree: &tree,
source: subject,
node,
files: &files,
},
"./big",
"Big",
),
None,
"the target's declaration was only partly read"
);
}
#[test]
fn a_damaged_heritage_parent_in_the_asking_file_is_unreadable() {
let project = Project::new(
"heritage-damaged-locally",
&[("src/big.d.ts", "export interface Root { r(): void }\n")],
);
let files = project.files();
let provider = lanekeep_types::BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let subject = "interface Damaged { d: ;;; }\ninterface Mid extends Damaged { m(): void }\nclass X implements Mid {}\n";
let tree = parse(subject);
let file = FilePath::new("src/a.ts");
let node = last_of(&tree, "type_identifier");
assert_eq!(
provider.is_assignable_to(
Query {
file: &file,
tree: &tree,
source: subject,
node,
files: &files,
},
"./big",
"Root",
),
None,
"the chain crosses a parent the parser only partly read"
);
}
#[test]
fn a_dead_star_source_does_not_hide_a_live_one() {
let project = Project::new(
"barrel-dead-star",
&[
(
"src/index.ts",
"export * from './removed';\nexport * from './money';\n",
),
("src/money.ts", "export const rate: number = 1;\n"),
],
);
let subject = "import { rate } from './index';\nconst y = rate;\n";
assert_eq!(
ask(&project, subject, TypeProvider::type_of),
Some(lanekeep_types::Type::Primitive(
lanekeep_types::Primitive::Number
)),
"the live source answers whatever sits ahead of it"
);
assert!(complete_of(&project, subject));
let nowhere = Project::new(
"barrel-dead-star-only",
&[("src/index.ts", "export * from './removed';\n")],
);
assert!(
!complete_of(
&nowhere,
"import { rate } from './index';\nconst y = rate;\n"
),
"with no source answering, the one that could not be read is what the verdict is about"
);
}
#[test]
fn a_three_segment_namespace_declares_its_first_segment() {
let project = Project::new(
"dotted-namespace-three",
&[
(
"src/legacy.d.ts",
"export declare namespace google.maps.places { const q: number }\n",
),
("src/index.ts", "export { google as G } from './legacy';\n"),
],
);
let subject = "import { G } from './index';\nconst y = G;\n";
assert!(complete_of(&project, subject));
let symbol = ask(&project, subject, TypeProvider::symbol_of).expect("a symbol");
assert_eq!(symbol.exported.as_deref(), Some("google"));
}
#[test]
fn a_tsx_file_is_unread_without_a_tsx_grammar_whatever_its_parse() {
let project = Project::new(
"relative-tsx-clean-parse",
&[("src/Button.tsx", "export const who: string = 'b';\n")],
);
let subject = "import { who } from './Button';\nlet w = who;\n";
assert!(!complete_of(&project, subject));
assert_eq!(ask(&project, subject, TypeProvider::type_of), None);
assert!(
!complete_of(&project, "import './Button';\nconst y = 1;\n"),
"the nameless arm agrees"
);
}