Skip to content

Commit

Permalink
refactor(lint/noEmptyInterface): ignore empty interface that extends …
Browse files Browse the repository at this point in the history
…a type (#1289)
  • Loading branch information
Conaclos authored Dec 22, 2023
1 parent 90e1a83 commit 00bf6d9
Show file tree
Hide file tree
Showing 10 changed files with 93 additions and 330 deletions.
10 changes: 5 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,23 +94,23 @@ is enabled to process only the files that were changed. Contributed by @simonxab

#### Enhancements

#### Bug fixes

- Fix [#959](https://github.com/biomejs/biome/issues/959). [noEmptyInterface](https://biomejs.dev/linter/rules/no-empty-interface) no longer reports interface that extends a type and is in an external module or a global declaration. Contributed by @Conaclos
- Address [#959](https://github.com/biomejs/biome/issues/959) and [#1157](https://github.com/biomejs/biome/issues/1157). [noEmptyInterface](https://biomejs.dev/linter/rules/no-empty-interface) no longer reports empty interfaces that extend a type. Contributed by @Conaclos

Empty interface that extends a type are sometimes used to extend an existing interface.
This is generally used to extend an interface of an external module.
This allows supporting interface augmentation in external modules as demonstrated in the following example:

```ts
interface Extension {
metadata: unknown;
}

declare module "@external/module" {
// Empty interface that extends a type.
export interface ExistingInterface extends Extension {}
}
```

#### Bug fixes

- Fix [#1061](https://github.com/biomejs/biome/issues/1061). [noRedeclare](https://biomejs.dev/linter/rules/no-redeclare) no longer reports overloads of `export default function`. Contributed by @Conaclos

The following code is no longer reported:
Expand Down
128 changes: 29 additions & 99 deletions crates/biome_js_analyze/src/analyzers/suspicious/no_empty_interface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,19 @@ use biome_js_factory::{
syntax::{AnyTsType, T},
};
use biome_js_syntax::{
AnyJsDeclarationClause, JsSyntaxKind, TriviaPieceKind, TsInterfaceDeclaration,
TsTypeAliasDeclaration,
AnyJsDeclarationClause, TriviaPieceKind, TsInterfaceDeclaration, TsTypeAliasDeclaration,
};
use biome_rowan::{AstNode, AstNodeList, AstSeparatedList, BatchMutationExt};
use biome_rowan::{AstNode, AstNodeList, BatchMutationExt, SyntaxResult};

declare_rule! {
/// Disallow the declaration of empty interfaces.
///
/// An empty interface in TypeScript does very little: any non-nullable value is assignable to `{}`.
/// Using an empty interface is often a sign of programmer error, such as misunderstanding the concept of `{}` or forgetting to fill in fields.
///
/// Source: https://typescript-eslint.io/rules/no-empty-interface
/// The rule ignores empty interfaces that `extends` one or multiple types.
///
/// Inspired by: https://typescript-eslint.io/rules/no-empty-interface
///
/// ## Examples
///
Expand All @@ -29,24 +30,15 @@ declare_rule! {
/// interface A {}
/// ```
///
/// ```ts,expect_diagnostic
/// interface A extends B {}
/// ```
///
/// ### Valid
///
/// ```ts
/// interface A {
/// prop: string;
/// }
///
/// // Allow empty interfaces that extend at least two types.
/// interface A extends B, C {}
///
/// declare module "@external/module" {
/// // Allow empty interfaces that extend at least one type in external module.
/// interface Existing extends A {}
/// }
/// // Allow empty interfaces that extend a type.
/// interface B extends A {}
/// ```
pub(crate) NoEmptyInterface {
version: "1.0.0",
Expand All @@ -56,104 +48,45 @@ declare_rule! {
}
}

pub enum DiagnosticMessage {
NoEmptyInterface,
NoEmptyInterfaceWithSuper,
}

impl DiagnosticMessage {
/// Convert a [DiagnosticMessage] to a string
fn as_str(&self) -> &'static str {
match self {
Self::NoEmptyInterface => "An empty interface is equivalent to '{}'.",
Self::NoEmptyInterfaceWithSuper => {
"An interface declaring no members is equivalent to its supertype."
}
}
}

/// Retrieves a [TsTypeAliasDeclaration] from a [DiagnosticMessage] that will be used to
/// replace it on the rule action
fn fix_with(&self, node: &TsInterfaceDeclaration) -> Option<TsTypeAliasDeclaration> {
match self {
Self::NoEmptyInterface => make_type_alias_from_interface(
node,
AnyTsType::from(make::ts_object_type(
make::token(T!['{']),
make::ts_type_member_list([]),
make::token(T!['}']),
)),
),
Self::NoEmptyInterfaceWithSuper => {
let super_interface = node.extends_clause()?.types().into_iter().next()?.ok()?;
let type_arguments = super_interface.type_arguments();
let ts_reference_type = make::ts_reference_type(super_interface.name().ok()?);

let ts_reference_type = if type_arguments.is_some() {
ts_reference_type
.with_type_arguments(type_arguments?)
.build()
} else {
ts_reference_type.build()
};

make_type_alias_from_interface(node, AnyTsType::from(ts_reference_type))
}
}
}
}

impl Rule for NoEmptyInterface {
type Query = Ast<TsInterfaceDeclaration>;
type State = DiagnosticMessage;
type State = ();
type Signals = Option<Self::State>;
type Options = ();

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let node = ctx.query();
if !node.members().is_empty() {
return None;
}
let Some(extends_clause) = node.extends_clause() else {
return Some(DiagnosticMessage::NoEmptyInterface);
};
if node.syntax().ancestors().skip(1).any(|x| {
matches!(
x.kind(),
JsSyntaxKind::TS_EXTERNAL_MODULE_DECLARATION | JsSyntaxKind::TS_GLOBAL_DECLARATION
)
}) {
// Ignore interfaces that extend a type in an external module declaration.
// The interface can be merged with an existing interface.
return None;
}
if extends_clause.types().len() == 1 {
return Some(DiagnosticMessage::NoEmptyInterfaceWithSuper);
}
None
(node.members().is_empty() && node.extends_clause().is_none()).then_some(())
}

fn diagnostic(ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
fn diagnostic(ctx: &RuleContext<Self>, _: &Self::State) -> Option<RuleDiagnostic> {
Some(RuleDiagnostic::new(
rule_category!(),
ctx.query().range(),
state.as_str(),
markup! { "An "<Emphasis>"empty interface"</Emphasis>" is equivalent to "<Emphasis>"{}"</Emphasis>"." },
))
}

fn action(ctx: &RuleContext<Self>, state: &Self::State) -> Option<JsRuleAction> {
fn action(ctx: &RuleContext<Self>, _: &Self::State) -> Option<JsRuleAction> {
let mut mutation = ctx.root().begin();
let node = ctx.query();

let new_node = make_type_alias_from_interface(
node,
AnyTsType::from(make::ts_object_type(
make::token(T!['{']),
make::ts_type_member_list([]),
make::token(T!['}']),
)),
)
.ok()?;
mutation.replace_node(
AnyJsDeclarationClause::from(node.clone()),
AnyJsDeclarationClause::from(state.fix_with(node)?),
AnyJsDeclarationClause::from(new_node),
);

Some(JsRuleAction {
category: ActionCategory::QuickFix,
applicability: Applicability::Always,
message: markup! { "Convert empty interface to type alias." }.to_owned(),
message: markup! { "Use a type alias instead." }.to_owned(),
mutation,
})
}
Expand All @@ -163,20 +96,17 @@ impl Rule for NoEmptyInterface {
fn make_type_alias_from_interface(
node: &TsInterfaceDeclaration,
ts_type: AnyTsType,
) -> Option<TsTypeAliasDeclaration> {
let type_params = node.type_parameters();
) -> SyntaxResult<TsTypeAliasDeclaration> {
let new_node = make::ts_type_alias_declaration(
make::token(T![type]).with_trailing_trivia([(TriviaPieceKind::Whitespace, " ")]),
node.id().ok()?,
node.id()?,
make::token(T![=]).with_trailing_trivia([(TriviaPieceKind::Whitespace, " ")]),
ts_type,
);

let new_node = if type_params.is_some() {
new_node.with_type_parameters(type_params?).build()
let new_node = if let Some(type_params) = node.type_parameters() {
new_node.with_type_parameters(type_params)
} else {
new_node.build()
new_node
};

Some(new_node)
Ok(new_node.build())
}
Original file line number Diff line number Diff line change
@@ -1,17 +1 @@
interface Baz extends Foo {}

interface Foo {}

interface Foo extends Array<number> {}

interface Foo extends Array<number | {}> {}

interface Foo<T> extends Bar<T> {}

declare module FooBar {
export interface Bar extends Baz {}
}

namespace Ns {
export interface Bar extends Baz {}
}
Loading

0 comments on commit 00bf6d9

Please sign in to comment.