Repository: prettier/plugin-pug (ts)

Issue:
Bug: pugClassNotation: 'attribute' outputs junk if class literals are after attributes
### Plugin Version

3.1.0

### Prettier Version

3.3.3

### Which frameworks are affected?

- [ ] none
- [ ] vue
- [ ] angular
- [ ] svelte

### Node Version

20.17.0

### Which operating systems have you used?

- [ ] Linux
- [X] macOS
- [ ] Windows

### Prettier config

```json
{
  pugClassNotation: 'attribute'
}
```


### Input

```pug
div.foo
div(attribute="value").foo
div(attribute="value" class="bar").foo
div(attribute="value" class="bar").foo.baz
div(
  attribute="value"
  class="bar"
).foo.baz
div.foo(attribute="value" class="bar").baz
div: span.foo
div: span(class="foo")
div.foo: span.bar(class="baz").foo
div.foo(class="bar").baz: h1.foo(class="bar").baz Let's really #[span.foo(class="bar").baz get nasty]!
```


### Output or Error

```bash
(class="foo")
div(attribute="value")div(class="foo")
div(attribute="value", class="bar")div(class="foo")
div(attribute="value", class="bar")div(class="foo baz")
div(attribute="value", class="bar")div(class="foo baz")
(attribute="value", class="foo bar")div(class="baz")
div: span(class="foo") // <- this line was correct!
div: span(class="foo") // <- this line was correct!
div: span(class="foo")
: span(class="foo bar baz")div(class="foo")
(class="foo bar")div: h1(class="baz foo bar")div(class="baz") Let's really #[span(class="foo bar")div(class="baz") get nasty]!
```


### Expected Output

```pug
div(class="foo")
div(attribute="value", class="foo")
div(attribute="value")
div(attribute="value", class="bar foo")
div(attribute="value", class="bar foo baz")
div(attribute="value", class="bar foo baz")
div(attribute="value", class="bar foo baz")
div: span(class="foo")
div: span(class="foo")
div(class="foo"): span(class="bar baz foo")
div(class="foo bar baz"): h1(class="foo bar baz") Let's really #[span(class="foo bar baz") get nasty]!
```


### Additional Context

My workaround for this issue was to first use the option that refactors all class literals to the beginning and only then enable this class attribute option.

This is easy to repro by expanding the `pugClassNotation` test with the above code.

The offending code is easy to spot at the beginning of the private `class` function in `printer.ts,` but it's not immediately clear to me why that code works in the first place (do multiple chained attributes get merged by some follow-up logic?) or how to fix it properly. I'll open a PR if I can puzzle it out 🤷‍♂️

My verbosely commented version of the code that needs fixing:
```
    // Special handling for class literals when using the attribute notation.
    if (this.options.pugClassNotation === 'attribute') {
      // Add the class to the list of class literals to be converted to attributes.
      this.classLiteralsToBeConvertedToAttributes.push(token.val);

      // TODO: This can print junk (bug) if the class literal is after attributes. Investigate.
      if (
        this.previousToken?.type !== 'tag' &&
        this.previousToken?.type !== 'class'
      ) {
        this.result += `${this.computedIndent}div`;
      }

      // If this is the last class token (i.e. we are done with the literals)...
      // TODO: This probably does not handle inline elements chained with : correctly. Investigate.
      if (
        this.nextToken &&
        ['text', 'newline', 'indent', 'outdent', 'eos'].includes(
          this.nextToken.type,
        )
      ) {
        // Take a copy of the class literals to be converted to attributes and clear the list.
        const classes: string[] =
          this.classLiteralsToBeConvertedToAttributes.splice(
            0,
            this.classLiteralsToBeConvertedToAttributes.length,
          );

        // Print all the class literals as one class attribute.
        // TODO: What happens if the class attribute is already present? Investigate.
        this.result += `(class=${this.quoteString(classes.join(' '))})`;
        if (this.nextToken.type === 'text') {
          this.result += ' ';
        }
      }

      // Done processing the class token. Return early.
      return;
    }
```

Pull request:
Multiple fixes for class attribute conversions
Closes #508 

This PR adds tests and fixes for previously untested variations of class literals in increasingly complex scenarios. Most of them output non-valid Pug code currently.

