blob: 1b2e265450fcd1d18731fdd5bf0e31694db3ab19 [file] [edit]
/* This file is a part of @mdn/browser-compat-data
* See LICENSE file for more information. */
import { platform } from 'node:os';
import chalk from 'chalk-template';
import { DataType } from '../types/index.js';
import { BrowserName } from '../types/types.js';
/**
* Get the date exactly two years ago
* @returns {Date} The date, two years prior to today
*/
const getTwoYearsAgo = () => {
const date = new Date();
date.setFullYear(date.getFullYear() - 2);
return date;
};
export const twoYearsAgo = getTwoYearsAgo();
/**
* @typedef LinterScope
* @type {('file'|'feature'|'browser'|'tree')}
*/
/**
* @typedef LoggerLevel
* @type {('error'|'warning')}
*/
/**
* @typedef Linter
* @type {{name: string, description: string, scope: string, check: any}}
*/
const INVISIBLES_MAP: { readonly [char: string]: string } = Object.freeze(
Object.assign(Object.create(null), {
'\0': '\\0', // ␀ (0x00)
'\b': '\\b', // ␈ (0x08)
'\t': '\\t', // ␉ (0x09)
'\n': '\\n', // ␊ (0x0A)
'\v': '\\v', // ␋ (0x0B)
'\f': '\\f', // ␌ (0x0C)
'\r': '\\r', // ␍ (0x0D)
}),
);
/* eslint-disable-next-line no-control-regex */
export const INVISIBLES_REGEXP = /[\0\x08-\x0D]/g;
/** Used to check if the process is running in a CI environment. */
export const IS_CI =
process.env.CI && String(process.env.CI).toLowerCase() === 'true';
/** Determines if the OS is Windows */
export const IS_WINDOWS = platform() === 'win32';
/** @type {string[]} */
export const VALID_ELEMENTS = ['code', 'kbd', 'em', 'strong', 'a'];
/**
* Escapes common invisible characters.
* @param {string} str The string to escape invisibles for
* @returns {string} The string with invisibles escaped
*/
export const escapeInvisibles = (str: string): string =>
INVISIBLES_REGEXP[Symbol.replace](
str,
(char) => INVISIBLES_MAP[char] || char,
);
/**
* Gets the row and column matching the index in a string.
* @param {string} str The string
* @param {number} index The character index
* @returns {[number, number] | [null, null]} The position from the index
*/
export const indexToPosRaw = (
str: string,
index: number,
): [number, number] | [null, null] => {
let line = 1,
col = 1;
if (
typeof str !== 'string' ||
typeof index !== 'number' ||
index > str.length
) {
return [null, null];
}
for (let i = 0; i < index; i++) {
const char = str[i];
switch (char) {
case '\r':
line++;
col = 1;
if (i + 1 < index && str[i + 1] === '\r') {
i++;
}
break;
case '\t':
// Use JSON `tab_size` value from `.editorconfig`
col += 2;
break;
default:
col++;
break;
}
}
return [line, col];
};
/**
* Gets the row and column matching the index in a string and formats it.
* @param {string} str The string
* @param {number} index The character index
* @returns {string} The line and column in the form of: `"(Ln <ln>, Col <col>)"`
*/
export const indexToPos = (str: string, index: number): string => {
const [line, col] = indexToPosRaw(str, index);
return `(Ln ${line}, Col ${col})`;
};
/**
* Get the stringified difference between two JSON strings
* @param {string} actual Actual JSON string
* @param {string} expected Expected JSON string
* @returns {string?} Statement explaining the difference in provided JSON strings
*/
export const jsonDiff = (actual: string, expected: string): string | null => {
const actualLines = actual.split(/\n/);
const expectedLines = expected.split(/\n/);
if (actualLines.length !== expectedLines.length) {
return chalk`{bold different number of lines:
{yellow → Actual: {bold ${actualLines.length}}}
{green → Expected: {bold ${expectedLines.length}}}}`;
}
for (let i = 0; i < actualLines.length; i++) {
if (actualLines[i] !== expectedLines[i]) {
return chalk`{bold line #${i + 1}}:
{yellow → Actual: {bold ${escapeInvisibles(actualLines[i])}}}
{green → Expected: {bold ${escapeInvisibles(expectedLines[i])}}}`;
}
}
return null;
};
export type Linter = {
name: string;
description: string;
scope: LinterScope;
check: (logger: Logger, options: object) => void;
exceptions?: string[];
};
export type LinterScope = 'file' | 'feature' | 'browser' | 'tree';
export type LinterMessageLevel = 'error' | 'warning';
export type LinterMessage = {
level: LinterMessageLevel;
title: string;
path: string;
message: string;
fixable?: true;
[k: string]: any;
};
export type LinterPath = {
full: string;
category: string;
browser?: BrowserName;
};
export type LinterData = {
data: DataType;
rawdata: string;
path: LinterPath;
};
/**
* Linter logger class
*/
export class Logger {
title: string;
path: string;
messages: LinterMessage[];
/**
* Construct the logger
* @param {string} title Logger title
* @param {string} path The scope path
*/
constructor(title: string, path: string) {
this.title = title;
this.path = path;
this.messages = [];
}
/**
* Throw an error
* @param {string} message Message string
* @param {object} options Additional options (ex. actual, expected)
*/
error(message: string, options?: object): void {
this.messages.push({
level: 'error',
title: this.title,
path: this.path,
message,
...options,
});
}
/**
* Throw a warning
* @param {string} message Message string
* @param {object} options Additional options (ex. actual, expected)
*/
warning(message: string, options?: object): void {
this.messages.push({
level: 'warning',
title: this.title,
path: this.path,
message,
...options,
});
}
}
/**
* Linters class
*/
export class Linters {
linters: Linter[];
messages: Record<string, LinterMessage[]>;
// Contains all seen tested objects, boolean means:
// false - failure occurred (good)
// true - failure did not occur (bad)
missingExpectedFailures: Record<string, Record<string, boolean>>;
/**
* Construct the linters
* @param {Linter[]} linters All the linters
*/
constructor(linters: Linter[]) {
this.linters = linters;
this.messages = {
File: [],
};
this.missingExpectedFailures = {};
for (const linter of this.linters) {
this.messages[linter.name] = [];
this.missingExpectedFailures[linter.name] = {};
}
}
/**
* Run the linters for a specific scope
* @param {LinterScope} scope The scope to run
* @param {LinterData} data The data to lint
*/
runScope(scope: LinterScope, data: LinterData): void {
const linters = this.linters.filter((linter) => linter.scope === scope);
for (const linter of linters) {
const logger = new Logger(linter.name, data.path.full);
try {
const shouldFail = linter.exceptions?.includes(data.path.full);
linter.check(logger, data);
if (shouldFail) {
this.missingExpectedFailures[linter.name][data.path.full] =
logger.messages.length === 0;
} else {
this.messages[linter.name].push(...logger.messages);
}
} catch (e: any) {
this.messages[linter.name].push({
level: 'error',
title: linter.name,
path: data.path.full,
message: 'Linter failure! ' + e.stack,
});
}
}
}
}