Repository: prettier/plugin-php (php)

Issue:
[bug] singleQuote option from API call does not work on latest release
<!--

BEFORE SUBMITTING AN ISSUE:

1.  Search for your issue on GitHub: https://github.com/prettier/plugin-php/issues
    A large number of opened issues are duplicates of existing issues.
    If someone has already opened an issue for what you are experiencing,
    you do not need to open a new issue — please add a 👍 reaction to the
    existing issue instead.

2.  We get a lot of requests for adding options, but Prettier is
    built on the principle of being opinionated about code formatting.
    This means we have a very high bar for adding new options.
    Find out more: https://prettier.io/docs/en/option-philosophy.html

Tip! Don't write this stuff manually.

1. Go to https://loilo.github.io/prettier-php-playground/
2. Paste your code and set options
3. Press the "Report issue" button in the lower right

-->

## Environment

- **Prettier 3.x**
- **PHP Plugin 0.22.1**

```js
 $  yarn list | grep prettier
├─ @prettier/plugin-php@0.22.1
...
├─ prettier@3.1.1

``` 


## Current behaviour

In recent release (at least `0.22.1`), `prettier.format` API call like below does not respect `singleQuote` option. Other options are respected as like before.

```js
 $  cat test.js
import prettier from "prettier/standalone";
import prettierPluginPhp from "@prettier/plugin-php/standalone";

console.log((await prettier.format(`<?php echo link_to_route("frontend.users.user.show", $users["name"], $users['_id']); ?>`, {
  plugins: [prettierPluginPhp],
  singleQuote: true,
  parser: "php",
})));

```

It will outputs like below. You can see double quote is still double quote.

```js
 $  node test.js
<?php echo link_to_route(
    "frontend.users.user.show",
    $users["name"],
    $users['_id']
); ?>

```

### Expected behaviour

Respect `singleQuote` option as like before. 

0.19.7 is OK. I think this regression is introduced since v0.20.0

```js
 $  cat package.json
{
  "dependencies": {
    "@prettier/plugin-php": "0.19.7",
    "prettier": "^2.0.0"
  }
}

 $  cat test.mjs
import prettier from "prettier/standalone.js";
import prettierPluginPhp from "@prettier/plugin-php/standalone.js";

console.log((prettier.format(`<?php echo link_to_route("frontend.users.user.show", $users["name"], $users['_id']); ?>`, {
  plugins: [prettierPluginPhp],
  singleQuote: true,
  parser: "php",
})));

```


```js
 $  node test.mjs
<?php echo link_to_route(
    'frontend.users.user.show',
    $users['name'],
    $users['_id']
); ?>

```

I tried some fix and found adding default option could solve the issue. I'll create the PR.

Pull request description:
fix: 🐛 singleQuote option does not work on format API
## Description
This PR fixes #2302. `singleQuote` option regression on format API.

## Related Issue
- Fixes: #2302

## How it has been tested?
Added test for format api call. Please point out if this test method is not allowed in this project.

Patch:
diff --git a/src/options.mjs b/src/options.mjs
index 1600dbd..93fca45 100644
--- a/src/options.mjs
+++ b/src/options.mjs
@@ -45,4 +45,11 @@ export default {
       { value: "1tbs", description: "Use 1tbs brace style." },
     ],
   },
+  singleQuote: {
+    since: "0.0.0",
+    category: CATEGORY_PHP,
+    type: "boolean",
+    default: false,
+    description: "Use single quotes instead of double quotes.",
+  },
 };
