Skip to content

Commit 28452c6

Browse files
Omar Qureshiclaude
andcommitted
feat(ruby): emit YARD documentation from jsii docs
The Ruby target was the only one emitting no documentation. Generated code now carries YARD comments sourced from the assembly: - summary and remarks as free text on classes, modules, structs, enums and their members; - @PARAM tags (with Ruby-mapped types, ', nil' for optionals, Array<T> for variadics and per-parameter summaries) on methods, constructors and struct initializers — struct members double as constructor kwargs, so each attr_reader also gets its own summary/remarks/@return/@note-Default block; - @return with mapped type and returns-doc on methods ([void] when none), and bare typed @return on property getters; - @deprecated (with reason), @see, @example (indented block) and @note Default where present. Type mapping: primitives to String/Numeric/Boolean/DateTime/Hash, named types to their full Ruby constant path, collections to Array<T> / Hash{String => T}, unions to comma lists. Text interpolated into single-line tags is newline-collapsed (inlineDoc) — a multi-line `returns` doc was leaking its continuation lines out of the comment and into code (caught by a YARD parse of the generated output). Verified: ruby -c passes on all regenerated fixture gems; yard stats parses jsii-calc.rb warning-free at 61% documented (the remainder is generated internals and doc-less fixture types). Full suite: 240 examples, 0 failures; compliance report unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 43da3e6 commit 28452c6

1 file changed

Lines changed: 198 additions & 0 deletions

File tree

  • packages/jsii-pacmak/lib/targets

packages/jsii-pacmak/lib/targets/ruby.ts

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,165 @@ export class RubyGenerator extends Generator {
200200
return !!(type?.isInterfaceType() && type?.isDataType());
201201
}
202202

