diff --git a/.github/workflows/sync-headers.yml b/.github/workflows/sync-headers.yml index df34857..ccf5323 100644 --- a/.github/workflows/sync-headers.yml +++ b/.github/workflows/sync-headers.yml @@ -18,6 +18,7 @@ jobs: - uses: actions/setup-node@v3 with: node-version: 18 + - run: npm install - shell: bash id: check-changes name: Check Changes diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f516844 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,14 @@ +name: Test + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + name: Test + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 + with: + node-version: 18 + - run: npm install && npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f8a75e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.vscode +node_modules/ + diff --git a/scripts/clang-utils.js b/lib/clang-utils.js similarity index 100% rename from scripts/clang-utils.js rename to lib/clang-utils.js diff --git a/lib/parse-utils.js b/lib/parse-utils.js new file mode 100644 index 0000000..7b2e755 --- /dev/null +++ b/lib/parse-utils.js @@ -0,0 +1,92 @@ +const parser = require("acorn"); + +/** + * @param {string} text Code to evaluate + * @returns {boolean | undefined} The result of the evaluation, `undefined` if + * parsing failed or the result is unknown. + */ +function evaluate(text) { + try { + const ast = parser.parse(text, { ecmaVersion: 2020 }); + + const expressionStatement = ast.body[0]; + + if (expressionStatement.type !== "ExpressionStatement") { + throw new Error("Expected an ExpressionStatement"); + } + + return visitExpression(expressionStatement.expression); + } catch { + // Return an unknown result if parsing failed + return undefined; + } +} + +/** + * @param {import("acorn").Expression} node + */ +const visitExpression = (node) => { + if (node.type === "LogicalExpression") { + return visitLogicalExpression(node); + } else if (node.type === "UnaryExpression") { + return visitUnaryExpression(node); + } else if (node.type === "CallExpression") { + return visitCallExpression(node); + } else { + throw new Error(`Unknown node type: ${node.type} ${JSON.stringify(node)}`); + } +}; + +/** + * @param {import("acorn").LogicalExpression} node + */ +const visitLogicalExpression = (node) => { + const left = visitExpression(node.left); + const right = visitExpression(node.right); + + if (node.operator === "&&") { + // We can shortcircuit regardless of `unknown` if either are false. + if (left === false || right === false) { + return false; + } else if (left === undefined || right === undefined) { + return undefined; + } else { + return left && right; + } + } else if (node.operator === "||") { + if (left === undefined || right === undefined) { + return undefined; + } else { + return left || right; + } + } +}; + +/** + * @param {import("acorn").UnaryExpression} node + */ +const visitUnaryExpression = (node) => { + const argument = visitExpression(node.argument); + if (typeof argument === 'boolean') { + return !argument; + } +}; + +/** + * @param {import("acorn").CallExpression} node + */ +const visitCallExpression = (node) => { + const isDefinedExperimentalCall = + // is `defined(arg)` call + node.callee.type === 'Identifier' && node.callee.name === 'defined' && node.arguments.length == 1 + // and that arg is `NAPI_EXPERIMENTAL` + && node.arguments[0].type === 'Identifier' && node.arguments[0].name === 'NAPI_EXPERIMENTAL'; + + if (isDefinedExperimentalCall) { + return false; + } +}; + +module.exports = { + evaluate +}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f7ac3cd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "node-api-headers", + "version": "1.3.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "node-api-headers", + "version": "1.3.0", + "license": "MIT", + "devDependencies": { + "acorn": "^8.12.1" + } + }, + "node_modules/acorn": { + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + } + } +} diff --git a/package.json b/package.json index c39f4ab..7a30212 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,9 @@ } ], "description": "Node-API headers", - "dependencies": {}, - "devDependencies": {}, + "devDependencies": { + "acorn": "^8.12.1" + }, "directories": {}, "gypfile": false, "homepage": "https://github.com/nodejs/node-api-headers", @@ -42,7 +43,6 @@ "license": "MIT", "main": "index.js", "name": "node-api-headers", - "optionalDependencies": {}, "readme": "README.md", "repository": { "type": "git", @@ -51,7 +51,8 @@ "scripts": { "update-headers": "node --no-warnings scripts/update-headers.js", "write-symbols": "node --no-warnings scripts/write-symbols.js", - "write-win32-def": "node --no-warnings scripts/write-win32-def.js" + "write-win32-def": "node --no-warnings scripts/write-win32-def.js", + "test": "node test/parse-utils.js " }, "version": "1.3.0", "support": true diff --git a/scripts/update-headers.js b/scripts/update-headers.js index fc9b406..2683314 100644 --- a/scripts/update-headers.js +++ b/scripts/update-headers.js @@ -6,7 +6,8 @@ const { resolve } = require('path'); const { parseArgs } = require('util') const { createInterface } = require('readline'); const { inspect } = require('util'); -const { runClang } = require('./clang-utils'); +const { runClang } = require('../lib/clang-utils'); +const { evaluate } = require('../lib/parse-utils'); /** * @returns {Promise} Version string, eg. `'v19.6.0'`. @@ -32,8 +33,11 @@ function removeExperimentals(stream, destination, verbose = false) { }; const rl = createInterface(stream); - /** @type {Array<'write' | 'ignore'>} */ - let mode = ['write']; + /** @type {Array<'write' | 'ignore' | 'preprocessor'>} */ + const mode = ['write']; + + /** @type {Array} */ + const preprocessor = []; /** @type {Array} */ const macroStack = []; @@ -44,6 +48,22 @@ function removeExperimentals(stream, destination, verbose = false) { let lineNumber = 0; let toWrite = ''; + const handlePreprocessor = (expression) => { + const result = evaluate(expression); + + macroStack.push(expression); + + if (result === false) { + debug(`Line ${lineNumber} Ignored '${expression}'`); + mode.push('ignore'); + return false; + } else { + debug(`Line ${lineNumber} Pushed '${expression}'`); + mode.push('write'); + return true; + } + }; + rl.on('line', function lineHandler(line) { ++lineNumber; if (matches = line.match(/^\s*#if(n)?def\s+([A-Za-z_][A-Za-z0-9_]*)/)) { @@ -63,14 +83,23 @@ function removeExperimentals(stream, destination, verbose = false) { } else { mode.push('write'); } - } else if (matches = line.match(/^\s*#if\s+(.+)$/)) { - const identifier = matches[1]; - macroStack.push(identifier); - mode.push('write'); + const expression = matches[1]; + if (expression.endsWith('\\')) { + if (preprocessor.length) { + reject(new Error(`Unexpected preprocessor continuation on line ${lineNumber}`)); + return; + } + preprocessor.push(expression.substring(0, expression.length - 1)); - debug(`Line ${lineNumber} Pushed ${identifier}`); + mode.push('preprocessor'); + return; + } else { + if (!handlePreprocessor(expression)) { + return; + } + } } else if (line.match(/^#else(?:\s+|$)/)) { const identifier = macroStack[macroStack.length - 1]; @@ -83,7 +112,7 @@ function removeExperimentals(stream, destination, verbose = false) { return; } - if (identifier === 'NAPI_EXPERIMENTAL') { + if (identifier.indexOf('NAPI_EXPERIMENTAL') > -1) { const lastMode = mode[mode.length - 1]; mode[mode.length - 1] = (lastMode === 'ignore') ? 'write' : 'ignore'; return; @@ -98,9 +127,10 @@ function removeExperimentals(stream, destination, verbose = false) { if (!identifier) { rl.off('line', lineHandler); reject(new Error(`Macro stack is empty handling #endif on line ${lineNumber}`)); + return; } - if (identifier === 'NAPI_EXPERIMENTAL') { + if (identifier.indexOf('NAPI_EXPERIMENTAL') > -1) { return; } } @@ -113,7 +143,28 @@ function removeExperimentals(stream, destination, verbose = false) { if (mode[mode.length - 1] === 'write') { toWrite += `${line}\n`; + } else if (mode[mode.length - 1] === 'preprocessor') { + if (!preprocessor) { + reject(new Error(`Preprocessor mode without preprocessor on line ${lineNumber}`)); + return; + } + + if (line.endsWith('\\')) { + preprocessor.push(line.substring(0, line.length - 1)); + return; + } + + preprocessor.push(line); + + const expression = preprocessor.join(''); + preprocessor.length = 0; + mode.pop(); + + if (!handlePreprocessor(expression)) { + return; + } } + }); rl.on('close', () => { @@ -138,7 +189,7 @@ function removeExperimentals(stream, destination, verbose = false) { * @param {string} path Path for file to validate with clang. */ async function validateSyntax(path) { - try { + try { await runClang(['-fsyntax-only', path]); } catch (e) { throw new Error(`Syntax validation failed for ${path}: ${e}`); diff --git a/scripts/write-symbols.js b/scripts/write-symbols.js index d3621c1..e45d7df 100644 --- a/scripts/write-symbols.js +++ b/scripts/write-symbols.js @@ -2,7 +2,7 @@ const { resolve: resolvePath } = require('path'); const { writeFile } = require('fs/promises'); -const { runClang } = require('./clang-utils'); +const { runClang } = require('../lib/clang-utils'); /** @typedef {{ js_native_api_symbols: string[]; node_api_symbols: string[]; }} SymbolInfo */ diff --git a/test/parse-utils.js b/test/parse-utils.js new file mode 100644 index 0000000..e11b57f --- /dev/null +++ b/test/parse-utils.js @@ -0,0 +1,21 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const { evaluate } = require("../lib/parse-utils"); + +/** @type {Array<[string, boolean | undefined]>} */ +const testCases = [ + [`defined(NAPI_EXPERIMENTAL)`, false], + [`!defined(NAPI_EXPERIMENTAL)`, true], + [`defined(NAPI_EXPERIMENTAL) || defined(NODE_API_EXPERIMENTAL_NOGC_ENV_OPT_OUT)`, undefined], + [`defined(NAPI_EXPERIMENTAL) && defined(NODE_API_EXPERIMENTAL_NOGC_ENV_OPT_OUT)`, false], + [`!defined(NAPI_EXPERIMENTAL) || (defined(NAPI_EXPERIMENTAL) && (defined(NODE_API_EXPERIMENTAL_NOGC_ENV_OPT_OUT) || defined(NODE_API_EXPERIMENTAL_BASIC_ENV_OPT_OUT)))`, true], + [`NAPI_VERSION >= 9`, undefined], + [`!defined __cplusplus || (defined(_MSC_VER) && _MSC_VER < 1900)`, undefined], // parser error on `defined __cplusplus` +]; + +for (const [text, expected] of testCases) { + test(`${text} -> ${expected}`, (t) => { + const result = evaluate(text); + assert.strictEqual(result, expected); + }); +}