diff --git a/package-lock.json b/package-lock.json index 5eaf2a1..f8fd3a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,11 +17,11 @@ }, "devDependencies": { "@textlint/legacy-textlint-core": "^15.2.1", + "@textlint/types": "^15.2.1", "lint-staged": "^16.1.5", "prettier": "^3.6.2", "textlint": "15.2.1", - "textlint-scripts": "^15.2.1", - "typescript": "^5.9.2" + "textlint-scripts": "^15.2.1" } }, "node_modules/@ampproject/remapping": { @@ -7143,6 +7143,8 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "optional": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 205c56d..cc604da 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "version": "6.1.0", "description": "textlint rule for prh.", - "main": "lib/textlint-rule-prh.js", + "main": "lib/node.js", "files": [ "lib", "src" @@ -42,6 +42,7 @@ }, "devDependencies": { "@textlint/legacy-textlint-core": "^15.2.1", + "@textlint/types": "^15.2.1", "lint-staged": "^16.1.5", "prettier": "^3.6.2", "textlint": "15.2.1", diff --git a/src/core.ts b/src/core.ts new file mode 100644 index 0000000..8bd7f8e --- /dev/null +++ b/src/core.ts @@ -0,0 +1,255 @@ +// LICENSE : MIT +import { RuleHelper } from "textlint-rule-helper"; +import { parse } from "@babel/parser"; +import { ChangeSet, Engine } from "prh"; +import { + TextlintRuleContext, + TextlintRuleOptions, + TextlintRuleReporter, + TextlintRuleReportHandler +} from "@textlint/types"; +import { ASTNodeTypes, TxtCodeBlockNode } from "@textlint/ast-node-types"; +import { CommentBlock, CommentLine } from "@babel/types"; + +const defaultOptions = { + checkLink: false, + checkBlockQuote: false, + checkEmphasis: false, + checkHeader: true, + checkParagraph: true, + /** + * Check CodeBlock text + * Default: [] + */ + checkCodeComment: [], + /** + * Report parsing error for debug + */ + debug: false +}; + +const assertOptions = (options: TextlintRuleOptions) => { + if (typeof options.ruleContents === "undefined" && typeof options.rulePaths === "undefined") { + throw new Error(`textlint-rule-prh require Rule Options. +Please set .textlintrc: +{ + "rules": { + "prh": { + "rulePaths" :["path/to/prh.yml"] + } + } +} +`); + } +}; + +const createIgnoreNodeTypes = (options: TextlintRuleOptions, Syntax: typeof ASTNodeTypes) => { + const nodeTypes = []; + if (!options.checkLink) { + nodeTypes.push(Syntax.Link); + } + if (!options.checkBlockQuote) { + nodeTypes.push(Syntax.BlockQuote); + } + if (!options.checkEmphasis) { + nodeTypes.push(Syntax.Emphasis); + } + if (!options.checkHeader) { + nodeTypes.push(Syntax.Header); + } + if (!options.checkParagraph) { + nodeTypes.push(Syntax.Paragraph); + } + return nodeTypes; +}; + +/** + * for each diff of changeSet + */ +const forEachChange = ( + changeSet: ChangeSet, + str: string, + onChangeOfMatch: (arg: { + matchStartIndex: number; + matchEndIndex: number; + actual: string; + expected: string; + prh?: string; + }) => void +) => { + const sortedDiffs = changeSet.diffs.sort(function (a, b) { + return a.index - b.index; + }); + let delta = 0; + sortedDiffs.forEach(function (diff) { + // TODO: What should I use `!` or `?` + const result = diff.expected!.replace(/\$([0-9]{1,2})/g, function (match, g1) { + const index = parseInt(g1); + if (index === 0 || diff.matches.length - 1 < index) { + return match; + } + return diff.matches[index] || ""; + }); + // matchStartIndex/matchEndIndex value is original position, not replaced position + // textlint use original position + const matchStartIndex = diff.index; + const matchEndIndex = matchStartIndex + diff.matches[0].length; + // actual => expected + const actual = str.slice(diff.index + delta, diff.index + delta + diff.matches[0].length); + // TODO: What should I use `!` or `?` + const prh = diff.rule!.raw.prh || null; + onChangeOfMatch({ + matchStartIndex, + matchEndIndex, + actual: actual, + expected: result, + prh + }); + str = str.slice(0, diff.index + delta) + result + str.slice(diff.index + delta + diff.matches[0].length); + delta += result.length - diff.matches[0].length; + }); +}; + +/** + * [Markdown] get actual code value from CodeBlock node + * @param node + * @param raw raw value include CodeBlock syntax + */ +function getUntrimmedCode(node: TxtCodeBlockNode, raw: string): string { + if (node.type !== "CodeBlock") { + return node.value; + } + // Space indented CodeBlock that has not lang + if (!node.lang) { + return node.value; + } + + // If it is not markdown codeBlock, just use node.value + if (!(raw.startsWith("```") && raw.endsWith("```"))) { + if (node.value.endsWith("\n")) { + return node.value; + } + return node.value + "\n"; + } + // Markdown(remark) specific hack + // https://github.com/wooorm/remark/issues/207#issuecomment-244620590 + const lines = raw.split("\n"); + // code lines without the first line and the last line + const codeLines = lines.slice(1, lines.length - 1); + // add last new line + // \n``` + return codeLines.join("\n") + "\n"; +} + +export function createReporter( + createPrhEngine: (context: TextlintRuleContext, options: TextlintRuleOptions) => Engine +): TextlintRuleReporter { + function reporter(context: TextlintRuleContext, userOptions: TextlintRuleOptions = {}): TextlintRuleReportHandler { + assertOptions(userOptions); + const options = Object.assign({}, defaultOptions, userOptions); + + const prhEngine = createPrhEngine(context, options); + + const helper = new RuleHelper(context); + const { Syntax, getSource, report, fixer, RuleError } = context; + const ignoreNodeTypes = createIgnoreNodeTypes(options, Syntax); + const codeCommentTypes = options.checkCodeComment ? options.checkCodeComment : defaultOptions.checkCodeComment; + const isDebug = options.debug ? options.debug : defaultOptions.debug; + return { + [Syntax.Str](node) { + if (helper.isChildNode(node, ignoreNodeTypes)) { + return; + } + const text = getSource(node); + // to get position from index + // https://github.com/prh/prh/issues/29 + const dummyFilePath = ""; + const makeChangeSet = prhEngine.makeChangeSet(dummyFilePath, text); + forEachChange(makeChangeSet, text, ({ matchStartIndex, matchEndIndex, actual, expected, prh }) => { + // If result is not changed, should not report + if (actual === expected) { + return; + } + + const suffix = prh !== null ? "\n" + prh : ""; + const messages = actual + " => " + expected + suffix; + report( + node, + new RuleError(messages, { + index: matchStartIndex, + fix: fixer.replaceTextRange([matchStartIndex, matchEndIndex], expected) + }) + ); + }); + }, + [Syntax.CodeBlock](node) { + const lang = node.lang; + if (!lang) { + return; + } + const checkLang = codeCommentTypes.some((type) => { + return type === node.lang; + }); + if (!checkLang) { + return; + } + const rawText = getSource(node); + const codeText = getUntrimmedCode(node, rawText); + const sourceBlockDiffIndex = rawText !== node.value ? rawText.indexOf(codeText) : 0; + const reportComment = (comment: CommentBlock | CommentLine) => { + // to get position from index + // https://github.com/prh/prh/issues/29 + const dummyFilePath = ""; + // TODO: trim option for value? + const text = comment.value; + const makeChangeSet = prhEngine.makeChangeSet(dummyFilePath, text); + forEachChange(makeChangeSet, text, ({ matchStartIndex, matchEndIndex, actual, expected, prh }) => { + // If result is not changed, should not report + if (actual === expected) { + return; + } + + const suffix = prh !== null ? "\n" + prh : ""; + const messages = actual + " => " + expected + suffix; + const commentIdentifier = comment.type === "CommentBlock" ? "/*" : "//"; + // TODO: What should I use `!` or `?` + const commentStart = sourceBlockDiffIndex + comment.start! + commentIdentifier.length; + report( + node, + new RuleError(messages, { + index: commentStart + matchStartIndex, + fix: fixer.replaceTextRange( + [commentStart + matchStartIndex, commentStart + matchEndIndex], + expected + ) + }) + ); + }); + }; + try { + const AST = parse(codeText, { + ranges: true, + allowReturnOutsideFunction: true, + allowAwaitOutsideFunction: true, + allowUndeclaredExports: true, + allowSuperOutsideMethod: true + }); + const comments = AST.comments; + if (!comments) { + return; + } + comments.forEach((comment) => { + reportComment(comment); + }); + } catch (error) { + if (isDebug) { + console.error(error); + //@ts-expect-error + report(node, new RuleError(error.message)); + } + } + } + }; + } + return reporter; +} diff --git a/src/node.ts b/src/node.ts new file mode 100644 index 0000000..fff2cd8 --- /dev/null +++ b/src/node.ts @@ -0,0 +1,77 @@ +// LICENSE : MIT +import { homedir } from "node:os"; +import { Engine, fromYAML, fromYAMLFilePath } from "prh"; +import { resolve, dirname } from "path"; +import { createReporter } from "./core"; +import { TextlintRuleContext, TextlintRuleReporter } from "@textlint/types"; + +const homeDirectory = homedir(); + +const untildify = (filePath: string): string => { + return homeDirectory ? filePath.replace(/^~(?=$|\/|\\)/, homeDirectory) : filePath; +}; + +function createPrhEngine(rulePaths: string[], baseDir: string): Engine | null { + if (rulePaths.length === 0) { + return null; + } + const expandedRulePaths = rulePaths.map((rulePath) => untildify(rulePath)); + const prhEngine = fromYAMLFilePath(resolve(baseDir, expandedRulePaths[0])); + expandedRulePaths.slice(1).forEach((ruleFilePath) => { + const config = fromYAMLFilePath(resolve(baseDir, ruleFilePath)); + prhEngine.merge(config); + }); + return prhEngine; +} + +function createPrhEngineFromContents(yamlContents: string[]) { + if (yamlContents.length === 0) { + return null; + } + const dummyFilePath = ""; + const prhEngine = fromYAML(dummyFilePath, yamlContents[0]); + yamlContents.slice(1).forEach((content) => { + const config = fromYAML(dummyFilePath, content); + prhEngine.merge(config); + }); + return prhEngine; +} + +function mergePrh(...engines: (Engine | null)[]) { + const engines_ = engines.filter((engine) => !!engine); + const mainEngine = engines_[0]; + engines_.slice(1).forEach((engine) => { + mainEngine.merge(engine); + }); + return mainEngine; +} + +const getConfigBaseDir = (context: TextlintRuleContext) => { + if (typeof context.getConfigBaseDir === "function") { + return context.getConfigBaseDir() || process.cwd(); + } + // @ts-expect-error Old fallback that use deprecated `config` value + // https://github.com/textlint/textlint/issues/294 + const textlintRcFilePath = context.config ? context.config.configFile : null; + // .textlintrc directory + return textlintRcFilePath ? dirname(textlintRcFilePath) : process.cwd(); +}; + +const reporter: TextlintRuleReporter = createReporter((context, options) => { + // .textlintrc directory + const textlintRCDir = getConfigBaseDir(context); + // create prh config + const rulePaths = options.rulePaths || []; + const ruleContents = options.ruleContents || []; + // yaml file + yaml contents + const prhEngineContent = createPrhEngineFromContents(ruleContents); + const prhEngineFiles = createPrhEngine(rulePaths, textlintRCDir); + const prhEngine = mergePrh(prhEngineFiles, prhEngineContent); + + return prhEngine; +}); + +export default { + linter: reporter, + fixer: reporter +}; diff --git a/src/textlint-rule-prh.js b/src/textlint-rule-prh.js deleted file mode 100644 index 6979349..0000000 --- a/src/textlint-rule-prh.js +++ /dev/null @@ -1,300 +0,0 @@ -// LICENSE : MIT -import { RuleHelper } from "textlint-rule-helper"; -import { parse } from "@babel/parser"; -import { fromYAMLFilePath, fromYAML } from "prh"; -import path from "node:path"; -import os from "node:os"; - -const homeDirectory = os.homedir(); - -const untildify = (filePath) => { - return homeDirectory ? filePath.replace(/^~(?=$|\/|\\)/, homeDirectory) : filePath; -}; -const defaultOptions = { - checkLink: false, - checkBlockQuote: false, - checkEmphasis: false, - checkHeader: true, - checkParagraph: true, - /** - * Check CodeBlock text - * Default: [] - */ - checkCodeComment: [], - /** - * Report parsing error for debug - */ - debug: false -}; - -function createPrhEngine(rulePaths, baseDir) { - if (rulePaths.length === 0) { - return null; - } - const expandedRulePaths = rulePaths.map((rulePath) => untildify(rulePath)); - const prhEngine = fromYAMLFilePath(path.resolve(baseDir, expandedRulePaths[0])); - expandedRulePaths.slice(1).forEach((ruleFilePath) => { - const config = fromYAMLFilePath(path.resolve(baseDir, ruleFilePath)); - prhEngine.merge(config); - }); - return prhEngine; -} - -function createPrhEngineFromContents(yamlContents) { - if (yamlContents.length === 0) { - return null; - } - const dummyFilePath = ""; - const prhEngine = fromYAML(dummyFilePath, yamlContents[0]); - yamlContents.slice(1).forEach((content) => { - const config = fromYAML(dummyFilePath, content); - prhEngine.merge(config); - }); - return prhEngine; -} - -function mergePrh(...engines) { - const engines_ = engines.filter((engine) => !!engine); - const mainEngine = engines_[0]; - engines_.slice(1).forEach((engine) => { - mainEngine.merge(engine); - }); - return mainEngine; -} - -const assertOptions = (options) => { - if (typeof options.ruleContents === "undefined" && typeof options.rulePaths === "undefined") { - throw new Error(`textlint-rule-prh require Rule Options. -Please set .textlintrc: -{ - "rules": { - "prh": { - "rulePaths" :["path/to/prh.yml"] - } - } -} -`); - } -}; - -const createIgnoreNodeTypes = (options, Syntax) => { - const nodeTypes = []; - if (!options.checkLink) { - nodeTypes.push(Syntax.Link); - } - if (!options.checkBlockQuote) { - nodeTypes.push(Syntax.BlockQuote); - } - if (!options.checkEmphasis) { - nodeTypes.push(Syntax.Emphasis); - } - if (!options.checkHeader) { - nodeTypes.push(Syntax.Header); - } - if (!options.checkParagraph) { - nodeTypes.push(Syntax.Paragraph); - } - return nodeTypes; -}; - -/** - * for each diff of changeSet - * @param {ChangeSet} changeSet - * @param {string} str - * @param {function({ - * matchStartIndex:number, - * matchEndIndex:number, - * actual:string - * expected:string - * }):void} onChangeOfMatch - */ -const forEachChange = (changeSet, str, onChangeOfMatch) => { - const sortedDiffs = changeSet.diffs.sort(function (a, b) { - return a.index - b.index; - }); - let delta = 0; - sortedDiffs.forEach(function (diff) { - const result = diff.expected.replace(/\$([0-9]{1,2})/g, function (match, g1) { - const index = parseInt(g1); - if (index === 0 || diff.matches.length - 1 < index) { - return match; - } - return diff.matches[index] || ""; - }); - // matchStartIndex/matchEndIndex value is original position, not replaced position - // textlint use original position - const matchStartIndex = diff.index; - const matchEndIndex = matchStartIndex + diff.matches[0].length; - // actual => expected - const actual = str.slice(diff.index + delta, diff.index + delta + diff.matches[0].length); - const prh = diff.rule.raw.prh || null; - onChangeOfMatch({ - matchStartIndex, - matchEndIndex, - actual: actual, - expected: result, - prh - }); - str = str.slice(0, diff.index + delta) + result + str.slice(diff.index + delta + diff.matches[0].length); - delta += result.length - diff.matches[0].length; - }); -}; -const getConfigBaseDir = (context) => { - if (typeof context.getConfigBaseDir === "function") { - return context.getConfigBaseDir() || process.cwd(); - } - // Old fallback that use deprecated `config` value - // https://github.com/textlint/textlint/issues/294 - const textlintRcFilePath = context.config ? context.config.configFile : null; - // .textlintrc directory - return textlintRcFilePath ? path.dirname(textlintRcFilePath) : process.cwd(); -}; - -/** - * [Markdown] get actual code value from CodeBlock node - * @param {Object} node - * @param {string} raw raw value include CodeBlock syntax - * @returns {string} - */ -function getUntrimmedCode(node, raw) { - if (node.type !== "CodeBlock") { - return node.value; - } - // Space indented CodeBlock that has not lang - if (!node.lang) { - return node.value; - } - - // If it is not markdown codeBlock, just use node.value - if (!(raw.startsWith("```") && raw.endsWith("```"))) { - if (node.value.endsWith("\n")) { - return node.value; - } - return node.value + "\n"; - } - // Markdown(remark) specific hack - // https://github.com/wooorm/remark/issues/207#issuecomment-244620590 - const lines = raw.split("\n"); - // code lines without the first line and the last line - const codeLines = lines.slice(1, lines.length - 1); - // add last new line - // \n``` - return codeLines.join("\n") + "\n"; -} - -function reporter(context, userOptions = {}) { - assertOptions(userOptions); - const options = Object.assign({}, defaultOptions, userOptions); - // .textlintrc directory - const textlintRCDir = getConfigBaseDir(context); - // create prh config - const rulePaths = options.rulePaths || []; - const ruleContents = options.ruleContents || []; - // yaml file + yaml contents - const prhEngineContent = createPrhEngineFromContents(ruleContents); - const prhEngineFiles = createPrhEngine(rulePaths, textlintRCDir); - const prhEngine = mergePrh(prhEngineFiles, prhEngineContent); - const helper = new RuleHelper(context); - const { Syntax, getSource, report, fixer, RuleError } = context; - const ignoreNodeTypes = createIgnoreNodeTypes(options, Syntax); - const codeCommentTypes = options.checkCodeComment ? options.checkCodeComment : defaultOptions.checkCodeComment; - const isDebug = options.debug ? options.debug : defaultOptions.debug; - return { - [Syntax.Str](node) { - if (helper.isChildNode(node, ignoreNodeTypes)) { - return; - } - const text = getSource(node); - // to get position from index - // https://github.com/prh/prh/issues/29 - const dummyFilePath = ""; - const makeChangeSet = prhEngine.makeChangeSet(dummyFilePath, text); - forEachChange(makeChangeSet, text, ({ matchStartIndex, matchEndIndex, actual, expected, prh }) => { - // If result is not changed, should not report - if (actual === expected) { - return; - } - - const suffix = prh !== null ? "\n" + prh : ""; - const messages = actual + " => " + expected + suffix; - report( - node, - new RuleError(messages, { - index: matchStartIndex, - fix: fixer.replaceTextRange([matchStartIndex, matchEndIndex], expected) - }) - ); - }); - }, - [Syntax.CodeBlock](node) { - const lang = node.lang; - if (!lang) { - return; - } - const checkLang = codeCommentTypes.some((type) => { - return type === node.lang; - }); - if (!checkLang) { - return; - } - const rawText = getSource(node); - const codeText = getUntrimmedCode(node, rawText); - const sourceBlockDiffIndex = rawText !== node.value ? rawText.indexOf(codeText) : 0; - const reportComment = (comment) => { - // to get position from index - // https://github.com/prh/prh/issues/29 - const dummyFilePath = ""; - // TODO: trim option for value? - const text = comment.value; - const makeChangeSet = prhEngine.makeChangeSet(dummyFilePath, text); - forEachChange(makeChangeSet, text, ({ matchStartIndex, matchEndIndex, actual, expected, prh }) => { - // If result is not changed, should not report - if (actual === expected) { - return; - } - - const suffix = prh !== null ? "\n" + prh : ""; - const messages = actual + " => " + expected + suffix; - const commentIdentifier = comment.type === "CommentBlock" ? "/*" : "//"; - const commentStart = sourceBlockDiffIndex + comment.start + commentIdentifier.length; - report( - node, - new RuleError(messages, { - index: commentStart + matchStartIndex, - fix: fixer.replaceTextRange( - [commentStart + matchStartIndex, commentStart + matchEndIndex], - expected - ) - }) - ); - }); - }; - try { - const AST = parse(codeText, { - ranges: true, - allowReturnOutsideFunction: true, - allowAwaitOutsideFunction: true, - allowUndeclaredExports: true, - allowSuperOutsideMethod: true - }); - const comments = AST.comments; - if (!comments) { - return; - } - comments.forEach((comment) => { - reportComment(comment); - }); - } catch (error) { - if (isDebug) { - console.error(error); - report(node, new RuleError(error.message)); - } - } - } - }; -} - -export default { - linter: reporter, - fixer: reporter -}; diff --git a/test/prh-rule-test.js b/test/prh-rule-test.js index d5d505f..4762c84 100644 --- a/test/prh-rule-test.js +++ b/test/prh-rule-test.js @@ -2,7 +2,7 @@ "use strict"; import assert from "assert"; import { TextLintCore } from "@textlint/legacy-textlint-core"; -import rule from "../src/textlint-rule-prh"; +import rule from "../src/node"; const textlint = new TextLintCore(); describe("prh-rule-test", function () { diff --git a/test/prh-rule-tester-test.js b/test/prh-rule-tester-test.js index 49bc786..ef0f22b 100644 --- a/test/prh-rule-tester-test.js +++ b/test/prh-rule-tester-test.js @@ -1,6 +1,6 @@ import TextLintTester from "textlint-tester"; // rule -import rule from "../src/textlint-rule-prh"; +import rule from "../src/node"; const tester = new TextLintTester(); // ruleName, rule, { valid, invalid } const CODE_START_JS = "```js"; diff --git a/test/textlintrc-test.js b/test/textlintrc-test.js index 4adae01..637ac26 100644 --- a/test/textlintrc-test.js +++ b/test/textlintrc-test.js @@ -3,7 +3,7 @@ import assert from "assert"; import fs from "fs"; import { TextLintCore } from "@textlint/legacy-textlint-core"; -import rule from "../src/textlint-rule-prh"; +import rule from "../src/node"; import path from "path"; describe(".textlintrc test", function () { diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1b250f2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + /* Basic Options */ + "module": "commonjs", + "moduleResolution": "node", + "esModuleInterop": true, + "newLine": "LF", + "noEmit": true, + "target": "ES2018", + "sourceMap": true, + "declaration": true, + "jsx": "preserve", + "lib": [ + "esnext", + "dom" + ], + /* Strict Type-Checking Options */ + "strict": true, + /* Additional Checks */ + /* Report errors on unused locals. */ + "noUnusedLocals": true, + /* Report errors on unused parameters. */ + "noUnusedParameters": true, + /* Report error when not all code paths in function return a value. */ + "noImplicitReturns": true, + /* Report errors for fallthrough cases in switch statement. */ + "noFallthroughCasesInSwitch": true + }, + "include": [ + "**/*" + ], + "exclude": [ + ".git", + "node_modules" + ] +} \ No newline at end of file