Skip to content

Commit dcbcc5c

Browse files
Peter Stengerclaude
andcommitted
Add print-width-aware wrapping and re-flow for inline text content, bump v0.0.32
Use fill() IR instead of concat() for inline content so long lines wrap at word boundaries respecting print width. Text node source newlines are treated as word boundaries (re-flow), letting the fill algorithm handle all wrapping. Punctuation attaches to preceding content. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5cf6991 commit dcbcc5c

4 files changed

Lines changed: 246 additions & 59 deletions

File tree

lsp/package.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lsp/server/src/formatting/formatters.ts

Lines changed: 148 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { TextDocument } from 'vscode-languageserver-textdocument';
1212
import {
1313
Doc,
1414
concat,
15+
fill,
1516
hardline,
1617
softline,
1718
line,
@@ -20,6 +21,7 @@ import {
2021
text,
2122
empty,
2223
ifBreak,
24+
isLine,
2325
} from './ir';
2426
import {
2527
isBlockLevel,
@@ -502,6 +504,76 @@ export function formatAttribute(node: SyntaxNode, context?: FormatterContext): D
502504
return concat(parts);
503505
}
504506

507+
/**
508+
* Split a single-line text string into alternating words and `line` separators.
509+
* Returns an array of fill-ready parts to spread into `currentLine`.
510+
*/
511+
function textWords(str: string): Doc[] {
512+
const words = str.split(/\s+/).filter((w) => w.length > 0);
513+
if (words.length === 0) return [];
514+
const parts: Doc[] = [words[0]];
515+
for (let i = 1; i < words.length; i++) {
516+
parts.push(line);
517+
parts.push(words[i]);
518+
}
519+
return parts;
520+
}
521+
522+
/**
523+
* Convert inline content parts into a fill Doc that wraps at word boundaries.
524+
*
525+
* `currentLine` is already fill-ready: text nodes are pre-split into
526+
* alternating word/`line` parts by `textWords`, and inter-node gaps are
527+
* `line` separators. This function enforces proper alternating
528+
* content/separator structure, concatenates adjacent content, and attaches
529+
* leading punctuation to the preceding content.
530+
*/
531+
function inlineContentToFill(parts: Doc[]): Doc {
532+
if (parts.length === 0) return empty;
533+
if (parts.length === 1) return parts[0];
534+
535+
const fillParts: Doc[] = [];
536+
for (const item of parts) {
537+
if (isLine(item)) {
538+
// Only push separator after content (skip leading/duplicate separators)
539+
if (fillParts.length > 0 && !isLine(fillParts[fillParts.length - 1])) {
540+
fillParts.push(item);
541+
}
542+
} else {
543+
const lastIdx = fillParts.length - 1;
544+
if (lastIdx >= 0 && !isLine(fillParts[lastIdx])) {
545+
// Adjacent content (no separator) — concat with previous
546+
fillParts[lastIdx] = concat([fillParts[lastIdx], item]);
547+
} else if (
548+
typeof item === 'string' &&
549+
/^[,.:;!?)\]]/.test(item) &&
550+
lastIdx >= 0 &&
551+
isLine(fillParts[lastIdx])
552+
) {
553+
// Punctuation after separator — attach to preceding content
554+
fillParts.pop();
555+
if (fillParts.length > 0) {
556+
fillParts[fillParts.length - 1] = concat([
557+
fillParts[fillParts.length - 1],
558+
item,
559+
]);
560+
} else {
561+
fillParts.push(item);
562+
}
563+
} else {
564+
fillParts.push(item);
565+
}
566+
}
567+
}
568+
569+
// Remove trailing separator
570+
if (fillParts.length > 0 && isLine(fillParts[fillParts.length - 1])) {
571+
fillParts.pop();
572+
}
573+
574+
return fill(fillParts);
575+
}
576+
505577
/**
506578
* Format block-level children with display-aware separators.
507579
*/
@@ -534,15 +606,15 @@ export function formatBlockChildren(
534606

535607
if (!prevTreatAsBlock && !treatAsBlock) {
536608
if (/\s/.test(gap)) {
537-
currentLine.push(text(' '));
609+
currentLine.push(line);
538610
}
539611
}
540612
}
541613

