diff --git a/javascript/packages/formatter/src/format-helpers.ts b/javascript/packages/formatter/src/format-helpers.ts index 1c592b4b0..56abd291e 100644 --- a/javascript/packages/formatter/src/format-helpers.ts +++ b/javascript/packages/formatter/src/format-helpers.ts @@ -194,7 +194,9 @@ export function isERBTag(text: string): boolean { * Check if a string ends with an ERB tag */ export function endsWithERBTag(text: string): boolean { - return /%>$/.test(text.trim()) + const trimmed = text.trim() + + return /%>$/.test(trimmed) || /%>\S+$/.test(trimmed) } /** diff --git a/javascript/packages/formatter/src/format-printer.ts b/javascript/packages/formatter/src/format-printer.ts index f19f9fe30..6dd97ec57 100644 --- a/javascript/packages/formatter/src/format-printer.ts +++ b/javascript/packages/formatter/src/format-printer.ts @@ -1862,7 +1862,7 @@ export class FormatPrinter extends Printer { const firstWord = words[0] const firstChar = firstWord[0] - if (!/[a-zA-Z0-9.!?:;]/.test(firstChar)) { + if (/\s/.test(firstChar)) { return false } @@ -1879,6 +1879,11 @@ export class FormatPrinter extends Printer { unit: { content: remainingText, type: 'text', isAtomic: false, breaksFlow: false }, node: textNode }) + } else if (endsWithWhitespace(textNode.content)) { + result.push({ + unit: { content: ' ', type: 'text', isAtomic: false, breaksFlow: false }, + node: textNode + }) } return true @@ -1955,10 +1960,8 @@ export class FormatPrinter extends Printer { const hasWhitespace = this.hasWhitespaceBeforeNode(children, lastProcessedIndex, index, child) const lastUnit = result[result.length - 1] const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'erb' || lastUnit.unit.type === 'inline') - const trimmed = child.content.trim() - const startsWithClosingPunct = trimmed.length > 0 && /^[.!?:;]/.test(trimmed) - if (lastIsAtomic && (!hasWhitespace || startsWithClosingPunct) && this.tryMergeTextAfterAtomic(result, child)) { + if (lastIsAtomic && !hasWhitespace && this.tryMergeTextAfterAtomic(result, child)) { return } } diff --git a/javascript/packages/formatter/test/erb/erb.test.ts b/javascript/packages/formatter/test/erb/erb.test.ts index a2f505e7f..c44301545 100644 --- a/javascript/packages/formatter/test/erb/erb.test.ts +++ b/javascript/packages/formatter/test/erb/erb.test.ts @@ -165,7 +165,7 @@ describe("@herb-tools/formatter", () => { expect(result).toBe(dedent`

- <%= link_to "Start", start_path %> ’s overview of + <%= link_to "Start", start_path %>’s overview of <%= link_to "Section", section_path %>, <%= link_to "End", end_path %>.

`) diff --git a/javascript/packages/formatter/test/erb/whitespace-formatting.test.ts b/javascript/packages/formatter/test/erb/whitespace-formatting.test.ts index 49590b98d..cac1ec4e0 100644 --- a/javascript/packages/formatter/test/erb/whitespace-formatting.test.ts +++ b/javascript/packages/formatter/test/erb/whitespace-formatting.test.ts @@ -105,6 +105,404 @@ describe("ERB whitespace formatting", () => { expect(result).toContain('') expect(result).toContain('') }) + + test("does not add whitespace before apostrophe after ERB tag (issue #855)", () => { + const source = dedent` +

+ Lorem <%= letter.patient.first_name.titlecase %>'s ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. +

+ ` + const result = formatter.format(source) + + expect(result).toEqual(dedent` +

+ Lorem <%= letter.patient.first_name.titlecase %>'s ipsum dolor sit amet, + consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et + dolore magna aliqua. +

+ `) + }) + + test("preserves dollar sign before ERB tag without adding space", () => { + const source = `

Lorem $<%= value %> ipsum dolor sit amet.

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves euro symbol after ERB tag without adding space", () => { + const source = `

Lorem <%= value %>€ ipsum dolor sit amet.

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves hash symbol before ERB tag without adding space", () => { + const source = `

Lorem #<%= value %> ipsum dolor sit amet.

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("keeps hyphen attached between adjacent ERB tags", () => { + const source = `

Lorem <%= value %>-<%= value %> ipsum dolor sit amet.

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("keeps period attached between adjacent ERB tags", () => { + const source = `

Lorem <%= value %>.<%= value %> ipsum dolor sit amet.

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves punctuation sequence around ERB tags without spaces", () => { + const source = `

Lorem .<%= value %>.<%= value %>. ipsum dolor sit amet.

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves punctuation sequence around ERB tags with spaces", () => { + const source = `

Lorem . <%= value %> . <%= value %> . ipsum dolor sit amet.

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("keeps adjacent ERB tags together without adding space", () => { + const source = `

Lorem <%= one %><%= two %> ipsum dolor sit amet.

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("formats standalone period after block element on new line", () => { + const source = `

hello

.` + const result = formatter.format(source) + expect(result).toEqual(dedent` +

hello

+ + . + `) + }) + + test("formats period after closing tag within parent block", () => { + const source = dedent` +
+

hello

. +
+ ` + const result = formatter.format(source) + expect(result).toEqual(dedent` +
+

hello

+ . +
+ `) + }) + + test("keeps period attached to inline element without space", () => { + const source = `

Hello World. Hello

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves period spacing after inline element", () => { + const source = `

Hello World . Hello

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + }) + + describe("edge cases and special characters", () => { + test("preserves exclamation mark after ERB tag", () => { + const source = `

Hello <%= name %>!

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves question mark after ERB tag", () => { + const source = `

Are you <%= adjective %>?

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves colon after ERB tag", () => { + const source = `

Result: <%= value %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves semicolon after ERB tag", () => { + const source = `

First <%= value %>; then <%= value2 %>.

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves multiple punctuation marks (ellipsis)", () => { + const source = `

Loading<%= dots %>...

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves multiple exclamation marks", () => { + const source = `

Alert<%= message %>!!!

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves quotes around ERB tag", () => { + const source = `

He said "<%= quote %>".

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves single quotes around ERB tag", () => { + const source = `

Word: '<%= word %>'

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves parentheses around ERB tag", () => { + const source = `

Details (<%= info %>)

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves brackets around ERB tag", () => { + const source = `

Index [<%= index %>]

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves slash between ERB tags (fraction)", () => { + const source = `

<%= numerator %>/<%= denominator %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves slash after ERB tag (file path)", () => { + const source = `

<%= dir %>/<%= file %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves backslash after ERB tag", () => { + const source = `

<%= path %>\\<%= file %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves at-sign before ERB tag (mention)", () => { + const source = `

@<%= username %> said hello

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves hashtag with ERB tag", () => { + const source = `

#<%= tag %> is trending

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves percent sign after ERB tag", () => { + const source = `

<%= value %>%

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves ampersand between ERB tags", () => { + const source = `

<%= first %>&<%= second %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves plus sign between ERB tags (concatenation)", () => { + const source = `

<%= a %>+<%= b %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves asterisk between ERB tags (multiplication)", () => { + const source = `

<%= width %>*<%= height %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves equals sign between ERB tags", () => { + const source = `

<%= key %>=<%= value %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves caret after ERB tag", () => { + const source = `

<%= base %>^<%= exponent %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves tilde before ERB tag", () => { + const source = `

~<%= approximate %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves pipe between ERB tags", () => { + const source = `

<%= option1 %>|<%= option2 %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves underscore between ERB tags", () => { + const source = `

<%= first %>_<%= last %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves numbers after ERB tag", () => { + const source = `

<%= value %>123

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves numbers before ERB tag", () => { + const source = `

Version 123<%= suffix %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves decimal point in price format", () => { + const source = `

$<%= dollars %>.<%= cents %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves colon in time format", () => { + const source = `

<%= hours %>:<%= minutes %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves x in dimensions format", () => { + const source = `

<%= width %>x<%= height %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves file extension with ERB tag", () => { + const source = `

<%= filename %>.html

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves multiple file extensions", () => { + const source = `

<%= filename %>.html.erb

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves em dash between ERB tags", () => { + const source = `

<%= start %>—<%= end %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves en dash between ERB tags", () => { + const source = `

<%= start %>–<%= end %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("handles ERB comment with apostrophe", () => { + const source = `

Hello <%# user's name %><%= name %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves possessive with multiple ERB tags", () => { + const source = `

<%= first_name %> <%= last_name %>'s profile

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves multiple apostrophes in sequence", () => { + const source = `

<%= name %>'s friend's house

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves abbreviation with ERB tag", () => { + const source = `

Dr.<%= name %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves trailing abbreviation", () => { + const source = `

<%= name %> Ph.D.

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves complex punctuation sequence", () => { + const source = `

"<%= title %>"—<%= author %>'s masterpiece!

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves comma and space between ERB tags", () => { + const source = `

<%= city %>, <%= state %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("handles mixed punctuation and text", () => { + const source = `

<%= value %>: <%= description %> (<%= note %>).

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves angle brackets (comparison operators)", () => { + const source = `

<%= a %><<%= b %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves greater than with ERB tags", () => { + const source = `

<%= a %>><%= b %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("handles nested quotes and apostrophes", () => { + const source = `

"<%= name %>'s quote"

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves contractions before ERB tag", () => { + const source = `

can't <%= verb %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves contractions after ERB tag", () => { + const source = `

<%= subject %> can't <%= verb %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("handles backticks around ERB tag (code)", () => { + const source = `

Use \`<%= code %>\` here

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves currency symbol before decimal ERB tag", () => { + const source = `

$<%= price %>.99

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves comma in large numbers", () => { + const source = `

<%= thousands %>,<%= hundreds %>

` + const result = formatter.format(source) + expect(result).toEqual(source) + }) }) describe("shared utility validation", () => {