diff --git a/.changeset/chubby-jokes-jam.md b/.changeset/chubby-jokes-jam.md new file mode 100644 index 0000000..8b523be --- /dev/null +++ b/.changeset/chubby-jokes-jam.md @@ -0,0 +1,5 @@ +--- +"@pretextbook/format": minor +--- + +Improve verbatim; add option to break long lists of attributes diff --git a/extension/package.json b/extension/package.json index 46b3556..df41128 100644 --- a/extension/package.json +++ b/extension/package.json @@ -290,6 +290,12 @@ "default": true, "markdownDescription": "Whether to add a line break after each period in a paragraph." }, + "pretext-tools.formatter.breakLongAttributes": { + "order": 4, + "type": "boolean", + "default": false, + "markdownDescription": "Wrap long block start-tag attributes onto their own lines." + }, "pretext-tools.formatter.printWidth": { "order": 5, "type": "number", diff --git a/packages/format/README.md b/packages/format/README.md index f5ac2d0..d334952 100644 --- a/packages/format/README.md +++ b/packages/format/README.md @@ -22,6 +22,7 @@ You can pass options to customize formatting: const options = { breakLines: "many", breakSentences: true, + breakLongAttributes: true, printWidth: 80, insertSpaces: true, tabSize: 2, @@ -79,6 +80,7 @@ Options: - `--stdin` read input from stdin - `--break-lines ` choose line break density - `--break-sentences` break plain-text sentences onto new lines +- `--break-long-attributes` wrap long block start-tag attributes onto their own lines - `--tab-size ` set spaces per indent level - `--use-tabs` indent with tabs instead of spaces - `-h, --help` show help diff --git a/packages/format/cli.cjs b/packages/format/cli.cjs index b52a583..7b42f4e 100644 --- a/packages/format/cli.cjs +++ b/packages/format/cli.cjs @@ -15,6 +15,7 @@ Options: --stdin Read input from stdin --break-lines Line break mode: few | some | many --break-sentences Break plain-text sentences onto new lines + --break-long-attributes Wrap long block start-tag attributes onto their own lines --tab-size Number of spaces per indent level --use-tabs Indent with tabs instead of spaces -h, --help Show this help @@ -36,6 +37,7 @@ function parseCli() { stdin: { type: "boolean", default: false }, "break-lines": { type: "string" }, "break-sentences": { type: "boolean", default: false }, + "break-long-attributes": { type: "boolean", default: false }, "tab-size": { type: "string" }, "use-tabs": { type: "boolean", default: false }, help: { type: "boolean", short: "h", default: false }, @@ -50,7 +52,7 @@ function parseCli() { } function parseOptions(values) { - /** @type {{breakLines?: "few" | "some" | "many"; breakSentences?: boolean; insertSpaces?: boolean; tabSize?: number}} */ + /** @type {{breakLines?: "few" | "some" | "many"; breakSentences?: boolean; breakLongAttributes?: boolean; insertSpaces?: boolean; tabSize?: number}} */ const formatOptions = {}; if (values["break-lines"] !== undefined) { if (!["few", "some", "many"].includes(values["break-lines"])) { @@ -61,6 +63,9 @@ function parseOptions(values) { if (values["break-sentences"]) { formatOptions.breakSentences = true; } + if (values["break-long-attributes"]) { + formatOptions.breakLongAttributes = true; + } if (values["use-tabs"]) { formatOptions.insertSpaces = false; } diff --git a/packages/format/src/lib/__fixtures__/minimal-book.ptx b/packages/format/src/lib/__fixtures__/minimal-book.ptx index 6c6967a..3c95d06 100644 --- a/packages/format/src/lib/__fixtures__/minimal-book.ptx +++ b/packages/format/src/lib/__fixtures__/minimal-book.ptx @@ -1,3 +1,3 @@ My BookJane - DoeSome University2024

This is a very short abstract for the book.

Introduction

This is the first paragraph of the introduction chapter. It has some text that is reasonably long.

This is a second paragraph.

First Section

Some content here in the first section.

Second Section

Content in the second section.

+ DoeSome University2024

This is a very short abstract for the book.

Introduction

This is the first paragraph of the introduction chapter. It has some text that is reasonably long.

This is a second paragraph.

First Section

Some content here in the first section.

Second Section

Content in the second section.

diff --git a/packages/format/src/lib/__fixtures__/verbatim-blocks.ptx b/packages/format/src/lib/__fixtures__/verbatim-blocks.ptx index a66da18..f15ca67 100644 --- a/packages/format/src/lib/__fixtures__/verbatim-blocks.ptx +++ b/packages/format/src/lib/__fixtures__/verbatim-blocks.ptx @@ -19,6 +19,13 @@ f.factor() (x + 1)*(x + 2)

Inline code like print("hello") should remain inline and not be broken across lines.

+

Inline code with surrounding spaces x + 1 should preserve those spaces.

+

A program with a trailing blank line:

+ +def foo(): + return 1 + +

A console session:

@@ -49,8 +56,25 @@ f.factor() edgePercentage: 0.35 }); anim.doDetectCycle(); - + + + + //line A +//line B + + + + +//line D + + + diff --git a/packages/format/src/lib/__snapshots__/minimal-book-breakLongAttributes-true.ptx b/packages/format/src/lib/__snapshots__/minimal-book-breakLongAttributes-true.ptx new file mode 100644 index 0000000..82c6747 --- /dev/null +++ b/packages/format/src/lib/__snapshots__/minimal-book-breakLongAttributes-true.ptx @@ -0,0 +1,58 @@ + + + + + My Book + + + + + Jane Doe + Some University + + 2024 + + + +

+ This is a very short abstract for the book. +

+
+
+ + + Introduction + +

+ This is the first paragraph of the introduction chapter. It has some + text that is reasonably long. +

+ +

+ This is a second paragraph. +

+ +
+ First Section + +

+ Some content here in the first section. +

+
+ +
+ Second Section + +

+ Content in the second section. +

+
+
+ + + + +
+
\ No newline at end of file diff --git a/packages/format/src/lib/__snapshots__/minimal-book-few.ptx b/packages/format/src/lib/__snapshots__/minimal-book-few.ptx index 3f40147..eb561d0 100644 --- a/packages/format/src/lib/__snapshots__/minimal-book-few.ptx +++ b/packages/format/src/lib/__snapshots__/minimal-book-few.ptx @@ -17,7 +17,7 @@

- + Introduction

This is the first paragraph of the introduction chapter. It has some diff --git a/packages/format/src/lib/__snapshots__/minimal-book-many.ptx b/packages/format/src/lib/__snapshots__/minimal-book-many.ptx index f539d78..c0f0fa1 100644 --- a/packages/format/src/lib/__snapshots__/minimal-book-many.ptx +++ b/packages/format/src/lib/__snapshots__/minimal-book-many.ptx @@ -32,7 +32,7 @@ - + Introduction diff --git a/packages/format/src/lib/__snapshots__/minimal-book-tabs.ptx b/packages/format/src/lib/__snapshots__/minimal-book-tabs.ptx index 6fac9d1..d5ddc94 100644 --- a/packages/format/src/lib/__snapshots__/minimal-book-tabs.ptx +++ b/packages/format/src/lib/__snapshots__/minimal-book-tabs.ptx @@ -20,7 +20,7 @@ - + Introduction

diff --git a/packages/format/src/lib/__snapshots__/minimal-book.ptx b/packages/format/src/lib/__snapshots__/minimal-book.ptx index 253f227..7be0539 100644 --- a/packages/format/src/lib/__snapshots__/minimal-book.ptx +++ b/packages/format/src/lib/__snapshots__/minimal-book.ptx @@ -20,7 +20,7 @@ - + Introduction

diff --git a/packages/format/src/lib/__snapshots__/runestone.ptx b/packages/format/src/lib/__snapshots__/runestone.ptx index 00920bd..c7e33bf 100644 --- a/packages/format/src/lib/__snapshots__/runestone.ptx +++ b/packages/format/src/lib/__snapshots__/runestone.ptx @@ -90,7 +90,9 @@ along with MathBook XML. If not, see . - print("Hello, World!") + + print("Hello, World!") + @@ -107,7 +109,9 @@ along with MathBook XML. If not, see . - print("Hello, World!") + + print("Hello, World!") + @@ -162,7 +166,9 @@ along with MathBook XML. If not, see . - document.write('Hello, world!'); + + document.write('Hello, world!'); + @@ -296,7 +302,6 @@ along with MathBook XML. If not, see . else: print("Test failed") -

A program can have a preamble and/or postamble which are added to the code that the user writes before it is run. They are @@ -333,11 +338,14 @@ along with MathBook XML. If not, see . - def add(a, b): + def add(a, b): - # TODO - complete the add function + + # TODO - complete the add function + - # Use the function + + # Use the function result = add(2, 3) if result == 5: print("Test passed") @@ -522,7 +530,9 @@ along with MathBook XML. If not, see . - SELECT * FROM test + + SELECT * FROM test + assert 1,1 == world assert 0,1 == hello @@ -565,7 +575,9 @@ along with MathBook XML. If not, see . add.h (version 1) - A very simple header file that lacks header guards. - int add(int a, int b); + + int add(int a, int b); +

@@ -624,7 +636,6 @@ along with MathBook XML. If not, see . cout << "The sum of " << a << " and " << b << " is " << add(a, b) << endl; }]]> -

Note that there is a cross page test of add-files located in @@ -651,7 +662,9 @@ along with MathBook XML. If not, see . A Python program, stepable with CodeLens - print('Hello, World!') + + print('Hello, World!') + @@ -741,8 +754,7 @@ along with MathBook XML. If not, see . - -. - -. - - const int len = 20; int main() { @@ -860,8 +870,7 @@ along with MathBook XML. If not, see . - - - - using namespace std; int main() { @@ -1145,7 +1153,9 @@ TEST_CASE( "Test the add function" ) { 1 - * + + * + 3 @@ -1408,8 +1418,7 @@ TEST_CASE( "Test the add function" ) { - - - a blue square @@ -7472,7 +7480,6 @@ TEST_CASE( "Test the add function" ) { 4$a 5$a - s (feet) $$s(0) @@ -7498,11 +7505,9 @@ TEST_CASE( "Test the add function" ) { -

  • Find the average velocity of the car on the interval 0 \le t \le 3*$a.

  • - ($$s(3*$a)-$$s(0*$a))/(3$a) @@ -7514,7 +7519,6 @@ TEST_CASE( "Test the add function" ) { Find the average velocity of the car on the interval [$c*$a, ($c+2)*$a].

    - ($$s(($c+2)*$a)-$$s($c*$a))/(2$a) @@ -7802,4 +7806,4 @@ TEST_CASE( "Test the add function" ) { -
    +
    \ No newline at end of file diff --git a/packages/format/src/lib/__snapshots__/verbatim-blocks.ptx b/packages/format/src/lib/__snapshots__/verbatim-blocks.ptx index a887d31..c919bc0 100644 --- a/packages/format/src/lib/__snapshots__/verbatim-blocks.ptx +++ b/packages/format/src/lib/__snapshots__/verbatim-blocks.ptx @@ -28,7 +28,9 @@ x = var('x') f = x^2 + 3*x + 2 f.factor() - (x + 1)*(x + 2) + +(x + 1)*(x + 2) +

    @@ -36,12 +38,26 @@ f.factor() broken across lines.

    +

    + Inline code with surrounding spaces x + 1 should preserve those + spaces. +

    + +

    + A program with a trailing blank line: +

    + + +def foo(): + return 1 + +

    A console session:

    - $ + $ echo "hello world" hello world @@ -58,7 +74,6 @@ f.factor() } } -

    Fast forward to the end of the animation and then click New Graph button to generate a new random graph. @@ -75,7 +90,23 @@ f.factor() }); anim.doDetectCycle(); - + +

    + + //line A +//line B + + + + +//line D + + \ No newline at end of file diff --git a/packages/format/src/lib/docStructure.ts b/packages/format/src/lib/docStructure.ts index 70d20ce..9b08da1 100644 --- a/packages/format/src/lib/docStructure.ts +++ b/packages/format/src/lib/docStructure.ts @@ -178,6 +178,8 @@ export const verbatimTags = [ "macros", "prefigure", "program", + "preamble", + "postamble", "input", "output", "prompt", diff --git a/packages/format/src/lib/format-snapshots.spec.ts b/packages/format/src/lib/format-snapshots.spec.ts index 534cb52..21199d7 100644 --- a/packages/format/src/lib/format-snapshots.spec.ts +++ b/packages/format/src/lib/format-snapshots.spec.ts @@ -60,6 +60,17 @@ describe("formatPretext — snapshot tests", () => { }); }); + describe("breakLongAttributes=true", () => { + it("minimal-book", async () => { + const result = formatPretext(readFixture("minimal-book"), { + breakLongAttributes: true, + }); + await expect(result).toMatchFileSnapshot( + snapshotPath("minimal-book-breakLongAttributes-true"), + ); + }); + }); + describe("tab indentation", () => { it("minimal-book with tabs", async () => { const result = formatPretext(readFixture("minimal-book"), { diff --git a/packages/format/src/lib/format.spec.ts b/packages/format/src/lib/format.spec.ts index 20ed7b3..10f8e7e 100644 --- a/packages/format/src/lib/format.spec.ts +++ b/packages/format/src/lib/format.spec.ts @@ -1,3 +1,4 @@ +import { describe, expect, it } from "vitest"; import { formatPretext } from "./format"; describe("format", () => { @@ -23,4 +24,57 @@ describe("format", () => { expect(result).not.toMatch(/\s*\n\s*]*\/>\s*\n\s*<\/webwork>/); }); + + it("wraps long block start-tag attributes when enabled", () => { + const input = `Example`; + const result = formatPretext(input, { + printWidth: 80, + breakLongAttributes: true, + }); + + expect(result).toBe( + `\n Example\n\n`, + ); + }); +}); + +describe("verbatim content preservation", () => { + it("preserves trailing space in single-line verbatim (e.g. $ )", () => { + const input = `$ ls`; + const result = formatPretext(input); + expect(result).toContain("$ "); + }); + + it("preserves leading and trailing spaces in inline tags", () => { + const input = `

    Inline x + 1 with spaces.

    `; + const result = formatPretext(input); + expect(result).toContain(" x + 1 "); + }); + + it("preserves intentional trailing blank line inside a program block", () => { + const input = `\ndef foo():\n return 1\n\n`; + const result = formatPretext(input); + // The blank line at the end of the code should survive formatting + expect(result).toMatch(/return 1\n\n/); + }); + + it("does not alter internal whitespace or indentation in code blocks", () => { + const code = " line1\n line2 indented\n line3"; + const input = `
    \n${code}\n
    `; + const result = formatPretext(input); + expect(result).toContain(code); + }); + + it("preserves internal blank lines inside verbatim blocks", () => { + const input = `\nline1\n\nline3\n`; + const result = formatPretext(input); + expect(result).toMatch(/line1\n\nline3/); + }); + + it("preserves boundary newlines in verbatim blocks", () => { + const input = `\nline\n`; + const result = formatPretext(input); + expect(result).toBe(input); + }); + }); diff --git a/packages/format/src/lib/format.ts b/packages/format/src/lib/format.ts index c2e2964..495ec41 100644 --- a/packages/format/src/lib/format.ts +++ b/packages/format/src/lib/format.ts @@ -13,6 +13,8 @@ import { export interface FormatOptions { breakLines?: "few" | "some" | "many"; breakSentences?: boolean; + /** Wrap long block start-tag attributes onto separate lines. */ + breakLongAttributes?: boolean; insertSpaces?: boolean; tabSize?: number; /** Target line width for paragraph text reflow. 0 = no width limit. Default 80. */ @@ -23,6 +25,7 @@ interface Ctx { ind: string; // one indent unit (e.g. " "); caller repeats it per depth level blankLines: "few" | "some" | "many"; breakSentences: boolean; + breakLongAttributes: boolean; printWidth: number; } @@ -31,9 +34,10 @@ function makeCtx(options?: FormatOptions): Ctx { const tabSize = options?.tabSize ?? 2; const insertSpaces = options?.insertSpaces ?? true; const breakSentences = options?.breakSentences ?? false; + const breakLongAttributes = options?.breakLongAttributes ?? false; const printWidth = options?.printWidth ?? 80; const ind = insertSpaces ? " ".repeat(tabSize) : "\t"; - return { ind, blankLines, breakSentences, printWidth }; + return { ind, blankLines, breakSentences, breakLongAttributes, printWidth }; } /** @@ -191,20 +195,9 @@ function appendVerbatim( out.push(`${ind}${selfClose(node)}`); return; } - const raw = extractVerbatimContent(node); - // Single-line verbatim (e.g. print(x)) stays on one line. Applies to sage code as well. - // We choose to remove whitespace padding around the content in this case as well, since it's more likely to be accidental and visually distracting than intentional when the content is short enough to fit on one line. - if (!raw.includes("\n")) { - out.push(`${ind}${openTag(node)}${raw.trim()}`); - return; - } - out.push(`${ind}${openTag(node)}`); - // Lines are pushed without re-indenting so code/math content is preserved exactly. - for (const line of raw.split("\n")) out.push(line); - out.push(`${ind}`); -} -function extractVerbatimContent(node: Element): string { + // Preserve verbatim inner content exactly as parsed (including newlines and + // trailing spaces), while still escaping text-node XML entities. const raw = node.children .map((c) => { if (c.type === "text") return escText(c.value); @@ -212,10 +205,16 @@ function extractVerbatimContent(node: Element): string { return ""; }) .join(""); - // Authors typically write \ncode\n; strip the surrounding newlines - // so the content lines themselves set the indentation, not the tag placement. - // /(\n\s*)*$/ removes all trailing blank/whitespace-only lines, not just one \n. - return raw.replace(/^\n/, "").replace(/(\n\s*)*$/, ""); + const trailingNewlineWithWhitespace = /\n[ \t]*$/.test(raw); + if (trailingNewlineWithWhitespace) { + // Strip any trailing whitespace after the final newline so the closing tag + // gets the correct indentation. + const trimmedRaw = raw.replace(/\n[ \t]*$/, "\n"); + out.push(`${ind}${openTag(node)}${trimmedRaw}${ind}`); + } else { + // Otherwise, render the whole verbatim element on one line. Any internal newlines will be preserved as literal \n characters in the text content, and any trailing spaces will be preserved because the closing tag is on the same line. + out.push(`${ind}${openTag(node)}${raw}`); + } } // ─── Line-end ───────────────────────────────────────────────────────────────── @@ -374,7 +373,7 @@ function appendBlock( ): void { const ind = ctx.ind.repeat(depth); if (isEmptyElement(node)) { - out.push(`${ind}${selfClose(node)}`); + out.push(...startTagLines(node, depth, ctx, true)); return; } @@ -383,15 +382,22 @@ function appendBlock( const mc = meaningfulChildren(node); if (mc.length === 1 && mc[0].type === "element" && (mc[0] as Element).name === "xi:include") { const el = mc[0] as Element; - const inner = isEmptyElement(el) - ? selfClose(el) - : `${openTag(el)}${inlineSerialize(el.children)}`; - out.push(`${ind}${openTag(node)}${inner}`); + const startLines = startTagLines(node, depth, ctx); + if (startLines.length === 1) { + const inner = isEmptyElement(el) + ? selfClose(el) + : `${openTag(el)}${inlineSerialize(el.children)}`; + out.push(`${startLines[0]}${inner}`); + return; + } + out.push(...startLines); + appendNode(el, out, depth + 1, ctx); + out.push(`${ind}`); return; } //Add starting tag as its own line. - out.push(`${ind}${openTag(node)}`); + out.push(...startTagLines(node, depth, ctx)); for (const child of meaningfulChildren(node)) { appendNode(child, out, depth + 1, ctx); } @@ -540,11 +546,41 @@ function selfClose(node: Element): string { } function buildAttrs(node: Element): string { + return buildAttrList(node).join(" "); +} + +function buildAttrList(node: Element): string[] { return Object.entries(node.attributes || {}) // v == null (loose equality) covers both null and undefined that can appear // in Object.entries output for boolean/valueless XML attributes. .map(([k, v]) => (v == null ? k : `${k}="${escAttr(v)}"`)) - .join(" "); +} + +function startTagLines( + node: Element, + depth: number, + ctx: Ctx, + selfClosing = false, +): string[] { + const ind = ctx.ind.repeat(depth); + const attrs = buildAttrList(node); + const close = selfClosing ? "/>" : ">"; + if (attrs.length === 0) { + return [`${ind}<${node.name}${close}`]; + } + + const singleLine = `${ind}<${node.name} ${attrs.join(" ")}${close}`; + if (!ctx.breakLongAttributes || ctx.printWidth === 0 || singleLine.length <= ctx.printWidth) { + return [singleLine]; + } + + const continuationIndent = `${ind}${" ".repeat(node.name.length + 2)}`; + const lines = [`${ind}<${node.name} ${attrs[0]}`]; + for (const attr of attrs.slice(1)) { + lines.push(`${continuationIndent}${attr}`); + } + lines[lines.length - 1] += close; + return lines; } // ─── Predicates ─────────────────────────────────────────────────────────────── diff --git a/packages/vscode-extension/src/lsp-server/formatter-ptx.ts b/packages/vscode-extension/src/lsp-server/formatter-ptx.ts index 032fc61..4a9a139 100644 --- a/packages/vscode-extension/src/lsp-server/formatter-ptx.ts +++ b/packages/vscode-extension/src/lsp-server/formatter-ptx.ts @@ -13,6 +13,7 @@ function getOptions() { return { breakSentences: globalSettings.formatter.breakSentences, breakLines: globalSettings.formatter.blankLines, + breakLongAttributes: globalSettings.formatter.breakLongAttributes, tabSize: globalSettings.editor.tabSize, insertSpaces: globalSettings.editor.insertSpaces, printWidth: globalSettings.formatter.printWidth, diff --git a/packages/vscode-extension/src/lsp-server/main.ts b/packages/vscode-extension/src/lsp-server/main.ts index 0acfb52..94564d0 100644 --- a/packages/vscode-extension/src/lsp-server/main.ts +++ b/packages/vscode-extension/src/lsp-server/main.ts @@ -158,6 +158,7 @@ interface LspSettings { formatter: { breakSentences: boolean; blankLines: "few" | "some" | "many"; + breakLongAttributes: boolean; printWidth: number; }; editor: { @@ -174,7 +175,12 @@ const insertSpacesConfigSection = "editor.insertSpaces"; // The global settings, used when the `workspace/configuration` request is not supported by the client. const defaultSettings: LspSettings = { schema: { versionName: "Stable", customPath: "" }, - formatter: { blankLines: "some", breakSentences: true, printWidth: 80 }, + formatter: { + blankLines: "some", + breakSentences: true, + breakLongAttributes: false, + printWidth: 80, + }, editor: { tabSize: 2, insertSpaces: true }, }; export let globalSettings: LspSettings = defaultSettings;