Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 2 additions & 8 deletions apps/oxlint/src-js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,10 @@ import type { SourceCode } from './plugins/source_code.ts';
import type { BeforeHook, Visitor, VisitorWithHooks } from './plugins/types.ts';

export type * as ESTree from './generated/types.d.ts';
export type {
Context,
Diagnostic,
DiagnosticBase,
DiagnosticWithLoc,
DiagnosticWithNode,
LanguageOptions,
} from './plugins/context.ts';
export type { Context, LanguageOptions } from './plugins/context.ts';
export type { Fix, Fixer, FixFn } from './plugins/fix.ts';
export type { CreateOnceRule, CreateRule, Plugin, Rule } from './plugins/load.ts';
export type { Diagnostic, DiagnosticBase, DiagnosticWithLoc, DiagnosticWithNode } from './plugins/report.ts';
export type {
Definition,
DefinitionType,
Expand Down
160 changes: 6 additions & 154 deletions apps/oxlint/src-js/plugins/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,55 +26,23 @@
* and global variables (`filePath`, `settings`, `cwd`).
*/

import { getFixes } from './fix.js';
import { getOffsetFromLineColumn } from './location.js';
import { ast, initAst, SOURCE_CODE } from './source_code.js';
import { report } from './report.js';
import { settings, initSettings } from './settings.js';

import type { Fix, FixFn } from './fix.ts';
import type { RuleDetails } from './load.ts';
import type { Diagnostic } from './report.ts';
import type { SourceCode } from './source_code.ts';
import type { Location, Ranged } from './location.ts';
import type { ModuleKind } from '../generated/types.d.ts';

const { hasOwn, keys: ObjectKeys, freeze, assign: ObjectAssign, create: ObjectCreate } = Object;

// Diagnostic in form passed by user to `Context#report()`
export type Diagnostic = DiagnosticWithNode | DiagnosticWithLoc;

export interface DiagnosticBase {
message?: string | null | undefined;
messageId?: string | null | undefined;
data?: Record<string, string | number> | null | undefined;
fix?: FixFn;
}

export interface DiagnosticWithNode extends DiagnosticBase {
node: Ranged;
}

export interface DiagnosticWithLoc extends DiagnosticBase {
loc: Location;
}

// Diagnostic in form sent to Rust
interface DiagnosticReport {
message: string;
start: number;
end: number;
ruleIndex: number;
fixes: Fix[] | null;
}

// Diagnostics array. Reused for every file.
export const diagnostics: DiagnosticReport[] = [];
const { freeze, assign: ObjectAssign, create: ObjectCreate } = Object;

// Cached current working directory
let cwd: string | null = null;

// Absolute path of file being linted.
// When `null`, indicates that no file is currently being linted (in `createOnce`, or between linting files).
let filePath: string | null = null;
export let filePath: string | null = null;

/**
* Set up context for linting a file.
Expand Down Expand Up @@ -391,124 +359,8 @@ export function createContext(fullRuleName: string, ruleDetails: RuleDetails): R
* @throws {TypeError} If `diagnostic` is invalid
*/
report(diagnostic: Diagnostic): void {
// Delegate to `reportImpl`, passing rule-specific details (`RuleDetails`)
reportImpl(diagnostic, ruleDetails);
// Delegate to `report` implementation shared between all rules, passing rule-specific details (`RuleDetails`)
report(diagnostic, ruleDetails);
},
} as unknown as Context); // It seems TS can't understand `__proto__: FILE_CONTEXT`
}

/**
* Report error.
* @param diagnostic - Diagnostic object
* @param ruleDetails - `RuleDetails` object, containing rule-specific details e.g. `isFixable`
* @throws {TypeError} If `diagnostic` is invalid
*/
function reportImpl(diagnostic: Diagnostic, ruleDetails: RuleDetails): void {
if (filePath === null) throw new Error('Cannot report errors in `createOnce`');

// Get message, resolving message from `messageId` if present
let message = getMessage(diagnostic, ruleDetails);

// Interpolate placeholders {{key}} with data values
if (hasOwn(diagnostic, 'data')) {
const { data } = diagnostic;
if (data != null) {
message = message.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
key = key.trim();
const value = data[key];
return value !== undefined ? String(value) : match;
});
}
}

// TODO: Validate `diagnostic`
let start: number, end: number, loc: Location;

