Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add rule types #110

Merged
merged 17 commits into from
Oct 25, 2024
Merged

feat: Add rule types #110

merged 17 commits into from
Oct 25, 2024

Conversation

nzakas
Copy link
Member

@nzakas nzakas commented Aug 22, 2024

Prerequisites checklist

What is the purpose of this pull request?

Add types for rules that will work regardless of the language being used.

What changes did you make? (Give an overview)

I added several types that let us completely type check rules. These types are as generic as possible to allow for language plugins to override or better define their types, too.

These are not guaranteed to match the types in @types/eslint, which are very specific to the JavaScript language. The intent is to define something that will work and then see if we can backport into @types/eslint in a way that still works with the JavaScript rules.

Related Issues

Is there anything you'd like reviewers to focus on?

Does the naming make sense for the types? I tried to be more specific to avoid ambiguity (i.e., RuleFixer is now RuleTextEditor).

/**
* The definition of an ESLint rule.
*/
export interface RuleDefinition<T extends RuleVisitor = RuleVisitor> {
Copy link
Member Author

@nzakas nzakas Aug 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using a type parameter here allows language plugins to insert their own RuleVisitor interface for better type checking. I imagine we can turn the one in @types/eslint into a RuleVisitor so those definitions will still work.

This is really the only difference between rules for different languages.

@nzakas
Copy link
Member Author

nzakas commented Sep 3, 2024

ping @eslint/eslint-tsc @JoshuaKGoldberg

Copy link
Contributor

@JoshuaKGoldberg JoshuaKGoldberg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the delay, and thanks for the ping! I still don't feel very confident reviewing functionally (#73), but have made some educated inferences just for what already exists in ESLint.

eslint.config.js Outdated Show resolved Hide resolved
Comment on lines 287 to 293
export interface RuleTextEditor {
/**
* Inserts text after the specified node or token.
* @param nodeOrToken The node or token to insert after.
* @param text The edit to insert after the node or token.
*/
insertTextAfter(nodeOrToken: object, text: string): RuleTextEdit;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PRs in this repo are probably the heaviest users of : object I've seen this year 😄. I'm getting the impression types using them are split across two use cases:

  • "Base" (generalized) types meant to be the constraints on type parameters, such as the current RuleVisitor
  • Generic types where there's a clear injectable, shared type

For "Base" (generalized) types, it makes sense to use types like object and unknown. It's also common to name them something like Any* or *Base to make it clear that they're base types, not the ones end users directly use. Some of the types here are named like that, but some aren't - is that intentional?

For generic types, it'd be more typical to use a type parameter. That way consumers of the type can substitute in their intended types, instead of having to do some extends strategy. Which would be very inconvenient for types like this with a lot of : objects!

Is there anything blocking making this type generic?

Suggested change
export interface RuleTextEditor {
/**
* Inserts text after the specified node or token.
* @param nodeOrToken The node or token to insert after.
* @param text The edit to insert after the node or token.
*/
insertTextAfter(nodeOrToken: object, text: string): RuleTextEdit;
export interface RuleTextEditor<NodeOrToken> {
/**
* Inserts text after the specified node or token.
* @param nodeOrToken The node or token to insert after.
* @param text The edit to insert after the node or token.
*/
insertTextAfter(nodeOrToken: NodeOrToken, text: string): RuleTextEdit;

Note that the same logic could be extended to any place that includes : object or : unknown.

