| /* This file is a part of @mdn/browser-compat-data |
| * See LICENSE file for more information. */ |
| |
| import { platform } from 'node:os'; |
| import { styleText } from 'node:util'; |
| |
| /** @import {SimpleSupportStatement} from '../types/types.js' */ |
| /** @import {Linter, LinterData, LinterMessage, LinterScope} from './types.js' */ |
| |
| /** @type {Readonly<Record<string, string>>} */ |
| const INVISIBLES_MAP = 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?.toLowerCase() === 'true'; |
| |
| /** Determines if the OS is Windows */ |
| export const IS_WINDOWS = platform() === 'win32'; |
| |
| 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) => |
| 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, index) => { |
| 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, index) => { |
| 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 | null} Statement explaining the difference in provided JSON strings |
| */ |
| export const jsonDiff = (actual, expected) => { |
| const actualLines = actual.split(/\n/); |
| const expectedLines = expected.split(/\n/); |
| |
| if (actualLines.length !== expectedLines.length) { |
| return styleText( |
| 'bold', |
| `different number of lines: |
| ${styleText('yellow', `→ Actual: ${styleText('bold', String(actualLines.length))}`)} |
| ${styleText('green', `→ Expected: ${styleText('bold', String(expectedLines.length))}`)}`, |
| ); |
| } |
| |
| for (let i = 0; i < actualLines.length; i++) { |
| if (actualLines[i] !== expectedLines[i]) { |
| return styleText( |
| 'bold', |
| `line #${i + 1}: |
| ${styleText('yellow', `→ Actual: ${styleText('bold', escapeInvisibles(actualLines[i]))}`)} |
| ${styleText('green', `→ Expected: ${styleText('bold', escapeInvisibles(expectedLines[i]))}`)}`, |
| ); |
| } |
| } |
| |
| return null; |
| }; |
| |
| /** |
| * Linter logger class |
| */ |
| export class Logger { |
| /** @type {string} */ |
| title; |
| /** @type {string} */ |
| path; |
| /** @type {LinterMessage[]} */ |
| messages; |
| |
| /** |
| * Construct the logger |
| * @param {string} title Logger title |
| * @param {string} path The scope path |
| */ |
| constructor(title, path) { |
| this.title = title; |
| this.path = path; |
| this.messages = []; |
| } |
| |
| /** |
| * Report an error |
| * @param {string} message Message string |
| * @param {object} [options] Additional options (ex. actual, expected) |
| * @returns {void} |
| */ |
| error(message, options) { |
| this.messages.push({ |
| level: 'error', |
| title: this.title, |
| path: this.path, |
| message, |
| ...options, |
| }); |
| } |
| |
| /** |
| * Report a warning |
| * @param {string} message Message string |
| * @param {object} [options] Additional options (ex. actual, expected) |
| * @returns {void} |
| */ |
| warning(message, options) { |
| this.messages.push({ |
| level: 'warning', |
| title: this.title, |
| path: this.path, |
| message, |
| ...options, |
| }); |
| } |
| |
| /** |
| * Report an info |
| * @param {string} message Message string |
| * @param {object} [options] Additional options (ex. actual, expected) |
| * @returns {void} |
| */ |
| info(message, options) { |
| this.messages.push({ |
| level: 'info', |
| title: this.title, |
| path: this.path, |
| message, |
| ...options, |
| }); |
| } |
| } |
| |
| /** |
| * Linters class |
| */ |
| export class Linters { |
| /** @type {Linter[]} */ |
| linters; |
| /** @type {Record<string, LinterMessage[]>} */ |
| messages; |
| /** |
| * Contains all seen tested objects, boolean means: |
| * false - failure occurred (good) |
| * true - failure did not occur (bad) |
| * @type {Record<string, Record<string, boolean>>} |
| */ |
| missingExpectedFailures; |
| |
| /** |
| * Construct the linters |
| * @param {Linter[]} linters All the linters |
| */ |
| constructor(linters) { |
| 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 |
| * @returns {Promise<void>} |
| */ |
| async runScope(scope, data) { |
| 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); |
| await 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) { |
| this.messages[linter.name].push({ |
| level: 'error', |
| title: linter.name, |
| path: data.path.full, |
| message: 'Linter failure! ' + /** @type {Error} */ (e).stack, |
| }); |
| } |
| } |
| } |
| } |
| |
| /** |
| * Returns the key for the group that this statement belongs to. |
| * @param {SimpleSupportStatement} support The support statement. |
| * @returns {string} The key of the support statement group. |
| */ |
| export const createStatementGroupKey = (support) => { |
| /** @type {string[]} */ |
| const parts = []; |
| if (support.prefix) { |
| parts.push(`prefix: ${support.prefix}`); |
| } |
| |
| if (support.alternative_name) { |
| parts.push(`alt. name: ${support.alternative_name}`); |
| } |
| |
| if (support.flags) { |
| parts.push(...support.flags.map((flag) => `${flag.type}: ${flag.name}`)); |
| } |
| |
| if (parts.length === 0) { |
| return 'normal name'; |
| } |
| |
| return parts.join(' / '); |
| }; |