diff --git a/RUBY_LANGUAGE_NOTES.md b/RUBY_LANGUAGE_NOTES.md new file mode 100644 index 000000000..67f149b99 --- /dev/null +++ b/RUBY_LANGUAGE_NOTES.md @@ -0,0 +1,134 @@ +# Ruby language notes + +This document describes how jsii-rosetta renders TypeScript example snippets into +Ruby, and how that rendering differs from the other target languages +(Python, Java, C#, Go). It is intended for maintainers reviewing or extending the +Ruby visitor (`src/languages/ruby.ts`). + +## Scope: what this is (and isn't) + +jsii-rosetta translates the **example code** embedded in API documentation from +TypeScript into each target language. The Ruby support added here is exactly that: +a source-to-source renderer for doc snippets. + +It is **not** a full jsii Ruby target. jsii itself has no official Ruby code +generator (jsii-pacmak) or runtime, so: + +- Published `.jsii` assemblies do **not** carry a `targets.ruby` block. The + resolver (`findRubyName`) reads `targets.ruby.module` / `targets.ruby.acronyms` + when present, but in practice always falls back to heuristic name generation + (`rubyModuleName`). Generated module names are therefore best-effort guesses, + not authoritative. +- The translated Ruby is meant to read naturally for documentation; it is not + guaranteed to be runnable against any real Ruby gem. + +## Naming conventions + +| TypeScript | Ruby | Notes | +|---|---|---| +| `myMethod`, `myProp`, local vars | `my_method`, `my_prop` | camelCase → snake_case (`toSnakeCase`) | +| `MyClass`, type/enum names | `MyClass` | PascalCase preserved | +| package / namespace / submodule | `JsiiCalc::Submodule` | PascalCased module path (`rubyModuleName`) | +| reserved words (`end`, `class`, `send`, …) | `_end`, `_class`, `_send` | escaped with a leading underscore (`RUBY_RESERVED_NAMES`) | +| acronyms (`vpc`, `s3`, `iam`, …) | `VPC`, `S3`, `IAM` | restored from `CDK_ACRONYMS` / assembly `targets.ruby.acronyms` | + +## Structural mappings + +- **Imports** → `require` (package deps) / `require_relative` (relative paths); + scoped names are de-scoped (`@scope/pkg` → `scope-pkg`). +- **`class X extends Y`** → `class X < Y`. +- **`implements I`** → `include I` (behavioral interfaces become Ruby modules). +- **`interface` (data struct)** → omitted; jsii structs are plain Ruby Hashes. +- **`interface` (behavioral)** → `module ... end` with method definitions. +- **`readonly` property** → `attr_reader`; otherwise `attr_accessor`; + `private` members are prefixed with `private`. +- **Constructor** → `def initialize ... end`. +- **Struct values** → Hash literals with symbol keys (`{ deletion_window_in_days: 7 }`). +- **Struct property access** → Hash lookup (`props[:prop1]`). +- **`this.x`** (field read or write) → `@x`; **`this.method(...)`** → + `self.method(...)`; bare **`this`** → `self`. +- **Strings** → double-quoted; multi-line strings use heredocs (`<<-'HERE'`); + template literals use `"#{...}"` interpolation. +- **Builtins** → `console.log` → `puts`, `console.error` → `STDERR.puts`, + `Math.random` → `rand`. +- **`null` / `undefined`** → `nil`. +- **`a instanceof B`** → `a.is_a?(B)`. +- **Optional chaining `a?.b`** → safe navigation `a&.b`. +- **Operators** → `===`/`!==` → `==`/`!=`; `??` → `||`; `??=` → `||=`; + `++`/`--` (prefix and postfix) → `+= 1` / `-= 1`. + +## Differences from the other target languages + +### Submodule access + +Ruby is registered as `supportsTransitiveSubmoduleAccess = true` +(`src/languages/target-language.ts`), like Python and C#. The snippet's import +shape is preserved (a single `require`), and namespace-traversing accesses are +kept inline. Unlike Python, however, Ruby has no import aliasing +(`import x as y`), so an alias such as `calc.submodule.MyClass` is resolved to the +**real** module path `JsiiCalc::Submodule::MyClass` rather than the alias. + +### Constructs Ruby renders that the others cannot + +The following are translated **only** by the Ruby visitor. In Python/Java/C#/Go +they currently produce a broken `(SpreadElement …)` / `(SpreadAssignment …)` +placeholder plus an error diagnostic, because those visitors do not implement +them: + +| TypeScript | Ruby | Other languages | +|---|---|---| +| `a !== b` | `a != b` | invalid `a !== b` passed through | +| `a ?? b` | `a \|\| b` | invalid `a ?? b` + error | +| `foo(...items)` | `foo(*items)` | `(SpreadElement …)` placeholder + error | +| `{ ...opts, b: 2 }` | `{ **opts, b: 2 }` | `(SpreadAssignment …)` placeholder + error | +| `i++` / `++i` | `i += 1` | invalid `i++` passed through + error / invalid `++i` | +| `i--` / `--i` | `i -= 1` | invalid `i--` passed through + error / invalid `--i` | +| `expr === other` | `expr == other` | (also handled by the base visitor) | + +These cases are covered by Ruby-only test fixtures under `test/translations/` +(`expressions/strict_inequality_and_nullish`, `calls/spread_arguments`, +`expressions/object_spread`, `expressions/increment_decrement`). We deliberately +do **not** add `.py`/`.java`/`.cs`/`.go` fixtures for them, since that would +codify the broken output as "expected". (The non-Ruby fallback for ternaries +and `++`/`--` is asserted by unit tests in `test/languages/default.test.ts` +instead.) + +Note: `PostfixUnaryExpression` (`i++` / `i--`) and `ConditionalExpression` +(ternaries) were previously not dispatched by the renderer at all. Supporting +them required a small shared change — `renderer.ts` (dispatch + `AstHandler`), +default handlers in `default.ts` that report the node as unsupported and then +fall back to the renderer's raw-source passthrough (via +`AstRenderer.renderUnsupported`, exactly what undispatched nodes got), and the +pass-throughs in `visualize.ts`. Other languages therefore keep their existing +"unsupported" behaviour; only Ruby translates them. + +### Object-literal diagnostics + +The base visitor reports *every* non-standard object-literal member +(spreads, methods, getters/setters) as unsupported. Ruby overrides +`objectLiteralExpression` to **allow object spreads** through without an error +(since it renders them as `**expr`), while **still** reporting methods, +getters, and setters — which Ruby cannot translate to valid syntax — and +keeping the "you cannot instantiate an interface with an object literal" check. + +## Known limitations / not yet handled + +These TypeScript constructs are not specifically handled by the Ruby visitor and +fall back to the renderer's best-effort raw-text passthrough (the same limitation +shared by the other language visitors): + +- `while`, classic `for (;;)`, `switch`, object/array destructuring (including + destructuring arrow parameters), `typeof`, `delete`, and `enum` + *declarations*. (In practice the example corpus iterates with `for...of`, + which **is** handled — `xs.each do |x| … end`. The ternary `?:` is also + handled — Ruby's syntax is identical. Arrow and function expressions with + simple parameters **are** handled: they render as Ruby lambdas — + `(bell) => bell.ring()` becomes `->(bell) { bell.ring }` — and the output is + runnable, because the Ruby runtime coerces Procs into single-method + interface implementations at jsii call sites.) + +The exported helpers `toSnakeCase` and `rubyModuleName` carry the trickiest logic +(acronyms, scoped packages, reserved-word escaping) and have dedicated unit tests +in `test/ruby.test.ts`. The internal `findRubyName` (assembly-aware name +resolution) is not exported and remains covered indirectly through the +translation fixtures (`imports/submodule-import`). diff --git a/src/languages/default.ts b/src/languages/default.ts index b3c6d35ab..456a5158b 100644 --- a/src/languages/default.ts +++ b/src/languages/default.ts @@ -88,6 +88,36 @@ export abstract class DefaultVisitor implements AstHandler { return UNARY_OPS[operator]; } + public postfixUnaryExpression(node: ts.PostfixUnaryExpression, context: AstRenderer): OTree { + // The only postfix unary operators are `++`/`--`, which most target languages + // cannot express. Report them as unsupported but keep the original source text, + // like nodes without a typed dispatch case; languages that can translate them + // (e.g. Ruby's `+= 1`/`-= 1`) override this. + return this.unsupported(node, context); + } + + public conditionalExpression(node: ts.ConditionalExpression, context: AstRenderer): OTree { + // Ternaries don't translate uniformly across languages (e.g. Python uses a + // different word order). Report as unsupported but keep the original source text, + // like nodes without a typed dispatch case; languages whose syntax matches + // (e.g. Ruby) override this. + return this.unsupported(node, context); + } + + public arrowFunction(node: ts.ArrowFunction, context: AstRenderer): OTree { + // Function values don't translate uniformly — most target languages model + // jsii callbacks as interface implementations, not bare functions. Report + // as unsupported by default (raw source text in best-effort mode, exactly + // as before this handler existed); languages with a natural lambda form + // (e.g. Ruby) override this. + return this.unsupported(node, context); + } + + public functionExpression(node: ts.FunctionExpression, context: AstRenderer): OTree { + // See arrowFunction. + return this.unsupported(node, context); + } + public translateBinaryOperator(operator: string) { if (operator === '===') { return '=='; @@ -361,6 +391,15 @@ export abstract class DefaultVisitor implements AstHandler { context.reportUnsupported(node, this.language); return nimpl(node, context); } + + /** + * Report the node as unsupported, but render it the way the renderer treats + * nodes without a typed dispatch case: raw source text in best-effort mode + * (the default), an UnknownSyntax placeholder otherwise. + */ + private unsupported(node: ts.Node, context: AstRenderer) { + return context.renderUnsupported(node, this.language); + } } const UNARY_OPS: { [op in ts.PrefixUnaryOperator]: string } = { @@ -388,7 +427,7 @@ const UNARY_OPS: { [op in ts.PrefixUnaryOperator]: string } = { * Array.isArray; // <- function type * ``` */ -function isExpressionOfFunctionType(typeChecker: ts.TypeChecker, expr: ts.Expression) { +export function isExpressionOfFunctionType(typeChecker: ts.TypeChecker, expr: ts.Expression) { const type = typeChecker.getTypeAtLocation(expr).getNonNullableType(); return type.getCallSignatures().length > 0; } diff --git a/src/languages/index.ts b/src/languages/index.ts index be7599e45..35f4b9392 100644 --- a/src/languages/index.ts +++ b/src/languages/index.ts @@ -2,6 +2,7 @@ import { CSharpVisitor } from './csharp'; import { GoVisitor } from './go'; import { JavaVisitor } from './java'; import { PythonVisitor } from './python'; +import { RubyVisitor } from './ruby'; import { TargetLanguage } from './target-language'; import { VisualizeAstVisitor } from './visualize'; import { AstHandler } from '../renderer'; @@ -30,6 +31,10 @@ export const TARGET_LANGUAGES: { [key in TargetLanguage]: VisitorFactory } = { version: GoVisitor.VERSION, createVisitor: () => new GoVisitor(), }, + [TargetLanguage.RUBY]: { + version: RubyVisitor.VERSION, + createVisitor: () => new RubyVisitor(), + }, }; export function getVisitorFromLanguage(language: string | undefined) { diff --git a/src/languages/ruby.ts b/src/languages/ruby.ts new file mode 100644 index 000000000..6224bb327 --- /dev/null +++ b/src/languages/ruby.ts @@ -0,0 +1,1138 @@ +import * as ts from 'typescript'; +import { DefaultVisitor, isExpressionOfFunctionType } from './default'; +import { TargetLanguage } from './target-language'; +import { analyzeObjectLiteral, ObjectLiteralStruct } from '../jsii/jsii-types'; +import { + analyzeStructType, + lookupJsiiSymbolFromNode, + isJsiiProtocolType, + JsiiSymbol, + simpleName, + namespaceName, +} from '../jsii/jsii-utils'; +import { jsiiTargetParameter } from '../jsii/packages'; +import { NO_SYNTAX, OTree } from '../o-tree'; +import { AstRenderer, CommentSyntax } from '../renderer'; +import { SubmoduleReference } from '../submodule-reference'; +import { stripCommentMarkers, voidExpressionString, matchAst, nodeOfType } from '../typescript/ast-utils'; +import { ImportStatement } from '../typescript/imports'; +import { + isEnumAccess, + isStaticReadonlyAccess, + parameterAcceptsUndefined, + inferredTypeOfExpression, +} from '../typescript/types'; + +// Ruby keywords and standard reserved names. Since Rosetta translates code snippets +// directly without dynamic target configurations, we use this hardcoded set to escape +// identifiers (e.g. by prefixing with an underscore) to avoid syntax errors in the output. +const RUBY_RESERVED_NAMES = new Set([ + 'BEGIN', + 'END', + 'alias', + 'and', + 'begin', + 'break', + 'case', + 'class', + 'def', + 'defined?', + 'do', + 'else', + 'elsif', + 'end', + 'ensure', + 'false', + 'for', + 'if', + 'in', + 'module', + 'next', + 'nil', + 'not', + 'or', + 'redo', + 'rescue', + 'retry', + 'return', + 'self', + 'super', + 'then', + 'true', + 'undef', + 'unless', + 'until', + 'when', + 'while', + 'yield', + 'send', + '__send__', +]); + +function toPascalCase(str: string) { + return str.replace(/(^|[^a-zA-Z0-9]+)([a-zA-Z0-9])/g, (_, _sep, char) => char.toUpperCase()); +} + +/** + * Escapes a raw string for embedding inside a Ruby double-quoted string. Uses JSON's escaping + * for quotes, backslashes and control characters, then additionally neutralises Ruby string + * interpolation sequences (`#{`, `#@`, `#$`) so literal `#`-sequences are not evaluated. + * Returns the escaped inner content only (without the surrounding quotes). + */ +function rubyDoubleQuotedInner(text: string): string { + return JSON.stringify(text) + .slice(1, -1) + .replace(/#(?=[{@$])/g, '\\#'); +} + +/** + * Escapes a literal chunk of a template literal for embedding inside a Ruby double-quoted + * (interpolating) string. Escapes backslashes, double quotes and Ruby interpolation sequences + * (`#{`, `#@`, `#$`), but deliberately PRESERVES newlines and other whitespace so multi-line + * template literals stay multi-line in the output. + */ +function escapeRubyTemplateText(text: string): string { + return text + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/#(?=[{@$])/g, '\\#'); +} + +/** + * Converts a camelCase identifier (e.g. property or method name) to ruby-style snake_case. + * Assumes class names (starting with an uppercase letter followed by lowercase) should not be converted. + * Escapes any Ruby keywords/reserved names by prefixing them with an underscore. + */ +export function toSnakeCase(camel: string) { + if (/^[A-Z][A-Z0-9_]*$/.test(camel)) { + // Looks like SCREAMING_SNAKE_CASE (a constant, e.g. `FOO_BAR`), leave it untouched. + return camel; + } + if (camel.match(/^[A-Z][a-z]/)) { + // Looks like PascalCase, probably a class name, don't snake_case + return camel; + } + const snake = camel + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2') + .toLowerCase(); + return RUBY_RESERVED_NAMES.has(snake) ? `_${snake}` : snake; +} + +/** + * Formats an assembly, package, or namespace segment name into a PascalCased Ruby module name. + * Handles scoped packages (@scope/name -> Scope::Name), hyphens (jsii-calc -> JsiiCalc), + * and capitalizes standard/dynamic acronyms while respecting word boundary rules. + * + * @param acronyms the acronyms to recognise, from the referenced assembly's + * `targets.ruby.acronyms`. The assembly config is the single source of truth + * for acronym casing (it is compiler-validated, library-specific data); + * rosetta deliberately carries no built-in list — a snippet translated + * without assembly info simply gets plain PascalCase. + */ +export function rubyModuleName(name: string, acronyms: string[] = []): string { + if (name.startsWith('@')) { + const parts = name.slice(1).split('/'); + return parts.map((p) => rubyModuleName(p, acronyms)).join('::'); + } + if (name.includes('-')) { + const parts = name.split('-'); + return parts.map((p) => rubyModuleName(p, acronyms)).join(''); + } + const sanitized = name.replace(/[^a-zA-Z0-9_]/g, ''); + let pascal = sanitized.charAt(0) === sanitized.charAt(0).toUpperCase() ? sanitized : toPascalCase(sanitized); + + const allAcronyms = [...new Set(acronyms)]; + // Restore uppercase casing to the caller-declared acronyms in the PascalCase string. + // We use word-boundary and next-character checks to avoid uppercase conversion inside unrelated + // words (e.g., capitalizing 'SI' inside 'Simple'). + for (const acronym of allAcronyms) { + const regex = new RegExp(`(${acronym})`, 'ig'); + pascal = pascal.replace(regex, (match, _p1, offset) => { + if (match[0] !== match[0].toUpperCase()) return match; + const nextChar = pascal[offset + match.length]; + if (nextChar) { + const isValid = + /^[A-Z0-9]$/.test(nextChar) || + (nextChar === 's' && + (!pascal[offset + match.length + 1] || /^[A-Z0-9]$/.test(pascal[offset + match.length + 1]))); + if (!isValid) return match; + } + return acronym; + }); + } + return pascal; +} + +/** + * Best-effort Ruby module name for a jsii module FQN (e.g. `aws-cdk-lib.aws_s3`) when no + * assembly is loaded. The core CDK library configures its Ruby names explicitly in + * `.jsiirc.json` (`aws-cdk-lib` -> `AWSCDK`, `aws-s3` -> `S3`, dropping the redundant + * service-level `aws` prefix); that config is unavailable without the assemblies, so the + * dominant case is mirrored here to keep snippet namespaces aligned with the compiled gems. + * + * NOTE: this special-casing is the one remaining piece of CDK-specific knowledge in this + * visitor, and it only affects snippets whose type references cannot be resolved to an + * assembly at all. The structural fix is for callers (which hold the assembly) to supply + * naming config for unresolved references; until that API exists, this guess keeps + * non-compiling README snippets readable. + */ +export function guessRubyModuleName(fqn: string): string { + const [packageName, ...submodulePath] = fqn.split('.'); + const isCoreCdk = packageName === 'aws-cdk-lib'; + const root = isCoreCdk ? 'AWSCDK' : rubyModuleName(packageName); + const segments = submodulePath.map((s) => rubyModuleName(isCoreCdk ? s.replace(/^aws[-_]/, '') : s)); + return [root, ...segments].join('::'); +} + +/** + * Recursively resolves the fully-qualified Ruby name of a TS module, class, or type. + * Inspects the associated JSII assembly target metadata for explicit module configuration + * (e.g. `ruby.module`) and package-specific acronyms to output accurate namespaces. + */ +function findRubyName(jsiiSymbol: JsiiSymbol): string | undefined { + if (!jsiiSymbol.sourceAssembly?.assembly) { + // Don't have accurate info, just guess from the FQN + return jsiiSymbol.symbolType !== 'module' ? simpleName(jsiiSymbol.fqn) : guessRubyModuleName(jsiiSymbol.fqn); + } + + const asm = jsiiSymbol.sourceAssembly.assembly; + + // Collect acronyms from the assembly targets + const acronyms: string[] = asm.targets?.ruby?.acronyms ?? []; + + return recurse(jsiiSymbol.fqn); + + function recurse(fqn: string): string { + const baseFqn = fqn.split('#')[0]; + if (baseFqn === asm.name) { + return jsiiTargetParameter(asm, 'ruby.module') ?? rubyModuleName(baseFqn, acronyms); + } + if (asm.submodules?.[baseFqn]) { + const modName = jsiiTargetParameter(asm.submodules[baseFqn], 'ruby.module'); + if (modName) { + return modName; + } + } + + const ns = namespaceName(baseFqn); + const nsRubyName = recurse(ns); + const leaf = simpleName(baseFqn); + return `${nsRubyName}::${rubyModuleName(leaf, acronyms)}`; + } +} + +/** + * Maps common JavaScript/TypeScript builtin functions to their native Ruby equivalents. + */ +const BUILTIN_FUNCTIONS: { [key: string]: string } = { + 'console.log': 'puts', + 'console.error': 'STDERR.puts', + 'Math.random': 'rand', +}; + +// Unlike Python (which requires complex state variables to handle struct parameter/argument explosion, +// keyword argument rendering, key-mangling suppression, and parent method name resolution), Ruby's syntax +// maps closely to TypeScript's behavioral forms (like hashes and blocks) without structural rewriting. +// Therefore, the Ruby context only needs to track class boundaries and type expressions. +interface RubyLanguageContext { + inClass?: boolean; + inTypeExpression?: boolean; +} +type RubyVisitorContext = AstRenderer; + +export class RubyVisitor extends DefaultVisitor { + public static readonly VERSION = '1'; + + public readonly language = TargetLanguage.RUBY; + public readonly defaultContext = {}; + protected override statementTerminator = ''; + + /** + * `require` statements already emitted for the source file currently being rendered. + * + * Distinct import statements can resolve to the same gem (e.g. two submodule imports + * of `aws-cdk-lib`), and repeating the `require` would be noise in the translation. + * Reset per source file (in `sourceFile`) because a single visitor instance may render + * multiple snippets (e.g. `translateMarkdown` reuses one visitor for a whole document). + */ + private readonly emittedRequires = new Set(); + + public constructor() { + super(); + } + + public mergeContext(old: RubyLanguageContext, update: Partial) { + return Object.assign({}, old, update); + } + + public override sourceFile(node: ts.SourceFile, context: RubyVisitorContext): OTree { + this.emittedRequires.clear(); + return super.sourceFile(node, context); + } + + /** + * Translates TypeScript import statements to Ruby `require` or `require_relative` statements. + * Maps relative paths to `require_relative` and package dependencies to `require` with scoped + * names converted to standard gem naming format (e.g. @scope/pkg -> scope-pkg). + */ + public override importStatement(node: ImportStatement, _context: RubyVisitorContext): OTree { + if (node.packageName.startsWith('.')) { + return this.renderRequire(`require_relative '${node.packageName}'`); + } + // The specifier may address a submodule (e.g. `aws-cdk-lib/aws-s3tables`), but the + // gem is the npm *package* — the submodule is autoloaded from it, there is no + // per-submodule require. Keep the package name only: two segments for a scoped + // package (`@scope/name`), one otherwise. So `aws-cdk-lib/aws-s3tables` -> the + // `aws-cdk-lib` gem, not the non-existent `aws-cdk-lib-aws-s3tables`. + const parts = node.packageName.split('/'); + const pkg = node.packageName.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]; + const gemName = pkg.replace(/^@/, '').replace(/\//g, '-'); + return this.renderRequire(`require '${gemName}'`); + } + + /** + * Renders a `require`/`require_relative` line, deduplicating repeats within a source file. + * + * A duplicate renders as an empty OTree *without* `canBreakLine`: the renderer only + * attaches leading trivia (the preceding newline) to trees that can break the line, + * so the duplicate disappears without leaving a blank line behind. + */ + private renderRequire(requireLine: string): OTree { + if (this.emittedRequires.has(requireLine)) { + return new OTree([]); + } + this.emittedRequires.add(requireLine); + return new OTree([requireLine], [], { canBreakLine: true }); + } + + /** + * Translates variable declarations (e.g. `const x = ...` or `let y: string[]`) to Ruby. + * Since Ruby is dynamically typed, we initialize variables with their initial value, + * or fallback to `[]`, `{}`, or `nil` based on the TypeScript type annotation. + */ + public override variableDeclaration(node: ts.VariableDeclaration, context: RubyVisitorContext): OTree { + if (node.initializer) { + return new OTree([context.convert(node.name), ' = ', context.convert(node.initializer)]); + } + if (node.type && ts.isArrayTypeNode(node.type)) { + return new OTree([context.convert(node.name), ' = []']); + } + if (node.type && ts.isTypeLiteralNode(node.type)) { + return new OTree([context.convert(node.name), ' = {}']); + } + // An uninitialised declaration (`declare const bucket: s3.IBucket`) is a "given" — + // something the reader supplies. Ruby has no type annotations, so keep the type as a + // trailing comment, e.g. `bucket = nil # AWSCDK::S3::IBucket`, instead of dropping it. + if (node.type && ts.isTypeReferenceNode(node.type)) { + const sym = lookupJsiiSymbolFromNode(context.typeChecker, node.type.typeName); + const rubyName = sym ? findRubyName(sym) : undefined; + if (rubyName) { + return new OTree([context.convert(node.name), ` = nil # ${rubyName}`]); + } + } + return new OTree([context.convert(node.name), ' = nil']); + } + + /** + * Translates a list of variable declarations (e.g. in multi-variable definitions) + * into newline-separated Ruby variable assignments. + */ + public override variableDeclarationList(node: ts.VariableDeclarationList, context: RubyVisitorContext): OTree { + return new OTree(context.convertAll(node.declarations), [], { separator: '\n' }); + } + + public get name() { + return 'Ruby'; + } + + /** + * Translates TypeScript comments (single-line and multi-line) into Ruby hash comments (`#`). + * Strips out specific TS-only directives like `@import`, and formats `@param` tags + * into YARD-style documentation (e.g. `@param name [RubyType]`). + */ + public override commentRange(comment: CommentSyntax, _context: RubyVisitorContext): OTree { + const commentText = stripCommentMarkers(comment.text, comment.kind === ts.SyntaxKind.MultiLineCommentTrivia); + const lines = commentText.split('\n'); + const filteredLines = lines.filter((l) => !l.trim().startsWith('@import')); + + if (filteredLines.length === 0 || (filteredLines.length === 1 && filteredLines[0].trim() === '')) { + return new OTree([]); + } + + const hashLines = filteredLines + .map((l) => { + let text = l; + if (text.includes('@param')) { + text = text.replace(/@param\s+\{([^}]+)\}\s+(\w+)/g, (_, type, name) => { + const rubyType = type + .split('.') + .map((p: string) => rubyModuleName(p)) + .join('::'); + return `@param ${name} [${rubyType}]`; + }); + } + return `# ${text}`.trimEnd(); + }) + .join('\n'); + + const needsAdditionalTrailer = comment.hasTrailingNewLine; + + return new OTree([comment.isTrailing ? ' ' : '', hashLines, needsAdditionalTrailer ? '\n' : ''], [], { + // Make sure comment is rendered exactly once in the output tree, no + // matter how many source nodes it is attached to. + renderOnce: `comment-${comment.pos}`, + }); + } + + /** + * Translates TypeScript void expressions (such as `void ...` placeholders in test snippets) + * into Ruby comment placeholders (`# ...`) or standard ellipsis (`...`). + */ + public override maskingVoidExpression(node: ts.VoidExpression, _context: RubyVisitorContext): OTree { + const arg = voidExpressionString(node); + if (arg === 'block') { + return new OTree(['# ...'], [], { canBreakLine: true }); + } + if (arg === '...') { + return new OTree(['...']); + } + return NO_SYNTAX; + } + + /** + * Translates property access expressions (e.g., `obj.prop` or `Namespace.Constant`). + * Handles mappings for builtin functions, enum variant accesses, submodule namespace navigation + * using `::`, class instantiations, `this` property mapping to instance variables (`@name`), + * and conversion of struct property access to hash lookups (`obj[:prop]`). + */ + public override propertyAccessExpression( + node: ts.PropertyAccessExpression, + context: RubyVisitorContext, + submoduleReference: SubmoduleReference | undefined, + ): OTree { + const fullText = context.textOf(node); + if (fullText in BUILTIN_FUNCTIONS) { + return new OTree([BUILTIN_FUNCTIONS[fullText]]); + } + + if (isEnumAccess(context.typeChecker, node)) { + return new OTree([context.convert(node.expression), '::', toSnakeCase(node.name.text).toUpperCase()]); + } + + // Static readonly (const) property access — the "enum-like class" pattern + // (`BlockPublicAccess.BLOCK_ALL`, `Runtime.RUBY_4_0`). Unlike enum members + // (`::`), pacmak exposes these as class methods accessed with `.`, and the + // member keeps its constant casing (matching pacmak's `rubyConstName`). + // Without this, the access falls into the type-reference branch below and the + // member is dropped, leaving just `AWSCDK::S3::BlockPublicAccess`. + if (isStaticReadonlyAccess(context.typeChecker, node)) { + return new OTree([context.convert(node.expression), '.', toSnakeCase(node.name.text).toUpperCase()]); + } + + const nameText = node.name.text; + const isPascalCase = /^[A-Z]/.test(nameText); + const inTypeExpr = context.currentContext.inTypeExpression || isPascalCase; + + if (submoduleReference != null || inTypeExpr) { + const jsiiSym = lookupJsiiSymbolFromNode(context.typeChecker, node); + if (jsiiSym) { + const rubyName = findRubyName(jsiiSym); + if (rubyName) { + return new OTree([rubyName]); + } + } + + let exprNode = context.updateContext({ inTypeExpression: inTypeExpr }).convert(node.expression); + if (inTypeExpr && ts.isIdentifier(node.expression)) { + exprNode = new OTree([rubyModuleName(context.textOf(node.expression))]); + } + + let nameNode = context.convert(node.name); + if (inTypeExpr) { + nameNode = new OTree([rubyModuleName(nameText)]); + } + + return new OTree([exprNode, '::', nameNode]); + } + + if (context.textOf(node.expression) === 'this') { + // `this.member` maps to the instance variable `@member` for field reads and + // writes alike — this is robust even for private fields that have no accessor. + // Method invocations (`this.method(...)`) are the exception: they fall through + // to the `self.method` call form rendered below. + const isMethodCall = ts.isCallExpression(node.parent) && node.parent.expression === node; + if (!isMethodCall) { + return new OTree(['@', toSnakeCase(node.name.text)]); + } + } + + const exprType = context.typeOfExpression(node.expression); + if (exprType && analyzeStructType(context.typeChecker, exprType) !== false) { + return new OTree([context.convert(node.expression), '[:', toSnakeCase(node.name.text), ']']); + } + + // Preserve optional chaining (`a?.b`) using Ruby's safe-navigation operator (`a&.b`). + const accessor = node.questionDotToken ? '&.' : '.'; + return new OTree([context.convert(node.expression), accessor, toSnakeCase(node.name.text)]); + } + + /** + * Translates binary expressions, mapping TypeScript operators that have no + * direct Ruby equivalent. Strict (in)equality (`===`/`!==`) collapse to Ruby's + * `==`/`!=`, and nullish coalescing (`??`) becomes `||`. Unlike the default + * visitor we do not report `??` as unsupported, since we render it faithfully. + */ + public override binaryExpression(node: ts.BinaryExpression, context: RubyVisitorContext): OTree { + // `a instanceof B` has no operator form in Ruby; use the `is_a?` predicate. + if (node.operatorToken.kind === ts.SyntaxKind.InstanceOfKeyword) { + return new OTree([context.convert(node.left), '.is_a?(', context.convert(node.right), ')']); + } + const operator = this.translateBinaryOperator(context.textOf(node.operatorToken)); + return new OTree([context.convert(node.left), ' ', operator, ' ', context.convert(node.right)]); + } + + public override translateBinaryOperator(operator: string) { + switch (operator) { + case '===': + return '=='; + case '!==': + return '!='; + case '??': + return '||'; + case '??=': + return '||='; + default: + return operator; + } + } + + /** + * Translates prefix unary expressions. Ruby has no `++`/`--` operators, so we + * rewrite them to the equivalent compound assignment (`i += 1` / `i -= 1`); + * all other unary operators (`-`, `!`, `~`, `+`) pass through unchanged. + */ + public override prefixUnaryExpression(node: ts.PrefixUnaryExpression, context: RubyVisitorContext): OTree { + if (node.operator === ts.SyntaxKind.PlusPlusToken) { + return new OTree([context.convert(node.operand), ' += 1']); + } + if (node.operator === ts.SyntaxKind.MinusMinusToken) { + return new OTree([context.convert(node.operand), ' -= 1']); + } + return super.prefixUnaryExpression(node, context); + } + + /** + * Translates postfix unary expressions (`i++` / `i--`) to the equivalent Ruby + * compound assignment (`i += 1` / `i -= 1`). The pre/post distinction is not + * preserved, which is correct in statement position (the common case in + * examples) and only lossy in the rare event the value is used inline. + */ + public override postfixUnaryExpression(node: ts.PostfixUnaryExpression, context: RubyVisitorContext): OTree { + const op = node.operator === ts.SyntaxKind.PlusPlusToken ? ' += 1' : ' -= 1'; + return new OTree([context.convert(node.operand), op]); + } + + /** + * Translates a ternary (`cond ? a : b`). Ruby's conditional expression uses the + * identical syntax, so this is a direct rendering. + */ + public override conditionalExpression(node: ts.ConditionalExpression, context: RubyVisitorContext): OTree { + return new OTree([ + context.convert(node.condition), + ' ? ', + context.convert(node.whenTrue), + ' : ', + context.convert(node.whenFalse), + ]); + } + + /** + * Translates TypeScript type assertions (`expr as Type`) to Ruby. A TS `as` cast is a + * compile-time assertion with no runtime effect, so the expression passes through unchanged + * (matching the Python/Java visitors). We deliberately do NOT emit `.to_i`/`.to_s`, which + * would be actual runtime conversions and change semantics. + */ + public override asExpression(node: ts.AsExpression, context: RubyVisitorContext): OTree { + return context.convert(node.expression); + } + + /** + * Translates call expressions (method and function invocations) to Ruby. + * Formats arguments separated by commas, wrapped in parentheses if arguments exist. + */ + public override callExpression(node: ts.CallExpression, context: RubyVisitorContext): OTree { + const args = + node.arguments.length > 0 + ? new OTree(['('], context.convertAll(node.arguments), { separator: ', ', suffix: ')' }) + : // A bare `super` in Ruby forwards ALL of the enclosing method's arguments, whereas + // TypeScript `super()` calls with none. Emit explicit empty parens to preserve that. + node.expression.kind === ts.SyntaxKind.SuperKeyword + ? new OTree(['()']) + : new OTree([]); + return new OTree([context.convert(node.expression), args]); + } + + /** + * Translates identifiers (variable, function, parameter names). + * Converts local/method names to snake_case, checks reserved keywords, and resolves + * fully-qualified Ruby names for known type/class symbols using JSII metadata. + */ + public override identifier(node: ts.Identifier, context: RubyVisitorContext): OTree { + const text = node.text; + // `undefined` is a global identifier in TS/JS (not a keyword); map it to Ruby's `nil`. + if (text === 'undefined') { + return new OTree(['nil']); + } + if (text.match(/^[_a-z]/)) { + return new OTree([toSnakeCase(text)]); + } + + const jsiiSym = lookupJsiiSymbolFromNode(context.typeChecker, node); + if (jsiiSym) { + const rubyName = findRubyName(jsiiSym); + if (rubyName) { + return new OTree([rubyName]); + } + } + + return new OTree([text]); + } + + /** + * Translates `new ClassName(...)` instantiations to Ruby `ClassName.new(...)`. + */ + public override newExpression(node: ts.NewExpression, context: RubyVisitorContext): OTree { + const args = + node.arguments && node.arguments.length > 0 + ? new OTree(['('], context.convertAll(node.arguments), { separator: ', ', suffix: ')' }) + : new OTree([]); + return new OTree([context.convert(node.expression), '.new', args], [], { canBreakLine: true }); + } + + /** + * Dispatches object literals to the appropriate Hash renderer. + * + * This mirrors the default visitor's reporting, with one deliberate exception: + * object spreads (`...expr`) are NOT reported as unsupported, because Ruby + * renders them faithfully as `**expr` (see `spreadAssignment`). Other + * non-standard members (methods, getters/setters) are still reported, since we + * cannot translate those to valid Ruby. + */ + public override objectLiteralExpression(node: ts.ObjectLiteralExpression, context: RubyVisitorContext): OTree { + const unsupported = node.properties.filter( + (p) => !ts.isPropertyAssignment(p) && !ts.isShorthandPropertyAssignment(p) && !ts.isSpreadAssignment(p), + ); + for (const unsup of unsupported) { + context.report(unsup, `Use of ${ts.SyntaxKind[unsup.kind]} in an object literal is not supported.`); + } + + const anyMembersFunctions = node.properties.some((p) => + ts.isPropertyAssignment(p) + ? isExpressionOfFunctionType(context.typeChecker, p.initializer) + : ts.isShorthandPropertyAssignment(p) + ? isExpressionOfFunctionType(context.typeChecker, p.name) + : false, + ); + + const inferredType = inferredTypeOfExpression(context.typeChecker, node); + if ((inferredType && isJsiiProtocolType(context.typeChecker, inferredType)) || anyMembersFunctions) { + context.report( + node, + `You cannot use an object literal to make an instance of an interface. Define a class instead.`, + ); + } + + const lit = analyzeObjectLiteral(context.typeChecker, node); + + switch (lit.kind) { + case 'unknown': + return this.unknownTypeObjectLiteralExpression(node, context); + case 'struct': + case 'local-struct': + return this.knownStructObjectLiteralExpression(node, lit, context); + case 'map': + return this.keyValueObjectLiteralExpression(node, context); + } + } + + /** + * Translates object literals with unknown types to Ruby Hash literals. + */ + public override unknownTypeObjectLiteralExpression( + node: ts.ObjectLiteralExpression, + context: RubyVisitorContext, + ): OTree { + return this.renderObjectLiteralExpression(node, context); + } + + /** + * Translates object literals matching a known JSII struct type to Ruby Hash literals. + */ + public override knownStructObjectLiteralExpression( + node: ts.ObjectLiteralExpression, + _structType: ObjectLiteralStruct, + context: RubyVisitorContext, + ): OTree { + return this.renderObjectLiteralExpression(node, context); + } + + /** + * Translates key-value object literals to Ruby Hash literals. + */ + public override keyValueObjectLiteralExpression( + node: ts.ObjectLiteralExpression, + context: RubyVisitorContext, + ): OTree { + return this.renderObjectLiteralExpression(node, context); + } + + /** + * Helper that renders a TypeScript object literal expression as a Ruby Hash literal. + * Properly indents multi-line hashes and formats empty hashes as `{}`. + */ + private renderObjectLiteralExpression(node: ts.ObjectLiteralExpression, context: RubyVisitorContext): OTree { + if (node.properties.length === 0) return new OTree(['{}']); + // Same normalisation as arrays: a multi-line hash puts every property on its own + // line, so a property following a multi-line value (e.g. `bucket:` after a broken + // `InputFormat.csv({...})`) no longer gets stranded on the value's closing line. + const multiline = context.textOf(node).includes('\n'); + return new OTree(['{'], context.convertAll(node.properties), { + suffix: '}', + separator: multiline ? ',' : ', ', + trailingSeparator: multiline, + indent: 4, + }); + } + + /** + * Translates object property assignments. + * Uses rocket syntax (`key => value`) for string/computed keys and symbol colon syntax (`key: value`) + * for standard identifiers. + */ + public override propertyAssignment(node: ts.PropertyAssignment, context: RubyVisitorContext): OTree { + if (ts.isStringLiteral(node.name) || ts.isComputedPropertyName(node.name)) { + return new OTree([context.convert(node.name), ' => ', context.convert(node.initializer)], [], { + canBreakLine: true, + }); + } else { + return new OTree([context.convert(node.name), ': ', context.convert(node.initializer)], [], { + canBreakLine: true, + }); + } + } + + /** + * Translates shorthand property assignments (e.g. `{ prop }`) to Ruby syntax (`prop: prop`). + */ + public override shorthandPropertyAssignment( + node: ts.ShorthandPropertyAssignment, + context: RubyVisitorContext, + ): OTree { + return new OTree([toSnakeCase(node.name.text), ': ', context.convert(node.name)]); + } + + /** + * Translates TypeScript array literals (`[...]`) to Ruby array literals. + */ + public override arrayLiteralExpression(node: ts.ArrayLiteralExpression, context: RubyVisitorContext): OTree { + if (node.elements.length === 0) return new OTree(['[]']); + // Normalise the layout instead of mirroring the source's line breaks faithfully: + // if the literal spans multiple lines, put *every* element on its own line (and the + // `]` on its own line). Otherwise keep it inline. Mirroring produced inconsistent + // output — elements sharing a line while `]` dropped to a line of its own. + const multiline = context.textOf(node).includes('\n'); + return new OTree(['['], context.convertAll(node.elements), { + suffix: ']', + separator: multiline ? ',' : ', ', + trailingSeparator: multiline, + indent: 4, + }); + } + + /** + * Translates an array/argument spread (`...arr`) to Ruby's splat operator (`*arr`). + */ + public override spreadElement(node: ts.SpreadElement, context: RubyVisitorContext): OTree { + return new OTree(['*', context.convert(node.expression)]); + } + + /** + * Translates an object spread (`{ ...opts }`) to Ruby's double-splat operator (`**opts`). + */ + public override spreadAssignment(node: ts.SpreadAssignment, context: RubyVisitorContext): OTree { + return new OTree(['**', context.convert(node.expression)]); + } + + /** + * Translates class method declarations to Ruby method definitions (`def ... end`). + */ + public override methodDeclaration(node: ts.MethodDeclaration, context: RubyVisitorContext): OTree { + return this.functionLike(node, context); + } + + /** + * Translates top-level function declarations to Ruby method definitions. + */ + public override functionDeclaration(node: ts.FunctionDeclaration, context: RubyVisitorContext): OTree { + return this.functionLike(node, context); + } + + /** + * Translates constructor declarations to the Ruby initializer method (`def initialize ... end`). + */ + public override constructorDeclaration(node: ts.ConstructorDeclaration, context: RubyVisitorContext): OTree { + return this.functionLike(node, context, { isConstructor: true }); + } + + /** + * Common helper for translating methods, functions, and constructors. + * Maps parameters and formats the block body with correct indentation and Ruby `def`/`end` boundaries. + */ + public functionLike( + node: ts.FunctionLikeDeclarationBase | ts.MethodSignature, + context: RubyVisitorContext, + opts: { isConstructor?: boolean } = {}, + ): OTree { + const isStatic = + !opts.isConstructor && + ((node as ts.MethodDeclaration).modifiers?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) ?? false); + const baseName = node.name ? toSnakeCase(context.textOf(node.name)) : 'anonymous'; + const methodName = opts.isConstructor ? 'initialize' : isStatic ? `self.${baseName}` : baseName; + const paramDecls = context.convertAll(node.parameters); + + const signature = paramDecls.length > 0 ? ['(', new OTree([], paramDecls, { separator: ', ' }), ')'] : []; + + const bodyNode = (node as any).body; + const bodyChildren = bodyNode ? [context.convert(bodyNode)] : []; + + return new OTree(['def ', methodName, ...signature], bodyChildren, { + canBreakLine: true, + suffix: '\nend', + }); + } + + /** + * Translates parameter declarations in function/method signatures. + * Handles default initializers, rest parameters (`*args`), and nullable parameters + * initialized to `nil`. + */ + public override parameterDeclaration(node: ts.ParameterDeclaration, context: RubyVisitorContext): OTree { + const name = toSnakeCase(context.textOf(node.name)); + const prefix = node.dotDotDotToken ? '*' : ''; + if (node.initializer) { + return new OTree([prefix, name, ' = ', context.convert(node.initializer)]); + } + + const type = node.type && context.typeOfType(node.type); + if (parameterAcceptsUndefined(node, type)) { + return new OTree([prefix, name, ' = nil']); + } + return new OTree([prefix, name]); + } + + /** + * Translates syntax tokens. Maps TypeScript `this` keywords to Ruby `self`. + */ + public override token(node: ts.Token, context: RubyVisitorContext): OTree { + const text = context.textOf(node); + if (text === 'this') { + return new OTree(['self']); + } + if (text === 'null') { + return new OTree(['nil']); + } + return super.token(node, context); + } + + /** + * Translates TypeScript class declarations to Ruby classes. + * Handles single inheritance (`< ParentClass`), maps interface implementations to module includes + * (`include InterfaceModule`), and converts class members. + */ + public override classDeclaration(node: ts.ClassDeclaration, context: RubyVisitorContext): OTree { + // Separate extends from implements + const extendsClauses: ts.ExpressionWithTypeArguments[] = []; + const implementsClauses: ts.ExpressionWithTypeArguments[] = []; + for (const clause of node.heritageClauses ?? []) { + if (clause.token === ts.SyntaxKind.ExtendsKeyword) { + extendsClauses.push(...clause.types); + } else if (clause.token === ts.SyntaxKind.ImplementsKeyword) { + implementsClauses.push(...clause.types); + } + } + + const extendsExpr = + extendsClauses.length > 0 + ? context.updateContext({ inTypeExpression: true }).convert(extendsClauses[0].expression) + : undefined; + + // In Ruby, implements becomes `include ModuleName` + const includes = implementsClauses.map( + (t) => new OTree(['\ninclude ', context.updateContext({ inTypeExpression: true }).convert(t.expression)]), + ); + + const members = context.updateContext({ inClass: true }).convertAll(node.members); + + return new OTree( + [ + 'class ', + node.name ? toPascalCase(context.textOf(node.name)) : '???', + extendsExpr ? ' < ' : '', + ...(extendsExpr ? [extendsExpr] : []), + ], + [...includes, ...members], + { + indent: 2, + canBreakLine: true, + suffix: '\nend', + }, + ); + } + + /** + * Translates TypeScript property declarations to Ruby attribute macros. + * Maps read-only properties to `attr_reader`, read-write to `attr_accessor`, and private fields + * using the `private` keyword prefix. + */ + public override propertyDeclaration(node: ts.PropertyDeclaration, context: RubyVisitorContext): OTree { + const isStatic = node.modifiers?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) ?? false; + const isPrivate = node.modifiers?.some((m) => m.kind === ts.SyntaxKind.PrivateKeyword) ?? false; + const isReadonly = node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ReadonlyKeyword) ?? false; + + // A `static` field is a class-level value; render it as a Ruby constant (`NAME = value`), + // preserving its initializer, rather than an instance attribute macro. Ruby constants must + // begin with an uppercase letter. + if (isStatic) { + const rawName = node.name.getText(); + const constName = rawName.charAt(0).toUpperCase() + rawName.slice(1); + const value = node.initializer ? context.convert(node.initializer) : new OTree(['nil']); + return new OTree([`${constName} = `, value], [], { canBreakLine: true }); + } + + const attrMethod = isReadonly ? 'attr_reader' : 'attr_accessor'; + const attrName = toSnakeCase(node.name.getText()); + const attrLine = `${attrMethod} :${attrName}`; + + if (isPrivate) { + return new OTree([`private ${attrLine}`], [], { canBreakLine: true }); + } + return new OTree([attrLine], [], { canBreakLine: true }); + } + + /** + * Translates TypeScript behavioral interface declarations to Ruby modules containing the member definitions. + */ + public override regularInterfaceDeclaration(node: ts.InterfaceDeclaration, context: RubyVisitorContext): OTree { + const members = context.updateContext({ inClass: true }).convertAll(node.members); + return new OTree(['module ', node.name ? toPascalCase(context.textOf(node.name)) : '???'], members, { + indent: 2, + canBreakLine: true, + suffix: '\nend', + }); + } + + /** + * Translates TypeScript data-only struct interfaces. + * In Ruby, structs are represented as plain Hash objects, so we skip module generation. + */ + public override structInterfaceDeclaration(_node: ts.InterfaceDeclaration, _context: RubyVisitorContext): OTree { + // In ruby, structs are just Hashes. Skip generation. + return new OTree([]); + } + + /** + * Translates method signatures inside interfaces to Ruby method definitions. + */ + public override methodSignature(node: ts.MethodSignature, context: RubyVisitorContext): OTree { + return this.functionLike(node, context); + } + + /** + * Translates property signatures inside interfaces to Ruby attribute macros. + */ + public override propertySignature(node: ts.PropertySignature, context: RubyVisitorContext): OTree { + return this.propertyDeclaration(node as unknown as ts.PropertyDeclaration, context); + } + + /** + * Translates template expressions (string interpolation) to double-quoted Ruby strings with `#{}` blocks. + */ + public override templateExpression(node: ts.TemplateExpression, context: RubyVisitorContext): OTree { + const elements = [new OTree(['"', escapeRubyTemplateText(node.head.text)])]; + for (const span of node.templateSpans) { + elements.push(new OTree(['#{', context.convert(span.expression), '}', escapeRubyTemplateText(span.literal.text)])); + } + elements.push(new OTree(['"'])); + return new OTree(elements); + } + + /** + * Translates non-interpolated template literals to Ruby string literals. + */ + public noSubstitutionTemplateLiteral(node: ts.NoSubstitutionTemplateLiteral, _context: RubyVisitorContext): OTree { + return this.renderStringLiteral(node); + } + + /** + * Translates standard TypeScript string literals to Ruby. + */ + public override stringLiteral(node: ts.StringLiteral, _context: RubyVisitorContext): OTree { + return this.renderStringLiteral(node); + } + + /** + * Helper that renders string literals. Handles multi-line strings by translating them + * into Ruby heredoc syntax (`<<-'HERE'...HERE`) with safe unique delimiters, and single-line strings + * using standard JSON-serialized double-quotes. + */ + private renderStringLiteral(node: ts.StringLiteral | ts.NoSubstitutionTemplateLiteral): OTree { + if (node.text.includes('\n')) { + const marker = 'HERE'; + let safeMarker = marker; + let i = 0; + while (node.text.includes(`\n${safeMarker}\n`) || node.text.endsWith(`\n${safeMarker}`)) { + safeMarker = `${marker}${i++}`; + } + return new OTree([`<<-'${safeMarker}'\n`, node.text, `\n${safeMarker}`]); + } + return new OTree([`"${rubyDoubleQuotedInner(node.text)}"`]); + } + + /** + * Translates block statements to indented Ruby statements, omitting curly braces. + */ + public override block(node: ts.Block, context: RubyVisitorContext): OTree { + if (node.statements.length === 0) { + return new OTree([]); + } + return new OTree([], context.convertAll(node.statements), { + separator: '', + indent: 2, + }); + } + + /** + * Translates arrow functions to Ruby lambdas: `(bell) => bell.ring()` + * becomes `->(bell) { bell.ring }`, and a block body becomes a multi-line + * lambda. Runnable output — the runtime coerces Procs into single-method + * interface implementations at jsii call sites, so a rendered lambda is a + * working callback, not just a visual approximation. + */ + public override arrowFunction(node: ts.ArrowFunction, context: RubyVisitorContext): OTree { + return this.renderLambda(node, node.body, context); + } + + /** Translates `function (a) { ... }` expressions exactly like arrows. */ + public override functionExpression(node: ts.FunctionExpression, context: RubyVisitorContext): OTree { + return this.renderLambda(node, node.body, context); + } + + private renderLambda( + node: ts.ArrowFunction | ts.FunctionExpression, + body: ts.ConciseBody, + context: RubyVisitorContext, + ): OTree { + // Only simple identifier parameters translate cleanly; destructuring, + // defaults and rest parameters fall back to the shared unsupported path + // (diagnostic + raw source text), same as any untranslatable node. + const simple = node.parameters.every( + (p) => ts.isIdentifier(p.name) && p.initializer == null && p.dotDotDotToken == null, + ); + if (!simple) { + return context.renderUnsupported(node, TargetLanguage.RUBY); + } + + const params = node.parameters.map((p) => toSnakeCase((p.name as ts.Identifier).text)); + const head = params.length > 0 ? `->(${params.join(', ')}) ` : '-> '; + + if (ts.isBlock(body)) { + return new OTree([head, '{'], [context.convert(body)], { + canBreakLine: true, + suffix: '\n}', + }); + } + return new OTree([head, '{ ', context.convert(body), ' }']); + } + + /** + * Translates `if-else` statements to Ruby syntax. + * Handles inline suffix `if` for single statements, `elsif` for else-if chains, and standard `if/else/end` blocks. + */ + public override ifStatement(node: ts.IfStatement, context: RubyVisitorContext): OTree { + const isThenBlock = ts.isBlock(node.thenStatement); + if (!node.elseStatement && !isThenBlock) { + return new OTree([context.convert(node.thenStatement), ' if ', context.convert(node.expression)]); + } + + const renderBody = (stmt: ts.Statement): OTree => + ts.isBlock(stmt) + ? context.convert(stmt) + : new OTree([], ['\n', context.convert(stmt)], { indent: 2 }); + + // Build the `if` / `elsif` / `else` chain iteratively so exactly one `end` is emitted. + // Recursively converting a nested `else if` would append that nested `if`'s own `end`, + // producing a doubled `end` and invalid Ruby. + const parts: Array = [ + new OTree(['if ', context.convert(node.expression)], [renderBody(node.thenStatement)], { canBreakLine: true }), + ]; + + let current: ts.IfStatement = node; + while (current.elseStatement && ts.isIfStatement(current.elseStatement)) { + const elseIf: ts.IfStatement = current.elseStatement; + parts.push( + new OTree(['\nelsif ', context.convert(elseIf.expression)], [renderBody(elseIf.thenStatement)], { + canBreakLine: true, + }), + ); + current = elseIf; + } + + if (current.elseStatement) { + parts.push(new OTree(['\nelse'], [renderBody(current.elseStatement)], { canBreakLine: true })); + } + + parts.push('\nend'); + return new OTree([], parts, { separator: '', canBreakLine: true }); + } + + /** + * Translates `for (const x of array)` loop statements to Ruby `.each` loops. + * Formats as single-line curly braces `{ |x| ... }` or multi-line `do |x| ... end` blocks. + */ + public override forOfStatement(node: ts.ForOfStatement, context: RubyVisitorContext): OTree { + let variableName = '???'; + matchAst( + node.initializer, + nodeOfType(ts.SyntaxKind.VariableDeclarationList, nodeOfType('var', ts.SyntaxKind.VariableDeclaration)), + (bindings) => { + variableName = toSnakeCase(context.textOf(bindings.var.name)); + }, + ); + + const isBlock = ts.isBlock(node.statement); + const statements = isBlock ? (node.statement as ts.Block).statements : [node.statement]; + const isMultiLine = statements.length !== 1 || node.getText().includes('\n'); + + if (!isMultiLine) { + return new OTree([ + context.convert(node.expression), + `.each { |${variableName}| `, + context.convert(statements[0]), + ' }', + ]); + } else { + const body = isBlock + ? context.convert(node.statement) + : new OTree([], ['\n', context.convert(node.statement)], { indent: 2 }); + + const loopStart = new OTree([context.convert(node.expression), `.each do |${variableName}|`], [body], { + canBreakLine: true, + }); + return new OTree([], [loopStart, '\nend'], { + separator: '', + canBreakLine: true, + }); + } + } +} diff --git a/src/languages/target-language.ts b/src/languages/target-language.ts index d0e7ba76b..56f23a2b6 100644 --- a/src/languages/target-language.ts +++ b/src/languages/target-language.ts @@ -5,17 +5,19 @@ export enum TargetLanguage { CSHARP = 'csharp', JAVA = 'java', GO = 'go', + RUBY = 'ruby', /** @internal an alias of PYTHON to make intent clear when language is irrelevant, must be last */ VISUALIZE = 'python', } const VALID_TARGET_LANGUAGES = new Set(Object.values(TargetLanguage)); -export function targetName(language: TargetLanguage): 'python' | 'dotnet' | 'java' | 'go'; +export function targetName(language: TargetLanguage): 'python' | 'dotnet' | 'java' | 'go' | 'ruby'; export function targetName(language: TargetLanguage.PYTHON): 'python'; export function targetName(language: TargetLanguage.CSHARP): 'dotnet'; export function targetName(language: TargetLanguage.JAVA): 'java'; export function targetName(language: TargetLanguage.GO): 'go'; +export function targetName(language: TargetLanguage.RUBY): 'ruby'; /** @internal an alias of PYTHON to make intent clear when language is irrelevant, must be last override */ export function targetName(language: TargetLanguage.VISUALIZE): 'python'; @@ -24,8 +26,8 @@ export function targetName(language: TargetLanguage.VISUALIZE): 'python'; * * @returns the name of the target configuration block for the given language. */ -export function targetName(language: TargetLanguage): 'python' | 'dotnet' | 'java' | 'go'; -export function targetName(language: TargetLanguage): 'python' | 'dotnet' | 'java' | 'go' { +export function targetName(language: TargetLanguage): 'python' | 'dotnet' | 'java' | 'go' | 'ruby'; +export function targetName(language: TargetLanguage): 'python' | 'dotnet' | 'java' | 'go' | 'ruby' { // The TypeScript compiler should guarantee the below `switch` statement covers all possible // values of the TargetLanguage enum, but we add an assert here for clarity of intent. assert(VALID_TARGET_LANGUAGES.has(language), `Invalid/unexpected target language identifier: ${language}`); @@ -40,6 +42,8 @@ export function targetName(language: TargetLanguage): 'python' | 'dotnet' | 'jav return 'java'; case TargetLanguage.GO: return 'go'; + case TargetLanguage.RUBY: + return 'ruby'; } } @@ -72,6 +76,8 @@ export function supportsTransitiveSubmoduleAccess(language: TargetLanguage): boo return true; case TargetLanguage.CSHARP: return true; + case TargetLanguage.RUBY: + return true; case TargetLanguage.JAVA: return false; case TargetLanguage.GO: diff --git a/src/languages/visualize.ts b/src/languages/visualize.ts index 381daed2f..3f5f18f11 100644 --- a/src/languages/visualize.ts +++ b/src/languages/visualize.ts @@ -164,6 +164,22 @@ export class VisualizeAstVisitor implements AstHandler { return this.defaultNode('prefixUnaryExpression', node, context); } + public postfixUnaryExpression(node: ts.PostfixUnaryExpression, context: AstRenderer): OTree { + return this.defaultNode('postfixUnaryExpression', node, context); + } + + public conditionalExpression(node: ts.ConditionalExpression, context: AstRenderer): OTree { + return this.defaultNode('conditionalExpression', node, context); + } + + public arrowFunction(node: ts.ArrowFunction, context: AstRenderer): OTree { + return this.defaultNode('arrowFunction', node, context); + } + + public functionExpression(node: ts.FunctionExpression, context: AstRenderer): OTree { + return this.defaultNode('functionExpression', node, context); + } + public spreadElement(node: ts.SpreadElement, context: AstRenderer): OTree { return this.defaultNode('spreadElement', node, context); } diff --git a/src/renderer.ts b/src/renderer.ts index 0bc6328c9..29dd35b66 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -326,6 +326,14 @@ export class AstRenderer { return visitor.asExpression(tree as ts.AsExpression, this); case ts.SyntaxKind.PrefixUnaryExpression: return visitor.prefixUnaryExpression(tree as ts.PrefixUnaryExpression, this); + case ts.SyntaxKind.PostfixUnaryExpression: + return visitor.postfixUnaryExpression(tree as ts.PostfixUnaryExpression, this); + case ts.SyntaxKind.ConditionalExpression: + return visitor.conditionalExpression(tree as ts.ConditionalExpression, this); + case ts.SyntaxKind.ArrowFunction: + return visitor.arrowFunction(tree as ts.ArrowFunction, this); + case ts.SyntaxKind.FunctionExpression: + return visitor.functionExpression(tree as ts.FunctionExpression, this); case ts.SyntaxKind.SpreadAssignment: if (this.textOf(tree) === '...') { return visitor.ellipsis(tree as ts.SpreadAssignment, this); @@ -353,8 +361,19 @@ export class AstRenderer { if (ts.isToken(tree)) { return visitor.token(tree, this); } - this.reportUnsupported(tree, undefined); + return this.renderUnsupported(tree, undefined); } + } + + /** + * Fallback for a node that cannot be translated: report it, then render its source text + * + * This is the treatment nodes without a typed dispatch case receive; typed handlers + * that cannot translate a node (such as the `DefaultVisitor` ternary and postfix + * `++`/`--` handlers) call this to get the identical fallback. + */ + public renderUnsupported(tree: ts.Node, language: TargetLanguage | undefined): OTree { + this.reportUnsupported(tree, language); if (this.options.bestEffort !== false) { // When doing best-effort conversion and we don't understand the node type, just return the complete text of it as-is @@ -474,6 +493,10 @@ export interface AstHandler { methodSignature(node: ts.MethodSignature, context: AstRenderer): OTree; asExpression(node: ts.AsExpression, context: AstRenderer): OTree; prefixUnaryExpression(node: ts.PrefixUnaryExpression, context: AstRenderer): OTree; + postfixUnaryExpression(node: ts.PostfixUnaryExpression, context: AstRenderer): OTree; + conditionalExpression(node: ts.ConditionalExpression, context: AstRenderer): OTree; + arrowFunction(node: ts.ArrowFunction, context: AstRenderer): OTree; + functionExpression(node: ts.FunctionExpression, context: AstRenderer): OTree; spreadElement(node: ts.SpreadElement, context: AstRenderer): OTree; spreadAssignment(node: ts.SpreadAssignment, context: AstRenderer): OTree; templateExpression(node: ts.TemplateExpression, context: AstRenderer): OTree; diff --git a/src/submodule-reference.ts b/src/submodule-reference.ts index 24861f630..d626ee0f3 100644 --- a/src/submodule-reference.ts +++ b/src/submodule-reference.ts @@ -166,12 +166,15 @@ function isLikelyNamespace(node: ts.Node, typeChecker: ts.TypeChecker): boolean } // If the identifier was bound to a symbol, we can inspect the declarations of - // it to validate they are all module or namespace declarations. + // it to validate they are all module or namespace declarations. Symbols may + // have no declarations at all (e.g. error symbols produced for property + // accesses into modules that could not be resolved), in which case we fall + // through to the name-based heuristic below. const symbol = typeChecker.getSymbolAtLocation(node); - if (symbol != null) { + if (symbol?.declarations != null) { return ( - symbol.declarations!.length > 0 && - symbol.declarations!.every( + symbol.declarations.length > 0 && + symbol.declarations.every( (decl) => ts.isModuleDeclaration(decl) || ts.isNamespaceExport(decl) || ts.isNamespaceImport(decl), ) ); diff --git a/test/languages/default.test.ts b/test/languages/default.test.ts new file mode 100644 index 000000000..7c9e31ddc --- /dev/null +++ b/test/languages/default.test.ts @@ -0,0 +1,99 @@ +import { JavaVisitor } from '../../lib/languages/java'; +import { PythonVisitor } from '../../lib/languages/python'; +import { RubyVisitor } from '../../lib/languages/ruby'; +import { translateTypeScript } from '../../lib/translate'; + +// The DefaultVisitor cannot translate ternaries or postfix `++`/`--`, so those +// handlers must degrade exactly like nodes without a typed dispatch case: report +// an "unsupported" diagnostic, then pass the original TypeScript text through +// unchanged (in best-effort mode, the default) or render an UnknownSyntax +// placeholder (when best-effort is disabled). Ruby overrides both handlers with +// real translations, which must be unaffected. + +const TERNARY = 'const x = a === b ? null : myValue;'; +const POSTFIX = 'let i = 0;\ni++;'; +const ARROW = 'foo({ produce: () => arnLookup });'; + +describe.each([ + ['Python', () => new PythonVisitor()], + ['Java', () => new JavaVisitor()], +] as const)('%s falls back to raw source text', (_language, makeVisitor) => { + test('for a ternary', () => { + const result = translateTypeScript({ contents: TERNARY, fileName: 'test.ts' }, makeVisitor()); + + expect(result.translation).toContain('a === b ? null : myValue'); + expect(result.translation).not.toContain('ConditionalExpression'); + expect(result.diagnostics.some((d) => d.formattedMessage.includes('not supported'))).toBe(true); + }); + + test('for a postfix increment', () => { + const result = translateTypeScript({ contents: POSTFIX, fileName: 'test.ts' }, makeVisitor()); + + expect(result.translation).toContain('i++'); + expect(result.translation).not.toContain('PostfixUnaryExpression'); + expect(result.diagnostics.some((d) => d.formattedMessage.includes('not supported'))).toBe(true); + }); + + test('for an arrow function', () => { + const result = translateTypeScript({ contents: ARROW, fileName: 'test.ts' }, makeVisitor()); + + expect(result.translation).toContain('() => arnLookup'); + expect(result.translation).not.toContain('ArrowFunction'); + expect(result.diagnostics.some((d) => d.formattedMessage.includes('not supported'))).toBe(true); + }); +}); + +test('a ternary renders a placeholder when best-effort is disabled', () => { + const result = translateTypeScript({ contents: TERNARY, fileName: 'test.ts' }, new PythonVisitor(), { + bestEffort: false, + }); + + expect(result.translation).toContain(''); +}); + +describe('Ruby overrides the fallback', () => { + test('translates a ternary', () => { + const result = translateTypeScript({ contents: TERNARY, fileName: 'test.ts' }, new RubyVisitor()); + + expect(result.translation).toContain('a == b ? nil : my_value'); + expect(result.diagnostics).toHaveLength(0); + }); + + test('translates a postfix increment', () => { + const result = translateTypeScript({ contents: POSTFIX, fileName: 'test.ts' }, new RubyVisitor()); + + expect(result.translation).toContain('i += 1'); + expect(result.diagnostics).toHaveLength(0); + }); + + test('translates arrow functions to lambdas', () => { + // Plain arrow: no diagnostics at all. + const plain = translateTypeScript({ contents: 'const cb = () => arnLookup;', fileName: 'test.ts' }, new RubyVisitor()); + expect(plain.translation).toContain('cb = -> { arn_lookup }'); + expect(plain.diagnostics).toHaveLength(0); + + // The TypeScript-object-literal callback mirror. (The untyped object + // literal itself carries rosetta's standard "cannot infer type" warning — + // unrelated to arrows — so only the rendering is asserted here.) + const literal = translateTypeScript({ contents: ARROW, fileName: 'test.ts' }, new RubyVisitor()); + expect(literal.translation).toContain('produce: -> { arn_lookup }'); + expect(literal.translation).not.toContain('=>'); + }); + + test('translates a parameterised block-bodied arrow', () => { + const source = 'consumer.ring((bell) => {\n bell.ring();\n return true;\n});'; + const result = translateTypeScript({ contents: source, fileName: 'test.ts' }, new RubyVisitor()); + + expect(result.translation).toContain('->(bell) {'); + expect(result.translation).toContain('bell.ring'); + expect(result.diagnostics).toHaveLength(0); + }); + + test('a destructuring arrow parameter falls back to raw source with a diagnostic', () => { + const source = 'foo(({ a }) => a);'; + const result = translateTypeScript({ contents: source, fileName: 'test.ts' }, new RubyVisitor()); + + expect(result.translation).toContain('({ a }) => a'); + expect(result.diagnostics.some((d) => d.formattedMessage.includes('not supported'))).toBe(true); + }); +}); diff --git a/test/rosetta.test.ts b/test/rosetta.test.ts index 7d3d3db15..4f5239fb4 100644 --- a/test/rosetta.test.ts +++ b/test/rosetta.test.ts @@ -60,6 +60,7 @@ test('Can use preloaded tablet', () => { csharp: 'Not Really Translated C#', java: 'Not Really Translated Java', go: 'Not Really Translated Go', + ruby: 'Not Really Translated Ruby', }), ); rosetta.addTablet(tablet); @@ -232,6 +233,7 @@ describe('with mocked filesystem', () => { csharp: 'My Stored Translation C#', java: 'My Stored Translation Java', go: 'My Stored Translation Go', + ruby: 'My Stored Translation Ruby', }), ); diff --git a/test/ruby-declares.test.ts b/test/ruby-declares.test.ts new file mode 100644 index 000000000..48263b4e0 --- /dev/null +++ b/test/ruby-declares.test.ts @@ -0,0 +1,27 @@ +import { DUMMY_JSII_CONFIG, TestJsiiModule } from './testutil'; +import { TargetLanguage } from '../lib/languages'; + +describe('Ruby: an uninitialised declaration keeps its type as a comment', () => { + let module: TestJsiiModule; + + beforeAll(() => { + module = TestJsiiModule.fromSource( + { 'index.ts': `export interface IBucket { readonly bucketName: string; }` }, + { name: 'my_assembly', jsii: DUMMY_JSII_CONFIG }, + ); + }); + + afterAll(() => module.cleanup()); + + test('`declare const bucket: IBucket` -> `bucket = nil # `', () => { + const trans = module.translateHere(` + import { IBucket } from 'my_assembly'; + declare const bucket: IBucket; + Array.isArray(bucket); + `); + const ruby = trans.get(TargetLanguage.RUBY)?.source ?? ''; + // The type (IBucket) is preserved as a trailing comment on the placeholder, rather + // than dropped — resolved to its fully-qualified Ruby name. + expect(ruby).toMatch(/^bucket = nil # \S*IBucket$/m); + }); +}); diff --git a/test/ruby-translations.test.ts b/test/ruby-translations.test.ts new file mode 100644 index 000000000..1344f2ae9 --- /dev/null +++ b/test/ruby-translations.test.ts @@ -0,0 +1,161 @@ +import { translateTypeScript } from '../lib/translate'; +import { RubyVisitor } from '../lib/languages/ruby'; + +/** + * Syntactic translation of a TypeScript snippet to Ruby (no type resolution needed for these + * cases). Returns the rendered Ruby source. + */ +function toRuby(source: string): string { + return translateTypeScript({ contents: source, fileName: 'test.ts' }, new RubyVisitor()).translation; +} + +describe('imports -> require', () => { + test.each([ + // A plain package import maps to the gem of the same name. + ["import * as cdk from 'aws-cdk-lib';", "require 'aws-cdk-lib'"], + // A *submodule* import resolves to the gem, not a per-submodule require: the + // submodule is autoloaded from the package. Regression: this used to `/`->`-` the + // whole path and emit `require 'aws-cdk-lib-aws-s3tables'`. + ["import * as s3tables from 'aws-cdk-lib/aws-s3tables';", "require 'aws-cdk-lib'"], + ["import { Bucket } from 'aws-cdk-lib/aws-s3';", "require 'aws-cdk-lib'"], + // Scoped packages: @scope/name -> scope-name; a submodule still maps to the package. + ["import { Foo } from '@scope/jsii-calc-lib';", "require 'scope-jsii-calc-lib'"], + ["import { Foo } from '@scope/jsii-calc-lib/submodule';", "require 'scope-jsii-calc-lib'"], + ])('%s -> %s', (source, expected) => { + expect(toRuby(source)).toContain(expected); + }); + + test('relative imports use require_relative', () => { + expect(toRuby("import { Foo } from './my-module';")).toContain("require_relative './my-module'"); + }); + + test('imports resolving to the same gem emit a single require', () => { + const ruby = toRuby( + ["import * as s3 from 'aws-cdk-lib/aws-s3';", "import * as sqs from 'aws-cdk-lib/aws-sqs';"].join('\n'), + ); + // Regression: each import used to emit its own `require 'aws-cdk-lib'`. + expect(ruby.match(/require 'aws-cdk-lib'/g)).toHaveLength(1); + expect(ruby).not.toContain('\n\n'); + }); + + test('require dedupe resets between snippets when a visitor is reused', () => { + // `translateMarkdown` renders every snippet in a document with a single + // visitor instance; a require emitted for one snippet must not suppress + // the same require in the next one. + const visitor = new RubyVisitor(); + const snippet = { contents: "import * as cdk from 'aws-cdk-lib';", fileName: 'test.ts' }; + translateTypeScript(snippet, visitor); + expect(translateTypeScript(snippet, visitor).translation).toContain("require 'aws-cdk-lib'"); + }); +}); + +describe('array literal formatting', () => { + test('a broken array puts each element on its own line, not just the closing bracket', () => { + const ruby = toRuby( + [ + "new Foo(stack, 'T', {", + ' replicas: [', + " { region: 'us-east-1' }, { region: 'us-east-2' }", + ' ],', + '});', + ].join('\n'), + ); + // Regression: elements shared one line while `]` dropped to its own line + // (`...{region: "us-east-2"}\n ]`). Each element should be on its own line. + expect(ruby).toMatch(/\{region: "us-east-1"\},\n/); + expect(ruby).toContain('{region: "us-east-2"}'); + }); + + test('a short array stays inline', () => { + expect(toRuby('const x = [1, 2, 3];')).toContain('[1, 2, 3]'); + }); + + test('a broken hash keeps a property after a multi-line value on its own line', () => { + const ruby = toRuby( + [ + "new Foo(stack, 'T', {", + ' importSource: {', + ' inputFormat: InputFormat.csv({', + " delimiter: ',',", + ' }),', + ' bucket: bucket,', + ' },', + '});', + ].join('\n'), + ); + // Regression: `bucket:` was stranded on the csv(...) closing line (`}), bucket: bucket`). + expect(ruby).toMatch(/\}\),\n\s*bucket: bucket/); + }); + + test('a short hash stays inline', () => { + expect(toRuby("const x = { a: 1, b: 2 };")).toContain('{a: 1, b: 2}'); + }); +}); + +describe('if / elsif / else chains', () => { + test('an if / else-if / else chain emits exactly one `end`', () => { + const ruby = toRuby(['if (a) {', ' x();', '} else if (b) {', ' y();', '} else {', ' z();', '}'].join('\n')); + + expect(ruby).toContain('elsif'); + expect(ruby).toContain('else'); + // Exactly one closing `end` for the whole chain (regression: used to emit two). + expect(ruby.match(/^end$/gm) ?? []).toHaveLength(1); + }); + + test('nested else-if renders `elsif`, not a nested `if`', () => { + const ruby = toRuby(['if (a) {', ' x();', '} else if (b) {', ' y();', '}'].join('\n')); + expect(ruby).toContain('elsif'); + expect(ruby.match(/^end$/gm) ?? []).toHaveLength(1); + }); +}); + +describe('string escaping', () => { + test('literal `#{` in a string is escaped so Ruby does not interpolate it', () => { + const ruby = toRuby('const s = "a#{b}c";'); + expect(ruby).toContain('"a\\#{b}c"'); + }); + + test('template literals escape embedded quotes but keep real interpolation', () => { + const ruby = toRuby('const x = 1;\nconst s = `say "hi" ${x}`;'); + expect(ruby).toContain('\\"hi\\"'); // embedded quotes escaped + expect(ruby).toContain('#{x}'); // interpolation preserved + }); +}); + +describe('static members', () => { + test('a static method becomes `def self.`', () => { + const ruby = toRuby('class C {\n static foo() {\n return 1;\n }\n}'); + expect(ruby).toContain('def self.foo'); + expect(ruby).not.toContain('def foo'); + }); + + test('a static readonly field becomes a Ruby constant preserving its value', () => { + const ruby = toRuby('class C {\n static readonly FOO = 5;\n}'); + expect(ruby).toContain('FOO = 5'); + expect(ruby).not.toContain('attr_reader :foo'); + }); + + test('static readonly (const) property access uses `.` + the constant name, not dropped', () => { + // Regression: `BlockPublicAccess.BLOCK_ALL` used to render as just the type + // (`...BlockPublicAccess`), silently dropping the member. + const ruby = toRuby(['class C {', ' static readonly BLOCK_ALL = new C();', '}', 'const x = C.BLOCK_ALL;'].join('\n')); + expect(ruby).toContain('C.BLOCK_ALL'); + // dot access, not the enum-style `::` + expect(ruby).not.toContain('C::BLOCK_ALL'); + }); +}); + +describe('type assertions', () => { + test('`as number` / `as string` pass through without runtime coercion', () => { + const ruby = toRuby('const a = 1;\nconst n = a as number;\nconst s = a as string;'); + expect(ruby).not.toContain('.to_i'); + expect(ruby).not.toContain('.to_s'); + }); +}); + +describe('super calls', () => { + test('`super()` renders with explicit empty parens (not bare `super`)', () => { + const ruby = toRuby('class C extends B {\n constructor() {\n super();\n }\n}'); + expect(ruby).toContain('super()'); + }); +}); diff --git a/test/ruby.test.ts b/test/ruby.test.ts new file mode 100644 index 000000000..6baf52654 --- /dev/null +++ b/test/ruby.test.ts @@ -0,0 +1,100 @@ +import { toSnakeCase, rubyModuleName, guessRubyModuleName } from '../lib/languages/ruby'; + +describe('toSnakeCase', () => { + test.each([ + // Plain camelCase + ['foo', 'foo'], + ['someMethod', 'some_method'], + ['arnValue', 'arn_value'], + // Single characters / digits + ['x', 'x'], + ['getX', 'get_x'], + // Consecutive uppercase (acronyms) collapse correctly + ['enforceSSL', 'enforce_ssl'], + ['myVPCId', 'my_vpc_id'], + ['parseJSON', 'parse_json'], + ['toJSON', 'to_json'], + ['ec2InstanceId', 'ec2_instance_id'], + ['x509Certificate', 'x509_certificate'], + ['fromHTTPSToJSON', 'from_https_to_json'], + // Already snake_case is left alone + ['already_snake', 'already_snake'], + // Leading underscore is preserved + ['_privateField', '_private_field'], + ])('converts %s -> %s', (input, expected) => { + expect(toSnakeCase(input)).toBe(expected); + }); + + test('leaves PascalCase (class-like) names untouched', () => { + expect(toSnakeCase('MyClass')).toBe('MyClass'); + expect(toSnakeCase('Bucket')).toBe('Bucket'); + }); + + test.each(['end', 'class', 'def', 'begin', 'send', 'next', 'retry'])( + 'escapes reserved word %s with a leading underscore', + (word) => { + expect(toSnakeCase(word)).toBe(`_${word}`); + }, + ); +}); + +describe('rubyModuleName', () => { + test.each([ + // Simple names get PascalCased + ['core', 'Core'], + ['submodule', 'Submodule'], + ['foo', 'Foo'], + ['child', 'Child'], + ['homonymousForwardReferences', 'HomonymousForwardReferences'], + // Hyphenated package names become a single concatenated module + ['jsii-calc', 'JsiiCalc'], + // Without declared acronyms there is no acronym knowledge: plain PascalCase. + // Acronym casing is library data (`targets.ruby.acronyms` in the assembly), + // not something this visitor knows on its own. + ['s3', 'S3'], // single letter + digit pascals to S3 with no list involved + ['vpc', 'Vpc'], + ['iam', 'Iam'], + ['aws', 'Aws'], + ])('formats %s -> %s', (input, expected) => { + expect(rubyModuleName(input)).toBe(expected); + }); + + test('handles scoped package names (@scope/name)', () => { + expect(rubyModuleName('@aws-cdk/core', ['AWS', 'CDK'])).toBe('AWSCDK::Core'); + expect(rubyModuleName('@aws-cdk/core')).toBe('AwsCdk::Core'); + }); + + test('declared acronyms are authoritative — the mechanism, with test-owned data', () => { + // Any caller-declared acronym is honoured... + expect(rubyModuleName('myFoo', ['FOO'])).toBe('MyFOO'); + expect(rubyModuleName('vpc', ['VPC'])).toBe('VPC'); + // ...and an undeclared one has no effect, because there is no built-in list. + expect(rubyModuleName('vpc', ['FOO'])).toBe('Vpc'); + // Duplicated declarations are applied once, not twice. + expect(rubyModuleName('vpc', ['VPC', 'VPC'])).toBe('VPC'); + }); + + test('short acronyms do not over-match inside unrelated words', () => { + expect(rubyModuleName('certificate', ['CE'])).toBe('Certificate'); + expect(rubyModuleName('database', ['DB'])).toBe('Database'); + expect(rubyModuleName('ramp', ['RAM'])).toBe('Ramp'); + }); +}); + +describe('guessRubyModuleName', () => { + test.each([ + // The core CDK library's explicit .jsiirc.json naming is mirrored: AWSCDK root, + // redundant service-level `aws` prefix dropped from submodules. + ['aws-cdk-lib', 'AWSCDK'], + ['aws-cdk-lib.aws_s3', 'AWSCDK::S3'], + // Without an assembly there is no acronym config, so multi-letter service + // names get plain PascalCase — an honest guess, not fake authority. + ['aws-cdk-lib.aws_ec2', 'AWSCDK::Ec2'], + ['aws-cdk-lib.pipelines', 'AWSCDK::Pipelines'], + // Non-CDK assemblies follow the default naming rules, with submodules nested via `::`. + ['jsii-calc', 'JsiiCalc'], + ['jsii-calc.submodule', 'JsiiCalc::Submodule'], + ])('guesses %s -> %s', (input, expected) => { + expect(guessRubyModuleName(input)).toBe(expected); + }); +}); diff --git a/test/translations.test.ts b/test/translations.test.ts index 4d550a830..fcaf60c1c 100644 --- a/test/translations.test.ts +++ b/test/translations.test.ts @@ -54,6 +54,11 @@ export const SUPPORTED_LANGUAGES = new Array( extension: '.go', visitorFactory: TARGET_LANGUAGES[TargetLanguage.GO], }, + { + name: 'Ruby', + extension: '.rb', + visitorFactory: TARGET_LANGUAGES[TargetLanguage.RUBY], + }, ); const translationsRoot = path.join(__dirname, 'translations'); diff --git a/test/translations/calls/declaring_default_arguments.rb b/test/translations/calls/declaring_default_arguments.rb new file mode 100644 index 000000000..d40e68dc1 --- /dev/null +++ b/test/translations/calls/declaring_default_arguments.rb @@ -0,0 +1,3 @@ +def foo(x = nil, y = "hello", z = nil) + puts(x, y, z) +end \ No newline at end of file diff --git a/test/translations/calls/default_struct_fields.rb b/test/translations/calls/default_struct_fields.rb new file mode 100644 index 000000000..df57ee4a4 --- /dev/null +++ b/test/translations/calls/default_struct_fields.rb @@ -0,0 +1,3 @@ +def foo(s) + puts(s[:x], s[:y]) +end \ No newline at end of file diff --git a/test/translations/calls/function_call.rb b/test/translations/calls/function_call.rb new file mode 100644 index 000000000..ae3911213 --- /dev/null +++ b/test/translations/calls/function_call.rb @@ -0,0 +1 @@ +call_some_function(1, 2, 3) \ No newline at end of file diff --git a/test/translations/calls/list_of_anonymous_structs.rb b/test/translations/calls/list_of_anonymous_structs.rb new file mode 100644 index 000000000..c9d72b871 --- /dev/null +++ b/test/translations/calls/list_of_anonymous_structs.rb @@ -0,0 +1,12 @@ +foo({ + list: [ + { + a: 1, + b: 2, + }, + { + a: 3, + b: 4, + }, + ], +}) \ No newline at end of file diff --git a/test/translations/calls/literal_map_argument.rb b/test/translations/calls/literal_map_argument.rb new file mode 100644 index 000000000..cb297e92c --- /dev/null +++ b/test/translations/calls/literal_map_argument.rb @@ -0,0 +1,4 @@ +def foo(xs) +end + +foo({foo: "bar", schmoo: "schmar"}) \ No newline at end of file diff --git a/test/translations/calls/method_call.rb b/test/translations/calls/method_call.rb new file mode 100644 index 000000000..947964009 --- /dev/null +++ b/test/translations/calls/method_call.rb @@ -0,0 +1 @@ +some_object.call_some_function(1, 2, 3) \ No newline at end of file diff --git a/test/translations/calls/self_method_call.rb b/test/translations/calls/self_method_call.rb new file mode 100644 index 000000000..f8fd716e1 --- /dev/null +++ b/test/translations/calls/self_method_call.rb @@ -0,0 +1 @@ +self.call_some_function(25) \ No newline at end of file diff --git a/test/translations/calls/shorthand_property.rb b/test/translations/calls/shorthand_property.rb new file mode 100644 index 000000000..8b1315aa1 --- /dev/null +++ b/test/translations/calls/shorthand_property.rb @@ -0,0 +1,2 @@ +foo = "hello" +call_function({foo: foo}) \ No newline at end of file diff --git a/test/translations/calls/spread_arguments.rb b/test/translations/calls/spread_arguments.rb new file mode 100644 index 000000000..9936055c7 --- /dev/null +++ b/test/translations/calls/spread_arguments.rb @@ -0,0 +1,2 @@ +items = ["a", "b"] +foo(*items) diff --git a/test/translations/calls/spread_arguments.ts b/test/translations/calls/spread_arguments.ts new file mode 100644 index 000000000..4e5d9fc04 --- /dev/null +++ b/test/translations/calls/spread_arguments.ts @@ -0,0 +1,2 @@ +const items = ['a', 'b']; +foo(...items); diff --git a/test/translations/calls/static_function_call.rb b/test/translations/calls/static_function_call.rb new file mode 100644 index 000000000..c118f5acc --- /dev/null +++ b/test/translations/calls/static_function_call.rb @@ -0,0 +1 @@ +SomeObject.call_some_function(1, 2, 3) \ No newline at end of file diff --git a/test/translations/calls/this_argument.rb b/test/translations/calls/this_argument.rb new file mode 100644 index 000000000..9f2a2bea1 --- /dev/null +++ b/test/translations/calls/this_argument.rb @@ -0,0 +1 @@ +call_some_function(self, 25) \ No newline at end of file diff --git a/test/translations/calls/translate_object_literals_in_function_call.rb b/test/translations/calls/translate_object_literals_in_function_call.rb new file mode 100644 index 000000000..db08bc912 --- /dev/null +++ b/test/translations/calls/translate_object_literals_in_function_call.rb @@ -0,0 +1 @@ +foo(25, {foo: 3, banana: "hello"}) \ No newline at end of file diff --git a/test/translations/calls/translate_object_literals_only_one_level_deep.rb b/test/translations/calls/translate_object_literals_only_one_level_deep.rb new file mode 100644 index 000000000..37b52ac2d --- /dev/null +++ b/test/translations/calls/translate_object_literals_only_one_level_deep.rb @@ -0,0 +1 @@ +foo(25, {foo: 3, deeper: {a: 1, b: 2}}) \ No newline at end of file diff --git a/test/translations/calls/translate_object_literals_second_level_with_newlines.rb b/test/translations/calls/translate_object_literals_second_level_with_newlines.rb new file mode 100644 index 000000000..6ed77e72a --- /dev/null +++ b/test/translations/calls/translate_object_literals_second_level_with_newlines.rb @@ -0,0 +1,7 @@ +foo(25, { + foo: 3, + deeper: { + a: 1, + b: 2, + }, +}) \ No newline at end of file diff --git a/test/translations/calls/translate_object_literals_with_multiple_newlines.rb b/test/translations/calls/translate_object_literals_with_multiple_newlines.rb new file mode 100644 index 000000000..9e53ae3a9 --- /dev/null +++ b/test/translations/calls/translate_object_literals_with_multiple_newlines.rb @@ -0,0 +1,5 @@ +foo(25, { + foo: 3, + + banana: "hello", +}) \ No newline at end of file diff --git a/test/translations/calls/translate_object_literals_with_newlines.rb b/test/translations/calls/translate_object_literals_with_newlines.rb new file mode 100644 index 000000000..3b486776c --- /dev/null +++ b/test/translations/calls/translate_object_literals_with_newlines.rb @@ -0,0 +1,4 @@ +foo(25, { + foo: 3, + banana: "hello", +}) \ No newline at end of file diff --git a/test/translations/calls/will_type_deep_structs_directly_if_type_info_is_available.rb b/test/translations/calls/will_type_deep_structs_directly_if_type_info_is_available.rb new file mode 100644 index 000000000..ed5939ac3 --- /dev/null +++ b/test/translations/calls/will_type_deep_structs_directly_if_type_info_is_available.rb @@ -0,0 +1,10 @@ +def foo(x, outer) +end + +foo(25, { + foo: 3, + deeper: { + a: 1, + b: 2, + }, +}) \ No newline at end of file diff --git a/test/translations/classes/class_declaration_with_private_fields_and_constructor.rb b/test/translations/classes/class_declaration_with_private_fields_and_constructor.rb new file mode 100644 index 000000000..591a71340 --- /dev/null +++ b/test/translations/classes/class_declaration_with_private_fields_and_constructor.rb @@ -0,0 +1,7 @@ +class MyClass + private attr_reader :x + + def initialize(y) + @x = y + end +end \ No newline at end of file diff --git a/test/translations/classes/class_declaration_with_public_fields_and_constructor.rb b/test/translations/classes/class_declaration_with_public_fields_and_constructor.rb new file mode 100644 index 000000000..dd05cfa34 --- /dev/null +++ b/test/translations/classes/class_declaration_with_public_fields_and_constructor.rb @@ -0,0 +1,7 @@ +class MyClass + attr_reader :x + + def initialize(y) + @x = y + end +end \ No newline at end of file diff --git a/test/translations/classes/class_implementing_jsii_interface.rb b/test/translations/classes/class_implementing_jsii_interface.rb new file mode 100644 index 000000000..0ede4a083 --- /dev/null +++ b/test/translations/classes/class_implementing_jsii_interface.rb @@ -0,0 +1,6 @@ +class MyClass + include IResolvable + def resolve + return 42 + end +end \ No newline at end of file diff --git a/test/translations/classes/class_with_different_name.rb b/test/translations/classes/class_with_different_name.rb new file mode 100644 index 000000000..f7ca8fb36 --- /dev/null +++ b/test/translations/classes/class_with_different_name.rb @@ -0,0 +1,4 @@ +class OtherName + def initialize + end +end \ No newline at end of file diff --git a/test/translations/classes/class_with_extends_and_implements.rb b/test/translations/classes/class_with_extends_and_implements.rb new file mode 100644 index 000000000..29bbc0b7a --- /dev/null +++ b/test/translations/classes/class_with_extends_and_implements.rb @@ -0,0 +1,3 @@ +class MyClass < SomeOtherClass + include Cdk::SomeInterface +end \ No newline at end of file diff --git a/test/translations/classes/class_with_inheritance.rb b/test/translations/classes/class_with_inheritance.rb new file mode 100644 index 000000000..545d8d547 --- /dev/null +++ b/test/translations/classes/class_with_inheritance.rb @@ -0,0 +1,2 @@ +class MyClass < Cdk::SomeOtherClass +end \ No newline at end of file diff --git a/test/translations/classes/class_with_inheritance_and_super_class.rb b/test/translations/classes/class_with_inheritance_and_super_class.rb new file mode 100644 index 000000000..a1291d005 --- /dev/null +++ b/test/translations/classes/class_with_inheritance_and_super_class.rb @@ -0,0 +1,5 @@ +class MyClass < Cdk::SomeOtherClass + def initialize(x, y) + super(x) + end +end \ No newline at end of file diff --git a/test/translations/classes/class_with_method.rb b/test/translations/classes/class_with_method.rb new file mode 100644 index 000000000..849227e21 --- /dev/null +++ b/test/translations/classes/class_with_method.rb @@ -0,0 +1,5 @@ +class MyClass < Cdk::SomeOtherClass + def some_method(x) + puts(x) + end +end \ No newline at end of file diff --git a/test/translations/classes/class_with_namespace.rb b/test/translations/classes/class_with_namespace.rb new file mode 100644 index 000000000..546511ced --- /dev/null +++ b/test/translations/classes/class_with_namespace.rb @@ -0,0 +1,3 @@ +require 'aws-cdk-lib' +class MyClass < Cdk::Construct +end \ No newline at end of file diff --git a/test/translations/classes/class_with_namespace.ts b/test/translations/classes/class_with_namespace.ts new file mode 100644 index 000000000..1fb1e6754 --- /dev/null +++ b/test/translations/classes/class_with_namespace.ts @@ -0,0 +1,3 @@ +import * as cdk from 'aws-cdk-lib'; +class MyClass extends cdk.Construct { +} diff --git a/test/translations/classes/class_with_props_argument.rb b/test/translations/classes/class_with_props_argument.rb new file mode 100644 index 000000000..7afdc7a72 --- /dev/null +++ b/test/translations/classes/class_with_props_argument.rb @@ -0,0 +1,7 @@ +class MyClass < Cdk::SomeOtherClass + def initialize(scope, id, props) + super(scope, id, props) + + puts(props[:prop1]) + end +end \ No newline at end of file diff --git a/test/translations/classes/constructor_with_optional_params.rb b/test/translations/classes/constructor_with_optional_params.rb new file mode 100644 index 000000000..2cdd17909 --- /dev/null +++ b/test/translations/classes/constructor_with_optional_params.rb @@ -0,0 +1,4 @@ +class A + def initialize(a = nil, b = 3) + end +end \ No newline at end of file diff --git a/test/translations/classes/empty_class.rb b/test/translations/classes/empty_class.rb new file mode 100644 index 000000000..3075aef57 --- /dev/null +++ b/test/translations/classes/empty_class.rb @@ -0,0 +1,2 @@ +class EmptyClass +end \ No newline at end of file diff --git a/test/translations/classes/invisible_interfaces_do_not_affect_whitespace.rb b/test/translations/classes/invisible_interfaces_do_not_affect_whitespace.rb new file mode 100644 index 000000000..39af3ffe8 --- /dev/null +++ b/test/translations/classes/invisible_interfaces_do_not_affect_whitespace.rb @@ -0,0 +1,5 @@ +class MyClass1 +end + +class MyClass2 +end \ No newline at end of file diff --git a/test/translations/classes/whitespace_between_multiple_empty_members.rb b/test/translations/classes/whitespace_between_multiple_empty_members.rb new file mode 100644 index 000000000..5bc57e3a0 --- /dev/null +++ b/test/translations/classes/whitespace_between_multiple_empty_members.rb @@ -0,0 +1,11 @@ +class MyClass + def initialize(y) + @x = y + end + + def hello + end + + def bye + end +end \ No newline at end of file diff --git a/test/translations/classes/whitespace_between_multiple_members.rb b/test/translations/classes/whitespace_between_multiple_members.rb new file mode 100644 index 000000000..84b3169f3 --- /dev/null +++ b/test/translations/classes/whitespace_between_multiple_members.rb @@ -0,0 +1,13 @@ +class MyClass + def initialize(y) + @x = y + end + + def hello + puts(@x) + end + + def bye + puts("bye") + end +end \ No newline at end of file diff --git a/test/translations/comments/empty_lines_in_comments.rb b/test/translations/comments/empty_lines_in_comments.rb new file mode 100644 index 000000000..302611309 --- /dev/null +++ b/test/translations/comments/empty_lines_in_comments.rb @@ -0,0 +1,3 @@ +# Here's a comment +# Second line +some_call \ No newline at end of file diff --git a/test/translations/comments/interleave_multiline_comments_with_function_call.rb b/test/translations/comments/interleave_multiline_comments_with_function_call.rb new file mode 100644 index 000000000..7691566a6 --- /dev/null +++ b/test/translations/comments/interleave_multiline_comments_with_function_call.rb @@ -0,0 +1,7 @@ +some_function(arg1, { + # A comment before arg2 + arg2: "string", + + # A comment before arg3 + arg3: "boo", +}) \ No newline at end of file diff --git a/test/translations/comments/interleave_single_line_comments_with_function_call.rb b/test/translations/comments/interleave_single_line_comments_with_function_call.rb new file mode 100644 index 000000000..7691566a6 --- /dev/null +++ b/test/translations/comments/interleave_single_line_comments_with_function_call.rb @@ -0,0 +1,7 @@ +some_function(arg1, { + # A comment before arg2 + arg2: "string", + + # A comment before arg3 + arg3: "boo", +}) \ No newline at end of file diff --git a/test/translations/comments/no_duplication_of_comments.rb b/test/translations/comments/no_duplication_of_comments.rb new file mode 100644 index 000000000..498ced257 --- /dev/null +++ b/test/translations/comments/no_duplication_of_comments.rb @@ -0,0 +1,2 @@ +# Here's a comment +object.member.function_call(Class.new, "argument") \ No newline at end of file diff --git a/test/translations/expressions/array_index.rb b/test/translations/expressions/array_index.rb new file mode 100644 index 000000000..3fa6a6c58 --- /dev/null +++ b/test/translations/expressions/array_index.rb @@ -0,0 +1,3 @@ +array = [] + +puts(array[3]) \ No newline at end of file diff --git a/test/translations/expressions/as_expression.rb b/test/translations/expressions/as_expression.rb new file mode 100644 index 000000000..cd4d15f5a --- /dev/null +++ b/test/translations/expressions/as_expression.rb @@ -0,0 +1 @@ +puts(3) diff --git a/test/translations/expressions/await.rb b/test/translations/expressions/await.rb new file mode 100644 index 000000000..8715208cd --- /dev/null +++ b/test/translations/expressions/await.rb @@ -0,0 +1 @@ +x = future \ No newline at end of file diff --git a/test/translations/expressions/backtick_string_w_o_substitutions.rb b/test/translations/expressions/backtick_string_w_o_substitutions.rb new file mode 100644 index 000000000..919c21a64 --- /dev/null +++ b/test/translations/expressions/backtick_string_w_o_substitutions.rb @@ -0,0 +1 @@ +x = "some string" \ No newline at end of file diff --git a/test/translations/expressions/computed_key.rb b/test/translations/expressions/computed_key.rb new file mode 100644 index 000000000..49ee15e6a --- /dev/null +++ b/test/translations/expressions/computed_key.rb @@ -0,0 +1,4 @@ +y = "WHY?" + +x = {"key-#{y}" => "value"} +z = {y => true} \ No newline at end of file diff --git a/test/translations/expressions/double_quoted_dict_keys.rb b/test/translations/expressions/double_quoted_dict_keys.rb new file mode 100644 index 000000000..5b1ac0db3 --- /dev/null +++ b/test/translations/expressions/double_quoted_dict_keys.rb @@ -0,0 +1 @@ +x = {"key" => "value"} \ No newline at end of file diff --git a/test/translations/expressions/ellipsis_at_a_random_place.rb b/test/translations/expressions/ellipsis_at_a_random_place.rb new file mode 100644 index 000000000..74f1d59f9 --- /dev/null +++ b/test/translations/expressions/ellipsis_at_a_random_place.rb @@ -0,0 +1 @@ +call_this_function(foo, ...) \ No newline at end of file diff --git a/test/translations/expressions/enum_access.rb b/test/translations/expressions/enum_access.rb new file mode 100644 index 000000000..de6c95aea --- /dev/null +++ b/test/translations/expressions/enum_access.rb @@ -0,0 +1 @@ +puts(EnumType::ENUM_VALUE_A) \ No newline at end of file diff --git a/test/translations/expressions/enum_like_access.rb b/test/translations/expressions/enum_like_access.rb new file mode 100644 index 000000000..c4efa98f7 --- /dev/null +++ b/test/translations/expressions/enum_like_access.rb @@ -0,0 +1 @@ +puts(EnumType.ENUM_VALUE_A) \ No newline at end of file diff --git a/test/translations/expressions/increment_decrement.rb b/test/translations/expressions/increment_decrement.rb new file mode 100644 index 000000000..9a6f70895 --- /dev/null +++ b/test/translations/expressions/increment_decrement.rb @@ -0,0 +1,5 @@ +i = 0 +i += 1 +i -= 1 +i += 1 +i -= 1 diff --git a/test/translations/expressions/increment_decrement.ts b/test/translations/expressions/increment_decrement.ts new file mode 100644 index 000000000..e84725afb --- /dev/null +++ b/test/translations/expressions/increment_decrement.ts @@ -0,0 +1,5 @@ +let i = 0; +i++; +i--; +++i; +--i; diff --git a/test/translations/expressions/map-literal.rb b/test/translations/expressions/map-literal.rb new file mode 100644 index 000000000..7d46d1771 --- /dev/null +++ b/test/translations/expressions/map-literal.rb @@ -0,0 +1,3 @@ +map = { + "Access-Control-Allow-Origin" => "\"*\"", +} \ No newline at end of file diff --git a/test/translations/expressions/nil_and_predicates.rb b/test/translations/expressions/nil_and_predicates.rb new file mode 100644 index 000000000..1993a7a41 --- /dev/null +++ b/test/translations/expressions/nil_and_predicates.rb @@ -0,0 +1,4 @@ +a = nil +b = nil +c = x.is_a?(Bucket) +d = obj&.value diff --git a/test/translations/expressions/nil_and_predicates.ts b/test/translations/expressions/nil_and_predicates.ts new file mode 100644 index 000000000..8ca938d1e --- /dev/null +++ b/test/translations/expressions/nil_and_predicates.ts @@ -0,0 +1,4 @@ +const a = null; +const b = undefined; +const c = x instanceof Bucket; +const d = obj?.value; diff --git a/test/translations/expressions/non_null_expression.rb b/test/translations/expressions/non_null_expression.rb new file mode 100644 index 000000000..f87ac0903 --- /dev/null +++ b/test/translations/expressions/non_null_expression.rb @@ -0,0 +1 @@ +x = some_object.some_attribute \ No newline at end of file diff --git a/test/translations/expressions/nullish_assignment.rb b/test/translations/expressions/nullish_assignment.rb new file mode 100644 index 000000000..9b4367780 --- /dev/null +++ b/test/translations/expressions/nullish_assignment.rb @@ -0,0 +1,2 @@ +x = 1 +x ||= 2 diff --git a/test/translations/expressions/nullish_assignment.ts b/test/translations/expressions/nullish_assignment.ts new file mode 100644 index 000000000..009016995 --- /dev/null +++ b/test/translations/expressions/nullish_assignment.ts @@ -0,0 +1,2 @@ +let x = 1; +x ??= 2; diff --git a/test/translations/expressions/object_spread.rb b/test/translations/expressions/object_spread.rb new file mode 100644 index 000000000..c9f30b48e --- /dev/null +++ b/test/translations/expressions/object_spread.rb @@ -0,0 +1,2 @@ +opts = {a: 1} +merged = {**opts, b: 2} diff --git a/test/translations/expressions/object_spread.ts b/test/translations/expressions/object_spread.ts new file mode 100644 index 000000000..21ece0cdc --- /dev/null +++ b/test/translations/expressions/object_spread.ts @@ -0,0 +1,2 @@ +const opts = { a: 1 }; +const merged = { ...opts, b: 2 }; diff --git a/test/translations/expressions/property_access.rb b/test/translations/expressions/property_access.rb new file mode 100644 index 000000000..521a90510 --- /dev/null +++ b/test/translations/expressions/property_access.rb @@ -0,0 +1,2 @@ +object.property_a +object.property_b \ No newline at end of file diff --git a/test/translations/expressions/strict_inequality_and_nullish.rb b/test/translations/expressions/strict_inequality_and_nullish.rb new file mode 100644 index 000000000..7a69a2227 --- /dev/null +++ b/test/translations/expressions/strict_inequality_and_nullish.rb @@ -0,0 +1,2 @@ +puts(a != b) +puts(a || b) diff --git a/test/translations/expressions/strict_inequality_and_nullish.ts b/test/translations/expressions/strict_inequality_and_nullish.ts new file mode 100644 index 000000000..e527ca684 --- /dev/null +++ b/test/translations/expressions/strict_inequality_and_nullish.ts @@ -0,0 +1,2 @@ +console.log(a !== b); +console.log(a ?? b); diff --git a/test/translations/expressions/string_interpolation.rb b/test/translations/expressions/string_interpolation.rb new file mode 100644 index 000000000..6be5ee43e --- /dev/null +++ b/test/translations/expressions/string_interpolation.rb @@ -0,0 +1,10 @@ +x = "world" +y = "well" +puts("Hello, #{x}, it works #{y}!") + +# And now a multi-line expression +puts(" +Hello, #{x}. + +It works #{y}! +") \ No newline at end of file diff --git a/test/translations/expressions/string_literal.rb b/test/translations/expressions/string_literal.rb new file mode 100644 index 000000000..d4f2a880e --- /dev/null +++ b/test/translations/expressions/string_literal.rb @@ -0,0 +1,11 @@ +literal = <<-'HERE' + +This is a multiline string literal. + +"It's cool!". + +YEAH BABY!! + +Litteral \n right here (not a newline!) + +HERE \ No newline at end of file diff --git a/test/translations/expressions/struct_assignment.rb b/test/translations/expressions/struct_assignment.rb new file mode 100644 index 000000000..5b1ac0db3 --- /dev/null +++ b/test/translations/expressions/struct_assignment.rb @@ -0,0 +1 @@ +x = {"key" => "value"} \ No newline at end of file diff --git a/test/translations/expressions/ternary.rb b/test/translations/expressions/ternary.rb new file mode 100644 index 000000000..434d51410 --- /dev/null +++ b/test/translations/expressions/ternary.rb @@ -0,0 +1 @@ +x = a == b ? nil : my_value diff --git a/test/translations/expressions/ternary.ts b/test/translations/expressions/ternary.ts new file mode 100644 index 000000000..df754693a --- /dev/null +++ b/test/translations/expressions/ternary.ts @@ -0,0 +1 @@ +const x = a === b ? null : myValue; diff --git a/test/translations/expressions/unary_and_binary_operators.rb b/test/translations/expressions/unary_and_binary_operators.rb new file mode 100644 index 000000000..061b3a213 --- /dev/null +++ b/test/translations/expressions/unary_and_binary_operators.rb @@ -0,0 +1,3 @@ +puts(-3) +puts(!false) +puts(a == b) \ No newline at end of file diff --git a/test/translations/hiding/hide_block_level_statements_using_void_directive.rb b/test/translations/hiding/hide_block_level_statements_using_void_directive.rb new file mode 100644 index 000000000..14b115ac7 --- /dev/null +++ b/test/translations/hiding/hide_block_level_statements_using_void_directive.rb @@ -0,0 +1,5 @@ +if true + puts("everything is well") +end + +only_to_end_of_block \ No newline at end of file diff --git a/test/translations/hiding/hide_expression_with_explicit_ellipsis.rb b/test/translations/hiding/hide_expression_with_explicit_ellipsis.rb new file mode 100644 index 000000000..de952271b --- /dev/null +++ b/test/translations/hiding/hide_expression_with_explicit_ellipsis.rb @@ -0,0 +1 @@ +foo(3, ...) \ No newline at end of file diff --git a/test/translations/hiding/hide_halfway_into_class_using_comments.rb b/test/translations/hiding/hide_halfway_into_class_using_comments.rb new file mode 100644 index 000000000..1b988f107 --- /dev/null +++ b/test/translations/hiding/hide_halfway_into_class_using_comments.rb @@ -0,0 +1,3 @@ +prepare + +puts(self, "it seems to work") \ No newline at end of file diff --git a/test/translations/hiding/hide_parameter_sequence.rb b/test/translations/hiding/hide_parameter_sequence.rb new file mode 100644 index 000000000..632090403 --- /dev/null +++ b/test/translations/hiding/hide_parameter_sequence.rb @@ -0,0 +1 @@ +foo(3, 8) \ No newline at end of file diff --git a/test/translations/hiding/hide_statements_with_explicit_ellipsis.rb b/test/translations/hiding/hide_statements_with_explicit_ellipsis.rb new file mode 100644 index 000000000..9f324c5d4 --- /dev/null +++ b/test/translations/hiding/hide_statements_with_explicit_ellipsis.rb @@ -0,0 +1,3 @@ +before +# ... +after \ No newline at end of file diff --git a/test/translations/hiding/hide_top_level_statements_using_void_directive.rb b/test/translations/hiding/hide_top_level_statements_using_void_directive.rb new file mode 100644 index 000000000..c8f119790 --- /dev/null +++ b/test/translations/hiding/hide_top_level_statements_using_void_directive.rb @@ -0,0 +1 @@ +foo(3) \ No newline at end of file diff --git a/test/translations/identifiers/consecutive_uppercase.rb b/test/translations/identifiers/consecutive_uppercase.rb new file mode 100644 index 000000000..f0ecdab27 --- /dev/null +++ b/test/translations/identifiers/consecutive_uppercase.rb @@ -0,0 +1,24 @@ +enforce_ssl = nil +my_vpc_id = nil +parse_json = nil +to_json = nil +ec2_instance_id = nil +x509_certificate = nil +get_x = nil +some_method = nil +arn_value = nil +_private_field = nil +already_snake = nil +from_https_to_json = nil +puts(enforce_ssl) +puts(my_vpc_id) +puts(parse_json) +puts(to_json) +puts(ec2_instance_id) +puts(x509_certificate) +puts(get_x) +puts(some_method) +puts(arn_value) +puts(_private_field) +puts(already_snake) +puts(from_https_to_json) \ No newline at end of file diff --git a/test/translations/identifiers/keyword.rb b/test/translations/identifiers/keyword.rb new file mode 100644 index 000000000..fd75602fa --- /dev/null +++ b/test/translations/identifiers/keyword.rb @@ -0,0 +1,4 @@ +require 'scope-aws-lambda' +Lambda::ClassFromLambda.new({ + key: "lambda.amazonaws.com", +}) \ No newline at end of file diff --git a/test/translations/imports/import_require.rb b/test/translations/imports/import_require.rb new file mode 100644 index 000000000..b9ba8fd6d --- /dev/null +++ b/test/translations/imports/import_require.rb @@ -0,0 +1,2 @@ +require 'scope-some-module' +Mod::ClassFromModule.new \ No newline at end of file diff --git a/test/translations/imports/import_star_as.rb b/test/translations/imports/import_star_as.rb new file mode 100644 index 000000000..b9ba8fd6d --- /dev/null +++ b/test/translations/imports/import_star_as.rb @@ -0,0 +1,2 @@ +require 'scope-some-module' +Mod::ClassFromModule.new \ No newline at end of file diff --git a/test/translations/imports/jsdoc-import-tag.rb b/test/translations/imports/jsdoc-import-tag.rb new file mode 100644 index 000000000..003849a07 --- /dev/null +++ b/test/translations/imports/jsdoc-import-tag.rb @@ -0,0 +1,8 @@ +# +# @param first [SomeType] +# @param second [SomeOtherModule::SomeType] +# +def do_something(first, second) + first + second +end \ No newline at end of file diff --git a/test/translations/imports/multiple-imports.rb b/test/translations/imports/multiple-imports.rb new file mode 100644 index 000000000..03738d204 --- /dev/null +++ b/test/translations/imports/multiple-imports.rb @@ -0,0 +1,2 @@ +require 'aws-cdk-lib' +require 'constructs' \ No newline at end of file diff --git a/test/translations/imports/selective_import.rb b/test/translations/imports/selective_import.rb new file mode 100644 index 000000000..e128bb176 --- /dev/null +++ b/test/translations/imports/selective_import.rb @@ -0,0 +1,3 @@ +require 'scope-some-module' +Two.new +renamed \ No newline at end of file diff --git a/test/translations/imports/submodule-import.rb b/test/translations/imports/submodule-import.rb new file mode 100644 index 000000000..a407bddbf --- /dev/null +++ b/test/translations/imports/submodule-import.rb @@ -0,0 +1,14 @@ +require 'jsii-calc' +require_relative './.gen/providers/aws' + +# Access without existing type information +aws_kms_key_examplekms = Aws::Kms::KmsKey.new(self, "examplekms", { + deletion_window_in_days: 7, + description: "KMS key 1", +}) + +# Accesses two distinct points of the submodule hierarchy +my_class = JsiiCalc::Submodule::MyClass.new({prop: JsiiCalc::Submodule::Child::SomeEnum::SOME}) + +# Access via a renamed import +JsiiCalc::HomonymousForwardReferences::Foo::Consumer.consume({homonymous: {string_property: "yes"}}) \ No newline at end of file diff --git a/test/translations/interfaces/interface_with_method.rb b/test/translations/interfaces/interface_with_method.rb new file mode 100644 index 000000000..143f281fc --- /dev/null +++ b/test/translations/interfaces/interface_with_method.rb @@ -0,0 +1,4 @@ +module IThing + def do_a_thing + end +end \ No newline at end of file diff --git a/test/translations/interfaces/interface_with_props.rb b/test/translations/interfaces/interface_with_props.rb new file mode 100644 index 000000000..7d13d02f6 --- /dev/null +++ b/test/translations/interfaces/interface_with_props.rb @@ -0,0 +1,3 @@ +module IThing + attr_reader :thing_arn +end \ No newline at end of file diff --git a/test/translations/intersections/declare_intersection_var.rb b/test/translations/intersections/declare_intersection_var.rb new file mode 100644 index 000000000..c8ae2363b --- /dev/null +++ b/test/translations/intersections/declare_intersection_var.rb @@ -0,0 +1 @@ +some_object = nil \ No newline at end of file diff --git a/test/translations/intersections/pass_intersection_in_constructor_props.rb b/test/translations/intersections/pass_intersection_in_constructor_props.rb new file mode 100644 index 000000000..d7c5e980a --- /dev/null +++ b/test/translations/intersections/pass_intersection_in_constructor_props.rb @@ -0,0 +1 @@ +TakingClass3.new({input: ProvidedClass.new}) \ No newline at end of file diff --git a/test/translations/intersections/pass_intersection_in_struct.rb b/test/translations/intersections/pass_intersection_in_struct.rb new file mode 100644 index 000000000..4badf94da --- /dev/null +++ b/test/translations/intersections/pass_intersection_in_struct.rb @@ -0,0 +1 @@ +TakingClass2.takes({input: ProvidedClass.new}) \ No newline at end of file diff --git a/test/translations/intersections/pass_intersection_to_function.rb b/test/translations/intersections/pass_intersection_to_function.rb new file mode 100644 index 000000000..54c9a46c3 --- /dev/null +++ b/test/translations/intersections/pass_intersection_to_function.rb @@ -0,0 +1 @@ +TakingClass.takes(ProvidedClass.new) \ No newline at end of file diff --git a/test/translations/misc/booleans_render_to_right_primitives.rb b/test/translations/misc/booleans_render_to_right_primitives.rb new file mode 100644 index 000000000..0850def20 --- /dev/null +++ b/test/translations/misc/booleans_render_to_right_primitives.rb @@ -0,0 +1 @@ +call_function(true, false) \ No newline at end of file diff --git a/test/translations/statements/block_without_braces.rb b/test/translations/statements/block_without_braces.rb new file mode 100644 index 000000000..18d96d9c5 --- /dev/null +++ b/test/translations/statements/block_without_braces.rb @@ -0,0 +1 @@ +puts("hello") if x == 3 \ No newline at end of file diff --git a/test/translations/statements/declare_var.rb b/test/translations/statements/declare_var.rb new file mode 100644 index 000000000..d66924401 --- /dev/null +++ b/test/translations/statements/declare_var.rb @@ -0,0 +1 @@ +variable = nil \ No newline at end of file diff --git a/test/translations/statements/empty_control_block.rb b/test/translations/statements/empty_control_block.rb new file mode 100644 index 000000000..43e981f0a --- /dev/null +++ b/test/translations/statements/empty_control_block.rb @@ -0,0 +1,2 @@ +if x == 3 +end \ No newline at end of file diff --git a/test/translations/statements/for_of_loop.rb b/test/translations/statements/for_of_loop.rb new file mode 100644 index 000000000..e912b4287 --- /dev/null +++ b/test/translations/statements/for_of_loop.rb @@ -0,0 +1,3 @@ +xs.each do |x| + puts(x) +end \ No newline at end of file diff --git a/test/translations/statements/if.rb b/test/translations/statements/if.rb new file mode 100644 index 000000000..af635724f --- /dev/null +++ b/test/translations/statements/if.rb @@ -0,0 +1,3 @@ +if x == 3 + puts("bye") +end \ No newline at end of file diff --git a/test/translations/statements/if_then_else.rb b/test/translations/statements/if_then_else.rb new file mode 100644 index 000000000..f923a1350 --- /dev/null +++ b/test/translations/statements/if_then_else.rb @@ -0,0 +1,5 @@ +if x == 3 + puts("bye") +else + puts("toodels") +end \ No newline at end of file diff --git a/test/translations/statements/initialize_object_literal.rb b/test/translations/statements/initialize_object_literal.rb new file mode 100644 index 000000000..75f2e814c --- /dev/null +++ b/test/translations/statements/initialize_object_literal.rb @@ -0,0 +1,5 @@ +expected = { + Foo: "Bar", + Baz: 5, + Qux: ["Waldo", "Fred"], +} \ No newline at end of file diff --git a/test/translations/statements/multiline_if_then_else.rb b/test/translations/statements/multiline_if_then_else.rb new file mode 100644 index 000000000..7953c1f54 --- /dev/null +++ b/test/translations/statements/multiline_if_then_else.rb @@ -0,0 +1,6 @@ +if x == 3 + x += 1 + puts("bye") +else + puts("toodels") +end \ No newline at end of file diff --git a/test/translations/statements/statements_and_newlines.rb b/test/translations/statements/statements_and_newlines.rb new file mode 100644 index 000000000..f84763463 --- /dev/null +++ b/test/translations/statements/statements_and_newlines.rb @@ -0,0 +1,21 @@ +def do_thing + x = 1 # x seems to be equal to 1 + return x + 1 +end + +def do_thing2(x) + if x == 1 + return true + end + return false +end + +def do_thing3 + x = 1 + return x + 1 +end + +def do_thing4 + x = 1 + x = 85 +end \ No newline at end of file diff --git a/test/translations/statements/vararg_any_call.rb b/test/translations/statements/vararg_any_call.rb new file mode 100644 index 000000000..bc746efac --- /dev/null +++ b/test/translations/statements/vararg_any_call.rb @@ -0,0 +1,6 @@ +def test(*_args) +end + +test({Key: "Value", also: 1337}) + +test({Key: "Value"}, {also: 1337}) \ No newline at end of file diff --git a/test/translations/statements/whitespace_between_statements.rb b/test/translations/statements/whitespace_between_statements.rb new file mode 100644 index 000000000..86d684842 --- /dev/null +++ b/test/translations/statements/whitespace_between_statements.rb @@ -0,0 +1,3 @@ +statement_one + +statement_two \ No newline at end of file diff --git a/test/translations/statements/whitespace_between_statements_in_a_block.rb b/test/translations/statements/whitespace_between_statements_in_a_block.rb new file mode 100644 index 000000000..cd02bd31e --- /dev/null +++ b/test/translations/statements/whitespace_between_statements_in_a_block.rb @@ -0,0 +1,5 @@ +if condition + statement_one + + statement_two +end \ No newline at end of file diff --git a/test/translations/structs/any_type_never_a_struct.rb b/test/translations/structs/any_type_never_a_struct.rb new file mode 100644 index 000000000..b810f38e8 --- /dev/null +++ b/test/translations/structs/any_type_never_a_struct.rb @@ -0,0 +1,3 @@ +function_that_takes_an_any({ + argument: 5, +}) \ No newline at end of file diff --git a/test/translations/structs/infer_struct_from_union.rb b/test/translations/structs/infer_struct_from_union.rb new file mode 100644 index 000000000..77b203800 --- /dev/null +++ b/test/translations/structs/infer_struct_from_union.rb @@ -0,0 +1,6 @@ +takes({ + struct: { + enabled: false, + option: "option", + }, +}) \ No newline at end of file diff --git a/test/translations/structs/optional_known_struct.rb b/test/translations/structs/optional_known_struct.rb new file mode 100644 index 000000000..6af6c6ceb --- /dev/null +++ b/test/translations/structs/optional_known_struct.rb @@ -0,0 +1,3 @@ +Vpc.new(self, "Something", { + argument: 5, +}) \ No newline at end of file diff --git a/test/translations/structs/struct_starting_with_i.rb b/test/translations/structs/struct_starting_with_i.rb new file mode 100644 index 000000000..65c5b555e --- /dev/null +++ b/test/translations/structs/struct_starting_with_i.rb @@ -0,0 +1,3 @@ +Integration.new(self, "Something", { + argument: 5, +}) \ No newline at end of file diff --git a/test/translations/structs/var_new_class_known_struct.rb b/test/translations/structs/var_new_class_known_struct.rb new file mode 100644 index 000000000..5a9850c66 --- /dev/null +++ b/test/translations/structs/var_new_class_known_struct.rb @@ -0,0 +1,3 @@ +vpc = Vpc.new(self, "Something", { + argument: 5, +}) \ No newline at end of file diff --git a/test/translations/structs/var_new_class_unknown_struct.rb b/test/translations/structs/var_new_class_unknown_struct.rb new file mode 100644 index 000000000..5a9850c66 --- /dev/null +++ b/test/translations/structs/var_new_class_unknown_struct.rb @@ -0,0 +1,3 @@ +vpc = Vpc.new(self, "Something", { + argument: 5, +}) \ No newline at end of file diff --git a/test/translations/tsconfig.json b/test/translations/tsconfig.json new file mode 100644 index 000000000..a42ecb11b --- /dev/null +++ b/test/translations/tsconfig.json @@ -0,0 +1,9 @@ +// ~~ Generated by projen. To modify, edit .projenrc.ts and run "yarn projen". +{ + "extends": "../tsconfig.dev.json", + "references": [ + { + "path": "../tsconfig.json" + } + ] +}