diff --git a/tests/config/constants.js b/tests/config/constants.js index e6c7c16a0..a1dc3adf7 100644 --- a/tests/config/constants.js +++ b/tests/config/constants.js @@ -8,4 +8,9 @@ export const FORMAT_TEST_DIRECTORY = normalizeDirectory( path.join(__dirname, "../format/"), ); +export const { FULL_TEST } = process.env; +export const BOM = "\uFEFF"; + export const CURSOR_PLACEHOLDER = "<|>"; +export const RANGE_START_PLACEHOLDER = "<<>>"; +export const RANGE_END_PLACEHOLDER = "<<>>"; diff --git a/tests/config/replace-placeholders.js b/tests/config/replace-placeholders.js new file mode 100644 index 000000000..39c58576e --- /dev/null +++ b/tests/config/replace-placeholders.js @@ -0,0 +1,42 @@ +import { + CURSOR_PLACEHOLDER, + RANGE_END_PLACEHOLDER, + RANGE_START_PLACEHOLDER, +} from "./constants.js"; + +const indexProperties = [ + { + property: "cursorOffset", + placeholder: CURSOR_PLACEHOLDER, + }, + { + property: "rangeStart", + placeholder: RANGE_START_PLACEHOLDER, + }, + { + property: "rangeEnd", + placeholder: RANGE_END_PLACEHOLDER, + }, +]; + +function replacePlaceholders(originalText, originalOptions) { + const indexes = indexProperties + .map(({ property, placeholder }) => { + const value = originalText.indexOf(placeholder); + return value === -1 ? undefined : { property, value, placeholder }; + }) + .filter(Boolean) + .sort((a, b) => a.value - b.value); + + const options = { ...originalOptions }; + let text = originalText; + let offset = 0; + for (const { property, value, placeholder } of indexes) { + text = text.replace(placeholder, ""); + options[property] = value + offset; + offset -= placeholder.length; + } + return { text, options }; +} + +export { replacePlaceholders }; diff --git a/tests/config/run-format-test.js b/tests/config/run-format-test.js index 3d0c5477e..1e7ee413f 100644 --- a/tests/config/run-format-test.js +++ b/tests/config/run-format-test.js @@ -2,79 +2,13 @@ import fs from "node:fs"; import path from "node:path"; import url from "node:url"; import createEsmUtils from "esm-utils"; -import getPrettier from "./get-prettier.js"; -import getCreateParser from "./get-create-parser.js"; -import getVariantCoverage from "./get-variant-coverage.js"; -import getPlugins from "./get-plugins.js"; -import compileContract from "./utils/compile-contract.js"; -import consistentEndOfLine from "./utils/consistent-end-of-line.js"; -import createSnapshot from "./utils/create-snapshot.js"; import { stringifyOptionsForTitle } from "./utils/stringify-options-for-title.js"; -import visualizeEndOfLine from "./utils/visualize-end-of-line.js"; -import { - isAntlrMismatch, - isAstUnstable, - isUnstable, -} from "./failed-format-tests.js"; +import { format } from "./run-prettier.js"; +import { runTest } from "./run-test.js"; +import { shouldThrowOnFormat } from "./utilities.js"; const { __dirname } = createEsmUtils(import.meta); -const { FULL_TEST } = process.env; -const BOM = "\uFEFF"; - -const CURSOR_PLACEHOLDER = "<|>"; -const RANGE_START_PLACEHOLDER = "<<>>"; -const RANGE_END_PLACEHOLDER = "<<>>"; - -const testsWithAstChanges = new Map( - [ - "Parentheses/AddNoParentheses.sol", - "Parentheses/SubNoParentheses.sol", - "Parentheses/MulNoParentheses.sol", - "Parentheses/DivNoParentheses.sol", - "Parentheses/ModNoParentheses.sol", - "Parentheses/ExpNoParentheses.sol", - "Parentheses/ShiftLNoParentheses.sol", - "Parentheses/ShiftRNoParentheses.sol", - "Parentheses/BitAndNoParentheses.sol", - "Parentheses/BitOrNoParentheses.sol", - "Parentheses/BitXorNoParentheses.sol", - "Parentheses/LogicNoParentheses.sol", - "HexLiteral/HexLiteral.sol", - "ModifierInvocations/ModifierInvocations.sol", - ].map((fixture) => { - const [file, compareBytecode = () => true] = Array.isArray(fixture) - ? fixture - : [fixture]; - return [path.join(__dirname, "../format/", file), compareBytecode]; - }), -); - -const shouldCompareBytecode = (filename, options) => { - const testFunction = testsWithAstChanges.get(filename); - - if (!testFunction) { - return false; - } - - return testFunction(options); -}; - -const shouldThrowOnFormat = (filename, options) => { - const { errors = {} } = options; - if (errors === true) { - return true; - } - - const files = errors[options.parser]; - - if (files === true || (Array.isArray(files) && files.includes(filename))) { - return true; - } - - return false; -}; - const isTestDirectory = (dirname, name) => (dirname + path.sep).startsWith( path.join(__dirname, "../format", name) + path.sep, @@ -204,249 +138,4 @@ function runFormatTest(fixtures, parsers, options) { } } -async function runTest({ - parsers, - name, - filename, - code, - output, - parser, - mainParserFormatResult, - mainParserFormatOptions, -}) { - let formatOptions = mainParserFormatOptions; - let formatResult = mainParserFormatResult; - - // Verify parsers or error tests - if ( - mainParserFormatResult.error || - mainParserFormatOptions.parser !== parser - ) { - formatOptions = { ...mainParserFormatResult.options, parser }; - const runFormat = () => format(code, formatOptions); - - if (shouldThrowOnFormat(name, formatOptions)) { - await expect(runFormat()).rejects.toThrowErrorMatchingSnapshot(); - return; - } - - // Verify parsers format result should be the same as main parser - output = mainParserFormatResult.outputWithCursor; - formatResult = await runFormat(); - } - - // Make sure output has consistent EOL - expect(formatResult.eolVisualizedOutput).toEqual( - visualizeEndOfLine(consistentEndOfLine(formatResult.outputWithCursor)), - ); - - // The result is assert to equals to `output` - if (typeof output === "string") { - expect(formatResult.eolVisualizedOutput).toEqual( - visualizeEndOfLine(output), - ); - return; - } - - // All parsers have the same result, only snapshot the result from main parser - expect( - createSnapshot(formatResult, { - parsers, - formatOptions, - CURSOR_PLACEHOLDER, - }), - ).toMatchSnapshot(); - - if (!FULL_TEST) { - return; - } - - if (formatOptions.parser === "slang") { - const createParser = await getCreateParser(); - const variantCoverage = await getVariantCoverage(); - const { parser, parseOutput } = createParser(code, formatOptions); - - // Check coverage - variantCoverage(parseOutput.tree.asNonterminalNode()); - - if (!isAntlrMismatch(filename, formatOptions)) { - // Compare with ANTLR's format - const prettier = await getPrettier(); - const { formatted: antlrOutput } = await prettier.formatWithCursor(code, { - ...formatOptions, - // Since Slang forces us to decide on a compiler version, we need to do the - // same for ANTLR unless it was already given as an option. - compiler: formatOptions.compiler || parser.languageVersion, - parser: "antlr", - plugins: await getPlugins(), - }); - expect(antlrOutput).toEqual(formatResult.output); - } - } - - const isUnstableTest = isUnstable(filename, formatOptions); - if ( - (formatResult.changed || isUnstableTest) && - // No range and cursor - formatResult.input === code - ) { - const { eolVisualizedOutput: firstOutput, output } = formatResult; - const { eolVisualizedOutput: secondOutput } = await format( - output, - formatOptions, - ); - if (isUnstableTest) { - // To keep eye on failed tests, this assert never supposed to pass, - // if it fails, just remove the file from `unstableTests` - expect(secondOutput).not.toEqual(firstOutput); - } else { - expect(secondOutput).toEqual(firstOutput); - } - } - - const isAstUnstableTest = isAstUnstable(filename, formatOptions); - // Some parsers skip parsing empty files - if (formatResult.changed && code.trim()) { - const { input, output } = formatResult; - const originalAst = await parse(input, formatOptions); - const formattedAst = await parse(output, formatOptions); - if (isAstUnstableTest) { - expect(formattedAst).not.toEqual(originalAst); - } else { - expect(formattedAst).toEqual(originalAst); - } - } - - if (!shouldSkipEolTest(code, formatResult.options)) { - for (const eol of ["\r\n", "\r"]) { - const { eolVisualizedOutput: output } = await format( - code.replace(/\n/gu, eol), - formatOptions, - ); - // Only if `endOfLine: "auto"` the result will be different - const expected = - formatOptions.endOfLine === "auto" - ? visualizeEndOfLine( - // All `code` use `LF`, so the `eol` of result is always `LF` - formatResult.outputWithCursor.replace(/\n/gu, eol), - ) - : formatResult.eolVisualizedOutput; - expect(output).toEqual(expected); - } - } - - if (code.charAt(0) !== BOM) { - const { eolVisualizedOutput: output } = await format( - BOM + code, - formatOptions, - ); - const expected = BOM + formatResult.eolVisualizedOutput; - expect(output).toEqual(expected); - } - - if (shouldCompareBytecode(filename, formatOptions)) { - const output = compileContract(filename, formatResult.output); - const expected = compileContract(filename, formatResult.input); - expect(output).toEqual(expected); - } -} - -function shouldSkipEolTest(code, options) { - if (code.includes("\r")) { - return true; - } - const { requirePragma, rangeStart, rangeEnd } = options; - if (requirePragma) { - return true; - } - - if ( - typeof rangeStart === "number" && - typeof rangeEnd === "number" && - rangeStart >= rangeEnd - ) { - return true; - } - return false; -} - -async function parse(source, options) { - const prettier = await getPrettier(); - - const { ast } = await prettier.__debug.parse( - source, - { ...options, plugins: await getPlugins() }, - { massage: true }, - ); - return ast; -} - -const indexProperties = [ - { - property: "cursorOffset", - placeholder: CURSOR_PLACEHOLDER, - }, - { - property: "rangeStart", - placeholder: RANGE_START_PLACEHOLDER, - }, - { - property: "rangeEnd", - placeholder: RANGE_END_PLACEHOLDER, - }, -]; -function replacePlaceholders(originalText, originalOptions) { - const indexes = indexProperties - .map(({ property, placeholder }) => { - const value = originalText.indexOf(placeholder); - return value === -1 ? undefined : { property, value, placeholder }; - }) - .filter(Boolean) - .sort((a, b) => a.value - b.value); - - const options = { ...originalOptions }; - let text = originalText; - let offset = 0; - for (const { property, value, placeholder } of indexes) { - text = text.replace(placeholder, ""); - options[property] = value + offset; - offset -= placeholder.length; - } - return { text, options }; -} - -const insertCursor = (text, cursorOffset) => - cursorOffset >= 0 - ? text.slice(0, cursorOffset) + - CURSOR_PLACEHOLDER + - text.slice(cursorOffset) - : text; -async function format(originalText, originalOptions) { - const { text: input, options } = replacePlaceholders( - originalText, - originalOptions, - ); - const inputWithCursor = insertCursor(input, options.cursorOffset); - const prettier = await getPrettier(); - - const { formatted: output, cursorOffset } = await prettier.formatWithCursor( - input, - { ...options, plugins: await getPlugins() }, - ); - const outputWithCursor = insertCursor(output, cursorOffset); - const eolVisualizedOutput = visualizeEndOfLine(outputWithCursor); - - const changed = outputWithCursor !== inputWithCursor; - - return { - changed, - options, - input, - inputWithCursor, - output, - outputWithCursor, - eolVisualizedOutput, - }; -} - export default runFormatTest; diff --git a/tests/config/run-prettier.js b/tests/config/run-prettier.js new file mode 100644 index 000000000..7f058e969 --- /dev/null +++ b/tests/config/run-prettier.js @@ -0,0 +1,57 @@ +import getPrettier from "./get-prettier.js"; +import getPlugins from "./get-plugins.js"; +import { CURSOR_PLACEHOLDER } from "./constants.js"; +import visualizeEndOfLine from "./utils/visualize-end-of-line.js"; +import { replacePlaceholders } from "./replace-placeholders.js"; + +async function parse(input, options) { + const prettier = await getPrettier(); + + const { ast } = await prettier.__debug.parse( + input, + await loadPlugins(options), + { massage: true }, + ); + return ast; +} + +async function format(originalText, originalOptions) { + const { text: input, options } = replacePlaceholders( + originalText, + originalOptions, + ); + const inputWithCursor = insertCursor(input, options.cursorOffset); + const prettier = await getPrettier(); + + const { formatted: output, cursorOffset } = await prettier.formatWithCursor( + input, + await loadPlugins(options), + ); + const outputWithCursor = insertCursor(output, cursorOffset); + const eolVisualizedOutput = visualizeEndOfLine(outputWithCursor); + + const changed = outputWithCursor !== inputWithCursor; + + return { + changed, + options, + input, + inputWithCursor, + output, + outputWithCursor, + eolVisualizedOutput, + }; +} + +const insertCursor = (text, cursorOffset) => + cursorOffset >= 0 + ? text.slice(0, cursorOffset) + + CURSOR_PLACEHOLDER + + text.slice(cursorOffset) + : text; + +async function loadPlugins(options) { + return { ...options, plugins: await getPlugins() }; +} + +export { format, parse }; diff --git a/tests/config/run-test.js b/tests/config/run-test.js new file mode 100644 index 000000000..8896019ed --- /dev/null +++ b/tests/config/run-test.js @@ -0,0 +1,214 @@ +import path from "node:path"; +import createEsmUtils from "esm-utils"; +import { BOM, FULL_TEST } from "./constants.js"; +import * as failedTests from "./failed-format-tests.js"; +import { format, parse } from "./run-prettier.js"; +import consistentEndOfLine from "./utils/consistent-end-of-line.js"; +import createSnapshot from "./utils/create-snapshot.js"; +import visualizeEndOfLine from "./utils/visualize-end-of-line.js"; +import { shouldThrowOnFormat } from "./utilities.js"; +import getPrettier from "./get-prettier.js"; +import getCreateParser from "./get-create-parser.js"; +import getVariantCoverage from "./get-variant-coverage.js"; +import getPlugins from "./get-plugins.js"; +import compileContract from "./utils/compile-contract.js"; + +const { __dirname } = createEsmUtils(import.meta); + +const testsWithAstChanges = new Map( + [ + "Parentheses/AddNoParentheses.sol", + "Parentheses/SubNoParentheses.sol", + "Parentheses/MulNoParentheses.sol", + "Parentheses/DivNoParentheses.sol", + "Parentheses/ModNoParentheses.sol", + "Parentheses/ExpNoParentheses.sol", + "Parentheses/ShiftLNoParentheses.sol", + "Parentheses/ShiftRNoParentheses.sol", + "Parentheses/BitAndNoParentheses.sol", + "Parentheses/BitOrNoParentheses.sol", + "Parentheses/BitXorNoParentheses.sol", + "Parentheses/LogicNoParentheses.sol", + "HexLiteral/HexLiteral.sol", + "ModifierInvocations/ModifierInvocations.sol", + ].map((fixture) => { + const [file, compareBytecode = () => true] = Array.isArray(fixture) + ? fixture + : [fixture]; + return [path.join(__dirname, "../format/", file), compareBytecode]; + }), +); + +const shouldCompareBytecode = (filename, options) => { + const testFunction = testsWithAstChanges.get(filename); + + if (!testFunction) { + return false; + } + + return testFunction(options); +}; + +async function runTest({ + parsers, + name, + filename, + code, + output, + parser, + mainParserFormatResult, + mainParserFormatOptions, +}) { + let formatOptions = mainParserFormatOptions; + let formatResult = mainParserFormatResult; + + // Verify parsers or error tests + if ( + mainParserFormatResult.error || + mainParserFormatOptions.parser !== parser + ) { + formatOptions = { ...mainParserFormatResult.options, parser }; + const runFormat = () => format(code, formatOptions); + + if (shouldThrowOnFormat(name, formatOptions)) { + await expect(runFormat()).rejects.toThrowErrorMatchingSnapshot(); + return; + } + + // Verify parsers format result should be the same as main parser + output = mainParserFormatResult.outputWithCursor; + formatResult = await runFormat(); + } + + // Make sure output has consistent EOL + expect(formatResult.eolVisualizedOutput).toEqual( + visualizeEndOfLine(consistentEndOfLine(formatResult.outputWithCursor)), + ); + + // The result is assert to equals to `output` + if (typeof output === "string") { + expect(formatResult.eolVisualizedOutput).toEqual( + visualizeEndOfLine(output), + ); + return; + } + + // All parsers have the same result, only snapshot the result from main parser + expect( + createSnapshot(formatResult, { parsers, formatOptions }), + ).toMatchSnapshot(); + + if (!FULL_TEST) { + return; + } + + if (formatOptions.parser === "slang") { + const createParser = await getCreateParser(); + const variantCoverage = await getVariantCoverage(); + const { parser, parseOutput } = createParser(code, formatOptions); + + // Check coverage + variantCoverage(parseOutput.tree.asNonterminalNode()); + + if (!failedTests.isAntlrMismatch(filename, formatOptions)) { + // Compare with ANTLR's format + const prettier = await getPrettier(); + const { formatted: antlrOutput } = await prettier.formatWithCursor(code, { + ...formatOptions, + // Since Slang forces us to decide on a compiler version, we need to do the + // same for ANTLR unless it was already given as an option. + compiler: formatOptions.compiler || parser.languageVersion, + parser: "antlr", + plugins: await getPlugins(), + }); + expect(antlrOutput).toEqual(formatResult.output); + } + } + + const isUnstableTest = failedTests.isUnstable(filename, formatOptions); + if ( + (formatResult.changed || isUnstableTest) && + // No range and cursor + formatResult.input === code + ) { + const { eolVisualizedOutput: firstOutput, output } = formatResult; + const { eolVisualizedOutput: secondOutput } = await format( + output, + formatOptions, + ); + if (isUnstableTest) { + // To keep eye on failed tests, this assert never supposed to pass, + // if it fails, just remove the file from `unstableTests` + expect(secondOutput).not.toEqual(firstOutput); + } else { + expect(secondOutput).toEqual(firstOutput); + } + } + + const isAstUnstableTest = failedTests.isAstUnstable(filename, formatOptions); + // Some parsers skip parsing empty files + if (formatResult.changed && code.trim()) { + const { input, output } = formatResult; + const originalAst = await parse(input, formatOptions); + const formattedAst = await parse(output, formatOptions); + if (isAstUnstableTest) { + expect(formattedAst).not.toEqual(originalAst); + } else { + expect(formattedAst).toEqual(originalAst); + } + } + + if (!shouldSkipEolTest(code, formatResult.options)) { + for (const eol of ["\r\n", "\r"]) { + const { eolVisualizedOutput: output } = await format( + code.replace(/\n/gu, eol), + formatOptions, + ); + // Only if `endOfLine: "auto"` the result will be different + const expected = + formatOptions.endOfLine === "auto" + ? visualizeEndOfLine( + // All `code` use `LF`, so the `eol` of result is always `LF` + formatResult.outputWithCursor.replace(/\n/gu, eol), + ) + : formatResult.eolVisualizedOutput; + expect(output).toEqual(expected); + } + } + + if (code.charAt(0) !== BOM) { + const { eolVisualizedOutput: output } = await format( + BOM + code, + formatOptions, + ); + const expected = BOM + formatResult.eolVisualizedOutput; + expect(output).toEqual(expected); + } + + if (shouldCompareBytecode(filename, formatOptions)) { + const output = compileContract(filename, formatResult.output); + const expected = compileContract(filename, formatResult.input); + expect(output).toEqual(expected); + } +} + +function shouldSkipEolTest(code, options) { + if (code.includes("\r")) { + return true; + } + const { requirePragma, rangeStart, rangeEnd } = options; + if (requirePragma) { + return true; + } + + if ( + typeof rangeStart === "number" && + typeof rangeEnd === "number" && + rangeStart >= rangeEnd + ) { + return true; + } + return false; +} + +export { runTest }; diff --git a/tests/config/utilities.js b/tests/config/utilities.js index bed70134e..2c335bf43 100644 --- a/tests/config/utilities.js +++ b/tests/config/utilities.js @@ -2,4 +2,19 @@ import path from "node:path"; const normalizeDirectory = (directory) => path.normalize(directory + path.sep); -export { normalizeDirectory }; +const shouldThrowOnFormat = (filename, options) => { + const { errors = {}, parser } = options; + if (errors === true) { + return true; + } + + const files = errors[parser]; + + if (files === true || (Array.isArray(files) && files.includes(filename))) { + return true; + } + + return false; +}; + +export { normalizeDirectory, shouldThrowOnFormat };