if (hasOwn(diagnostic, 'loc') && (loc = (diagnostic as DiagnosticWithLoc).loc) != null) {
// `loc`
if (typeof loc !== 'object') throw new TypeError('`loc` must be an object');
start = getOffsetFromLineColumn(loc.start);
end = getOffsetFromLineColumn(loc.end);
} else {
// `node`
const { node } = diagnostic as DiagnosticWithNode;
if (node == null) throw new TypeError('Either `node` or `loc` is required');
if (typeof node !== 'object') throw new TypeError('`node` must be an object');

// ESLint uses `loc` here instead of `range`.
// We can't do that because AST nodes don't have `loc` property yet. In any case, `range` is preferable,
// as otherwise we have to convert `loc` to `range` which is expensive at present.
// TODO: Revisit this once we have `loc` support in AST, and a fast translation table to convert `loc` to `range`.
const { range } = node;
if (range === null || typeof range !== 'object') throw new TypeError('`node.range` must be present');
start = range[0];
end = range[1];

// Do type validation checks here, to ensure no error in serialization / deserialization.
// Range validation happens on Rust side.
if (
typeof start !== 'number' ||
typeof end !== 'number' ||
start < 0 ||
end < 0 ||
(start | 0) !== start ||
(end | 0) !== end
) {
throw new TypeError('`node.range[0]` and `node.range[1]` must be non-negative integers');
}
}

diagnostics.push({
message,
start,
end,
ruleIndex: ruleDetails.ruleIndex,
fixes: getFixes(diagnostic, ruleDetails),
});
}

/**
* Get message from diagnostic.
* @param diagnostic - Diagnostic object
* @param ruleDetails - `RuleDetails` object, containing rule-specific `messages`
* @returns Message string
* @throws {Error|TypeError} If neither `message` nor `messageId` provided, or of wrong type
*/
function getMessage(diagnostic: Diagnostic, ruleDetails: RuleDetails): string {
if (hasOwn(diagnostic, 'messageId')) {
const { messageId } = diagnostic as { messageId: string | null | undefined };
if (messageId != null) return resolveMessageFromMessageId(messageId, ruleDetails);
}

if (hasOwn(diagnostic, 'message')) {
const { message } = diagnostic;
if (typeof message === 'string') return message;
if (message != null) throw new TypeError('`message` must be a string');
}

throw new Error('Either `message` or `messageId` is required');
}

/**
* Resolve a message ID to its message string, with optional data interpolation.
* @param messageId - The message ID to resolve
* @param ruleDetails - `RuleDetails` object, containing rule-specific `messages`
* @returns Resolved message string
* @throws {Error} If `messageId` is not found in `messages`
*/
function resolveMessageFromMessageId(messageId: string, ruleDetails: RuleDetails): string {
const { messages } = ruleDetails;
if (messages === null) {
throw new Error(`Cannot use messageId '${messageId}' - rule does not define any messages in \`meta.messages\``);
}

if (!hasOwn(messages, messageId)) {
throw new Error(
`Unknown messageId '${messageId}'. Available \`messageIds\`: ${ObjectKeys(messages)
.map((msg) => `'${msg}'`)
.join(', ')}`,
);
}

return messages[messageId];
}
2 changes: 1 addition & 1 deletion apps/oxlint/src-js/plugins/fix.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { assertIs } from './utils.js';

import type { Diagnostic } from './context.ts';
import type { RuleDetails } from './load.ts';
import type { Range, Ranged } from './location.ts';
import type { Diagnostic } from './report.ts';

const { prototype: ArrayPrototype, from: ArrayFrom } = Array,
{ getPrototypeOf, hasOwn, prototype: ObjectPrototype } = Object,
Expand Down
3 changes: 2 additions & 1 deletion apps/oxlint/src-js/plugins/lint.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { diagnostics, setupFileContext, resetFileContext } from './context.js';
import { setupFileContext, resetFileContext } from './context.js';
import { registeredRules } from './load.js';
import { diagnostics } from './report.js';
import { setSettingsForFile, resetSettings } from './settings.js';
import { ast, initAst, resetSourceAndAst, setupSourceForFile } from './source_code.js';
import { assertIs, getErrorMessage } from './utils.js';
Expand Down
164 changes: 164 additions & 0 deletions apps/oxlint/src-js/plugins/report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
* `report` function to report errors + diagnostic types.
*/

import { filePath } from './context.js';
import { getFixes } from './fix.js';
import { getOffsetFromLineColumn } from './location.js';

import type { Fix, FixFn } from './fix.ts';
import type { RuleDetails } from './load.ts';
import type { Location, Ranged } from './location.ts';

const { hasOwn, keys: ObjectKeys } = Object;

/**
* Diagnostic object.
* Passed to `Context#report()`.
*/
// This is the type of the value passed to `Context#report()` by user.
// `DiagnosticReport` (see below) is the type of diagnostics sent to Rust.
export type Diagnostic = DiagnosticWithNode | DiagnosticWithLoc;