203+
/**
204+
* Extract the raw `spec.Docs` from either shape we hold: jsii-reflect
205+
* objects wrap it under `.spec.docs`; plain spec objects (enum members,
206+
* initializer parameters) carry `.docs` directly.
207+
*/
208+
private rawDocs(obj: any): spec.Docs | undefined {
209+
return obj?.spec?.docs ?? obj?.docs;
210+
}
211+
212+
/**
213+
* Render a jsii type reference as a YARD type string for `@param` /
214+
* `@return` tags.
215+
*/
216+
private rubyDocType(ref: spec.TypeReference | undefined): string {
217+
if (!ref) {
218+
return 'Object';
219+
}
220+
if (spec.isPrimitiveTypeReference(ref)) {
221+
switch (ref.primitive) {
222+
case spec.PrimitiveType.String:
223+
return 'String';
224+
case spec.PrimitiveType.Number:
225+
return 'Numeric';
226+
case spec.PrimitiveType.Boolean:
227+
return 'Boolean';
228+
case spec.PrimitiveType.Date:
229+
return 'DateTime';
230+
case spec.PrimitiveType.Json:
231+
return 'Hash';
232+
default:
233+
return 'Object';
234+
}
235+
}
236+
if (spec.isNamedTypeReference(ref)) {
237+
return this.rubyFullTypeName(ref.fqn);
238+
}
239+
if (spec.isCollectionTypeReference(ref)) {
240+
const elem = this.rubyDocType(ref.collection.elementtype);
241+
return ref.collection.kind === spec.CollectionKind.Array
242+
? `Array<${elem}>`
243+
: `Hash{String => ${elem}}`;
244+
}
245+
if (spec.isUnionTypeReference(ref)) {
246+
return ref.union.types.map((t) => this.rubyDocType(t)).join(', ');
247+
}
248+
return 'Object';
249+
}
250+
251+
/**
252+
* Emit a block of text as `#`-prefixed comment lines.
253+
*/
254+
private emitDocLines(text: string): void {
255+
for (const line of text.split('\n')) {
256+
const trimmed = line.trimEnd();
257+
this.code.line(trimmed === '' ? '#' : `# ${trimmed}`);
258+
}
259+
}
260+
261+
/**
262+
* Collapse text for interpolation into a single-line YARD tag. Doc text
263+
* (e.g. `docs.returns`) may contain newlines, which would leak subsequent
264+
* lines out of the comment and into generated code.
265+
*/
266+
private inlineDoc(text: string): string {
267+
return text
268+
.split('\n')
269+
.map((l) => l.trim())
270+
.filter((l) => l !== '')
271+
.join(' ');
272+
}
273+
274+
/**
275+
* Emit a YARD documentation comment from jsii docs: summary and remarks
276+
* as free text, followed by `@param` / `@return` / `@deprecated` /
277+
* `@see` / `@example` tags as applicable. Silently emits nothing when
278+
* there are no docs and no tags to write.
279+
*/
280+
private emitDocs(
281+
docsSource: any,
282+
opts: {
283+
/** Parameters (raw spec or reflect) to render as @param tags. */
284+
params?: any[];
285+
/** The method's raw `returns` OptionalValue; pass with isMethod. */
286+
returns?: any;
287+
/** Emit `@return [void]` when a method declares no return type. */
288+
isMethod?: boolean;
289+
/** Property getter: emit an @return of the property's type. */
290+
propertyType?: any;
291+
propertyOptional?: boolean;
292+
} = {},
293+
): void {
294+
const docs: spec.Docs = this.rawDocs(docsSource) ?? {};
295+
const tags: string[] = [];
296+
297+
for (const p of opts.params ?? []) {
298+
const pDocs: spec.Docs = this.rawDocs(p) ?? {};
299+
const baseType = this.rubyDocType(this.typeRefSpec(p.type));
300+
const rendered = p.variadic
301+
? `Array<${baseType}>`
302+
: `${baseType}${p.optional ? ', nil' : ''}`;
303+
const summary = pDocs.summary ? ` ${this.inlineDoc(pDocs.summary)}` : '';
304+
tags.push(`# @param ${this.rubyName(p.name)} [${rendered}]${summary}`);
305+
}
306+
307+
if (opts.returns?.type) {
308+
const t = this.rubyDocType(this.typeRefSpec(opts.returns.type));
309+
const optional = opts.returns.optional ? ', nil' : '';
310+
const text = docs.returns ? ` ${this.inlineDoc(docs.returns)}` : '';
311+
tags.push(`# @return [${t}${optional}]${text}`);
312+
} else if (opts.isMethod) {
313+
tags.push('# @return [void]');
314+
} else if (opts.propertyType) {
315+
const t = this.rubyDocType(this.typeRefSpec(opts.propertyType));
316+
tags.push(`# @return [${t}${opts.propertyOptional ? ', nil' : ''}]`);
317+
}
318+
319+
if (docs.default !== undefined) {
320+
tags.push(`# @note Default: ${this.inlineDoc(docs.default)}`);
321+
}
322+
if (docs.deprecated !== undefined) {
323+
const reason =
324+
typeof docs.deprecated === 'string'
325+
? ` ${this.inlineDoc(docs.deprecated)}`
326+
: '';
327+
tags.push(`# @deprecated${reason}`);
328+
}
329+
if (docs.see) {
330+
tags.push(`# @see ${this.inlineDoc(docs.see)}`);
331+
}
332+
333+
const exampleLines = docs.example
334+
? ['# @example', ...docs.example.split('\n').map((l) => `# ${l.trimEnd()}`.trimEnd())]
335+
: [];
336+
337+
const hasText = !!(docs.summary || docs.remarks);
338+
if (!hasText && tags.length === 0 && exampleLines.length === 0) {
339+
return;
340+
}
341+
342+
if (docs.summary) {
343+
this.emitDocLines(docs.summary);
344+
}
345+
if (docs.remarks) {
346+
this.code.line('#');
347+
this.emitDocLines(docs.remarks);
348+
}
349+
if (tags.length > 0 || exampleLines.length > 0) {
350+
if (hasText) {
351+
this.code.line('#');
352+
}
353+
for (const tag of tags) {
354+
this.code.line(tag);
355+
}
356+
for (const line of exampleLines) {
357+
this.code.line(line);
358+
}
359+
}
360+
}
361+
203362
public async save(outdir: string, tarball: string, legalese: Legalese) {
204363
const assembly = this.reflectAssembly;
205364

@@ -366,8 +525,10 @@ export class RubyGenerator extends Generator {
366525
(m: any) => this.rubyConstName(m.name),
367526
typeSpec.fqn,
368527
);
528+
this.emitDocs(typeSpec);
369529
this.code.open(`module ${prefix}${this.rubyModuleName(typeSpec.name)}`);
370530
for (const member of resolvedMembers) {
531+
this.emitDocs(member);
371532
this.code.line(
372533
`${this.rubyConstName(member.name)} = Jsii::Enum.new("${rubyDq(typeSpec.fqn)}", "${rubyDq(member.name)}")`,
373534
);
@@ -410,6 +571,7 @@ export class RubyGenerator extends Generator {
410571
? ' < Jsii::Struct'
411572
: '';
412573

574+
this.emitDocs(typeSpec);
413575
this.code.open(`${kind} ${prefix}${rubyName}${baseString}`);
414576

415577
if (!typeSpec.datatype) {
@@ -438,6 +600,9 @@ export class RubyGenerator extends Generator {
438600
})
439601
.join(', ');
440602

603+
// Struct members double as constructor keyword arguments — document
604+
// them as @params (each carries its own summary/type/optionality).
605+
this.emitDocs(undefined, { params: props });
441606
this.code.open(`def initialize(${initArgs})`);
442607
for (const prop of props) {
443608
const rubyName = this.rubyName(prop.name);
@@ -455,6 +620,10 @@ export class RubyGenerator extends Generator {
455620
this.code.line('');
456621

457622
for (const prop of props) {
623+
this.emitDocs(prop, {
624+
propertyType: prop.type,
625+
propertyOptional: prop.optional,
626+
});
458627
this.code.line(`attr_reader :${this.rubyName(prop.name)}`);
459628
}
460629
this.code.line('');
@@ -487,6 +656,10 @@ export class RubyGenerator extends Generator {
487656
} else {
488657
for (const prop of resolvedAllProperties) {
489658
const propRubyName = this.rubyName(prop.name);
659+
this.emitDocs(prop, {
660+
propertyType: prop.type,
661+
propertyOptional: prop.optional,
662+
});
490663
this.code.open(`def ${propRubyName}()`);
491664
this.code.line(`jsii_get_property("${rubyDq(prop.name)}")`);
492665
this.code.close(`end`);
@@ -518,6 +691,11 @@ export class RubyGenerator extends Generator {
518691
return rubyParam;
519692
})
520693
.join(', ');
694+
this.emitDocs(method, {
695+
params: method.parameters,
696+
returns: method.spec?.returns,
697+
isMethod: true,
698+
});
521699
this.code.open(`def ${this.rubyName(method.name)}(${sigParams})`);
522700
for (const p of method.parameters) {
523701
const rubyParam = this.rubyName(p.name);
@@ -593,6 +771,7 @@ export class RubyGenerator extends Generator {
593771
(i: any) => `::${this.rubyFullTypeName(i)}`,
594772
);
595773

774+
this.emitDocs(typeSpec);
596775
this.code.open(`class ${prefix}${rubyName} < ${baseClass}`);
597776

598777
for (const mixin of interfaceMixins) {
@@ -618,6 +797,7 @@ export class RubyGenerator extends Generator {
618797
})
619798
.join(', ');
620799

800+
this.emitDocs(initializer, { params: initializer.parameters });
621801
this.code.open(`def initialize(${initParams})`);
622802
for (const p of initializer.parameters) {
623803
const rubyParam = this.rubyName(p.name);
@@ -721,6 +901,11 @@ export class RubyGenerator extends Generator {
721901
})
722902
.join(', ');
723903

904+
this.emitDocs(method, {
905+
params: method.parameters,
906+
returns: method.spec?.returns,
907+
isMethod: true,
908+
});
724909
this.code.open(`def self.${this.rubyMethodName(method)}(${sigParams})`);
725910
for (const p of method.parameters) {
726911
const rubyParam = this.rubyName(p.name);
@@ -745,6 +930,10 @@ export class RubyGenerator extends Generator {
745930
const rubyName = this.rubyPropertyName(prop);
746931

747932
if (prop.static) {
933+
this.emitDocs(prop, {
934+
propertyType: prop.type,
935+
propertyOptional: prop.optional,
936+
});
748937
this.code.open(`def self.${rubyName}()`);
749938
this.code.line(
750939
`Jsii::Kernel.instance.get_static("${rubyDq(typeSpec.fqn)}", "${rubyDq(prop.name)}")`,
@@ -765,6 +954,10 @@ export class RubyGenerator extends Generator {
765954
this.code.line('');
766955
}
767956
} else {
957+
this.emitDocs(prop, {
958+
propertyType: prop.type,
959+
propertyOptional: prop.optional,
960+
});
768961
this.code.open(`def ${rubyName}()`);
769962
this.code.line(`jsii_get_property("${rubyDq(prop.name)}")`);
770963
this.code.close(`end`);
@@ -802,6 +995,11 @@ export class RubyGenerator extends Generator {
802995
})
803996
.join(', ');
804997

998+
this.emitDocs(method, {
999+
params: method.parameters,
1000+
returns: method.spec?.returns,
1001+
isMethod: true,
1002+
});
8051003
this.code.open(`def ${this.rubyMethodName(method)}(${sigParams})`);
8061004
for (const p of method.parameters) {
8071005
const rubyParam = this.rubyName(p.name);

0 commit comments

Comments
 (0)