diff --git a/src/print/directive.ts b/src/print/directive.ts index 3c382be..eef9b2d 100644 --- a/src/print/directive.ts +++ b/src/print/directive.ts @@ -1,7 +1,7 @@ import type { AstPath, Doc, Options } from "prettier"; import { doc } from "prettier"; import type { WrappedNode } from "../types.js"; -import { NodeKind } from "../tree/types.js"; +import { NodeKind, StructureRole } from "../tree/types.js"; import { TokenType } from "../lexer/types.js"; import { formatDirectiveNameToken, @@ -43,15 +43,6 @@ function isBranchNode(node: WrappedNode): node is WrappedNode & { kind: BranchNo return node.kind === NodeKind.Directive || node.kind === NodeKind.PhpTag; } -const BODY_BLANK_LINE_LAYOUT_DIRECTIVES = new Set([ - "include", - "includeif", - "includewhen", - "includeunless", - "includefirst", - "each", -]); - function isIgnoreRangeNode(node: WrappedNode): boolean { return node.kind === NodeKind.IgnoreRange; } @@ -757,24 +748,21 @@ function printBetweenLine(prev: WrappedNode, next: WrappedNode): Doc { } function shouldPreserveBodyBlankLine(prev: WrappedNode, next: WrappedNode): boolean { - // Nested branch markers such as @default/@else inside directive bodies already - // have dedicated separator handling. Preserving source blank lines here causes - // pass-2 duplication when an outer mode like `always` inserts its own branch gap. - if (isBranchNode(next) && next.children.length > 0) { + // Branch markers such as @else/@elseif/@case/@default already have dedicated + // separator handling. Preserving source blank lines here causes pass-2 + // duplication when an outer mode like `always` inserts its own branch gap. + if (isBranchBoundaryDirective(prev) || isBranchBoundaryDirective(next)) { return false; } - // Keep preserve-mode scope narrow: retain authored gaps only for layout-like - // boundaries such as `@include` before a container component. This matches - // the expected section-body use case without changing general directive-body - // spacing across the validation corpus. - return ( - (isBodyLayoutDirective(prev) && isContainerLikeBodySibling(next)) || - (isBodyLayoutDirective(next) && isContainerLikeBodySibling(prev)) - ); + if (isMeaningfulBodySibling(prev) && isMeaningfulBodySibling(next)) { + return true; + } + + return false; } -function isBodyLayoutDirective(node: WrappedNode): boolean { +function isBranchBoundaryDirective(node: WrappedNode): boolean { if (node.kind !== NodeKind.Directive) { return false; } @@ -784,21 +772,24 @@ function isBodyLayoutDirective(node: WrappedNode): boolean { return false; } - return BODY_BLANK_LINE_LAYOUT_DIRECTIVES.has(name); -} - -function isContainerLikeBodySibling(node: WrappedNode): boolean { - if (isTextLikeNode(node)) { + const directive = node.buildResult.directives?.getDirective(name); + if (!directive) { return false; } - if (node.kind === NodeKind.Element) { - return node.children.length > 0 && isComponentLikeElement(node); - } - - return node.children.length > 0; + return ( + directive.role === StructureRole.Mixed || + directive.role === StructureRole.Closing || + directive.isSwitchBranch || + directive.isSwitchTerminator || + directive.isConditionalClose + ); } -function isComponentLikeElement(node: WrappedNode): boolean { - return node.tagName.includes("-") || node.fullName.includes(":"); +function isMeaningfulBodySibling(node: WrappedNode): boolean { + if (node.kind === NodeKind.NonOutput) { + return false; + } + + return !(node.kind === NodeKind.Text && fullText(node).trim().length === 0); } diff --git a/tests/directives/body-blank-lines.test.ts b/tests/directives/body-blank-lines.test.ts new file mode 100644 index 0000000..0ba45a5 --- /dev/null +++ b/tests/directives/body-blank-lines.test.ts @@ -0,0 +1,385 @@ +import { describe, it } from "vitest"; +import bladePlugin from "../../src/index.js"; +import * as phpPlugin from "@prettier/plugin-php"; +import { formatEqual } from "../helpers.js"; + +const WITH_PHP = { + plugins: [bladePlugin, phpPlugin], + bladePhpFormatting: "safe" as const, + singleQuote: true, +}; + +describe("directives/body-blank-lines", () => { + it.each([ + { + name: "php block then directive", + input: [ + "@foreach ($items as $item)", + "@php", + "$x = 1;", + "@endphp", + "", + "@livewire($x)", + "@endforeach", + "", + ].join("\n"), + expected: [ + "@foreach ($items as $item)", + " @php", + " $x = 1;", + " @endphp", + "", + " @livewire ($x)", + "@endforeach", + "", + ].join("\n"), + }, + { + name: "verbatim block then directive", + input: [ + "@foreach ($items as $item)", + "@verbatim", + "{{ raw }}", + "@endverbatim", + "", + "@livewire($item)", + "@endforeach", + "", + ].join("\n"), + expected: [ + "@foreach ($items as $item)", + " @verbatim", + "{{ raw }}", + "@endverbatim", + "", + " @livewire ($item)", + "@endforeach", + "", + ].join("\n"), + }, + { + name: "nested directive block then directive", + input: [ + "@foreach ($items as $item)", + "@if ($item->visible)", + "{{ $item->label }}", + "@endif", + "", + "@livewire($item)", + "@endforeach", + "", + ].join("\n"), + expected: [ + "@foreach ($items as $item)", + " @if ($item->visible)", + " {{ $item->label }}", + " @endif", + "", + " @livewire ($item)", + "@endforeach", + "", + ].join("\n"), + }, + { + name: "directive then nested directive block", + input: [ + "@foreach ($items as $item)", + "@livewire($item)", + "", + "@if ($item->visible)", + "{{ $item->label }}", + "@endif", + "@endforeach", + "", + ].join("\n"), + expected: [ + "@foreach ($items as $item)", + " @livewire ($item)", + "", + " @if ($item->visible)", + " {{ $item->label }}", + " @endif", + "@endforeach", + "", + ].join("\n"), + }, + { + name: "element then directive", + input: [ + "@foreach ($items as $item)", + "
{{ $item->label }}
", + "", + "@livewire($item)", + "@endforeach", + "", + ].join("\n"), + expected: [ + "@foreach ($items as $item)", + "
{{ $item->label }}
", + "", + " @livewire ($item)", + "@endforeach", + "", + ].join("\n"), + }, + { + name: "echo then directive", + input: [ + "@foreach ($items as $item)", + "{{ $item->label }}", + "", + "@livewire($item)", + "@endforeach", + "", + ].join("\n"), + expected: [ + "@foreach ($items as $item)", + " {{ $item->label }}", + "", + " @livewire ($item)", + "@endforeach", + "", + ].join("\n"), + }, + { + name: "directive then php block", + input: [ + "@foreach ($items as $item)", + "@livewire($item)", + "", + "@php", + "$x = 1;", + "@endphp", + "@endforeach", + "", + ].join("\n"), + expected: [ + "@foreach ($items as $item)", + " @livewire ($item)", + "", + " @php", + " $x = 1;", + " @endphp", + "@endforeach", + "", + ].join("\n"), + }, + ])( + "preserves authored blank lines between mixed body siblings: $name", + async ({ input, expected }) => { + await formatEqual(input, expected, WITH_PHP, 3); + }, + ); + + it.each([ + { + name: "section", + input: [ + "@section('content')", + "", + "", + "@php", + "$title = $post->title;", + "@endphp", + "@endsection", + "", + ].join("\n"), + expected: [ + "@section ('content')", + " ", + "", + " @php", + " $title = $post->title;", + " @endphp", + "@endsection", + "", + ].join("\n"), + }, + { + name: "component", + input: [ + "@component('mail::message')", + "@php", + "$total = $order->total();", + "@endphp", + "", + '', + "@endcomponent", + "", + ].join("\n"), + expected: [ + "@component ('mail::message')", + " @php", + " $total = $order->total();", + " @endphp", + "", + ' ', + "@endcomponent", + "", + ].join("\n"), + }, + { + name: "for", + input: [ + "@for ($i = 0; $i < 2; $i++)", + "@php", + "$value = $items[$i];", + "@endphp", + "", + '', + "@endfor", + "", + ].join("\n"), + expected: [ + "@for ($i = 0; $i < 2; $i++)", + " @php", + " $value = $items[$i];", + " @endphp", + "", + ' ', + "@endfor", + "", + ].join("\n"), + }, + { + name: "forelse primary branch", + input: [ + "@forelse ($items as $item)", + '', + "", + "@include('partials.row-actions')", + "@empty", + "

No items.

", + "@endforelse", + "", + ].join("\n"), + expected: [ + "@forelse ($items as $item)", + ' ', + "", + " @include ('partials.row-actions')", + "@empty", + "

No items.

", + "@endforelse", + "", + ].join("\n"), + }, + { + name: "push", + input: [ + "@push('scripts')", + "@vite('resources/js/app.js')", + "", + "", + "@endpush", + "", + ].join("\n"), + expected: [ + "@push ('scripts')", + " @vite ('resources/js/app.js')", + "", + " ", + "@endpush", + "", + ].join("\n"), + }, + { + name: "fragment", + input: [ + "@fragment('modal')", + "", + "", + "@livewire('settings.modal')", + "@endfragment", + "", + ].join("\n"), + expected: [ + "@fragment ('modal')", + " ", + "", + " @livewire ('settings.modal')", + "@endfragment", + "", + ].join("\n"), + }, + { + name: "switch case", + input: [ + "@switch($type)", + "@case('a')", + "@php", + "$label = $type;", + "@endphp", + "", + "

{{ $label }}

", + "@break", + "@default", + "

Unknown

", + "@endswitch", + "", + ].join("\n"), + expected: [ + "@switch ($type)", + " @case ('a')", + " @php", + " $label = $type;", + " @endphp", + "", + "

{{ $label }}

", + " @break", + " @default", + "

Unknown

", + "@endswitch", + "", + ].join("\n"), + }, + ])( + "preserves authored blank lines inside representative block directive bodies: $name", + async ({ input, expected }) => { + await formatEqual(input, expected, WITH_PHP, 3); + }, + ); + + it("does not duplicate branch separator blank lines while preserving body sibling gaps", async () => { + const input = [ + "@if($ready)", + "@php", + "$state = 'ready';", + "@endphp", + "", + "@else", + "

Waiting

", + "", + "@endif", + "", + ].join("\n"); + + const expected = [ + "@if ($ready)", + " @php", + " $state = 'ready';", + " @endphp", + "", + "@else", + "

Waiting

", + "", + "@endif", + "", + ].join("\n"); + + await formatEqual( + input, + expected, + { + ...WITH_PHP, + bladeBlankLinesAroundDirectives: "always", + bladeDirectiveBlockStyle: "multiline", + }, + 3, + ); + }); +}); diff --git a/tests/directives/issue-cases.test.ts b/tests/directives/issue-cases.test.ts index d526d80..72cef97 100644 --- a/tests/directives/issue-cases.test.ts +++ b/tests/directives/issue-cases.test.ts @@ -235,6 +235,38 @@ describe("directives/issue-cases", () => { }); }); + // Issue #180: preserve authored body gap after @php before another directive. + it("#180: @php block preserves following blank line inside control directive", async () => { + const input = [ + "@foreach ($widgets as $widget)", + " @php", + " $widgetClass = $normalizeWidgetClass($widget);", + " @endphp", + "", + " @livewire($widgetClass, ['widget' => $widget])", + "@endforeach", + "", + ].join("\n"); + + const expected = [ + "@foreach ($widgets as $widget)", + " @php", + " $widgetClass = $normalizeWidgetClass($widget);", + " @endphp", + "", + " @livewire($widgetClass, ['widget' => $widget])", + "@endforeach", + "", + ].join("\n"); + + await formatEqual(input, expected, { + plugins: [bladePlugin, phpPlugin], + bladePhpFormatting: "safe", + bladeDirectiveArgSpacing: "preserve", + singleQuote: true, + }); + }); + // Issue #119: custom component with @class does not error it("#119: x-component with @class in attributes does not error", async () => { const input = [ @@ -330,6 +362,7 @@ describe("directives/issue-cases", () => { " @if ($form->picture !== '')", ' ', " @endif", + "", " @if ($form->picture !== '')", ' ', " @endif", @@ -464,6 +497,7 @@ describe("directives/issue-cases", () => { " @endempty", " ", " ", + "", '
', '
', " @empty ($key)", diff --git a/tests/validation/conformance/__snapshots__/formatter-cases.test.ts.snap b/tests/validation/conformance/__snapshots__/formatter-cases.test.ts.snap index 37a9eae..7267655 100644 --- a/tests/validation/conformance/__snapshots__/formatter-cases.test.ts.snap +++ b/tests/validation/conformance/__snapshots__/formatter-cases.test.ts.snap @@ -608,7 +608,9 @@ Placeholder text. href="coffee.htm" /> + + Something. @@ -878,11 +880,13 @@ exports[`validation/conformance/formatter-cases > formats directives__009 1`] =
@pair

Test one.

+
@pair
@pair

Test two.

+
@pair
@@ -2101,6 +2105,7 @@ exports[`validation/conformance/formatter-cases > formats ignore__001 1`] = ` @unless ($true)

World

+

test

@@ -2280,6 +2285,7 @@ exports[`validation/conformance/formatter-cases > formats ignore__002 1`] = ` @unless ($true)

World

+

test

@@ -3103,6 +3109,7 @@ exports[`validation/conformance/formatter-cases > formats switch__001 1`] = ` exports[`validation/conformance/formatter-cases > formats switch__002 1`] = ` "@switch ($i) {{-- Leading node test. --}} +

Test {{ $title }}

@case (1) First case... @@ -3120,6 +3127,7 @@ exports[`validation/conformance/formatter-cases > formats switch__002 1`] = ` exports[`validation/conformance/formatter-cases > formats switch__003 1`] = ` "@switch ($i) {{-- Leading node test. --}} +

Test {{ $title }}

@case (1)

First case...

@@ -3177,6 +3185,7 @@ exports[`validation/conformance/formatter-cases > formats switch__007 1`] = ` exports[`validation/conformance/formatter-cases > formats switch__008 1`] = ` "@switch ($i) {{-- Leading node test. --}} +

Test {{ $title }}

@case (1)

First case...

@@ -3278,10 +3287,12 @@ exports[`validation/conformance/formatter-cases > formats templates__002 1`] = ` @section ('content')

About Us

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis convallis mauris mauris, id volutpat diam feugiat nec.

+ @include ('partials.team') @endsection " @@ -3294,6 +3305,7 @@ exports[`validation/conformance/formatter-cases > formats templates__003 1`] = ` @section ('content')

User List

+ @if ($users->isEmpty())

No users found.

@else @@ -3314,6 +3326,7 @@ exports[`validation/conformance/formatter-cases > formats templates__004 1`] = ` @section ('content')

Contact Us

+
@csrf @@ -3339,8 +3352,10 @@ exports[`validation/conformance/formatter-cases > formats templates__005 1`] = ` @section ('content')

Product Categories

+ @foreach ($categories as $category)

{{ $category->name }}

+ @if ($category->products->isEmpty())

No products in this category.

@else @@ -3362,8 +3377,10 @@ exports[`validation/conformance/formatter-cases > formats templates__006 1`] = ` @section ('content')

Dashboard

+ @if (Auth::check())

Welcome back, {{ Auth::user()->name }}!

+ @if (Auth::user()->isAdmin())

You have admin privileges.

@else @@ -3383,6 +3400,7 @@ exports[`validation/conformance/formatter-cases > formats templates__007 1`] = ` @section ('content')

Dashboard

+ @if (Auth::check()) @if (Auth::user()->isAdmin()) @else @@ -3400,6 +3418,7 @@ exports[`validation/conformance/formatter-cases > formats templates__008 1`] = ` @section ('content')

Notification

+ @switch ($notification->type) @case ('message')

You have a new message from {{ $notification->from }}.

diff --git a/tests/validation/corpora/__snapshots__/filament-fixtures.test.ts.snap b/tests/validation/corpora/__snapshots__/filament-fixtures.test.ts.snap index 5351ffd..e7146ee 100644 --- a/tests/validation/corpora/__snapshots__/filament-fixtures.test.ts.snap +++ b/tests/validation/corpora/__snapshots__/filament-fixtures.test.ts.snap @@ -56,7 +56,9 @@ exports[`validation/filament-fixtures > formats actions_resources_views_action-m @if ($this->mountedActionHasSchema(mountedAction: $action)) {{ FilamentView::renderHook(ActionsRenderHook::MODAL_SCHEMA_BEFORE, scopes: static::class, data: ['action' => $action]) }} + {{ $this->getMountedActionSchema(mountedAction: $action) }} + {{ FilamentView::renderHook(ActionsRenderHook::MODAL_SCHEMA_AFTER, scopes: static::class, data: ['action' => $action]) }} @endif @@ -152,6 +154,7 @@ exports[`validation/filament-fixtures > formats actions_resources_views_componen @endif @endforeach
+ @php $this->hasActionsModalRendered = true; @endphp @@ -275,6 +278,7 @@ exports[`validation/filament-fixtures > formats forms_resources_views_components $reorderActionIsVisible = $isReorderableWithDragAndDrop && $reorderAction->isVisible(); $hasItemHeader = $hasBlockHeaders && ($reorderActionIsVisible || $moveUpActionIsVisible || $moveDownActionIsVisible || $hasBlockIcons || $hasBlockLabels || $editActionIsVisible || $cloneActionIsVisible || $deleteActionIsVisible || $isCollapsible || $visibleExtraItemActions); @endphp +
  • formats forms_resources_views_components @if ($moveUpActionIsVisible || $moveDownActionIsVisible)
  • {{ $moveUpAction }}
  • +
  • {{ $moveDownAction }}
  • @endif @@ -394,6 +399,7 @@ exports[`validation/filament-fixtures > formats forms_resources_views_components > {{ $item->getParentComponent()->renderPreview($item->getRawState()) }}
    + @if ($editActionIsVisible && (! $hasInteractiveBlockPreviews))
    formats forms_resources_views_components @endif
    + @if (! $loop->last) @if ($isAddable && $addBetweenAction(['afterItem' => $itemKey])->isVisible())
  • @@ -524,6 +531,7 @@ exports[`validation/filament-fixtures > formats forms_resources_views_components $wireClickAction = "mountAction('{$action->getName()}', {$wireClickActionArguments}, { schemaComponent: '{$key}' })"; @endphp + formats forms_resources_views_components /> @endif + @if ($isBulkToggleable && count($options))
    formats forms_resources_views_components class="fi-fo-date-time-picker-year-input" />
    +
    +