542614
if (treatAsBlock) {
543615
// Flush current inline content
544616
if (currentLine.length > 0) {
545-
const lineContent = trimDoc(concat(currentLine));
617+
const lineContent = trimDoc(inlineContentToFill(currentLine));
546618
if (hasDocContent(lineContent)) {
547619
lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
548620
}
@@ -557,7 +629,7 @@ export function formatBlockChildren(
557629
const isMultiline = node.startPosition.row !== node.endPosition.row;
558630
if (isMultiline) {
559631
if (currentLine.length > 0) {
560-
const lineContent = trimDoc(concat(currentLine));
632+
const lineContent = trimDoc(inlineContentToFill(currentLine));
561633
if (hasDocContent(lineContent)) {
562634
lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
563635
}
@@ -586,56 +658,87 @@ export function formatBlockChildren(
586658

587659
// Check if formatted content contains newlines (multi-line text)
588660
if (typeof formatted === 'string' && formatted.includes('\n')) {
589-
// Split text into lines. The first part joins the current line
590-
// (preserving text flow with preceding inline elements), and the
591-
// last part becomes the new current line (so subsequent inline
592-
// elements like <code> can join it).
593661
const contentLines = formatted.split('\n');
594-
595-
// First part continues the current inline flow
596-
const firstTrimmed = contentLines[0].trim();
597-
if (firstTrimmed) {
598-
currentLine.push(firstTrimmed);
599-
}
600-
601-
// Flush current line before adding subsequent lines
602-
if (currentLine.length > 0) {
603-
const lineContent = trimDoc(concat(currentLine));
604-
if (hasDocContent(lineContent)) {
605-
lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
606-
blankLineBeforeCurrentLine = pendingBlankLine;
607-
pendingBlankLine = false;
662+
const isTextNode = node.type === 'text';
663+
664+
if (isTextNode) {
665+
// Re-flow: treat source newlines as word boundaries, only flush at
666+
// blank lines. This lets the fill algorithm handle all wrapping.
667+
for (let j = 0; j < contentLines.length; j++) {
668+
const trimmed = contentLines[j].trim();
669+
if (!trimmed) {
670+
// Empty line = paragraph break — flush current inline flow
671+
if (currentLine.length > 0) {
672+
const lineContent = trimDoc(inlineContentToFill(currentLine));
673+
if (hasDocContent(lineContent)) {
674+
lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
675+
blankLineBeforeCurrentLine = false;
676+
}
677+
currentLine = [];
678+
}
679+
pendingBlankLine = true;
680+
} else {
681+
if (currentLine.length === 0) {
682+
blankLineBeforeCurrentLine = pendingBlankLine;
683+
pendingBlankLine = false;
684+
}
685+
// Add a line separator between joined source lines (j > 0),
686+
// but not before the first line — it continues the existing flow
687+
if (j > 0 && currentLine.length > 0) {
688+
currentLine.push(line);
689+
}
690+
currentLine.push(...textWords(trimmed));
691+
}
692+
}
693+
} else {
694+
// Non-text nodes (force-inline mustache sections, etc.):
695+
// preserve source newlines as hard line breaks.
696+
const firstTrimmed = contentLines[0].trim();
697+
if (firstTrimmed) {
698+
currentLine.push(firstTrimmed);
608699
}
609-
currentLine = [];
610-
}
611700

612-
// Middle parts (index 1 to length-2) become separate lines
613-
let sawBlankLine = false;
614-
for (let j = 1; j < contentLines.length - 1; j++) {
615-
const trimmed = contentLines[j].trim();
616-
if (trimmed) {
617-
lines.push({ doc: text(trimmed), blankLineBefore: blankLineBeforeCurrentLine || sawBlankLine });
618-
blankLineBeforeCurrentLine = false;
619-
sawBlankLine = false;
620-
} else {
621-
sawBlankLine = true;
701+
if (currentLine.length > 0) {
702+
const lineContent = trimDoc(inlineContentToFill(currentLine));
703+
if (hasDocContent(lineContent)) {
704+
lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
705+
blankLineBeforeCurrentLine = pendingBlankLine;
706+
pendingBlankLine = false;
707+
}
708+
currentLine = [];
622709
}
623-
}
624710

625-
// Last part starts a new current line (subsequent inline elements join it)
626-
if (contentLines.length > 1) {
627-
const lastTrimmed = contentLines[contentLines.length - 1].trim();
628-
if (lastTrimmed) {
629-
blankLineBeforeCurrentLine = sawBlankLine;
630-
sawBlankLine = false;
631-
currentLine = [lastTrimmed];
711+
let sawBlankLine = false;
712+
for (let j = 1; j < contentLines.length - 1; j++) {
713+
const trimmed = contentLines[j].trim();
714+
if (trimmed) {
715+
lines.push({ doc: text(trimmed), blankLineBefore: blankLineBeforeCurrentLine || sawBlankLine });
716+
blankLineBeforeCurrentLine = false;
717+
sawBlankLine = false;
718+
} else {
719+
sawBlankLine = true;
720+
}
632721
}
633-
if (sawBlankLine) {
634-
pendingBlankLine = true;
722+
723+
if (contentLines.length > 1) {
724+
const lastTrimmed = contentLines[contentLines.length - 1].trim();
725+
if (lastTrimmed) {
726+
blankLineBeforeCurrentLine = sawBlankLine;
727+
sawBlankLine = false;
728+
currentLine = [lastTrimmed];
729+
}
730+
if (sawBlankLine) {
731+
pendingBlankLine = true;
732+
}
635733
}
636734
}
637735
} else {
638-
currentLine.push(formatted);
736+
// For text nodes, spread word/line parts directly into currentLine
737+
if (node.type === 'text' && typeof formatted === 'string') {
738+
currentLine.push(...textWords(formatted));
739+
} else {
740+
currentLine.push(formatted);
741+
}
639742
}
640743
}
641744

@@ -644,7 +747,7 @@ export function formatBlockChildren(
644747

645748
// Flush remaining inline content
646749
if (currentLine.length > 0) {
647-
const lineContent = trimDoc(concat(currentLine));
750+
const lineContent = trimDoc(inlineContentToFill(currentLine));
648751
if (hasDocContent(lineContent)) {
649752
lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
650753
}

lsp/server/test/formatting.test.ts

Lines changed: 91 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ describe('Document Formatting', () => {
2424
return edits[0].newText;
2525
}
2626

27+
function formatWithPrintWidth(content: string, printWidth: number, options: FormattingOptions = defaultOptions): string {
28+
const tree = parseText(content);
29+
const document = createMockDocument(content);
30+
const edits = formatDocument(tree, document, options, undefined, printWidth);
31+
expect(edits.length).toBe(1);
32+
return edits[0].newText;
33+
}
34+
2735
describe('HTML elements', () => {
2836
it('keeps short block element on one line', () => {
2937
const result = format('<div>content</div>');
@@ -64,16 +72,16 @@ describe('Document Formatting', () => {
6472
});
6573

6674
it('breaks long text flow', () => {
67-
// This content exceeds 80 chars so the group breaks
75+
// This content exceeds 80 chars so the group breaks, and fill wraps at print width
6876
const result = format('<p>The answer must be a <i>double-precision</i> {{#complex}}or complex{{/complex}} number.</p>');
69-
expect(result).toBe('<p>\n The answer must be a <i>double-precision</i> {{#complex}}or complex{{/complex}} number.\n</p>\n');
77+
expect(result).toBe('<p>\n The answer must be a <i>double-precision</i>\n {{#complex}}or complex{{/complex}} number.\n</p>\n');
7078
});
7179

72-
it('preserves multi-line text content', () => {
73-
// Content on multiple lines should stay on multiple lines
80+
it('reflows multi-line text content', () => {
81+
// Source newlines in text are treated as word boundaries; short content stays on one line
7482
const input = '<p>First line of text.\nSecond line of text.</p>';
7583
const result = format(input);
76-
expect(result).toBe('<p>\n First line of text.\n Second line of text.\n</p>\n');
84+
expect(result).toBe('<p>First line of text. Second line of text.</p>\n');
7785
});
7886

7987
it('keeps short element with attributes flat', () => {
@@ -287,15 +295,89 @@ is the number of false options that you select, and <code>n</code> is the total
287295
At minimum, you will receive a score of 0%.
288296
{{/net-correct}}`;
289297
const result = format(input);
290-
// Comma stays with </code>, "where" stays with <code>t</code>, etc.
298+
// Fill wraps at 80 chars; comma stays with </code>, inline elements stay atomic
291299
expect(result).toBe(
292300
'{{#net-correct}}\n' +
293-
' You must select {{{insert_text}}} You will receive a score of <code>100% * (t - f) / n</code>,\n' +
294-
' where <code>t</code> is the number of true options that you select, <code>f</code> is the number of false options that you select, and <code>n</code> is the total number of true options.\n' +
295-
' At minimum, you will receive a score of 0%.\n' +
301+
' You must select {{{insert_text}}} You will receive a score of\n' +
302+
' <code>100% * (t - f) / n</code>, where <code>t</code> is the number of true\n' +
303+
' options that you select, <code>f</code> is the number of false options that\n' +
304+
' you select, and <code>n</code> is the total number of true options. At\n' +
305+
' minimum, you will receive a score of 0%.\n' +
296306
'{{/net-correct}}\n'
297307
);
298308
});
309+
310+
it('wraps at print width 100 with 4-space indent', () => {
311+
const input = `{{#net-correct}}
312+
You must select {{{insert_text}}} You will receive a score of <code>100% * (t - f) / n</code>,
313+
where <code>t</code> is the number of true options that you select, <code>f</code>
314+
is the number of false options that you select, and <code>n</code> is the total number of true options.
315+
At minimum, you will receive a score of 0%.
316+
{{/net-correct}}`;
317+
const result = formatWithPrintWidth(input, 100, { tabSize: 4, insertSpaces: true });
318+
expect(result).toBe(
319+
'{{#net-correct}}\n' +
320+
' You must select {{{insert_text}}} You will receive a score of <code>100% * (t - f) / n</code>,\n' +
321+
' where <code>t</code> is the number of true options that you select, <code>f</code> is the number\n' +
322+
' of false options that you select, and <code>n</code> is the total number of true options. At\n' +
323+
' minimum, you will receive a score of 0%.\n' +
324+
'{{/net-correct}}\n'
325+
);
326+
});
327+
328+
it('keeps short inline content on one line', () => {
329+
const input = '<p>Hello <code>world</code> and more.</p>';
330+
const result = format(input);
331+
expect(result).toBe('<p>Hello <code>world</code> and more.</p>\n');
332+
});
333+
334+
it('wraps long inline text at word boundaries respecting print width', () => {
335+
const input = '<p>This is a very long sentence that contains <code>inline code</code> and should wrap at word boundaries when it exceeds the print width limit.</p>';
336+
const result = format(input);
337+
// The group breaks because content exceeds 80 chars, then fill wraps at word boundaries
338+
expect(result).toBe(
339+
'<p>\n' +
340+
' This is a very long sentence that contains <code>inline code</code> and should\n' +
341+
' wrap at word boundaries when it exceeds the print width limit.\n' +
342+
'</p>\n'
343+
);
344+
});
345+
346+
it('wraps at print width 100 with 8-space indent', () => {
347+
// 8-space indent comes from nesting: e.g. tabSize=4 at depth 2
348+
const input = `<div>
349+
<div>
350+
{{#net-correct}}
351+
You must select {{{insert_text}}} You will receive a score of <code>100% * (t - f) / n</code>,
352+
where <code>t</code> is the number of true options that you select, <code>f</code>
353+
is the number of false options that you select, and <code>n</code> is the total number of true options.
354+
At minimum, you will receive a score of 0%.
355+
{{/net-correct}}
356+
</div>
357+
</div>`;
358+
const result = formatWithPrintWidth(input, 100, { tabSize: 4, insertSpaces: true });
359+
// comma stays with </code>, "where" joins on same line, "At" joins "options."
360+
expect(result).toBe(
361+
'<div>\n' +
362+
' <div>\n' +
363+
' {{#net-correct}}\n' +
364+
' You must select {{{insert_text}}} You will receive a score of\n' +
365+
' <code>100% * (t - f) / n</code>, where <code>t</code> is the number of true options that\n' +
366+
' you select, <code>f</code> is the number of false options that you select, and\n' +
367+
' <code>n</code> is the total number of true options. At minimum, you will receive a score\n' +
368+
' of 0%.\n' +
369+
' {{/net-correct}}\n' +
370+
' </div>\n' +
371+
'</div>\n'
372+
);
373+
});
374+
375+
it('attaches punctuation to preceding content after wrapping', () => {
376+
const input = '<div>Some text before <code>value</code>, and some text after the comma.</div>';
377+
const result = format(input);
378+
// Comma stays attached to </code>
379+
expect(result).toBe('<div>Some text before <code>value</code>, and some text after the comma.</div>\n');
380+
});
299381
});
300382

301383
describe('Complex documents', () => {

0 commit comments

Comments
 (0)