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

fix: Use updated types from @eslint/core #66

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
1 change: 1 addition & 0 deletions jsr.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"include": [
"dist/esm/index.js",
"dist/esm/index.d.ts",
"dist/esm/types.ts",
"README.md",
"jsr.json",
"LICENSE"
Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"homepage": "https://github.com/eslint/json#readme",
"scripts": {
"build:dedupe-types": "node tools/dedupe-types.js dist/cjs/index.cjs dist/esm/index.js",
"build:cts": "node -e \"fs.copyFileSync('dist/esm/index.d.ts', 'dist/cjs/index.d.cts')\"",
"build:cts": "node tools/build-cts.js",
"build": "rollup -c && npm run build:dedupe-types && tsc -p tsconfig.esm.json && npm run build:cts",
"build:readme": "node tools/update-readme.js",
"test:jsr": "npx jsr@latest publish --dry-run",
Expand All @@ -68,7 +68,6 @@
"@humanwhocodes/momoa": "^3.3.4"
},
"devDependencies": {
"@types/eslint": "^8.56.10",
"c8": "^9.1.0",
"dedent": "^1.5.3",
"eslint": "^9.11.1",
Expand Down
10 changes: 10 additions & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import copy from "rollup-plugin-copy";

export default {
input: "src/index.js",
output: [
Expand All @@ -11,4 +13,12 @@ export default {
banner: '// @ts-self-types="./index.d.ts"',
},
],
plugins: [
copy({
targets: [
{ src: "src/types.ts", dest: "dist/cjs", rename: "types.cts" },
{ src: "src/types.ts", dest: "dist/esm" },
Copy link

@JoshuaKGoldberg JoshuaKGoldberg Dec 13, 2024

Choose a reason for hiding this comment

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

I now see dist/(cjs|esm)/types.d.ts and dst/(cjs|esm)/types.ts files locally. They're identical other than comments and an auto-generated export {}. I don't see anything importing from the types.d.ts. Is the duplication intentional?

FWIW I believe it's more common to have just .d.ts files. My instinct is that the expected path here would be to just have dist/(cjs|esm)/types.d.ts and typedefs use {import("./types.d.ts")}.

Copy link
Member Author

@nzakas nzakas Dec 16, 2024

Choose a reason for hiding this comment

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

Because we run tsc on dist/esm and dist/cjs, types.ts needs to be present in both of those directories in order for the project to compile. types.d.ts is output by tsc from types.ts

Choose a reason for hiding this comment

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

Gotcha. I tried renaming src/types.ts to src/types.d.ts with a find-and-replace. I think it's all working here: JoshuaKGoldberg@631810d. At least npm run build passes and the imports all work in editor.

],
}),
],
};
8 changes: 3 additions & 5 deletions src/languages/json-language.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,16 @@ import { visitorKeys } from "@humanwhocodes/momoa";
/** @typedef {import("@eslint/core").OkParseResult<DocumentNode>} OkParseResult */
/** @typedef {import("@eslint/core").ParseResult<DocumentNode>} ParseResult */
/** @typedef {import("@eslint/core").File} File */
/**
* @typedef {Object} JSONLanguageOptions
* @property {boolean} [allowTrailingCommas] Whether to allow trailing commas.
*/
/** @typedef {import("../types.ts").IJSONLanguage} IJSONLanguage */
/** @typedef {import("../types.ts").JSONLanguageOptions} JSONLanguageOptions */

//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------

/**
* JSON Language Object
* @implements {Language}
* @implements {IJSONLanguage}
*/
export class JSONLanguage {
/**
Expand Down
6 changes: 4 additions & 2 deletions src/languages/json-source-code.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ import {
/** @typedef {import("@eslint/core").SourceLocation} SourceLocation */
/** @typedef {import("@eslint/core").File} File */
/** @typedef {import("@eslint/core").TraversalStep} TraversalStep */
/** @typedef {import("@eslint/core").TextSourceCode} TextSourceCode */
/** @typedef {import("@eslint/core").VisitTraversalStep} VisitTraversalStep */
/** @typedef {import("@eslint/core").FileProblem} FileProblem */
/** @typedef {import("@eslint/core").DirectiveType} DirectiveType */
/** @typedef {import("@eslint/core").RulesConfig} RulesConfig */
/** @typedef {import("../types.ts").IJSONSourceCode} IJSONSourceCode */
/** @typedef {import("../types.ts").JSONSyntaxElement} JSONSyntaxElement */

//-----------------------------------------------------------------------------
// Helpers
Expand Down Expand Up @@ -71,6 +72,7 @@ class JSONTraversalStep extends VisitNodeStep {

/**
* JSON Source Code Object
* @implements {IJSONSourceCode}
*/
export class JSONSourceCode extends TextSourceCodeBase {
/**
Expand Down Expand Up @@ -147,7 +149,7 @@ export class JSONSourceCode extends TextSourceCodeBase {
);
}

return this.#inlineConfigComments;
return this.#inlineConfigComments ?? [];
}

/**
Expand Down
14 changes: 13 additions & 1 deletion src/rules/no-duplicate-keys.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,22 @@
* @author Nicholas C. Zakas
*/

//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------

/** @typedef {"duplicateKey"} NoDuplicateKeysMessageIds */
/** @typedef {import("../types.ts").JSONRuleDefinition<[], NoDuplicateKeysMessageIds>} NoDuplicateKeysRuleDefinition */
/** @typedef {import("@humanwhocodes/momoa").MemberNode} MemberNode */

//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------

/** @type {NoDuplicateKeysRuleDefinition} */
export default {
meta: {
type: /** @type {const} */ ("problem"),
type: "problem",

docs: {
description: "Disallow duplicate keys in JSON objects",
Expand All @@ -21,7 +30,10 @@ export default {
},

create(context) {
/** @type {Array<Map<string, MemberNode>|undefined>} */
const objectKeys = [];

/** @type {Map<string, MemberNode>|undefined} */
let keys;

return {
Expand Down
14 changes: 13 additions & 1 deletion src/rules/no-empty-keys.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,21 @@
* @author Nicholas C. Zakas
*/

//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------

/** @typedef {"emptyKey"} NoEmptyKeysMessageIds */
/** @typedef {import("../types.ts").JSONRuleDefinition<[], NoEmptyKeysMessageIds>} NoEmptyKeysRuleDefinition */

//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------

/** @type {NoEmptyKeysRuleDefinition} */
export default {
meta: {
type: /** @type {const} */ ("problem"),
type: "problem",

docs: {
description: "Disallow empty keys in JSON objects",
Expand Down
31 changes: 27 additions & 4 deletions src/rules/no-unsafe-values.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,36 @@
* @author Bradley Meck Farias
*/

// RFC 8259's `number` production, as a regex. Capture the integer part
// and the fractional part.
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------

/** @typedef {"unsafeNumber"|"unsafeInteger"|"unsafeZero"|"subnormal"|"loneSurrogate"} NoUnsafeValuesMessageIds */
/** @typedef {import("../types.ts").JSONRuleDefinition<[], NoUnsafeValuesMessageIds>} NoUnsafeValuesRuleDefinition */

//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------

/*
* This rule is based on the JSON grammar from RFC 8259, section 6.
* https://tools.ietf.org/html/rfc8259#section-6
*
* We separately capture the integer and fractional parts of a number, so that
* we can check for unsafe numbers that will evaluate to Infinity.
*/
const NUMBER =
/^-?(?<int>0|([1-9][0-9]*))(?:\.(?<frac>[0-9]+))?(?:[eE][+-]?[0-9]+)?$/u;
const NON_ZERO = /[1-9]/u;

//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------

/** @type {NoUnsafeValuesRuleDefinition} */
export default {
meta: {
type: /** @type {const} */ ("problem"),
type: "problem",

docs: {
description: "Disallow JSON values that are unsafe for interchange",
Expand Down Expand Up @@ -85,7 +106,9 @@ export default {
loc: node.loc,
messageId: "subnormal",
// Value included so that it's seen in scientific notation
data: node,
data: {
value,
},
});
}
}
Expand Down
128 changes: 128 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* @fileoverview Additional types for this package.
* @author Nicholas C. Zakas
*/

//------------------------------------------------------------------------------
// Imports
//------------------------------------------------------------------------------

import type {
RuleVisitor,
TextSourceCode,
Language,
LanguageOptions,
RuleDefinition,
} from "@eslint/core";
Comment on lines +10 to +16
Copy link
Member

Choose a reason for hiding this comment

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

@eslint/core and is imported in the types. Should it be a runtime dependency?

Copy link
Member Author

Choose a reason for hiding this comment

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

At this point I honestly don't understand when types are supposed to be a dev dependency vs. a runtime dependency. What would you do?

Choose a reason for hiding this comment

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

If references to @eslint/core are in the distributed .d.ts files, then I believe @eslint/core will need to be some kind of a runtime dependency. Otherwise end-users might import types from this package and get type errors on @eslint/core not being found.

It does feel weird making a types-only package be a runtime dependency. But 🤷 without first-party support from package managers on delineating "runtime" vs. "types-only" dependencies, it's all "runtime".

import {
DocumentNode,
MemberNode,
ElementNode,
ObjectNode,
ArrayNode,
StringNode,
NullNode,
NumberNode,
BooleanNode,
NaNNode,
InfinityNode,
IdentifierNode,
AnyNode,
Token,
} from "@humanwhocodes/momoa";

//------------------------------------------------------------------------------
// Types
//------------------------------------------------------------------------------

type ValueNodeParent = DocumentNode | MemberNode | ElementNode;

/**
* A JSON syntax element, including nodes and tokens.
*/
export type JSONSyntaxElement = Token | AnyNode;

/**
* Language options provided for JSON files.
*/
export interface JSONLanguageOptions extends LanguageOptions {
/**
* Whether to allow trailing commas. Only valid in JSONC.
*/
allowTrailingCommas?: boolean;
}

/**
* The visitor format returned from rules in this package.
*/
export interface JSONRuleVisitor extends RuleVisitor {
Document?(node: DocumentNode): void;
Member?(node: MemberNode, parent?: ObjectNode): void;
Element?(node: ElementNode, parent?: ArrayNode): void;
Object?(node: ObjectNode, parent?: ValueNodeParent): void;
Array?(node: ArrayNode, parent?: ValueNodeParent): void;
String?(node: StringNode, parent?: ValueNodeParent): void;
Null?(node: NullNode, parent?: ValueNodeParent): void;
Number?(node: NumberNode, parent?: ValueNodeParent): void;
Boolean?(node: BooleanNode, parent?: ValueNodeParent): void;
NaN?(node: NaNNode, parent?: ValueNodeParent): void;
Infinity?(node: InfinityNode, parent?: ValueNodeParent): void;
Identifier?(node: IdentifierNode, parent?: ValueNodeParent): void;

"Document:exit"?(node: DocumentNode): void;
"Member:exit"?(node: MemberNode, parent?: ObjectNode): void;
"Element:exit"?(node: ElementNode, parent?: ArrayNode): void;
"Object:exit"?(node: ObjectNode, parent?: ValueNodeParent): void;
"Array:exit"?(node: ArrayNode, parent?: ValueNodeParent): void;
"String:exit"?(node: StringNode, parent?: ValueNodeParent): void;
"Null:exit"?(node: NullNode, parent?: ValueNodeParent): void;
"Number:exit"?(node: NumberNode, parent?: ValueNodeParent): void;
"Boolean:exit"?(node: BooleanNode, parent?: ValueNodeParent): void;
"NaN:exit"?(node: NaNNode, parent?: ValueNodeParent): void;
"Infinity:exit"?(node: InfinityNode, parent?: ValueNodeParent): void;
"Identifier:exit"?(node: IdentifierNode, parent?: ValueNodeParent): void;
}

/**
* The `SourceCode` implementation for JSON files.
*/
export interface IJSONSourceCode

Choose a reason for hiding this comment

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

[Style] Was the I an intentional addition? It's not typical in TypeScript code.

Suggested change
export interface IJSONSourceCode
export interface JSONSourceCode

Here and with IJSONLanguage.

Aside: I've always found this discussion around the choice to not preserve that naming in TypeScript ... interesting: microsoft/TypeScript-Handbook#121

extends TextSourceCode<{
LangOptions: JSONLanguageOptions;
RootNode: DocumentNode;
SyntaxElementWithLoc: JSONSyntaxElement;
ConfigNode: Token;
}> {
/**
* Get the text of a syntax element.
* @param syntaxElement The syntax element to get the text of.
* @param beforeCount The number of characters to include before the syntax element.
* @param afterCount The number of characters to include after the syntax element.
* @returns The text of the syntax element.
*/
getText(
syntaxElement: JSONSyntaxElement,
beforeCount?: number,
afterCount?: number,
): string;
}

export type IJSONLanguage = Language<{

Choose a reason for hiding this comment

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

[Style] Was the I an intentional addition? It's not typical in TypeScript code.

Suggested change
export type IJSONLanguage = Language<{
export type JSONLanguage = Language<{

Here and with IJSONSourceCode.

Copy link
Member Author

@nzakas nzakas Dec 16, 2024

Choose a reason for hiding this comment

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

Yes, because there's already a class called JSONLanguage, so the type is IJSONLanguage. The I stands for interface.

I have seen this in other projects, and it's fairly typical in languages like Java where you can define an interface separate from a class.

Is there a definitive TypeScript way to handle this? "This" being, there's an interface I want a class to adhere to, and it's only used for that class. What should the name be?

Copy link

@JoshuaKGoldberg JoshuaKGoldberg Dec 17, 2024

Choose a reason for hiding this comment

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

Quick answer: there isn't a single standard, but a name like JSONLanguageLike would be reasonable.


Longer answer: the "interface only used for one class" pattern isn't common in TypeScript-land. If the interface is really only used for that one class then most of the time folks would just use the class name.

Since we're not beholden to classic Java-style class hierarchies, it's less common AFAIU to put everything in a single shape the way the @eslint/core Language type is set up. A lot of architectures avoid classes altogether and instead just go with generic factory functions.

Quickly sketching a theoretical vague equivalent:

declare function createLanguage<Settings extends LanguageSettings>
  (settings: Settings): Language<Settings>;

export const jsonLanguage = createLanguage({
  fileType: "text",
  lineStart: 1,
  // ...
});

In that world, there wouldn't be a need for a JSONLanguage to be explicitly declared. It could be inferred from typeof jsonLanguage with a helper.

Not suggesting changes, just posting context for why this naming problem isn't as commonly solved.

Copy link
Member Author

Choose a reason for hiding this comment

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

Hmm okay, thanks for the background and details. I think I'm going to stick with what I have. I understand it's not TypeScript convention, but there aren't a lot of good options for the way we're doing things with JS + TS type definitions that make what I'm doing clear. So, we can live with a bit of ugliness.

LangOptions: JSONLanguageOptions;
Code: IJSONSourceCode;
RootNode: DocumentNode;
Node: AnyNode;
}>;

export type JSONRuleDefinition<
JSONRuleOptions extends unknown[],
JSONRuleMessageIds extends string = "",
> = RuleDefinition<{
LangOptions: JSONLanguageOptions;
Code: IJSONSourceCode;
RuleOptions: JSONRuleOptions;
Visitor: JSONRuleVisitor;
Node: AnyNode;
MessageIds: JSONRuleMessageIds;
ExtRuleDocs: {};
}>;
4 changes: 2 additions & 2 deletions tests/rules/no-unsafe-values.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ ruleTester.run("no-unsafe-values", rule, {
{
messageId: "subnormal",
data: {
value: "2.225073858507201e-308",
value: "2.2250738585072009e-308",
},
line: 1,
column: 1,
Expand All @@ -266,7 +266,7 @@ ruleTester.run("no-unsafe-values", rule, {
{
messageId: "subnormal",
data: {
value: "-2.225073858507201e-308",
value: "-2.2250738585072009e-308",
},
line: 1,
column: 1,
Expand Down
4 changes: 2 additions & 2 deletions tests/types/types.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import json from "@eslint/json";
import { ESLint } from "eslint";
// import { ESLint } from "eslint";

json satisfies ESLint.Plugin;
// json satisfies ESLint.Plugin;
nzakas marked this conversation as resolved.
Show resolved Hide resolved
json.meta.name satisfies string;
json.meta.version satisfies string;

Expand Down
16 changes: 16 additions & 0 deletions tools/build-cts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* @fileoverview Rewrites import expressions for CommonJS compatibility.
* This script creates "dist/cjs/index.d.cts" from "dist/esm/index.d.ts" by modifying imports
* from `"./types.ts"` to `"./types.cts"`.
*
* @author Francesco Trotta
*/

import { readFile, writeFile } from "node:fs/promises";

const oldSourceText = await readFile("dist/esm/index.d.ts", "utf-8");
const newSourceText = oldSourceText.replaceAll(
'import("./types.ts")',
'import("./types.cts")',
);
await writeFile("dist/cjs/index.d.cts", newSourceText);
7 changes: 6 additions & 1 deletion tools/dedupe-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,10 @@ files.forEach(filePath => {
return true;
});

fs.writeFileSync(filePath, remainingLines.join("\n"), "utf8");
// replace references to ../types.ts with ./types.ts
const text = remainingLines
.join("\n")
.replace(/\.\.\/types\.ts/gu, "./types.ts");

fs.writeFileSync(filePath, text, "utf8");
});
Loading
Loading