diff --git a/packages/scratch-gui/src/lib/ruby-generator/data.js b/packages/scratch-gui/src/lib/ruby-generator/data.js index 6ffee33634..8d858e56cb 100644 --- a/packages/scratch-gui/src/lib/ruby-generator/data.js +++ b/packages/scratch-gui/src/lib/ruby-generator/data.js @@ -72,6 +72,14 @@ export default function (Generator) { // Check if this is the last block in procedure definition if (block._isLastReturnInProcedure) { + // Check if there's a cached super call for this method + const returnMethodMatch = comment.match(/@ruby:return:(\w+)/); + if (returnMethodMatch && Generator.returnCallCache_ && + Generator.returnCallCache_[returnMethodMatch[1]]) { + const cachedCall = Generator.returnCallCache_[returnMethodMatch[1]]; + delete Generator.returnCallCache_[returnMethodMatch[1]]; + return `${cachedCall}\n`; + } // Output just the value (implicit return) return `${Generator.nosToCode(value)}\n`; } diff --git a/packages/scratch-gui/src/lib/ruby-generator/procedure.js b/packages/scratch-gui/src/lib/ruby-generator/procedure.js index 9b96399fa2..f9cb19a4b3 100644 --- a/packages/scratch-gui/src/lib/ruby-generator/procedure.js +++ b/packages/scratch-gui/src/lib/ruby-generator/procedure.js @@ -15,11 +15,73 @@ export default function (Generator) { .toLowerCase(); }; + /** + * Generate super call code from a procedures_call block with super comment. + * @param {object} block - The procedures_call block + * @param {string} superComment - `@ruby`:super or `@ruby`:super:forwarding + * @returns {string} The super call code (without trailing newline) + */ + const generateSuperCall = function (block, superComment) { + if (superComment === '@ruby:super:forwarding') { + return 'super'; + } + // SuperNode: output 'super(args)' + const args = []; + const paramNamesIdsAndDefaults = + Generator.currentTarget.blocks.getProcedureParamNamesIdsAndDefaults(block.mutation.proccode); + const ids = paramNamesIdsAndDefaults[1]; + const defaults = paramNamesIdsAndDefaults[2]; + for (let i = 0; i < ids.length; i++) { + let value; + if (block.inputs[ids[i]]) { + value = Generator.valueToCode(block, ids[i], Generator.ORDER_NONE); + } else { + value = defaults[i]; + } + args.push(Generator.nosToCode(value)); + } + const argsString = args.length > 0 ? `(${args.join(', ')})` : ''; + return `super${argsString}`; + }; + + /** + * Generate method header with a specific name (for renamed module methods with super_of). + * @param {object} defBlock - The procedures_definition block + * @param {object} customBlock - The procedures_prototype block + * @param {string} originalName - The original method name to use + * @returns {string} The method header code + */ + const blockToMethodWithName = function (defBlock, customBlock, originalName) { + const args = []; + const paramNamesIdsAndDefaults = + Generator.currentTarget.blocks.getProcedureParamNamesIdsAndDefaults(customBlock.mutation.proccode); + for (let i = 0; i < paramNamesIdsAndDefaults[0].length; i++) { + let paramName = Generator.escapeVariableName(paramNamesIdsAndDefaults[0][i]); + paramName = toSnakeCaseLowercase(paramName); + args.push(paramName); + } + const argsString = args.length > 0 ? `(${args.join(', ')})` : ''; + if (Generator.version.toString() === '2') { + return `def ${originalName}${argsString}\n`; + } + return `def self.${originalName}${argsString}\n`; + }; + Generator.procedures_definition = function (block) { // Check for @ruby:module_source:ModuleName comment + // Extended format: @ruby:module_source:Mod:super_of:originalName const comment = Generator.getCommentText(block); const moduleSourceMatch = comment && comment.match(/^@ruby:module_source:(.+)$/); + // Check for super_of: extract original method name for renamed module methods + let superOfOriginalName = null; + if (moduleSourceMatch) { + const superOfMatch = moduleSourceMatch[1].match(/^(.+):super_of:(.+)$/); + if (superOfMatch) { + superOfOriginalName = superOfMatch[2]; + } + } + const customBlock = Generator.getInputTargetBlock(block, 'custom_block'); // Save and temporarily clear block.next to prevent scrub_ from processing it @@ -27,7 +89,13 @@ export default function (Generator) { block.next = null; // Generate method header (def self.method_name(args)) - let code = Generator.blockToCode(customBlock); + // If this is a renamed module method (super_of), use the original name + let code; + if (superOfOriginalName) { + code = blockToMethodWithName(block, customBlock, superOfOriginalName); + } else { + code = Generator.blockToCode(customBlock); + } // Generate method body from the saved next block const bodyBlock = Generator.getBlock(savedNext); @@ -43,6 +111,32 @@ export default function (Generator) { // Mark the last block so data_setvariableto knows it's the final expression if (Generator.isRubyReturnAssignment(lastBlock)) { lastBlock._isLastReturnInProcedure = true; + + // Check if the block before the return assignment is a super call. + // If so, store the super call in returnCallCache_ so the return + // assignment's data_variable can retrieve it as the implicit return value. + let prevBlock = bodyBlock; + while (prevBlock.next && Generator.getBlock(prevBlock.next) !== lastBlock) { + prevBlock = Generator.getBlock(prevBlock.next); + } + if (prevBlock !== lastBlock) { + const prevComment = Generator.getCommentText(prevBlock); + if (prevComment === '@ruby:super' || prevComment === '@ruby:super:forwarding') { + const superCode = generateSuperCall(prevBlock, prevComment); + // Set up cache so the return block outputs the super call + // The return comment contains the method name + const returnComment = Generator.getCommentText(lastBlock); + if (returnComment) { + const returnMatch = returnComment.match(/@ruby:return:(\w+)/); + if (returnMatch) { + if (!Generator.returnCallCache_) { + Generator.returnCallCache_ = {}; + } + Generator.returnCallCache_[returnMatch[1]] = superCode; + } + } + } + } } const bodyCode = Generator.blockToCode(bodyBlock); @@ -68,7 +162,12 @@ export default function (Generator) { // If this is a module method, store the code separately and suppress from main output if (moduleSourceMatch) { - const moduleName = moduleSourceMatch[1]; + // Extract module name (handle super_of: suffix) + let moduleName = moduleSourceMatch[1]; + const superOfIdx = moduleName.indexOf(':super_of:'); + if (superOfIdx !== -1) { + moduleName = moduleName.substring(0, superOfIdx); + } if (!Generator._moduleMethodCodes[moduleName]) { Generator._moduleMethodCodes[moduleName] = []; } @@ -139,8 +238,35 @@ export default function (Generator) { }; Generator.procedures_call = function (block) { - // Check if this procedures_call has @ruby:return:methodName comment const comment = Generator.getCommentText(block); + + // Check if this is a super call AND next block is a return assignment + // (i.e., super is the last expression in a method with return value) + if (comment === '@ruby:super' || comment === '@ruby:super:forwarding') { + const nextBlock = Generator.getBlock(block.next); + if (Generator.isRubyReturnAssignment(nextBlock)) { + // Super is the implicit return value — suppress this block's output. + // The data_setvariableto handler will output the value. + // Store the super call so data_setvariableto can use it. + const superCode = generateSuperCall(block, comment); + if (!Generator.returnCallCache_) { + Generator.returnCallCache_ = {}; + } + // Extract method name from the return assignment comment + const nextComment = Generator.getCommentText(nextBlock); + if (nextComment) { + const match = nextComment.match(/@ruby:return:(\w+)/); + if (match) { + Generator.returnCallCache_[match[1]] = superCode; + } + } + return ''; + } + // Super call as a standalone statement + return `${generateSuperCall(block, comment)}\n`; + } + + // Check if this procedures_call has @ruby:return:methodName comment if (comment && comment.startsWith('@ruby:return:')) { const methodName = comment.replace('@ruby:return:', ''); diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/ast-handlers/expressions.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/ast-handlers/expressions.js index 82edb4c29f..b5dd6a35f0 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/ast-handlers/expressions.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/ast-handlers/expressions.js @@ -1,7 +1,28 @@ +import {defineMessages} from 'react-intl'; import _ from 'lodash'; import Primitive from '../primitive'; import {RubyToBlocksConverterError} from '../errors'; +const messages = defineMessages({ + superNotSupportedInV1: { + defaultMessage: 'super is only available in Ruby version 2.' + + '\nPlease switch to Ruby version 2 from the settings menu.', + description: 'Error message when super is used in Ruby version 1', + id: 'gui.smalruby3.rubyToBlocksConverter.superNotSupportedInV1' + }, + superOutsideMethod: { + defaultMessage: 'super can only be used inside a method definition (def).', + description: 'Error message when super is used outside a method', + id: 'gui.smalruby3.rubyToBlocksConverter.superOutsideMethod' + }, + superWithoutModuleMethod: { + defaultMessage: 'super in "{ METHOD }" requires a same-named method in an included module.' + + '\nDefine "{ METHOD }" in a module and include it in the class.', + description: 'Error message when super is used but no matching module method exists', + id: 'gui.smalruby3.rubyToBlocksConverter.superWithoutModuleMethod' + } +}); + /** * Expression AST handlers for RubyToBlocksConverter. * @mixes RubyToBlocksConverter @@ -345,6 +366,130 @@ const ExpressionHandlers = { } return name; + }, + + /** + * Handle super(args) call - SuperNode from `@ruby`/prism. + * super is syntactic sugar for calling the same-named method in an included module. + * @param {object} node - SuperNode AST node + * @returns {object} procedures_call block for the module method + */ + visitSuperNode (node) { + return this._handleSuper(node, false); + }, + + /** + * Handle bare super call (forwarding args) - ForwardingSuperNode from `@ruby`/prism. + * @param {object} node - ForwardingSuperNode AST node + * @returns {object} procedures_call block for the module method + */ + visitForwardingSuperNode (node) { + return this._handleSuper(node, true); + }, + + /** + * Common handler for SuperNode and ForwardingSuperNode. + * @param {object} node - AST node + * @param {boolean} isForwarding - true for bare super (forward all args) + * @returns {object} procedures_call block + */ + _handleSuper (node, isForwarding) { + // Validate: v1 not supported + if (String(this.version) === '1') { + throw new RubyToBlocksConverterError( + node, + this._translator(messages.superNotSupportedInV1) + ); + } + + // Validate: must be inside a method definition + const procedureName = this._context.currentProcedureName; + if (!procedureName) { + throw new RubyToBlocksConverterError( + node, + this._translator(messages.superOutsideMethod) + ); + } + + // Validate: a same-named method must exist in an included module + const superInfo = this._context.superMethodMap && this._context.superMethodMap[procedureName]; + if (!superInfo) { + throw new RubyToBlocksConverterError( + node, + this._translator(messages.superWithoutModuleMethod, {METHOD: procedureName}) + ); + } + + // Get the renamed module procedure + const renamedProcName = superInfo.renamedProcName; + const procedure = this._lookupProcedure(renamedProcName); + if (!procedure) { + throw new RubyToBlocksConverterError( + node, + this._translator(messages.superWithoutModuleMethod, {METHOD: procedureName}) + ); + } + + // Build arguments + let args; + if (isForwarding) { + // Forward all arguments from the current method + const currentProc = this._lookupProcedure(procedureName); + if (currentProc) { + args = currentProc.argumentVariables.map(v => { + const block = this._callConvertersHandler('onVar', 'local', v); + return block || v.name; + }); + } else { + args = []; + } + } else { + // Explicit arguments from super(a, b, ...) + const savedIsValue = this._context.isValue; + this._context.isValue = true; + args = (node.arguments_ ? node.arguments_.arguments_ : []).map( + childNode => this.visit(childNode) + ); + this._context.isValue = savedIsValue; + } + + // Create procedures_call block directly (not via callMethod) to avoid + // side effects like argument boolean type conversion + const callBlock = this._createBlock('procedures_call', 'statement', { + mutation: { + argumentids: JSON.stringify(procedure.argumentIds), + proccode: procedure.procCode.join(' '), + tagName: 'mutation', + children: [], + warp: 'false' + } + }); + + // Add arguments as inputs + for (let i = 0; i < procedure.argumentIds.length; i++) { + const inputId = procedure.argumentIds[i]; + const arg = i < args.length ? args[i] : procedure.argumentDefaults[i]; + if (this._isNumberOrBlock(arg) || this._isStringOrBlock(arg)) { + this._addTextInput(callBlock, inputId, this._isNumber(arg) ? arg.toString() : arg, + procedure.argumentDefaults[i] || ''); + } else if (this._isFalse(arg)) { + // boolean false: don't add input (default) + } else { + this._addInput(callBlock, inputId, arg, null); + } + } + + // Track this call for procedure boolean detection + if (!this._context.procedureCallBlocks[procedure.id]) { + this._context.procedureCallBlocks[procedure.id] = []; + } + this._context.procedureCallBlocks[procedure.id].push(callBlock.id); + + // Attach @ruby:super or @ruby:super:forwarding comment + const commentText = isForwarding ? '@ruby:super:forwarding' : '@ruby:super'; + callBlock.comment = this._createComment(commentText, callBlock.id); + + return callBlock; } }; diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/context-utils.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/context-utils.js index 8e0b8a3e1b..330e87040a 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/context-utils.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/context-utils.js @@ -40,6 +40,8 @@ const ContextUtils = { rootNode: null, modules: {}, currentModuleName: null, + superMethodMap: {}, + superRenameTarget: null, symbols: new Set() }; if (this.vm && this.vm.runtime && this.vm.runtime.getTargetForStage) { diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/index.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/index.js index 0e7ef9346f..45bd6edc06 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/index.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/index.js @@ -1008,6 +1008,36 @@ class RubyToBlocksConverter extends Visitor { } } + // Pre-scan class methods for super usage to determine which module methods need renaming + // superMethodMap: { methodName: { moduleName, renamedProcName } } + const superMethodMap = {}; + if (node.body && node.body.body && includedModuleNames.length > 0) { + for (const stmt of node.body.body) { + if (this._getNodeTypeName(stmt) === 'DefNode' && !stmt.receiver) { + const methodName = stmt.name; + if (this._nodeContainsSuper(stmt)) { + // Find which module has a method with the same name + let foundModule = null; + for (const moduleName of includedModuleNames) { + const moduleDef = this._context.modules[moduleName]; + if (moduleDef && moduleDef.methods.some(m => m.name === methodName)) { + foundModule = moduleName; + break; + } + } + if (foundModule) { + const index = Object.keys(superMethodMap).length + 1; + superMethodMap[methodName] = { + moduleName: foundModule, + renamedProcName: `_super_${methodName}_${index}_` + }; + } + } + } + } + } + this._context.superMethodMap = superMethodMap; + // Mutual exclusion: set_sprite cannot be used with set_costumes/set_sounds (sprite only) const has = prop => Object.prototype.hasOwnProperty.call(classInfo, prop); if (!isStageClass && has('sprite') && (has('costumes') || has('sounds'))) { @@ -1114,14 +1144,31 @@ class RubyToBlocksConverter extends Visitor { const moduleDef = this._context.modules[moduleName]; this._context.currentModuleName = moduleName; for (const methodNode of moduleDef.methods) { + const originalMethodName = methodNode.name; + const superEntry = superMethodMap[originalMethodName]; + + // If this method is overridden with super, rename it + if (superEntry && superEntry.moduleName === moduleName) { + this._context.superRenameTarget = superEntry.renamedProcName; + } + const block = this.visit(methodNode); + + this._context.superRenameTarget = null; + if (block) { const blocks = Array.isArray(block) ? block : [block]; for (const b of blocks) { if (b && b.opcode === 'procedures_definition') { - // Attach @ruby:module_source:ModuleName comment + // Attach comment with super_of info if renamed + let moduleCommentText; + if (superEntry && superEntry.moduleName === moduleName) { + moduleCommentText = `@ruby:module_source:${moduleName}:super_of:${originalMethodName}`; + } else { + moduleCommentText = `@ruby:module_source:${moduleName}`; + } const commentId = this._createComment( - `@ruby:module_source:${moduleName}`, b.id, 0, 0, true + moduleCommentText, b.id, 0, 0, true ); b.comment = commentId; } @@ -1190,6 +1237,27 @@ class RubyToBlocksConverter extends Visitor { return moduleBlocks; } + /** + * Check if an AST node tree contains a SuperNode or ForwardingSuperNode. + * @param {object} node - AST node to search + * @returns {boolean} true if super is found + */ + _nodeContainsSuper (node) { + if (!node) return false; + const typeName = this._getNodeTypeName(node); + if (typeName === 'SuperNode' || typeName === 'ForwardingSuperNode') { + return true; + } + if (node.compactChildNodes) { + for (const child of node.compactChildNodes()) { + if (this._nodeContainsSuper(child)) { + return true; + } + } + } + return false; + } + _extractClassMethodArg (argNode) { const type = this._getNodeTypeName(argNode); switch (type) { diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/my-blocks.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/my-blocks.js index 5a450825fd..8010566421 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/my-blocks.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/my-blocks.js @@ -187,7 +187,8 @@ const MyBlocksConverter = { converter._enterScope('method'); - const procedureName = node.name; + // Use renamed name if this is a module method being renamed for super + const procedureName = converter._context.superRenameTarget || node.name; const block = converter._createBlock('procedures_definition', 'hat', { topLevel: true }); @@ -272,7 +273,55 @@ const MyBlocksConverter = { last = last[last.length - 1]; } const lastCommentText = (last && last.comment) ? converter._context.comments[last.comment].text : ''; - if (converter._isValueBlock(last) && + // Check if the last expression is a procedures_call to a procedure with a return + // value (e.g., super calls to module methods that return values). In this case, + // the procedures_call sets the callee's return variable, and we need to copy it + // to this procedure's return variable. + if (last && last.opcode === 'procedures_call' && + converter._isProcedureCallWithReturnValue(last) && + !lastCommentText.includes('@ruby:syntax:return')) { + const calledProccode = last.mutation.proccode; + const calledProcName = calledProccode + .split(' ') + .filter(i => !/^%[sb]$/.test(i)) + .join('_'); + const sourceVariable = converter._lookupOrCreateVariable(`@_return_${calledProcName}_`); + const targetVariable = converter._lookupOrCreateVariable(`@_return_${procedureName}_`); + + // Create a data_variable block to read the callee's return variable + const sourceBlock = converter._createBlock('data_variable', 'value_variable', { + fields: { + VARIABLE: { + name: 'VARIABLE', + id: sourceVariable.id, + value: sourceVariable.name, + variableType: sourceVariable.type + } + } + }); + + // Create assignment: @_return_procedureName_ = @_return_calledProcName_ + const returnBlock = converter._createBlock('data_setvariableto', 'statement', { + fields: { + VARIABLE: { + name: 'VARIABLE', + id: targetVariable.id, + value: targetVariable.name, + variableType: targetVariable.type + } + } + }); + returnBlock.comment = converter._createComment(`@ruby:return:${procedureName}`, returnBlock.id); + converter._addInput(returnBlock, 'VALUE', sourceBlock); + + // Link: procedures_call → return assignment + last.next = returnBlock.id; + returnBlock.parent = last.id; + body.push(returnBlock); + + // Mark procedure as having a return value + procedure.hasReturnValue = true; + } else if (converter._isValueBlock(last) && !(last instanceof Primitive && (last.type === 'self' || last.type === 'nil')) && !lastCommentText.includes('@ruby:syntax:return')) { const variable = converter._lookupOrCreateVariable(`@_return_${procedureName}_`); diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variable-utils.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variable-utils.js index 0bf628547f..02b0e58dd7 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variable-utils.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variable-utils.js @@ -186,6 +186,23 @@ const VariableUtils = { return this._context.procedures[name]; }, + /** + * Check if a procedures_call block calls a procedure that has a return value. + * @param {object} block - A procedures_call block + * @returns {boolean} true if the called procedure has hasReturnValue + */ + _isProcedureCallWithReturnValue (block) { + if (!block || block.opcode !== 'procedures_call' || !block.mutation) return false; + const proccode = block.mutation.proccode; + if (!proccode) return false; + // Extract procedure name from proccode (name is the first part before any %s/%b) + const procName = proccode.split(' ') + .filter(i => !/^%[sb]$/.test(i)) + .join('_'); + const procedure = this._lookupProcedure(procName); + return procedure && procedure.hasReturnValue; + }, + _createProcedure (name) { name = name.toString(); let procedure = this._context.procedures[name]; diff --git a/packages/scratch-gui/test/integration/ruby-super.test.js b/packages/scratch-gui/test/integration/ruby-super.test.js new file mode 100644 index 0000000000..0d27f840f8 --- /dev/null +++ b/packages/scratch-gui/test/integration/ruby-super.test.js @@ -0,0 +1,89 @@ +/** + * Integration tests for Ruby super keyword feature. + * Tests round-trip conversion: Ruby → Blocks → Ruby + */ +import path from 'path'; +import SeleniumHelper from '../helpers/selenium-helper'; +import RubyHelper from '../helpers/ruby-helper'; + +const seleniumHelper = new SeleniumHelper(); +const { + clickText, + getDriver, + loadUri +} = seleniumHelper; +const rubyHelper = new RubyHelper(seleniumHelper); +const { + expectInterconvertBetweenCodeAndRuby +} = rubyHelper; + +const uri = `${path.resolve(__dirname, '../../build/index.html')}?ruby_version=2`; + +let driver; + +describe('Ruby super keyword round-trip', () => { + beforeAll(() => { + driver = getDriver(); + }); + + afterAll(async () => { + await driver.quit(); + }); + + test('super(args) with module include', async () => { + await loadUri(uri); + await expectInterconvertBetweenCodeAndRuby( + 'module Mod\n' + + ' def func(a, b)\n' + + ' a + b\n' + + ' end\n' + + 'end\n' + + '\n' + + 'class Sprite1\n' + + ' include Mod\n' + + '\n' + + ' def func(a)\n' + + ' super(a, a)\n' + + ' end\n' + + '\n' + + ' when_flag_clicked do\n' + + ' move(func(5))\n' + + ' end\n' + + 'end' + ); + }); + + test('forwarding super with module include', async () => { + await loadUri(uri); + await expectInterconvertBetweenCodeAndRuby( + 'module Mod\n' + + ' def func(a)\n' + + ' say(a)\n' + + ' end\n' + + 'end\n' + + '\n' + + 'class Sprite1\n' + + ' include Mod\n' + + '\n' + + ' def func(a)\n' + + ' super\n' + + ' end\n' + + 'end', + // Generator adds an extra blank line before class end + 'module Mod\n' + + ' def func(a)\n' + + ' say(a)\n' + + ' end\n' + + 'end\n' + + '\n' + + 'class Sprite1\n' + + ' include Mod\n' + + '\n' + + ' def func(a)\n' + + ' super\n' + + ' end\n' + + '\n' + + 'end' + ); + }); +}); diff --git a/packages/scratch-gui/test/unit/lib/ruby-roundtrip-super.test.js b/packages/scratch-gui/test/unit/lib/ruby-roundtrip-super.test.js new file mode 100644 index 0000000000..47fbd8002c --- /dev/null +++ b/packages/scratch-gui/test/unit/lib/ruby-roundtrip-super.test.js @@ -0,0 +1,184 @@ +import RubyToBlocksConverter from '../../../src/lib/ruby-to-blocks-converter'; +import RubyGenerator from '../../../src/lib/ruby-generator'; +import Variable from '@smalruby/scratch-vm/src/engine/variable'; +import Blocks from '@smalruby/scratch-vm/src/engine/blocks'; + +// Import all block generators +import MathBlocks from '../../../src/lib/ruby-generator/math.js'; +import TextBlocks from '../../../src/lib/ruby-generator/text.js'; +import ColourBlocks from '../../../src/lib/ruby-generator/colour.js'; +import MotionBlocks from '../../../src/lib/ruby-generator/motion.js'; +import LooksBlocks from '../../../src/lib/ruby-generator/looks.js'; +import SoundBlocks from '../../../src/lib/ruby-generator/sound.js'; +import EventBlocks from '../../../src/lib/ruby-generator/event.js'; +import ControlBlocks from '../../../src/lib/ruby-generator/control.js'; +import SensingBlocks from '../../../src/lib/ruby-generator/sensing.js'; +import OperatorsBlocks from '../../../src/lib/ruby-generator/operators.js'; +import DataBlocks from '../../../src/lib/ruby-generator/data.js'; +import ProcedureBlocks from '../../../src/lib/ruby-generator/procedure.js'; +import RubyBlocks from '../../../src/lib/ruby-generator/ruby.js'; + +describe('Ruby Roundtrip/Super', () => { + let converter; + let target; + let runtime; + let vm; + + beforeEach(() => { + runtime = { + emitProjectChanged: () => {}, + getTargetForStage: () => target + }; + target = { + blocks: new Blocks(runtime), + variables: {}, + lists: {}, + broadcastMsgs: {}, + comments: {}, + isStage: false, + id: 'sprite1', + createVariable: function (id, name, type) { + this.variables[id] = new Variable(id, name, type); + }, + lookupVariableByNameAndType: function (name, type) { + for (const varId in this.variables) { + const currVar = this.variables[varId]; + if (currVar.name === name && currVar.type === type) { + return currVar; + } + } + return null; + }, + createComment: function (id, blockId, text, x, y, width, height, minimized) { + this.comments[id] = { + id, + blockId, + text, + x, + y, + width, + height, + minimized + }; + } + }; + vm = { + runtime: runtime, + emitWorkspaceUpdate: () => {} + }; + converter = new RubyToBlocksConverter(vm, {version: '2'}); + converter._context.target = target; + + // Reset RubyGenerator state + RubyGenerator.cache_ = { + comments: {}, + targetCommentTexts: [] + }; + RubyGenerator.definitions_ = {}; + RubyGenerator.functionNames_ = {}; + + // Register all block generators + MathBlocks(RubyGenerator); + TextBlocks(RubyGenerator); + ColourBlocks(RubyGenerator); + MotionBlocks(RubyGenerator); + LooksBlocks(RubyGenerator); + SoundBlocks(RubyGenerator); + EventBlocks(RubyGenerator); + ControlBlocks(RubyGenerator); + SensingBlocks(RubyGenerator); + OperatorsBlocks(RubyGenerator); + DataBlocks(RubyGenerator); + ProcedureBlocks(RubyGenerator); + RubyBlocks(RubyGenerator); + }); + + const rubyToBlocksToRuby = async (code) => { + // Ruby -> Blocks + const result = await converter.targetCodeToBlocks(target, code); + + if (!result) { + throw new Error(`Failed to convert Ruby to blocks. Errors: ${JSON.stringify(converter.errors)}`); + } + + // Apply the blocks to the target (async operation) + await converter.applyTargetBlocks(target); + + // Set up runtime.targets for class wrapping + target.runtime = runtime; + runtime.targets = [ + {isStage: true, sprite: {name: 'Stage'}}, + target + ]; + target.sprite = {name: 'Sprite1'}; + + // Blocks -> Ruby (version 2) + RubyGenerator.currentTarget = target; + const generatedCode = RubyGenerator.targetToCode(target, {version: '2'}); + + return generatedCode; + }; + + describe('super(args) round-trip', () => { + test('super(a, a) is preserved in round-trip', async () => { + const inputCode = `module Mod + def func(a, b) + a + b + end +end + +class Sprite1 + include Mod + + def func(a) + super(a, a) + end + + when_flag_clicked do + move(func(5)) + end +end +`; + const outputCode = await rubyToBlocksToRuby(inputCode); + + // Module should be regenerated + expect(outputCode).toContain('module Mod'); + expect(outputCode).toContain('def func(a, b)'); + expect(outputCode).toContain('a + b'); + + // Class should contain include and method + expect(outputCode).toContain('include Mod'); + expect(outputCode).toContain('def func(a)'); + expect(outputCode).toContain('super(a, a)'); + + // func(5) call should be preserved + expect(outputCode).toContain('func(5)'); + }); + }); + + describe('forwarding super round-trip', () => { + test('bare super is preserved in round-trip', async () => { + const inputCode = `module Mod + def func(a) + say(a) + end +end + +class Sprite1 + include Mod + + def func(a) + super + end +end +`; + const outputCode = await rubyToBlocksToRuby(inputCode); + + expect(outputCode).toContain('module Mod'); + expect(outputCode).toContain('def func(a)'); + expect(outputCode).toContain('super'); + // Should be bare super, not super(a) + expect(outputCode).not.toMatch(/super\(/); + }); + }); +}); diff --git a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/super.test.js b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/super.test.js new file mode 100644 index 0000000000..0eb0054268 --- /dev/null +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/super.test.js @@ -0,0 +1,314 @@ +import RubyToBlocksConverter from '../../../../src/lib/ruby-to-blocks-converter'; + +describe('RubyToBlocksConverter/Super', () => { + let converter; + let target; + + beforeEach(() => { + converter = new RubyToBlocksConverter(null, {version: '2'}); + target = null; + }); + + describe('error cases', () => { + test('super in v1 throws error', async () => { + const converterV1 = new RubyToBlocksConverter(null, {version: '1'}); + const code = ` + module Mod + def func(a, b) + a + b + end + end + + class Sprite1 + include Mod + + def func(a) + super(a, a) + end + end + `; + const result = await converterV1.targetCodeToBlocks(target, code); + expect(result).toBeFalsy(); + expect(converterV1.errors.length).toBeGreaterThan(0); + }); + + test('super in stage class throws error', async () => { + const stageTarget = { + isStage: true, + variables: {}, + sprite: {} + }; + const code = ` + module Mod + def func(a) + say(a) + end + end + + class Stage + include Mod + + def func(a) + super(a) + end + end + `; + // Stage doesn't support include, so it should error at include level + const result = await converter.targetCodeToBlocks(stageTarget, code); + expect(result).toBeFalsy(); + expect(converter.errors.length).toBeGreaterThan(0); + }); + + test('super outside method throws error', async () => { + const code = ` + module Mod + def func(a) + say(a) + end + end + + class Sprite1 + include Mod + super(1) + end + `; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeFalsy(); + expect(converter.errors.length).toBeGreaterThan(0); + expect(converter.errors[0].text).toMatch(/super/i); + }); + + test('super without module include throws error', async () => { + const code = ` + class Sprite1 + def func(a) + super(a) + end + end + `; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeFalsy(); + expect(converter.errors.length).toBeGreaterThan(0); + expect(converter.errors[0].text).toMatch(/super/i); + }); + + test('super when no same-named method in module throws error', async () => { + const code = ` + module Mod + def helper(a) + say(a) + end + end + + class Sprite1 + include Mod + + def func(a) + super(a) + end + end + `; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeFalsy(); + expect(converter.errors.length).toBeGreaterThan(0); + expect(converter.errors[0].text).toMatch(/super/i); + }); + + test('forwarding super (no args) in v1 throws error', async () => { + const converterV1 = new RubyToBlocksConverter(null, {version: '1'}); + const code = ` + module Mod + def func(a) + say(a) + end + end + + class Sprite1 + include Mod + + def func(a) + super + end + end + `; + const result = await converterV1.targetCodeToBlocks(target, code); + expect(result).toBeFalsy(); + expect(converterV1.errors.length).toBeGreaterThan(0); + }); + + test('forwarding super outside method throws error', async () => { + const code = ` + module Mod + def func(a) + say(a) + end + end + + class Sprite1 + include Mod + super + end + `; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeFalsy(); + expect(converter.errors.length).toBeGreaterThan(0); + expect(converter.errors[0].text).toMatch(/super/i); + }); + + test('forwarding super without same-named method in module throws error', async () => { + const code = ` + module Mod + def helper(a) + say(a) + end + end + + class Sprite1 + include Mod + + def func(a) + super + end + end + `; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeFalsy(); + expect(converter.errors.length).toBeGreaterThan(0); + expect(converter.errors[0].text).toMatch(/super/i); + }); + }); + + describe('basic super conversion', () => { + test('super(args) converts module method to renamed procedure and creates super call', async () => { + const code = ` + module Mod + def func(a, b) + a + b + end + end + + class Sprite1 + include Mod + + def func(a) + super(a, a) + end + + when_flag_clicked do + move(func(5)) + end + end + `; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + + const blocks = converter._context.blocks; + + // Check that there are two procedures_definition blocks + const procDefs = Object.values(blocks).filter(b => b.opcode === 'procedures_definition'); + expect(procDefs).toHaveLength(2); + + // Find the module method (renamed) and class method + const moduleProc = procDefs.find(b => { + const comment = converter._context.comments[b.comment]; + return comment && comment.text.includes('module_source'); + }); + const classProc = procDefs.find(b => b !== moduleProc); + + expect(moduleProc).toBeTruthy(); + expect(classProc).toBeTruthy(); + + // Module method should be renamed to _super_func_1_ + const modulePrototype = blocks[moduleProc.inputs.custom_block.block]; + expect(modulePrototype.mutation.proccode).toMatch(/^_super_func_1_ /); + + // Module method comment should include super_of:func + const moduleComment = converter._context.comments[moduleProc.comment]; + expect(moduleComment.text).toEqual('@ruby:module_source:Mod:super_of:func'); + + // Class method should keep the name 'func' + const classPrototype = blocks[classProc.inputs.custom_block.block]; + expect(classPrototype.mutation.proccode).toMatch(/^func /); + + // Check that super call exists as procedures_call to _super_func_1_ + const superCalls = Object.values(blocks).filter(b => { + if (b.opcode !== 'procedures_call') return false; + const comment = b.comment && converter._context.comments[b.comment]; + return comment && comment.text === '@ruby:super'; + }); + expect(superCalls).toHaveLength(1); + expect(superCalls[0].mutation.proccode).toMatch(/^_super_func_1_ /); + }); + + test('forwarding super converts to procedures_call with forwarded args', async () => { + const code = ` + module Mod + def func(a) + say(a) + end + end + + class Sprite1 + include Mod + + def func(a) + super + end + end + `; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + + const blocks = converter._context.blocks; + + // Check that super call exists with forwarding comment + const superCalls = Object.values(blocks).filter(b => { + if (b.opcode !== 'procedures_call') return false; + const comment = b.comment && converter._context.comments[b.comment]; + return comment && comment.text === '@ruby:super:forwarding'; + }); + expect(superCalls).toHaveLength(1); + expect(superCalls[0].mutation.proccode).toMatch(/^_super_func_1_ /); + }); + + test('class method func(5) calls the class method, not the module method', async () => { + const code = ` + module Mod + def func(a, b) + a + b + end + end + + class Sprite1 + include Mod + + def func(a) + super(a, a) + end + + when_flag_clicked do + move(func(5)) + end + end + `; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + + const blocks = converter._context.blocks; + + // Find all procedures_call blocks that are NOT super calls + const regularCalls = Object.values(blocks).filter(b => { + if (b.opcode !== 'procedures_call') return false; + const comment = b.comment && converter._context.comments[b.comment]; + return !comment || !comment.text.startsWith('@ruby:super'); + }); + + // The func(5) call should reference the class method 'func', not '_super_func_1_' + const funcCall = regularCalls.find(b => b.mutation.proccode.startsWith('func ')); + expect(funcCall).toBeTruthy(); + }); + }); +});