@contractkit/prettier-plugin 0.12.2 → 0.13.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.
- package/.turbo/turbo-build$colon$ci.log +5 -5
- package/.turbo/turbo-test$colon$ci.log +13 -10
- package/CHANGELOG.md +17 -0
- package/README.md +12 -3
- package/dist/index.js +148 -45
- package/dist/index.js.map +1 -1
- package/dist/print-ck.d.ts +9 -3
- package/dist/print-ck.d.ts.map +1 -1
- package/dist/print-contract.d.ts +8 -5
- package/dist/print-contract.d.ts.map +1 -1
- package/dist/print-operation.d.ts +10 -6
- package/dist/print-operation.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/print-ck.ts +42 -6
- package/src/print-contract.ts +14 -7
- package/src/print-operation.ts +116 -47
- package/tests/print-ck.test.ts +79 -1
- package/tests/round-trip.test.ts +268 -0
package/src/print-operation.ts
CHANGED
|
@@ -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
|
|
58
|
-
*
|
|
59
|
-
* operation, interleaving orphan comment `blocks` at their
|
|
60
|
-
* tracks how far through `blocks` we've consumed, and
|
|
61
|
-
* comments before the following route. Any
|
|
62
|
-
* operation, before `}`) are emitted before
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
139
|
-
lines.push(`${I2}signature: ${formatSignatureValue(op.signature)}${comment}`);
|
|
165
|
+
return lines;
|
|
140
166
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
179
|
+
case 'responses':
|
|
180
|
+
return op.responses.length > 0 ? printResponseBlock(op.responses) : [];
|
|
149
181
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
lines.push(`${
|
|
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
|
-
|
|
164
|
-
|
|
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_$]*$/;
|
|
@@ -318,7 +384,10 @@ function printResponseBlock(responses: OpResponseNode[]): string[] {
|
|
|
318
384
|
const hasBody = resp.contentType && resp.bodyType;
|
|
319
385
|
const hasHeaders = resp.headers && resp.headers.length > 0;
|
|
320
386
|
const optOut = resp.headersOptOut;
|
|
321
|
-
if (hasBody
|
|
387
|
+
if (resp.inline && hasBody && !hasHeaders && !optOut && resp.bodyType!.kind !== 'inlineObject') {
|
|
388
|
+
// Written on one line in the source, so keep it there: `200: { application/json: Pet }`.
|
|
389
|
+
lines.push(`${I3}${resp.statusCode}: { ${resp.contentType}: ${printType(resp.bodyType!)} }`);
|
|
390
|
+
} else if (hasBody || hasHeaders || optOut) {
|
|
322
391
|
lines.push(`${I3}${resp.statusCode}: {`);
|
|
323
392
|
if (hasBody) {
|
|
324
393
|
lines.push(...printContentTypeLine(resp.contentType!, resp.bodyType!, I4));
|
package/tests/print-ck.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
|
@@ -531,6 +533,82 @@ describe('printCk — response headers', () => {
|
|
|
531
533
|
});
|
|
532
534
|
});
|
|
533
535
|
|
|
536
|
+
describe('printCk — mcp field', () => {
|
|
537
|
+
function roundTrip(source: string): string {
|
|
538
|
+
const diag = new DiagnosticCollector();
|
|
539
|
+
const ast = parseCk(source, 'test.ck', diag);
|
|
540
|
+
expect(diag.hasErrors()).toBe(false);
|
|
541
|
+
return printCk(ast);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
it('prints mcp: true', () => {
|
|
545
|
+
const ast = makeRoot([makeRoute('/users', [makeOp('get', { mcp: true })])]);
|
|
546
|
+
expect(printCk(ast)).toContain(' mcp: true');
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it('prints explicit mcp: false', () => {
|
|
550
|
+
const ast = makeRoot([makeRoute('/users', [makeOp('get', { mcp: false })])]);
|
|
551
|
+
expect(printCk(ast)).toContain(' mcp: false');
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('omits mcp when undefined', () => {
|
|
555
|
+
const ast = makeRoot([makeRoute('/users', [makeOp('get')])]);
|
|
556
|
+
expect(printCk(ast)).not.toContain('mcp:');
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it('prints mcp block with text fields and reconstructed hint list', () => {
|
|
560
|
+
const ast = makeRoot([
|
|
561
|
+
makeRoute('/routes', [
|
|
562
|
+
makeOp('post', {
|
|
563
|
+
mcp: {
|
|
564
|
+
name: 'searchRoutes',
|
|
565
|
+
title: 'Search routes',
|
|
566
|
+
description: 'Full-text search.',
|
|
567
|
+
readOnlyHint: true,
|
|
568
|
+
idempotentHint: true,
|
|
569
|
+
destructiveHint: false,
|
|
570
|
+
loc: makeLoc(),
|
|
571
|
+
},
|
|
572
|
+
}),
|
|
573
|
+
]),
|
|
574
|
+
]);
|
|
575
|
+
const out = printCk(ast);
|
|
576
|
+
expect(out).toContain(' mcp: {');
|
|
577
|
+
expect(out).toContain(' name: "searchRoutes"');
|
|
578
|
+
expect(out).toContain(' title: "Search routes"');
|
|
579
|
+
expect(out).toContain(' description: "Full-text search."');
|
|
580
|
+
expect(out).toContain(' hint: readOnly, idempotent, nonDestructive');
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it('round-trips mcp: true / false through parse', () => {
|
|
584
|
+
expect(roundTrip('operation /a: {\n get: {\n mcp: true\n }\n}\n')).toContain(' mcp: true');
|
|
585
|
+
expect(roundTrip('operation /b: {\n get: {\n mcp: false\n }\n}\n')).toContain(' mcp: false');
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it('round-trips an mcp settings block through parse', () => {
|
|
589
|
+
const source = `\
|
|
590
|
+
operation /routes: {
|
|
591
|
+
post: {
|
|
592
|
+
mcp: {
|
|
593
|
+
name: "searchRoutes"
|
|
594
|
+
title: "Search routes"
|
|
595
|
+
description: "Full-text search across routes."
|
|
596
|
+
hint: readOnly, idempotent, nonDestructive, closedWorld
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
`;
|
|
601
|
+
const out = roundTrip(source);
|
|
602
|
+
expect(out).toContain(' mcp: {');
|
|
603
|
+
expect(out).toContain(' name: "searchRoutes"');
|
|
604
|
+
expect(out).toContain(' hint: readOnly, idempotent, nonDestructive, closedWorld');
|
|
605
|
+
// Idempotent: printing the round-tripped output again is stable.
|
|
606
|
+
const diag = new DiagnosticCollector();
|
|
607
|
+
expect(printCk(parseCk(out, 'test.ck', diag))).toBe(out);
|
|
608
|
+
expect(diag.hasErrors()).toBe(false);
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
|
|
534
612
|
describe('printCk — options-level header globals (round-trip)', () => {
|
|
535
613
|
function roundTrip(source: string): string {
|
|
536
614
|
const diag = new DiagnosticCollector();
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { printCk } from '../src/print-ck.js';
|
|
5
|
+
import { parseCk, DiagnosticCollector } from '@contractkit/core';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Formatting a `.ck` file must not change it.
|
|
9
|
+
*
|
|
10
|
+
* The prettier plugin used to fold standalone `#` comment blocks into trailing comments on the
|
|
11
|
+
* following declaration, reorder operation body keys, drop blank lines between operations, and
|
|
12
|
+
* expand single-line response bodies — all of which silently rewrote a user's file on
|
|
13
|
+
* `pnpm format`. These tests pin the guarantee: parse → print is the identity on well-formed
|
|
14
|
+
* source, and printing is idempotent.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
function format(source: string, file = 'test.ck'): string {
|
|
18
|
+
const diag = new DiagnosticCollector();
|
|
19
|
+
const ast = parseCk(source, file, diag);
|
|
20
|
+
expect(diag.hasErrors()).toBe(false);
|
|
21
|
+
return printCk(ast);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ─── Repository contracts ────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
const CONTRACTS_DIR = new URL('../../../contracts', import.meta.url).pathname;
|
|
27
|
+
const ckFiles = readdirSync(CONTRACTS_DIR).filter(f => f.endsWith('.ck'));
|
|
28
|
+
|
|
29
|
+
describe('round-trip — repository .ck files', () => {
|
|
30
|
+
it('finds .ck files to check', () => {
|
|
31
|
+
expect(ckFiles.length).toBeGreaterThan(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
for (const name of ckFiles) {
|
|
35
|
+
it(`formats ${name} to itself`, () => {
|
|
36
|
+
const source = readFileSync(join(CONTRACTS_DIR, name), 'utf8');
|
|
37
|
+
expect(format(source, name)).toBe(source);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ─── Constructs that previously round-tripped lossily ────────────────────────
|
|
43
|
+
|
|
44
|
+
describe('round-trip — comment placement', () => {
|
|
45
|
+
it('keeps a standalone comment block above the declaration it precedes', () => {
|
|
46
|
+
const source = `# ─── Pet endpoints ───
|
|
47
|
+
|
|
48
|
+
operation /pet: {
|
|
49
|
+
get: {
|
|
50
|
+
response: {
|
|
51
|
+
200:
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
`;
|
|
56
|
+
expect(format(source)).toBe(source);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('keeps a contract doc comment on its own line', () => {
|
|
60
|
+
const source = `# A pet for sale
|
|
61
|
+
contract Pet: {
|
|
62
|
+
id: int
|
|
63
|
+
}
|
|
64
|
+
`;
|
|
65
|
+
expect(format(source)).toBe(source);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('keeps a contract doc comment inline when written inline', () => {
|
|
69
|
+
const source = `contract Pet: { # A pet for sale
|
|
70
|
+
id: int
|
|
71
|
+
}
|
|
72
|
+
`;
|
|
73
|
+
expect(format(source)).toBe(source);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('distinguishes a divider from the doc comment below it', () => {
|
|
77
|
+
const source = `# ─── Models ───
|
|
78
|
+
|
|
79
|
+
# A pet for sale
|
|
80
|
+
contract Pet: {
|
|
81
|
+
id: int
|
|
82
|
+
}
|
|
83
|
+
`;
|
|
84
|
+
expect(format(source)).toBe(source);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('keeps an operation doc comment on its own line', () => {
|
|
88
|
+
const source = `operation /pet: {
|
|
89
|
+
# update an existing pet
|
|
90
|
+
put: {
|
|
91
|
+
response: {
|
|
92
|
+
200:
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
`;
|
|
97
|
+
expect(format(source)).toBe(source);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('keeps an operation doc comment inline when written inline', () => {
|
|
101
|
+
const source = `operation /pet: {
|
|
102
|
+
put: { # update an existing pet
|
|
103
|
+
response: {
|
|
104
|
+
200:
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
`;
|
|
109
|
+
expect(format(source)).toBe(source);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('round-trip — comments in the options block', () => {
|
|
114
|
+
it('keeps a comment above a sub-block', () => {
|
|
115
|
+
const source = `options {
|
|
116
|
+
# where these come from
|
|
117
|
+
keys: {
|
|
118
|
+
area: ledger
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
`;
|
|
122
|
+
expect(format(source)).toBe(source);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('keeps a comment run above the sub-block it precedes', () => {
|
|
126
|
+
const source = `options {
|
|
127
|
+
keys: {
|
|
128
|
+
area: ledger
|
|
129
|
+
}
|
|
130
|
+
# service wiring
|
|
131
|
+
# one per module
|
|
132
|
+
services: {
|
|
133
|
+
UserService: "#src/user.js"
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
`;
|
|
137
|
+
expect(format(source)).toBe(source);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('keeps a trailing comment before the closing brace', () => {
|
|
141
|
+
const source = `options {
|
|
142
|
+
keys: {
|
|
143
|
+
area: ledger
|
|
144
|
+
}
|
|
145
|
+
# nothing below
|
|
146
|
+
}
|
|
147
|
+
`;
|
|
148
|
+
expect(format(source)).toBe(source);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('keeps an options block that holds nothing but a comment', () => {
|
|
152
|
+
const source = `options {
|
|
153
|
+
# a note
|
|
154
|
+
}
|
|
155
|
+
`;
|
|
156
|
+
expect(format(source)).toBe(source);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('round-trip — operation body key order', () => {
|
|
161
|
+
it('does not reorder keys into a canonical order', () => {
|
|
162
|
+
const source = `operation /pet: {
|
|
163
|
+
put: {
|
|
164
|
+
sdk: updatePet
|
|
165
|
+
service: PetService.update
|
|
166
|
+
response: {
|
|
167
|
+
200:
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
`;
|
|
172
|
+
expect(format(source)).toBe(source);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('preserves the opposite order just as faithfully', () => {
|
|
176
|
+
const source = `operation /pet: {
|
|
177
|
+
put: {
|
|
178
|
+
service: PetService.update
|
|
179
|
+
sdk: updatePet
|
|
180
|
+
response: {
|
|
181
|
+
200:
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
`;
|
|
186
|
+
expect(format(source)).toBe(source);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('round-trip — layout', () => {
|
|
191
|
+
it('keeps blank lines between operations', () => {
|
|
192
|
+
const source = `operation /pet: {
|
|
193
|
+
get: {
|
|
194
|
+
response: {
|
|
195
|
+
200:
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
post: {
|
|
200
|
+
response: {
|
|
201
|
+
201:
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
`;
|
|
206
|
+
expect(format(source)).toBe(source);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('keeps operations packed when the source has no blank line', () => {
|
|
210
|
+
const source = `operation /pet: {
|
|
211
|
+
get: {
|
|
212
|
+
response: {
|
|
213
|
+
200:
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
post: {
|
|
217
|
+
response: {
|
|
218
|
+
201:
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
`;
|
|
223
|
+
expect(format(source)).toBe(source);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('keeps a single-line response body on one line', () => {
|
|
227
|
+
const source = `operation /pet: {
|
|
228
|
+
get: {
|
|
229
|
+
response: {
|
|
230
|
+
200: { application/json: Pet }
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
`;
|
|
235
|
+
expect(format(source)).toBe(source);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('keeps an expanded response body expanded', () => {
|
|
239
|
+
const source = `operation /pet: {
|
|
240
|
+
get: {
|
|
241
|
+
response: {
|
|
242
|
+
200: {
|
|
243
|
+
application/json: Pet
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
`;
|
|
249
|
+
expect(format(source)).toBe(source);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ─── Idempotence ─────────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
describe('round-trip — idempotence', () => {
|
|
256
|
+
const sources = [
|
|
257
|
+
...ckFiles.map(name => readFileSync(join(CONTRACTS_DIR, name), 'utf8')),
|
|
258
|
+
// Non-canonical spacing: formatting once must reach a fixed point.
|
|
259
|
+
`operation /pet: {\n get: {\n response: {\n 200:\n }\n }\n}\n`,
|
|
260
|
+
];
|
|
261
|
+
|
|
262
|
+
for (const [i, source] of sources.entries()) {
|
|
263
|
+
it(`formatting is a fixed point for source #${i}`, () => {
|
|
264
|
+
const once = format(source);
|
|
265
|
+
expect(format(once)).toBe(once);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
});
|