Adding a new layout strategy to Prettier
At Geckoboard, we have regular “innovation” days where we get to work on whatever we want. Recently, I’ve been spending that time working on Prettier, an open source JavaScript formatter that we use on all our code.
Specifically I’ve added a new layout strategy to Prettier to improve text formatting in JSX.
Prettier
At a base level Prettier works by splitting your code into separate groups. If a group fits on a single line, then it outputs a single line.
foo(arg1, arg2, arg3, arg4);
But when a group grows too long for a single line it outputs it across multiple lines. This generally results in outputting children one per line.
foo(
reallyLongArg(),
omgSoManyParameters(),
IShouldRefactorThis(),
isThereSeriouslyAnotherOne()
);
This works well for code, but doesn’t handle the more textual nature of JSX very well. Turning this:
<div>
Please state your <b>name</b> and <b>occupation</b> for the board.
</div>
Into this:
<div>
Please state your
{" "}
<b>name</b>
{" "}
and
{" "}
<b>occupation</b>
{" "}
for the board.
</div>
Filling a line with text
Instead of splitting each item onto its own line we want to use a fill strategy. Adding as many children to a line as will fit before adding a new line and filling up the following line.
So our long line of text:
<div>
Some text that is too long to fit on a single line.
</div>
Is output as:
<div>
Some text that is too long to fit
on a single line.
</div>
Prettier uses an intermediate document representation to encode how we want to layout our code.
We could imagine our simple fill strategy using the following document representation:
fill(['Some', 'text', 'that', 'is', 'too', long', 'to', 'fit', 'on', 'a', 'single', 'line.']);
Adding this new fill strategy and its document representation is what I’ve spent the last few innovation days working on. It was recently merged into Prettier and released as part of Prettier 1.4
How does it work?
Our algorithm to generate output from a fill document representation initially seems pretty simple. If an item would fit on the current line then output a space and the item, otherwise output a new line and the item.
Conceptually very simple, but when dealing with JSX where text and tags can be interleaved it gets a little trickier.
When a tag ends up split across multiple lines it doesn’t look nice to have text immediately follow the closing tag.
<div>
Some text that is too long to fit
<a href="https://example.com/">
on
</a> a single line
</div>
We want a line break before and after.
<div>
Some text that is too long to fit
<a href="https://example.com/">
on
</a>
a single line
</div>
To accomplish this we only want to put a space between two items if they both fit on a single line. Otherwise we separate them with a new line.
This ensures that an item which is split across multiple lines has a new line before and after it.
The algorithm to do this is roughly the following:
- If the first and second item fit on a single line, output the first and a space.
- Otherwise output the first and a new line.
- Continue as above starting with the second item.
Or in code:
const fill = ([first, second, ...rest]) => {
if (fits([first, second])) {
output(first);
output(' ');
} else {
output(first);
output('\n');
}
fill([second, ...rest]);
};
This is the fill algorithm from A Prettier Printer, the Philip Wadler paper that Prettier is based on. You can find an implementation of it in Haskell on page 14.
Handling JSX white space
The JSX white space rules add another wrinkle to our relatively simple algorithm. When two items of text are separated by a space, that space can be replaced with a new line to break the text across lines.
When the JSX is then parsed it converts that new line back into a space.
// These are equivalent
<div>first second</div><div>
first
second
</div>
But a space between two tags or tags and text can’t just be replaced by a new line to split the tags across lines.
When parsing JSX a new line between tags is not converted to a space, it is instead discarded.
When splitting tags onto multiple lines, if they are separated by a space in the input we need to add {" "}
to the output to force a space when rendering.
// Renders as `first second`
<div>
<b>first</b> <b>second</b>
</div>// Renders as `firstsecond` (we don't want this!)
<div>
<b>first</b>
<b>second</b>
</div>// Renders as `first second` (all good)
<div>
<b>first</b>{" "}
<b>second</b>
</div>
So we now have three different white space rules:
' '
or\n
between text items.' '
or{" "}\n
between any other pair of items that started with a space between them.''
or\n
between items that didn’t start with a space between them.
To cope with this our fill document representation needs to be passed both the items to output and a representation of the white space between the items.
Our fill now takes an array of alternating content and white space.
The following JSX
first second <tag /><tag />
would be represented as:
fill('first', line, 'second', jsxWhitespace, '<tag />', softline, '<tag />');// line will output a space when flat and a new line when it breaks.// softline outputs nothing when flat and a new line when it breaks.// jsxWhitespace outputs a space when flat and {" "} and a new line
// when it breaks.
This allows us to encode the complex white space rules for JSX.
Representing the document as alternating content and white space is the final representation I settled on for Prettier. It can handle all the vagaries of JSX while still being generic enough to be used to format other code structures.
Further applications
While Prettier currently only uses the fill strategy for JSX text, we can see how it could be applied elsewhere.
1. Handling Comments
The whitespace would add //
to the start of any new lines when splitting a comment.
Turning this:
// A comment that is too long for a single line
Into this:
// A comment that is too long
// for a single line
Using the following document representation:
const sep = ifBreak(concat([line, '// ']), ' ');
fill(['// first', sep, 'second', sep, 'third']);
2. Handling Strings
The separator would add " +
to the end of the current line and "
to the start of the next line when splitting strings.
Turning this:
const x = "Some text that is too long for a single line";
Into this:
const x =
"Some text that is too long " +
"for a single line";
Using the following document representation:
const sep = ifBreak(concat(['"', line, '+ "']), ' ');
fill(['"first', sep, 'second', sep, 'third"']);
3. Handling Binary expressions
The easy case! We just use line
as the separator as it will use a space when items fits on one line and a line break when they won’t.
Turning this:
const x = first * second * third * fourth * fifth;
Into this:
const x =
first * second * third *
fourth * fifth;
Using the following document representation:
const sep = line;
fill(['a', sep, '+', sep, 'b']);
Recombining split lines
Now that we can split text within JSX, the next step is to have Prettier automatically recombine lines when they can fit on a single line.
Turning this
<div>
Please state your
{" "}
<b>name</b>
{" "}
and
{" "}
<b>occupation</b>
{" "}
for the board of directors.
</div>
Into this:
<div>
Please state your <b>name</b> and <b>occupation</b> for the board
of directors.
</div>
Work is already underway to accomplish this! You can track the progress in this Prettier PR: https://github.com/prettier/prettier/pull/1831
P.S Geckoboard is hiring! Check out our open positions.