@contractkit/prettier-plugin 0.12.2 → 0.14.0

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.
@@ -5,11 +5,14 @@ import { INDENT } from './indent.js';
5
5
  // ─── Model declaration ───────────────────────────────────────────────────────
6
6
 
7
7
  /**
8
- * Render a `contract` body from its `Name: ...` onward (the `contract ` keyword is
9
- * prepended by the caller). Handles both the type-alias form (`Name: <type>`) and the
10
- * regular field-block form, including modifiers, base chain, and per-field printing.
11
- * Any `model.trailingComments` (comments after the last field, before `}`) are emitted
12
- * as indented `# text` lines so they round-trip.
8
+ * Render a `contract` body from its `Name: ...` onward (the `contract ` keyword and any
9
+ * doc comment written above the declaration are prepended by the caller). Handles both the
10
+ * type-alias form (`Name: <type>`) and the regular field-block form, including modifiers,
11
+ * base chain, and per-field printing. Any `model.trailingComments` (comments after the last
12
+ * field, before `}`) are emitted as indented `# text` lines so they round-trip.
13
+ *
14
+ * `model.description` is emitted here only when `model.descriptionInline` says the author wrote
15
+ * it on the header line; otherwise it belongs above the declaration and the caller emits it.
13
16
  */
14
17
  export function printModelDecl(model: ModelNode, printWidth: number = 80): string {
15
18
  // Type alias form: Name : typeExpression
@@ -18,7 +21,9 @@ export function printModelDecl(model: ModelNode, printWidth: number = 80): strin
18
21
  }
19
22
 
20
23
  // Regular model with fields (possibly inherited)
