diff options
Diffstat (limited to 'dev/lint')
-rw-r--r-- | dev/lint/global-declarations.js | 157 | ||||
-rw-r--r-- | dev/lint/html-scripts.js | 202 |
2 files changed, 359 insertions, 0 deletions
diff --git a/dev/lint/global-declarations.js b/dev/lint/global-declarations.js new file mode 100644 index 00000000..648ad368 --- /dev/null +++ b/dev/lint/global-declarations.js @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2023 Yomitan Authors + * Copyright (C) 2020-2022 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); +const {getAllFiles} = require('../util'); + + +/** + * @param {string} string + * @returns {string} + */ +function escapeRegExp(string) { + return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * @param {string} string + * @param {RegExp} pattern + * @returns {number} + */ +function countOccurences(string, pattern) { + return (string.match(pattern) || []).length; +} + +/** + * @param {string} string + * @returns {'\r'|'\n'|'\r\n'} + */ +function getNewline(string) { + const count1 = countOccurences(string, /(?:^|[^\r])\n/g); + const count2 = countOccurences(string, /\r\n/g); + const count3 = countOccurences(string, /\r(?:[^\n]|$)/g); + if (count2 > count1) { + return (count3 > count2) ? '\r' : '\r\n'; + } else { + return (count3 > count1) ? '\r' : '\n'; + } +} + +/** + * @param {string} string + * @param {string} substring + * @returns {number} + */ +function getSubstringCount(string, substring) { + let count = 0; + const pattern = new RegExp(`\\b${escapeRegExp(substring)}\\b`, 'g'); + while (true) { + const match = pattern.exec(string); + if (match === null) { break; } + ++count; + } + return count; +} + + +/** + * @param {string} fileName + * @param {boolean} fix + * @returns {boolean} + */ +function validateGlobals(fileName, fix) { + const pattern = /\/\*\s*global\s+([\w\W]*?)\*\//g; + const trimPattern = /^[\s,*]+|[\s,*]+$/g; + const splitPattern = /[\s,*]+/; + const source = fs.readFileSync(fileName, {encoding: 'utf8'}); + let match; + let first = true; + let endIndex = 0; + let newSource = ''; + const allGlobals = []; + const newline = getNewline(source); + while ((match = pattern.exec(source)) !== null) { + if (!first) { + console.error(`Encountered more than one global declaration in ${fileName}`); + return false; + } + first = false; + + const parts = match[1].replace(trimPattern, '').split(splitPattern); + parts.sort(); + + const actual = match[0]; + const expected = `/* global${parts.map((v) => `${newline} * ${v}`).join('')}${newline} */`; + + try { + assert.strictEqual(actual, expected); + } catch (e) { + console.error(`Global declaration error encountered in ${fileName}:`); + console.error(e instanceof Error ? e.message : `${e}`); + if (!fix) { + return false; + } + } + + newSource += source.substring(0, match.index); + newSource += expected; + endIndex = match.index + match[0].length; + + allGlobals.push(...parts); + } + + newSource += source.substring(endIndex); + + // This is an approximate check to see if a global variable is unused. + // If the global appears in a comment, string, or similar, the check will pass. + let errorCount = 0; + for (const global of allGlobals) { + if (getSubstringCount(newSource, global) <= 1) { + console.error(`Global variable ${global} appears to be unused in ${fileName}`); + ++errorCount; + } + } + + if (fix) { + fs.writeFileSync(fileName, newSource, {encoding: 'utf8'}); + } + + return errorCount === 0; +} + + +/** */ +function main() { + const fix = (process.argv.length >= 2 && process.argv[2] === '--fix'); + const directory = path.resolve(__dirname, '..', '..', 'ext'); + const pattern = /\.js$/; + const ignorePattern = /^lib[\\/]/; + const fileNames = getAllFiles(directory, (f) => pattern.test(f) && !ignorePattern.test(f)); + for (const fileName of fileNames) { + if (!validateGlobals(path.join(directory, fileName), fix)) { + process.exit(-1); + return; + } + } + process.exit(0); +} + + +if (require.main === module) { main(); } diff --git a/dev/lint/html-scripts.js b/dev/lint/html-scripts.js new file mode 100644 index 00000000..da8c2c71 --- /dev/null +++ b/dev/lint/html-scripts.js @@ -0,0 +1,202 @@ +/* + * Copyright (C) 2023 Yomitan Authors + * Copyright (C) 2020-2022 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); +const {JSDOM} = require('jsdom'); +const {getAllFiles} = require('../util'); + + +/** + * @param {string} fileName + * @returns {?fs.Stats} + */ +function lstatSyncSafe(fileName) { + try { + return fs.lstatSync(fileName); + } catch (e) { + return null; + } +} + +/** + * @param {string} src + * @param {string} fileName + * @param {string} extDir + */ +function validatePath(src, fileName, extDir) { + assert.ok(typeof src === 'string', `<script> missing src attribute in ${fileName}`); + assert.ok(src.startsWith('/'), `<script> src attribute is not absolute in ${fileName} (src=${JSON.stringify(src)})`); + const relativeSrc = src.substring(1); + assert.ok(!path.isAbsolute(relativeSrc), `<script> src attribute is invalid in ${fileName} (src=${JSON.stringify(src)})`); + const fullSrc = path.join(extDir, relativeSrc); + const stats = lstatSyncSafe(fullSrc); + assert.ok(stats !== null, `<script> src file not found in ${fileName} (src=${JSON.stringify(src)})`); + assert.ok(stats.isFile(), `<script> src file invalid in ${fileName} (src=${JSON.stringify(src)})`); +} + +/** + * @param {string} string + * @param {RegExp} pattern + * @returns {number} + */ +function getSubstringCount(string, pattern) { + let count = 0; + while (true) { + const match = pattern.exec(string); + if (match === null) { break; } + ++count; + } + return count; +} + +/** + * @param {string[]} scriptPaths + * @returns {string[]} + */ +function getSortedScriptPaths(scriptPaths) { + // Sort file names without the extension + const extensionPattern = /\.[^.]*$/; + const scriptPaths2 = scriptPaths.map((value) => { + const match = extensionPattern.exec(value); + let ext = ''; + if (match !== null) { + ext = match[0]; + value = value.substring(0, value.length - ext.length); + } + return {value, ext}; + }); + + const stringComparer = new Intl.Collator('en-US'); // Invariant locale + scriptPaths2.sort((a, b) => stringComparer.compare(a.value, b.value)); + + return scriptPaths2.map(({value, ext}) => `${value}${ext}`); +} + +/** + * @param {string} fileName + * @param {import('jsdom').DOMWindow} window + * @throws {Error} + */ +function validateScriptOrder(fileName, window) { + const {document, Node: {ELEMENT_NODE, TEXT_NODE}, NodeFilter} = window; + + const scriptElements = document.querySelectorAll('script'); + if (scriptElements.length === 0) { return; } + + // Assert all scripts are siblings + const scriptContainerElement = /** @type {Node} */ (scriptElements[0].parentNode); + for (const element of scriptElements) { + if (element.parentNode !== scriptContainerElement) { + assert.fail('All script nodes are not contained within the same element'); + } + } + + // Get script groupings and order + /** @type {string[][]} */ + const scriptGroups = []; + const newlinePattern = /\n/g; + let separatingText = ''; + const walker = document.createTreeWalker(scriptContainerElement, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); + walker.firstChild(); + for (let node = /** @type {?Node} */ (walker.currentNode); node !== null; node = walker.nextSibling()) { + switch (node.nodeType) { + case ELEMENT_NODE: + if (/** @type {Element} */ (node).tagName.toLowerCase() === 'script') { + /** @type {string[]} */ + let scriptGroup; + if (scriptGroups.length === 0 || getSubstringCount(separatingText, newlinePattern) >= 2) { + scriptGroup = []; + scriptGroups.push(scriptGroup); + } else { + scriptGroup = scriptGroups[scriptGroups.length - 1]; + } + scriptGroup.push(/** @type {HTMLScriptElement} */ (node).src); + separatingText = ''; + } + break; + case TEXT_NODE: + separatingText += node.nodeValue; + break; + } + } + + // Ensure core.js is first (if it is present) + const ignorePattern = /^\/lib\//; + const index = scriptGroups.flat() + .filter((value) => !ignorePattern.test(value)) + .findIndex((value) => (value === '/js/core.js')); + assert.ok(index <= 0, 'core.js is not the first included script'); + + // Check script order + for (let i = 0, ii = scriptGroups.length; i < ii; ++i) { + const scriptGroup = scriptGroups[i]; + try { + assert.deepStrictEqual(scriptGroup, getSortedScriptPaths(scriptGroup)); + } catch (e) { + console.error(`Script order for group ${i + 1} in file ${fileName} is not correct:`); + throw e; + } + } +} + +/** + * @param {string} fileName + * @param {string} extDir + */ +function validateHtmlScripts(fileName, extDir) { + const fullFileName = path.join(extDir, fileName); + const domSource = fs.readFileSync(fullFileName, {encoding: 'utf8'}); + const dom = new JSDOM(domSource); + const {window} = dom; + const {document} = window; + try { + for (const {src} of document.querySelectorAll('script')) { + validatePath(src, fullFileName, extDir); + } + for (const {href} of document.querySelectorAll('link')) { + validatePath(href, fullFileName, extDir); + } + validateScriptOrder(fileName, window); + } finally { + window.close(); + } +} + + +/** */ +function main() { + try { + const extDir = path.resolve(__dirname, '..', '..', 'ext'); + const pattern = /\.html$/; + const ignorePattern = /^lib[\\/]/; + const fileNames = getAllFiles(extDir, (f) => pattern.test(f) && !ignorePattern.test(f)); + for (const fileName of fileNames) { + validateHtmlScripts(fileName, extDir); + } + } catch (e) { + console.error(e); + process.exit(-1); + return; + } + process.exit(0); +} + + +if (require.main === module) { main(); } |