@contractkit/prettier-plugin 0.8.1

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.
Files changed (62) hide show
  1. package/.turbo/turbo-build$colon$ci.log +35 -0
  2. package/.turbo/turbo-build.log +15 -0
  3. package/.turbo/turbo-format.log +29 -0
  4. package/.turbo/turbo-test$colon$ci.log +105 -0
  5. package/.turbo/turbo-test.log +14 -0
  6. package/CHANGELOG.md +111 -0
  7. package/coverage/base.css +224 -0
  8. package/coverage/block-navigation.js +87 -0
  9. package/coverage/clover.xml +305 -0
  10. package/coverage/coverage-final.json +6 -0
  11. package/coverage/favicon.png +0 -0
  12. package/coverage/indent.ts.html +88 -0
  13. package/coverage/index.html +176 -0
  14. package/coverage/prettify.css +1 -0
  15. package/coverage/prettify.js +2 -0
  16. package/coverage/print-ck.ts.html +370 -0
  17. package/coverage/print-contract.ts.html +292 -0
  18. package/coverage/print-operation.ts.html +811 -0
  19. package/coverage/print-type.ts.html +511 -0
  20. package/coverage/sort-arrow-sprite.png +0 -0
  21. package/coverage/sorter.js +210 -0
  22. package/dist/__tests__/print-ck.test.d.ts +2 -0
  23. package/dist/__tests__/print-ck.test.d.ts.map +1 -0
  24. package/dist/__tests__/print-op.test.d.ts +2 -0
  25. package/dist/__tests__/print-op.test.d.ts.map +1 -0
  26. package/dist/__tests__/print-op.test.js +359 -0
  27. package/dist/__tests__/print-op.test.js.map +1 -0
  28. package/dist/indent.d.ts +2 -0
  29. package/dist/indent.d.ts.map +1 -0
  30. package/dist/index.d.ts +5 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +520 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/print-ck.d.ts +4 -0
  35. package/dist/print-ck.d.ts.map +1 -0
  36. package/dist/print-contract.d.ts +3 -0
  37. package/dist/print-contract.d.ts.map +1 -0
  38. package/dist/print-dto.d.ts +3 -0
  39. package/dist/print-dto.d.ts.map +1 -0
  40. package/dist/print-dto.js +100 -0
  41. package/dist/print-dto.js.map +1 -0
  42. package/dist/print-op.d.ts +24 -0
  43. package/dist/print-op.d.ts.map +1 -0
  44. package/dist/print-op.js +224 -0
  45. package/dist/print-op.js.map +1 -0
  46. package/dist/print-operation.d.ts +24 -0
  47. package/dist/print-operation.d.ts.map +1 -0
  48. package/dist/print-type.d.ts +21 -0
  49. package/dist/print-type.d.ts.map +1 -0
  50. package/dist/print-type.js +110 -0
  51. package/dist/print-type.js.map +1 -0
  52. package/eslint.config.js +6 -0
  53. package/package.json +47 -0
  54. package/src/indent.ts +1 -0
  55. package/src/index.ts +46 -0
  56. package/src/print-ck.ts +95 -0
  57. package/src/print-contract.ts +69 -0
  58. package/src/print-operation.ts +242 -0
  59. package/src/print-type.ts +142 -0
  60. package/tests/print-ck.test.ts +606 -0
  61. package/tsconfig.json +9 -0
  62. package/vitest.config.ts +11 -0
