@contractkit/prettier-plugin 0.12.1 → 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 +25 -0
- package/README.md +12 -3
- package/dist/index.js +170 -52
- package/dist/index.js.map +1 -1
- package/dist/print-ck.d.ts +10 -3
- package/dist/print-ck.d.ts.map +1 -1
- package/dist/print-contract.d.ts +10 -0
- package/dist/print-contract.d.ts.map +1 -1
- package/dist/print-operation.d.ts +13 -0
- package/dist/print-operation.d.ts.map +1 -1
- package/dist/print-type.d.ts +3 -1
- package/dist/print-type.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/print-ck.ts +60 -13
- package/src/print-contract.ts +19 -2
- package/src/print-operation.ts +124 -41
- package/src/print-type.ts +8 -2
- package/tests/print-ck.test.ts +318 -1
- package/tests/round-trip.test.ts +268 -0
- package/coverage/base.css +0 -224
- package/coverage/block-navigation.js +0 -87
- package/coverage/clover.xml +0 -358
- package/coverage/coverage-final.json +0 -6
- package/coverage/favicon.png +0 -0
- package/coverage/indent.ts.html +0 -88
- package/coverage/index.html +0 -176
- package/coverage/prettify.css +0 -1
- package/coverage/prettify.js +0 -2
- package/coverage/print-ck.ts.html +0 -424
- package/coverage/print-contract.ts.html +0 -292
- package/coverage/print-operation.ts.html +0 -1078
- package/coverage/print-type.ts.html +0 -532
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +0 -210
package/src/print-contract.ts
CHANGED
|
@@ -4,6 +4,16 @@ import { INDENT } from './indent.js';
|
|
|
4
4
|
|
|
5
5
|
// ─── Model declaration ───────────────────────────────────────────────────────
|
|
6
6
|
|
|
7
|
+
/**
|
|
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.
|
|
16
|
+
*/
|
|
7
17
|
export function printModelDecl(model: ModelNode, printWidth: number = 80): string {
|
|
8
18
|
// Type alias form: Name : typeExpression
|
|
9
19
|
if (model.type !== undefined) {
|
|
@@ -11,7 +21,9 @@ export function printModelDecl(model: ModelNode, printWidth: number = 80): strin
|
|
|
11
21
|
}
|
|
12
22
|
|
|
13
23
|
// Regular model with fields (possibly inherited)
|
|
14
|
-
|
|
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}` : '';
|
|
15
27
|
const modifiers = [
|
|
16
28
|
model.deprecated ? 'deprecated' : '',
|
|
17
29
|
model.inputCase || model.outputCase
|
|
@@ -29,13 +41,18 @@ export function printModelDecl(model: ModelNode, printWidth: number = 80): strin
|
|
|
29
41
|
for (const field of model.fields) {
|
|
30
42
|
lines.push(printField(field, INDENT, printWidth));
|
|
31
43
|
}
|
|
44
|
+
for (const comment of model.trailingComments ?? []) {
|
|
45
|
+
lines.push(`${INDENT}# ${comment}`);
|
|
46
|
+
}
|
|
32
47
|
lines.push('}');
|
|
33
48
|
return lines.join('\n');
|
|
34
49
|
}
|
|
35
50
|
|
|
36
51
|
function printTypeAlias(model: ModelNode, printWidth: number): string {
|
|
37
52
|
const type = model.type!;
|
|
38
|
-
|
|
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}` : '';
|
|
39
56
|
const modifiers = [
|
|
40
57
|
model.deprecated ? 'deprecated' : '',
|
|
41
58
|
model.inputCase || model.outputCase
|
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';
|
|
@@ -21,6 +23,7 @@ const I4 = INDENT.repeat(4);
|
|
|
21
23
|
// ─── Orphan comment helpers ──────────────────────────────────────────────────
|
|
22
24
|
|
|
23
25
|
type CommentEntry = { line: number; text: string };
|
|
26
|
+
/** A run of consecutive-line orphan comments, keyed by the source line it starts on. */
|
|
24
27
|
export type CommentBlock = { startLine: number; lines: string[] };
|
|
25
28
|
|
|
26
29
|
/** Group sorted orphan comment entries into consecutive-line blocks. */
|
|
@@ -52,10 +55,21 @@ export function flushBlocks(out: string[], blocks: CommentBlock[], idx: { value:
|
|
|
52
55
|
|
|
53
56
|
// ─── Route ───────────────────────────────────────────────────────────────────
|
|
54
57
|
|
|
58
|
+
/**
|
|
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.
|
|
69
|
+
*/
|
|
55
70
|
export function printRoute(route: OpRouteNode, blocks: CommentBlock[], idx: { value: number }, nextRouteStart: number): string {
|
|
56
71
|
const lines: string[] = [];
|
|
57
|
-
|
|
58
|
-
lines.push(`${route.path}: {${commentSuffix}`);
|
|
72
|
+
lines.push(`${route.path}: {`);
|
|
59
73
|
|
|
60
74
|
if (route.params !== undefined) {
|
|
61
75
|
lines.push(...printParamsBlock(route.params, I1, route.paramsMode));
|
|
@@ -66,6 +80,8 @@ export function printRoute(route: OpRouteNode, blocks: CommentBlock[], idx: { va
|
|
|
66
80
|
}
|
|
67
81
|
|
|
68
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('');
|
|
69
85
|
// Flush comment blocks that appear before this operation (inside the route)
|
|
70
86
|
flushBlocks(lines, blocks, idx, op.loc.line, I1);
|
|
71
87
|
lines.push(...printOperation(op));
|
|
@@ -74,6 +90,11 @@ export function printRoute(route: OpRouteNode, blocks: CommentBlock[], idx: { va
|
|
|
74
90
|
// Flush comment blocks between last operation and the next route
|
|
75
91
|
flushBlocks(lines, blocks, idx, nextRouteStart, I1);
|
|
76
92
|
|
|
93
|
+
// Trailing/orphan comments after the last operation, before the closing brace.
|
|
94
|
+
for (const comment of route.trailingComments ?? []) {
|
|
95
|
+
lines.push(`${I1}# ${comment}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
77
98
|
lines.push('}');
|
|
78
99
|
return lines.join('\n');
|
|
79
100
|
}
|
|
@@ -105,55 +126,114 @@ function printParamsBlock(source: ParamSource, indent: string, mode?: ObjectMode
|
|
|
105
126
|
|
|
106
127
|
// ─── HTTP operation ──────────────────────────────────────────────────────────
|
|
107
128
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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));
|
|
123
164
|
lines.push(`${I2}}`);
|
|
124
|
-
|
|
125
|
-
lines.push(`${I2}signature: ${formatSignatureValue(op.signature)}${comment}`);
|
|
165
|
+
return lines;
|
|
126
166
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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;
|
|
133
178
|
}
|
|
134
|
-
|
|
179
|
+
case 'responses':
|
|
180
|
+
return op.responses.length > 0 ? printResponseBlock(op.responses) : [];
|
|
135
181
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
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}`);
|
|
148
194
|
}
|
|
149
|
-
|
|
150
|
-
|
|
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));
|
|
151
204
|
}
|
|
152
205
|
|
|
153
206
|
lines.push(`${I1}}`);
|
|
154
207
|
return lines;
|
|
155
208
|
}
|
|
156
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
|
+
|
|
157
237
|
// ─── Plugins block ───────────────────────────────────────────────────────────
|
|
158
238
|
|
|
159
239
|
const IDENT_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
|
|
@@ -304,7 +384,10 @@ function printResponseBlock(responses: OpResponseNode[]): string[] {
|
|
|
304
384
|
const hasBody = resp.contentType && resp.bodyType;
|
|
305
385
|
const hasHeaders = resp.headers && resp.headers.length > 0;
|
|
306
386
|
const optOut = resp.headersOptOut;
|
|
307
|
-
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) {
|
|
308
391
|
lines.push(`${I3}${resp.statusCode}: {`);
|
|
309
392
|
if (hasBody) {
|
|
310
393
|
lines.push(...printContentTypeLine(resp.contentType!, resp.bodyType!, I4));
|
package/src/print-type.ts
CHANGED
|
@@ -105,9 +105,15 @@ export function printField(field: FieldNode, indent: string, printWidth: number
|
|
|
105
105
|
return fullLine;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
/** Print inline-object fields expanded (used when an inline brace object trails a type alias).
|
|
108
|
+
/** Print inline-object fields expanded (used when an inline brace object trails a type alias).
|
|
109
|
+
* Any `trailingComments` (comments after the last field, before `}`) are emitted as indented
|
|
110
|
+
* `# text` lines after the fields, matching how model bodies round-trip trailing comments. */
|
|
109
111
|
export function printInlineObjectExpanded(obj: InlineObjectTypeNode, indent: string, printWidth: number = 80): string[] {
|
|
110
|
-
|
|
112
|
+
const lines = obj.fields.map(f => printField(f, indent, printWidth));
|
|
113
|
+
for (const comment of obj.trailingComments ?? []) {
|
|
114
|
+
lines.push(`${indent}# ${comment}`);
|
|
115
|
+
}
|
|
116
|
+
return lines;
|
|
111
117
|
}
|
|
112
118
|
|
|
113
119
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
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();
|
|
@@ -800,3 +878,242 @@ contract M: {
|
|
|
800
878
|
expect(roundTrip(source)).toBe(source);
|
|
801
879
|
});
|
|
802
880
|
});
|
|
881
|
+
|
|
882
|
+
describe('printCk — trailing/orphan comments (round-trip)', () => {
|
|
883
|
+
function roundTrip(source: string): string {
|
|
884
|
+
const diag = new DiagnosticCollector();
|
|
885
|
+
const ast = parseCk(source, 'test.ck', diag);
|
|
886
|
+
expect(diag.hasErrors()).toBe(false);
|
|
887
|
+
return printCk(ast);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
it('preserves a comment on the last line of a contract body', () => {
|
|
891
|
+
const source = `\
|
|
892
|
+
contract Payment: {
|
|
893
|
+
id: uuid
|
|
894
|
+
# more fields to come
|
|
895
|
+
}
|
|
896
|
+
`;
|
|
897
|
+
const out = roundTrip(source);
|
|
898
|
+
expect(out).toContain('# more fields to come');
|
|
899
|
+
// Comment sits on its own line after the last field, before the closing brace.
|
|
900
|
+
expect(out).toBe(source);
|
|
901
|
+
// Idempotent: formatting the output again yields the same result.
|
|
902
|
+
expect(roundTrip(out)).toBe(out);
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it('preserves multiple trailing comments in a contract body', () => {
|
|
906
|
+
const source = `\
|
|
907
|
+
contract Payment: {
|
|
908
|
+
id: uuid
|
|
909
|
+
# note one
|
|
910
|
+
# note two
|
|
911
|
+
}
|
|
912
|
+
`;
|
|
913
|
+
const out = roundTrip(source);
|
|
914
|
+
expect(out).toContain('# note one');
|
|
915
|
+
expect(out).toContain('# note two');
|
|
916
|
+
expect(out).toBe(source);
|
|
917
|
+
expect(roundTrip(out)).toBe(out);
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
it('preserves a comment as the only content of a contract body', () => {
|
|
921
|
+
const source = `\
|
|
922
|
+
contract Empty: {
|
|
923
|
+
# nothing here yet
|
|
924
|
+
}
|
|
925
|
+
`;
|
|
926
|
+
const out = roundTrip(source);
|
|
927
|
+
expect(out).toContain('# nothing here yet');
|
|
928
|
+
expect(out).toBe(source);
|
|
929
|
+
expect(roundTrip(out)).toBe(out);
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
it('preserves a comment on the last line of an operation/route body', () => {
|
|
933
|
+
const source = `\
|
|
934
|
+
operation /users: {
|
|
935
|
+
get: {
|
|
936
|
+
}
|
|
937
|
+
# TODO: add post
|
|
938
|
+
}
|
|
939
|
+
`;
|
|
940
|
+
const out = roundTrip(source);
|
|
941
|
+
expect(out).toContain('# TODO: add post');
|
|
942
|
+
expect(out).toBe(source);
|
|
943
|
+
expect(roundTrip(out)).toBe(out);
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
it('does not double-emit a header inline comment on a contract with no fields', () => {
|
|
947
|
+
const source = `\
|
|
948
|
+
contract Empty: { # nothing here yet
|
|
949
|
+
}
|
|
950
|
+
`;
|
|
951
|
+
const out = roundTrip(source);
|
|
952
|
+
// The inline header comment must survive exactly once, not be duplicated on a trailing line.
|
|
953
|
+
expect(out.match(/# nothing here yet/g)?.length).toBe(1);
|
|
954
|
+
expect(roundTrip(out)).toBe(out);
|
|
955
|
+
});
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
describe('printCk — inline object trailing comments (round-trip)', () => {
|
|
959
|
+
function roundTrip(source: string): string {
|
|
960
|
+
const diag = new DiagnosticCollector();
|
|
961
|
+
const ast = parseCk(source, 'test.ck', diag);
|
|
962
|
+
expect(diag.hasErrors()).toBe(false);
|
|
963
|
+
return printCk(ast);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
it('preserves a trailing comment as the last inner item of an inline object type', () => {
|
|
967
|
+
const source = `\
|
|
968
|
+
contract Foo: {
|
|
969
|
+
bar: {
|
|
970
|
+
a: string
|
|
971
|
+
# trailing note
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
`;
|
|
975
|
+
const out = roundTrip(source);
|
|
976
|
+
expect(out).toContain('# trailing note');
|
|
977
|
+
expect(out).toBe(source);
|
|
978
|
+
expect(roundTrip(out)).toBe(out);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
it('preserves multiple trailing comments in an inline object type', () => {
|
|
982
|
+
const source = `\
|
|
983
|
+
contract Foo: {
|
|
984
|
+
bar: {
|
|
985
|
+
a: string
|
|
986
|
+
# note one
|
|
987
|
+
# note two
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
`;
|
|
991
|
+
const out = roundTrip(source);
|
|
992
|
+
expect(out).toContain('# note one');
|
|
993
|
+
expect(out).toContain('# note two');
|
|
994
|
+
expect(out).toBe(source);
|
|
995
|
+
expect(roundTrip(out)).toBe(out);
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
it('preserves a trailing comment as the only inner item of an inline object type', () => {
|
|
999
|
+
const source = `\
|
|
1000
|
+
contract Foo: {
|
|
1001
|
+
bar: {
|
|
1002
|
+
# placeholder
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
`;
|
|
1006
|
+
const out = roundTrip(source);
|
|
1007
|
+
expect(out).toContain('# placeholder');
|
|
1008
|
+
expect(out).toBe(source);
|
|
1009
|
+
expect(roundTrip(out)).toBe(out);
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
it('still attaches an inline comment on an inline field (no regression)', () => {
|
|
1013
|
+
const source = `\
|
|
1014
|
+
contract Foo: {
|
|
1015
|
+
bar: {
|
|
1016
|
+
a: string # about a
|
|
1017
|
+
b: int
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
`;
|
|
1021
|
+
const out = roundTrip(source);
|
|
1022
|
+
expect(out).toContain('a: string # about a');
|
|
1023
|
+
expect(out).toBe(source);
|
|
1024
|
+
expect(roundTrip(out)).toBe(out);
|
|
1025
|
+
});
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
describe('printCk — options block sub-block comments (round-trip)', () => {
|
|
1029
|
+
function roundTrip(source: string): string {
|
|
1030
|
+
const diag = new DiagnosticCollector();
|
|
1031
|
+
const ast = parseCk(source, 'test.ck', diag);
|
|
1032
|
+
expect(diag.hasErrors()).toBe(false);
|
|
1033
|
+
return printCk(ast);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
it('preserves a leading comment on a keys entry', () => {
|
|
1037
|
+
const source = `\
|
|
1038
|
+
options {
|
|
1039
|
+
keys: {
|
|
1040
|
+
# the api area
|
|
1041
|
+
area: payments
|
|
1042
|
+
version: v2
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
`;
|
|
1046
|
+
const out = roundTrip(source);
|
|
1047
|
+
expect(out).toContain('# the api area');
|
|
1048
|
+
expect(out).toBe(source);
|
|
1049
|
+
expect(roundTrip(out)).toBe(out);
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
it('preserves a trailing comment at the end of the keys sub-block', () => {
|
|
1053
|
+
const source = `\
|
|
1054
|
+
options {
|
|
1055
|
+
keys: {
|
|
1056
|
+
area: payments
|
|
1057
|
+
# more keys to come
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
`;
|
|
1061
|
+
const out = roundTrip(source);
|
|
1062
|
+
expect(out).toContain('# more keys to come');
|
|
1063
|
+
expect(out).toBe(source);
|
|
1064
|
+
expect(roundTrip(out)).toBe(out);
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
it('preserves a leading comment on a services entry', () => {
|
|
1068
|
+
const source = `\
|
|
1069
|
+
options {
|
|
1070
|
+
services: {
|
|
1071
|
+
# payments backend
|
|
1072
|
+
PaymentsService: "#src/services/payments.service.js"
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
`;
|
|
1076
|
+
const out = roundTrip(source);
|
|
1077
|
+
expect(out).toContain('# payments backend');
|
|
1078
|
+
expect(out).toBe(source);
|
|
1079
|
+
expect(roundTrip(out)).toBe(out);
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
it('preserves a comment at the end of the last options sub-block (end of options block)', () => {
|
|
1083
|
+
const source = `\
|
|
1084
|
+
options {
|
|
1085
|
+
keys: {
|
|
1086
|
+
area: payments
|
|
1087
|
+
}
|
|
1088
|
+
services: {
|
|
1089
|
+
PaymentsService: "#src/services/payments.service.js"
|
|
1090
|
+
# end of options
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
`;
|
|
1094
|
+
const out = roundTrip(source);
|
|
1095
|
+
expect(out).toContain('# end of options');
|
|
1096
|
+
expect(out).toBe(source);
|
|
1097
|
+
expect(roundTrip(out)).toBe(out);
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
it('preserves both leading and trailing comments across keys and services', () => {
|
|
1101
|
+
const source = `\
|
|
1102
|
+
options {
|
|
1103
|
+
keys: {
|
|
1104
|
+
# area comment
|
|
1105
|
+
area: payments
|
|
1106
|
+
# trailing keys
|
|
1107
|
+
}
|
|
1108
|
+
services: {
|
|
1109
|
+
# service comment
|
|
1110
|
+
PaymentsService: "#src/services/payments.service.js"
|
|
1111
|
+
# trailing services
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
`;
|
|
1115
|
+
const out = roundTrip(source);
|
|
1116
|
+
expect(out).toBe(source);
|
|
1117
|
+
expect(roundTrip(out)).toBe(out);
|
|
1118
|
+
});
|
|
1119
|
+
});
|