@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.
@@ -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
- 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}` : '';
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
- 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}` : '';
39
56
  const modifiers = [
40
57
  model.deprecated ? 'deprecated' : '',
41
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';
@@ -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
- const commentSuffix = route.description ? ` # ${route.description}` : '';
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
- function printOperation(op: OpOperationNode): string[] {
109
- const lines: string[] = [];
110
- const commentSuffix = op.description ? ` # ${op.description}` : '';
111
- const modPart = op.modifiers?.length ? `(${op.modifiers[0]})` : '';
112
- lines.push(`${I1}${op.method}${modPart}: {${commentSuffix}`);
113
-
114
- if (op.name) lines.push(`${I2}name: ${op.name}`);
115
- if (op.service) lines.push(`${I2}service: ${op.service}`);
116
- if (op.sdk) lines.push(`${I2}sdk: ${op.sdk}`);
117
- if (op.signature) {
118
- const comment = op.signatureDescription ? ` # ${op.signatureDescription}` : '';
119
- if (op.signaturePolicy) {
120
- lines.push(`${I2}signature: {`);
121
- lines.push(`${I3}options: ${formatSignatureValue(op.signature)}${comment}`);
122
- 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));
123
164
  lines.push(`${I2}}`);
124
- } else {
125
- lines.push(`${I2}signature: ${formatSignatureValue(op.signature)}${comment}`);
165
+ return lines;
126
166
  }
127
- }
128
- if (op.security !== undefined) lines.push(...printSecurity(op.security));
129
- if (op.plugins && Object.keys(op.plugins).length > 0) {
130
- lines.push(`${I2}plugins: {`);
131
- for (const [key, val] of Object.entries(op.plugins)) {
132
- 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;
133
178
  }
134
- lines.push(`${I2}}`);
179
+ case 'responses':
180
+ return op.responses.length > 0 ? printResponseBlock(op.responses) : [];
135
181
  }
136
- if (op.query !== undefined) lines.push(...printQueryOrHeaders('query', op.query, op.queryMode));
137
- if (op.requestHeadersOptOut) {
138
- lines.push(`${I2}headers: none`);
139
- } else if (op.headers !== undefined) {
140
- lines.push(...printQueryOrHeaders('headers', op.headers, op.headersMode));
141
- }
142
- if (op.request) {
143
- lines.push(`${I2}request: {`);
144
- for (const body of op.request.bodies) {
145
- lines.push(...printContentTypeLine(body.contentType, body.bodyType, I3));
146
- }
147
- 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}`);
148
194
  }
149
- if (op.responses.length > 0) {
150
- 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));
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 || hasHeaders || optOut) {
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
- return obj.fields.map(f => printField(f, indent, printWidth));
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 ────────────────────────────────────────────────────────────────
@@ -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
 
@@ -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
+ });