diff --git a/src/printer.mjs b/src/printer.mjs
index c822dce..8c7e8fb 100644
--- a/src/printer.mjs
+++ b/src/printer.mjs
@@ -36,6 +36,7 @@ import {
   getAncestorNode,
   isReferenceLikeNode,
   normalizeMagicMethodName,
+  isSimpleCallArgument,
 } from "./util.mjs";
 
 const {
@@ -508,9 +509,9 @@ function printMemberChain(path, options, print) {
     printIndentedGroup(groups.slice(shouldMerge ? 2 : 1)),
   ];
 
-  const callExpressionCount = printedNodes.filter(
-    (tuple) => tuple.node.kind === "call"
-  ).length;
+  const callExpressions = printedNodes.filter((tuple) =>
+    ["call", "new"].includes(tuple.node.kind)
+  );
 
   // We don't want to print in one line if there's:
   //  * A comment.
@@ -519,7 +520,10 @@ function printMemberChain(path, options, print) {
   // If the last group is a function it's okay to inline if it fits.
   if (
     hasComment ||
-    callExpressionCount >= 3 ||
+    (callExpressions.length > 2 &&
+      callExpressions.some(
+        (exp) => !exp.node.arguments.every((arg) => isSimpleCallArgument(arg))
+      )) ||
     printedGroups.slice(0, -1).some(willBreak)
   ) {
     return group(expanded);
diff --git a/src/util.mjs b/src/util.mjs
index f54e9af..6890e7d 100644
--- a/src/util.mjs
+++ b/src/util.mjs
@@ -612,6 +612,79 @@ function normalizeMagicMethodName(name) {
   return name;
 }
 
+/**
+ * @param {string[]} kindsArray
+ * @returns {(node: Node | Comment) => Boolean}
+ */
+function createTypeCheckFunction(kindsArray) {
+  const kinds = new Set(kindsArray);
+  return (node) => kinds.has(node?.kind);
+}
+
+const isSingleWordType = createTypeCheckFunction([
+  "variable",
+  "parameter",
+  "variadic",
+  "clone",
+  "cast",
+  "boolean",
+  "number",
+  "string",
+  "literal",
+  "nullkeyword",
+  "namedargument",
+  "variadicplaceholder",
+]);
+
+const isArrayExpression = createTypeCheckFunction(["array"]);
+const isCallOrNewExpression = createTypeCheckFunction(["call", "new"]);
+const isArrowFuncExpression = createTypeCheckFunction(["arrowfunc"]);
+
+function getChainParts(node, prev = []) {
+  const parts = prev;
+  if (isCallOrNewExpression(node)) {
+    parts.push(node);
+  }
+
+  if (!node.what) {
+    return parts;
+  }
+
+  return getChainParts(node.what, parts);
+}
+
+function isSimpleCallArgument(node, depth = 2) {
+  if (depth <= 0) {
+    return false;
+  }
+
+  const isChildSimple = (child) => isSimpleCallArgument(child, depth - 1);
+
+  if (isSingleWordType(node)) {
+    return true;
+  }
+
+  if (isArrayExpression(node)) {
+    return node.items.every((x) => x === null || isChildSimple(x));
+  }
+
+  if (isCallOrNewExpression(node)) {
+    const parts = getChainParts(node);
+    return (
+      parts.filter((node) => node.kind === "call").length <= depth &&
+      parts.every((node) => node.arguments.every(isChildSimple))
+    );
+  }
+
+  if (isArrowFuncExpression(node)) {
+    return (
+      node.arguments.length <= depth && node.arguments.every(isChildSimple)
+    );
+  }
+
+  return false;
+}
+
 export {
   printNumber,
   getPrecedence,
@@ -641,4 +714,5 @@ export {
   isDocNode,
   getAncestorNode,
   normalizeMagicMethodName,
+  isSimpleCallArgument,
 };


Test patch:
diff --git a/tests/call/__snapshots__/jsfmt.spec.mjs.snap b/tests/call/__snapshots__/jsfmt.spec.mjs.snap
index a56a8dc..94fbc34 100644
--- a/tests/call/__snapshots__/jsfmt.spec.mjs.snap
+++ b/tests/call/__snapshots__/jsfmt.spec.mjs.snap
@@ -705,18 +705,8 @@ $var = $this->foo()();
 $var = ($this->foo->bar)();
 $var = $this->foo->bar()();
 $var = ($this->foo->bar->baz->foo->bar->baz)();
-$var = $this->foo()
-    ->bar()
-    ->baz()
-    ->foo()
-    ->bar()
-    ->baz()();
-$var = ($this->foo()
-    ->bar()
-    ->baz()
-    ->foo()
-    ->bar()
-    ->baz)();
+$var = $this->foo()->bar()->baz()->foo()->bar()->baz()();
+$var = ($this->foo()->bar()->baz()->foo()->bar()->baz)();
 $var = $this["foo"]();
 $var = $this["foo"]["bar"]();
 $var = ($this::$foo)();
@@ -733,10 +723,7 @@ $var = ((["Foo", "bar"])->bar)();
 $var = ($var->foo)()();
 $var = ($var->foo)()();
 $var = (($var->foo)()->bar)();
-$var = ((($var
-    ->foo)()
-    ->bar)()
-    ->baz)();
+$var = ((($var->foo)()->bar)()->baz)();
 
 $obj = call('return new class($value)
 {
diff --git a/tests/member_chain/__snapshots__/jsfmt.spec.mjs.snap b/tests/member_chain/__snapshots__/jsfmt.spec.mjs.snap
index eeaab35..5089bfd 100644
--- a/tests/member_chain/__snapshots__/jsfmt.spec.mjs.snap
+++ b/tests/member_chain/__snapshots__/jsfmt.spec.mjs.snap
@@ -110,6 +110,12 @@ printWidth: 80
 
 $object->foo()->bar()->baz();
 
+$object->foo()->bar()->baz()->foo()->bar()->baz();
+
+$object->foo()->bar()->baz()->foo()->bar()->baz()->foo()->bar()->baz();
+
+$object->foo()->bar()->baz()->foo()->bar()->baz()->foo()->bar()->baz()->foo()->bar()->baz()->foo()->bar()->baz()->foo()->bar()->baz();
+
 foo()->bar()->baz();
 
 foo()->bar->baz();
@@ -117,15 +123,34 @@ foo()->bar->baz();
 =====================================output=====================================
 <?php
 
+$object->foo()->bar()->baz();
+
+$object->foo()->bar()->baz()->foo()->bar()->baz();
+
+$object->foo()->bar()->baz()->foo()->bar()->baz()->foo()->bar()->baz();
+
 $object
     ->foo()
     ->bar()
-    ->baz();
-
-foo()
+    ->baz()
+    ->foo()
+    ->bar()
+    ->baz()
+    ->foo()
+    ->bar()
+    ->baz()
+    ->foo()
+    ->bar()
+    ->baz()
+    ->foo()
+    ->bar()
+    ->baz()
+    ->foo()
     ->bar()
     ->baz();
 
+foo()->bar()->baz();
+
 foo()->bar->baz();
 
 ================================================================================
@@ -191,10 +216,7 @@ $object[$valid
 
 ($a ? $b : $c)->d()->e();
 
-($a ? $b : $c)
-    ->d()
-    ->e()
-    ->f();
+($a ? $b : $c)->d()->e()->f();
 
 ($valid
     ? $helper->responseBody($this->currentUser)
@@ -436,12 +458,8 @@ $var = Foo::keys($items)->filter(function ($x) { return $x > 2; })->map(function
 <?php
 
 one()->two();
-one()
-    ->two()
-    ->three();
-one()
-    ->two->three()
-    ->four->five();
+one()->two()->three();
+one()->two->three()->four->five();
 
 Route::prefix("api")
     ->middleware("api")
@@ -476,9 +494,7 @@ $a->a()->b();
 $a->a()->b();
 $a->a()->b;
 $a->b->a();
-$a->b()
-    ->c()
-    ->d();
+$a->b()->c()->d();
 $a->b->c->d;
 
 // should inline
@@ -545,67 +561,19 @@ $foo->bar(
     "veryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryLong"
 )()()()()();
 
-$brian->hotel
-    ->orders()
-    ->ordered()
-    ->with("smith")
-    ->get();
-$brian::$hotel
-    ->orders()
-    ->ordered()
-    ->with("smith")
-    ->get();
-$brian["hotel"]
-    ->orders()
-    ->ordered()
-    ->with("smith")
-    ->get();
-Foo::$hotel
-    ->orders()
-    ->ordered()
-    ->with("smith")
-    ->get();
-(new Foo())->hotel
-    ->orders()
-    ->ordered()
-    ->with("smith")
-    ->get();
-(clone $a)->hotel
-    ->orders()
-    ->ordered()
-    ->with("smith")
-    ->get();
-
-$var = $brian->hotel
-    ->orders()
-    ->ordered()
-    ->with("smith")
-    ->get();
-$var = $brian::$hotel
-    ->orders()
-    ->ordered()
-    ->with("smith")
-    ->get();
-$var = $brian["hotel"]
-    ->orders()
-    ->ordered()
-    ->with("smith")
-    ->get();
-$var = Foo::$hotel
-    ->orders()
-    ->ordered()
-    ->with("smith")
-    ->get();
-$var = (new Foo())->hotel
-    ->orders()
-    ->ordered()
-    ->with("smith")
-    ->get();
-$var = (clone $a)->hotel
-    ->orders()
-    ->ordered()
-    ->with("smith")
-    ->get();
+$brian->hotel->orders()->ordered()->with("smith")->get();
+$brian::$hotel->orders()->ordered()->with("smith")->get();
+$brian["hotel"]->orders()->ordered()->with("smith")->get();
+Foo::$hotel->orders()->ordered()->with("smith")->get();
+(new Foo())->hotel->orders()->ordered()->with("smith")->get();
+(clone $a)->hotel->orders()->ordered()->with("smith")->get();
+
+$var = $brian->hotel->orders()->ordered()->with("smith")->get();
+$var = $brian::$hotel->orders()->ordered()->with("smith")->get();
+$var = $brian["hotel"]->orders()->ordered()->with("smith")->get();
+$var = Foo::$hotel->orders()->ordered()->with("smith")->get();
+$var = (new Foo())->hotel->orders()->ordered()->with("smith")->get();
+$var = (clone $a)->hotel->orders()->ordered()->with("smith")->get();
 
 $var = Foo::keys($items)
     ->filter(function ($x) {
@@ -615,9 +583,11 @@ $var = Foo::keys($items)
         return $x * 2;
     });
 
-(new static(func_get_args()))->push($this)->each(function ($item) {
-    VarDumper::dump($item);
-});
+(new static(func_get_args()))
+    ->push($this)
+    ->each(function ($item) {
+        VarDumper::dump($item);
+    });
 (new static(func_get_args()))
     ->offset(10)
     ->push($this)
diff --git a/tests/member_chain/break-multiple.php b/tests/member_chain/break-multiple.php
index 642568a..8bc7fbb 100644
--- a/tests/member_chain/break-multiple.php
+++ b/tests/member_chain/break-multiple.php
@@ -2,6 +2,12 @@
 
 $object->foo()->bar()->baz();
 
+$object->foo()->bar()->baz()->foo()->bar()->baz();
+
+$object->foo()->bar()->baz()->foo()->bar()->baz()->foo()->bar()->baz();
+
+$object->foo()->bar()->baz()->foo()->bar()->baz()->foo()->bar()->baz()->foo()->bar()->baz()->foo()->bar()->baz()->foo()->bar()->baz();
+
 foo()->bar()->baz();
 
 foo()->bar->baz();
diff --git a/tests/parens/__snapshots__/jsfmt.spec.mjs.snap b/tests/parens/__snapshots__/jsfmt.spec.mjs.snap
index 72c1922..373b185 100644
--- a/tests/parens/__snapshots__/jsfmt.spec.mjs.snap
+++ b/tests/parens/__snapshots__/jsfmt.spec.mjs.snap
@@ -1568,10 +1568,7 @@ $var = (clone foo())->bar()->foo();
 $var = (clone foo())->bar()->foo();
 $var = (clone foo())->bar()->foo()[0];
 $var = (clone foo())->bar()->foo()[0][1];
-$var = (clone foo())
-    ->bar()
-    ->foo()
-    ->baz();
+$var = (clone foo())->bar()->foo()->baz();
 $var = (clone $foo())->bar;
 $var = (clone $bar->y)->x;
 $var = (clone $foo)[0];
@@ -2562,10 +2559,7 @@ $var = (new foo())->bar()->foo();
 $var = (new foo())->bar()->foo();
 $var = (new foo())->bar()->foo()[0];
 $var = (new foo())->bar()->foo()[0][1];
-$var = (new foo())
-    ->bar()
-    ->foo()
-    ->baz();
+$var = (new foo())->bar()->foo()->baz();
 $var = (new $foo())->bar;
 $var = (new $bar->y())->x;
 $var = (new foo())[0];
@@ -2935,10 +2929,7 @@ $var = ((int) $var) + 1 === 2 ? "1" : "2";
 ($var ? $var : $var)[1];
 ($var ? $var : $var)->d();
 ($var ? $var : $var)->d()->e();
-($var ? $var : $var)
-    ->d()
-    ->e()
-    ->f();
+($var ? $var : $var)->d()->e()->f();
 ($var
     ? $var->responseBody($var->currentUser)
     : $var->responseBody($var->defaultUser)
diff --git a/tests/print/__snapshots__/jsfmt.spec.mjs.snap b/tests/print/__snapshots__/jsfmt.spec.mjs.snap
index 2aa77bc..d93a2e4 100644
--- a/tests/print/__snapshots__/jsfmt.spec.mjs.snap
+++ b/tests/print/__snapshots__/jsfmt.spec.mjs.snap
@@ -299,10 +299,7 @@ print $var->veryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryLongProperty
     ->veryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryLongProperty
     ->veryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryLongProperty;
 
-print $var
-    ->call()
-    ->call()
-    ->call();
+print $var->call()->call()->call();
 print $var
     ->veryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryLongCall()
     ->veryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryLongCall()
diff --git a/tests/retif/__snapshots__/jsfmt.spec.mjs.snap b/tests/retif/__snapshots__/jsfmt.spec.mjs.snap
index b372be8..94fc73a 100644
--- a/tests/retif/__snapshots__/jsfmt.spec.mjs.snap
+++ b/tests/retif/__snapshots__/jsfmt.spec.mjs.snap
@@ -636,15 +636,11 @@ $callback =
 $var =
     $delay instanceof DateTimeInterface
         ? $delay->getTimestamp()
-        : Carbon::now()
-            ->addSeconds($delay)
-            ->getTimestamp();
+        : Carbon::now()->addSeconds($delay)->getTimestamp();
 
 return $delay instanceof DateTimeInterface
     ? $delay->getTimestamp()
-    : Carbon::now()
-        ->addSeconds($delay)
-        ->getTimestamp();
+    : Carbon::now()->addSeconds($delay)->getTimestamp();
 
 $var = true
     ? 'string
diff --git a/tests/return/__snapshots__/jsfmt.spec.mjs.snap b/tests/return/__snapshots__/jsfmt.spec.mjs.snap
index ac27d06..c1c416e 100644
--- a/tests/return/__snapshots__/jsfmt.spec.mjs.snap
+++ b/tests/return/__snapshots__/jsfmt.spec.mjs.snap
@@ -319,30 +319,10 @@ return (function () {})();
 
 return call();
 return $a->b->c->d->e->f;
-return $a
-    ->b()
-    ->c()
-    ->d()
-    ->e()
-    ->f();
-return call()
-    ->b()
-    ->c()
-    ->d()
-    ->e()
-    ->f();
-return $a
-    ::b()
-    ::c()
-    ::d()
-    ::e()
-    ::f();
-return Foo::a()
-    ::b()
-    ::c()
-    ::d()
-    ::e()
-    ::f();
+return $a->b()->c()->d()->e()->f();
+return call()->b()->c()->d()->e()->f();
+return $a::b()::c()::d()::e()::f();
+return Foo::a()::b()::c()::d()::e()::f();
 
 return "
 string
diff --git a/tests/single-quote-api/jsfmt.spec.mjs b/tests/single-quote-api/jsfmt.spec.mjs
new file mode 100644
index 0000000..c95b4ab
--- /dev/null
+++ b/tests/single-quote-api/jsfmt.spec.mjs
@@ -0,0 +1,21 @@
+import prettier from "prettier/standalone";
+import * as prettierPluginPhp from "../../src/index.mjs";
+
+// https://github.com/prettier/plugin-php/issues/2302
+test(`singleQuote option on format api`, async () => {
+  const input = `<?php echo link_to_route("frontend.users.user.show", $users["name"], $users['_id']); ?>`;
+  const result = await prettier.format(input, {
+    plugins: [prettierPluginPhp],
+    singleQuote: true,
+    parser: "php",
+  });
+
+  const expected = `<?php echo link_to_route(
+    'frontend.users.user.show',
+    $users['name'],
+    $users['_id']
+); ?>
+`;
+
+  expect(result).toEqual(expected);
+});