fix(check/lsp): fall back to @types/* packages if npm package doesn't have types (#28185)

Fixes https://github.com/denoland/deno/issues/27569.
Fixes https://github.com/denoland/deno/issues/27215.

This PR makes it so type resolution falls back to looking for definitely
typed packages (`@types/foo`) if a given NPM package does not contain
type declarations.

One complication is choosing _which_ version of the `@types/*` package
to use, if the project depends on multiple versions. The heuristic here
is to try to match the major and minor versions, falling back to the
latest version. So if you have
```
@types/foo: 0.1.0, 0.2.0, 3.1.0, 3.1.2, 4.0.0
foo: 3.1.0
```
we would choose `@types/foo@3.1.2` when resolving types for `foo`.

---

Note that this only uses `@types/` packages if you _already_ depend on
them. So a follow up to this PR could be to add a diagnostic and
quickfix to install `@types/foo` if we don't find types for `foo`.
This commit is contained in:
Nathan Whitaker 2025-02-19 15:55:06 -08:00 committed by GitHub
parent 3da3fe8f7b
commit 08f5e797b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 567 additions and 389 deletions

View file

@ -18,9 +18,9 @@ use deno_core::serde_json::json;
use deno_core::ModuleSpecifier;
use deno_error::JsErrorBox;
use deno_lint::diagnostic::LintDiagnosticRange;
use deno_npm::NpmPackageId;
use deno_path_util::url_to_file_path;
use deno_resolver::npm::managed::NpmResolutionCell;
use deno_resolver::workspace::MappedResolution;
use deno_runtime::deno_node::PathClean;
use deno_semver::jsr::JsrPackageNvReference;
use deno_semver::jsr::JsrPackageReqReference;
@ -38,7 +38,6 @@ use node_resolver::NodeResolutionKind;
use node_resolver::ResolutionMode;
use once_cell::sync::Lazy;
use regex::Regex;
use text_lines::LineAndColumnIndex;
use tokio_util::sync::CancellationToken;
use tower_lsp::lsp_types as lsp;
use tower_lsp::lsp_types::Position;
@ -46,7 +45,6 @@ use tower_lsp::lsp_types::Range;
use super::diagnostics::DenoDiagnostic;
use super::diagnostics::DiagnosticSource;
use super::documents::Document;
use super::documents::Documents;
use super::language_server;
use super::resolver::LspResolver;
@ -54,7 +52,6 @@ use super::tsc;
use super::urls::url_to_uri;
use crate::args::jsr_url;
use crate::lsp::logging::lsp_warn;
use crate::lsp::search::PackageSearchApi;
use crate::tools::lint::CliLinter;
use crate::util::path::relative_specifier;
@ -370,9 +367,13 @@ impl<'a> TsResponseImportMapper<'a> {
if let Ok(Some(pkg_id)) =
npm_resolver.resolve_pkg_id_from_specifier(specifier)
{
let pkg_reqs = npm_resolver
.resolution()
.resolve_pkg_reqs_from_pkg_id(&pkg_id);
let pkg_reqs =
maybe_reverse_definitely_typed(&pkg_id, npm_resolver.resolution())
.unwrap_or_else(|| {
npm_resolver
.resolution()
.resolve_pkg_reqs_from_pkg_id(&pkg_id)
});
// check if any pkg reqs match what is found in an import map
if !pkg_reqs.is_empty() {
let sub_path = npm_resolver
@ -558,6 +559,30 @@ impl<'a> TsResponseImportMapper<'a> {
}
}
fn maybe_reverse_definitely_typed(
pkg_id: &NpmPackageId,
resolution: &NpmResolutionCell,
) -> Option<Vec<PackageReq>> {
let rest = pkg_id.nv.name.strip_prefix("@types/")?;
let package_name = if rest.contains("__") {
Cow::Owned(format!("@{}", rest.replace("__", "/")))
} else {
Cow::Borrowed(rest)
};
let reqs = resolution
.package_reqs()
.into_iter()
.filter_map(|(req, nv)| (*nv.name == package_name).then_some(req))
.collect::<Vec<_>>();
if reqs.is_empty() {
None
} else {
Some(reqs)
}
}
fn try_reverse_map_package_json_exports(
root_path: &Path,
target_path: &Path,
@ -1267,205 +1292,6 @@ impl CodeActionCollection {
..Default::default()
}));
}
pub async fn add_source_actions(
&mut self,
document: &Document,
range: &lsp::Range,
language_server: &language_server::Inner,
) {
fn import_start_from_specifier(
document: &Document,
import: &deno_graph::Import,
) -> Option<LineAndColumnIndex> {
// find the top level statement that contains the specifier
let parsed_source = document.maybe_parsed_source()?.as_ref().ok()?;
let text_info = parsed_source.text_info_lazy();
let specifier_range = SourceRange::new(
text_info.loc_to_source_pos(LineAndColumnIndex {
line_index: import.specifier_range.range.start.line,
column_index: import.specifier_range.range.start.character,
}),
text_info.loc_to_source_pos(LineAndColumnIndex {
line_index: import.specifier_range.range.end.line,
column_index: import.specifier_range.range.end.character,
}),
);
parsed_source
.program_ref()
.body()
.find(|i| i.range().contains(&specifier_range))
.map(|i| text_info.line_and_column_index(i.range().start))
}
async fn deno_types_for_npm_action(
document: &Document,
range: &lsp::Range,
language_server: &language_server::Inner,
) -> Option<lsp::CodeAction> {
fn top_package_req_for_name(
resolution: &NpmResolutionCell,
name: &str,
) -> Option<PackageReq> {
let package_reqs = resolution.package_reqs();
let mut entries = package_reqs
.into_iter()
.filter(|(_, nv)| nv.name == name)
.collect::<Vec<_>>();
entries.sort_by(|a, b| a.1.version.cmp(&b.1.version));
Some(entries.pop()?.0)
}
let (dep_key, dependency, _) =
document.get_maybe_dependency(&range.end)?;
if dependency.maybe_deno_types_specifier.is_some() {
return None;
}
if dependency.maybe_code.maybe_specifier().is_none()
&& dependency.maybe_type.maybe_specifier().is_none()
{
// We're using byonm and the package is not cached.
return None;
}
let position = deno_graph::Position::new(
range.end.line as usize,
range.end.character as usize,
);
let import_start = dependency.imports.iter().find_map(|i| {
if json!(i.kind) != json!("es") && json!(i.kind) != json!("tsType") {
return None;
}
if !i.specifier_range.includes(position) {
return None;
}
import_start_from_specifier(document, i)
})?;
let referrer = document.specifier();
let resolution_mode = document.resolution_mode();
let file_referrer = document.file_referrer();
let config_data = language_server
.config
.tree
.data_for_specifier(file_referrer?)?;
let workspace_resolver = config_data.resolver.clone();
let npm_ref = if let Ok(resolution) = workspace_resolver.resolve(
&dep_key,
document.specifier(),
deno_resolver::workspace::ResolutionKind::Execution,
) {
let specifier = match resolution {
MappedResolution::Normal { specifier, .. } => specifier,
_ => {
return None;
}
};
NpmPackageReqReference::from_specifier(&specifier).ok()?
} else {
// Only resolve bare package.json deps for byonm.
if !config_data.byonm {
return None;
}
if !language_server.resolver.is_bare_package_json_dep(
&dep_key,
referrer,
resolution_mode,
) {
return None;
}
NpmPackageReqReference::from_str(&format!("npm:{}", &dep_key)).ok()?
};
let package_name = &npm_ref.req().name;
if package_name.starts_with("@types/") {
return None;
}
let managed_npm_resolver = language_server
.resolver
.maybe_managed_npm_resolver(file_referrer);
if let Some(npm_resolver) = managed_npm_resolver {
if !npm_resolver.is_pkg_req_folder_cached(npm_ref.req()) {
return None;
}
}
if language_server
.resolver
.npm_to_file_url(&npm_ref, referrer, resolution_mode, file_referrer)
.is_some()
{
// The package import has types.
return None;
}
let types_package_name = format!("@types/{package_name}");
let types_package_version = language_server
.npm_search_api
.versions(&types_package_name)
.await
.ok()
.and_then(|versions| versions.first().cloned())?;
let types_specifier_text =
if let Some(npm_resolver) = managed_npm_resolver {
let mut specifier_text = if let Some(req) = top_package_req_for_name(
npm_resolver.resolution(),
&types_package_name,
) {
format!("npm:{req}")
} else {
format!("npm:{}@^{}", &types_package_name, types_package_version)
};
let specifier = ModuleSpecifier::parse(&specifier_text).ok()?;
if let Some(file_referrer) = file_referrer {
if let Some(text) = language_server
.get_ts_response_import_mapper(file_referrer)
.check_specifier(&specifier, referrer)
{
specifier_text = text;
}
}
specifier_text
} else {
types_package_name.clone()
};
let uri = language_server
.url_map
.specifier_to_uri(referrer, file_referrer)
.ok()?;
let position = lsp::Position {
line: import_start.line_index as u32,
character: import_start.column_index as u32,
};
let new_text = format!(
"{}// @ts-types=\"{}\"\n",
if position.character == 0 { "" } else { "\n" },
&types_specifier_text
);
let text_edit = lsp::TextEdit {
range: lsp::Range {
start: position,
end: position,
},
new_text,
};
Some(lsp::CodeAction {
title: format!(
"Add @ts-types directive for \"{}\"",
&types_specifier_text
),
kind: Some(lsp::CodeActionKind::QUICKFIX),
diagnostics: None,
edit: Some(lsp::WorkspaceEdit {
changes: Some([(uri, vec![text_edit])].into_iter().collect()),
..Default::default()
}),
..Default::default()
})
}
if let Some(action) =
deno_types_for_npm_action(document, range, language_server).await
{
self.actions.push(CodeActionKind::Deno(action));
}
}
}
/// Prepend the whitespace characters found at the start of line_content to content.

View file

@ -1847,11 +1847,7 @@ impl Inner {
}
}
}
if let Some(document) = asset_or_doc.document() {
code_actions
.add_source_actions(document, &params.range, self)
.await;
}
code_actions.set_preferred_fixes();
all_actions.extend(code_actions.get_response());

View file

@ -189,32 +189,34 @@ impl LspScopeResolver {
let result = dependencies
.iter()
.flat_map(|(name, _)| {
let req_ref =
NpmPackageReqReference::from_str(&format!("npm:{name}")).ok()?;
let specifier = into_specifier_and_media_type(Some(
npm_pkg_req_resolver
let mut deps = Vec::with_capacity(2);
let Some(req_ref) =
NpmPackageReqReference::from_str(&format!("npm:{name}")).ok()
else {
return vec![];
};
for kind in [NodeResolutionKind::Types, NodeResolutionKind::Execution]
{
let Some(req) = npm_pkg_req_resolver
.resolve_req_reference(
&req_ref,
&referrer,
// todo(dsherret): this is wrong because it doesn't consider CJS referrers
ResolutionMode::Import,
NodeResolutionKind::Types,
kind,
)
.or_else(|_| {
npm_pkg_req_resolver.resolve_req_reference(
&req_ref,
&referrer,
// todo(dsherret): this is wrong because it doesn't consider CJS referrers
ResolutionMode::Import,
NodeResolutionKind::Execution,
)
})
.ok()?
.into_url()
.ok()?,
))
.0;
Some((specifier, name.clone()))
.ok()
else {
continue;
};
let Some(url) = req.into_url().ok() else {
continue;
};
let specifier = into_specifier_and_media_type(Some(url)).0;
deps.push((specifier, name.clone()))
}
deps
})
.collect();
Some(result)
@ -579,29 +581,6 @@ impl LspResolver {
has_node_modules_dir(specifier)
}
pub fn is_bare_package_json_dep(
&self,
specifier_text: &str,
referrer: &ModuleSpecifier,
resolution_mode: ResolutionMode,
) -> bool {
let resolver = self.get_scope_resolver(Some(referrer));
let Some(npm_pkg_req_resolver) = resolver.npm_pkg_req_resolver.as_ref()
else {
return false;
};
npm_pkg_req_resolver
.resolve_if_for_npm_pkg(
specifier_text,
referrer,
resolution_mode,
NodeResolutionKind::Types,
)
.ok()
.flatten()
.is_some()
}
pub fn resolve_redirects(
&self,
specifier: &ModuleSpecifier,

View file

@ -1131,8 +1131,42 @@ fn resolve_graph_specifier_types(
let maybe_url = match res_result {
Ok(path_or_url) => Some(path_or_url.into_url()?),
Err(err) => match err.code() {
NodeJsErrorCode::ERR_TYPES_NOT_FOUND
| NodeJsErrorCode::ERR_MODULE_NOT_FOUND => None,
NodeJsErrorCode::ERR_TYPES_NOT_FOUND => {
let reqs = npm
.npm_resolver
.as_managed()
.unwrap()
.resolution()
.package_reqs();
if let Some((_, types_nv)) =
deno_resolver::npm::find_definitely_typed_package(
module.nv_reference.nv(),
reqs.iter().map(|tup| (&tup.0, &tup.1)),
)
{
let package_folder = npm
.npm_resolver
.as_managed()
.unwrap() // should never be byonm because it won't create Module::Npm
.resolve_pkg_folder_from_deno_module(types_nv)?;
let res_result =
npm.node_resolver.resolve_package_subpath_from_deno_module(
&package_folder,
module.nv_reference.sub_path(),
Some(referrer),
resolution_mode,
NodeResolutionKind::Types,
);
if let Ok(res_result) = res_result {
Some(res_result.into_url()?)
} else {
None
}
} else {
None
}
}
NodeJsErrorCode::ERR_MODULE_NOT_FOUND => None,
_ => return Err(ResolveError::PackageSubpathResolve(err)),
},
};

View file

@ -7,6 +7,7 @@ use std::path::PathBuf;
use boxed_error::Boxed;
use deno_error::JsError;
use deno_semver::npm::NpmPackageReqReference;
use deno_semver::package::PackageNv;
use deno_semver::package::PackageReq;
use node_resolver::errors::NodeResolveError;
use node_resolver::errors::NodeResolveErrorKind;
@ -15,6 +16,8 @@ use node_resolver::errors::PackageFolderResolveIoError;
use node_resolver::errors::PackageNotFoundError;
use node_resolver::errors::PackageResolveErrorKind;
use node_resolver::errors::PackageSubpathResolveError;
use node_resolver::errors::TypesNotFoundError;
use node_resolver::types_package_name;
use node_resolver::InNpmPackageChecker;
use node_resolver::IsBuiltInNodeModuleChecker;
use node_resolver::NodeResolution;
@ -133,6 +136,18 @@ pub enum ResolveReqWithSubPathErrorKind {
PackageSubpathResolve(#[from] PackageSubpathResolveError),
}
impl ResolveReqWithSubPathErrorKind {
pub fn as_types_not_found(&self) -> Option<&TypesNotFoundError> {
match self {
ResolveReqWithSubPathErrorKind::MissingPackageNodeModulesFolder(_)
| ResolveReqWithSubPathErrorKind::ResolvePkgFolderFromDenoReq(_) => None,
ResolveReqWithSubPathErrorKind::PackageSubpathResolve(
package_subpath_resolve_error,
) => package_subpath_resolve_error.as_types_not_found(),
}
}
}
#[derive(Debug, Error, JsError)]
pub enum ResolvePkgFolderFromDenoReqError {
#[class(inherit)]
@ -365,6 +380,41 @@ impl<
match resolution_result {
Ok(url) => Ok(url),
Err(err) => {
if err.as_types_not_found().is_some() {
let maybe_definitely_typed_req =
if let Some(npm_resolver) = self.npm_resolver.as_managed() {
let snapshot = npm_resolver.resolution().snapshot();
if let Some(nv) = snapshot.package_reqs().get(req) {
let type_req = find_definitely_typed_package(
nv,
snapshot.package_reqs().iter(),
);
type_req.map(|(r, _)| r).cloned()
} else {
None
}
} else {
Some(
PackageReq::from_str(&format!(
"{}@*",
types_package_name(&req.name)
))
.unwrap(),
)
};
if let Some(req) = maybe_definitely_typed_req {
if let Ok(resolved) = self.resolve_req_with_sub_path(
&req,
sub_path,
referrer,
resolution_mode,
resolution_kind,
) {
return Ok(resolved);
}
}
}
if matches!(self.npm_resolver, NpmResolver::Byonm(_)) {
let package_json_path = package_folder.join("package.json");
if !self.sys.fs_exists_no_err(&package_json_path) {
@ -489,3 +539,41 @@ impl<
}
}
}
/// Attempt to choose the "best" `@types/*` package
/// if possible. If multiple versions exist, try to match
/// the major and minor versions of the `@types` package with the
/// actual package, falling back to the latest @types version present.
pub fn find_definitely_typed_package<'a>(
nv: &'a PackageNv,
packages: impl IntoIterator<Item = (&'a PackageReq, &'a PackageNv)>,
) -> Option<(&PackageReq, &PackageNv)> {
let types_name = types_package_name(&nv.name);
let mut best_patch = 0;
let mut highest: Option<(&PackageReq, &PackageNv)> = None;
let mut best = None;
for (req, type_nv) in packages {
if type_nv.name != types_name {
continue;
}
if type_nv.version.major == nv.version.major
&& type_nv.version.minor == nv.version.minor
&& type_nv.version.patch >= best_patch
&& type_nv.version.pre == nv.version.pre
{
best = Some((req, type_nv));
best_patch = type_nv.version.patch;
}
if let Some((_, highest_nv)) = highest {
if type_nv.version > highest_nv.version {
highest = Some((req, type_nv));
}
} else {
highest = Some((req, type_nv));
}
}
best.or(highest)
}

View file

@ -203,6 +203,12 @@ impl NodeJsErrorCoded for PackageSubpathResolveError {
}
}
impl PackageSubpathResolveError {
pub fn as_types_not_found(&self) -> Option<&TypesNotFoundError> {
self.as_kind().as_types_not_found()
}
}
#[derive(Debug, Boxed, JsError)]
pub struct PackageSubpathResolveError(pub Box<PackageSubpathResolveErrorKind>);
@ -222,6 +228,35 @@ pub enum PackageSubpathResolveErrorKind {
FinalizeResolution(#[from] FinalizeResolutionError),
}
impl PackageSubpathResolveErrorKind {
pub fn as_types_not_found(&self) -> Option<&TypesNotFoundError> {
match self {
PackageSubpathResolveErrorKind::PkgJsonLoad(_) => None,
PackageSubpathResolveErrorKind::Exports(err) => match err.as_kind() {
PackageExportsResolveErrorKind::PackagePathNotExported(_) => None,
PackageExportsResolveErrorKind::PackageTargetResolve(err) => {
match err.as_kind() {
PackageTargetResolveErrorKind::TypesNotFound(not_found) => {
Some(not_found)
}
PackageTargetResolveErrorKind::NotFound(_)
| PackageTargetResolveErrorKind::InvalidPackageTarget(_)
| PackageTargetResolveErrorKind::InvalidModuleSpecifier(_)
| PackageTargetResolveErrorKind::PackageResolve(_)
| PackageTargetResolveErrorKind::UrlToFilePath(_) => None,
}
}
},
PackageSubpathResolveErrorKind::LegacyResolve(err) => match err.as_kind()
{
LegacyResolveErrorKind::TypesNotFound(not_found) => Some(not_found),
LegacyResolveErrorKind::ModuleNotFound(_) => None,
},
PackageSubpathResolveErrorKind::FinalizeResolution(_) => None,
}
}
}
#[derive(Debug, Error, JsError)]
#[class(generic)]
#[error(
@ -297,6 +332,15 @@ pub enum PackageTargetResolveErrorKind {
UrlToFilePath(#[from] deno_path_util::UrlToFilePathError),
}
impl PackageTargetResolveErrorKind {
pub fn as_types_not_found(&self) -> Option<&TypesNotFoundError> {
match self {
Self::TypesNotFound(not_found) => Some(not_found),
_ => None,
}
}
}
impl NodeJsErrorCoded for PackageExportsResolveError {
fn code(&self) -> NodeJsErrorCode {
match self.as_kind() {
@ -417,6 +461,15 @@ pub enum PackageImportsResolveErrorKind {
Target(#[from] PackageTargetResolveError),
}
impl PackageImportsResolveErrorKind {
pub fn as_types_not_found(&self) -> Option<&TypesNotFoundError> {
match self {
Self::Target(err) => err.as_types_not_found(),
_ => None,
}
}
}
impl NodeJsErrorCoded for PackageImportsResolveErrorKind {
fn code(&self) -> NodeJsErrorCode {
match self {
@ -468,6 +521,19 @@ pub enum PackageResolveErrorKind {
UrlToFilePath(#[from] UrlToFilePathError),
}
impl PackageResolveErrorKind {
pub fn as_types_not_found(&self) -> Option<&TypesNotFoundError> {
match self {
PackageResolveErrorKind::ClosestPkgJson(_)
| PackageResolveErrorKind::InvalidModuleSpecifier(_)
| PackageResolveErrorKind::PackageFolderResolve(_)
| PackageResolveErrorKind::ExportsResolve(_)
| PackageResolveErrorKind::UrlToFilePath(_) => None,
PackageResolveErrorKind::SubpathResolve(err) => err.as_types_not_found(),
}
}
}
#[derive(Debug, Error, JsError)]
#[class(generic)]
#[error("Failed joining '{path}' from '{base}'.")]
@ -520,6 +586,26 @@ pub enum NodeResolveErrorKind {
FinalizeResolution(#[from] FinalizeResolutionError),
}
impl NodeResolveErrorKind {
pub fn as_types_not_found(&self) -> Option<&TypesNotFoundError> {
match self {
NodeResolveErrorKind::TypesNotFound(not_found) => Some(not_found),
NodeResolveErrorKind::PackageImportsResolve(err) => {
err.as_kind().as_types_not_found()
}
NodeResolveErrorKind::PackageResolve(package_resolve_error) => {
package_resolve_error.as_types_not_found()
}
NodeResolveErrorKind::UnsupportedEsmUrlScheme(_)
| NodeResolveErrorKind::DataUrlReferrer(_)
| NodeResolveErrorKind::FinalizeResolution(_)
| NodeResolveErrorKind::RelativeJoin(_)
| NodeResolveErrorKind::PathToUrl(_)
| NodeResolveErrorKind::UrlToFilePath(_) => None,
}
}
}
#[derive(Debug, Boxed, JsError)]
pub struct FinalizeResolutionError(pub Box<FinalizeResolutionErrorKind>);

View file

@ -31,6 +31,7 @@ pub use path::UrlOrPath;
pub use path::UrlOrPathRef;
pub use resolution::parse_npm_pkg_name;
pub use resolution::resolve_specifier_into_node_modules;
pub use resolution::types_package_name;
pub use resolution::ConditionsFromResolutionMode;
pub use resolution::NodeResolution;
pub use resolution::NodeResolutionKind;

View file

@ -2090,11 +2090,14 @@ fn pattern_key_compare(a: &str, b: &str) -> i32 {
}
/// Gets the corresponding @types package for the provided package name.
fn types_package_name(package_name: &str) -> String {
pub fn types_package_name(package_name: &str) -> String {
debug_assert!(!package_name.starts_with("@types/"));
// Scoped packages will get two underscores for each slash
// https://github.com/DefinitelyTyped/DefinitelyTyped/tree/15f1ece08f7b498f4b9a2147c2a46e94416ca777#what-about-scoped-packages
format!("@types/{}", package_name.replace('/', "__"))
format!(
"@types/{}",
package_name.trim_start_matches('@').replace('/', "__")
)
}
/// Ex. returns `fs` for `node:fs`
@ -2307,7 +2310,7 @@ mod tests {
assert_eq!(types_package_name("name"), "@types/name");
assert_eq!(
types_package_name("@scoped/package"),
"@types/@scoped__package"
"@types/scoped__package"
);
}
}

View file

@ -6493,136 +6493,6 @@ fn lsp_code_actions_deno_cache_all() {
client.shutdown();
}
#[test]
#[timeout(300_000)]
fn lsp_code_actions_deno_types_for_npm() {
let context = TestContextBuilder::new()
.use_http_server()
.use_temp_cwd()
.add_npm_env_vars()
.build();
let temp_dir = context.temp_dir();
temp_dir.write("deno.json", json!({}).to_string());
temp_dir.write(
"package.json",
json!({
"dependencies": {
"react": "^18.2.0",
"@types/react": "^18.3.10",
},
})
.to_string(),
);
temp_dir.write(
"managed_node_modules/deno.json",
json!({
"nodeModulesDir": false,
})
.to_string(),
);
context.run_npm("install");
let mut client = context.new_lsp_command().build();
client.initialize_default();
client.did_open(json!({
"textDocument": {
"uri": temp_dir.url().join("file.ts").unwrap(),
"languageId": "typescript",
"version": 1,
"text": "import \"react\";\n",
}
}));
let res = client.write_request(
"textDocument/codeAction",
json!({
"textDocument": {
"uri": temp_dir.url().join("file.ts").unwrap(),
},
"range": {
"start": { "line": 0, "character": 7 },
"end": { "line": 0, "character": 7 },
},
"context": { "diagnostics": [], "only": ["quickfix"] },
}),
);
assert_eq!(
res,
json!([
{
"title": "Add @ts-types directive for \"@types/react\"",
"kind": "quickfix",
"edit": {
"changes": {
temp_dir.url().join("file.ts").unwrap(): [
{
"range": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 0 },
},
"newText": "// @ts-types=\"@types/react\"\n",
},
],
},
},
},
]),
);
client.did_open(json!({
"textDocument": {
"uri": temp_dir.url().join("managed_node_modules/file.ts").unwrap(),
"languageId": "typescript",
"version": 1,
"text": "import \"npm:react\";\n",
}
}));
client.write_request(
"workspace/executeCommand",
json!({
"command": "deno.cache",
"arguments": [
[],
temp_dir.url().join("managed_node_modules/file.ts").unwrap(),
],
}),
);
client.read_diagnostics();
let res = client.write_request(
"textDocument/codeAction",
json!({
"textDocument": {
"uri": temp_dir.url().join("managed_node_modules/file.ts").unwrap(),
},
"range": {
"start": { "line": 0, "character": 7 },
"end": { "line": 0, "character": 7 },
},
"context": { "diagnostics": [], "only": ["quickfix"] },
}),
);
assert_eq!(
res,
json!([
{
"title": "Add @ts-types directive for \"npm:@types/react@^18.3.10\"",
"kind": "quickfix",
"edit": {
"changes": {
temp_dir.url().join("managed_node_modules/file.ts").unwrap(): [
{
"range": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 0 },
},
"newText": "// @ts-types=\"npm:@types/react@^18.3.10\"\n",
},
],
},
},
},
]),
);
client.shutdown();
}
#[test]
#[timeout(300_000)]
fn lsp_cache_on_save() {
@ -17713,3 +17583,151 @@ fn ambient_module_errors_suppressed() {
])
);
}
#[test]
#[timeout(300_000)]
fn definitely_typed_fallback() {
let context = TestContextBuilder::for_npm().use_temp_cwd().build();
let mut client = context.new_lsp_command().build();
let temp = context.temp_dir();
let temp_dir = temp.path();
let source = source_file(
temp_dir.join("index.ts"),
r#"
import { foo } from "@denotest/index-export-no-types";
const _res: boolean = foo(1, 2);
console.log(_res);
"#,
);
let deno_json = json!({
"imports": {
"@denotest/index-export-no-types": "npm:@denotest/index-export-no-types@1.0.0",
"@types/denotest__index-export-no-types": "npm:@types/denotest__index-export-no-types@1.0.0",
}
});
temp.write("deno.json", deno_json.to_string());
client.initialize_default();
for node_modules_dir in ["none", "auto", "manual"] {
let mut deno_json = deno_json.clone();
deno_json["nodeModulesDir"] = json!(node_modules_dir);
temp.write("deno.json", deno_json.to_string());
context.run_deno("install");
client.did_change_watched_files(json!({
"changes": [{
"uri": temp.url().join("deno.json").unwrap(),
"type": 2,
}],
}));
client.read_diagnostics();
let diagnostics = client.did_open_file(&source);
eprintln!("{:#?}", diagnostics.all());
assert_eq!(diagnostics.all().len(), 1);
assert_eq!(
json!(diagnostics.all()),
json!([
{
"range": source.range_of("_res"),
"severity": 1,
"code": 2322,
"source": "deno-ts",
"message": "Type 'number' is not assignable to type 'boolean'."
}
])
);
client.did_close_file(&source);
}
}
#[test]
#[timeout(300_000)]
fn do_not_auto_import_from_definitely_typed() {
for node_modules_dir in ["none", "auto", "manual"] {
let context = TestContextBuilder::for_npm().use_temp_cwd().build();
let mut client = context.new_lsp_command().build();
let temp = context.temp_dir();
let temp_dir = temp.path();
let source = source_file(
temp_dir.join("index.ts"),
r#"
import {} from "@denotest/index-export-no-types";
foo
"#,
);
let deno_json = json!({
"imports": {
"@denotest/index-export-no-types": "npm:@denotest/index-export-no-types@1.0.0",
"@types/denotest__index-export-no-types": "npm:@types/denotest__index-export-no-types@1.0.0",
},
"nodeModulesDir": node_modules_dir
});
if node_modules_dir == "manual" {
// TODO: there's a (pre-existing) bug that prevents auto-imports
// from working with nodeModuleDir "manual" w/o a package.json
temp.write(
"package.json",
json!({
"dependencies": {
"@denotest/index-export-no-types": "1.0.0",
},
"devDependencies": {
"@types/denotest__index-export-no-types": "1.0.0",
}
})
.to_string(),
);
}
temp.write("deno.json", deno_json.to_string());
context.run_deno("install");
client.initialize_default();
let mut deno_json = deno_json.clone();
deno_json["nodeModulesDir"] = json!(node_modules_dir);
temp.write("deno.json", deno_json.to_string());
client.did_change_watched_files(json!({
"changes": [{
"uri": temp.url().join("deno.json").unwrap(),
"type": 2,
}],
}));
client.did_open_file(&source);
let pos = source.range_of("foo").end;
let completions = client.get_completion_list(
source.uri().as_str(),
(pos.line as usize, pos.character as usize),
json!({
"triggerKind": 2,
}),
);
let item = completions
.items
.iter()
.find(|it| it.label == "foo")
.unwrap();
eprintln!("item: {item:#?}");
let res = client.write_request("completionItem/resolve", json!(item));
eprintln!("resolved: {res}");
assert_json_subset(
res,
json!({
"label": "foo",
"detail": "Update import from \"@denotest/index-export-no-types\"\n\nfunction foo(a: number, b: number): number",
"additionalTextEdits": [json!({
"range": source.range_of("{}"),
"newText": "{ foo }"
})]
}),
);
client.did_close_file(&source);
}
}

View file

@ -0,0 +1,3 @@
export function foo(a, b) {
return a + b;
}

View file

@ -0,0 +1,5 @@
{
"name": "@denoetst/index-export-no-types",
"version": "1.0.0",
"type": "module"
}

View file

@ -0,0 +1 @@
export function foo(a: number, b: number): string;

View file

@ -0,0 +1,7 @@
{
"name": "@types/denotest__index-export-no-types",
"version": "0.5.0",
"main": "",
"types": "index.d.ts",
"type": "module"
}

View file

@ -0,0 +1 @@
export function foo(a: number, b: number): number;

View file

@ -0,0 +1,7 @@
{
"name": "@types/denotest__index-export-no-types",
"version": "1.0.0",
"main": "",
"types": "index.d.ts",
"type": "module"
}

View file

@ -0,0 +1,76 @@
{
"tempDir": true,
"envs": {
"RUST_BACKTRACE": "0"
},
"tests": {
"node_modules_dir_auto": {
"steps": [
{
"args": "run -A ./set_node_modules_dir.ts auto",
"output": "[WILDCARD]"
},
{
"args": "i",
"output": "[WILDCARD]"
},
{
"args": "check main.ts",
"output": "type_mismatch.out",
"exitCode": 1
}
]
},
"node_modules_dir_none": {
"steps": [
{
"args": "run -A ./set_node_modules_dir.ts none",
"output": "[WILDCARD]"
},
{
"args": "i",
"output": "[WILDCARD]"
},
{
"args": "check main.ts",
"output": "type_mismatch.out",
"exitCode": 1
}
]
},
"node_modules_dir_manual": {
"steps": [
{
"args": "run -A ./set_node_modules_dir.ts manual",
"output": "[WILDCARD]"
},
{
"args": "i",
"output": "[WILDCARD]"
},
{
"args": "check main.ts",
"output": "type_mismatch.out",
"exitCode": 1
}
]
},
"respects_ts_types": {
"steps": [
{
"args": "run -A ./set_node_modules_dir.ts auto",
"output": "[WILDCARD]"
},
{
"args": "i",
"output": "[WILDCARD]"
},
{
"args": "check ts_types.ts",
"output": "ts_types_mismatch.out",
"exitCode": 1
}
]
}
}
}

View file

@ -0,0 +1,7 @@
{
"imports": {
"@denotest/index-export-no-types": "npm:@denotest/index-export-no-types@1.0.0",
"@types/denotest__index-export-no-types": "npm:@types/denotest__index-export-no-types@1.0.0",
"oldtypes": "npm:@types/denotest__index-export-no-types@0.5.0"
}
}

View file

@ -0,0 +1,3 @@
import { foo } from "@denotest/index-export-no-types";
const _res: boolean = foo(1, 2);

View file

@ -0,0 +1,8 @@
if (Deno.args.length !== 1) {
console.error("Usage: set_node_modules_dir.ts <setting>");
Deno.exit(1);
}
const setting = Deno.args[0].trim();
const denoJson = JSON.parse(Deno.readTextFileSync("./deno.json"));
denoJson["nodeModulesDir"] = setting;
Deno.writeTextFileSync("./deno.json", JSON.stringify(denoJson, null, 2));

View file

@ -0,0 +1,4 @@
// @ts-types="oldtypes"
import { foo } from "@denotest/index-export-no-types";
const _res: boolean = foo(1, 2);

View file

@ -0,0 +1,7 @@
Check [WILDCARD]ts_types.ts
TS2322 [ERROR]: Type 'string' is not assignable to type 'boolean'.
const _res: boolean = foo(1, 2);
~~~~
at [WILDCARD]ts_types.ts:4:7
error: Type checking failed.

View file

@ -0,0 +1,7 @@
Check [WILDCARD]main.ts
TS2322 [ERROR]: Type 'number' is not assignable to type 'boolean'.
const _res: boolean = foo(1, 2);
~~~~
at [WILDCARD]main.ts:3:7
error: Type checking failed.

View file

@ -186,6 +186,17 @@ impl TestNpmRegistry {
return Some((DENOTEST3_SCOPE_NAME, package_name_with_path));
}
let prefix1 = format!("/{}/", "@types");
let prefix2 = format!("/{}%2f", "@types");
let maybe_package_name_with_path = uri_path
.strip_prefix(&prefix1)
.or_else(|| uri_path.strip_prefix(&prefix2));
if let Some(package_name_with_path) = maybe_package_name_with_path {
if package_name_with_path.starts_with("denotest") {
return Some(("@types", package_name_with_path));
}
}
None
}
}