diff --git a/.changeset/fast-glasses-ask.md b/.changeset/fast-glasses-ask.md new file mode 100644 index 000000000000..f2fc3906b744 --- /dev/null +++ b/.changeset/fast-glasses-ask.md @@ -0,0 +1,33 @@ +--- +"@biomejs/biome": patch +--- + +Add a new lint rule `useDisposables` for JavaScript, which detects disposable objects assigned to variables without `using` or `await using` syntax. Disposable objects that implement the `Disposable` or `AsyncDisposable` interface are intended to be disposed of after use. Not disposing them can lead to resource or memory leaks, depending on the implementation. + +**Invalid:** + +```js +function createDisposable(): Disposable { + return { + [Symbol.dispose]() { + // do something + }, + }; +} + +const disposable = createDisposable(); +``` + +**Valid:** + +```js +function createDisposable(): Disposable { + return { + [Symbol.dispose]() { + // do something + }, + }; +} + +using disposable = createDisposable(); +``` diff --git a/crates/biome_configuration/src/analyzer/linter/rules.rs b/crates/biome_configuration/src/analyzer/linter/rules.rs index cf0b555ae343..eff6ecf13186 100644 --- a/crates/biome_configuration/src/analyzer/linter/rules.rs +++ b/crates/biome_configuration/src/analyzer/linter/rules.rs @@ -433,6 +433,7 @@ pub enum RuleName { UseDeprecatedDate, UseDeprecatedReason, UseDestructuring, + UseDisposables, UseEnumInitializers, UseErrorCause, UseErrorMessage, @@ -911,6 +912,7 @@ impl RuleName { Self::UseDeprecatedDate => "useDeprecatedDate", Self::UseDeprecatedReason => "useDeprecatedReason", Self::UseDestructuring => "useDestructuring", + Self::UseDisposables => "useDisposables", Self::UseEnumInitializers => "useEnumInitializers", Self::UseErrorCause => "useErrorCause", Self::UseErrorMessage => "useErrorMessage", @@ -1385,6 +1387,7 @@ impl RuleName { Self::UseDeprecatedDate => RuleGroup::Suspicious, Self::UseDeprecatedReason => RuleGroup::Style, Self::UseDestructuring => RuleGroup::Nursery, + Self::UseDisposables => RuleGroup::Nursery, Self::UseEnumInitializers => RuleGroup::Style, Self::UseErrorCause => RuleGroup::Nursery, Self::UseErrorMessage => RuleGroup::Suspicious, @@ -1868,6 +1871,7 @@ impl std::str::FromStr for RuleName { "useDeprecatedDate" => Ok(Self::UseDeprecatedDate), "useDeprecatedReason" => Ok(Self::UseDeprecatedReason), "useDestructuring" => Ok(Self::UseDestructuring), + "useDisposables" => Ok(Self::UseDisposables), "useEnumInitializers" => Ok(Self::UseEnumInitializers), "useErrorCause" => Ok(Self::UseErrorCause), "useErrorMessage" => Ok(Self::UseErrorMessage), diff --git a/crates/biome_configuration/src/generated/domain_selector.rs b/crates/biome_configuration/src/generated/domain_selector.rs index 6d6dfeffeec7..18ae882a4c3e 100644 --- a/crates/biome_configuration/src/generated/domain_selector.rs +++ b/crates/biome_configuration/src/generated/domain_selector.rs @@ -117,6 +117,7 @@ static TYPES_FILTERS: LazyLock>> = LazyLock::new(|| { RuleFilter::Rule("nursery", "useArraySortCompare"), RuleFilter::Rule("nursery", "useAwaitThenable"), RuleFilter::Rule("nursery", "useConsistentEnumValueType"), + RuleFilter::Rule("nursery", "useDisposables"), RuleFilter::Rule("nursery", "useExhaustiveSwitchCases"), RuleFilter::Rule("nursery", "useFind"), RuleFilter::Rule("nursery", "useNullishCoalescing"), diff --git a/crates/biome_configuration/src/generated/linter_options_check.rs b/crates/biome_configuration/src/generated/linter_options_check.rs index 47de67d13c66..859e92e757b4 100644 --- a/crates/biome_configuration/src/generated/linter_options_check.rs +++ b/crates/biome_configuration/src/generated/linter_options_check.rs @@ -1615,6 +1615,11 @@ pub fn config_side_rule_options_types() -> Vec<(&'static str, &'static str, Type "useDestructuring", TypeId::of::(), )); + result.push(( + "nursery", + "useDisposables", + TypeId::of::(), + )); result.push(( "style", "useEnumInitializers", diff --git a/crates/biome_diagnostics_categories/src/categories.rs b/crates/biome_diagnostics_categories/src/categories.rs index a998e56d42a2..98ce0e1d10c7 100644 --- a/crates/biome_diagnostics_categories/src/categories.rs +++ b/crates/biome_diagnostics_categories/src/categories.rs @@ -255,6 +255,7 @@ define_categories! { "lint/nursery/useConsistentObjectDefinition": "https://biomejs.dev/linter/rules/use-consistent-object-definition", "lint/nursery/useConsistentTestIt": "https://biomejs.dev/linter/rules/use-consistent-test-it", "lint/nursery/useDestructuring": "https://biomejs.dev/linter/rules/use-destructuring", + "lint/nursery/useDisposables": "https://biomejs.dev/linter/rules/use-disposables", "lint/nursery/useErrorCause": "https://biomejs.dev/linter/rules/use-error-cause", "lint/nursery/useExhaustiveSwitchCases": "https://biomejs.dev/linter/rules/use-exhaustive-switch-cases", "lint/nursery/useExpect": "https://biomejs.dev/linter/rules/use-expect", diff --git a/crates/biome_js_analyze/src/lint/nursery/use_disposables.rs b/crates/biome_js_analyze/src/lint/nursery/use_disposables.rs new file mode 100644 index 000000000000..e9c631062e0c --- /dev/null +++ b/crates/biome_js_analyze/src/lint/nursery/use_disposables.rs @@ -0,0 +1,162 @@ +use crate::JsRuleAction; +use crate::services::typed::Typed; +use biome_analyze::{ + FixKind, Rule, RuleDiagnostic, RuleDomain, context::RuleContext, declare_lint_rule, +}; +use biome_console::markup; +use biome_js_factory::make; +use biome_js_syntax::{JsVariableDeclaration, JsVariableDeclarator, JsVariableDeclaratorList, T}; +use biome_rowan::{AstNode, BatchMutationExt}; +use biome_rule_options::use_disposables::UseDisposablesOptions; + +declare_lint_rule! { + /// Detects a disposable object assigned to a variable without using or await using syntax. + /// + /// Disposable objects, which implements Disposable or AsyncDisposable interface, are intended + /// to dispose after use. Not disposing them can lead some resource or memory leak depending on + /// the implementation. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```ts,expect_diagnostic,file=example1.ts + /// function createDisposable(): Disposable { + /// return { + /// [Symbol.dispose]() { + /// // do something + /// }, + /// }; + /// } + /// + /// const disposable = createDisposable(); + /// ``` + /// + /// ```ts,expect_diagnostic,file=example2.ts + /// class MyClass implements AsyncDisposable { + /// async [Symbol.asyncDispose]() { + /// // do something + /// } + /// } + /// + /// const instance = new MyClass(); + /// ``` + /// + /// ### Valid + /// + /// ```ts,file=example3.ts + /// function createDisposable(): Disposable { + /// return { + /// [Symbol.dispose]() { + /// // do something + /// }, + /// }; + /// } + /// + /// using disposable = createDisposable(); + /// ``` + /// + /// ```ts,file=example4.ts + /// class MyClass implements AsyncDisposable { + /// async [Symbol.asyncDispose]() { + /// // do something + /// } + /// } + /// + /// await using instance = new MyClass(); + /// ``` + /// + pub UseDisposables { + version: "next", + name: "useDisposables", + language: "js", + recommended: false, + fix_kind: FixKind::Unsafe, + domains: &[RuleDomain::Types], + } +} + +impl Rule for UseDisposables { + type Query = Typed; + type State = DisposableKind; + type Signals = Option; + type Options = UseDisposablesOptions; + + fn run(ctx: &RuleContext) -> Self::Signals { + let decl = ctx.query(); + let initializer = decl.initializer()?; + let expression = initializer.expression().ok()?; + let ty = ctx.type_of_expression(&expression); + + // Lookup the parent declaration which possibly has `await` and/or `using` tokens. + let parent = decl + .parent::()? + .parent::()?; + + let is_disposed = parent.kind().ok()?.kind() == T![using]; + if ty.is_disposable() && !is_disposed { + return Some(DisposableKind::Disposable); + } + + let is_async_disposed = is_disposed && parent.await_token().is_some(); + if ty.is_async_disposable() && !is_async_disposed { + return Some(DisposableKind::AsyncDisposable); + } + + None + } + + fn diagnostic(ctx: &RuleContext, state: &Self::State) -> Option { + let node = ctx.query(); + + Some( + RuleDiagnostic::new( + rule_category!(), + node.range(), + markup! { "Disposable object is assigned here but never disposed." }, + ) + .note(match state { + DisposableKind::Disposable => markup! { + "The object implements the ""Disposable"" interface, which is intended to be disposed after use with ""using"" syntax." + }, + DisposableKind::AsyncDisposable => markup! { + "The object implements the ""AsyncDisposable"" interface, which is intended to be disposed after use with ""await using"" syntax." + }, + }) + .note(markup! { + "Not disposing the object properly can lead some resource or memory leak." + }) + ) + } + + fn action(ctx: &RuleContext, state: &Self::State) -> Option { + let mut mutation = ctx.root().begin(); + + let decl = ctx + .query() + .parent::()? + .parent::()?; + + let mut new_decl = decl + .clone() + .with_kind_token(make::token_with_trailing_space(T![using])); + + if let DisposableKind::AsyncDisposable = state { + new_decl = new_decl.with_await_token(Some(make::token_with_trailing_space(T![await]))); + } + + mutation.replace_node(decl, new_decl); + + Some(JsRuleAction::new( + ctx.metadata().action_category(ctx.category(), ctx.group()), + ctx.metadata().applicability(), + markup! { "Add the ""using"" keyword to dispose the object when leaving the scope." }, + mutation, + )) + } +} + +pub enum DisposableKind { + Disposable, + AsyncDisposable, +} diff --git a/crates/biome_js_analyze/tests/specs/nursery/useDisposables/invalid.ts b/crates/biome_js_analyze/tests/specs/nursery/useDisposables/invalid.ts new file mode 100644 index 000000000000..1dd73869bbf4 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useDisposables/invalid.ts @@ -0,0 +1,48 @@ +/* should generate diagnostics */ +const disposable = { + [Symbol.dispose]() { + // do something + } +}; + +const asyncDisposable = { + async [Symbol.asyncDispose]() { + // do something + } +}; + +function createDisposable(): Disposable { + return { + [Symbol.dispose]() { + // do something + }, + }; +} + +const createdDisposable = createDisposable(); + +function createAsyncDisposable(): AsyncDisposable { + return { + async [Symbol.asyncDispose](): Promise { + // do something + }, + }; +} + +const createdAsyncDisposable = createAsyncDisposable(); + +class DisposableClass implements Disposable { + [Symbol.dispose](): void { + // do something + } +} + +const disposableInstance = new DisposableClass(); + +class AsyncDisposableClass implements AsyncDisposable { + async [Symbol.asyncDispose](): Promise { + // do something + } +} + +const asyncDisposableInstance = new AsyncDisposableClass(); diff --git a/crates/biome_js_analyze/tests/specs/nursery/useDisposables/invalid.ts.snap b/crates/biome_js_analyze/tests/specs/nursery/useDisposables/invalid.ts.snap new file mode 100644 index 000000000000..75b8b5372921 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useDisposables/invalid.ts.snap @@ -0,0 +1,243 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: invalid.ts +--- +# Input +```ts +/* should generate diagnostics */ +const disposable = { + [Symbol.dispose]() { + // do something + } +}; + +const asyncDisposable = { + async [Symbol.asyncDispose]() { + // do something + } +}; + +function createDisposable(): Disposable { + return { + [Symbol.dispose]() { + // do something + }, + }; +} + +const createdDisposable = createDisposable(); + +function createAsyncDisposable(): AsyncDisposable { + return { + async [Symbol.asyncDispose](): Promise { + // do something + }, + }; +} + +const createdAsyncDisposable = createAsyncDisposable(); + +class DisposableClass implements Disposable { + [Symbol.dispose](): void { + // do something + } +} + +const disposableInstance = new DisposableClass(); + +class AsyncDisposableClass implements AsyncDisposable { + async [Symbol.asyncDispose](): Promise { + // do something + } +} + +const asyncDisposableInstance = new AsyncDisposableClass(); + +``` + +# Diagnostics +``` +invalid.ts:2:7 lint/nursery/useDisposables FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Disposable object is assigned here but never disposed. + + 1 │ /* should generate diagnostics */ + > 2 │ const disposable = { + │ ^^^^^^^^^^^^^^ + > 3 │ [Symbol.dispose]() { + > 4 │ // do something + > 5 │ } + > 6 │ }; + │ ^ + 7 │ + 8 │ const asyncDisposable = { + + i The object implements the Disposable interface, which is intended to be disposed after use with using syntax. + + i Not disposing the object properly can lead some resource or memory leak. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + i Unsafe fix: Add the using keyword to dispose the object when leaving the scope. + + 1 1 │ /* should generate diagnostics */ + 2 │ - const·disposable·=·{ + 2 │ + using·disposable·=·{ + 3 3 │ [Symbol.dispose]() { + 4 4 │ // do something + + +``` + +``` +invalid.ts:8:7 lint/nursery/useDisposables FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Disposable object is assigned here but never disposed. + + 6 │ }; + 7 │ + > 8 │ const asyncDisposable = { + │ ^^^^^^^^^^^^^^^^^^^ + > 9 │ async [Symbol.asyncDispose]() { + > 10 │ // do something + > 11 │ } + > 12 │ }; + │ ^ + 13 │ + 14 │ function createDisposable(): Disposable { + + i The object implements the AsyncDisposable interface, which is intended to be disposed after use with await using syntax. + + i Not disposing the object properly can lead some resource or memory leak. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + i Unsafe fix: Add the using keyword to dispose the object when leaving the scope. + + 6 6 │ }; + 7 7 │ + 8 │ - const·asyncDisposable·=·{ + 8 │ + await·using·asyncDisposable·=·{ + 9 9 │ async [Symbol.asyncDispose]() { + 10 10 │ // do something + + +``` + +``` +invalid.ts:22:7 lint/nursery/useDisposables FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Disposable object is assigned here but never disposed. + + 20 │ } + 21 │ + > 22 │ const createdDisposable = createDisposable(); + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 23 │ + 24 │ function createAsyncDisposable(): AsyncDisposable { + + i The object implements the Disposable interface, which is intended to be disposed after use with using syntax. + + i Not disposing the object properly can lead some resource or memory leak. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + i Unsafe fix: Add the using keyword to dispose the object when leaving the scope. + + 20 20 │ } + 21 21 │ + 22 │ - const·createdDisposable·=·createDisposable(); + 22 │ + using·createdDisposable·=·createDisposable(); + 23 23 │ + 24 24 │ function createAsyncDisposable(): AsyncDisposable { + + +``` + +``` +invalid.ts:32:7 lint/nursery/useDisposables FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Disposable object is assigned here but never disposed. + + 30 │ } + 31 │ + > 32 │ const createdAsyncDisposable = createAsyncDisposable(); + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 33 │ + 34 │ class DisposableClass implements Disposable { + + i The object implements the AsyncDisposable interface, which is intended to be disposed after use with await using syntax. + + i Not disposing the object properly can lead some resource or memory leak. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + i Unsafe fix: Add the using keyword to dispose the object when leaving the scope. + + 30 30 │ } + 31 31 │ + 32 │ - const·createdAsyncDisposable·=·createAsyncDisposable(); + 32 │ + await·using·createdAsyncDisposable·=·createAsyncDisposable(); + 33 33 │ + 34 34 │ class DisposableClass implements Disposable { + + +``` + +``` +invalid.ts:40:7 lint/nursery/useDisposables FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Disposable object is assigned here but never disposed. + + 38 │ } + 39 │ + > 40 │ const disposableInstance = new DisposableClass(); + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 41 │ + 42 │ class AsyncDisposableClass implements AsyncDisposable { + + i The object implements the Disposable interface, which is intended to be disposed after use with using syntax. + + i Not disposing the object properly can lead some resource or memory leak. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + i Unsafe fix: Add the using keyword to dispose the object when leaving the scope. + + 38 38 │ } + 39 39 │ + 40 │ - const·disposableInstance·=·new·DisposableClass(); + 40 │ + using·disposableInstance·=·new·DisposableClass(); + 41 41 │ + 42 42 │ class AsyncDisposableClass implements AsyncDisposable { + + +``` + +``` +invalid.ts:48:7 lint/nursery/useDisposables FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Disposable object is assigned here but never disposed. + + 46 │ } + 47 │ + > 48 │ const asyncDisposableInstance = new AsyncDisposableClass(); + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 49 │ + + i The object implements the AsyncDisposable interface, which is intended to be disposed after use with await using syntax. + + i Not disposing the object properly can lead some resource or memory leak. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + i Unsafe fix: Add the using keyword to dispose the object when leaving the scope. + + 46 46 │ } + 47 47 │ + 48 │ - const·asyncDisposableInstance·=·new·AsyncDisposableClass(); + 48 │ + await·using·asyncDisposableInstance·=·new·AsyncDisposableClass(); + 49 49 │ + + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useDisposables/valid.ts b/crates/biome_js_analyze/tests/specs/nursery/useDisposables/valid.ts new file mode 100644 index 000000000000..f06bb16a7fe5 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useDisposables/valid.ts @@ -0,0 +1,48 @@ +/* should not generate diagnostics */ +using disposable = { + [Symbol.dispose]() { + // do something + }, +}; + +await using asyncDisposable = { + async [Symbol.asyncDispose](): Promise { + // do something + }, +}; + +function createDisposable(): Disposable { + return { + [Symbol.dispose]() { + // do something + }, + }; +} + +using createdDisposable = createDisposable(); + +function createAsyncDisposable(): AsyncDisposable { + return { + async [Symbol.asyncDispose](): Promise { + // do something + }, + }; +} + +await using createdAsyncDisposable = createAsyncDisposable(); + +class DisposableClass implements Disposable { + [Symbol.dispose](): void { + // do something + } +} + +using disposableInstance = new DisposableClass(); + +class AsyncDisposableClass implements AsyncDisposable { + async [Symbol.asyncDispose](): Promise { + // do something + } +} + +await using asyncDisposableInstance = new AsyncDisposableClass(); diff --git a/crates/biome_js_analyze/tests/specs/nursery/useDisposables/valid.ts.snap b/crates/biome_js_analyze/tests/specs/nursery/useDisposables/valid.ts.snap new file mode 100644 index 000000000000..83e69221b10e --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useDisposables/valid.ts.snap @@ -0,0 +1,56 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: valid.ts +--- +# Input +```ts +/* should not generate diagnostics */ +using disposable = { + [Symbol.dispose]() { + // do something + }, +}; + +await using asyncDisposable = { + async [Symbol.asyncDispose](): Promise { + // do something + }, +}; + +function createDisposable(): Disposable { + return { + [Symbol.dispose]() { + // do something + }, + }; +} + +using createdDisposable = createDisposable(); + +function createAsyncDisposable(): AsyncDisposable { + return { + async [Symbol.asyncDispose](): Promise { + // do something + }, + }; +} + +await using createdAsyncDisposable = createAsyncDisposable(); + +class DisposableClass implements Disposable { + [Symbol.dispose](): void { + // do something + } +} + +using disposableInstance = new DisposableClass(); + +class AsyncDisposableClass implements AsyncDisposable { + async [Symbol.asyncDispose](): Promise { + // do something + } +} + +await using asyncDisposableInstance = new AsyncDisposableClass(); + +``` diff --git a/crates/biome_js_type_info/src/globals.rs b/crates/biome_js_type_info/src/globals.rs index a737805dc510..a9e559c0b57d 100644 --- a/crates/biome_js_type_info/src/globals.rs +++ b/crates/biome_js_type_info/src/globals.rs @@ -11,10 +11,11 @@ use biome_js_syntax::AnyJsExpression; use biome_rowan::Text; use crate::{ - Class, Function, FunctionParameter, GenericTypeParameter, Literal, PatternFunctionParameter, - Resolvable, ResolvedTypeData, ResolvedTypeId, ResolverId, ReturnType, ScopeId, TypeData, - TypeId, TypeInstance, TypeMember, TypeMemberKind, TypeReference, TypeReferenceQualifier, - TypeResolver, TypeResolverLevel, TypeStore, Union, flattening::MAX_FLATTEN_DEPTH, + Class, Function, FunctionParameter, GenericTypeParameter, Interface, Literal, + PatternFunctionParameter, Resolvable, ResolvedTypeData, ResolvedTypeId, ResolverId, ReturnType, + ScopeId, TypeData, TypeId, TypeInstance, TypeMember, TypeMemberKind, TypeReference, + TypeReferenceQualifier, TypeResolver, TypeResolverLevel, TypeStore, Union, + flattening::MAX_FLATTEN_DEPTH, }; use super::globals_builder::GlobalsResolverBuilder; @@ -91,6 +92,14 @@ pub fn global_type_name(id: TypeId) -> Option<&'static str> { INSTANCEOF_REGEXP_ID => Some(INSTANCEOF_REGEXP_ID_NAME), REGEXP_ID => Some(REGEXP_ID_NAME), REGEXP_EXEC_ID => Some(REGEXP_EXEC_ID_NAME), + INSTANCEOF_SYMBOL_ID => Some(INSTANCEOF_SYMBOL_ID_NAME), + SYMBOL_ID => Some(SYMBOL_ID_NAME), + SYMBOL_DISPOSE_ID => Some(SYMBOL_DISPOSE_ID_NAME), + SYMBOL_ASYNC_DISPOSE_ID => Some(SYMBOL_ASYNC_DISPOSE_ID_NAME), + DISPOSABLE_ID => Some(DISPOSABLE_ID_NAME), + DISPOSABLE_DISPOSE_ID => Some(DISPOSABLE_DISPOSE_ID_NAME), + ASYNC_DISPOSABLE_ID => Some(ASYNC_DISPOSABLE_ID_NAME), + ASYNC_DISPOSABLE_ASYNC_DISPOSE_ID => Some(ASYNC_DISPOSABLE_ASYNC_DISPOSE_ID_NAME), _ => None, } } @@ -106,12 +115,12 @@ pub struct GlobalsResolver { impl Default for GlobalsResolver { fn default() -> Self { - let method = |name: &'static str, id: TypeId| TypeMember { + let member = |name: &'static str, id: TypeId| TypeMember { kind: TypeMemberKind::Named(Text::new_static(name)), ty: ResolvedTypeId::new(TypeResolverLevel::Global, id).into(), }; - let static_method = |name: &'static str, id: TypeId| TypeMember { + let static_member = |name: &'static str, id: TypeId| TypeMember { kind: TypeMemberKind::NamedStatic(Text::new_static(name)), ty: ResolvedTypeId::new(TypeResolverLevel::Global, id).into(), }; @@ -184,9 +193,9 @@ impl Default for GlobalsResolver { extends: None, implements: [].into(), members: Box::new([ - method("filter", ARRAY_FILTER_ID), - method("forEach", ARRAY_FOREACH_ID), - method("map", ARRAY_MAP_ID), + member("filter", ARRAY_FILTER_ID), + member("forEach", ARRAY_FOREACH_ID), + member("map", ARRAY_MAP_ID), TypeMember { kind: TypeMemberKind::Named(Text::new_static("length")), ty: GLOBAL_NUMBER_ID.into(), @@ -239,16 +248,16 @@ impl Default for GlobalsResolver { kind: TypeMemberKind::Constructor, ty: GLOBAL_PROMISE_CONSTRUCTOR_ID.into(), }, - method("catch", PROMISE_CATCH_ID), - method("finally", PROMISE_FINALLY_ID), - method("then", PROMISE_THEN_ID), - static_method("all", PROMISE_ALL_ID), - static_method("allSettled", PROMISE_ALL_SETTLED_ID), - static_method("any", PROMISE_ANY_ID), - static_method("race", PROMISE_RACE_ID), - static_method("reject", PROMISE_REJECT_ID), - static_method("resolve", PROMISE_RESOLVE_ID), - static_method("try", PROMISE_TRY_ID), + member("catch", PROMISE_CATCH_ID), + member("finally", PROMISE_FINALLY_ID), + member("then", PROMISE_THEN_ID), + static_member("all", PROMISE_ALL_ID), + static_member("allSettled", PROMISE_ALL_SETTLED_ID), + static_member("any", PROMISE_ANY_ID), + static_member("race", PROMISE_RACE_ID), + static_member("reject", PROMISE_REJECT_ID), + static_member("resolve", PROMISE_RESOLVE_ID), + static_member("try", PROMISE_TRY_ID), ]), })), ); @@ -392,7 +401,7 @@ impl Default for GlobalsResolver { type_parameters: Box::default(), extends: None, implements: [].into(), - members: Box::new([method("exec", REGEXP_EXEC_ID)]), + members: Box::new([member("exec", REGEXP_EXEC_ID)]), })), ); builder.set_type_data( @@ -405,6 +414,79 @@ impl Default for GlobalsResolver { return_type: ReturnType::Type(GLOBAL_INSTANCEOF_REGEXP_ID.into()), }), ); + // Known symbols + builder.set_type_data( + INSTANCEOF_SYMBOL_ID, + TypeData::instance_of(TypeReference::from(GLOBAL_SYMBOL_ID)), + ); + builder.set_type_data( + SYMBOL_ID, + TypeData::Class(Box::new(Class { + name: Some(Text::new_static(SYMBOL_ID_NAME)), + type_parameters: Box::default(), + extends: None, + implements: [].into(), + members: Box::new([ + static_member("dispose", SYMBOL_DISPOSE_ID), + static_member("asyncDispose", SYMBOL_ASYNC_DISPOSE_ID), + ]), + })), + ); + builder.set_type_data(SYMBOL_DISPOSE_ID, TypeData::Symbol); + builder.set_type_data(SYMBOL_ASYNC_DISPOSE_ID, TypeData::Symbol); + builder.set_type_data( + DISPOSABLE_ID, + TypeData::Interface(Box::new(Interface { + name: Text::new_static(DISPOSABLE_ID_NAME), + type_parameters: Box::default(), + extends: [].into(), + members: Box::new([TypeMember { + kind: TypeMemberKind::IndexSignature(TypeReference::Resolved( + GLOBAL_SYMBOL_DISPOSE_ID, + )), + ty: ResolvedTypeId::new(TypeResolverLevel::Global, DISPOSABLE_DISPOSE_ID) + .into(), + }]), + })), + ); + builder.set_type_data( + DISPOSABLE_DISPOSE_ID, + TypeData::Function(Box::new(Function { + is_async: false, + type_parameters: Default::default(), + name: None, + parameters: Default::default(), + return_type: ReturnType::Type(GLOBAL_VOID_ID.into()), + })), + ); + builder.set_type_data( + ASYNC_DISPOSABLE_ID, + TypeData::Interface(Box::new(Interface { + name: Text::new_static(ASYNC_DISPOSABLE_ID_NAME), + type_parameters: Box::default(), + extends: [].into(), + members: Box::new([TypeMember { + kind: TypeMemberKind::IndexSignature(TypeReference::Resolved( + GLOBAL_SYMBOL_ASYNC_DISPOSE_ID, + )), + ty: ResolvedTypeId::new( + TypeResolverLevel::Global, + ASYNC_DISPOSABLE_ASYNC_DISPOSE_ID, + ) + .into(), + }]), + })), + ); + builder.set_type_data( + ASYNC_DISPOSABLE_ASYNC_DISPOSE_ID, + TypeData::Function(Box::new(Function { + is_async: true, + type_parameters: Default::default(), + name: None, + parameters: Default::default(), + return_type: ReturnType::Type(GLOBAL_INSTANCEOF_PROMISE_ID.into()), + })), + ); builder.build() } @@ -484,6 +566,12 @@ impl TypeResolver for GlobalsResolver { Some(GLOBAL_PROMISE_ID) } else if qualifier.is_regex() && !qualifier.has_known_type_parameters() { Some(GLOBAL_REGEXP_ID) + } else if qualifier.is_symbol() && !qualifier.has_known_type_parameters() { + Some(GLOBAL_SYMBOL_ID) + } else if qualifier.is_disposable() && !qualifier.has_known_type_parameters() { + Some(GLOBAL_DISPOSABLE_ID) + } else if qualifier.is_async_disposable() && !qualifier.has_known_type_parameters() { + Some(GLOBAL_ASYNC_DISPOSABLE_ID) } else if !qualifier.type_only && let Some(ident) = qualifier.path.identifier() { diff --git a/crates/biome_js_type_info/src/globals_ids.rs b/crates/biome_js_type_info/src/globals_ids.rs index 1d37a88da0e2..0ba3ada165c9 100644 --- a/crates/biome_js_type_info/src/globals_ids.rs +++ b/crates/biome_js_type_info/src/globals_ids.rs @@ -183,10 +183,48 @@ define_global_type!( ); define_global_type!(REGEXP_ID, REGEXP_ID_NAME, 42, "RegExp"); define_global_type!(REGEXP_EXEC_ID, REGEXP_EXEC_ID_NAME, 43, "RegExp.exec"); +define_global_type!( + INSTANCEOF_SYMBOL_ID, + INSTANCEOF_SYMBOL_ID_NAME, + 44, + "instanceof Symbol" +); +define_global_type!(SYMBOL_ID, SYMBOL_ID_NAME, 45, "Symbol"); +define_global_type!( + SYMBOL_DISPOSE_ID, + SYMBOL_DISPOSE_ID_NAME, + 46, + "Symbol.dispose" +); +define_global_type!( + SYMBOL_ASYNC_DISPOSE_ID, + SYMBOL_ASYNC_DISPOSE_ID_NAME, + 47, + "Symbol.asyncDispose" +); +define_global_type!(DISPOSABLE_ID, DISPOSABLE_ID_NAME, 48, "Disposable"); +define_global_type!( + DISPOSABLE_DISPOSE_ID, + DISPOSABLE_DISPOSE_ID_NAME, + 49, + "Disposable[Symbol.dispose]" +); +define_global_type!( + ASYNC_DISPOSABLE_ID, + ASYNC_DISPOSABLE_ID_NAME, + 50, + "AsyncDisposable" +); +define_global_type!( + ASYNC_DISPOSABLE_ASYNC_DISPOSE_ID, + ASYNC_DISPOSABLE_ASYNC_DISPOSE_ID_NAME, + 51, + "AsyncDisposable[Symbol.asyncDispose]" +); /// Total number of predefined types. /// Must be one more than the highest TypeId above. -pub const NUM_PREDEFINED_TYPES: usize = 44; +pub const NUM_PREDEFINED_TYPES: usize = 52; // Resolved type ID constants (TypeId wrapped with GlobalLevel) pub const GLOBAL_UNKNOWN_ID: ResolvedTypeId = ResolvedTypeId::new(GLOBAL_LEVEL, UNKNOWN_ID); @@ -227,3 +265,11 @@ pub const GLOBAL_FETCH_ID: ResolvedTypeId = ResolvedTypeId::new(GLOBAL_LEVEL, FE pub const GLOBAL_INSTANCEOF_REGEXP_ID: ResolvedTypeId = ResolvedTypeId::new(GLOBAL_LEVEL, INSTANCEOF_REGEXP_ID); pub const GLOBAL_REGEXP_ID: ResolvedTypeId = ResolvedTypeId::new(GLOBAL_LEVEL, REGEXP_ID); +pub const GLOBAL_SYMBOL_ID: ResolvedTypeId = ResolvedTypeId::new(GLOBAL_LEVEL, SYMBOL_ID); +pub const GLOBAL_SYMBOL_DISPOSE_ID: ResolvedTypeId = + ResolvedTypeId::new(GLOBAL_LEVEL, SYMBOL_DISPOSE_ID); +pub const GLOBAL_SYMBOL_ASYNC_DISPOSE_ID: ResolvedTypeId = + ResolvedTypeId::new(GLOBAL_LEVEL, SYMBOL_ASYNC_DISPOSE_ID); +pub const GLOBAL_DISPOSABLE_ID: ResolvedTypeId = ResolvedTypeId::new(GLOBAL_LEVEL, DISPOSABLE_ID); +pub const GLOBAL_ASYNC_DISPOSABLE_ID: ResolvedTypeId = + ResolvedTypeId::new(GLOBAL_LEVEL, ASYNC_DISPOSABLE_ID); diff --git a/crates/biome_js_type_info/src/local_inference.rs b/crates/biome_js_type_info/src/local_inference.rs index 14b691386ef8..e7030406b5d5 100644 --- a/crates/biome_js_type_info/src/local_inference.rs +++ b/crates/biome_js_type_info/src/local_inference.rs @@ -3,11 +3,12 @@ use std::str::FromStr; use biome_js_syntax::{ AnyJsArrayBindingPatternElement, AnyJsArrayElement, AnyJsArrowFunctionParameters, AnyJsBinding, - AnyJsBindingPattern, AnyJsCallArgument, AnyJsClassMember, AnyJsConstructorParameter, - AnyJsDeclaration, AnyJsDeclarationClause, AnyJsExportDefaultDeclaration, AnyJsExpression, - AnyJsFormalParameter, AnyJsFunction, AnyJsFunctionBody, AnyJsLiteralExpression, AnyJsName, - AnyJsObjectBindingPatternMember, AnyJsObjectMember, AnyJsObjectMemberName, AnyJsParameter, - AnyTsModuleName, AnyTsName, AnyTsReturnType, AnyTsTupleTypeElement, AnyTsType, AnyTsTypeMember, + AnyJsBindingPattern, AnyJsCallArgument, AnyJsClassMember, AnyJsClassMemberName, + AnyJsConstructorParameter, AnyJsDeclaration, AnyJsDeclarationClause, + AnyJsExportDefaultDeclaration, AnyJsExpression, AnyJsFormalParameter, AnyJsFunction, + AnyJsFunctionBody, AnyJsLiteralExpression, AnyJsName, AnyJsObjectBindingPatternMember, + AnyJsObjectMember, AnyJsObjectMemberName, AnyJsParameter, AnyTsModuleName, AnyTsName, + AnyTsReturnType, AnyTsTupleTypeElement, AnyTsType, AnyTsTypeMember, AnyTsTypePredicateParameterName, ClassMemberName, JsArrayBindingPattern, JsArrowFunctionExpression, JsBinaryExpression, JsBinaryOperator, JsCallArguments, JsClassDeclaration, JsClassExportDefaultDeclaration, JsClassExpression, JsClassMemberList, @@ -1837,40 +1838,38 @@ impl TypeMember { ty: ty.into(), }) } - AnyJsClassMember::JsMethodClassMember(member) => { - member.name().ok().and_then(|name| name.name()).map(|name| { - let is_async = member.async_token().is_some(); - let function = Function { + AnyJsClassMember::JsMethodClassMember(member) => member.name().ok().and_then(|name| { + let is_async = member.async_token().is_some(); + let function = Function { + is_async, + type_parameters: generic_params_from_ts_type_params( + resolver, + scope_id, + member.type_parameters(), + ), + name: name.name().map(text_from_class_member_name), + parameters: function_params_from_js_params( + resolver, + scope_id, + member.parameters(), + ), + return_type: function_return_type( + resolver, + scope_id, is_async, - type_parameters: generic_params_from_ts_type_params( - resolver, - scope_id, - member.type_parameters(), - ), - name: Some(text_from_class_member_name(name.clone())), - parameters: function_params_from_js_params( - resolver, - scope_id, - member.parameters(), - ), - return_type: function_return_type( - resolver, - scope_id, - is_async, - member.return_type_annotation(), - member.body().ok().map(AnyJsFunctionBody::JsFunctionBody), - ), - }; - let ty = resolver.register_and_resolve(function.into()); - let is_static = member - .modifiers() - .into_iter() - .any(|modifier| modifier.as_js_static_modifier().is_some()); - Self::from_class_member_info(resolver, name, ty.into(), is_static, false) - }) - } + member.return_type_annotation(), + member.body().ok().map(AnyJsFunctionBody::JsFunctionBody), + ), + }; + let ty = resolver.register_and_resolve(function.into()); + let is_static = member + .modifiers() + .into_iter() + .any(|modifier| modifier.as_js_static_modifier().is_some()); + Self::from_class_member_info(resolver, scope_id, name, ty.into(), is_static, false) + }), AnyJsClassMember::JsPropertyClassMember(member) => { - member.name().ok().and_then(|name| name.name()).map(|name| { + member.name().ok().and_then(|name| { let ty = match member .property_annotation() .and_then(|annotation| annotation.type_annotation().ok()) @@ -1893,7 +1892,14 @@ impl TypeMember { .as_ref() .and_then(|annotation| annotation.as_ts_optional_property_annotation()) .is_some(); - Self::from_class_member_info(resolver, name, ty, is_static, is_optional) + Self::from_class_member_info( + resolver, + scope_id, + name, + ty, + is_static, + is_optional, + ) }) } AnyJsClassMember::JsGetterClassMember(member) => { @@ -1917,11 +1923,8 @@ impl TypeMember { } }) } - AnyJsClassMember::TsInitializedPropertySignatureClassMember(member) => member - .name() - .ok() - .and_then(|name| name.name()) - .and_then(|name| { + AnyJsClassMember::TsInitializedPropertySignatureClassMember(member) => { + member.name().ok().and_then(|name| { let ty = resolver.reference_to_resolved_expression( scope_id, &member.value().ok()?.expression().ok()?, @@ -1931,16 +1934,18 @@ impl TypeMember { .into_iter() .any(|modifier| modifier.as_js_static_modifier().is_some()); let is_optional = member.question_mark_token().is_some(); - Some(Self::from_class_member_info( + Self::from_class_member_info( resolver, + scope_id, name, ty, is_static, is_optional, - )) - }), + ) + }) + } AnyJsClassMember::TsPropertySignatureClassMember(member) => { - member.name().ok().and_then(|name| name.name()).map(|name| { + member.name().ok().and_then(|name| { let ty = member .property_annotation() .and_then(|annotation| annotation.type_annotation().ok()) @@ -1957,7 +1962,14 @@ impl TypeMember { .as_ref() .and_then(|annotation| annotation.as_ts_optional_property_annotation()) .is_some(); - Self::from_class_member_info(resolver, name, ty, is_static, is_optional) + Self::from_class_member_info( + resolver, + scope_id, + name, + ty, + is_static, + is_optional, + ) }) } _ => { @@ -1994,8 +2006,24 @@ impl TypeMember { } }) } - AnyJsObjectMember::JsMethodObjectMember(member) => { - member.name().ok().and_then(|name| name.name()).map(|name| { + AnyJsObjectMember::JsMethodObjectMember(member) => member + .name() + .ok() + .and_then(|name| match name { + AnyJsObjectMemberName::JsComputedMemberName(name) => { + name.expression().ok().map(|expr| { + TypeMemberKind::IndexSignature(TypeReference::from_any_js_expression( + resolver, scope_id, &expr, + )) + }) + } + AnyJsObjectMemberName::JsLiteralMemberName(name) => name + .name() + .ok() + .map(|name| TypeMemberKind::Named(name.into())), + _ => None, + }) + .map(|kind| { let is_async = member.async_token().is_some(); let function = Function { is_async, @@ -2004,7 +2032,10 @@ impl TypeMember { scope_id, member.type_parameters(), ), - name: Some(name.clone().into()), + name: match &kind { + TypeMemberKind::Named(name) => Some(name.clone()), + _ => None, + }, parameters: function_params_from_js_params( resolver, scope_id, @@ -2019,17 +2050,29 @@ impl TypeMember { ), }; Self { - kind: TypeMemberKind::Named(name.into()), + kind, ty: resolver.register_and_resolve(function.into()).into(), } - }) - } + }), AnyJsObjectMember::JsPropertyObjectMember(member) => member .name() .ok() - .and_then(|name| name.name()) - .map(|name| Self { - kind: TypeMemberKind::Named(name.into()), + .and_then(|name| match name { + AnyJsObjectMemberName::JsComputedMemberName(name) => { + name.expression().ok().map(|expr| { + TypeMemberKind::IndexSignature(TypeReference::from_any_js_expression( + resolver, scope_id, &expr, + )) + }) + } + AnyJsObjectMemberName::JsLiteralMemberName(name) => name + .name() + .ok() + .map(|name| TypeMemberKind::Named(name.into())), + _ => None, + }) + .map(|kind| Self { + kind, ty: member .value() .map(|value| resolver.reference_to_resolved_expression(scope_id, &value)) @@ -2198,18 +2241,28 @@ impl TypeMember { #[inline] fn from_class_member_info( resolver: &mut dyn TypeResolver, - name: ClassMemberName, + scope_id: ScopeId, + name: AnyJsClassMemberName, ty: TypeReference, is_static: bool, is_optional: bool, - ) -> Self { - let name = text_from_class_member_name(name); - Self { - kind: if is_static { - TypeMemberKind::NamedStatic(name) - } else { - TypeMemberKind::Named(name) - }, + ) -> Option { + let kind = match name { + AnyJsClassMemberName::JsComputedMemberName(name) => TypeMemberKind::IndexSignature( + TypeReference::from_any_js_expression(resolver, scope_id, &name.expression().ok()?), + ), + _ => { + let name = text_from_class_member_name(name.name()?); + if is_static { + TypeMemberKind::NamedStatic(name) + } else { + TypeMemberKind::Named(name) + } + } + }; + + Some(Self { + kind, ty: match is_optional { true => { let id = resolver.optional(ty); @@ -2217,7 +2270,7 @@ impl TypeMember { } false => ty, }, - } + }) } #[inline] diff --git a/crates/biome_js_type_info/src/type.rs b/crates/biome_js_type_info/src/type.rs index 2bb4851dd915..7c135e2cfae7 100644 --- a/crates/biome_js_type_info/src/type.rs +++ b/crates/biome_js_type_info/src/type.rs @@ -17,7 +17,9 @@ use crate::{ GLOBAL_RESOLVER, Literal, ResolvedTypeData, ResolvedTypeId, ResolvedTypeMember, TypeData, TypeId, TypeReference, TypeResolver, UNKNOWN_DATA, globals::{ - GLOBAL_ARRAY_ID, GLOBAL_NUMBER_ID, GLOBAL_PROMISE_ID, GLOBAL_STRING_ID, GLOBAL_UNKNOWN_ID, + GLOBAL_ARRAY_ID, GLOBAL_ASYNC_DISPOSABLE_ID, GLOBAL_DISPOSABLE_ID, GLOBAL_NUMBER_ID, + GLOBAL_PROMISE_ID, GLOBAL_STRING_ID, GLOBAL_SYMBOL_ASYNC_DISPOSE_ID, + GLOBAL_SYMBOL_DISPOSE_ID, GLOBAL_UNKNOWN_ID, }, }; @@ -247,6 +249,38 @@ impl Type { }) } + pub fn is_disposable(&self) -> bool { + if self.id == GLOBAL_DISPOSABLE_ID { + return true; + } + + self.resolved_data().is_some_and(|ty| { + ty.find_member(self.resolver.as_ref(), |member| { + member.is_index_signature_with_ty(|ty| { + self.resolve(ty) + .is_some_and(|ty| ty.id == GLOBAL_SYMBOL_DISPOSE_ID) + }) + }) + .is_some() + }) + } + + pub fn is_async_disposable(&self) -> bool { + if self.id == GLOBAL_ASYNC_DISPOSABLE_ID { + return true; + } + + self.resolved_data().is_some_and(|ty| { + ty.find_member(self.resolver.as_ref(), |member| { + member.is_index_signature_with_ty(|ty| { + self.resolve(ty) + .is_some_and(|ty| ty.id == GLOBAL_SYMBOL_ASYNC_DISPOSE_ID) + }) + }) + .is_some() + }) + } + pub fn resolve(&self, ty: &TypeReference) -> Option { self.resolver .resolve_reference(&self.id.apply_module_id_to_reference(ty)) diff --git a/crates/biome_js_type_info/src/type_data.rs b/crates/biome_js_type_info/src/type_data.rs index 60231e01fa19..aa5f4d745b18 100644 --- a/crates/biome_js_type_info/src/type_data.rs +++ b/crates/biome_js_type_info/src/type_data.rs @@ -1449,6 +1449,39 @@ impl TypeReferenceQualifier { self.path.is_identifier("RegExp") } + /// Checks whether this type qualifier references the `Symbol` type. + /// + /// This method simply checks whether the reference is for a literal + /// `Symbol`, without considering whether another symbol named `Symbol` is + /// in scope. It can be used _after_ type resolution has failed to find a + /// `Symbol` symbol in scope, but should not be used _instead of_ such type + /// resolution. + pub fn is_symbol(&self) -> bool { + self.path.is_identifier("Symbol") + } + + /// Checks whether this type qualifier references the `Disposable` type. + /// + /// This method simply checks whether the reference is for a literal + /// `Disposable`, without considering whether another symbol named `Disposable` is + /// in scope. It can be used _after_ type resolution has failed to find a + /// `Disposable` symbol in scope, but should not be used _instead of_ such type + /// resolution. + pub fn is_disposable(&self) -> bool { + self.path.is_identifier("Disposable") + } + + /// Checks whether this type qualifier references the `AsyncDisposable` type. + /// + /// This method simply checks whether the reference is for a literal + /// `AsyncDisposable`, without considering whether another symbol named `AsyncDisposable` is + /// in scope. It can be used _after_ type resolution has failed to find a + /// `AsyncDisposable` symbol in scope, but should not be used _instead of_ such type + /// resolution. + pub fn is_async_disposable(&self) -> bool { + self.path.is_identifier("AsyncDisposable") + } + pub fn with_excluded_binding_id(mut self, binding_id: BindingId) -> Self { self.excluded_binding_id = Some(binding_id); self diff --git a/crates/biome_js_type_info/tests/resolver.rs b/crates/biome_js_type_info/tests/resolver.rs index 0f642011a361..c0e6d4fd1ad7 100644 --- a/crates/biome_js_type_info/tests/resolver.rs +++ b/crates/biome_js_type_info/tests/resolver.rs @@ -212,6 +212,98 @@ fn infer_resolved_type_of_destructured_array_element() { ); } +#[test] +fn infer_resolved_type_of_disposable_object() { + const CODE: &str = r#"const a = { + [Symbol.dispose](): void { + // do something + } + };"#; + + let root = parse_ts(CODE); + let decl = get_variable_declaration(&root); + let mut resolver = GlobalsResolver::default(); + let bindings = TypeData::typed_bindings_from_js_variable_declaration( + &mut resolver, + ScopeId::GLOBAL, + &decl, + ); + resolver.resolve_all(); + + assert_typed_bindings_snapshot( + CODE, + &bindings, + &resolver, + "infer_resolved_type_of_disposable_object", + ); +} + +#[test] +fn infer_resolved_type_of_async_disposable_object() { + const CODE: &str = r#"const a = { + [Symbol.asyncDispose](): void { + // do something + } + };"#; + + let root = parse_ts(CODE); + let decl = get_variable_declaration(&root); + let mut resolver = GlobalsResolver::default(); + let bindings = TypeData::typed_bindings_from_js_variable_declaration( + &mut resolver, + ScopeId::GLOBAL, + &decl, + ); + resolver.resolve_all(); + + assert_typed_bindings_snapshot( + CODE, + &bindings, + &resolver, + "infer_resolved_type_of_async_disposable_object", + ); +} + +#[test] +fn infer_resolved_type_of_disposable_returning_function() { + const CODE: &str = r#"function returnsDisposable(): Disposable { + return {}; +}"#; + + let root = parse_ts(CODE); + let decl = get_function_declaration(&root); + let mut resolver = GlobalsResolver::default(); + let ty = TypeData::from_js_function_declaration(&mut resolver, ScopeId::GLOBAL, &decl); + resolver.resolve_all(); + + assert_type_data_snapshot( + CODE, + &ty, + &resolver, + "infer_resolved_type_of_disposable_returning_function", + ) +} + +#[test] +fn infer_resolved_type_of_async_disposable_returning_function() { + const CODE: &str = r#"function returnsAsyncDisposable(): AsyncDisposable { + return {}; +}"#; + + let root = parse_ts(CODE); + let decl = get_function_declaration(&root); + let mut resolver = GlobalsResolver::default(); + let ty = TypeData::from_js_function_declaration(&mut resolver, ScopeId::GLOBAL, &decl); + resolver.resolve_all(); + + assert_type_data_snapshot( + CODE, + &ty, + &resolver, + "infer_resolved_type_of_async_disposable_returning_function", + ) +} + pub fn get_expression_statement(root: &AnyJsRoot) -> JsExpressionStatement { let module = root.as_js_module().unwrap(); module diff --git a/crates/biome_js_type_info/tests/snapshots/infer_resolved_type_of_async_disposable_object.snap b/crates/biome_js_type_info/tests/snapshots/infer_resolved_type_of_async_disposable_object.snap new file mode 100644 index 000000000000..f757012974f1 --- /dev/null +++ b/crates/biome_js_type_info/tests/snapshots/infer_resolved_type_of_async_disposable_object.snap @@ -0,0 +1,35 @@ +--- +source: crates/biome_js_type_info/tests/utils.rs +expression: content +--- +## Input + +```ts +const a = { + [Symbol.asyncDispose](): void { + // do something + }, +}; + +``` + +## Result + +``` +a => Object { + prototype: No prototype + members: [[Global TypeId(0)]: Disposable[Symbol.dispose]] +} + +``` + +## Registered types + +``` +Global TypeId(0) => Symbol.asyncDispose + +Global TypeId(1) => Object { + prototype: No prototype + members: [[Global TypeId(0)]: Disposable[Symbol.dispose]] +} +``` diff --git a/crates/biome_js_type_info/tests/snapshots/infer_resolved_type_of_async_disposable_returning_function.snap b/crates/biome_js_type_info/tests/snapshots/infer_resolved_type_of_async_disposable_returning_function.snap new file mode 100644 index 000000000000..31ecccd0480d --- /dev/null +++ b/crates/biome_js_type_info/tests/snapshots/infer_resolved_type_of_async_disposable_returning_function.snap @@ -0,0 +1,30 @@ +--- +source: crates/biome_js_type_info/tests/utils.rs +expression: content +--- +## Input + +```ts +function returnsAsyncDisposable(): AsyncDisposable { + return {}; +} + +``` + +## Result + +``` +sync Function "returnsAsyncDisposable" { + accepts: { + params: [] + type_args: [] + } + returns: Global TypeId(0) +} +``` + +## Registered types + +``` +Global TypeId(0) => instanceof AsyncDisposable +``` diff --git a/crates/biome_js_type_info/tests/snapshots/infer_resolved_type_of_disposable_object.snap b/crates/biome_js_type_info/tests/snapshots/infer_resolved_type_of_disposable_object.snap new file mode 100644 index 000000000000..2d1f366fee4e --- /dev/null +++ b/crates/biome_js_type_info/tests/snapshots/infer_resolved_type_of_disposable_object.snap @@ -0,0 +1,35 @@ +--- +source: crates/biome_js_type_info/tests/utils.rs +expression: content +--- +## Input + +```ts +const a = { + [Symbol.dispose](): void { + // do something + }, +}; + +``` + +## Result + +``` +a => Object { + prototype: No prototype + members: [[Global TypeId(0)]: Disposable[Symbol.dispose]] +} + +``` + +## Registered types + +``` +Global TypeId(0) => Symbol.dispose + +Global TypeId(1) => Object { + prototype: No prototype + members: [[Global TypeId(0)]: Disposable[Symbol.dispose]] +} +``` diff --git a/crates/biome_js_type_info/tests/snapshots/infer_resolved_type_of_disposable_returning_function.snap b/crates/biome_js_type_info/tests/snapshots/infer_resolved_type_of_disposable_returning_function.snap new file mode 100644 index 000000000000..ce1a20e367da --- /dev/null +++ b/crates/biome_js_type_info/tests/snapshots/infer_resolved_type_of_disposable_returning_function.snap @@ -0,0 +1,30 @@ +--- +source: crates/biome_js_type_info/tests/utils.rs +expression: content +--- +## Input + +```ts +function returnsDisposable(): Disposable { + return {}; +} + +``` + +## Result + +``` +sync Function "returnsDisposable" { + accepts: { + params: [] + type_args: [] + } + returns: Global TypeId(0) +} +``` + +## Registered types + +``` +Global TypeId(0) => instanceof Disposable +``` diff --git a/crates/biome_rule_options/src/lib.rs b/crates/biome_rule_options/src/lib.rs index 8c86597772fb..188079280e25 100644 --- a/crates/biome_rule_options/src/lib.rs +++ b/crates/biome_rule_options/src/lib.rs @@ -348,6 +348,7 @@ pub mod use_default_switch_clause_last; pub mod use_deprecated_date; pub mod use_deprecated_reason; pub mod use_destructuring; +pub mod use_disposables; pub mod use_enum_initializers; pub mod use_error_cause; pub mod use_error_message; diff --git a/crates/biome_rule_options/src/use_disposables.rs b/crates/biome_rule_options/src/use_disposables.rs new file mode 100644 index 000000000000..a4adea88be70 --- /dev/null +++ b/crates/biome_rule_options/src/use_disposables.rs @@ -0,0 +1,6 @@ +use biome_deserialize_macros::{Deserializable, Merge}; +use serde::{Deserialize, Serialize}; +#[derive(Default, Clone, Debug, Deserialize, Deserializable, Merge, Eq, PartialEq, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields, default)] +pub struct UseDisposablesOptions {} diff --git a/packages/@biomejs/backend-jsonrpc/src/workspace.ts b/packages/@biomejs/backend-jsonrpc/src/workspace.ts index d7752ffba81a..62fa006e514d 100644 --- a/packages/@biomejs/backend-jsonrpc/src/workspace.ts +++ b/packages/@biomejs/backend-jsonrpc/src/workspace.ts @@ -2416,6 +2416,11 @@ See https://biomejs.dev/linter/rules/use-destructuring */ useDestructuring?: UseDestructuringConfiguration; /** + * Detects a disposable object assigned to a variable without using or await using syntax. +See https://biomejs.dev/linter/rules/use-disposables + */ + useDisposables?: UseDisposablesConfiguration; + /** * Enforce that new Error() is thrown with the original error as cause. See https://biomejs.dev/linter/rules/use-error-cause */ @@ -4348,6 +4353,9 @@ export type UseConsistentTestItConfiguration = export type UseDestructuringConfiguration = | RulePlainConfiguration | RuleWithUseDestructuringOptions; +export type UseDisposablesConfiguration = + | RulePlainConfiguration + | RuleWithUseDisposablesOptions; export type UseErrorCauseConfiguration = | RulePlainConfiguration | RuleWithUseErrorCauseOptions; @@ -6102,6 +6110,11 @@ export interface RuleWithUseDestructuringOptions { level: RulePlainConfiguration; options?: UseDestructuringOptions; } +export interface RuleWithUseDisposablesOptions { + fix?: FixKind; + level: RulePlainConfiguration; + options?: UseDisposablesOptions; +} export interface RuleWithUseErrorCauseOptions { level: RulePlainConfiguration; options?: UseErrorCauseOptions; @@ -7682,6 +7695,7 @@ Default: `"it"` withinDescribe?: TestFunctionKind; } export type UseDestructuringOptions = {}; +export type UseDisposablesOptions = {}; /** * Options for the `useErrorCause` rule. */ @@ -8715,6 +8729,7 @@ export type Category = | "lint/nursery/useConsistentObjectDefinition" | "lint/nursery/useConsistentTestIt" | "lint/nursery/useDestructuring" + | "lint/nursery/useDisposables" | "lint/nursery/useErrorCause" | "lint/nursery/useExhaustiveSwitchCases" | "lint/nursery/useExpect" diff --git a/packages/@biomejs/biome/configuration_schema.json b/packages/@biomejs/biome/configuration_schema.json index 0cefe1396819..f8dcb32b3125 100644 --- a/packages/@biomejs/biome/configuration_schema.json +++ b/packages/@biomejs/biome/configuration_schema.json @@ -6440,6 +6440,13 @@ { "type": "null" } ] }, + "useDisposables": { + "description": "Detects a disposable object assigned to a variable without using or await using syntax.\nSee https://biomejs.dev/linter/rules/use-disposables", + "anyOf": [ + { "$ref": "#/$defs/UseDisposablesConfiguration" }, + { "type": "null" } + ] + }, "useErrorCause": { "description": "Enforce that new Error() is thrown with the original error as cause.\nSee https://biomejs.dev/linter/rules/use-error-cause", "anyOf": [ @@ -10506,6 +10513,16 @@ "additionalProperties": false, "required": ["level"] }, + "RuleWithUseDisposablesOptions": { + "type": "object", + "properties": { + "fix": { "anyOf": [{ "$ref": "#/$defs/FixKind" }, { "type": "null" }] }, + "level": { "$ref": "#/$defs/RulePlainConfiguration" }, + "options": { "$ref": "#/$defs/UseDisposablesOptions" } + }, + "additionalProperties": false, + "required": ["level"] + }, "RuleWithUseEnumInitializersOptions": { "type": "object", "properties": { @@ -13769,6 +13786,16 @@ "type": "object", "additionalProperties": false }, + "UseDisposablesConfiguration": { + "oneOf": [ + { "$ref": "#/$defs/RulePlainConfiguration" }, + { "$ref": "#/$defs/RuleWithUseDisposablesOptions" } + ] + }, + "UseDisposablesOptions": { + "type": "object", + "additionalProperties": false + }, "UseEnumInitializersConfiguration": { "oneOf": [ { "$ref": "#/$defs/RulePlainConfiguration" },