Repository: ony3000/prettier-plugin-classnames (ts)

Issue:
Component composition with dot notation is not supported.
## Dependency information
TypeScript
React
Prettier 3.2
prettier-plugin-classnames 0.6.3

## Steps to reproduce

```javascript
export const Overlay = () => {
  return (
    <Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
  );
};
```

## The current behavior
The className of the Dialog.Overlay is completely ignored.

## The expected behavior
The component classes wrapped based on the printWidth option.

Pull request description:
Correctly extract name of compound component
This PR closes #47.

Patch:
diff --git a/src/packages/core-parts/finder.ts b/src/packages/core-parts/finder.ts
index 9393e65..85a9d80 100644
--- a/src/packages/core-parts/finder.ts
+++ b/src/packages/core-parts/finder.ts
@@ -9,10 +9,31 @@ type ASTNode = {
   range: NodeRange;
 };
 
+type JSXOpeningElementNameAsString = {
+  type: 'JSXIdentifier';
+  name: string;
+};
+
+type JSXOpeningElementNameAsObject = {
+  type: 'JSXMemberExpression';
+  object: JSXOpeningElementNameAsString | JSXOpeningElementNameAsObject;
+  property: JSXOpeningElementNameAsString;
+};
+
 function isTypeof<T extends ZodTypeAny>(arg: unknown, expectedSchema: T): arg is ZodInfer<T> {
   return expectedSchema.safeParse(arg).success;
 }
 
+function getElementName(
+  param: JSXOpeningElementNameAsString | JSXOpeningElementNameAsObject,
+): string {
+  if (param.type === 'JSXIdentifier') {
+    return param.name;
+  }
+
+  return `${getElementName(param.object)}.${param.property.name}`;
+}
+
 function filterAndSortClassNameNodes(
   nonCommentNodes: ASTNode[],
   prettierIgnoreNodes: ASTNode[],
@@ -168,9 +189,28 @@ export function findTargetClassNameNodes(
                   line: z.unknown(),
                 }),
               }),
-              name: z.object({
-                name: z.string(),
-              }),
+              name: z.union([
+                z.object({
+                  type: z.literal('JSXIdentifier'),
+                  name: z.string(),
+                }),
+                z.object({
+                  type: z.literal('JSXMemberExpression'),
+                  // recursive structure
+                  object: z.union([
+                    z.object({
+                      type: z.literal('JSXIdentifier'),
+                    }),
+                    z.object({
+                      type: z.literal('JSXMemberExpression'),
+                    }),
+                  ]),
+                  property: z.object({
+                    type: z.literal('JSXIdentifier'),
+                    name: z.string(),
+                  }),
+                }),
+              ]),
             }),
           ) &&
           parentNode.type === 'JSXOpeningElement' &&
@@ -210,7 +250,10 @@ export function findTargetClassNameNodes(
                     ? ClassNameType.ASL
                     : ClassNameType.AOL;
                 // eslint-disable-next-line no-param-reassign
-                classNameNode.elementName = parentNode.name.name;
+                classNameNode.elementName = getElementName(
+                  // @ts-ignore
+                  parentNode.name,
+                );
               }
             }
           });