package/src/index.ts ADDED
@@ -0,0 +1,46 @@
1
+ import type { Plugin } from 'prettier';
2
+ import { builders } from 'prettier/doc';
3
+ import { parseCk, DiagnosticCollector } from '@contractkit/core';
4
+ import type { CkRootNode } from '@contractkit/core';
5
+ import { printCk } from './print-ck.js';
6
+
7
+ const { hardline, join } = builders;
8
+
9
+ function toDoc(text: string) {
10
+ const lines = text.trimEnd().split('\n');
11
+ return join(hardline, lines);
12
+ }
13
+
14
+ const plugin: Plugin<CkRootNode> = {
15
+ languages: [
16
+ {
17
+ name: 'ContractDSL',
18
+ parsers: ['contract-ck'],
19
+ extensions: ['.ck'],
20
+ vscodeLanguageIds: ['contract-ck'],
21
+ },
22
+ ],
23
+
24
+ parsers: {
25
+ 'contract-ck': {
26
+ parse(text, _options) {
27
+ const diag = new DiagnosticCollector();
28
+ return parseCk(text, '<stdin>', diag);
29
+ },
30
+ astFormat: 'contract-ck',
31
+ locStart: () => 0,
32
+ locEnd: _node => 0,
33
+ },
34
+ },
35
+
36
+ printers: {
37
+ 'contract-ck': {
38
+ print(path, options) {
39
+ const node = path.node as CkRootNode;
40
+ return toDoc(printCk(node, options.printWidth));
41
+ },
42
+ },
43
+ },
44
+ };
45
+
46
+ export default plugin;
@@ -0,0 +1,95 @@
1
+ import type { CkRootNode, OpResponseHeaderNode } from '@contractkit/core';
2
+ import { printModelDecl } from './print-contract.js';
3
+ import { printRoute, printSecurity, type CommentBlock } from './print-operation.js';
4
+ import { printType } from './print-type.js';
5
+ import { INDENT } from './indent.js';
6
+
7
+ export const DEFAULT_PRINT_WIDTH = 80;
8
+
9
+ // ─── Options block ──────────────────────────────────────────────────────────
10
+
11
+ function printOptionsBlock(ast: CkRootNode): string | null {
12
+ const hasMeta = Object.keys(ast.meta).length > 0;
13
+ const hasServices = Object.keys(ast.services).length > 0;
14
+ const hasSecurity = ast.security !== undefined;
15
+ const hasRequestHeaders = (ast.requestHeaders?.length ?? 0) > 0;
16
+ const hasResponseHeaders = (ast.responseHeaders?.length ?? 0) > 0;
17
+
18
+ if (!hasMeta && !hasServices && !hasSecurity && !hasRequestHeaders && !hasResponseHeaders) return null;
19
+
20
+ const lines: string[] = ['options {'];
21
+
22
+ if (hasMeta) {
23
+ lines.push(`${INDENT}keys: {`);
24
+ for (const [key, value] of Object.entries(ast.meta)) {
25
+ const v = value.startsWith('#') || value.includes(' ') ? `"${value}"` : value;
26
+ lines.push(`${INDENT}${INDENT}${key}: ${v}`);
27
+ }
28
+ lines.push(`${INDENT}}`);
29
+ }
30
+
31
+ if (hasServices) {
32
+ lines.push(`${INDENT}services: {`);
33
+ for (const [key, value] of Object.entries(ast.services)) {
34
+ const v = value.startsWith('#') || value.includes(' ') ? `"${value}"` : value;
35
+ lines.push(`${INDENT}${INDENT}${key}: ${v}`);
36
+ }
37
+ lines.push(`${INDENT}}`);
38
+ }
39
+
40
+ if (hasRequestHeaders) {
41
+ lines.push(...printOptionsHeaderScope('request', ast.requestHeaders!));
42
+ }
43
+
44
+ if (hasResponseHeaders) {
45
+ lines.push(...printOptionsHeaderScope('response', ast.responseHeaders!));
46
+ }
47
+
48
+ if (hasSecurity) {
49
+ lines.push(...printSecurity(ast.security!, INDENT, INDENT + INDENT));
50
+ }
51
+
52
+ lines.push('}');
53
+ return lines.join('\n');
54
+ }
55
+
56
+ function printOptionsHeaderScope(keyword: 'request' | 'response', headers: OpResponseHeaderNode[]): string[] {
57
+ const I2 = INDENT + INDENT;
58
+ const I3 = INDENT + INDENT + INDENT;
59
+ const lines = [`${INDENT}${keyword}: {`, `${I2}headers: {`];
60
+ for (const h of headers) {
61
+ const opt = h.optional ? '?' : '';
62
+ const trail = h.description ? ` # ${h.description}` : '';
63
+ lines.push(`${I3}${h.name}${opt}: ${printType(h.type)}${trail}`);
64
+ }
65
+ lines.push(`${I2}}`);
66
+ lines.push(`${INDENT}}`);
67
+ return lines;
68
+ }
69
+
70
+ // ─── CK file printer ───────────────────────────────────────────────────────
71
+
72
+ export function printCk(ast: CkRootNode, printWidth: number = DEFAULT_PRINT_WIDTH): string {
73
+ const parts: string[] = [];
74
+
75
+ // Options block
76
+ const options = printOptionsBlock(ast);
77
+ if (options) parts.push(options);
78
+
79
+ // Contracts (models)
80
+ for (const model of ast.models) {
81
+ if (parts.length > 0) parts.push('');
82
+ parts.push(`contract ${printModelDecl(model, printWidth)}`);
83
+ }
84
+
85
+ // Operations (routes)
86
+ const emptyBlocks: CommentBlock[] = [];
87
+ const emptyIdx = { value: 0 };
88
+ for (const route of ast.routes) {
89
+ if (parts.length > 0) parts.push('');
90
+ const modPart = route.modifiers?.length ? `(${route.modifiers[0]})` : '';
91
+ parts.push(`operation${modPart} ${printRoute(route, emptyBlocks, emptyIdx, Infinity)}`);
92
+ }
93
+
94
+ return parts.join('\n') + '\n';
95
+ }
@@ -0,0 +1,69 @@
1
+ import type { ModelNode } from '@contractkit/core';
2
+ import { printField, printInlineObjectExpanded, extractTrailingInlineObject, printType, printEnumExpanded } from './print-type.js';
3
+ import { INDENT } from './indent.js';
4
+
5
+ // ─── Model declaration ───────────────────────────────────────────────────────
6
+
7
+ export function printModelDecl(model: ModelNode, printWidth: number = 80): string {
8
+ // Type alias form: Name : typeExpression
9
+ if (model.type !== undefined) {
10
+ return printTypeAlias(model, printWidth);
11
+ }
12
+
13
+ // Regular model with fields (possibly inherited)
14
+ const commentSuffix = model.description ? ` # ${model.description}` : '';
15
+ const modifiers = [
16
+ model.deprecated ? 'deprecated' : '',
17
+ model.inputCase || model.outputCase
18
+ ? `format(${[model.inputCase ? `input=${model.inputCase}` : '', model.outputCase ? `output=${model.outputCase}` : ''].filter(Boolean).join(', ')})`
19
+ : '',
20
+ model.mode ? `mode(${model.mode})` : '',
21
+ ]
22
+ .filter(Boolean)
23
+ .join(' ');
24
+ const modePrefix = modifiers ? `${modifiers} ` : '';
25
+ const baseChain = model.bases && model.bases.length > 0 ? `${model.bases.join(' & ')} & ` : '';
26
+ const header = `${modePrefix}${model.name}: ${baseChain}{${commentSuffix}`;
27
+
28
+ const lines: string[] = [header];
29
+ for (const field of model.fields) {
30
+ lines.push(printField(field, INDENT, printWidth));
31
+ }
32
+ lines.push('}');
33
+ return lines.join('\n');
34
+ }
35
+
36
+ function printTypeAlias(model: ModelNode, printWidth: number): string {
37
+ const type = model.type!;
38
+ const commentSuffix = model.description ? ` # ${model.description}` : '';
39
+ const modifiers = [
40
+ model.deprecated ? 'deprecated' : '',
41
+ model.inputCase || model.outputCase
42
+ ? `format(${[model.inputCase ? `input=${model.inputCase}` : '', model.outputCase ? `output=${model.outputCase}` : ''].filter(Boolean).join(', ')})`
43
+ : '',
44
+ model.mode ? `mode(${model.mode})` : '',
45
+ ]
46
+ .filter(Boolean)
47
+ .join(' ');
48
+ const modePrefix = modifiers ? `${modifiers} ` : '';
49
+
50
+ // If the type ends with an inline brace object, expand it as a pseudo-model block.
51
+ const trailing = extractTrailingInlineObject(type);
52
+ if (trailing) {
53
+ const { prefix, inlineObj } = trailing;
54
+ const modePart = inlineObj.mode ? `mode(${inlineObj.mode}) ` : '';
55
+ const header = prefix
56
+ ? `${modePrefix}${model.name}: ${prefix} & ${modePart}{${commentSuffix}`
57
+ : `${modePrefix}${model.name}: ${modePart}{${commentSuffix}`;
58
+ const lines: string[] = [header, ...printInlineObjectExpanded(inlineObj, INDENT, printWidth), '}'];
59
+ return lines.join('\n');
60
+ }
61
+
62
+ // Simple type alias — single line, unless it's a long enum.
63
+ // Note: the contract prefix "contract " (9 chars) is prepended by the caller.
64
+ const singleLine = `${modePrefix}${model.name}: ${printType(type)}${commentSuffix}`;
65
+ if (type.kind === 'enum' && 'contract '.length + singleLine.length > printWidth) {
66
+ return `${modePrefix}${model.name}: ${printEnumExpanded(type.values, '')}${commentSuffix}`;
67
+ }
68
+ return singleLine;
69
+ }
@@ -0,0 +1,242 @@
1
+ import type {
2
+ OpRouteNode,
3
+ OpOperationNode,
4
+ OpResponseNode,
5
+ ParamSource,
6
+ SecurityNode,
7
+ SecurityFields,
8
+ ContractTypeNode,
9
+ ObjectMode,
10
+ } from '@contractkit/core';
11
+ import { SECURITY_NONE } from '@contractkit/core';
12
+ import { printType, formatDefault } from './print-type.js';
13
+ import { INDENT } from './indent.js';
14
+
15
+ const I1 = INDENT;
16
+ const I2 = INDENT.repeat(2);
17
+ const I3 = INDENT.repeat(3);
18
+ const I4 = INDENT.repeat(4);
19
+
20
+ // ─── Orphan comment helpers ──────────────────────────────────────────────────
21
+
22
+ type CommentEntry = { line: number; text: string };
23
+ export type CommentBlock = { startLine: number; lines: string[] };
24
+
25
+ /** Group sorted orphan comment entries into consecutive-line blocks. */
26
+ export function groupComments(entries: CommentEntry[]): CommentBlock[] {
27
+ const blocks: CommentBlock[] = [];
28
+ let current: CommentBlock | null = null;
29
+ for (const { line, text } of entries) {
30
+ if (current && line === current.startLine + current.lines.length) {
31
+ current.lines.push(text);
32
+ } else {
33
+ if (current) blocks.push(current);
34
+ current = { startLine: line, lines: [text] };
35
+ }
36
+ }
37
+ if (current) blocks.push(current);
38
+ return blocks;
39
+ }
40
+
41
+ /**
42
+ * Emit any comment blocks whose startLine is < beforeLine.
43
+ * Lines are emitted verbatim — they already carry their original indentation.
44
+ */
45
+ export function flushBlocks(out: string[], blocks: CommentBlock[], idx: { value: number }, beforeLine: number, _indent = '') {
46
+ while (idx.value < blocks.length && blocks[idx.value]!.startLine < beforeLine) {
47
+ for (const l of blocks[idx.value]!.lines) out.push(l);
48
+ idx.value++;
49
+ }
50
+ }
51
+
52
+ // ─── Route ───────────────────────────────────────────────────────────────────
53
+
54
+ export function printRoute(route: OpRouteNode, blocks: CommentBlock[], idx: { value: number }, nextRouteStart: number): string {
55
+ const lines: string[] = [];
56
+ const commentSuffix = route.description ? ` # ${route.description}` : '';
57
+ lines.push(`${route.path}: {${commentSuffix}`);
58
+
59
+ if (route.params !== undefined) {
60
+ lines.push(...printParamsBlock(route.params, I1, route.paramsMode));
61
+ }
62
+
63
+ if (route.security !== undefined) {
64
+ lines.push(...printSecurity(route.security, I1, I2));
65
+ }
66
+
67
+ for (const op of route.operations) {
68
+ // Flush comment blocks that appear before this operation (inside the route)
69
+ flushBlocks(lines, blocks, idx, op.loc.line, I1);
70
+ lines.push(...printOperation(op));
71
+ }
72
+
73
+ // Flush comment blocks between last operation and the next route
74
+ flushBlocks(lines, blocks, idx, nextRouteStart, I1);
75
+
76
+ lines.push('}');
77
+ return lines.join('\n');
78
+ }
79
+
80
+ // ─── Params block ────────────────────────────────────────────────────────────
81
+
82
+ function printParamsBlock(source: ParamSource, indent: string, mode?: ObjectMode): string[] {
83
+ const prefix = mode ? `mode(${mode}) ` : '';
84
+ if (source.kind === 'ref') {
85
+ return [`${indent}${prefix}params: ${source.name}`];
86
+ }
87
+ if (source.kind === 'params') {
88
+ const lines: string[] = [`${indent}${prefix}params: {`];
89
+ const inner = indent + INDENT;
90
+ for (const p of source.nodes) {
91
+ const opt = p.optional ? '?' : '';
92
+ let t = printType(p.type);
93
+ if (p.nullable) t += ' | null';
94
+ const def = p.default !== undefined ? ` = ${formatDefault(p.default)}` : '';
95
+ const comment = p.description ? ` # ${p.description}` : '';
96
+ lines.push(`${inner}${p.name}${opt}: ${t}${def}${comment}`);
97
+ }
98
+ lines.push(`${indent}}`);
99
+ return lines;
100
+ }
101
+ // ContractTypeNode
102
+ return [`${indent}${prefix}params: ${printType(source.node)}`];
103
+ }
104
+
105
+ // ─── HTTP operation ──────────────────────────────────────────────────────────
106
+
107
+ function printOperation(op: OpOperationNode): string[] {
108
+ const lines: string[] = [];
109
+ const commentSuffix = op.description ? ` # ${op.description}` : '';
110
+ const modPart = op.modifiers?.length ? `(${op.modifiers[0]})` : '';
111
+ lines.push(`${I1}${op.method}${modPart}: {${commentSuffix}`);
112
+
113
+ if (op.name) lines.push(`${I2}name: ${op.name}`);
114
+ if (op.service) lines.push(`${I2}service: ${op.service}`);
115
+ if (op.sdk) lines.push(`${I2}sdk: ${op.sdk}`);
116
+ if (op.signature) {
117
+ const comment = op.signatureDescription ? ` # ${op.signatureDescription}` : '';
118
+ lines.push(`${I2}signature: ${formatSignatureValue(op.signature)}${comment}`);
119
+ }
120
+ if (op.security !== undefined) lines.push(...printSecurity(op.security));
121
+ if (op.query !== undefined) lines.push(...printQueryOrHeaders('query', op.query, op.queryMode));
122
+ if (op.requestHeadersOptOut) {
123
+ lines.push(`${I2}headers: none`);
124
+ } else if (op.headers !== undefined) {
125
+ lines.push(...printQueryOrHeaders('headers', op.headers, op.headersMode));
126
+ }
127
+ if (op.request) {
128
+ lines.push(`${I2}request: {`);
129
+ for (const body of op.request.bodies) {
130
+ lines.push(...printContentTypeLine(body.contentType, body.bodyType, I3));
131
+ }
132
+ lines.push(`${I2}}`);
133
+ }
134
+ if (op.responses.length > 0) {
135
+ lines.push(...printResponseBlock(op.responses));
136
+ }
137
+
138
+ lines.push(`${I1}}`);
139
+ return lines;
140
+ }
141
+
142
+ // ─── Security ────────────────────────────────────────────────────────────────
143
+
144
+ /** Print a signature key: unquoted when it's a plain identifier, quoted otherwise. */
145
+ function formatSignatureValue(value: string): string {
146
+ return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(value) ? value : `"${value}"`;
147
+ }
148
+
149
+ // indent: indentation for the `security` keyword line
150
+ // innerIndent: indentation for field lines inside the block
151
+ export function printSecurity(security: SecurityNode, indent = I2, innerIndent = I3): string[] {
152
+ if (security === SECURITY_NONE) return [`${indent}security: none`];
153
+ const fields = security as SecurityFields;
154
+ const hasRoles = fields.roles && fields.roles.length > 0;
155
+ if (!hasRoles) return [];
156
+ const lines = [`${indent}security: {`];
157
+ const comment = fields.rolesDescription ? ` # ${fields.rolesDescription}` : '';
158
+ lines.push(`${innerIndent}roles: ${fields.roles!.join(' ')}${comment}`);
159
+ lines.push(`${indent}}`);
160
+ return lines;
161
+ }
162
+
163
+ // ─── Query / headers ─────────────────────────────────────────────────────────
164
+
165
+ function printQueryOrHeaders(keyword: 'query' | 'headers', source: ParamSource, mode?: ObjectMode): string[] {
166
+ const prefix = mode ? `mode(${mode}) ` : '';
167
+ if (source.kind === 'ref') {
168
+ return [`${I2}${prefix}${keyword}: ${source.name}`];
169
+ }
170
+ if (source.kind === 'params') {
171
+ if (source.nodes.length === 0) return [];
172
+ const lines: string[] = [`${I2}${prefix}${keyword}: {`];
173
+ for (const p of source.nodes) {
174
+ const opt = p.optional ? '?' : '';
175
+ let t = printType(p.type);
176
+ if (p.nullable) t += ' | null';
177
+ const def = p.default !== undefined ? ` = ${formatDefault(p.default)}` : '';
178
+ const comment = p.description ? ` # ${p.description}` : '';
179
+ lines.push(`${I3}${p.name}${opt}: ${t}${def}${comment}`);
180
+ }
181
+ lines.push(`${I2}}`);
182
+ return lines;
183
+ }
184
+ // ContractTypeNode (e.g. intersection)
185
+ return [`${I2}${prefix}${keyword}: ${printType(source.node)}`];
186
+ }
187
+
188
+ // ─── Content-type line ───────────────────────────────────────────────────────
189
+
190
+ /** Print a `contentType: bodyType` line, expanding inline brace objects onto separate lines. */
191
+ function printContentTypeLine(contentType: string, bodyType: ContractTypeNode, lineIndent: string): string[] {
192
+ if (bodyType.kind === 'inlineObject') {
193
+ const fieldIndent = lineIndent + INDENT;
194
+ const lines: string[] = [`${lineIndent}${contentType}: {`];
195
+ for (const f of bodyType.fields) {
196
+ const opt = f.optional ? '?' : '';
197
+ let t = printType(f.type);
198
+ if (f.nullable) t += ' | null';
199
+ const def = f.default !== undefined ? ` = ${formatDefault(f.default)}` : '';
200
+ const comment = f.description ? ` # ${f.description}` : '';
201
+ lines.push(`${fieldIndent}${f.name}${opt}: ${t}${def}${comment}`);
202
+ }
203
+ lines.push(`${lineIndent}}`);
204
+ return lines;
205
+ }
206
+ return [`${lineIndent}${contentType}: ${printType(bodyType)}`];
207
+ }
208
+
209
+ // ─── Response block ──────────────────────────────────────────────────────────
210
+
211
+ function printResponseBlock(responses: OpResponseNode[]): string[] {
212
+ const lines: string[] = [`${I2}response: {`];
213
+
214
+ for (const resp of responses) {
215
+ const hasBody = resp.contentType && resp.bodyType;
216
+ const hasHeaders = resp.headers && resp.headers.length > 0;
217
+ const optOut = resp.headersOptOut;
218
+ if (hasBody || hasHeaders || optOut) {
219
+ lines.push(`${I3}${resp.statusCode}: {`);
220
+ if (hasBody) {
221
+ lines.push(...printContentTypeLine(resp.contentType!, resp.bodyType!, I4));
222
+ }
223
+ if (optOut) {
224
+ lines.push(`${I4}headers: none`);
225
+ } else if (hasHeaders) {
226
+ lines.push(`${I4}headers: {`);
227
+ for (const h of resp.headers!) {
228
+ const opt = h.optional ? '?' : '';
229
+ const trail = h.description ? ` # ${h.description}` : '';
230
+ lines.push(`${I4}${INDENT}${h.name}${opt}: ${printType(h.type)}${trail}`);
231
+ }
232
+ lines.push(`${I4}}`);
233
+ }
234
+ lines.push(`${I3}}`);
235
+ } else {
236
+ lines.push(`${I3}${resp.statusCode}:`);
237
+ }
238
+ }
239
+
240
+ lines.push(`${I2}}`);
241
+ return lines;
242
+ }
@@ -0,0 +1,142 @@
1
+ import type { ContractTypeNode, FieldNode, InlineObjectTypeNode } from '@contractkit/core';
2
+ import { INDENT } from './indent.js';
3
+
4
+ // ─── Type expression printer ────────────────────────────────────────────────
5
+
6
+ export function printType(type: ContractTypeNode): string {
7
+ switch (type.kind) {
8
+ case 'scalar': {
9
+ const constraints: string[] = [];
10
+ if (type.format !== undefined) {
11
+ // Print unquoted when format contains only safe chars; quote otherwise
12
+ const fmt = type.format;
13
+ constraints.push(/^[a-zA-Z0-9\-.:/]+$/.test(fmt) ? fmt : `"${fmt}"`);
14
+ }
15
+ if (type.min !== undefined) constraints.push(`min=${type.min}`);
16
+ if (type.max !== undefined) constraints.push(`max=${type.max}`);
17
+ if (type.len !== undefined) constraints.push(`len=${type.len}`);
18
+ if (type.regex !== undefined) constraints.push(`regex=/${type.regex}/`);
19
+ return constraints.length > 0 ? `${type.name}(${constraints.join(', ')})` : type.name;
20
+ }
21
+ case 'array': {
22
+ const args: string[] = [printType(type.item)];
23
+ if (type.min !== undefined) args.push(`min=${type.min}`);
24
+ if (type.max !== undefined) args.push(`max=${type.max}`);
25
+ return `array(${args.join(', ')})`;
26
+ }
27
+ case 'tuple':
28
+ return `tuple(${type.items.map(printType).join(', ')})`;
29
+ case 'record':
30
+ return `record(${printType(type.key)}, ${printType(type.value)})`;
31
+ case 'enum':
32
+ return `enum(${type.values.join(', ')})`;
33
+ case 'literal':
34
+ return typeof type.value === 'string' ? `literal("${type.value}")` : `literal(${type.value})`;
35
+ case 'union':
36
+ return type.members.map(printType).join(' | ');
37
+ case 'discriminatedUnion':
38
+ return `discriminated(by=${type.discriminator}, ${type.members.map(printType).join(' | ')})`;
39
+ case 'intersection':
40
+ return type.members.map(printType).join(' & ');
41
+ case 'ref':
42
+ return type.name;
43
+ case 'inlineObject':
44
+ return printInlineObjectCompact(type);
45
+ case 'lazy':
46
+ return `lazy(${printType(type.inner)})`;
47
+ }
48
+ }
49
+
50
+ /** Compact single-line form — used when inline object appears nested inside another type. */
51
+ function printInlineObjectCompact(obj: InlineObjectTypeNode): string {
52
+ const prefix = obj.mode ? `mode(${obj.mode}) ` : '';
53
+ if (obj.fields.length === 0) return `${prefix}{}`;
54
+ const parts = obj.fields.map(f => {
55
+ const opt = f.optional ? '?' : '';
56
+ let t = printType(f.type);
57
+ if (f.nullable) t += ' | null';
58
+ return `${f.name}${opt}: ${t}`;
59
+ });
60
+ return `${prefix}{ ${parts.join(', ')} }`;
61
+ }
62
+
63
+ /** Multi-line enum form — one value per line, used when single-line would exceed print width. */
64
+ export function printEnumExpanded(values: string[], indent: string): string {
65
+ const innerIndent = indent + INDENT;
66
+ return `enum(\n${values.map(v => `${innerIndent}${v}`).join(',\n')}\n${indent})`;
67
+ }
68
+
69
+ // ─── Field printer ──────────────────────────────────────────────────────────
70
+
71
+ /** Print a full field declaration, including visibility, default, and inline comment.
72
+ * Modifier order is canonical: override → deprecated → readonly|writeonly → type. */
73
+ export function printField(field: FieldNode, indent: string, printWidth: number = 80): string {
74
+ const opt = field.optional ? '?' : '';
75
+ const ovr = field.override ? 'override ' : '';
76
+ const dep = field.deprecated ? 'deprecated ' : '';
77
+ const vis = field.visibility !== 'normal' ? `${field.visibility} ` : '';
78
+ const mods = `${ovr}${dep}${vis}`;
79
+ const def = field.default !== undefined ? ` = ${formatDefault(field.default)}` : '';
80
+ const comment = field.description ? ` # ${field.description}` : '';
81
+ const innerIndent = indent + INDENT;
82
+
83
+ // Expand inline object types to multi-line — same rule as type aliases.
84
+ // Only when there's no default and no nullable union (those can't split cleanly).
85
+ if (!field.nullable && field.default === undefined) {
86
+ const trailing = extractTrailingInlineObject(field.type);
87
+ if (trailing) {
88
+ const { prefix, inlineObj } = trailing;
89
+ const modePart = inlineObj.mode ? `mode(${inlineObj.mode}) ` : '';
90
+ const header = prefix
91
+ ? `${indent}${field.name}${opt}: ${mods}${prefix} & ${modePart}{${comment}`
92
+ : `${indent}${field.name}${opt}: ${mods}${modePart}{${comment}`;
93
+ return [header, ...printInlineObjectExpanded(inlineObj, innerIndent, printWidth), `${indent}}`].join('\n');
94
+ }
95
+ }
96
+
97
+ let typeStr = printType(field.type);
98
+ if (field.nullable) typeStr += ' | null';
99
+ const fullLine = `${indent}${field.name}${opt}: ${mods}${typeStr}${def}${comment}`;
100
+ if (field.type.kind === 'enum' && !field.nullable && field.default === undefined && fullLine.length > printWidth) {
101
+ const enumStr = printEnumExpanded(field.type.values, indent);
102
+ return `${indent}${field.name}${opt}: ${mods}${enumStr}${comment}`;
103
+ }
104
+ return fullLine;
105
+ }
106
+
107
+ /** Print inline-object fields expanded (used when an inline brace object trails a type alias). */
108
+ export function printInlineObjectExpanded(obj: InlineObjectTypeNode, indent: string, printWidth: number = 80): string[] {
109
+ return obj.fields.map(f => printField(f, indent, printWidth));
110
+ }
111
+
112
+ // ─── Helpers ────────────────────────────────────────────────────────────────
113
+
114
+ /** Format a default value: quote strings that aren't valid bare identifiers. */
115
+ export function formatDefault(val: string | number | boolean): string {
116
+ if (typeof val === 'number' || typeof val === 'boolean') return String(val);
117
+ // If it looks like a bare identifier (enum value, unquoted token), keep it bare.
118
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$\-.]*$/.test(val)) return val;
119
+ return `"${val}"`;
120
+ }
121
+
122
+ /**
123
+ * Detect whether the last member of a type is an inline brace object, and if so
124
+ * return the prefix type string and the inline object for expanded printing.
125
+ * Returns null if the type doesn't end with an inline object.
126
+ */
127
+ export function extractTrailingInlineObject(type: ContractTypeNode): {
128
+ prefix: string | null;
129
+ inlineObj: InlineObjectTypeNode;
130
+ } | null {
131
+ if (type.kind === 'inlineObject') {
132
+ return { prefix: null, inlineObj: type };
133
+ }
134
+ if (type.kind === 'intersection') {
135
+ const last = type.members[type.members.length - 1];
136
+ if (last?.kind === 'inlineObject') {
137
+ const prefixStr = type.members.slice(0, -1).map(printType).join(' & ');
138
+ return { prefix: prefixStr, inlineObj: last };
139
+ }
140
+ }
141
+ return null;
142
+ }