  • ViolationLocation's node: which would make ViolationReport generic, which would make RuleContext generic.
  • RuleContext's options
  • RuleVisitor's node and parent - though that type looks like it might be more of a "Base" generalized type?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, there's only ever one instance of RuleTextEditor, which exists in the core and needs to work on everything...would that still be a case for a generic? 🤔

(Still trying to wrap my brain around this.)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type parameters are for when you want to substitute out different parts of a type for different uses. If there's only ever one instance of something, then you likely don't need type parameters - but then the type for nodeOrToken should be known as something more specific than object, right?

If the type for nodeOrToken is always going to be some known thing, then a different suggestion could be:

Suggested change
export interface RuleTextEditor {
/**
* Inserts text after the specified node or token.
* @param nodeOrToken The node or token to insert after.
* @param text The edit to insert after the node or token.
*/
insertTextAfter(nodeOrToken: object, text: string): RuleTextEdit;
// or some existing type, or some union thereof...
export interface EditableNodeOrToken {
/* ... */
}
export interface RuleTextEditor {
/**
* Inserts text after the specified node or token.
* @param nodeOrToken The node or token to insert after.
* @param text The edit to insert after the node or token.
*/
insertTextAfter(nodeOrToken: EditableNodeOrToken, text: string): RuleTextEdit;

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To fully flesh out what we have here: this same RuleTextEditor is used inside the core for applying fixes to any language that ESLint is linting (via a Language object). So for JavaScript, it works on ESTree(ish) nodes, while for Markdown it works on Unist nodes. While these two types of nodes only shared a type, there is no guarantee that any particular language will have that.

So, a rule is passing some representation of a node (or token) into these methods on RuleTextEditor. ESLint itself doesn't really need to know what those are, because we just pass them back into Language#getRange() to get the range information for that node or token.

🤔 I suppose the audience for types here would be the rule developer, who may not be able to tell what to pass in. In that case, I could see it being helpful to specify the type as a parameter...but that would then bubble up into RuleContext and then up to RuleDefinition. Maybe that's not a bad thing in TypeScript land?

Copy link
Contributor

@JoshuaKGoldberg JoshuaKGoldberg Sep 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 if the RuleContext cares about the type of nodes, and RuleDefinition therefore does as well, then it makes sense that RuleDefinition would have a type parameter for the kind of nodes.

In other words: if some consumer of RuleContext/RuleDefinition wanted to write a function that takes one of those in...

// js version
function workOnContextNode(context: RuleContext) {
 context.sourceCode.ast.exampleProperty; // (or some equivalent)
}

...then TS users will want to be able to have that be well-typed without an as.

interface MyLanguageRootAST {
  exampleProperty: true;
}

function workOnContextNode(context: RuleContext<MyLanguage>) {
 context.sourceCode.ast.exampleProperty;
}

...or at least that's what I see this as. Is that the right context?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so. Going to dig into this now a bit more.

/**
* Meta information about a rule.
*/
export interface RulesMeta {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the note on RuleTextEditor below, messages and could be made generic. We do this in typescript-eslint's types so that rule tests have a type error reported if an incorrect message ID is used.

For reference, https://github.com/typescript-eslint/typescript-eslint/blob/2ad3404690cd601d1bbb80daa7f861ab5bb127e4/packages/utils/src/eslint-utils/RuleCreator.ts#L32-L38:

export interface RuleWithMeta<
  Options extends readonly unknown[],
  MessageIds extends string,
  Docs = unknown,
> extends RuleCreateAndOptions<Options, MessageIds> {
  meta: RuleMetaData<MessageIds, Docs>;
}

That Docs generic is something to consider here too. Plugins can define their own docs, and we found often want to have their own properties on top of ESLint's. The RuleMetadata type for docs is an & intersection of the provided Docs and the ESLint standard RuleMetaDataDocs. https://typescript-eslint.io/blog/announcing-typescript-eslint-v8#custom-rule-metadocs-types

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll need to look at this a bit closer. The code snippet you pasted above makes my head hurt -- I have a hard time reasoning about what it actually means.

Copy link
Contributor

@JoshuaKGoldberg JoshuaKGoldberg Sep 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah we're not huge fans of how complicated it's gotten ourselves 😄. But such is the complexity we've had to go with. A general summary if it helps...

What's happening is that each RuleWithMeta has three "generic" parts that can be substituted in:

  • What Options the rule has specified, which must be* a readonly array
  • The MessageIds it can report, which must be* a string
  • The shape of Documentation, which doesn't need to be explicitly specified - it defaults to unknown

*"must be" here is "assignable to": as in, whatever type we provide must satisfy that type:

  • readonly unknown[] can be satisfied by, say, [] or [{ allowInterfaces: boolean; allowObjects: boolean; }]
  • MessageIds can be satisfied by, say, "noEmptyInterface" | "noEmptyObject"

It then extends the interface RuleCreateAndOptions, which defines the standard ESLint create() method and typescript-eslint's defaultOptions. Plus then it adds a meta object of type RuleMetaData.

Putting it all together, this is a rough subset of @typescript-eslint/no-empty-object-type:

type NoEmptyObjectType = RuleWithMeta<
  // Options
  [
    {
      allowInterfaces: boolean;
      allowObjects: boolean;
    }
  ],