export interface DiagnosticBase {
message?: string | null | undefined;
messageId?: string | null | undefined;
data?: Record<string, string | number> | null | undefined;
fix?: FixFn;
}

export interface DiagnosticWithNode extends DiagnosticBase {
node: Ranged;
}

export interface DiagnosticWithLoc extends DiagnosticBase {
loc: Location;
}

// Diagnostic in form sent to Rust
interface DiagnosticReport {
message: string;
start: number;
end: number;
ruleIndex: number;
fixes: Fix[] | null;
}

// Diagnostics array. Reused for every file.
export const diagnostics: DiagnosticReport[] = [];

/**
* Report error.
* @param diagnostic - Diagnostic object
* @param ruleDetails - `RuleDetails` object, containing rule-specific details e.g. `isFixable`
* @throws {TypeError} If `diagnostic` is invalid
*/
export function report(diagnostic: Diagnostic, ruleDetails: RuleDetails): void {
if (filePath === null) throw new Error('Cannot report errors in `createOnce`');

// Get message, resolving message from `messageId` if present
let message = getMessage(diagnostic, ruleDetails);

// Interpolate placeholders {{key}} with data values
if (hasOwn(diagnostic, 'data')) {
const { data } = diagnostic;
if (data != null) {
message = message.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
key = key.trim();
const value = data[key];
return value !== undefined ? String(value) : match;
});
}
}

// TODO: Validate `diagnostic`
let start: number, end: number, loc: Location;

if (hasOwn(diagnostic, 'loc') && (loc = (diagnostic as DiagnosticWithLoc).loc) != null) {
// `loc`
if (typeof loc !== 'object') throw new TypeError('`loc` must be an object');
start = getOffsetFromLineColumn(loc.start);
end = getOffsetFromLineColumn(loc.end);
} else {
// `node`
const { node } = diagnostic as DiagnosticWithNode;
if (node == null) throw new TypeError('Either `node` or `loc` is required');
if (typeof node !== 'object') throw new TypeError('`node` must be an object');

// ESLint uses `loc` here instead of `range`.
// We can't do that because AST nodes don't have `loc` property yet. In any case, `range` is preferable,
// as otherwise we have to convert `loc` to `range` which is expensive at present.
// TODO: Revisit this once we have `loc` support in AST, and a fast translation table to convert `loc` to `range`.
const { range } = node;
if (range === null || typeof range !== 'object') throw new TypeError('`node.range` must be present');
start = range[0];
end = range[1];

// Do type validation checks here, to ensure no error in serialization / deserialization.
// Range validation happens on Rust side.
if (
typeof start !== 'number' ||
typeof end !== 'number' ||
start < 0 ||
end < 0 ||
(start | 0) !== start ||
(end | 0) !== end
) {
throw new TypeError('`node.range[0]` and `node.range[1]` must be non-negative integers');
}
}

diagnostics.push({
message,
start,
end,
ruleIndex: ruleDetails.ruleIndex,
fixes: getFixes(diagnostic, ruleDetails),
});
}

/**
* Get message from diagnostic.
* @param diagnostic - Diagnostic object
* @param ruleDetails - `RuleDetails` object, containing rule-specific `messages`
* @returns Message string
* @throws {Error|TypeError} If neither `message` nor `messageId` provided, or of wrong type
*/
function getMessage(diagnostic: Diagnostic, ruleDetails: RuleDetails): string {
if (hasOwn(diagnostic, 'messageId')) {
const { messageId } = diagnostic as { messageId: string | null | undefined };
if (messageId != null) return resolveMessageFromMessageId(messageId, ruleDetails);
}

if (hasOwn(diagnostic, 'message')) {
const { message } = diagnostic;
if (typeof message === 'string') return message;
if (message != null) throw new TypeError('`message` must be a string');
}

throw new Error('Either `message` or `messageId` is required');
}

/**
* Resolve a message ID to its message string, with optional data interpolation.
* @param messageId - The message ID to resolve
* @param ruleDetails - `RuleDetails` object, containing rule-specific `messages`
* @returns Resolved message string
* @throws {Error} If `messageId` is not found in `messages`
*/
function resolveMessageFromMessageId(messageId: string, ruleDetails: RuleDetails): string {
const { messages } = ruleDetails;
if (messages === null) {
throw new Error(`Cannot use messageId '${messageId}' - rule does not define any messages in \`meta.messages\``);
}

if (!hasOwn(messages, messageId)) {
throw new Error(
`Unknown messageId '${messageId}'. Available \`messageIds\`: ${ObjectKeys(messages)
.map((msg) => `'${msg}'`)
.join(', ')}`,
);
}

return messages[messageId];
}
Loading