use std::cell::Cell;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use lanekeep_core::{AnalysisBudget, FileAccess, FilePath};
use lanekeep_lang::Language;
use lanekeep_lang::binding::ImportedName;
use crate::declarations::{
Declaration, ExportTarget, Exported, declared_in, declared_name, find_export,
imports_with_names, target_node,
};
use crate::oracle::{Followed, ImportResolution, MAX_DEPTH, TypeScriptOracle, TypeScriptSupport};
use crate::provider::{BeginRunError, Query, TypeProvider};
use crate::resolve::resolve_specifier;
use crate::types::{Symbol, Type};
const MAX_EXPORT_DEPTH: u32 = 16;
pub struct BuiltinProvider {
support: TypeScriptSupport,
grammar_digest: [u8; 32],
analysis_identity: [u8; 32],
parser: Mutex<tree_sitter::Parser>,
tsx: Option<TsxParser>,
declarations: Mutex<BTreeMap<FilePath, Arc<Declaration>>>,
completeness: Mutex<BTreeMap<FilePath, bool>>,
#[cfg(test)]
parses: std::sync::atomic::AtomicUsize,
}
impl fmt::Debug for BuiltinProvider {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("BuiltinProvider").finish_non_exhaustive()
}
}
struct TsxParser {
parser: Mutex<tree_sitter::Parser>,
grammar_digest: [u8; 32],
}
impl TsxParser {
fn probe(language: &dyn Language) -> Option<Self> {
let mut parser = tree_sitter::Parser::new();
parser.set_language(&language.grammar()).ok()?;
Some(Self {
parser: Mutex::new(parser),
grammar_digest: lanekeep_lang::grammar_digest(&language.grammar()),
})
}
}
fn extension_is_tsx(path: &str) -> bool {
match path.rsplit_once('.') {
Some((stem, extension)) => {
!stem.is_empty() && !stem.ends_with('/') && extension.eq_ignore_ascii_case("tsx")
}
None => false,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Unreached {
Unread,
Unmodeled,
}
struct Walk {
visiting: BTreeMap<(FilePath, String), usize>,
answers: BTreeMap<(FilePath, String), Option<bool>>,
lowlink: usize,
exhausted: u32,
}
impl BuiltinProvider {
#[must_use]
pub fn probe(language: &dyn Language) -> Option<Self> {
Self::probe_with(language, None)
}
#[must_use]
pub fn probe_with(language: &dyn Language, tsx: Option<&dyn Language>) -> Option<Self> {
let support = TypeScriptSupport::probe(language)?;
let mut parser = tree_sitter::Parser::new();
parser.set_language(&language.grammar()).ok()?;
let tsx = match tsx {
None => None,
Some(tsx) => Some(TsxParser::probe(tsx)?),
};
Some(Self {
support,
grammar_digest: lanekeep_lang::grammar_digest(&language.grammar()),
analysis_identity: language.analysis_identity(),
parser: Mutex::new(parser),
tsx,
declarations: Mutex::new(BTreeMap::new()),
completeness: Mutex::new(BTreeMap::new()),
#[cfg(test)]
parses: std::sync::atomic::AtomicUsize::new(0),
})
}
fn reads_dialect_of(&self, path: &str) -> bool {
self.tsx.is_some() || !extension_is_tsx(path)
}
fn parser(&self) -> MutexGuard<'_, tree_sitter::Parser> {
self.parser.lock().unwrap_or_else(PoisonError::into_inner)
}
fn parser_for(&self, path: &str) -> MutexGuard<'_, tree_sitter::Parser> {
let tsx = self.tsx.as_ref().filter(|_| extension_is_tsx(path));
match tsx {
Some(tsx) => tsx.parser.lock().unwrap_or_else(PoisonError::into_inner),
None => self.parser(),
}
}
fn oracle_with<'q>(&'q self, q: &Query<'q>, imports: &'q Imports<'q>) -> TypeScriptOracle<'q> {
TypeScriptOracle::new(&self.support, q.tree, q.source).with_imports(q.file, imports)
}
#[must_use]
pub fn declaration(&self, files: &FileAccess, path: &FilePath) -> Option<Arc<Declaration>> {
let Ok(Some(hash)) = files.hash_of(path.as_str()) else {
self.declarations().remove(path);
return None;
};
if let Some(found) = self.declarations().get(path)
&& found.hash == hash
{
return Some(Arc::clone(found));
}
let Ok(Some(source)) = files.read(path.as_str()) else {
return None;
};
#[cfg(test)]
self.parses
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let parsed = Declaration::parse(
path.clone(),
source,
&mut self.parser_for(path.as_str()),
Arc::clone(self.support.resolver()),
)?;
let parsed = Arc::new(parsed);
self.declarations()
.insert(path.clone(), Arc::clone(&parsed));
Some(parsed)
}
fn declarations(&self) -> MutexGuard<'_, BTreeMap<FilePath, Arc<Declaration>>> {
self.declarations
.lock()
.unwrap_or_else(PoisonError::into_inner)
}
fn completeness(&self) -> MutexGuard<'_, BTreeMap<FilePath, bool>> {
self.completeness
.lock()
.unwrap_or_else(PoisonError::into_inner)
}
#[cfg(test)]
fn parses(&self) -> usize {
self.parses.load(std::sync::atomic::Ordering::Relaxed)
}
#[must_use]
pub fn export_target(
&self,
files: &FileAccess,
file: &FilePath,
name: &str,
) -> Option<ExportTarget> {
self.export_walk(files, file, name).ok()
}
fn export_walk(
&self,
files: &FileAccess,
file: &FilePath,
name: &str,
) -> Result<ExportTarget, Unreached> {
let mut visited = BTreeSet::new();
self.walk_export(files, file, name, 0, &mut visited)
}
fn walk_export(
&self,
files: &FileAccess,
file: &FilePath,
name: &str,
depth: u32,
visited: &mut BTreeSet<(FilePath, String)>,
) -> Result<ExportTarget, Unreached> {
if depth >= MAX_EXPORT_DEPTH {
return Err(Unreached::Unread);
}
if !visited.insert((file.clone(), name.to_owned())) {
return Err(Unreached::Unmodeled);
}
if !self.reads_dialect_of(file.as_str()) {
return Err(Unreached::Unread);
}
let decl = self.declaration(files, file).ok_or(Unreached::Unread)?;
let Some(exported) = find_export(&decl, name) else {
return Err(if decl.has_error {
Unreached::Unread
} else {
Unreached::Unmodeled
});
};
match exported {
Exported::Here(node) => {
if node.has_error() {
return Err(Unreached::Unread);
}
Ok(ExportTarget {
file: file.clone(),
name: declared_name(&decl, node).unwrap_or_else(|| name.to_owned()),
})
}
Exported::From {
specifier,
name: exported,
} => {
let next = resolve_specifier(files, file, &specifier).ok_or(Unreached::Unread)?;
self.walk_export(files, &next, &exported, depth.saturating_add(1), visited)
}
Exported::Namespace { .. } => Err(Unreached::Unmodeled),
Exported::Star(sources) => {
let mut unread = false;
for specifier in &sources {
let Some(next) = resolve_specifier(files, file, specifier) else {
unread = true;
continue;
};
match self.walk_export(files, &next, name, depth.saturating_add(1), visited) {
Err(Unreached::Unmodeled) => {}
Err(Unreached::Unread) => unread = true,
found @ Ok(_) => return found,
}
}
Err(if unread {
Unreached::Unread
} else {
Unreached::Unmodeled
})
}
}
}
fn imported(
&self,
files: &FileAccess,
from: &FilePath,
module: &str,
name: &ImportedName,
) -> Option<(Arc<Declaration>, ExportTarget)> {
let wanted = match name {
ImportedName::Named(exported) => exported.clone(),
ImportedName::Default => "default".to_owned(),
ImportedName::Namespace => return None,
};
let entry = resolve_specifier(files, from, module)?;
let target = self.export_target(files, &entry, &wanted)?;
let decl = self.declaration(files, &target.file)?;
Some((decl, target))
}
fn assignable(
&self,
files: &FileAccess,
at: (&FilePath, &tree_sitter::Tree, &str),
ty: &Type,
target: (&FilePath, &str),
depth: u32,
walk: &mut Walk,
) -> Option<bool> {
let (at_path, tree, source) = at;
if depth >= MAX_EXPORT_DEPTH {
walk.exhausted = walk.exhausted.saturating_add(1);
return None;
}
match ty {
Type::Primitive(_) => Some(false),
Type::Union(members) => {
let mut all = true;
for member in members {
if !self.assignable(files, at, member, target, depth.saturating_add(1), walk)? {
all = false;
}
}
Some(all)
}
Type::Nominal {
name: written,
symbol,
} => {
let (declaring, declared) = if let Some(symbol) = symbol {
match &symbol.module {
Some(specifier) => {
let entry = resolve_specifier(files, at_path, specifier)?;
let exported = symbol.exported.as_deref()?;
let found = self.export_target(files, &entry, exported)?;
(found.file, found.name)
}
None => (at_path.clone(), written.clone()),
}
} else {
declared_in(self.support.resolver().as_ref(), tree, source, written)?;
(at_path.clone(), written.clone())
};
if declaring == *target.0 && declared == target.1 {
return Some(true);
}
let key = (declaring.clone(), declared.clone());
if let Some(known) = walk.answers.get(&key) {
return *known;
}
if let Some(&reached) = walk.visiting.get(&key) {
walk.lowlink = walk.lowlink.min(reached);
return Some(false);
}
let index = walk.visiting.len();
walk.visiting.insert(key.clone(), index);
let outer_lowlink = walk.lowlink;
walk.lowlink = usize::MAX;
let exhausted_before = walk.exhausted;
let result = if &declaring == at_path {
self.heritage_assignable(files, at, &declared, target, depth, walk)
} else {
let decl = self.declaration(files, &declaring);
match decl {
Some(decl) => self.heritage_assignable(
files,
(&decl.path, &decl.tree, &decl.source),
&declared,
target,
depth,
walk,
),
None => None,
}
};
walk.visiting.remove(&key);
let reached = walk.lowlink;
walk.lowlink = outer_lowlink.min(reached);
if reached >= index && walk.exhausted == exhausted_before {
walk.answers.insert(key, result);
}
result
}
}
}
fn heritage_assignable(
&self,
files: &FileAccess,
at: (&FilePath, &tree_sitter::Tree, &str),
declared: &str,
target: (&FilePath, &str),
depth: u32,
walk: &mut Walk,
) -> Option<bool> {
let (_, tree, source) = at;
let Some(declaration) =
declared_in(self.support.resolver().as_ref(), tree, source, declared)
else {
return None;
};
if declaration.has_error() {
return None;
}
let truncated = Cell::new(false);
let oracle = TypeScriptOracle::new(&self.support, tree, source).with_exhaustion(&truncated);
if declaration.kind() == "type_alias_declaration"
&& let Some(value) = declaration.child_by_field_name("value")
{
if let Some(aliased) = oracle.type_of_from(value, depth) {
return self.assignable(files, at, &aliased, target, depth.saturating_add(1), walk);
}
if truncated.get() {
walk.exhausted = walk.exhausted.saturating_add(1);
}
}
let mut answer = Some(false);
for parent in heritage_of(declaration) {
let Some(parent_type) = oracle.type_named_by(parent) else {
answer = None;
continue;
};
match self.assignable(
files,
at,
&parent_type,
target,
depth.saturating_add(1),
walk,
) {
Some(true) => return Some(true),
Some(false) => {}
None => answer = None,
}
}
answer
}
}
fn reads_as_code(specifier: &str) -> bool {
let last = specifier.rsplit('/').next().unwrap_or(specifier);
match last.rsplit_once('.') {
None => true,
Some((_, extension)) => !ASSET_EXTENSIONS.contains(&extension),
}
}
const ASSET_EXTENSIONS: &[&str] = &[
"css", "scss", "sass", "less", "styl", "json", "svg", "png", "jpg", "jpeg", "gif", "webp",
"avif", "ico", "woff", "woff2", "ttf", "eot", "otf", "md", "mdx", "txt", "yaml", "yml", "toml",
"graphql", "gql", "wasm", "mp4", "webm", "mp3",
];
fn heritage_of(declaration: tree_sitter::Node<'_>) -> Vec<tree_sitter::Node<'_>> {
let mut out = Vec::new();
let mut cursor = declaration.walk();
for child in declaration.named_children(&mut cursor) {
match child.kind() {
"class_heritage" => {
let mut inner = child.walk();
for clause in child.named_children(&mut inner) {
match clause.kind() {
"extends_clause" => collect_field(clause, "value", &mut out),
"implements_clause" => {
let mut types = clause.walk();
for interface in clause
.named_children(&mut types)
.filter(|child| child.kind() != "comment")
{
out.push(inner_type_name(interface));
}
}
_ => {}
}
}
}
"extends_type_clause" => collect_field(child, "type", &mut out),
_ => {}
}
}
out
}
fn collect_field<'t>(
node: tree_sitter::Node<'t>,
field: &str,
out: &mut Vec<tree_sitter::Node<'t>>,
) {
let mut cursor = node.walk();
for child in node.children_by_field_name(field, &mut cursor) {
out.push(inner_type_name(child));
}
}
fn inner_type_name(node: tree_sitter::Node<'_>) -> tree_sitter::Node<'_> {
if node.kind() == "generic_type" {
node.child_by_field_name("name").unwrap_or(node)
} else {
node
}
}
struct Imports<'a> {
provider: &'a BuiltinProvider,
files: &'a FileAccess,
exhausted: &'a Cell<bool>,
}
impl ImportResolution for Imports<'_> {
fn imported_value_type(
&self,
from: &FilePath,
module: &str,
name: &ImportedName,
depth: u32,
) -> Option<Type> {
let (decl, target) = self.provider.imported(self.files, from, module, name)?;
let node = target_node(&decl, &target.name)?;
let nested = Imports {
provider: self.provider,
files: self.files,
exhausted: self.exhausted,
};
let oracle = TypeScriptOracle::new(&self.provider.support, &decl.tree, &decl.source)
.with_imports(&decl.path, &nested);
oracle.declaration_type_from(node, depth)
}
fn imported_alias_type(
&self,
from: &FilePath,
module: &str,
name: &ImportedName,
depth: u32,
) -> Followed {
if depth >= MAX_DEPTH {
self.exhausted.set(true);
return Followed::Exhausted;
}
let Some((decl, target)) = self.provider.imported(self.files, from, module, name) else {
return Followed::NotAnAlias;
};
let Some(node) = target_node(&decl, &target.name) else {
return Followed::NotAnAlias;
};
if node.kind() != "type_alias_declaration" {
return Followed::NotAnAlias;
}
let Some(value) = node.child_by_field_name("value") else {
return Followed::NotAnAlias;
};
let nested = Imports {
provider: self.provider,
files: self.files,
exhausted: self.exhausted,
};
let oracle = TypeScriptOracle::new(&self.provider.support, &decl.tree, &decl.source)
.with_imports(&decl.path, &nested);
match oracle.type_of_from(value, depth) {
Some(ty) => Followed::Type(ty),
None if self.exhausted.get() => Followed::Exhausted,
None => Followed::NotAnAlias,
}
}
fn imported_return_type(
&self,
from: &FilePath,
module: &str,
name: &ImportedName,
depth: u32,
) -> Option<Type> {
let (decl, target) = self.provider.imported(self.files, from, module, name)?;
let node = target_node(&decl, &target.name)?;
let nested = Imports {
provider: self.provider,
files: self.files,
exhausted: self.exhausted,
};
let oracle = TypeScriptOracle::new(&self.provider.support, &decl.tree, &decl.source)
.with_imports(&decl.path, &nested);
oracle.return_type_from(node, depth)
}
fn imported_export(
&self,
from: &FilePath,
module: &str,
name: &ImportedName,
) -> Option<ExportTarget> {
self.provider
.imported(self.files, from, module, name)
.map(|(_, target)| target)
}
}
impl TypeProvider for BuiltinProvider {
fn type_of(&self, q: Query<'_>) -> Option<Type> {
let exhausted = Cell::new(false);
let imports = Imports {
provider: self,
files: q.files,
exhausted: &exhausted,
};
self.oracle_with(&q, &imports).type_of(q.node)
}
fn symbol_of(&self, q: Query<'_>) -> Option<Symbol> {
let exhausted = Cell::new(false);
let imports = Imports {
provider: self,
files: q.files,
exhausted: &exhausted,
};
self.oracle_with(&q, &imports).symbol_of(q.node)
}
fn return_type_of(&self, q: Query<'_>) -> Option<Type> {
let exhausted = Cell::new(false);
let imports = Imports {
provider: self,
files: q.files,
exhausted: &exhausted,
};
self.oracle_with(&q, &imports).return_type_of(q.node)
}
fn is_assignable_to(&self, q: Query<'_>, module: &str, name: &str) -> Option<bool> {
let entry = resolve_specifier(q.files, q.file, module)?;
let target = self.export_target(q.files, &entry, name)?;
let ty = TypeScriptOracle::new(&self.support, q.tree, q.source).type_of(q.node)?;
let mut walk = Walk {
visiting: BTreeMap::new(),
answers: BTreeMap::new(),
lowlink: usize::MAX,
exhausted: 0,
};
self.assignable(
q.files,
(q.file, q.tree, q.source),
&ty,
(&target.file, &target.name),
0,
&mut walk,
)
}
fn complete(&self, q: Query<'_>) -> bool {
if let Some(known) = self.completeness().get(q.file) {
return *known;
}
let mut complete = true;
for imported in imports_with_names(q.tree, q.source) {
if !reads_as_code(&imported.specifier) {
continue;
}
let Some(file) = resolve_specifier(q.files, q.file, &imported.specifier) else {
complete = false;
continue;
};
if !self.reads_dialect_of(file.as_str()) {
complete = false;
continue;
}
let Some(decl) = self.declaration(q.files, &file) else {
complete = false;
continue;
};
if imported.names.is_empty() || imported.names.contains(&ImportedName::Namespace) {
if decl.has_error {
complete = false;
}
continue;
}
for name in &imported.names {
let wanted = match name {
ImportedName::Named(exported) => exported.as_str(),
ImportedName::Default => "default",
ImportedName::Namespace => continue,
};
if matches!(
self.export_walk(q.files, &file, wanted),
Err(Unreached::Unread)
) {
complete = false;
}
}
}
self.completeness().insert(q.file.clone(), complete);
complete
}
fn begin_run(
&self,
files: &dyn Fn() -> Vec<FilePath>,
budget: AnalysisBudget,
) -> Result<Vec<u8>, BeginRunError> {
let _ = (files, budget);
self.completeness().clear();
Ok(Vec::new())
}
fn identity(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(8 + 32 + 1 + 32 + 32 + 32);
out.extend_from_slice(b"builtin:");
out.extend_from_slice(&self.grammar_digest);
if let Some(tsx) = &self.tsx {
out.push(1);
out.extend_from_slice(&tsx.grammar_digest);
}
out.extend_from_slice(&self.analysis_identity);
out.extend_from_slice(&crate::oracle_identity());
out
}
fn revalidate(&self, files: &FileAccess) {
self.declarations().retain(|path, decl| {
matches!(files.hash_of(path.as_str()), Ok(Some(hash)) if hash == decl.hash)
});
self.completeness().clear();
}
}
const _: () = {
const fn assert_shareable<T: Send + Sync>() {}
assert_shareable::<BuiltinProvider>();
};
#[cfg(test)]
mod tests {
use lanekeep_lang::Language as _;
use lanekeep_lang_js::{Tsx, TypeScript};
use super::{
AnalysisBudget, BuiltinProvider, FileAccess, FilePath, Query, Type, TypeProvider,
extension_is_tsx,
};
use crate::types::Primitive;
fn parse(source: &str) -> tree_sitter::Tree {
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"))
}
fn budget() -> AnalysisBudget {
AnalysisBudget::start(std::time::Duration::from_mins(10))
}
#[test]
fn a_tsx_parser_moves_the_provider_identity() {
let without = BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let with =
BuiltinProvider::probe_with(&TypeScript, Some(&Tsx)).expect("TypeScript and tsx");
assert_ne!(
without.identity(),
with.identity(),
"the tsx grammar's identity is part of the provider's"
);
}
#[test]
fn the_main_grammar_moves_the_provider_identity() {
let over_typescript =
BuiltinProvider::probe_with(&TypeScript, Some(&Tsx)).expect("TypeScript and tsx");
let over_tsx = BuiltinProvider::probe_with(&Tsx, Some(&Tsx)).expect("tsx twice");
assert_ne!(
over_typescript.identity(),
over_tsx.identity(),
"two main grammars over one tsx grammar are two providers"
);
}
#[test]
fn the_identity_folds_both_grammar_digests_and_the_resolver() {
let with =
BuiltinProvider::probe_with(&TypeScript, Some(&Tsx)).expect("TypeScript and tsx");
let expected = [
&b"builtin:"[..],
&lanekeep_lang::grammar_digest(&TypeScript.grammar()),
&[1],
&lanekeep_lang::grammar_digest(&Tsx.grammar()),
&TypeScript.analysis_identity(),
&crate::oracle_identity(),
]
.concat();
assert_eq!(with.identity(), expected);
let without = BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let expected = [
&b"builtin:"[..],
&lanekeep_lang::grammar_digest(&TypeScript.grammar()),
&TypeScript.analysis_identity(),
&crate::oracle_identity(),
]
.concat();
assert_eq!(without.identity(), expected);
}
#[test]
fn a_tsx_extension_is_the_last_component_dot_tsx() {
assert!(extension_is_tsx("src/Button.tsx"));
assert!(extension_is_tsx("node_modules/w/src/Button.TSX"));
assert!(!extension_is_tsx("src/Button.ts"));
assert!(!extension_is_tsx("src/v1.2/Button"));
assert!(
!extension_is_tsx("src/.tsx"),
"a hidden file has no extension"
);
assert!(!extension_is_tsx(".tsx"), "nor does one at the root");
assert!(
!extension_is_tsx("Button.tsx/index"),
"the extension is the last component's"
);
}
#[test]
fn a_declaration_whose_file_vanished_is_dropped() {
let dir =
std::env::temp_dir().join(format!("lanekeep-builtin-vanished-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("creates the project directory");
std::fs::write(dir.join("lib.d.ts"), "export declare class Big {}\n")
.expect("writes the declaration file");
let provider = BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let path = FilePath::new("lib.d.ts");
assert!(
provider
.declaration(&FileAccess::new(&dir), &path)
.is_some(),
"it is there and it parses"
);
assert_eq!(provider.declarations().len(), 1, "so it is held");
std::fs::remove_file(dir.join("lib.d.ts")).expect("removes the declaration file");
assert!(
provider
.declaration(&FileAccess::new(&dir), &path)
.is_none(),
"nothing is there now"
);
assert_eq!(
provider.declarations().len(),
0,
"and the parse it can no longer serve is not held either"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn revalidate_drops_only_the_rewritten_declaration() {
let dir = std::env::temp_dir().join(format!(
"lanekeep-builtin-revalidate-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("creates the project directory");
std::fs::write(
dir.join("stable.d.ts"),
"export declare const rate: number;\n",
)
.expect("writes the stable declaration file");
std::fs::write(
dir.join("moved.d.ts"),
"export declare const rate: number;\n",
)
.expect("writes the declaration file that will move");
let provider = BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let files = FileAccess::new(&dir);
let stable = FilePath::new("stable.d.ts");
let moved = FilePath::new("moved.d.ts");
assert!(provider.declaration(&files, &stable).is_some());
assert!(provider.declaration(&files, &moved).is_some());
assert_eq!(provider.declarations().len(), 2, "both are held");
provider
.completeness()
.insert(FilePath::new("src/a.ts"), true);
std::fs::write(
dir.join("moved.d.ts"),
"export declare const rate: string;\n",
)
.expect("rewrites the declaration file");
provider.revalidate(&FileAccess::new(&dir));
assert_eq!(
provider.declarations().len(),
1,
"the rewritten entry is dropped, the unchanged one is not"
);
assert!(
provider.declarations().contains_key(&stable),
"the file whose bytes did not move is still held"
);
assert!(
!provider.declarations().contains_key(&moved),
"the file whose bytes moved is not"
);
assert!(
provider.completeness().is_empty(),
"completeness carries no hash to compare against, so it is simply forgotten"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_held_declaration_survives_begin_run_and_is_reparsed_after_a_revalidated_rewrite() {
let dir = std::env::temp_dir().join(format!(
"lanekeep-builtin-parse-once-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("creates the project directory");
std::fs::write(
dir.join("money.d.ts"),
"export declare const rate: number;\n",
)
.expect("writes the declaration file");
let provider = BuiltinProvider::probe(&TypeScript).expect("TypeScript");
let subject = "import { rate } from './money';\nconst y = rate;\n";
let tree = parse(subject);
let file = FilePath::new("a.ts");
let node = last_of(&tree, "identifier");
let request_one = FileAccess::new(&dir);
assert_eq!(
provider.type_of(Query {
file: &file,
tree: &tree,
source: subject,
node,
files: &request_one,
}),
Some(Type::Primitive(Primitive::Number)),
"the first request reads and parses the declaration file"
);
assert_eq!(provider.parses(), 1, "one read, one parse");
provider
.begin_run(&Vec::new, budget())
.expect("a second run begins");
let request_two = FileAccess::new(&dir);
assert_eq!(
provider.type_of(Query {
file: &file,
tree: &tree,
source: subject,
node,
files: &request_two,
}),
Some(Type::Primitive(Primitive::Number)),
"still answers across the run boundary"
);
assert_eq!(
provider.parses(),
1,
"the declaration is held across `begin_run` now — its bytes did not move, so it \
is not parsed again"
);
std::fs::write(
dir.join("money.d.ts"),
"export declare const rate: string;\n",
)
.expect("rewrites the declaration file");
let request_three = FileAccess::new(&dir);
provider.revalidate(&request_three);
provider
.begin_run(&Vec::new, budget())
.expect("a third run begins");
assert_eq!(
provider.type_of(Query {
file: &file,
tree: &tree,
source: subject,
node,
files: &request_three,
}),
Some(Type::Primitive(Primitive::String)),
"revalidate dropped the stale entry, so the rewrite is seen"
);
assert_eq!(
provider.parses(),
2,
"the rewritten file is re-parsed exactly once, on the request that revalidated it"
);
let _ = std::fs::remove_dir_all(&dir);
}
}