Test patch:
diff --git a/tests/v2-test/babel/issue-47/absolute.test.ts b/tests/v2-test/babel/issue-47/absolute.test.ts
new file mode 100644
index 0000000..0f431fe
--- /dev/null
+++ b/tests/v2-test/babel/issue-47/absolute.test.ts
@@ -0,0 +1,92 @@
+import { format } from 'prettier';
+import type { Fixture } from 'test-settings';
+import { baseOptions } from 'test-settings';
+import { describe, expect, test } from 'vitest';
+
+// eslint-disable-next-line import/no-extraneous-dependencies
+import * as thisPlugin from '@/packages/v2-plugin';
+
+const options = {
+  ...baseOptions,
+  plugins: [thisPlugin],
+  parser: 'babel',
+  endingPosition: 'absolute',
+};
+
+const fixtures: Fixture[] = [
+  {
+    name: 'component composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0
+z-50 h-screen w-screen"
+    />
+  );
+};
+`,
+  },
+  {
+    name: 'double composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Foo.Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Foo.Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0
+z-50 h-screen w-screen"
+    />
+  );
+};
+`,
+  },
+  {
+    name: 'triple composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Foo.Bar.Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Foo.Bar.Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0
+z-50 h-screen w-screen"
+    />
+  );
+};
+`,
+  },
+];
+
+describe.each(fixtures)('$name', ({ input, output, options: fixtureOptions }) => {
+  const fixedOptions = {
+    ...options,
+    ...(fixtureOptions ?? {}),
+  };
+  const formattedText = format(input, fixedOptions);
+
+  test('expectation', () => {
+    expect(formattedText).toBe(output);
+  });
+
+  test.runIf(formattedText === output)('consistency', () => {
+    const doubleFormattedText = format(formattedText, fixedOptions);
+
+    expect(doubleFormattedText).toBe(formattedText);
+  });
+});
diff --git a/tests/v2-test/babel/issue-47/ideal.test.ts b/tests/v2-test/babel/issue-47/ideal.test.ts
new file mode 100644
index 0000000..2231ce8
--- /dev/null
+++ b/tests/v2-test/babel/issue-47/ideal.test.ts
@@ -0,0 +1,92 @@
+import { format } from 'prettier';
+import type { Fixture } from 'test-settings';
+import { baseOptions } from 'test-settings';
+import { describe, expect, test } from 'vitest';
+
+// eslint-disable-next-line import/no-extraneous-dependencies
+import * as thisPlugin from '@/packages/v2-plugin';
+
+const options = {
+  ...baseOptions,
+  plugins: [thisPlugin],
+  parser: 'babel',
+  endingPosition: 'absolute-with-indent',
+};
+
+const fixtures: Fixture[] = [
+  {
+    name: 'component composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0
+        z-50 h-screen w-screen"
+    />
+  );
+};
+`,
+  },
+  {
+    name: 'double composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Foo.Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Foo.Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0
+        z-50 h-screen w-screen"
+    />
+  );
+};
+`,
+  },
+  {
+    name: 'triple composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Foo.Bar.Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Foo.Bar.Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0
+        z-50 h-screen w-screen"
+    />
+  );
+};
+`,
+  },
+];
+
+describe.each(fixtures)('$name', ({ input, output, options: fixtureOptions }) => {
+  const fixedOptions = {
+    ...options,
+    ...(fixtureOptions ?? {}),
+  };
+  const formattedText = format(input, fixedOptions);
+
+  test('expectation', () => {
+    expect(formattedText).toBe(output);
+  });
+
+  test.runIf(formattedText === output)('consistency', () => {
+    const doubleFormattedText = format(formattedText, fixedOptions);
+
+    expect(doubleFormattedText).toBe(formattedText);
+  });
+});
diff --git a/tests/v2-test/babel/issue-47/relative.test.ts b/tests/v2-test/babel/issue-47/relative.test.ts
new file mode 100644
index 0000000..869104e
--- /dev/null
+++ b/tests/v2-test/babel/issue-47/relative.test.ts
@@ -0,0 +1,92 @@
+import { format } from 'prettier';
+import type { Fixture } from 'test-settings';
+import { baseOptions } from 'test-settings';
+import { describe, expect, test } from 'vitest';
+
+// eslint-disable-next-line import/no-extraneous-dependencies
+import * as thisPlugin from '@/packages/v2-plugin';
+
+const options = {
+  ...baseOptions,
+  plugins: [thisPlugin],
+  parser: 'babel',
+  endingPosition: 'relative',
+};
+
+const fixtures: Fixture[] = [
+  {
+    name: 'component composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen
+        w-screen"
+    />
+  );
+};
+`,
+  },
+  {
+    name: 'double composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Foo.Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Foo.Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen
+        w-screen"
+    />
+  );
+};
+`,
+  },
+  {
+    name: 'triple composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Foo.Bar.Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Foo.Bar.Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen
+        w-screen"
+    />
+  );
+};
+`,
+  },
+];
+
+describe.each(fixtures)('$name', ({ input, output, options: fixtureOptions }) => {
+  const fixedOptions = {
+    ...options,
+    ...(fixtureOptions ?? {}),
+  };
+  const formattedText = format(input, fixedOptions);
+
+  test('expectation', () => {
+    expect(formattedText).toBe(output);
+  });
+
+  test.runIf(formattedText === output)('consistency', () => {
+    const doubleFormattedText = format(formattedText, fixedOptions);
+
+    expect(doubleFormattedText).toBe(formattedText);
+  });
+});
diff --git a/tests/v2-test/typescript/issue-47/absolute.test.ts b/tests/v2-test/typescript/issue-47/absolute.test.ts
new file mode 100644
index 0000000..3d198e8
--- /dev/null
+++ b/tests/v2-test/typescript/issue-47/absolute.test.ts
@@ -0,0 +1,92 @@
+import { format } from 'prettier';
+import type { Fixture } from 'test-settings';
+import { baseOptions } from 'test-settings';
+import { describe, expect, test } from 'vitest';
+
+// eslint-disable-next-line import/no-extraneous-dependencies
+import * as thisPlugin from '@/packages/v2-plugin';
+
+const options = {
+  ...baseOptions,
+  plugins: [thisPlugin],
+  parser: 'typescript',
+  endingPosition: 'absolute',
+};
+
+const fixtures: Fixture[] = [
+  {
+    name: 'component composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0
+z-50 h-screen w-screen"
+    />
+  );
+};
+`,
+  },
+  {
+    name: 'double composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Foo.Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Foo.Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0
+z-50 h-screen w-screen"
+    />
+  );
+};
+`,
+  },
+  {
+    name: 'triple composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Foo.Bar.Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Foo.Bar.Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0
+z-50 h-screen w-screen"
+    />
+  );
+};
+`,
+  },
+];
+
+describe.each(fixtures)('$name', ({ input, output, options: fixtureOptions }) => {
+  const fixedOptions = {
+    ...options,
+    ...(fixtureOptions ?? {}),
+  };
+  const formattedText = format(input, fixedOptions);
+
+  test('expectation', () => {
+    expect(formattedText).toBe(output);
+  });
+
+  test.runIf(formattedText === output)('consistency', () => {
+    const doubleFormattedText = format(formattedText, fixedOptions);
+
+    expect(doubleFormattedText).toBe(formattedText);
+  });
+});
diff --git a/tests/v2-test/typescript/issue-47/ideal.test.ts b/tests/v2-test/typescript/issue-47/ideal.test.ts
new file mode 100644
index 0000000..16a0ead
--- /dev/null
+++ b/tests/v2-test/typescript/issue-47/ideal.test.ts
@@ -0,0 +1,92 @@
+import { format } from 'prettier';
+import type { Fixture } from 'test-settings';
+import { baseOptions } from 'test-settings';
+import { describe, expect, test } from 'vitest';
+
+// eslint-disable-next-line import/no-extraneous-dependencies
+import * as thisPlugin from '@/packages/v2-plugin';
+
+const options = {
+  ...baseOptions,
+  plugins: [thisPlugin],
+  parser: 'typescript',
+  endingPosition: 'absolute-with-indent',
+};
+
+const fixtures: Fixture[] = [
+  {
+    name: 'component composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0
+        z-50 h-screen w-screen"
+    />
+  );
+};
+`,
+  },
+  {
+    name: 'double composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Foo.Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Foo.Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0
+        z-50 h-screen w-screen"
+    />
+  );
+};
+`,
+  },
+  {
+    name: 'triple composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Foo.Bar.Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Foo.Bar.Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0
+        z-50 h-screen w-screen"
+    />
+  );
+};
+`,
+  },
+];
+
+describe.each(fixtures)('$name', ({ input, output, options: fixtureOptions }) => {
+  const fixedOptions = {
+    ...options,
+    ...(fixtureOptions ?? {}),
+  };
+  const formattedText = format(input, fixedOptions);
+
+  test('expectation', () => {
+    expect(formattedText).toBe(output);
+  });
+
+  test.runIf(formattedText === output)('consistency', () => {
+    const doubleFormattedText = format(formattedText, fixedOptions);
+
+    expect(doubleFormattedText).toBe(formattedText);
+  });
+});
diff --git a/tests/v2-test/typescript/issue-47/relative.test.ts b/tests/v2-test/typescript/issue-47/relative.test.ts
new file mode 100644
index 0000000..54888ad
--- /dev/null
+++ b/tests/v2-test/typescript/issue-47/relative.test.ts
@@ -0,0 +1,92 @@
+import { format } from 'prettier';
+import type { Fixture } from 'test-settings';
+import { baseOptions } from 'test-settings';
+import { describe, expect, test } from 'vitest';
+
+// eslint-disable-next-line import/no-extraneous-dependencies
+import * as thisPlugin from '@/packages/v2-plugin';
+
+const options = {
+  ...baseOptions,
+  plugins: [thisPlugin],
+  parser: 'typescript',
+  endingPosition: 'relative',
+};
+
+const fixtures: Fixture[] = [
+  {
+    name: 'component composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen
+        w-screen"
+    />
+  );
+};
+`,
+  },
+  {
+    name: 'double composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Foo.Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Foo.Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen
+        w-screen"
+    />
+  );
+};
+`,
+  },
+  {
+    name: 'triple composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Foo.Bar.Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Foo.Bar.Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen
+        w-screen"
+    />
+  );
+};
+`,
+  },
+];
+
+describe.each(fixtures)('$name', ({ input, output, options: fixtureOptions }) => {
+  const fixedOptions = {
+    ...options,
+    ...(fixtureOptions ?? {}),
+  };
+  const formattedText = format(input, fixedOptions);
+
+  test('expectation', () => {
+    expect(formattedText).toBe(output);
+  });
+
+  test.runIf(formattedText === output)('consistency', () => {
+    const doubleFormattedText = format(formattedText, fixedOptions);
+
+    expect(doubleFormattedText).toBe(formattedText);
+  });
+});
diff --git a/tests/v3-test/babel/issue-47/absolute.test.ts b/tests/v3-test/babel/issue-47/absolute.test.ts
new file mode 100644
index 0000000..de1c1fb
--- /dev/null
+++ b/tests/v3-test/babel/issue-47/absolute.test.ts
@@ -0,0 +1,92 @@
+import { format } from 'prettier';
+import type { Fixture } from 'test-settings';
+import { baseOptions } from 'test-settings';
+import { describe, expect, test } from 'vitest';
+
+// eslint-disable-next-line import/no-extraneous-dependencies
+import * as thisPlugin from '@/packages/v3-plugin';
+
+const options = {
+  ...baseOptions,
+  plugins: [thisPlugin],
+  parser: 'babel',
+  endingPosition: 'absolute',
+};
+
+const fixtures: Fixture[] = [
+  {
+    name: 'component composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0
+z-50 h-screen w-screen"
+    />
+  );
+};
+`,
+  },
+  {
+    name: 'double composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Foo.Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Foo.Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0
+z-50 h-screen w-screen"
+    />
+  );
+};
+`,
+  },
+  {
+    name: 'triple composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Foo.Bar.Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Foo.Bar.Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0
+z-50 h-screen w-screen"
+    />
+  );
+};
+`,
+  },
+];
+
+describe.each(fixtures)('$name', async ({ input, output, options: fixtureOptions }) => {
+  const fixedOptions = {
+    ...options,
+    ...(fixtureOptions ?? {}),
+  };
+  const formattedText = await format(input, fixedOptions);
+
+  test('expectation', () => {
+    expect(formattedText).toBe(output);
+  });
+
+  test.runIf(formattedText === output)('consistency', async () => {
+    const doubleFormattedText = await format(formattedText, fixedOptions);
+
+    expect(doubleFormattedText).toBe(formattedText);
+  });
+});
diff --git a/tests/v3-test/babel/issue-47/ideal.test.ts b/tests/v3-test/babel/issue-47/ideal.test.ts
new file mode 100644
index 0000000..7631ae1
--- /dev/null
+++ b/tests/v3-test/babel/issue-47/ideal.test.ts
@@ -0,0 +1,92 @@
+import { format } from 'prettier';
+import type { Fixture } from 'test-settings';
+import { baseOptions } from 'test-settings';
+import { describe, expect, test } from 'vitest';
+
+// eslint-disable-next-line import/no-extraneous-dependencies
+import * as thisPlugin from '@/packages/v3-plugin';
+
+const options = {
+  ...baseOptions,
+  plugins: [thisPlugin],
+  parser: 'babel',
+  endingPosition: 'absolute-with-indent',
+};
+
+const fixtures: Fixture[] = [
+  {
+    name: 'component composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0
+        z-50 h-screen w-screen"
+    />
+  );
+};
+`,
+  },
+  {
+    name: 'double composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Foo.Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Foo.Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0
+        z-50 h-screen w-screen"
+    />
+  );
+};
+`,
+  },
+  {
+    name: 'triple composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Foo.Bar.Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Foo.Bar.Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0
+        z-50 h-screen w-screen"
+    />
+  );
+};
+`,
+  },
+];
+
+describe.each(fixtures)('$name', async ({ input, output, options: fixtureOptions }) => {
+  const fixedOptions = {
+    ...options,
+    ...(fixtureOptions ?? {}),
+  };
+  const formattedText = await format(input, fixedOptions);
+
+  test('expectation', () => {
+    expect(formattedText).toBe(output);
+  });
+
+  test.runIf(formattedText === output)('consistency', async () => {
+    const doubleFormattedText = await format(formattedText, fixedOptions);
+
+    expect(doubleFormattedText).toBe(formattedText);
+  });
+});
diff --git a/tests/v3-test/babel/issue-47/relative.test.ts b/tests/v3-test/babel/issue-47/relative.test.ts
new file mode 100644
index 0000000..310b2f8
--- /dev/null
+++ b/tests/v3-test/babel/issue-47/relative.test.ts
@@ -0,0 +1,92 @@
+import { format } from 'prettier';
+import type { Fixture } from 'test-settings';
+import { baseOptions } from 'test-settings';
+import { describe, expect, test } from 'vitest';
+
+// eslint-disable-next-line import/no-extraneous-dependencies
+import * as thisPlugin from '@/packages/v3-plugin';
+
+const options = {
+  ...baseOptions,
+  plugins: [thisPlugin],
+  parser: 'babel',
+  endingPosition: 'relative',
+};
+
+const fixtures: Fixture[] = [
+  {
+    name: 'component composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen
+        w-screen"
+    />
+  );
+};
+`,
+  },
+  {
+    name: 'double composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Foo.Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Foo.Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen
+        w-screen"
+    />
+  );
+};
+`,
+  },
+  {
+    name: 'triple composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Foo.Bar.Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Foo.Bar.Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen
+        w-screen"
+    />
+  );
+};
+`,
+  },
+];
+
+describe.each(fixtures)('$name', async ({ input, output, options: fixtureOptions }) => {
+  const fixedOptions = {
+    ...options,
+    ...(fixtureOptions ?? {}),
+  };
+  const formattedText = await format(input, fixedOptions);
+
+  test('expectation', () => {
+    expect(formattedText).toBe(output);
+  });
+
+  test.runIf(formattedText === output)('consistency', async () => {
+    const doubleFormattedText = await format(formattedText, fixedOptions);
+
+    expect(doubleFormattedText).toBe(formattedText);
+  });
+});
diff --git a/tests/v3-test/typescript/issue-47/absolute.test.ts b/tests/v3-test/typescript/issue-47/absolute.test.ts
new file mode 100644
index 0000000..8719c07
--- /dev/null
+++ b/tests/v3-test/typescript/issue-47/absolute.test.ts
@@ -0,0 +1,92 @@
+import { format } from 'prettier';
+import type { Fixture } from 'test-settings';
+import { baseOptions } from 'test-settings';
+import { describe, expect, test } from 'vitest';
+
+// eslint-disable-next-line import/no-extraneous-dependencies
+import * as thisPlugin from '@/packages/v3-plugin';
+
+const options = {
+  ...baseOptions,
+  plugins: [thisPlugin],
+  parser: 'typescript',
+  endingPosition: 'absolute',
+};
+
+const fixtures: Fixture[] = [
+  {
+    name: 'component composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0
+z-50 h-screen w-screen"
+    />
+  );
+};
+`,
+  },
+  {
+    name: 'double composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Foo.Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Foo.Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0
+z-50 h-screen w-screen"
+    />
+  );
+};
+`,
+  },
+  {
+    name: 'triple composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Foo.Bar.Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Foo.Bar.Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0
+z-50 h-screen w-screen"
+    />
+  );
+};
+`,
+  },
+];
+
+describe.each(fixtures)('$name', async ({ input, output, options: fixtureOptions }) => {
+  const fixedOptions = {
+    ...options,
+    ...(fixtureOptions ?? {}),
+  };
+  const formattedText = await format(input, fixedOptions);
+
+  test('expectation', () => {
+    expect(formattedText).toBe(output);
+  });
+
+  test.runIf(formattedText === output)('consistency', async () => {
+    const doubleFormattedText = await format(formattedText, fixedOptions);
+
+    expect(doubleFormattedText).toBe(formattedText);
+  });
+});
diff --git a/tests/v3-test/typescript/issue-47/ideal.test.ts b/tests/v3-test/typescript/issue-47/ideal.test.ts
new file mode 100644
index 0000000..1337a8b
--- /dev/null
+++ b/tests/v3-test/typescript/issue-47/ideal.test.ts
@@ -0,0 +1,92 @@
+import { format } from 'prettier';
+import type { Fixture } from 'test-settings';
+import { baseOptions } from 'test-settings';
+import { describe, expect, test } from 'vitest';
+
+// eslint-disable-next-line import/no-extraneous-dependencies
+import * as thisPlugin from '@/packages/v3-plugin';
+
+const options = {
+  ...baseOptions,
+  plugins: [thisPlugin],
+  parser: 'typescript',
+  endingPosition: 'absolute-with-indent',
+};
+
+const fixtures: Fixture[] = [
+  {
+    name: 'component composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0
+        z-50 h-screen w-screen"
+    />
+  );
+};
+`,
+  },
+  {
+    name: 'double composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Foo.Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Foo.Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0
+        z-50 h-screen w-screen"
+    />
+  );
+};
+`,
+  },
+  {
+    name: 'triple composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Foo.Bar.Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Foo.Bar.Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0
+        z-50 h-screen w-screen"
+    />
+  );
+};
+`,
+  },
+];
+
+describe.each(fixtures)('$name', async ({ input, output, options: fixtureOptions }) => {
+  const fixedOptions = {
+    ...options,
+    ...(fixtureOptions ?? {}),
+  };
+  const formattedText = await format(input, fixedOptions);
+
+  test('expectation', () => {
+    expect(formattedText).toBe(output);
+  });
+
+  test.runIf(formattedText === output)('consistency', async () => {
+    const doubleFormattedText = await format(formattedText, fixedOptions);
+
+    expect(doubleFormattedText).toBe(formattedText);
+  });
+});
diff --git a/tests/v3-test/typescript/issue-47/relative.test.ts b/tests/v3-test/typescript/issue-47/relative.test.ts
new file mode 100644
index 0000000..dbf4754
--- /dev/null
+++ b/tests/v3-test/typescript/issue-47/relative.test.ts
@@ -0,0 +1,92 @@
+import { format } from 'prettier';
+import type { Fixture } from 'test-settings';
+import { baseOptions } from 'test-settings';
+import { describe, expect, test } from 'vitest';
+
+// eslint-disable-next-line import/no-extraneous-dependencies
+import * as thisPlugin from '@/packages/v3-plugin';
+
+const options = {
+  ...baseOptions,
+  plugins: [thisPlugin],
+  parser: 'typescript',
+  endingPosition: 'relative',
+};
+
+const fixtures: Fixture[] = [
+  {
+    name: 'component composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen
+        w-screen"
+    />
+  );
+};
+`,
+  },
+  {
+    name: 'double composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Foo.Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Foo.Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen
+        w-screen"
+    />
+  );
+};
+`,
+  },
+  {
+    name: 'triple composition',
+    input: `
+export const Overlay = () => {
+  return (
+    <Foo.Bar.Dialog.Overlay className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen w-screen" />
+  );
+};
+`,
+    output: `export const Overlay = () => {
+  return (
+    <Foo.Bar.Dialog.Overlay
+      className="bg-popover backdrop-opacity-disabled focus:ring-0 fixed inset-0 z-50 h-screen
+        w-screen"
+    />
+  );
+};
+`,
+  },
+];
+
+describe.each(fixtures)('$name', async ({ input, output, options: fixtureOptions }) => {
+  const fixedOptions = {
+    ...options,
+    ...(fixtureOptions ?? {}),
+  };
+  const formattedText = await format(input, fixedOptions);
+
+  test('expectation', () => {
+    expect(formattedText).toBe(output);
+  });
+
+  test.runIf(formattedText === output)('consistency', async () => {
+    const doubleFormattedText = await format(formattedText, fixedOptions);
+
+    expect(doubleFormattedText).toBe(formattedText);
+  });
+});