|
| 1 | +// LICENSE : MIT |
| 2 | +import { RuleHelper } from "textlint-rule-helper"; |
| 3 | +import { parse } from "@babel/parser"; |
| 4 | +import { ChangeSet, Engine } from "prh"; |
| 5 | +import { |
| 6 | + TextlintRuleContext, |
| 7 | + TextlintRuleOptions, |
| 8 | + TextlintRuleReporter, |
| 9 | + TextlintRuleReportHandler |
| 10 | +} from "@textlint/types"; |
| 11 | +import { ASTNodeTypes, TxtCodeBlockNode } from "@textlint/ast-node-types"; |
| 12 | +import { CommentBlock, CommentLine } from "@babel/types"; |
| 13 | + |
| 14 | +const defaultOptions = { |
| 15 | + checkLink: false, |
| 16 | + checkBlockQuote: false, |
| 17 | + checkEmphasis: false, |
| 18 | + checkHeader: true, |
| 19 | + checkParagraph: true, |
| 20 | + /** |
| 21 | + * Check CodeBlock text |
| 22 | + * Default: [] |
| 23 | + */ |
| 24 | + checkCodeComment: [], |
| 25 | + /** |
| 26 | + * Report parsing error for debug |
| 27 | + */ |
| 28 | + debug: false |
| 29 | +}; |
| 30 | + |
| 31 | +const assertOptions = (options: TextlintRuleOptions) => { |
| 32 | + if (typeof options.ruleContents === "undefined" && typeof options.rulePaths === "undefined") { |
| 33 | + throw new Error(`textlint-rule-prh require Rule Options. |
| 34 | +Please set .textlintrc: |
| 35 | +{ |
| 36 | + "rules": { |
| 37 | + "prh": { |
| 38 | + "rulePaths" :["path/to/prh.yml"] |
| 39 | + } |
| 40 | + } |
| 41 | +} |
| 42 | +`); |
| 43 | + } |
| 44 | +}; |
| 45 | + |
| 46 | +const createIgnoreNodeTypes = (options: TextlintRuleOptions, Syntax: typeof ASTNodeTypes) => { |
| 47 | + const nodeTypes = []; |
| 48 | + if (!options.checkLink) { |
| 49 | + nodeTypes.push(Syntax.Link); |
| 50 | + } |
| 51 | + if (!options.checkBlockQuote) { |
| 52 | + nodeTypes.push(Syntax.BlockQuote); |
| 53 | + } |
| 54 | + if (!options.checkEmphasis) { |
| 55 | + nodeTypes.push(Syntax.Emphasis); |
| 56 | + } |
| 57 | + if (!options.checkHeader) { |
| 58 | + nodeTypes.push(Syntax.Header); |
| 59 | + } |
| 60 | + if (!options.checkParagraph) { |
| 61 | + nodeTypes.push(Syntax.Paragraph); |
| 62 | + } |
| 63 | + return nodeTypes; |
| 64 | +}; |
| 65 | + |
| 66 | +/** |
| 67 | + * for each diff of changeSet |
| 68 | + */ |
| 69 | +const forEachChange = ( |
| 70 | + changeSet: ChangeSet, |
| 71 | + str: string, |
| 72 | + onChangeOfMatch: (arg: { |
| 73 | + matchStartIndex: number; |
| 74 | + matchEndIndex: number; |
| 75 | + actual: string; |
| 76 | + expected: string; |
| 77 | + prh?: string; |
| 78 | + }) => void |
| 79 | +) => { |
| 80 | + const sortedDiffs = changeSet.diffs.sort(function (a, b) { |
| 81 | + return a.index - b.index; |
| 82 | + }); |
| 83 | + let delta = 0; |
| 84 | + sortedDiffs.forEach(function (diff) { |
| 85 | + // TODO: What should I use `!` or `?` |
| 86 | + const result = diff.expected!.replace(/\$([0-9]{1,2})/g, function (match, g1) { |
| 87 | + const index = parseInt(g1); |
| 88 | + if (index === 0 || diff.matches.length - 1 < index) { |
| 89 | + return match; |
| 90 | + } |
| 91 | + return diff.matches[index] || ""; |
| 92 | + }); |
| 93 | + // matchStartIndex/matchEndIndex value is original position, not replaced position |
| 94 | + // textlint use original position |
| 95 | + const matchStartIndex = diff.index; |
| 96 | + const matchEndIndex = matchStartIndex + diff.matches[0].length; |
| 97 | + // actual => expected |
| 98 | + const actual = str.slice(diff.index + delta, diff.index + delta + diff.matches[0].length); |
| 99 | + // TODO: What should I use `!` or `?` |
| 100 | + const prh = diff.rule!.raw.prh || null; |
| 101 | + onChangeOfMatch({ |
| 102 | + matchStartIndex, |
| 103 | + matchEndIndex, |
| 104 | + actual: actual, |
| 105 | + expected: result, |
| 106 | + prh |
| 107 | + }); |
| 108 | + str = str.slice(0, diff.index + delta) + result + str.slice(diff.index + delta + diff.matches[0].length); |
| 109 | + delta += result.length - diff.matches[0].length; |
| 110 | + }); |
| 111 | +}; |
| 112 | + |
| 113 | +/** |
| 114 | + * [Markdown] get actual code value from CodeBlock node |
| 115 | + * @param node |
| 116 | + * @param raw raw value include CodeBlock syntax |
| 117 | + */ |
| 118 | +function getUntrimmedCode(node: TxtCodeBlockNode, raw: string): string { |
| 119 | + if (node.type !== "CodeBlock") { |
| 120 | + return node.value; |
| 121 | + } |
| 122 | + // Space indented CodeBlock that has not lang |
| 123 | + if (!node.lang) { |
| 124 | + return node.value; |
| 125 | + } |
| 126 | + |
| 127 | + // If it is not markdown codeBlock, just use node.value |
| 128 | + if (!(raw.startsWith("```") && raw.endsWith("```"))) { |
| 129 | + if (node.value.endsWith("\n")) { |
| 130 | + return node.value; |
| 131 | + } |
| 132 | + return node.value + "\n"; |
| 133 | + } |
| 134 | + // Markdown(remark) specific hack |
| 135 | + // https://github.com/wooorm/remark/issues/207#issuecomment-244620590 |
| 136 | + const lines = raw.split("\n"); |
| 137 | + // code lines without the first line and the last line |
| 138 | + const codeLines = lines.slice(1, lines.length - 1); |
| 139 | + // add last new line |
| 140 | + // \n``` |
| 141 | + return codeLines.join("\n") + "\n"; |
| 142 | +} |
| 143 | + |
| 144 | +export function createReporter( |
| 145 | + createPrhEngine: (context: TextlintRuleContext, options: TextlintRuleOptions) => Engine |
| 146 | +): TextlintRuleReporter { |
| 147 | + function reporter(context: TextlintRuleContext, userOptions: TextlintRuleOptions = {}): TextlintRuleReportHandler { |
| 148 | + assertOptions(userOptions); |
| 149 | + const options = Object.assign({}, defaultOptions, userOptions); |
| 150 | + |
| 151 | + const prhEngine = createPrhEngine(context, options); |
| 152 | + |
| 153 | + const helper = new RuleHelper(context); |
| 154 | + const { Syntax, getSource, report, fixer, RuleError } = context; |
| 155 | + const ignoreNodeTypes = createIgnoreNodeTypes(options, Syntax); |
| 156 | + const codeCommentTypes = options.checkCodeComment ? options.checkCodeComment : defaultOptions.checkCodeComment; |
| 157 | + const isDebug = options.debug ? options.debug : defaultOptions.debug; |
| 158 | + return { |
| 159 | + [Syntax.Str](node) { |
| 160 | + if (helper.isChildNode(node, ignoreNodeTypes)) { |
| 161 | + return; |
| 162 | + } |
| 163 | + const text = getSource(node); |
| 164 | + // to get position from index |
| 165 | + // https://github.com/prh/prh/issues/29 |
| 166 | + const dummyFilePath = ""; |
| 167 | + const makeChangeSet = prhEngine.makeChangeSet(dummyFilePath, text); |
| 168 | + forEachChange(makeChangeSet, text, ({ matchStartIndex, matchEndIndex, actual, expected, prh }) => { |
| 169 | + // If result is not changed, should not report |
| 170 | + if (actual === expected) { |
| 171 | + return; |
| 172 | + } |
| 173 | + |
| 174 | + const suffix = prh !== null ? "\n" + prh : ""; |
| 175 | + const messages = actual + " => " + expected + suffix; |
| 176 | + report( |
| 177 | + node, |
| 178 | + new RuleError(messages, { |
| 179 | + index: matchStartIndex, |
| 180 | + fix: fixer.replaceTextRange([matchStartIndex, matchEndIndex], expected) |
| 181 | + }) |
| 182 | + ); |
| 183 | + }); |
| 184 | + }, |
| 185 | + [Syntax.CodeBlock](node) { |
| 186 | + const lang = node.lang; |
| 187 | + if (!lang) { |
| 188 | + return; |
| 189 | + } |
| 190 | + const checkLang = codeCommentTypes.some((type) => { |
| 191 | + return type === node.lang; |
| 192 | + }); |
| 193 | + if (!checkLang) { |
| 194 | + return; |
| 195 | + } |
| 196 | + const rawText = getSource(node); |
| 197 | + const codeText = getUntrimmedCode(node, rawText); |
| 198 | + const sourceBlockDiffIndex = rawText !== node.value ? rawText.indexOf(codeText) : 0; |
| 199 | + const reportComment = (comment: CommentBlock | CommentLine) => { |
| 200 | + // to get position from index |
| 201 | + // https://github.com/prh/prh/issues/29 |
| 202 | + const dummyFilePath = ""; |
| 203 | + // TODO: trim option for value? |
| 204 | + const text = comment.value; |
| 205 | + const makeChangeSet = prhEngine.makeChangeSet(dummyFilePath, text); |
| 206 | + forEachChange(makeChangeSet, text, ({ matchStartIndex, matchEndIndex, actual, expected, prh }) => { |
| 207 | + // If result is not changed, should not report |
| 208 | + if (actual === expected) { |
| 209 | + return; |
| 210 | + } |
| 211 | + |
| 212 | + const suffix = prh !== null ? "\n" + prh : ""; |
| 213 | + const messages = actual + " => " + expected + suffix; |
| 214 | + const commentIdentifier = comment.type === "CommentBlock" ? "/*" : "//"; |
| 215 | + // TODO: What should I use `!` or `?` |
| 216 | + const commentStart = sourceBlockDiffIndex + comment.start! + commentIdentifier.length; |
| 217 | + report( |
| 218 | + node, |
| 219 | + new RuleError(messages, { |
| 220 | + index: commentStart + matchStartIndex, |
| 221 | + fix: fixer.replaceTextRange( |
| 222 | + [commentStart + matchStartIndex, commentStart + matchEndIndex], |
| 223 | + expected |
| 224 | + ) |
| 225 | + }) |
| 226 | + ); |
| 227 | + }); |
| 228 | + }; |
| 229 | + try { |
| 230 | + const AST = parse(codeText, { |
| 231 | + ranges: true, |
| 232 | + allowReturnOutsideFunction: true, |
| 233 | + allowAwaitOutsideFunction: true, |
| 234 | + allowUndeclaredExports: true, |
| 235 | + allowSuperOutsideMethod: true |
| 236 | + }); |
| 237 | + const comments = AST.comments; |
| 238 | + if (!comments) { |
| 239 | + return; |
| 240 | + } |
| 241 | + comments.forEach((comment) => { |
| 242 | + reportComment(comment); |
| 243 | + }); |
| 244 | + } catch (error) { |
| 245 | + if (isDebug) { |
| 246 | + console.error(error); |
| 247 | + //@ts-expect-error |
| 248 | + report(node, new RuleError(error.message)); |
| 249 | + } |
| 250 | + } |
| 251 | + } |
| 252 | + }; |
| 253 | + } |
| 254 | + return reporter; |
| 255 | +} |
0 commit comments