I fixed all the new test cases to output valid Pug code without regressions, so this is probably safe to merge. I don't have full confidence that there are no other (currently untested) bad combinations of the various options, but this is for sure better than before.

Examples of code that previously outputted invalid Pug but now work:

```pug
// Class literals at the end of attributes did not work.
div(attribute="value").foo
div(class="bar", attribute="value").foo
div(attribute="value", class="bar").foo.baz
div(
  attribute="value"
  class="bar"
).foo.baz
div.foo(attribute="value" class="bar").baz

// Class literals ending at : did not work
div(class="foo").bar: span Text
div: span.foo
div: span(class="foo")
div.foo: span.bar(class="baz").foo

// Class literals in an interpolated line did not work
div.foo(class="bar").baz: h1.foo(class="bar").baz Let's really #[span.foo(class="bar").baz get nasty]!
```

Patch:
diff --git a/src/printer.ts b/src/printer.ts
index 1a47402..aa6b8ac 100644
--- a/src/printer.ts
+++ b/src/printer.ts
@@ -1274,24 +1274,73 @@ export class PugPrinter {
   private class(token: ClassToken): void {
     if (this.options.pugClassNotation === 'attribute') {
       this.classLiteralToAttribute.push(token.val);
+
+      // An extra div should be printed if...
       if (
-        this.previousToken?.type !== 'tag' &&
-        this.previousToken?.type !== 'class'
+        this.previousToken === undefined ||
+        // ...the previous token indicates that this was the first class literal and thus a div did not previously exist...
+        this.checkTokenType(
+          this.previousToken,
+          ['tag', 'class', 'end-attributes'],
+          true,
+        ) ||
+        // ...OR the previous token is a div that will be removed because of the no explicit divs rule.
+        (this.previousToken.type === 'tag' &&
+          this.previousToken.val === 'div' &&
+          this.nextToken?.type !== 'attribute' &&
+          !this.options.pugExplicitDiv)
       ) {
         this.result += `${this.computedIndent}div`;
       }
+
       if (
-        this.nextToken &&
-        ['text', 'newline', 'indent', 'outdent', 'eos'].includes(
-          this.nextToken.type,
-        )
+        this.checkTokenType(this.nextToken, [
+          'text',
+          'newline',
+          'indent',
+          'outdent',
+          'eos',
+          ':',
+        ])
       ) {
+        // Copy and clear the class literals list.
         const classes: string[] = this.classLiteralToAttribute.splice(
           0,
           this.classLiteralToAttribute.length,
         );
-        this.result += `(class=${this.quoteString(classes.join(' '))})`;
-        if (this.nextToken.type === 'text') {
+
+        // If the last result character was a )...
+        if (this.result.at(-1) === ')') {
+          // Look for 'class=' that is before the last '('...
+          const attributesStartIndex: number = this.result.lastIndexOf('(');
+          const lastClassIndex: number = this.result.indexOf(
+            'class=',
+            attributesStartIndex,
+          );
+
+          // If a 'class=' is found...
+          // eslint-disable-next-line unicorn/prefer-ternary -- This is more readable without ternaries.
+          if (lastClassIndex > -1) {
+            // ...then insert the new class into it.
+            this.result = [
+              this.result.slice(0, lastClassIndex + 7),
+              classes.join(' '),
+              ' ',
+              this.result.slice(lastClassIndex + 7),
+            ].join('');
+          } else {
+            // ...otherwise add a new class attribute into the existing attributes.
+            this.result =
+              this.result.slice(0, -1) +
+              `${this.neverUseAttributeSeparator ? ' ' : ', '}class=${this.quoteString(classes.join(' '))})`;
+          }
+          // ...or if the element has no attributes...
+        } else {
+          // Start a new attribute list with the class attribute in it.
+          this.result += `(class=${this.quoteString(classes.join(' '))})`;
+        }
+
+        if (this.nextToken?.type === 'text') {
           this.result += ' ';
         }
       }


Test patch:
diff --git a/tests/options/pugClassNotation/formatted-as-is.pug b/tests/options/pugClassNotation/formatted-as-is.pug
new file mode 100644
index 0000000..d7646a5
--- /dev/null
+++ b/tests/options/pugClassNotation/formatted-as-is.pug
@@ -0,0 +1,55 @@
+input.bar.baz(class="foo")
+input.bar.baz(
+  readonly,
+  class="foo",
+  name="test-input",
+  type="password",
+  value="Hello World"
+)
+.foo bar
+.foo(bar="baz") Hello World
+.foo
+  div(class="bar") baz
+.baz
+.bar foo
+- var code = "ma-2 px-1";
+div(class=code)
+.foo
+div.foo(attribute="value")
+div.foo(class="bar", attribute="value")
+div.foo.baz(attribute="value", class="bar")
+div.foo.baz(attribute="value", class="bar")
+.foo.baz(attribute="value", class="bar")
+div.bar(class="foo"): span Text
+div: span.foo
+div: span(class="foo")
+.foo: span.bar.foo(class="baz")
+.foo.baz(class="bar"): h1.foo.baz(class="bar") Let's really #[span.foo.baz(class="bar") get nasty]!
+.indent
+  input.bar.baz(class="foo")
+  input.bar.baz(
+    readonly,
+    class="foo",
+    name="test-input",
+    type="password",
+    value="Hello World"
+  )
+  .foo bar
+  .foo(bar="baz") Hello World
+  .foo
+    div(class="bar") baz
+  .baz
+  .bar foo
+  - var code = "ma-2 px-1";
+  div(class=code)
+  .foo
+  div.foo(attribute="value")
+  div.foo(class="bar", attribute="value")
+  div.foo.baz(attribute="value", class="bar")
+  div.foo.baz(attribute="value", class="bar")
+  .foo.baz(attribute="value", class="bar")
+  div.bar(class="foo"): span Text
+  div: span.foo
+  div: span(class="foo")
+  .foo: span.bar.foo(class="baz")
+  .foo.baz(class="bar"): h1.foo.baz(class="bar") Let's really #[span.foo.baz(class="bar") get nasty]!
diff --git a/tests/options/pugClassNotation/formatted-attribute.pug b/tests/options/pugClassNotation/formatted-attribute.pug
index b13d68e..8681466 100644
--- a/tests/options/pugClassNotation/formatted-attribute.pug
+++ b/tests/options/pugClassNotation/formatted-attribute.pug
@@ -8,3 +8,36 @@ div(class="baz")
 div(class="bar") foo
 - var code = "ma-2 px-1";
 div(class=code)
+div(class="foo")
+div(attribute="value", class="foo")
+div(class="foo bar", attribute="value")
+div(attribute="value", class="foo baz bar")
+div(attribute="value", class="foo baz bar")
+div(attribute="value", class="baz foo bar")
+div(class="bar foo"): span Text
+div: span(class="foo")
+div: span(class="foo")
+div(class="foo"): span(class="foo bar baz")
+div(class="baz foo bar"): h1(class="baz foo bar") Let's really #[span(class="baz foo bar") get nasty]!
+div(class="indent")
+  input(class="bar baz foo")
+  input(readonly, class="bar baz foo", name="test-input", type="password", value="Hello World")
+  div(class="foo") bar
+  div(bar="baz" class="foo") Hello World
+  div(class="foo")
+    div(class="bar") baz
+  div(class="baz")
+  div(class="bar") foo
+  - var code = "ma-2 px-1";
+  div(class=code)
+  div(class="foo")
+  div(attribute="value", class="foo")
+  div(class="foo bar", attribute="value")
+  div(attribute="value", class="foo baz bar")
+  div(attribute="value", class="foo baz bar")
+  div(attribute="value", class="baz foo bar")
+  div(class="bar foo"): span Text
+  div: span(class="foo")
+  div: span(class="foo")
+  div(class="foo"): span(class="foo bar baz")
+  div(class="baz foo bar"): h1(class="baz foo bar") Let's really #[span(class="baz foo bar") get nasty]!
diff --git a/tests/options/pugClassNotation/formatted-literal.pug b/tests/options/pugClassNotation/formatted-literal.pug
index b0af196..76eb9ee 100644
--- a/tests/options/pugClassNotation/formatted-literal.pug
+++ b/tests/options/pugClassNotation/formatted-literal.pug
@@ -13,3 +13,41 @@ input.bar.baz.foo(
 .bar foo
 - var code = "ma-2 px-1";
 div(class=code)
+.foo
+div.foo(attribute="value")
+.bar.foo(attribute="value")
+.bar.foo.baz(attribute="value")
+.bar.foo.baz(attribute="value")
+.foo.bar.baz(attribute="value")
+.foo.bar: span Text
+div: span.foo
+div: span.foo
+.foo: span.bar.baz.foo
+.foo.bar.baz: h1.foo.bar.baz Let's really #[span.foo.bar.baz get nasty]!
+.indent
+  input.bar.baz.foo
+  input.bar.baz.foo(
+    readonly,
+    name="test-input",
+    type="password",
+    value="Hello World"
+  )
+  .foo bar
+  .foo(bar="baz") Hello World
+  .foo
+    .bar baz
+  .baz
+  .bar foo
+  - var code = "ma-2 px-1";
+  div(class=code)
+  .foo
+  div.foo(attribute="value")
+  .bar.foo(attribute="value")
+  .bar.foo.baz(attribute="value")
+  .bar.foo.baz(attribute="value")
+  .foo.bar.baz(attribute="value")
+  .foo.bar: span Text
+  div: span.foo
+  div: span.foo
+  .foo: span.bar.baz.foo
+  .foo.bar.baz: h1.foo.bar.baz Let's really #[span.foo.bar.baz get nasty]!
diff --git a/tests/options/pugClassNotation/pug-class-notation.test.ts b/tests/options/pugClassNotation/pug-class-notation.test.ts
index ea76a6d..4c842b5 100644
--- a/tests/options/pugClassNotation/pug-class-notation.test.ts
+++ b/tests/options/pugClassNotation/pug-class-notation.test.ts
@@ -4,13 +4,13 @@ import { describe, expect, it } from 'vitest';
 describe('Options', () => {
   describe('pugClassNotation', () => {
     it('should keep classes as is', async () => {
-      const { actual, code } = await compareFiles(import.meta.url, {
-        target: null,
+      const { actual, expected } = await compareFiles(import.meta.url, {
+        target: 'formatted-as-is.pug',
         formatOptions: {
           pugClassNotation: 'as-is',
         },
       });
-      expect(actual).toBe(code);
+      expect(actual).toBe(expected);
     });
 
     it('should keep classes as literals', async () => {
diff --git a/tests/options/pugClassNotation/unformatted.pug b/tests/options/pugClassNotation/unformatted.pug
index 9b0b78b..1b47c50 100644
--- a/tests/options/pugClassNotation/unformatted.pug
+++ b/tests/options/pugClassNotation/unformatted.pug
@@ -14,3 +14,48 @@ input.bar.baz(
 .bar foo
 - var code = "ma-2 px-1";
 div(class=code)
+div.foo
+div(attribute="value").foo
+div(class="bar", attribute="value").foo
+div(attribute="value", class="bar").foo.baz
+div(
+  attribute="value"
+  class="bar"
+).foo.baz
+div.foo(attribute="value" class="bar").baz
+div(class="foo").bar: span Text
+div: span.foo
+div: span(class="foo")
+div.foo: span.bar(class="baz").foo
+div.foo(class="bar").baz: h1.foo(class="bar").baz Let's really #[span.foo(class="bar").baz get nasty]!
+.indent
+  input.bar.baz(class="foo")
+  input.bar.baz(
+    readonly,
+    class="foo",
+    name="test-input",
+    type="password",
+    value="Hello World"
+  )
+  .foo bar
+  .foo(bar="baz") Hello World
+  .foo
+    div(class="bar") baz
+  .baz
+  .bar foo
+  - var code = "ma-2 px-1";
+  div(class=code)
+  div.foo
+  div(attribute="value").foo
+  div(class="bar", attribute="value").foo
+  div(attribute="value", class="bar").foo.baz
+  div(
+    attribute="value"
+    class="bar"
+  ).foo.baz
+  div.foo(attribute="value" class="bar").baz
+  div(class="foo").bar: span Text
+  div: span.foo
+  div: span(class="foo")
+  div.foo: span.bar(class="baz").foo
+  div.foo(class="bar").baz: h1.foo(class="bar").baz Let's really #[span.foo(class="bar").baz get nasty]!