  // MessageIds
  "noEmptyInterface" | "noEmptyObject",

  // Docs
  {
    recommended: 'recommended';
    requiresTypeChecking: true;
  }
>

packages/core/src/types.ts Outdated Show resolved Hide resolved
packages/core/src/types.ts Show resolved Hide resolved
Comment on lines 103 to 107
/**
* An object containing visitor information for a rule. Each method is either the
* name of a node type or a selector, or is a method that will be called at specific
* times during the traversal.
*/
export interface RuleVisitor {
/**
* Called for each node in the AST or at specific times during the traversal.
*/
[key: string]:
| ((node: unknown, parent?: unknown) => void)
| ((...unknown: unknown[]) => void);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How will this interface be used? Will it be extended to declare language-specific interfaces where node and parent have a particular type, or will rules simply create and return a RuleVisitor object?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's designed to be extended but still work as a default. (see the RuleDefinition interface)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to declare an interface that extends RuleVisitor with something more specific, but it's not clear to me how the TypeScript syntax would look like (Playground link).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A possible solution: we could use generics in place of the actual argument types, e.g.:

export interface RuleVisitor<NodeType = unknown, ArgsType extends unknown[] = unknown[]> {
	[key: string]:
		| ((node: NodeType, parent?: NodeType) => void)
		| ((...unknown: ArgsType) => void);
}

Implementors could specify those types explicitly:

interface MyRuleVisitor extends RuleVisitor<MyNode> {
	[key: string]: (node: MyNode, parent?: MyNode) => void;
}

or

interface MyRuleVisitor extends RuleVisitor<never, [string, number, boolean]> {
	[key: string]: (arg1: string, arg2: number, arg3: boolean) => void;
}

Playground link

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The expectation is that people who extend RuleVisitor will actually define the methods they want. These two methods are just fallbacks for cases where they miss something.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see. The problem is that if you extend RuleVisitor as it is currently defined, TypeScript won't let you implement just some methods and it will require you to implement an index signature (for [key: string]).

REPRO

Then maybe RuleVisitor should be just an empty interface so that it can be extended with arbitrary methods? I can add unit tests to verify that these types work as expected in a follow-up PR.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any value in having an empty interface? Isn't that just like putting any (or unknown)?

I was looking for something where everyone wouldn't need to create an index signature for their own rule visitor. For methods with esquery selectors, they'd need to override [key: string] to get a valid type for that kind of method.

Copy link
Member

@fasttime fasttime Sep 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's true, an empty interface can be assigned any value when the strictNullChecks TypeScript option is not set, so it would be only useful as an indicator for people who read the code.

Another option is keeping the index signature with a generic function type, for example:

export interface RuleVisitor {
  [key: string]: (...args: any) => void;
}

This would still not prevent implementors from adding methods that return non-void types.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with this.

@nzakas
Copy link
Member Author

nzakas commented Sep 25, 2024

I've updated the definitions for Language, SourceCode, RuleVisitor, RuleTextEditor, and friends to be generic. @JoshuaKGoldberg would appreciate your feedback on this new attempt.

Copy link
Contributor

@JoshuaKGoldberg JoshuaKGoldberg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK! Thanks for the ping. I took a dive and think this is all looking right. I can tell you took care to work with the types, and it's showing - this feels very clean 🙂.

I left a few comments or questions, but nothing I can find that should block.

@@ -309,11 +688,16 @@ interface InlineConfigElement {
/**
* Represents the basic interface for a source code object.
*/
interface SourceCodeBase {
interface SourceCodeBase<
LangOptions = LanguageOptions,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Question] Can LangOptions be something that isn't assignable to LanguageOptions? Put another way: is it intentional that there's no extends LanguageOptions here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LanguageOptions itself doesn't have any keys by default, so it seemed like extends LanguageOptions wasn't necessary?

I was actually trying to reason through this choice, too, so any insights appreciated.

* @param context The rule context.
* @returns The rule visitor.
*/
create(context: RuleContext<LangOptions, Code, RuleOptions>): Visitor;
Copy link
Contributor

@JoshuaKGoldberg JoshuaKGoldberg Sep 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

context: RuleContext<...>

[Question] This isn't passing an argument for the Node = unknown type parameter. Is it intentional that RuleDefinition wouldn't know about the type of nodes in its language?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not intentional, just missed it. Thanks!

Comment on lines 182 to 207
/**
* Represents the context object that is passed to a rule. This object contains
* information about the current state of the linting process and is the rule's
* view into the outside world.
*/
export interface RuleContext<
LangOptions = LanguageOptions,
Code extends SourceCode = SourceCode,
RuleOptions = unknown[],
Node = unknown,
> {
Copy link
Contributor

@JoshuaKGoldberg JoshuaKGoldberg Sep 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LangOptions, Code, RuleOptions, Node

[Comment] 👍 confirming that, as unwieldy as having four type parameters feels, I think this is the right way to represent things. Each rule definition is specific to the language, source code type, rule options, and language node.

I'll note that a somewhat-equivalent-ish syntax to represent passing many type parameters would be to pass a single type parameter extending a shape. Roughly:

Suggested change
/**
* Represents the context object that is passed to a rule. This object contains
* information about the current state of the linting process and is the rule's
* view into the outside world.
*/
export interface RuleContext<
LangOptions = LanguageOptions,
Code extends SourceCode = SourceCode,
RuleOptions = unknown[],
Node = unknown,
> {
export interface RuleContextSettings {
LangOptions: LanguageOptions;
Code?: SourceCode;
RuleOptions: unknown[];
Node?: unknown;
}
/**
* Represents the context object that is passed to a rule. This object contains
* information about the current state of the linting process and is the rule's
* view into the outside world.
*/
export interface RuleContext<Settings extends RuleContextSettings> {

...then later on referring to members of that type parameter:

- sourceCode: Code;
+ sourceCode: Settings["Code"];

It's conceptually like the refactor from a function with four parameter to a function with a single object parameter:

- function ruleContext(langOptions, code, ruleOptions, node) {
+ function ruleContext(settings) {

- langOptions;
+ settings.langOptions;

I've put up a comparison of what that could look like here: types-update-2...JoshuaKGoldberg:rewrite:types-update-settings-type-parameters.

Not saying either way is better, just showing another syntax as reference in case you prefer it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, interesting! I think I prefer the way it is, currently, but I consider myself educated. Thanks!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, would that mean the type parameters could be specified in any order?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah! And you're forced to use explicit names for them. RuleContext<{ Key: Type, ... }> instead of RuleContext<Type, ...>.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooh nice! Okay, going to change to that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've switched all the types with way-too-may generic parameters to this model. The downside (as far as I can tell), is that TypeScript then either requires all of the properties or none of them -- the default is only used if nothing is passed, so I'm unable to assign defaults for each property. 🤔

@nzakas
Copy link
Member Author

nzakas commented Oct 17, 2024

@fasttime @JoshuaKGoldberg can you take another look?

Copy link
Contributor

@JoshuaKGoldberg JoshuaKGoldberg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK! I have no more inputs on the TypeScript types. Nicely done 🙁. This is a much more complex deep dive into TypeScript than most folks would want to get into at this stage of onboarding, I think, so it's great to see it so fleshed out.

A lot of the nuances (catch-all interfaces, Options extends *Options, ...) will probably have to evolve over time as the team figures out its preferred TypeScript practices. I'm excited to see how that goes, it'll be interesting!

packages/core/src/types.ts Outdated Show resolved Hide resolved
packages/core/src/types.ts Outdated Show resolved Hide resolved
packages/core/src/types.ts Outdated Show resolved Hide resolved
packages/core/src/types.ts Outdated Show resolved Hide resolved
packages/core/src/types.ts Outdated Show resolved Hide resolved
packages/core/src/types.ts Outdated Show resolved Hide resolved
Copy link
Member

@fasttime fasttime left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks!

@fasttime fasttime merged commit ffa176f into main Oct 25, 2024
17 checks passed
@fasttime fasttime deleted the types-update-2 branch October 25, 2024 17:58
@github-actions github-actions bot mentioned this pull request Oct 25, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
Status: Complete
Development

Successfully merging this pull request may close these issues.

4 participants