packages/flow-dev-tools/src/comment/commentMutator.js (302 lines of code) (raw):

/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @flow * @format */ import type {Context} from './getContext'; import type {FlowLoc} from '../flowResult'; const {getNodeAtRange} = require('./getPathToLoc'); const {NORMAL, JSX, JSX_FRAGMENT, TEMPLATE} = require('./getContext'); const {format} = require('util'); function bufferCharAt(buf: Buffer, pos: number): string { return buf.toString('utf8', pos, pos + 1); } const flowlintRegex = /^[ \t\n\r*]*flowlint(-line|-next-line)?\b/; function isLintSuppression(commentAST: {value: string}): boolean { return flowlintRegex.test(commentAST.value); } const newlineRegex = /[\u2029\u2028\r\n]/; const edible = /[\t ]/; /* This is the most confusing part of this command. A simple version of this * code would just remove exact characters of a comment. This might leave * extra whitespace and blank lines. So this code tries to expand the range * we remove to cover the following cases * * /* Comment with nothing before or after it * / * var foo; /* Comment with something before it * / * /* Comment with something after it * / var foo; * var foo; /* Comment with something before and after it * / var bar; * * The TL;DR is that we only want to expand the range and remove the newline * in the case where there is nothing before or after it */ function expandComment( contents: Buffer, startOffset: number, endOffset: number, commentAST: {value: string, range: [number, number]} | void, ast: Object, ) { const length = contents.length; const emptyFlowlintRegex = /^[ \t\n\r*]*flowlint(-line|-next-line)?[ \t\n\r*]*$/; if (commentAST && isLintSuppression(commentAST)) { // We're operating on a flowlint comment // All comments start with 2 chars const [commentStartOffset, commentEndOffset] = commentAST.range; const commentValueOffset = commentStartOffset + 2; if ( commentAST.value[startOffset - commentValueOffset - 1].match(/[ \t\n\r]/) ) { // Remove the preceding whitespace when removing an element from the flowlint startOffset--; } const newCommentValue = commentAST.value.slice(0, startOffset - commentValueOffset) + commentAST.value.slice(endOffset - commentValueOffset); if (newCommentValue.match(emptyFlowlintRegex)) { startOffset = commentStartOffset; endOffset = commentEndOffset; } else { commentAST.range[1] -= endOffset - startOffset; commentAST.value = newCommentValue; // All we're doing is removing a piece of a flowlint comment. Return immediately return [startOffset, endOffset]; } } let origBeforeStart = startOffset - 1; let origAfterEnd = endOffset; // Find the next interesting characters before and after the removed comment let beforeStart = origBeforeStart; let afterEnd = origAfterEnd; while ( beforeStart >= 0 && bufferCharAt(contents, beforeStart).match(edible) ) { beforeStart--; } while (afterEnd < length && bufferCharAt(contents, afterEnd).match(edible)) { afterEnd++; } if ( beforeStart >= 0 && afterEnd < length && bufferCharAt(contents, beforeStart) === '{' && bufferCharAt(contents, afterEnd) === '}' ) { // If this is JSX, then the curly braces start and stop a JSXExpressionContainer const node = getNodeAtRange([beforeStart, afterEnd + 1], ast); if (node && node.type == 'JSXExpressionContainer') { // Consume the curly braces beforeStart--; afterEnd++; // If we do reset our spacing, we'll at least eat the curly braces origBeforeStart = beforeStart; origAfterEnd = afterEnd; while ( beforeStart >= 0 && bufferCharAt(contents, beforeStart).match(edible) ) { beforeStart--; } while ( afterEnd < length && bufferCharAt(contents, afterEnd).match(edible) ) { afterEnd++; } } } if (beforeStart < 0 || bufferCharAt(contents, beforeStart) === '\n') { // There's nothing before the removed comment on this line if (afterEnd > length || bufferCharAt(contents, afterEnd) === '\n') { // The line is completely empty. Let's remove a newline from the start or // end of the line if (afterEnd < length) { afterEnd++; } else if (beforeStart >= 0) { beforeStart--; } } else { // There's something after the comment. We shouldn't remove // preceding whitespace thanks to indentation beforeStart = origBeforeStart; } } else if (afterEnd >= length || bufferCharAt(contents, afterEnd) === '\n') { // There's something preceding the comment but nothing afterwards. We can // just remove the rest of the line } else { beforeStart = origBeforeStart; afterEnd = origAfterEnd; } // The range should be [start, end) - that is includes start, excludes end return [beforeStart + 1, afterEnd]; } function findStartOfLine(contents: Buffer, startOffset: number): number { // if startOffset is already a newline, that's not the start of the line, // it's the end of the line. so start from the character before. let start = startOffset - 1; while (start >= 0 && !bufferCharAt(contents, start).match(newlineRegex)) { start--; } return start + 1; } function findEndOfLine(contents: Buffer, startOffset): number { let start = startOffset; while ( start < contents.length && !bufferCharAt(contents, start).match(newlineRegex) ) { start++; } return start; } function insertCommentToText( contents: Buffer, startOffset: number, comment: string, ): Buffer { return Buffer.concat([ contents.slice(0, startOffset), Buffer.from(comment), contents.slice(startOffset), ]); } function addCommentToText( contents: Buffer, loc: FlowLoc, inside: Context, comments: Array<string>, ast: any, startOfLine?: number, ): Buffer { let startOffset; let start; if (startOfLine == null) { startOffset = loc.start.offset; start = findStartOfLine(contents, startOffset); } else { start = startOfLine; startOffset = startOfLine; } const endOfLine = findEndOfLine(contents, startOffset); let line = contents.toString('utf8', start, endOfLine); const inJSX = inside === JSX_FRAGMENT || inside === JSX; if (inside === NORMAL) { return insertCommentToText( contents, start, formatComment(comments, line, {jsx: false}).join('\n') + '\n', ); } else if (inJSX && ast.type === 'JSXElement') { return insertCommentToText( contents, start, formatComment(comments, line, {jsx: true}).join('\n') + '\n', ); } else if ( inside === TEMPLATE || (inJSX && ast.type === 'JSXExpressionContainer') ) { /* Ok, so we have something like * * <jsx> * {10 * 'hello'} * <jsx> * * We need to stick the comment inside the expression container. * So the above example turns into * * <jsx> * { * // Comment * 10 * 'hello'} * <jsx> * * Same thing if we have something like * * var str = `hello * ${10 * 'hello'} * `; * * We need to stick the comment inside of the template element. So the * above example turns into * * var str = `hello * ${ * // Comment * 10 * 'hello'} * `; */ const start_col = inJSX ? ast.loc.start.column + 1 : ast.loc.start.column; const part1 = line.substr(0, start_col); const match = part1.match(/^ */); const padding = match ? match[0] + ' ' : ' '; const part2 = padding + line.substr(start_col); const newCode = [] // $FlowFixMe unsealed object but should just be {||} .concat([part1], formatComment(comments, part2, {}), [part2]) .join('\n'); return Buffer.concat([ contents.slice(0, start), Buffer.from(newCode), contents.slice(endOfLine), ]); } else if (inJSX && ast.type === 'JSXText') { /* Ignore the case where the error's loc starts after the last non-whitespace * character of the line. This can occur when an error's loc spans the * children of a JSX element. We cannot safely add a comment to the line * before the error's loc, as it may be contained within a JSXOpeningElement. * * Loc * | * v * <jsx> * JSXElement, JSXExpressionContainer, or JSXText here... * <jsx> */ let firstNonWhitespaceCharacter = startOffset; let atEndOfLine = true; while (firstNonWhitespaceCharacter < contents.length) { if ( bufferCharAt(contents, firstNonWhitespaceCharacter).match(newlineRegex) ) { break; } else if ( bufferCharAt(contents, firstNonWhitespaceCharacter).match(edible) ) { firstNonWhitespaceCharacter++; } else { atEndOfLine = false; break; } } if (atEndOfLine) { return contents; } /* * Otherwise add an expression container above the text with our comment. * * <jsx> * {// Comment} * JSX Text Here * <jsx> */ return insertCommentToText( contents, start, formatComment(comments, line, {jsx: true}).join('\n') + '\n', ); } return contents; } function removeUnusedErrorSuppressionFromText( contents: Buffer, startOffset: number, endOffset: number, commentAST: Object | void, ast: Object, ): Buffer { // remove the comment and surrounding whitespace let [start, end] = expandComment( contents, startOffset, endOffset, commentAST, ast, ); return Buffer.concat([contents.slice(0, start), contents.slice(end)]); } /* Take up to `max` characters from str, trying to split at a space or dash or * something like that. */ function splitAtWord(str: string, max: number): [string, string] { let ret = ''; let maybe = ''; for (let i = 0; i < max; i++) { if (i === str.length) { ret += maybe; break; } maybe += str[i]; if (str[i].match(/[- _\t]/)) { ret += maybe; maybe = ''; } } // If there were no breaks then take it all if (ret === '') { ret = maybe; } return [ret, str.substr(ret.length)]; } /* Figures out how to pad the comment and split it into multiple lines */ function formatComment( comments: Array<string>, line: string, args: {| jsx?: boolean, |}, ): Array<string> { const {jsx = false} = args; const match = line.match(/^ */); let padding = match ? match[0] : ''; padding.length > 40 && (padding = ' '); if (jsx === false) { const singleLineComments = comments.map(comment => format('%s// %s', padding, comment), ); const allUnder80 = comments.reduce( (acc, comment) => acc && comment.length <= 80, true, ); if (allUnder80) { return singleLineComments; } } const commentLines = []; const firstLinePrefix = format(!jsx ? '%s/* ' : '%s /* ', padding); for (let comment of comments) { let firstLineComment; [firstLineComment, comment] = splitAtWord( comment.trim(), 80 - firstLinePrefix.length, ); commentLines.push(firstLinePrefix + firstLineComment.trim()); const prefix = format(!jsx ? '%s * ' : '%s * ', padding); let commentLine; while (comment.length > 0) { [commentLine, comment] = splitAtWord(comment.trim(), 80 - prefix.length); commentLines.push(prefix + commentLine.trim()); } if (commentLines[commentLines.length - 1].length < 76) { const last = commentLines.pop(); commentLines.push(format('%s */', last)); } else { commentLines.push(format('%s */', padding)); } } if (jsx) { commentLines[0] = format('%s{%s', padding, commentLines[0].trim()); commentLines[commentLines.length - 1] = format( '%s}', commentLines[commentLines.length - 1], ); } return commentLines; } module.exports = { isLintSuppression, findStartOfLine, insertCommentToText, addCommentToText, removeUnusedErrorSuppressionFromText, formatComment, };