Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 132 additions & 18 deletions packages/tsrx-prettier-plugin/src/jsx.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,18 @@

import { builders, utils } from 'prettier/doc';

const { conditionalGroup, cursor, fill, group, hardline, ifBreak, indent, line, softline } =
builders;
const {
breakParent,
conditionalGroup,
cursor,
fill,
group,
hardline,
ifBreak,
indent,
line,
softline,
} = builders;
const { findInDoc, willBreak } = utils;

/**
Expand Down Expand Up @@ -76,16 +86,21 @@ function isJsxWhitespaceExpression(node) {
);
}

/** @param {Doc} doc */
/**
* Prettier's `isEmptyDoc`: no text, and no line or other printed part.
* `findInDoc` stops at the first result that isn't `undefined`, so an empty
* string must not return one.
* @param {Doc} doc
*/
function isEmptyDoc(doc) {
return !findInDoc(
doc,
(part) =>
typeof part === 'string'
(typeof part === 'string'
? part !== ''
: ['trim', 'line-suffix-boundary', 'line', 'break-parent'].includes(
/** @type {{ type: string }} */ (part).type,
),
)) || undefined,
false,
);
}
Expand Down Expand Up @@ -126,7 +141,14 @@ export function printJsxElementInternal(path, options, print) {
// can then print as either a space or `{" "}` when breaking.
node.children = node.children.map((/** @type {Node} */ child) => {
if (isJsxWhitespaceExpression(child)) {
return { type: 'JSXText', value: ' ', raw: ' ' };
// TSRX: a comment prints the `{" "}` beside it (`printCommentChild`).
return {
type: 'JSXText',
value: ' ',
raw: ' ',
tsrxCommentSpace: child.tsrxCommentSpace,
tsrxInCommentRun: child.tsrxInCommentRun,
};
}
return child;
});
Expand All @@ -152,8 +174,12 @@ export function printJsxElementInternal(path, options, print) {

const children = printJsxChildren(path, options, print, whitespace, isFacebookTranslationTag);

const containsText = node.children.some((/** @type {Node} */ child) =>
isMeaningfulJsxText(child),
// TSRX: a comment whose run renders a space prints it as JSX whitespace,
// which needs `fill`, like text.
const containsText = node.children.some(
(/** @type {Node} */ child) =>
isMeaningfulJsxText(child) ||
(child.type === 'TSRXJSXComment' && child.run.space && !child.run.lineBreak),
);

// We can end up we multiple whitespace elements with empty string
Expand Down Expand Up @@ -307,6 +333,8 @@ export function printJsxElementInternal(path, options, print) {
* @returns {Doc[]}
*/
function printJsxChildren(path, options, print, whitespace, isFacebookTranslationTag) {
// TSRX: `{" "}`, written out.
const rawWhitespace = options.singleQuote ? "{' '}" : '{" "}';
/** @type {Doc} */
let prevPart = '';
/** @type {Doc[]} */
Expand Down Expand Up @@ -335,8 +363,16 @@ function printJsxChildren(path, options, print, whitespace, isFacebookTranslatio
printCommentChild(node);
return;
}
// TSRX: the comment beside a `{" "}` prints it, and whitespace in a
// comment's run keeps what the comment printed after itself.
if (
node.tsrxCommentSpace ||
(node.tsrxInCommentRun && node.type === 'JSXText' && !/\n/u.test(getRaw(node)))
) {
return;
}
Comment thread
leonidaz marked this conversation as resolved.
Resolved
const followsComment = afterComment;
afterComment = false;
if (!node.tsrxInCommentRun) afterComment = false;

if (node.type === 'JSXText') {
const text = getRaw(node);
Expand Down Expand Up @@ -418,23 +454,101 @@ function printJsxChildren(path, options, print, whitespace, isFacebookTranslatio
* stays on its own line; one after other content on the same line stays
* there, with the space the source has before it. A line comment always
* ends its line; a block comment keeps what followed it on its line.
*
* The whitespace around a group of comments renders as one run
* (`describeCommentRuns` in `parse.js`), so its outer sides are printed to
* render the same: a run that renders a space keeps it where a line may
* break (as `{" "}` there, like Prettier's JSX whitespace, or a line
* between two words), and a run with a line break keeps one, which the
* start or end of the children only has when the element breaks.
* @param {Node} comment
*/
function printCommentChild(comment) {
const { run } = comment;
const lineAfter = comment.commentType === 'Line' || comment.newlineAfter;
/** @type {Doc} */
let before = comment.newlineBefore ? hardline : comment.spaceBefore ? ' ' : '';
/** @type {Doc} */
const before = comment.newlineBefore ? hardline : comment.spaceBefore ? ' ' : '';
let after = lineAfter ? hardline : comment.spaceAfter ? ' ' : '';
const outerBefore = run.first;
const outerAfter = run.last;
if (!outerBefore) {
// A line comment after another comment starts its own line: on the same
// line, the parser reads it as text.
before =
run.gapBefore === 'line' || comment.commentType === 'Line'
? hardline
: run.gapBefore === 'space'
? ' '
: '';
}
if (
run.space &&
!run.lineBreak &&
run.hardBreak &&
!(run.before === 'text' && run.after === 'text')
) {
// A line break in the run would drop a space beside it, so the space
// the run renders is written out, before the comments.
if (outerBefore) before = comment.newlineBefore ? [rawWhitespace, hardline] : rawWhitespace;
if (outerAfter && !lineAfter) after = comment.spaceAfter ? ' ' : '';
} else if (run.space && !run.lineBreak) {
if (run.before === 'text' && run.after === 'text') {
// Between two words, a line break renders the space too.
if (outerBefore && comment.spaceBefore) before = line;
if (outerAfter && comment.spaceAfter) after = line;
} else if (run.after === 'boundary' || run.before === 'boundary') {
// The start or end of the children has a line break when the element
// breaks, so the space goes there, where it prints as `{" "}`.
if (run.after === 'boundary') {
if (outerAfter) after = whitespace;
if (outerBefore && run.before === 'boundary') before = '';
} else if (outerBefore) {
before = whitespace;
}
} else {
// The space stays on its side, once for all the comments; a line
// break there prints `{" "}`.
if (run.spaceAfter) {
if (outerAfter) after = whitespace;
} else if (outerBefore && comment.spaceBefore) {
before = whitespace;
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
} else if (run.lineBreak) {
// A space beside the start or end of the children renders nothing here.
if (outerBefore && run.before === 'boundary' && !comment.newlineBefore) before = '';
if (outerAfter && run.after === 'boundary' && !lineAfter) after = '';
}
// A line comment after text on its line is read as text, so it starts
// its own line; between two words, the line break renders the space.
if (
outerBefore &&
comment.commentType === 'Line' &&
run.before === 'text' &&
(before === ' ' || before === line || before === '')
) {
before = hardline;
}
// A line comment ends its line.
if (comment.commentType === 'Line') after = hardline;
// The line break at the start or end of the children is only there when
// the element breaks. Without it, a space left in the run would render.
const breaksAtEdge =
run.lineBreak &&
((outerBefore &&
run.before === 'boundary' &&
comment.newlineBefore &&
(after === ' ' || !outerAfter)) ||
(outerAfter && run.after === 'boundary' && lineAfter && (before === ' ' || !outerBefore)));

if (parts.length > 1 && parts.at(-1) === '') {
// Replace the separator printed after the previous child.
parts[parts.length - 2] = before;
} else {
pushLine(before);
}
push(print());
/** @type {Doc} */
const after =
comment.commentType === 'Line' || comment.newlineAfter
? hardline
: comment.spaceAfter
? ' '
: '';
push(breaksAtEdge ? [print(), breakParent] : print());
prevPart = after;
parts.push(after, '');
afterComment = true;
Expand Down
121 changes: 121 additions & 0 deletions packages/tsrx-prettier-plugin/src/parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,123 @@ class Adapter {
node.children = children;
if (children.some((child) => child.type === 'TSRXJSXComment')) {
node.tsrxCommentChildren = true;
this.describeCommentRuns(children, opening.end, closing.start);
}
}

/**
* A comment between children adds nothing to the text around it (#615): the
* whitespace on its two sides is one run, which renders as it would without
* the comment. Without a line break it renders a space; with one it renders
* a space only between two words of text, and nothing beside an element, an
* expression, or the start or end of the children. For each group of
* comments with only whitespace between them, record what the run renders
* and what is beside it, so `printCommentChild` prints a run that renders the
* same.
* @param {Node[]} children
* @param {number} start Where the children start.
* @param {number} end Where the children end.
*/
describeCommentRuns(children, start, end) {
const isBlank = (/** @type {Node | undefined} */ child) =>
child?.type === 'JSXText' && /^[ \t\r\n]*$/u.test(child.value);
// `{" "}` renders a space whatever is around it. One with a comment inside
// is a child like any other, which prints its comment.
const isSpace = (/** @type {Node | undefined} */ child) =>
child?.type === 'JSXExpressionContainer' &&
child.expression.type === 'Literal' &&
child.expression.value === ' ' &&
/^\{[ \t\r\n]*$/u.test(this.text.slice(child.start, child.expression.start)) &&
/^[ \t\r\n]*\}$/u.test(this.text.slice(child.expression.end, child.end));
const isGap = (/** @type {Node | undefined} */ child) => isBlank(child) || isSpace(child);
const kind = (/** @type {Node | undefined} */ child) =>
!child ? 'boundary' : child.type === 'JSXText' ? 'text' : 'node';
for (let first = 0; first < children.length; first++) {
if (children[first].type !== 'TSRXJSXComment') continue;
/** @type {Node[]} */
const group = [children[first]];
let last = first;
for (let next = last + 1; next < children.length; next++) {
if (isGap(children[next])) continue;
if (children[next].type !== 'TSRXJSXComment') break;
group.push(children[next]);
last = next;
}
let previous = first - 1;
while (isGap(children[previous])) previous--;
let following = last + 1;
while (isGap(children[following])) following++;
const before = children[previous];
const after = children[following];
// The whitespace and `{" "}`s beside the comments are the run, which the
// comments print.
let explicitSpace = false;
for (let index = previous + 1; index < following; index++) {
children[index].tsrxInCommentRun = true;
if (isSpace(children[index])) {
children[index].tsrxCommentSpace = true;
explicitSpace = true;
// The comment beside it prints the space on that side.
if (index < first) group[0].spaceBefore = true;
if (index > last) group[group.length - 1].spaceAfter = true;
}
}
const runStart = !before
? start
: before.type === 'JSXText'
? before.start + before.value.replace(/[ \t\r\n]+$/u, '').length
: before.end;
const runEnd = !after
? end
: after.type === 'JSXText'
? after.end - after.value.replace(/^[ \t\r\n]+/u, '').length
: after.start;
// The run's whitespace, without the comments and the `{" "}`s.
let run = '';
let position = runStart;
for (const skipped of children.slice(previous + 1, following)) {
if (skipped.type !== 'TSRXJSXComment' && !isSpace(skipped)) continue;
run += this.text.slice(position, skipped.start);
position = skipped.end;
}
run += this.text.slice(position, runEnd);
const lineBreak = /[\r\n]/u.test(run);
const betweenWords = kind(before) === 'text' && kind(after) === 'text';
const description = {
before: kind(before),
after: kind(after),
// Whether the run renders a space.
space: explicitSpace || (run !== '' && (!lineBreak || betweenWords)),
// Whether a line break in the run is what it renders (nothing, or a
// space between words) rather than a space that must be kept.
lineBreak: lineBreak && !explicitSpace,
// Whether the comments keep a line break in the run: a line comment, or
// one between two comments or between a comment and the child beside
// the run. (One beside the start or end of the children isn't kept
// when the element fits on a line.)
hardBreak:
group.some(
(comment, index) =>
comment.commentType === 'Line' ||
(index > 0 && /[\r\n]/u.test(this.text.slice(group[index - 1].end, comment.start))),
) ||
(before !== undefined && group[0].newlineBefore) ||
(after !== undefined && group[group.length - 1].newlineAfter),
// The one side of the comments where a space the run renders goes.
spaceAfter: group[group.length - 1].spaceAfter,
};
for (const [index, comment] of group.entries()) {
// Between two comments, whatever the source had there (whitespace or
// a `{" "}`) keeps them apart.
const gap = index > 0 ? this.text.slice(group[index - 1].end, comment.start) : '';
comment.run = {
...description,
first: index === 0,
last: index === group.length - 1,
gapBefore: /[\r\n]/u.test(gap) ? 'line' : gap !== '' ? 'space' : '',
};
}
first = last;
}
}

Expand Down Expand Up @@ -589,7 +706,11 @@ class Adapter {
case 'MethodDefinition':
case 'TSAbstractMethodDefinition':
if (node.typeParameters) {
// The function starts at its type parameters, as in typescript-estree,
// so a comment inside them belongs to the function.
node.value.typeParameters = node.typeParameters;
node.value.start = node.typeParameters.start;
if (node.value.range) node.value.range = [node.value.start, node.value.range[1]];
delete node.typeParameters;
}
break;
Expand Down
Loading
Loading