@contractkit/openapi-to-ck 0.9.0 → 0.9.2

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/src/ast-to-ck.ts CHANGED
@@ -16,13 +16,48 @@ import type {
16
16
 
17
17
  const INDENT = ' '; // 4 spaces
18
18
 
19
+ /** Matches a bare identifier that needs no quoting (mirrors serializeDefault). */
20
+ const IDENT_RE = /^[a-zA-Z_$][a-zA-Z0-9_$\-.]*$/;
21
+
22
+ /**
23
+ * Flatten a description to a single line so it is safe to embed in a trailing
24
+ * `# ...` comment. OpenAPI descriptions routinely contain newlines; an embedded
25
+ * newline would terminate the comment and dump the rest as raw `.ck` source.
26
+ */
27
+ function singleLineComment(text: string): string {
28
+ return text.replace(/\s+/g, ' ').trim();
29
+ }
30
+
31
+ /**
32
+ * Serialize an enum value as a `.ck` enum argument. Bare identifiers are emitted
33
+ * as-is; anything else (spaces, punctuation) is wrapped in a string literal.
34
+ * `.ck` string literals have no escape sequences, so when the value contains a
35
+ * double quote we fall back to single quotes (and vice versa).
36
+ */
37
+ function quoteEnumValue(value: string): string {
38
+ if (IDENT_RE.test(value)) return value;
39
+ if (!value.includes('"')) return `"${value}"`;
40
+ if (!value.includes("'")) return `'${value}'`;
41
+ // Value contains both quote styles; `.ck` cannot escape, so keep double
42
+ // quotes and preserve the inner ones as best we can.
43
+ return `"${value}"`;
44
+ }
45
+
19
46
  // ─── Public API ───────────────────────────────────────────────────────────
20
47
 
48
+ /** Options controlling how a {@link CkRootNode} is rendered to `.ck` source. */
21
49
  export interface SerializeOptions {
22
50
  /** Emit descriptions as inline # comments. Default: true. */
23
51
  includeComments?: boolean;
24
52
  }
25
53
 
54
+ /**
55
+ * Serialize a `.ck` AST back to formatted `.ck` source text. Emits the options
56
+ * block first, then every model, then every route, separated by blank lines and
57
+ * terminated with a trailing newline. Descriptions become trailing `# ...`
58
+ * comments unless `options.includeComments` is `false`. The output is designed
59
+ * to re-parse cleanly via `parseCk` (see round-trip tests).
60
+ */
26
61
  export function astToCk(root: CkRootNode, options: SerializeOptions = {}): string {
27
62
  const { includeComments = true } = options;
28
63
  const ctx: Ctx = { includeComments };
@@ -114,7 +149,7 @@ function serializeModel(model: ModelNode, ctx: Ctx): string {
114
149
  }
115
150
 
116
151
  const prefix = prefixes.length > 0 ? prefixes.join(' ') + ' ' : '';
117
- const comment = ctx.includeComments && model.description ? ` # ${model.description}` : '';
152
+ const comment = ctx.includeComments && model.description ? ` # ${singleLineComment(model.description)}` : '';
118
153
 
119
154
  // Type alias: contract Name: typeExpression
120
155
  if (model.type) {
@@ -151,7 +186,7 @@ function serializeField(field: FieldNode, depth: number, ctx: Ctx): string {
151
186
  }
152
187
 
153
188
  const defaultVal = field.default !== undefined ? ` = ${serializeDefault(field.default)}` : '';
154
- const comment = ctx.includeComments && field.description ? ` # ${field.description}` : '';
189
+ const comment = ctx.includeComments && field.description ? ` # ${singleLineComment(field.description)}` : '';
155
190
 
156
191
  return `${indent}${field.name}${optional}: ${deprecated}${visibility}${typeStr}${defaultVal}${comment}`;
157
192
  }
@@ -173,6 +208,12 @@ function serializeDefault(value: string | number | boolean): string {
173
208
 
174
209
  // ─── Types ────────────────────────────────────────────────────────────────
175
210
 
211
+ /**
212
+ * Serialize a single {@link ContractTypeNode} to its inline `.ck` type
213
+ * expression (e.g. `array(User, min=1)`, `string | int`, `enum(asc, desc)`).
214
+ * Recurses through composite types; enum values are quoted as needed via
215
+ * {@link quoteEnumValue}.
216
+ */
176
217
  export function serializeType(type: ContractTypeNode): string {
177
218
  switch (type.kind) {
178
219
  case 'scalar':
@@ -184,7 +225,7 @@ export function serializeType(type: ContractTypeNode): string {
184
225
  case 'record':
185
226
  return `record(${serializeType(type.key)}, ${serializeType(type.value)})`;
186
227
  case 'enum':
187
- return `enum(${type.values.join(', ')})`;
228
+ return `enum(${type.values.map(quoteEnumValue).join(', ')})`;
188
229
  case 'literal':
189
230
  return serializeLiteral(type);
190
231
  case 'union':
@@ -251,7 +292,7 @@ function serializeRoute(route: OpRouteNode, ctx: Ctx): string {
251
292
  const lines: string[] = [];
252
293
 
253
294
  const modStr = serializeModifiers(route.modifiers);
254
- const comment = ctx.includeComments && route.description ? ` # ${route.description}` : '';
295
+ const comment = ctx.includeComments && route.description ? ` # ${singleLineComment(route.description)}` : '';
255
296
  lines.push(`operation${modStr} ${route.path}: {${comment}`);
256
297
 
257
298
  // Route-level params
@@ -276,7 +317,7 @@ function serializeRoute(route: OpRouteNode, ctx: Ctx): string {
276
317
  function serializeOperation(lines: string[], op: OpOperationNode, depth: number, ctx: Ctx): string[] {
277
318
  const indent = INDENT.repeat(depth);
278
319
  const modStr = serializeModifiers(op.modifiers);
279
- const comment = ctx.includeComments && op.description ? ` # ${op.description}` : '';
320
+ const comment = ctx.includeComments && op.description ? ` # ${singleLineComment(op.description)}` : '';
280
321
  lines.push(`${indent}${op.method}${modStr}: {${comment}`);
281
322
 
282
323
  const inner = INDENT.repeat(depth + 1);
@@ -293,7 +334,7 @@ function serializeOperation(lines: string[], op: OpOperationNode, depth: number,
293
334
 
294
335
  // Signature
295
336
  if (op.signature) {
296
- const sigComment = ctx.includeComments && op.signatureDescription ? ` # ${op.signatureDescription}` : '';
337
+ const sigComment = ctx.includeComments && op.signatureDescription ? ` # ${singleLineComment(op.signatureDescription)}` : '';
297
338
  if (op.signaturePolicy) {
298
339
  lines.push(`${inner}signature: {`);
299
340
  lines.push(`${inner} options: ${op.signature}${sigComment}`);
@@ -363,7 +404,7 @@ function serializeParamSource(lines: string[], keyword: string, source: ParamSou
363
404
  typeStr = `${typeStr} | null`;
364
405
  }
365
406
  const defaultVal = param.default !== undefined ? ` = ${serializeDefault(param.default)}` : '';
366
- const comment = ctx.includeComments && param.description ? ` # ${param.description}` : '';
407
+ const comment = ctx.includeComments && param.description ? ` # ${singleLineComment(param.description)}` : '';
367
408
  lines.push(`${INDENT.repeat(depth + 1)}${param.name}${optional}: ${typeStr}${defaultVal}${comment}`);
368
409
  }
369
410
  lines.push(`${indent}}`);
@@ -393,7 +434,7 @@ function serializeResponses(lines: string[], responses: OpResponseNode[], depth:
393
434
  lines.push(`${INDENT.repeat(depth + 2)}headers: {`);
394
435
  for (const h of resp.headers!) {
395
436
  const opt = h.optional ? '?' : '';
396
- const trail = h.description ? ` # ${h.description}` : '';
437
+ const trail = h.description ? ` # ${singleLineComment(h.description)}` : '';
397
438
  lines.push(`${INDENT.repeat(depth + 3)}${h.name}${opt}: ${serializeType(h.type)}${trail}`);
398
439
  }
399
440
  lines.push(`${INDENT.repeat(depth + 2)}}`);
@@ -415,7 +456,7 @@ function serializeSecurityBlock(lines: string[], security: SecurityNode, depth:
415
456
 
416
457
  const sec = security as SecurityFields;
417
458
  if (sec.policy !== undefined) {
418
- const comment = ctx.includeComments && sec.policyDescription ? ` # ${sec.policyDescription}` : '';
459
+ const comment = ctx.includeComments && sec.policyDescription ? ` # ${singleLineComment(sec.policyDescription)}` : '';
419
460
  const value = sec.policy === false ? 'none' : sec.policy;
420
461
  lines.push(`${indent}security: {`);
421
462
  lines.push(`${INDENT.repeat(depth + 1)}policy: ${value}${comment}`);
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
+ import { parseCk, DiagnosticCollector } from '@contractkit/core';
2
3
  import { astToCk, serializeType } from '../src/ast-to-ck.js';
3
4
  import {
4
5
  ckRoot,
@@ -449,3 +450,79 @@ describe('full document', () => {
449
450
  expect(result).toContain(' service: LedgerService.listAccounts');
450
451
  });
451
452
  });
453
+
454
+ // ─── Round-trip regressions ─────────────────────────────────────────────────
455
+
456
+ /** Parse `.ck` source and return true when it re-parses with no errors. */
457
+ function parsesCleanly(source: string): boolean {
458
+ const diag = new DiagnosticCollector();
459
+ parseCk(source, 'roundtrip.ck', diag);
460
+ return !diag.hasErrors();
461
+ }
462
+
463
+ describe('round-trip: multi-line descriptions', () => {
464
+ it('flattens a multi-line model description into a single-line trailing comment', () => {
465
+ const root = ckRoot({
466
+ models: [model('User', [field('id', scalarType('uuid'))], { description: 'A user.\nSpans multiple\nlines.' })],
467
+ });
468
+ const result = astToCk(root);
469
+ expect(result).toContain('contract User: { # A user. Spans multiple lines.');
470
+ // The comment line must not contain an embedded newline that leaks source.
471
+ const commentLine = result.split('\n').find(l => l.includes('contract User'))!;
472
+ expect(commentLine).toBe('contract User: { # A user. Spans multiple lines.');
473
+ });
474
+
475
+ it('flattens a multi-line field description', () => {
476
+ const root = ckRoot({
477
+ models: [model('User', [field('id', scalarType('uuid'), { description: 'The\nuser\nID' })])],
478
+ });
479
+ const result = astToCk(root);
480
+ expect(result).toContain(' id: uuid # The user ID');
481
+ });
482
+
483
+ it('re-parses cleanly with multi-line descriptions on model and field', () => {
484
+ const root = ckRoot({
485
+ models: [
486
+ model(
487
+ 'User',
488
+ [
489
+ field('id', scalarType('uuid'), { visibility: 'readonly', description: 'The user ID.\nGenerated server-side.' }),
490
+ field('name', scalarType('string'), { description: 'Display name.\r\n\r\nCan contain\ttabs.' }),
491
+ ],
492
+ { description: 'A user record.\nMultiple paragraphs here.\n\nEven blank lines.' },
493
+ ),
494
+ ],
495
+ });
496
+ const result = astToCk(root);
497
+ expect(parsesCleanly(result)).toBe(true);
498
+ });
499
+ });
500
+
501
+ describe('round-trip: enum values needing quotes', () => {
502
+ it('quotes enum values that contain spaces', () => {
503
+ expect(serializeType(enumType('Not Started', 'In Progress', 'Done'))).toBe('enum("Not Started", "In Progress", Done)');
504
+ });
505
+
506
+ it('leaves bare-identifier enum values unquoted', () => {
507
+ expect(serializeType(enumType('asc', 'desc'))).toBe('enum(asc, desc)');
508
+ });
509
+
510
+ it('falls back to single quotes when the value contains a double quote', () => {
511
+ expect(serializeType(enumType('a "quoted" value'))).toBe(`enum('a "quoted" value')`);
512
+ });
513
+
514
+ it('keeps double quotes when the value contains both quote styles', () => {
515
+ // `.ck` string literals have no escape sequences, so a value with both
516
+ // quote styles cannot round-trip; the serializer keeps double quotes.
517
+ expect(serializeType(enumType(`a "b" 'c'`))).toBe(`enum("a "b" 'c'")`);
518
+ });
519
+
520
+ it('re-parses cleanly with space-containing enum values', () => {
521
+ const root = ckRoot({
522
+ models: [model('Task', [field('status', enumType('Not Started', 'in progress', 'Done'))])],
523
+ });
524
+ const result = astToCk(root);
525
+ expect(result).toContain('status: enum("Not Started", "in progress", Done)');
526
+ expect(parsesCleanly(result)).toBe(true);
527
+ });
528
+ });
package/coverage/base.css DELETED
@@ -1,224 +0,0 @@
1
- body, html {
2
- margin:0; padding: 0;
3
- height: 100%;
4
- }
5
- body {
6
- font-family: Helvetica Neue, Helvetica, Arial;
7
- font-size: 14px;
8
- color:#333;
9
- }
10
- .small { font-size: 12px; }
11
- *, *:after, *:before {
12
- -webkit-box-sizing:border-box;
13
- -moz-box-sizing:border-box;
14
- box-sizing:border-box;
15
- }
16
- h1 { font-size: 20px; margin: 0;}
17
- h2 { font-size: 14px; }
18
- pre {
19
- font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace;
20
- margin: 0;
21
- padding: 0;
22
- -moz-tab-size: 2;
23
- -o-tab-size: 2;
24
- tab-size: 2;
25
- }
26
- a { color:#0074D9; text-decoration:none; }
27
- a:hover { text-decoration:underline; }
28
- .strong { font-weight: bold; }
29
- .space-top1 { padding: 10px 0 0 0; }
30
- .pad2y { padding: 20px 0; }
31
- .pad1y { padding: 10px 0; }
32
- .pad2x { padding: 0 20px; }
33
- .pad2 { padding: 20px; }
34
- .pad1 { padding: 10px; }
35
- .space-left2 { padding-left:55px; }
36
- .space-right2 { padding-right:20px; }
37
- .center { text-align:center; }
38
- .clearfix { display:block; }
39
- .clearfix:after {
40
- content:'';
41
- display:block;
42
- height:0;
43
- clear:both;
44
- visibility:hidden;
45
- }
46
- .fl { float: left; }
47
- @media only screen and (max-width:640px) {
48
- .col3 { width:100%; max-width:100%; }
49
- .hide-mobile { display:none!important; }
50
- }
51
-
52
- .quiet {
53
- color: #7f7f7f;
54
- color: rgba(0,0,0,0.5);
55
- }
56
- .quiet a { opacity: 0.7; }
57
-
58
- .fraction {
59
- font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
60
- font-size: 10px;
61
- color: #555;
62
- background: #E8E8E8;
63
- padding: 4px 5px;
64
- border-radius: 3px;
65
- vertical-align: middle;
66
- }
67
-
68
- div.path a:link, div.path a:visited { color: #333; }
69
- table.coverage {
70
- border-collapse: collapse;
71
- margin: 10px 0 0 0;
72
- padding: 0;
73
- }
74
-
75
- table.coverage td {
76
- margin: 0;
77
- padding: 0;
78
- vertical-align: top;
79
- }
80
- table.coverage td.line-count {
81
- text-align: right;
82
- padding: 0 5px 0 20px;
83
- }
84
- table.coverage td.line-coverage {
85
- text-align: right;
86
- padding-right: 10px;
87
- min-width:20px;
88
- }
89
-
90
- table.coverage td span.cline-any {
91
- display: inline-block;
92
- padding: 0 5px;
93
- width: 100%;
94
- }
95
- .missing-if-branch {
96
- display: inline-block;
97
- margin-right: 5px;
98
- border-radius: 3px;
99
- position: relative;
100
- padding: 0 4px;
101
- background: #333;
102
- color: yellow;
103
- }
104
-
105
- .skip-if-branch {
106
- display: none;
107
- margin-right: 10px;
108
- position: relative;
109
- padding: 0 4px;
110
- background: #ccc;
111
- color: white;
112
- }
113
- .missing-if-branch .typ, .skip-if-branch .typ {
114
- color: inherit !important;
115
- }
116
- .coverage-summary {
117
- border-collapse: collapse;
118
- width: 100%;
119
- }
120
- .coverage-summary tr { border-bottom: 1px solid #bbb; }
121
- .keyline-all { border: 1px solid #ddd; }
122
- .coverage-summary td, .coverage-summary th { padding: 10px; }
123
- .coverage-summary tbody { border: 1px solid #bbb; }
124
- .coverage-summary td { border-right: 1px solid #bbb; }
125
- .coverage-summary td:last-child { border-right: none; }
126
- .coverage-summary th {
127
- text-align: left;
128
- font-weight: normal;
129
- white-space: nowrap;
130
- }
131
- .coverage-summary th.file { border-right: none !important; }
132
- .coverage-summary th.pct { }
133
- .coverage-summary th.pic,
134
- .coverage-summary th.abs,
135
- .coverage-summary td.pct,
136
- .coverage-summary td.abs { text-align: right; }
137
- .coverage-summary td.file { white-space: nowrap; }
138
- .coverage-summary td.pic { min-width: 120px !important; }
139
- .coverage-summary tfoot td { }
140
-
141
- .coverage-summary .sorter {
142
- height: 10px;
143
- width: 7px;
144
- display: inline-block;
145
- margin-left: 0.5em;
146
- background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent;
147
- }
148
- .coverage-summary .sorted .sorter {
149
- background-position: 0 -20px;
150
- }
151
- .coverage-summary .sorted-desc .sorter {
152
- background-position: 0 -10px;
153
- }
154
- .status-line { height: 10px; }
155
- /* yellow */
156
- .cbranch-no { background: yellow !important; color: #111; }
157
- /* dark red */
158
- .red.solid, .status-line.low, .low .cover-fill { background:#C21F39 }
159
- .low .chart { border:1px solid #C21F39 }
160
- .highlighted,
161
- .highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{
162
- background: #C21F39 !important;
163
- }
164
- /* medium red */
165
- .cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE }
166
- /* light red */
167
- .low, .cline-no { background:#FCE1E5 }
168
- /* light green */
169
- .high, .cline-yes { background:rgb(230,245,208) }
170
- /* medium green */
171
- .cstat-yes { background:rgb(161,215,106) }
172
- /* dark green */
173
- .status-line.high, .high .cover-fill { background:rgb(77,146,33) }
174
- .high .chart { border:1px solid rgb(77,146,33) }
175
- /* dark yellow (gold) */
176
- .status-line.medium, .medium .cover-fill { background: #f9cd0b; }
177
- .medium .chart { border:1px solid #f9cd0b; }
178
- /* light yellow */
179
- .medium { background: #fff4c2; }
180
-
181
- .cstat-skip { background: #ddd; color: #111; }
182
- .fstat-skip { background: #ddd; color: #111 !important; }
183
- .cbranch-skip { background: #ddd !important; color: #111; }
184
-
185
- span.cline-neutral { background: #eaeaea; }
186
-
187
- .coverage-summary td.empty {
188
- opacity: .5;
189
- padding-top: 4px;
190
- padding-bottom: 4px;
191
- line-height: 1;
192
- color: #888;
193
- }
194
-
195
- .cover-fill, .cover-empty {
196
- display:inline-block;
197
- height: 12px;
198
- }
199
- .chart {
200
- line-height: 0;
201
- }
202
- .cover-empty {
203
- background: white;
204
- }
205
- .cover-full {
206
- border-right: none !important;
207
- }
208
- pre.prettyprint {
209
- border: none !important;
210
- padding: 0 !important;
211
- margin: 0 !important;
212
- }
213
- .com { color: #999 !important; }
214
- .ignore-none { color: #999; font-weight: normal; }
215
-
216
- .wrapper {
217
- min-height: 100%;
218
- height: auto !important;
219
- height: 100%;
220
- margin: 0 auto -48px;
221
- }
222
- .footer, .push {
223
- height: 48px;
224
- }
@@ -1,87 +0,0 @@
1
- /* eslint-disable */
2
- var jumpToCode = (function init() {
3
- // Classes of code we would like to highlight in the file view
4
- var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no'];
5
-
6
- // Elements to highlight in the file listing view
7
- var fileListingElements = ['td.pct.low'];
8
-
9
- // We don't want to select elements that are direct descendants of another match
10
- var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > `
11
-
12
- // Selector that finds elements on the page to which we can jump
13
- var selector =
14
- fileListingElements.join(', ') +
15
- ', ' +
16
- notSelector +
17
- missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b`
18
-
19
- // The NodeList of matching elements
20
- var missingCoverageElements = document.querySelectorAll(selector);
21
-
22
- var currentIndex;
23
-
24
- function toggleClass(index) {
25
- missingCoverageElements
26
- .item(currentIndex)
27
- .classList.remove('highlighted');
28
- missingCoverageElements.item(index).classList.add('highlighted');
29
- }
30
-
31
- function makeCurrent(index) {
32
- toggleClass(index);
33
- currentIndex = index;
34
- missingCoverageElements.item(index).scrollIntoView({
35
- behavior: 'smooth',
36
- block: 'center',
37
- inline: 'center'
38
- });
39
- }
40
-
41
- function goToPrevious() {
42
- var nextIndex = 0;
43
- if (typeof currentIndex !== 'number' || currentIndex === 0) {
44
- nextIndex = missingCoverageElements.length - 1;
45
- } else if (missingCoverageElements.length > 1) {
46
- nextIndex = currentIndex - 1;
47
- }
48
-
49
- makeCurrent(nextIndex);
50
- }
51
-
52
- function goToNext() {
53
- var nextIndex = 0;
54
-
55
- if (
56
- typeof currentIndex === 'number' &&
57
- currentIndex < missingCoverageElements.length - 1
58
- ) {
59
- nextIndex = currentIndex + 1;
60
- }
61
-
62
- makeCurrent(nextIndex);
63
- }
64
-
65
- return function jump(event) {
66
- if (
67
- document.getElementById('fileSearch') === document.activeElement &&
68
- document.activeElement != null
69
- ) {
70
- // if we're currently focused on the search input, we don't want to navigate
71
- return;
72
- }
73
-
74
- switch (event.which) {
75
- case 78: // n
76
- case 74: // j
77
- goToNext();
78
- break;
79
- case 66: // b
80
- case 75: // k
81
- case 80: // p
82
- goToPrevious();
83
- break;
84
- }
85
- };
86
- })();
87
- window.addEventListener('keydown', jumpToCode);