Find nearest node when formatting range (#1659)

* Move range extension code into helper functions

* Add findNodeByOffset() helper

This was adapted from https://github.com/prettier/prettier/pull/1637/commits/cbc1929c64db558b4e444500bca3d2ce1d550359

* Test extending formatted range to entire node

* Fix extending formatted range to entire node

* Fix style errors

* Add run_file test function

This makes it possible to use different options on a per-file basis,
which is useful for things like range formatting tests.

* Test extending the format range to nearest parseable node

This means you can select the range of a `catch` clause, attempt to
format it, and have the `try` formatted as well, rather than throwing an
error.

* Fix extending the format range to nearest parseable node

This means you can select the range of a `catch` clause, attempt to
format it, and have the `try` formatted as well, rather than throwing an
error.

* Test that external indentation is left alone when formatting a range

* Preserve external indentation when formatting a range

* Dedupe range formatting traversal callbacks

* Simplify range formatting traversal using ast-types

See https://github.com/prettier/prettier/pull/1659#issuecomment-302974798

* Make range formatting traversal more efficient

There's less unnecessary parsing now.

* Fix style errors

* Add test where range expanding fails

* Fix test where range expanding fails

This makes sure that the range contains the entirety of the nodes
containing each of the range's endpoints.

* Add test for expanding range to beginning of line

* Pass test for expanding range to beginning of line

This makes it so that indentation before the range is added to the
formatted range.

* Don't parse/stringify AST to detect pre-range indentation

See https://github.com/prettier/prettier/pull/1659#discussion_r117790671

* When formatting a range, find closest statement rather than parsing

The `isStatement` implementation came from `docs/prettier.min.js`.

See https://github.com/prettier/prettier/pull/1659#issuecomment-303154770

* Add test for range-formatting a FunctionDeclaration's argument object

* Include FunctionDeclaration when searching for nearest node to range-format

From the spec, a Program is a series of SourceElements, each of which is
either a Statement or a FunctionDeclaration. See
https://www.ecma-international.org/ecma-262/5.1/#sec-A.5

* Remove unnecessary try-catch

See https://github.com/prettier/prettier/pull/1659#discussion_r117810096

* Add tests with multiple statements

See https://github.com/prettier/prettier/pull/1659#discussion_r117810753

* Remove unnecessary arguments from findNodeByOffset()

* Contract format range to ensure it starts/ends on nodes

* Specify test ranges in the fixtures

See https://github.com/prettier/prettier/pull/1659#discussion_r117811186

* Remove unnecessary comments from range fixtures

* Remove run_file test function

It's no longer used. This essentially reverts
8241216e68f2e0da997a4f558b03658d642c89a2

* Update range formatting docs

Clarify that the range expands to the nearest statement, and not to the
end of the line.

* Don't overwrite test options when detecting range

Now that multiple files share the same object again, we shouldn't be
re-assigning to it.

* Reuse already-read fixtures for AST_COMPARE=1 tests

* Remove `run_file` global from test eslintrc

* Undo package.json churn

`yarn` reformatted it before, but the whitespace visually sets off the
comment, so let's put it back how it was before.

See https://github.com/prettier/prettier/pull/1659#discussion_r117864655

* Remove misleading comments from isSourceElement

See https://github.com/prettier/prettier/pull/1659#discussion_r117865196

* Loop backwards through string instead of reversing it

See https://github.com/prettier/prettier/pull/1659#discussion_r117865759

* Don't recompute indent string when formatting range

See https://github.com/prettier/prettier/pull/1659#discussion_r117867268

* Rename findNodeByOffset to findNodeAtOffset

"Find x by y" is the common usage for finding an `x` by a key `y`.
However, since "by" has positional meaning, let's use "at" instead.

See https://github.com/prettier/prettier/pull/1659#discussion_r117865121

* Always trimRight() in formatRange and explain why

See https://github.com/prettier/prettier/pull/1659#discussion_r117864635

* Test formatting a range that crosses AST levels

See https://github.com/prettier/prettier/pull/1659#issuecomment-303243688

* Fix formatting a range that crosses AST levels

See https://github.com/prettier/prettier/pull/1659#issuecomment-303243688

* Remove unnecessary try-catch

See https://github.com/prettier/prettier/pull/1659/files/e52db5e9f9ec4af599f658da03739e206dd4578c#r117878763

* Add test demonstrating range formatting indent detection

* Detect alignment from text on line before range, but don't reformat it

This avoids reformatting non-indentation that happens to precede the
range on the same line, while still correctly indenting the range based
on it.

See https://github.com/prettier/prettier/pull/1659#discussion_r117881430
This commit is contained in:
Joseph Frazier authored and Christopher Chedeau committed 2017-05-23 07:43:58 -07:00
1 parent 316c76172b
commit 330601c77d
17 files changed
+403 -34

No files matched your search

+143 -21
View File
@@ -3,7 +3,7 @@
const comments = require("./src/comments");
const version = require("./package.json").version;
const printAstToDoc = require("./src/printer").printAstToDoc;
const getAlignmentSize = require("./src/util").getAlignmentSize;
const util = require("./src/util");
const printDocToString = require("./src/doc-printer").printDocToString;
const normalizeOptions = require("./src/options").normalize;
const parser = require("./src/parser");
@@ -52,41 +52,159 @@ function ensureAllCommentsPrinted(astComments) {
function format(text, opts, addAlignmentSize) {
addAlignmentSize = addAlignmentSize || 0;
const formattedRangeOnly = formatRange(text, opts);
const ast = parser.parse(text, opts);
const formattedRangeOnly = formatRange(text, opts, ast);
if (formattedRangeOnly) {
return formattedRangeOnly;
}
const ast = parser.parse(text, opts);
const astComments = attachComments(text, ast, opts);
const doc = printAstToDoc(ast, opts, addAlignmentSize);
opts.newLine = guessLineEnding(text);
const str = printDocToString(doc, opts);
ensureAllCommentsPrinted(astComments);
// Remove extra leading newline as well as the added indentation after last newline
// Remove extra leading indentation as well as the added indentation after last newline
if (addAlignmentSize > 0) {
return str.slice(opts.newLine.length).trimRight() + opts.newLine;
return str.trim() + opts.newLine;
}
return str;
}
function formatRange(text, opts) {
// Use `Math.min` since `lastIndexOf` returns 0 when `rangeStart` is 0
const rangeStart = Math.min(
opts.rangeStart,
text.lastIndexOf("\n", opts.rangeStart) + 1
);
// Use `text.length - 1` as the maximum since `indexOf` returns -1 if `fromIndex >= text.length`
const fromIndex = Math.min(opts.rangeEnd, text.length - 1);
const nextNewLineIndex = text.indexOf("\n", fromIndex);
const rangeEnd = (nextNewLineIndex < 0 ? fromIndex : nextNewLineIndex) + 1; // Add one to make rangeEnd exclusive
function findSiblingAncestors(startNodeAndParents, endNodeAndParents) {
let resultStartNode = startNodeAndParents.node;
let resultEndNode = endNodeAndParents.node;
if (0 < rangeStart || rangeEnd < text.length) {
const rangeString = text.substring(rangeStart, rangeEnd);
const alignmentSize = getAlignmentSize(
rangeString.slice(0, rangeString.search(/[^ \t]/)),
opts.tabWidth
for (const endParent of endNodeAndParents.parentNodes) {
if (util.locStart(endParent) >= util.locStart(startNodeAndParents.node)) {
resultEndNode = endParent;
} else {
break;
}
}
for (const startParent of startNodeAndParents.parentNodes) {
if (util.locEnd(startParent) <= util.locEnd(endNodeAndParents.node)) {
resultStartNode = startParent;
} else {
break;
}
}
return {
startNode: resultStartNode,
endNode: resultEndNode
};
}
function findNodeAtOffset(node, offset, parentNodes) {
parentNodes = parentNodes || [];
const start = util.locStart(node);
const end = util.locEnd(node);
if (start <= offset && offset <= end) {
for (const childNode of comments.getSortedChildNodes(node)) {
const childResult = findNodeAtOffset(
childNode,
offset,
[node].concat(parentNodes)
);
if (childResult) {
return childResult;
}
}
if (isSourceElement(node)) {
return {
node: node,
parentNodes: parentNodes
};
}
}
}
// See https://www.ecma-international.org/ecma-262/5.1/#sec-A.5
function isSourceElement(node) {
if (node == null) {
return false;
}
switch (node.type) {
case "FunctionDeclaration":
case "BlockStatement":
case "BreakStatement":
case "ContinueStatement":
case "DebuggerStatement":
case "DoWhileStatement":
case "EmptyStatement":
case "ExpressionStatement":
case "ForInStatement":
case "ForStatement":
case "IfStatement":
case "LabeledStatement":
case "ReturnStatement":
case "SwitchStatement":
case "ThrowStatement":
case "TryStatement":
case "VariableDeclaration":
case "WhileStatement":
case "WithStatement":
return true;
}
return false;
}
function calculateRange(text, opts, ast) {
// Contract the range so that it has non-whitespace characters at its endpoints.
// This ensures we can format a range that doesn't end on a node.
const rangeStringOrig = text.slice(opts.rangeStart, opts.rangeEnd);
const startNonWhitespace = Math.max(
opts.rangeStart + rangeStringOrig.search(/\S/),
opts.rangeStart
);
let endNonWhitespace;
for (
endNonWhitespace = opts.rangeEnd;
endNonWhitespace > opts.rangeStart;
--endNonWhitespace
) {
if (text[endNonWhitespace - 1].match(/\S/)) {
break;
}
}
const startNodeAndParents = findNodeAtOffset(ast, startNonWhitespace);
const endNodeAndParents = findNodeAtOffset(ast, endNonWhitespace);
const siblingAncestors = findSiblingAncestors(
startNodeAndParents,
endNodeAndParents
);
const startNode = siblingAncestors.startNode;
const endNode = siblingAncestors.endNode;
const rangeStart = Math.min(util.locStart(startNode), util.locStart(endNode));
const rangeEnd = Math.max(util.locEnd(startNode), util.locEnd(endNode));
return {
rangeStart: rangeStart,
rangeEnd: rangeEnd
};
}
function formatRange(text, opts, ast) {
if (0 < opts.rangeStart || opts.rangeEnd < text.length) {
const range = calculateRange(text, opts, ast);
const rangeStart = range.rangeStart;
const rangeEnd = range.rangeEnd;
const rangeString = text.slice(rangeStart, rangeEnd);
// Try to extend the range backwards to the beginning of the line.
// This is so we can detect indentation correctly and restore it.
// Use `Math.min` since `lastIndexOf` returns 0 when `rangeStart` is 0
const rangeStart2 = Math.min(
rangeStart,
text.lastIndexOf("\n", rangeStart) + 1
);
const indentString = text.slice(rangeStart2, rangeStart);
const alignmentSize = util.getAlignmentSize(indentString, opts.tabWidth);
const rangeFormatted = format(
rangeString,
@@ -98,7 +216,11 @@ function formatRange(text, opts) {
alignmentSize
);
return text.slice(0, rangeStart) + rangeFormatted + text.slice(rangeEnd);
// Since the range contracts to avoid trailing whitespace,
// we need to remove the newline that was inserted by the `format` call.
const rangeTrimmed = rangeFormatted.trimRight();
return text.slice(0, rangeStart) + rangeTrimmed + text.slice(rangeEnd);
}
}