21
- const commentSuffix = model.description ? ` # ${model.description}` : '';
24
+ // A doc comment written above the declaration is re-emitted there by the caller; only an
25
+ // inline one belongs on the header line.
26
+ const commentSuffix = model.description && model.descriptionInline ? ` # ${model.description}` : '';
22
27
  const modifiers = [
23
28
  model.deprecated ? 'deprecated' : '',
24
29
  model.inputCase || model.outputCase
@@ -45,7 +50,9 @@ export function printModelDecl(model: ModelNode, printWidth: number = 80): strin
45
50
 
46
51
  function printTypeAlias(model: ModelNode, printWidth: number): string {
47
52
  const type = model.type!;
48
- const commentSuffix = model.description ? ` # ${model.description}` : '';
53
+ // A doc comment written above the declaration is re-emitted there by the caller; only an
54
+ // inline one belongs on the header line.
55
+ const commentSuffix = model.description && model.descriptionInline ? ` # ${model.description}` : '';
49
56
  const modifiers = [
50
57
  model.deprecated ? 'deprecated' : '',
51
58
  model.inputCase || model.outputCase
@@ -8,6 +8,8 @@ import type {
8
8
  ContractTypeNode,
9
9
  ObjectMode,
10
10
  PluginValue,
11
+ McpConfigNode,
12
+ OpBodyKey,
11
13
  } from '@contractkit/core';
12
14
  import { SECURITY_NONE } from '@contractkit/core';
13
15
  import { printType, formatDefault } from './print-type.js';
@@ -54,17 +56,20 @@ export function flushBlocks(out: string[], blocks: CommentBlock[], idx: { value:
54
56
  // ─── Route ───────────────────────────────────────────────────────────────────
55
57
 
56
58
  /**
57
- * Render an `operation` route body from its `path: {` onward (the `operation` keyword and
58
- * any modifier are prepended by the caller). Emits the params/security blocks and each HTTP
59
- * operation, interleaving orphan comment `blocks` at their original source positions — `idx`
60
- * tracks how far through `blocks` we've consumed, and `nextRouteStart` bounds the flush to
61
- * comments before the following route. Any `route.trailingComments` (comments after the last
62
- * operation, before `}`) are emitted before the closing brace so they round-trip.
59
+ * Render an `operation` route body from its `path: {` onward (the `operation` keyword, any
60
+ * modifier, and the route's leading comments are prepended by the caller). Emits the
61
+ * params/security blocks and each HTTP operation, interleaving orphan comment `blocks` at their
62
+ * original source positions — `idx` tracks how far through `blocks` we've consumed, and
63
+ * `nextRouteStart` bounds the flush to comments before the following route. Any
64
+ * `route.trailingComments` (comments after the last operation, before `}`) are emitted before
65
+ * the closing brace so they round-trip.
66
+ *
67
+ * Blank lines between operations come from each operation's `blankLineBefore`, so the author's
68
+ * spacing survives rather than being normalized to one rule or the other.
63
69
  */
64
70
  export function printRoute(route: OpRouteNode, blocks: CommentBlock[], idx: { value: number }, nextRouteStart: number): string {
65
71
  const lines: string[] = [];
66
- const commentSuffix = route.description ? ` # ${route.description}` : '';
67
- lines.push(`${route.path}: {${commentSuffix}`);
72
+ lines.push(`${route.path}: {`);
68
73
 
69
74
  if (route.params !== undefined) {
70
75
  lines.push(...printParamsBlock(route.params, I1, route.paramsMode));
@@ -75,6 +80,8 @@ export function printRoute(route: OpRouteNode, blocks: CommentBlock[], idx: { va
75
80
  }
76
81
 
77
82
  for (const op of route.operations) {
83
+ // Reproduce the author's spacing rather than imposing our own.
84
+ if (op.blankLineBefore && lines.length > 1) lines.push('');
78
85
  // Flush comment blocks that appear before this operation (inside the route)
79
86
  flushBlocks(lines, blocks, idx, op.loc.line, I1);
80
87
  lines.push(...printOperation(op));
@@ -119,55 +126,114 @@ function printParamsBlock(source: ParamSource, indent: string, mode?: ObjectMode
119
126
 
120
127
  // ─── HTTP operation ──────────────────────────────────────────────────────────
121
128
 
122
- function printOperation(op: OpOperationNode): string[] {
123
- const lines: string[] = [];
124
- const commentSuffix = op.description ? ` # ${op.description}` : '';
125
- const modPart = op.modifiers?.length ? `(${op.modifiers[0]})` : '';
126
- lines.push(`${I1}${op.method}${modPart}: {${commentSuffix}`);
127
-
128
- if (op.name) lines.push(`${I2}name: ${op.name}`);
129
- if (op.service) lines.push(`${I2}service: ${op.service}`);
130
- if (op.sdk) lines.push(`${I2}sdk: ${op.sdk}`);
131
- if (op.signature) {
132
- const comment = op.signatureDescription ? ` # ${op.signatureDescription}` : '';
133
- if (op.signaturePolicy) {
134
- lines.push(`${I2}signature: {`);
135
- lines.push(`${I3}options: ${formatSignatureValue(op.signature)}${comment}`);
136
- lines.push(`${I3}policy: ${op.signaturePolicy}`);
129
+ /** Order the body keys are emitted in when the node carries no source order (built programmatically). */
130
+ const CANONICAL_KEY_ORDER: OpBodyKey[] = ['name', 'service', 'sdk', 'mcp', 'signature', 'security', 'plugins', 'query', 'headers', 'request', 'responses'];
131
+
132
+ /** Render a single operation-body key. Returns `[]` when the operation doesn't carry that key. */
133
+ function printOperationKey(op: OpOperationNode, key: OpBodyKey): string[] {
134
+ switch (key) {
135
+ case 'name':
136
+ return op.name ? [`${I2}name: ${op.name}`] : [];
137
+ case 'service':
138
+ return op.service ? [`${I2}service: ${op.service}`] : [];
139
+ case 'sdk':
140
+ return op.sdk ? [`${I2}sdk: ${op.sdk}`] : [];
141
+ case 'mcp':
142
+ if (op.mcp === true) return [`${I2}mcp: true`];
143
+ if (op.mcp === false) return [`${I2}mcp: false`];
144
+ return op.mcp ? printMcpBlock(op.mcp) : [];
145
+ case 'signature': {
146
+ if (!op.signature) return [];
147
+ const comment = op.signatureDescription ? ` # ${op.signatureDescription}` : '';
148
+ if (op.signaturePolicy) {
149
+ return [
150
+ `${I2}signature: {`,
151
+ `${I3}options: ${formatSignatureValue(op.signature)}${comment}`,
152
+ `${I3}policy: ${op.signaturePolicy}`,
153
+ `${I2}}`,
154
+ ];
155
+ }
156
+ return [`${I2}signature: ${formatSignatureValue(op.signature)}${comment}`];
157
+ }
158
+ case 'security':
159
+ return op.security !== undefined ? printSecurity(op.security) : [];
160
+ case 'plugins': {
161
+ if (!op.plugins || Object.keys(op.plugins).length === 0) return [];
162
+ const lines = [`${I2}plugins: {`];
163
+ for (const [k, val] of Object.entries(op.plugins)) lines.push(...printPluginEntry(k, val, I3));
137
164
  lines.push(`${I2}}`);
138
- } else {
139
- lines.push(`${I2}signature: ${formatSignatureValue(op.signature)}${comment}`);
165
+ return lines;
140
166
  }
141
- }
142
- if (op.security !== undefined) lines.push(...printSecurity(op.security));
143
- if (op.plugins && Object.keys(op.plugins).length > 0) {
144
- lines.push(`${I2}plugins: {`);
145
- for (const [key, val] of Object.entries(op.plugins)) {
146
- lines.push(...printPluginEntry(key, val, I3));
167
+ case 'query':
168
+ return op.query !== undefined ? printQueryOrHeaders('query', op.query, op.queryMode) : [];
169
+ case 'headers':
170
+ if (op.requestHeadersOptOut) return [`${I2}headers: none`];
171
+ return op.headers !== undefined ? printQueryOrHeaders('headers', op.headers, op.headersMode) : [];
172
+ case 'request': {
173
+ if (!op.request) return [];
174
+ const lines = [`${I2}request: {`];
175
+ for (const body of op.request.bodies) lines.push(...printContentTypeLine(body.contentType, body.bodyType, I3));
176
+ lines.push(`${I2}}`);
177
+ return lines;
147
178
  }
148
- lines.push(`${I2}}`);
179
+ case 'responses':
180
+ return op.responses.length > 0 ? printResponseBlock(op.responses, op.responsesTrailingComments) : [];
149
181
  }
150
- if (op.query !== undefined) lines.push(...printQueryOrHeaders('query', op.query, op.queryMode));
151
- if (op.requestHeadersOptOut) {
152
- lines.push(`${I2}headers: none`);
153
- } else if (op.headers !== undefined) {
154
- lines.push(...printQueryOrHeaders('headers', op.headers, op.headersMode));
155
- }
156
- if (op.request) {
157
- lines.push(`${I2}request: {`);
158
- for (const body of op.request.bodies) {
159
- lines.push(...printContentTypeLine(body.contentType, body.bodyType, I3));
160
- }
161
- lines.push(`${I2}}`);
182
+ }
183
+
184
+ function printOperation(op: OpOperationNode): string[] {
185
+ const lines: string[] = [];
186
+ const modPart = op.modifiers?.length ? `(${op.modifiers[0]})` : '';
187
+
188
+ // A doc comment written above the method line goes back above it; only an inline one is
189
+ // re-emitted as a trailing `#` on the header. Nodes built programmatically carry no placement,
190
+ // and default to inline — the form most `.ck` sources use and one that round-trips as written.
191
+ const inlineDescription = op.descriptionInline ?? true;
192
+ if (op.description && !inlineDescription) {
193
+ for (const line of op.description.split('\n')) lines.push(`${I1}# ${line}`);
162
194
  }
163
- if (op.responses.length > 0) {
164
- lines.push(...printResponseBlock(op.responses));
195
+ const commentSuffix = op.description && inlineDescription ? ` # ${op.description}` : '';
196
+ lines.push(`${I1}${op.method}${modPart}: {${commentSuffix}`);
197
+
198
+ // Emit in source order when the parser recorded it, so formatting never reorders a user's keys.
199
+ // Any key the source order doesn't mention (e.g. added by a later AST pass) follows in canonical order.
200
+ const order = op.keyOrder ?? [];
201
+ const rest = CANONICAL_KEY_ORDER.filter(k => !order.includes(k));
202
+ for (const key of [...order, ...rest]) {
203
+ lines.push(...printOperationKey(op, key));
165
204
  }
166
205
 
167
206
  lines.push(`${I1}}`);
168
207
  return lines;
169
208
  }
170
209
 
210
+ // ─── MCP block ───────────────────────────────────────────────────────────────
211
+
212
+ /**
213
+ * Reconstruct the `hint:` token list from the four annotation booleans, in canonical
214
+ * order. Each set boolean contributes its positive or negative token; unset hints are omitted.
215
+ */
216
+ function mcpHintTokens(mcp: McpConfigNode): string[] {
217
+ const tokens: string[] = [];
218
+ if (mcp.readOnlyHint !== undefined) tokens.push(mcp.readOnlyHint ? 'readOnly' : 'nonReadOnly');
219
+ if (mcp.idempotentHint !== undefined) tokens.push(mcp.idempotentHint ? 'idempotent' : 'nonIdempotent');
220
+ if (mcp.destructiveHint !== undefined) tokens.push(mcp.destructiveHint ? 'destructive' : 'nonDestructive');
221
+ if (mcp.openWorldHint !== undefined) tokens.push(mcp.openWorldHint ? 'openWorld' : 'closedWorld');
222
+ return tokens;
223
+ }
224
+
225
+ /** Print an `mcp: { ... }` settings block. Fields are emitted in canonical order; `hint:` is omitted when no hints are set. */
226
+ function printMcpBlock(mcp: McpConfigNode): string[] {
227
+ const lines: string[] = [`${I2}mcp: {`];
228
+ if (mcp.name !== undefined) lines.push(`${I3}name: "${escapeString(mcp.name)}"`);
229
+ if (mcp.title !== undefined) lines.push(`${I3}title: "${escapeString(mcp.title)}"`);
230
+ if (mcp.description !== undefined) lines.push(`${I3}description: "${escapeString(mcp.description)}"`);
231
+ const tokens = mcpHintTokens(mcp);
232
+ if (tokens.length > 0) lines.push(`${I3}hint: ${tokens.join(', ')}`);
233
+ lines.push(`${I2}}`);
234
+ return lines;
235
+ }
236
+
171
237
  // ─── Plugins block ───────────────────────────────────────────────────────────
172
238
 
173
239
  const IDENT_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
@@ -311,18 +377,33 @@ function printContentTypeLine(contentType: string, bodyType: ContractTypeNode, l
311
377
 
312
378
  // ─── Response block ──────────────────────────────────────────────────────────
313
379
 
314
- function printResponseBlock(responses: OpResponseNode[]): string[] {
380
+ function printResponseBlock(responses: OpResponseNode[], trailingComments?: string[]): string[] {
315
381
  const lines: string[] = [`${I2}response: {`];
316
382
 
317
383
  for (const resp of responses) {
318
- const hasBody = resp.contentType && resp.bodyType;
384
+ for (const comment of resp.leadingComments ?? []) lines.push(`${I3}# ${comment}`);
385
+ const bodies = resp.bodies;
319
386
  const hasHeaders = resp.headers && resp.headers.length > 0;
320
387
  const optOut = resp.headersOptOut;
321
- if (hasBody || hasHeaders || optOut) {
322
- lines.push(`${I3}${resp.statusCode}: {`);
323
- if (hasBody) {
324
- lines.push(...printContentTypeLine(resp.contentType!, resp.bodyType!, I4));
388
+ // `404(documented):` the modifier changes what codegen does, so it has to survive.
389
+ const code = resp.emit ? `${resp.statusCode}(${resp.emit})` : `${resp.statusCode}`;
390
+ const inlinable = resp.inline && bodies.length > 0 && !hasHeaders && !optOut && bodies.every(b => b.bodyType.kind !== 'inlineObject');
391
+ if (inlinable) {
392
+ // Written on one line in the source, so keep it there: `200: { application/json: Pet }`.
393
+ // Several mimes on that line stay on it too, space-separated as the grammar has them.
394
+ const inner = bodies.map(b => `${b.contentType}: ${printType(b.bodyType)}`).join(' ');
395
+ lines.push(`${I3}${code}: { ${inner} }`);
396
+ } else if (bodies.length === 0 && !hasHeaders && !optOut && resp.hasBlock) {
397
+ // An empty block means "emitted, no body" — collapsing it to `304:` would change
398
+ // the generated router, so it is not a formatting detail.
399
+ lines.push(`${I3}${code}: {}`);
400
+ } else if (bodies.length > 0 || hasHeaders || optOut || (resp.trailingComments?.length ?? 0) > 0) {
401
+ lines.push(`${I3}${code}: {`);
402
+ for (const body of bodies) {
403
+ for (const comment of body.leadingComments ?? []) lines.push(`${I4}# ${comment}`);
404
+ lines.push(...printContentTypeLine(body.contentType, body.bodyType, I4));
325
405
  }
406
+ for (const comment of resp.headersLeadingComments ?? []) lines.push(`${I4}# ${comment}`);
326
407
  if (optOut) {
327
408
  lines.push(`${I4}headers: none`);
328
409
  } else if (hasHeaders) {
@@ -334,12 +415,14 @@ function printResponseBlock(responses: OpResponseNode[]): string[] {
334
415
  }
335
416
  lines.push(`${I4}}`);
336
417
  }
418
+ for (const comment of resp.trailingComments ?? []) lines.push(`${I4}# ${comment}`);
337
419
  lines.push(`${I3}}`);
338
420
  } else {
339
- lines.push(`${I3}${resp.statusCode}:`);
421
+ lines.push(`${I3}${code}:`);
340
422
  }
341
423
  }
342
424
 
425
+ for (const comment of trailingComments ?? []) lines.push(`${I3}# ${comment}`);
343
426
  lines.push(`${I2}}`);
344
427
  return lines;
345
428
  }
@@ -60,7 +60,9 @@ describe('printCk — route modifiers', () => {
60
60
 
61
61
  it('preserves description alongside modifier', () => {
62
62
  const ast = makeRoot([makeRoute('/users', [makeOp('get')], { modifiers: ['deprecated'], description: 'Old user list' })]);
63
- expect(printCk(ast)).toContain('operation(deprecated) /users: { # Old user list');
63
+ // A route description is emitted above the declaration, not inline: a `#` after the `{`
64
+ // would re-parse as the first operation's description instead.
65
+ expect(printCk(ast)).toContain('# Old user list\noperation(deprecated) /users: {');
64
66
  });
65
67
  });
66
68
 
@@ -451,7 +453,7 @@ describe('printCk — request blocks', () => {
451
453
  makeRoute('/users', [
452
454
  makeOp('post', {
453
455
  request: { bodies: [{ contentType: 'application/vnd.api+json', bodyType: { kind: 'ref', name: 'CreateUser' } }] },
454
- responses: [{ statusCode: 201, contentType: 'application/vnd.api+json', bodyType: { kind: 'ref', name: 'User' } }],
456
+ responses: [{ statusCode: 201, hasBlock: true, bodies: [{ contentType: 'application/vnd.api+json', bodyType: { kind: 'ref', name: 'User' } }] }],
455
457
  }),
456
458
  ]),
457
459
  ]);
@@ -491,8 +493,8 @@ describe('printCk — response headers', () => {
491
493
  responses: [
492
494
  {
493
495
  statusCode: 200,
494
- contentType: 'application/json',
495
- bodyType: { kind: 'ref', name: 'Transfer' },
496
+ hasBlock: true,
497
+ bodies: [{ contentType: 'application/json', bodyType: { kind: 'ref', name: 'Transfer' } }],
496
498
  headers: [
497
499
  { name: 'preference-applied', optional: true, type: { kind: 'scalar', name: 'string' } },
498
500
  { name: 'etag', optional: false, type: { kind: 'scalar', name: 'string' }, description: 'cache validator' },
@@ -517,6 +519,8 @@ describe('printCk — response headers', () => {
517
519
  responses: [
518
520
  {
519
521
  statusCode: 204,
522
+ hasBlock: true,
523
+ bodies: [],
520
524
  headers: [{ name: 'x-deleted-at', optional: false, type: { kind: 'scalar', name: 'string' } }],
521
525
  },
522
526
  ],
@@ -531,6 +535,82 @@ describe('printCk — response headers', () => {
531
535
  });
532
536
  });
533
537
 
538
+ describe('printCk — mcp field', () => {
539
+ function roundTrip(source: string): string {
540
+ const diag = new DiagnosticCollector();
541
+ const ast = parseCk(source, 'test.ck', diag);
542
+ expect(diag.hasErrors()).toBe(false);
543
+ return printCk(ast);
544
+ }
545
+
546
+ it('prints mcp: true', () => {
547
+ const ast = makeRoot([makeRoute('/users', [makeOp('get', { mcp: true })])]);
548
+ expect(printCk(ast)).toContain(' mcp: true');
549
+ });
550
+
551
+ it('prints explicit mcp: false', () => {
552
+ const ast = makeRoot([makeRoute('/users', [makeOp('get', { mcp: false })])]);
553
+ expect(printCk(ast)).toContain(' mcp: false');
554
+ });
555
+
556
+ it('omits mcp when undefined', () => {
557
+ const ast = makeRoot([makeRoute('/users', [makeOp('get')])]);
558
+ expect(printCk(ast)).not.toContain('mcp:');
559
+ });
560
+
561
+ it('prints mcp block with text fields and reconstructed hint list', () => {
562
+ const ast = makeRoot([
563
+ makeRoute('/routes', [
564
+ makeOp('post', {
565
+ mcp: {
566
+ name: 'searchRoutes',
567
+ title: 'Search routes',
568
+ description: 'Full-text search.',
569
+ readOnlyHint: true,
570
+ idempotentHint: true,
571
+ destructiveHint: false,
572
+ loc: makeLoc(),
573
+ },
574
+ }),
575
+ ]),
576
+ ]);
577
+ const out = printCk(ast);
578
+ expect(out).toContain(' mcp: {');
579
+ expect(out).toContain(' name: "searchRoutes"');
580
+ expect(out).toContain(' title: "Search routes"');
581
+ expect(out).toContain(' description: "Full-text search."');
582
+ expect(out).toContain(' hint: readOnly, idempotent, nonDestructive');
583
+ });
584
+
585
+ it('round-trips mcp: true / false through parse', () => {
586
+ expect(roundTrip('operation /a: {\n get: {\n mcp: true\n }\n}\n')).toContain(' mcp: true');
587
+ expect(roundTrip('operation /b: {\n get: {\n mcp: false\n }\n}\n')).toContain(' mcp: false');
588
+ });
589
+
590
+ it('round-trips an mcp settings block through parse', () => {
591
+ const source = `\
592
+ operation /routes: {
593
+ post: {
594
+ mcp: {
595
+ name: "searchRoutes"
596
+ title: "Search routes"
597
+ description: "Full-text search across routes."
598
+ hint: readOnly, idempotent, nonDestructive, closedWorld
599
+ }
600
+ }
601
+ }
602
+ `;
603
+ const out = roundTrip(source);
604
+ expect(out).toContain(' mcp: {');
605
+ expect(out).toContain(' name: "searchRoutes"');
606
+ expect(out).toContain(' hint: readOnly, idempotent, nonDestructive, closedWorld');
607
+ // Idempotent: printing the round-tripped output again is stable.
608
+ const diag = new DiagnosticCollector();
609
+ expect(printCk(parseCk(out, 'test.ck', diag))).toBe(out);
610
+ expect(diag.hasErrors()).toBe(false);
611
+ });
612
+ });
613
+
534
614
  describe('printCk — options-level header globals (round-trip)', () => {
535
615
  function roundTrip(source: string): string {
536
616
  const diag = new DiagnosticCollector();