prettier-plugin-hug-call-arguments 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # prettier-plugin-hug-call-arguments
2
2
 
3
+ [![license][license-src]][license-href]
4
+ [![npm version][npm-version-src]][npm-version-href]
5
+ [![npm downloads][npm-downloads-src]][npm-downloads-href]
6
+ [![CI][ci-src]][ci-href]
7
+
8
+ [license-src]: https://img.shields.io/badge/license-MIT-brightgreen?&style=flat-square
9
+ [license-href]: https://github.com/Generalsimus/prettier-plugin-hug-call-arguments/blob/main/LICENSE
10
+ [npm-version-src]: https://img.shields.io/npm/v/prettier-plugin-hug-call-arguments?&style=flat-square&logo=npm&logoColor=white&color=CB3837
11
+ [npm-version-href]: https://www.npmjs.com/package/prettier-plugin-hug-call-arguments
12
+ [npm-downloads-src]: https://img.shields.io/npm/dt/prettier-plugin-hug-call-arguments?&style=flat-square&logo=npm&logoColor=white&color=CB3837
13
+ [npm-downloads-href]: https://www.npmjs.com/package/prettier-plugin-hug-call-arguments
14
+ [ci-src]: https://img.shields.io/github/actions/workflow/status/Generalsimus/prettier-plugin-hug-call-arguments/test.yml?branch=main&&style=flat-square
15
+ [ci-href]: https://github.com/Generalsimus/prettier-plugin-hug-call-arguments/actions/workflows/test.yml
16
+
3
17
  Fixes [prettier/prettier#11080](https://github.com/prettier/prettier/issues/11080).
4
18
 
5
19
  Prettier hugs the last argument of a call when it's a function/object/array
@@ -41,10 +55,43 @@ app.delete("/campgrounds/:id", catchAsync(async (req, res) => {
41
55
  }));
42
56
  ```
43
57
 
58
+ It also flattens method chains instead of breaking every `.method()` onto its
59
+ own line — a common look with query builders:
60
+
61
+ ```js
62
+ // input
63
+ db.updateTable('User').set({
64
+ isDeleted: true,
65
+ profilePicture: null,
66
+ username: null,
67
+ }).where('id', '=', user.id).execute();
68
+
69
+ // vanilla Prettier output
70
+ db
71
+ .updateTable("User")
72
+ .set({
73
+ isDeleted: true,
74
+ profilePicture: null,
75
+ username: null,
76
+ })
77
+ .where("id", "=", user.id)
78
+ .execute();
79
+
80
+ // with this plugin
81
+ db.updateTable("User").set({
82
+ isDeleted: true,
83
+ profilePicture: null,
84
+ username: null,
85
+ }).where("id", "=", user.id).execute();
86
+ ```
87
+
44
88
  ## How it works
45
89
 
46
90
  This plugin wraps Prettier's built-in `estree` printer and only overrides the
47
- `print` step for `CallExpression` nodes that match a narrow, specific shape:
91
+ `print` step for `CallExpression` nodes that match one of two narrow, specific
92
+ shapes.
93
+
94
+ **Hugging a call-wrapped callback:**
48
95
 
49
96
  - the callee is a plain identifier or non-computed member chain (`foo`,
50
97
  `a.b.c`) — chained calls (`a().b()`) are left untouched
@@ -54,8 +101,23 @@ This plugin wraps Prettier's built-in `estree` printer and only overrides the
54
101
  - there's no blank line between arguments, no comments on the relevant
55
102
  nodes, and no TS type arguments
56
103
 
57
- Every other node, and every `CallExpression` that doesn't match that shape,
58
- is delegated unchanged to Prettier's original printer.
104
+ **Flattening a method chain:**
105
+
106
+ - every link is a plain, non-optional `.name(...)` or `[expr](...)` call down
107
+ to a simple base (an identifier, `this`, or a non-computed member chain)
108
+ - each link's own arguments are printed exactly as Prettier would print them
109
+ standalone — a single object/array/function argument hugs the parens, a
110
+ multi-argument list breaks onto its own lines only if it doesn't fit — so
111
+ any number of links can break independently, correctly, with no special
112
+ casing needed
113
+ - the whole flattened candidate is still rendered and measured against
114
+ `printWidth` as a final check (catching the one thing per-link breaking
115
+ can't: many short, individually-fitting links whose *combined* length
116
+ still overflows); if any line would overflow, it falls back to Prettier's
117
+ normal chain layout
118
+
119
+ Every other node, and every `CallExpression` that doesn't match one of these
120
+ shapes, is delegated unchanged to Prettier's original printer.
59
121
 
60
122
  ## Usage
61
123
 
package/dist/index.d.ts CHANGED
@@ -1,5 +1,14 @@
1
1
  import type { Plugin } from "prettier";
2
- /** Minimal structural view of the ESTree/Babel nodes this plugin touches. */
2
+ /**
3
+ * Minimal structural view of the ESTree/Babel nodes this plugin touches.
4
+ *
5
+ * `callee` and `object` are declared as required so `AstPath#call` can chain
6
+ * through them (`path.call(fn, "callee", "object")`) without the type
7
+ * collapsing to `never` — see https://github.com/microsoft/TypeScript's
8
+ * handling of `keyof (T | undefined)`. Every real read of these fields is
9
+ * still guarded by a runtime truthiness check, since plenty of node types
10
+ * don't actually have them.
11
+ */
3
12
  interface ESNode {
4
13
  type: string;
5
14
  start?: number;
@@ -9,8 +18,10 @@ interface ESNode {
9
18
  computed?: boolean;
10
19
  typeArguments?: unknown;
11
20
  typeParameters?: unknown;
12
- callee?: ESNode;
13
- object?: ESNode;
21
+ callee: ESNode;
22
+ object: ESNode;
23
+ property: ESNode;
24
+ name?: string;
14
25
  arguments?: ESNode[];
15
26
  body?: ESNode;
16
27
  }
package/dist/index.js CHANGED
@@ -59,7 +59,7 @@ const doc_1 = require("prettier/doc");
59
59
  // what `require`/interop resolves to, and its printer object is what we
60
60
  // spread and delegate to for every node we don't special-case.
61
61
  const estree = __importStar(require("prettier/plugins/estree"));
62
- const { group, conditionalGroup, indent, hardline, join, breakParent } = doc_1.builders;
62
+ const { group, conditionalGroup, indent, hardline, line, softline, join, ifBreak, breakParent, } = doc_1.builders;
63
63
  const { willBreak } = doc_1.utils;
64
64
  // `estree.printers.estree` is typed as `Printer<any>` by Prettier, which is
65
65
  // assignable to `Printer<ESNode>` on its own — no cast needed.
@@ -122,6 +122,23 @@ function isSimpleCallee(callee) {
122
122
  function hasBlankLineBetween(originalText, start, end) {
123
123
  return /\n[^\S\n]*\n/.test(originalText.slice(start, end));
124
124
  }
125
+ /**
126
+ * `willBreak` only sees *forced* breaks (a hardline, or a group Prettier
127
+ * already marked `shouldBreak: true` — e.g. an object literal whose first
128
+ * property was already on its own line in the source, via `objectWrap:
129
+ * "preserve"`). A conditional break — an object written on one line that's
130
+ * simply too long to fit — has neither, so `willBreak` misses it. Render the
131
+ * doc standalone and check whether it needed more than one line instead.
132
+ * This only decides *how many* links look like they'll break for our own
133
+ * "exactly one" heuristic; the actual safety net against overflow is
134
+ * `fitsWhenFlattened`, which measures the real assembled doc.
135
+ */
136
+ function wouldBreakStandalone(doc, options) {
137
+ if (willBreak(doc)) {
138
+ return true;
139
+ }
140
+ return doc_1.printer.printDocToString(doc, options).formatted.includes("\n");
141
+ }
125
142
  function tryHugWrappedCallback(path, options, print) {
126
143
  const { node } = path;
127
144
  if (node.type !== "CallExpression" || node.optional) {
@@ -159,8 +176,8 @@ function tryHugWrappedCallback(path, options, print) {
159
176
  // If the earlier args don't fit on one line, or the wrapped callback
160
177
  // wouldn't break anyway, Prettier's default output is already fine.
161
178
  if (lastDoc === undefined ||
162
- headDocs.some((doc) => willBreak(doc)) ||
163
- !willBreak(lastDoc)) {
179
+ headDocs.some((doc) => wouldBreakStandalone(doc, options)) ||
180
+ !wouldBreakStandalone(lastDoc, options)) {
164
181
  return undefined;
165
182
  }
166
183
  const headWithCommas = headDocs.flatMap((doc) => [doc, ", "]);
@@ -181,12 +198,157 @@ function tryHugWrappedCallback(path, options, print) {
181
198
  ]),
182
199
  ];
183
200
  }
201
+ const MAX_CHAIN_LINKS = 6;
202
+ /**
203
+ * Builds the `(...)` doc for one link's argument list the same way a plain,
204
+ * standalone call would print it: a single argument is hugged directly
205
+ * against the parens (so an object/array still expands in place, e.g.
206
+ * `.set({ ... })`, not indented as its own nested line); more than one
207
+ * argument gets Prettier's normal "fits on one line, else one per line"
208
+ * group. Either way the break decision is left to Prettier's regular,
209
+ * width-aware group mechanics — not something this plugin pre-computes.
210
+ */
211
+ function buildArgsDoc(argsDocs, trailingComma) {
212
+ if (argsDocs.length === 0) {
213
+ return "()";
214
+ }
215
+ const [only] = argsDocs;
216
+ if (argsDocs.length === 1 && only !== undefined) {
217
+ return ["(", only, ")"];
218
+ }
219
+ return group([
220
+ "(",
221
+ indent([softline, join([",", line], argsDocs)]),
222
+ ifBreak(trailingComma),
223
+ softline,
224
+ ")",
225
+ ]);
226
+ }
227
+ /**
228
+ * Walks down `node.callee.object` as long as it's a plain, non-optional
229
+ * `.name(...)` or `[expr](...)` call, printing each link's property access
230
+ * and arguments along the way. Returns `undefined` the moment anything
231
+ * doesn't match that shape (optional chaining, generics, comments, spread
232
+ * callee, ...), so the caller can safely fall back to Prettier's own chain
233
+ * printer.
234
+ */
235
+ function collectChainLinks(path, print, options, depth) {
236
+ if (depth > MAX_CHAIN_LINKS) {
237
+ return undefined;
238
+ }
239
+ const { node } = path;
240
+ if (node.type !== "CallExpression" ||
241
+ node.optional ||
242
+ node.typeArguments ||
243
+ node.typeParameters ||
244
+ hasComment(node)) {
245
+ return undefined;
246
+ }
247
+ const { callee } = node;
248
+ if (!callee ||
249
+ callee.type !== "MemberExpression" ||
250
+ callee.optional ||
251
+ hasComment(callee)) {
252
+ return undefined;
253
+ }
254
+ const { property } = callee;
255
+ if (!property || hasComment(property)) {
256
+ return undefined;
257
+ }
258
+ let accessDoc;
259
+ if (callee.computed) {
260
+ accessDoc = ["[", path.call(print, "callee", "property"), "]"];
261
+ }
262
+ else if (property.type === "Identifier") {
263
+ accessDoc = [".", property.name ?? ""];
264
+ }
265
+ else {
266
+ return undefined;
267
+ }
268
+ const trailingComma = options.trailingComma === "all" ? "," : "";
269
+ const link = {
270
+ accessDoc,
271
+ argsDoc: buildArgsDoc(path.map(print, "arguments"), trailingComma),
272
+ };
273
+ const { object } = callee;
274
+ if (!object || hasComment(object)) {
275
+ return undefined;
276
+ }
277
+ if (object.type === "CallExpression") {
278
+ const inner = path.call((innerPath) => collectChainLinks(innerPath, print, options, depth + 1), "callee", "object");
279
+ return inner === undefined
280
+ ? undefined
281
+ : { links: [...inner.links, link], baseDoc: inner.baseDoc };
282
+ }
283
+ if (!isSimpleCallee(object)) {
284
+ return undefined;
285
+ }
286
+ const baseDoc = path.call(print, "callee", "object");
287
+ return { links: [link], baseDoc };
288
+ }
289
+ /**
290
+ * Each link's own argument list already breaks correctly on its own — via
291
+ * Prettier's normal, width-aware group mechanics — because there's no group
292
+ * wrapping the whole chain forcing anything. The one thing that mechanism
293
+ * can't see is a chain of many short, individually-non-breaking links whose
294
+ * *combined* length still overflows `printWidth` (nothing internal to break
295
+ * on). So render the assembled candidate standalone and check every line
296
+ * before committing to it, falling back to Prettier's own chain layout if
297
+ * it doesn't fit.
298
+ */
299
+ function fitsWhenFlattened(doc, options) {
300
+ const { formatted } = doc_1.printer.printDocToString(doc, options);
301
+ return formatted
302
+ .split("\n")
303
+ .every((line) => line.length <= options.printWidth);
304
+ }
305
+ /**
306
+ * Handles method chains like `db.updateTable("User").set({ ... }).where(
307
+ * "id", "=", user.id).execute()`, where Prettier's default chain printer
308
+ * breaks every `.method()` onto its own line as soon as any argument
309
+ * doesn't fit. This instead keeps the whole chain flat — `.foo(...).bar(
310
+ * ...).baz(...)` — and lets each link's own arguments break independently
311
+ * if they need to, falling back to Prettier's normal chain layout only if
312
+ * the flattened result doesn't fit the shape this plugin handles, or would
313
+ * overflow `printWidth`.
314
+ */
315
+ function tryHugChainArgument(path, options, print) {
316
+ const { node } = path;
317
+ if (node.type !== "CallExpression" || node.optional) {
318
+ return undefined;
319
+ }
320
+ // Only handle the outermost call of a chain — if this call is itself
321
+ // being member-accessed further, let that outer node make the decision.
322
+ if (path.parent?.type === "MemberExpression") {
323
+ return undefined;
324
+ }
325
+ if (!node.callee ||
326
+ node.callee.type !== "MemberExpression" ||
327
+ !node.callee.object ||
328
+ node.callee.object.type !== "CallExpression") {
329
+ return undefined;
330
+ }
331
+ const collected = collectChainLinks(path, print, options, 0);
332
+ if (!collected || collected.links.length < 2) {
333
+ return undefined;
334
+ }
335
+ const { links, baseDoc } = collected;
336
+ const flatDoc = [
337
+ baseDoc,
338
+ ...links.map((link) => [link.accessDoc, link.argsDoc]),
339
+ ];
340
+ if (!fitsWhenFlattened(flatDoc, options)) {
341
+ return undefined;
342
+ }
343
+ return willBreak(flatDoc) ? [breakParent, flatDoc] : flatDoc;
344
+ }
184
345
  const plugin = {
185
346
  printers: {
186
347
  estree: {
187
348
  ...estreePrinter,
188
349
  print(path, options, print, args) {
189
- const hugged = tryHugWrappedCallback(path, options, print);
350
+ const hugged = tryHugWrappedCallback(path, options, print) ??
351
+ tryHugChainArgument(path, options, print);
190
352
  return hugged === undefined
191
353
  ? estreePrinter.print(path, options, print, args)
192
354
  : hugged;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prettier-plugin-hug-call-arguments",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Prettier plugin: hug the last call argument even when it's wrapped in another call, e.g. app.delete(\"/x\", catchAsync(async (req, res) => { ... })). Fixes https://github.com/prettier/prettier/issues/11080",
5
5
  "author": "soso tsertsvadze",
6
6
  "license": "MIT",