From 642e08b1440f342f4b407cf6daedab76d9fe6d2e Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 14 Mar 2026 00:53:47 +0900 Subject: [PATCH 01/14] feat: add module/include support to ruby-to-blocks converter Add visitModuleNode to parse Ruby module definitions and store method ASTs in context. Modify visitClassNode to handle include statements, expanding module methods as procedures_definition blocks with @ruby:module_source:ModuleName comments. Add error handling for v1, nested modules, undefined modules, and non-method statements in modules. Co-Authored-By: Claude Opus 4.6 --- .../ruby-to-blocks-converter/context-utils.js | 4 +- .../src/lib/ruby-to-blocks-converter/index.js | 131 ++++++++- .../ruby-to-blocks-converter/module.test.js | 252 ++++++++++++++++++ 3 files changed, 382 insertions(+), 5 deletions(-) create mode 100644 packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/module.test.js 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 1552441bd6f..15230ab6681 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 @@ -37,7 +37,9 @@ const ContextUtils = { lineToNodeMap: new Map(), containerNodeRanges: [], // Store {startLine, endLine} for container nodes (block, begin, kwbegin) processDepth: 0, - rootNode: null + rootNode: null, + modules: {}, + currentModuleName: null }; if (this.vm && this.vm.runtime && this.vm.runtime.getTargetForStage) { this._loadVariables(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 8c4296111fa..8cdb32a7bb6 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 @@ -105,6 +105,27 @@ const messages = defineMessages({ defaultMessage: 'Stage class can only inherit from ::Smalruby3::Stage or Smalruby3::Stage.', description: 'Error message when Stage class has invalid superclass', id: 'gui.smalruby3.rubyToBlocksConverter.invalidStageSuperclass' + }, + moduleNotSupportedInV1: { + defaultMessage: 'module is only available in Ruby version 2.' + + '\nPlease switch to Ruby version 2 from the settings menu.', + description: 'Error message when module syntax is used in Ruby version 1', + id: 'gui.smalruby3.rubyToBlocksConverter.moduleNotSupportedInV1' + }, + nestedModuleNotSupported: { + defaultMessage: 'Nested modules are not supported in Smalruby.', + description: 'Error message when a module is nested inside another module', + id: 'gui.smalruby3.rubyToBlocksConverter.nestedModuleNotSupported' + }, + onlyMethodsInModule: { + defaultMessage: 'Only method definitions (def) can be placed inside a module in Smalruby.', + description: 'Error message when non-def statement is inside a module', + id: 'gui.smalruby3.rubyToBlocksConverter.onlyMethodsInModule' + }, + undefinedModule: { + defaultMessage: 'Module "{ NAME }" is not defined.', + description: 'Error message when include references an undefined module', + id: 'gui.smalruby3.rubyToBlocksConverter.undefinedModule' } }); @@ -414,6 +435,48 @@ class RubyToBlocksConverter extends Visitor { return this.visit(node.statements); } + visitModuleNode (node) { + // module definitions are only supported in version 2 + if (String(this.version) === '1') { + throw new RubyToBlocksConverterError( + node, + this._translator(messages.moduleNotSupportedInV1) + ); + } + + // Nested modules are not supported + if (this._context.currentModuleName) { + throw new RubyToBlocksConverterError( + node, + this._translator(messages.nestedModuleNotSupported) + ); + } + + const moduleName = node.name; + + // Validate: only DefNode allowed in module body + if (node.body && node.body.body) { + for (const stmt of node.body.body) { + const typeName = this._getNodeTypeName(stmt); + if (typeName !== 'DefNode') { + throw new RubyToBlocksConverterError( + stmt, + this._translator(messages.onlyMethodsInModule) + ); + } + } + } + + // Save the module's method DefNodes in context for later expansion by include + this._context.modules[moduleName] = { + name: moduleName, + methods: (node.body && node.body.body) ? node.body.body : [] + }; + + // Module definition itself does not produce blocks + return []; + } + visitClassNode (node) { // class definitions are only supported in version 2 if (String(this.version) === '1') { @@ -532,6 +595,33 @@ class RubyToBlocksConverter extends Visitor { } } + // Pre-scan for include statements and collect included module names (in order) + const includedModuleNames = []; + const includeStatements = new Set(); + if (node.body && node.body.body) { + for (const stmt of node.body.body) { + if (this._getNodeTypeName(stmt) === 'CallNode' && + stmt.name === 'include' && + !stmt.receiver && + stmt.arguments_ && + stmt.arguments_.arguments_.length === 1) { + const argNode = stmt.arguments_.arguments_[0]; + const argType = this._getNodeTypeName(argNode); + if (argType === 'ConstantReadNode') { + const moduleName = argNode.name; + if (!this._context.modules[moduleName]) { + throw new RubyToBlocksConverterError( + stmt, + this._translator(messages.undefinedModule, {NAME: moduleName}) + ); + } + includedModuleNames.push(moduleName); + includeStatements.add(stmt); + } + } + } + } + // 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'))) { @@ -612,6 +702,11 @@ class RubyToBlocksConverter extends Visitor { } }); } + // Add include= parts for each included module (in order) + includedModuleNames.forEach(moduleName => { + commentParts.push(`include=${moduleName}`); + }); + if (commentParts.length > 0) { commentText = `@ruby:class:${commentParts.join(',')}`; } else { @@ -627,7 +722,31 @@ class RubyToBlocksConverter extends Visitor { this._context.classInfo = classInfo; } - // Visit class body, filtering out set_xxx calls + // Expand included module methods: visit each module's DefNodes and attach comments + const moduleBlocks = []; + for (const moduleName of includedModuleNames) { + const moduleDef = this._context.modules[moduleName]; + this._context.currentModuleName = moduleName; + for (const methodNode of moduleDef.methods) { + const block = this.visit(methodNode); + 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 + const commentId = this._createComment( + `@ruby:module_source:${moduleName}`, b.id, 0, 0, true + ); + b.comment = commentId; + } + } + moduleBlocks.push(...blocks); + } + } + this._context.currentModuleName = null; + } + + // Visit class body, filtering out set_xxx calls and include statements if (node.body && node.body.body) { const filteredStatements = node.body.body.filter(stmt => { if (this._getNodeTypeName(stmt) === 'CallNode' && @@ -635,11 +754,15 @@ class RubyToBlocksConverter extends Visitor { !stmt.receiver) { return false; } + // Filter out include statements (already processed above) + if (includeStatements.has(stmt)) { + return false; + } return true; }); if (filteredStatements.length === 0) { - return []; + return moduleBlocks; } // Visit filtered statements manually @@ -676,9 +799,9 @@ class RubyToBlocksConverter extends Visitor { } } - return blocks; + return [...moduleBlocks, ...blocks]; } - return []; + return moduleBlocks; } _extractClassMethodArg (argNode) { diff --git a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/module.test.js b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/module.test.js new file mode 100644 index 00000000000..b97677c27e4 --- /dev/null +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/module.test.js @@ -0,0 +1,252 @@ +import RubyToBlocksConverter from '../../../../src/lib/ruby-to-blocks-converter'; + +describe('RubyToBlocksConverter/Module', () => { + let converter; + let target; + + beforeEach(() => { + converter = new RubyToBlocksConverter(null, {version: '2'}); + target = null; + }); + + describe('basic module with include', () => { + test('module with method creates procedures_definition with @ruby:module_source comment', async () => { + const code = ` + module Utils + def add(a, b) + a + b + end + end + + class Sprite1 + include Utils + + self.when(:flag_clicked) do + move(10) + end + end + `; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + + // Check that procedures_definition block exists + const blocks = converter._context.blocks; + const procDefs = Object.values(blocks).filter(b => b.opcode === 'procedures_definition'); + expect(procDefs).toHaveLength(1); + + // Check @ruby:module_source:Utils comment on the procedures_definition + const procDef = procDefs[0]; + expect(procDef.comment).toBeTruthy(); + const comment = converter._context.comments[procDef.comment]; + expect(comment.text).toEqual('@ruby:module_source:Utils'); + + // Check class comment includes include=Utils + const classComments = Object.values(converter._context.comments).filter(c => + c.blockId === null && c.text.startsWith('@ruby:class') + ); + expect(classComments).toHaveLength(1); + expect(classComments[0].text).toContain('include=Utils'); + }); + + test('module method with no arguments', async () => { + const code = ` + module Utils + def greet + say("hello") + end + end + + class Sprite1 + include Utils + end + `; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + + const blocks = converter._context.blocks; + const procDefs = Object.values(blocks).filter(b => b.opcode === 'procedures_definition'); + expect(procDefs).toHaveLength(1); + + // Verify the procedure name via the prototype mutation + const customBlockInput = procDefs[0].inputs.custom_block; + const prototype = blocks[customBlockInput.block]; + expect(prototype.mutation.proccode).toEqual('greet'); + }); + + test('module with multiple methods', async () => { + const code = ` + module Utils + def add(a, b) + a + b + end + + def multiply(a, b) + a * b + end + end + + class Sprite1 + include Utils + + self.when(:flag_clicked) do + move(10) + end + end + `; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + + const blocks = converter._context.blocks; + const procDefs = Object.values(blocks).filter(b => b.opcode === 'procedures_definition'); + expect(procDefs).toHaveLength(2); + + // Both should have @ruby:module_source:Utils comment + procDefs.forEach(pd => { + expect(pd.comment).toBeTruthy(); + const comment = converter._context.comments[pd.comment]; + expect(comment.text).toEqual('@ruby:module_source:Utils'); + }); + }); + + test('multiple modules with include', async () => { + const code = ` + module Utils + def add(a, b) + a + b + end + end + + module Helpers + def greet + say("hello") + end + end + + class Sprite1 + include Utils + include Helpers + + self.when(:flag_clicked) do + move(10) + end + end + `; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + + const blocks = converter._context.blocks; + const procDefs = Object.values(blocks).filter(b => b.opcode === 'procedures_definition'); + expect(procDefs).toHaveLength(2); + + // Check module source comments + const moduleComments = procDefs.map(pd => converter._context.comments[pd.comment].text); + expect(moduleComments).toContain('@ruby:module_source:Utils'); + expect(moduleComments).toContain('@ruby:module_source:Helpers'); + + // Check class comment includes both includes in order + const classComments = Object.values(converter._context.comments).filter(c => + c.blockId === null && c.text.startsWith('@ruby:class') + ); + expect(classComments).toHaveLength(1); + expect(classComments[0].text).toContain('include=Utils'); + expect(classComments[0].text).toContain('include=Helpers'); + // Order should be Utils first, then Helpers + const text = classComments[0].text; + expect(text.indexOf('include=Utils')).toBeLessThan(text.indexOf('include=Helpers')); + }); + }); + + describe('v1 restrictions', () => { + test('module in v1 throws error', async () => { + const converterV1 = new RubyToBlocksConverter(null, {version: '1'}); + const code = ` + module Utils + def add(a, b) + a + b + end + end + `; + const result = await converterV1.targetCodeToBlocks(target, code); + expect(result).toBeFalsy(); + expect(converterV1.errors.length).toBeGreaterThan(0); + }); + }); + + describe('error handling', () => { + test('include with undefined module throws error', async () => { + const code = ` + class Sprite1 + include NonExistent + + self.when(:flag_clicked) do + move(10) + end + end + `; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeFalsy(); + expect(converter.errors.length).toBeGreaterThan(0); + }); + + test('non-method statement in module throws error', async () => { + const code = ` + module Utils + x = 1 + end + + class Sprite1 + include Utils + end + `; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeFalsy(); + expect(converter.errors.length).toBeGreaterThan(0); + }); + + test('nested module throws error', async () => { + const code = ` + module Outer + module Inner + def foo + end + end + end + `; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeFalsy(); + expect(converter.errors.length).toBeGreaterThan(0); + }); + }); + + describe('module before class in code', () => { + test('module defined before class, procedures are created', async () => { + const code = ` + module Utils + def add(a, b) + a + b + end + end + + class Sprite1 + include Utils + end + `; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + + const blocks = converter._context.blocks; + const procDefs = Object.values(blocks).filter(b => b.opcode === 'procedures_definition'); + expect(procDefs).toHaveLength(1); + + // The procedure block should have the module_source comment + const procDef = procDefs[0]; + const comment = converter._context.comments[procDef.comment]; + expect(comment.text).toEqual('@ruby:module_source:Utils'); + }); + }); +}); From 20097f4880b6f9f841506eee66fb3d82c31147c9 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 14 Mar 2026 01:01:24 +0900 Subject: [PATCH 02/14] feat: generate module/include syntax from procedure comments In procedure.js, detect @ruby:module_source:ModuleName comments on procedures_definition blocks, store the generated code separately in _moduleMethodCodes, and suppress from main output. In finish(), generate module...end blocks before the class definition. In _wrapWithClass(), parse include=ModuleName from class comment and generate include statements inside the class body. Co-Authored-By: Claude Opus 4.6 --- .../src/lib/ruby-generator/index.js | 55 +++++++- .../src/lib/ruby-generator/procedure.js | 14 +++ .../unit/lib/ruby-generator/module.test.js | 118 ++++++++++++++++++ 3 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 packages/scratch-gui/test/unit/lib/ruby-generator/module.test.js diff --git a/packages/scratch-gui/src/lib/ruby-generator/index.js b/packages/scratch-gui/src/lib/ruby-generator/index.js index b51ca1bfc91..1e9391e544a 100644 --- a/packages/scratch-gui/src/lib/ruby-generator/index.js +++ b/packages/scratch-gui/src/lib/ruby-generator/index.js @@ -117,6 +117,7 @@ RubyGenerator.init = function (options) { this.notEqualsCallCache_ = {}; this.greaterThanOrEqualCallCache_ = {}; this.lessThanOrEqualCallCache_ = {}; + this._moduleMethodCodes = {}; this.version = options && options.version ? String(options.version) : '1'; if (this.variableDB_) { this.variableDB_.reset(); @@ -153,6 +154,34 @@ RubyGenerator.finish = function (code, options) { } } + // Generate module...end blocks from collected module method codes + let moduleCode = ''; + if (classComment) { + // Parse include= from class comment to determine module order + const includeModuleNames = []; + if (classComment.startsWith('@ruby:class:')) { + const attrPart = classComment.slice('@ruby:class:'.length); + const attrs = attrPart.split(','); + for (const attr of attrs) { + const includeMatch = attr.match(/^include=(.+)$/); + if (includeMatch) { + includeModuleNames.push(includeMatch[1]); + } + } + } + + // Generate module blocks in include order + for (const moduleName of includeModuleNames) { + const methods = this._moduleMethodCodes[moduleName]; + if (methods && methods.length > 0) { + const methodsCode = methods.join('\n'); + moduleCode += `module ${moduleName}\n`; + moduleCode += this.prefixLines(methodsCode, this.INDENT); + moduleCode += `end\n\n`; + } + } + } + // For version 1 file output (withSpriteNew), use Sprite.new format // even when @ruby:class comment is present. // For version 2, @ruby:class takes priority over withSpriteNew. @@ -181,7 +210,7 @@ RubyGenerator.finish = function (code, options) { code = `${commentCodes.join('\n')}\n${code}`; } - if (defs.length === 0 && code.length === 0) { + if (defs.length === 0 && moduleCode.length === 0 && code.length === 0) { return ''; } @@ -190,7 +219,7 @@ RubyGenerator.finish = function (code, options) { s += `${defs.join('\n')}\n\n`; } - return s + code; + return s + moduleCode + code; }; // Check if a string is a valid Ruby constant name (class name) @@ -202,6 +231,7 @@ RubyGenerator._wrapWithClass = function (code, classComment, forFileOutput) { const target = this.currentTarget; const isStage = target && target.isStage; let className; + const includeNames = []; const setLines = []; // Parse attribute list from @ruby:class:attr1,attr2,... @@ -242,6 +272,15 @@ RubyGenerator._wrapWithClass = function (code, classComment, forFileOutput) { // Replace sprite=Name with plain 'sprite' for attribute processing (already handled) allowedAttributes[spriteAttrIndex] = 'sprite'; } + + // Extract include=ModuleName entries (in order) and remove from allowedAttributes + for (let i = allowedAttributes.length - 1; i >= 0; i--) { + const includeMatch = allowedAttributes[i].match(/^include=(.+)$/); + if (includeMatch) { + includeNames.unshift(includeMatch[1]); + allowedAttributes.splice(i, 1); + } + } } // Determine if this is an auto-wrap (no user-defined @ruby:class attributes) @@ -301,6 +340,12 @@ RubyGenerator._wrapWithClass = function (code, classComment, forFileOutput) { setCode = setLines.map(line => `${this.INDENT}${line}\n`).join(''); } + // Generate include statements for modules + let includeCode = ''; + if (includeNames.length > 0) { + includeCode = includeNames.map(name => `${this.INDENT}include ${name}\n`).join(''); + } + let outsideCode = ''; if (forFileOutput && code.length > 0) { // Split code into top-level sections (separated by blank lines) @@ -337,14 +382,16 @@ RubyGenerator._wrapWithClass = function (code, classComment, forFileOutput) { if (code.length > 0) { code = this.prefixLines(code, this.INDENT); } - const separator = setCode.length > 0 && code.length > 0 ? '\n' : ''; + // Build the inner class content with separators + const innerParts = [setCode, includeCode, code].filter(p => p.length > 0); + const innerCode = innerParts.join('\n'); let inheritance = ''; if (superclassPath) { inheritance = ` < ${superclassPath}`; } else if (forFileOutput) { inheritance = ' < ::Smalruby3::Sprite'; } - code = `class ${className}${inheritance}\n${setCode}${separator}${code}end\n`; + code = `class ${className}${inheritance}\n${innerCode}end\n`; if (outsideCode.length > 0) { code += outsideCode; diff --git a/packages/scratch-gui/src/lib/ruby-generator/procedure.js b/packages/scratch-gui/src/lib/ruby-generator/procedure.js index 78daa26321a..9b96399fa2a 100644 --- a/packages/scratch-gui/src/lib/ruby-generator/procedure.js +++ b/packages/scratch-gui/src/lib/ruby-generator/procedure.js @@ -16,6 +16,10 @@ export default function (Generator) { }; Generator.procedures_definition = function (block) { + // Check for @ruby:module_source:ModuleName comment + const comment = Generator.getCommentText(block); + const moduleSourceMatch = comment && comment.match(/^@ruby:module_source:(.+)$/); + const customBlock = Generator.getInputTargetBlock(block, 'custom_block'); // Save and temporarily clear block.next to prevent scrub_ from processing it @@ -62,6 +66,16 @@ export default function (Generator) { // (we've already manually processed it above) block._skipNextInScrub = true; + // If this is a module method, store the code separately and suppress from main output + if (moduleSourceMatch) { + const moduleName = moduleSourceMatch[1]; + if (!Generator._moduleMethodCodes[moduleName]) { + Generator._moduleMethodCodes[moduleName] = []; + } + Generator._moduleMethodCodes[moduleName].push(code); + return ''; + } + return code; }; diff --git a/packages/scratch-gui/test/unit/lib/ruby-generator/module.test.js b/packages/scratch-gui/test/unit/lib/ruby-generator/module.test.js new file mode 100644 index 00000000000..7e1f747d2ed --- /dev/null +++ b/packages/scratch-gui/test/unit/lib/ruby-generator/module.test.js @@ -0,0 +1,118 @@ +import RubyGenerator from '../../../../src/lib/ruby-generator'; + +describe('RubyGenerator/Module', () => { + beforeEach(() => { + RubyGenerator.init({version: '2'}); + RubyGenerator.definitions_ = {}; + RubyGenerator.requires_ = {}; + RubyGenerator.prepares_ = {}; + RubyGenerator.cache_ = { + targetCommentTexts: [], + comments: {} + }; + }); + + const makeMockTarget = (name, index = 1) => { + const targets = []; + const stage = {isStage: true, sprite: {name: 'Stage'}}; + for (let i = 0; i < index; i++) { + targets.push({isStage: false, sprite: {name: `Sprite${i + 1}`}}); + } + const target = targets[index - 1]; + target.sprite = {name}; + targets.unshift(stage); + return { + target, + runtime: {targets} + }; + }; + + describe('_moduleMethodCodes initialization', () => { + test('init() resets _moduleMethodCodes', () => { + RubyGenerator._moduleMethodCodes = {Utils: ['def foo\nend\n']}; + RubyGenerator.init({version: '2'}); + expect(RubyGenerator._moduleMethodCodes).toEqual({}); + }); + }); + + describe('finish(): module wrapping and include', () => { + test('generates module...end before class and include inside class', () => { + const {target, runtime} = makeMockTarget('Sprite1', 1); + target.runtime = runtime; + RubyGenerator.currentTarget_ = target; + RubyGenerator.cache_.targetCommentTexts = ['@ruby:class:include=Utils']; + + // Simulate module method codes collected during block generation + RubyGenerator._moduleMethodCodes = { + Utils: ['def add(a, b)\n a + b\nend\n'] + }; + + const code = 'self.when(:flag_clicked) do\n move(10)\nend\n'; + const result = RubyGenerator.finish(code, {}); + + // module...end should appear before class + expect(result).toContain('module Utils'); + expect(result).toContain(' def add(a, b)'); + expect(result).toContain('end\n'); + + // include inside class + expect(result).toContain('include Utils'); + + // module should be before class + const moduleIdx = result.indexOf('module Utils'); + const classIdx = result.indexOf('class Sprite1'); + expect(moduleIdx).toBeLessThan(classIdx); + + // include should be inside class (after class line, before end) + const includeIdx = result.indexOf('include Utils'); + expect(includeIdx).toBeGreaterThan(classIdx); + }); + + test('multiple modules with include order preserved', () => { + const {target, runtime} = makeMockTarget('Sprite1', 1); + target.runtime = runtime; + RubyGenerator.currentTarget_ = target; + RubyGenerator.cache_.targetCommentTexts = [ + '@ruby:class:include=Utils,include=Helpers' + ]; + + RubyGenerator._moduleMethodCodes = { + Utils: ['def add(a, b)\n a + b\nend\n'], + Helpers: ['def greet\n say("hello")\nend\n'] + }; + + const code = 'self.when(:flag_clicked) do\n move(10)\nend\n'; + const result = RubyGenerator.finish(code, {}); + + // Both modules should appear + expect(result).toContain('module Utils'); + expect(result).toContain('module Helpers'); + + // Module order: Utils first then Helpers (following include order) + expect(result.indexOf('module Utils')).toBeLessThan(result.indexOf('module Helpers')); + + // include order preserved inside class + expect(result).toContain('include Utils'); + expect(result).toContain('include Helpers'); + const includeUtilsIdx = result.indexOf('include Utils'); + const includeHelpersIdx = result.indexOf('include Helpers'); + expect(includeUtilsIdx).toBeLessThan(includeHelpersIdx); + }); + + test('no module codes means no module block generated', () => { + const {target, runtime} = makeMockTarget('Sprite1', 1); + target.runtime = runtime; + RubyGenerator.currentTarget_ = target; + RubyGenerator.cache_.targetCommentTexts = ['@ruby:class']; + + RubyGenerator._moduleMethodCodes = {}; + + const code = 'self.when(:flag_clicked) do\n move(10)\nend\n'; + const result = RubyGenerator.finish(code, {}); + + expect(result).not.toContain('module '); + expect(result).not.toContain('include '); + expect(result).toContain('class Sprite1'); + }); + }); +}); From b2f2f1efb85531e18d3b5235939b4633dca0d3ec Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 14 Mar 2026 01:05:47 +0900 Subject: [PATCH 03/14] feat: add immediate module sync across sprites Create module-sync.js with utilities to detect module changes, find affected sprites, and sync module definitions by regenerating Ruby code, replacing the module definition, and re-converting. Integrate sync into ruby-tab.jsx after code-to-blocks conversion (v2 only). Co-Authored-By: Claude Opus 4.6 --- .../scratch-gui/src/containers/ruby-tab.jsx | 31 ++- packages/scratch-gui/src/lib/module-sync.js | 129 +++++++++++++ .../test/unit/lib/module-sync.test.js | 180 ++++++++++++++++++ 3 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 packages/scratch-gui/src/lib/module-sync.js create mode 100644 packages/scratch-gui/test/unit/lib/module-sync.test.js diff --git a/packages/scratch-gui/src/containers/ruby-tab.jsx b/packages/scratch-gui/src/containers/ruby-tab.jsx index 5ee55a323ca..6ac47fe0ef3 100644 --- a/packages/scratch-gui/src/containers/ruby-tab.jsx +++ b/packages/scratch-gui/src/containers/ruby-tab.jsx @@ -19,6 +19,9 @@ import {BLOCKS_TAB_INDEX, RUBY_TAB_INDEX} from '../reducers/editor-tab'; import RubyToBlocksConverterHOC from '../lib/ruby-to-blocks-converter-hoc.jsx'; import {targetCodeToBlocks} from '../lib/ruby-to-blocks-converter'; +// === Smalruby: Start of module sync === +import {syncModules} from '../lib/module-sync'; +// === Smalruby: End of module sync === import QuickFixProvider from './ruby-tab/quick-fix-provider'; import { @@ -557,8 +560,20 @@ const RubyTab = props => { if (rubyCode.modified) { const converter = await targetCodeToBlocksHOC(intl); if (converter.result) { - converter.apply().then(() => { + converter.apply().then(async () => { clearErrors(); + // === Smalruby: Start of module sync === + if (rubyCode.target && String(newVersion) === '2') { + try { + await syncModules( + vm, rubyCode.target, intl, newVersion + ); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Module sync error:', e); + } + } + // === Smalruby: End of module sync === updateRubyCodeTargetState(vm.editingTarget, newVersion); }); } else { @@ -786,9 +801,21 @@ const RubyTab = props => { if (changedTarget || blocksTabVisible) { targetCodeToBlocksHOC(intl).then(converter => { if (converter.result) { - converter.apply().then(() => { + converter.apply().then(async () => { modified = false; clearErrors(); + // === Smalruby: Start of module sync === + if (rubyCode.target && String(rubyVersion) === '2') { + try { + await syncModules( + vm, rubyCode.target, intl, rubyVersion + ); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Module sync error:', e); + } + } + // === Smalruby: End of module sync === if (!modified) { const etChanged = editingTarget && editingTarget !== prev.editingTarget; diff --git a/packages/scratch-gui/src/lib/module-sync.js b/packages/scratch-gui/src/lib/module-sync.js new file mode 100644 index 00000000000..c1071c3c02a --- /dev/null +++ b/packages/scratch-gui/src/lib/module-sync.js @@ -0,0 +1,129 @@ +// === Smalruby: This file is Smalruby-specific (module sync across sprites) === + +import RubyGenerator from './ruby-generator'; +import {targetCodeToBlocks} from './ruby-to-blocks-converter'; + +/** + * Get module names from a target's procedure block comments. + * @param {object} target - A Scratch RenderedTarget + * @returns {Set} Set of module names found in the target + */ +export const getModuleNamesFromTarget = target => { + const moduleNames = new Set(); + if (!target || !target.comments) return moduleNames; + + for (const commentId in target.comments) { + const comment = target.comments[commentId]; + const match = comment.text && comment.text.match(/^@ruby:module_source:(.+)$/); + if (match) { + moduleNames.add(match[1]); + } + } + return moduleNames; +}; + +/** + * Find all other targets that include a given module. + * @param {object} vm - Scratch VM + * @param {string} moduleName - Module name to search for + * @param {string} excludeTargetId - Target ID to exclude from results + * @returns {Array} Targets that have the module + */ +export const findTargetsWithModule = (vm, moduleName, excludeTargetId) => { + const targets = []; + for (const target of vm.runtime.targets) { + if (target.id === excludeTargetId) continue; + if (!target.comments) continue; + + for (const commentId in target.comments) { + const comment = target.comments[commentId]; + if (comment.text === `@ruby:module_source:${moduleName}`) { + targets.push(target); + break; + } + } + } + return targets; +}; + +/** + * Generate Ruby code for a single target (without require statements). + * @param {object} target - A Scratch RenderedTarget + * @param {string} version - Ruby version ('1' or '2') + * @returns {string} Generated Ruby code + */ +export const generateTargetCode = (target, version) => { + RubyGenerator.initTargets({}); + return RubyGenerator.targetToCode_(target, { + withSpriteNew: true, + version + }); +}; + +/** + * Extract a module definition (module Name ... end) from Ruby code. + * @param {string} code - Ruby code + * @param {string} moduleName - Module name to extract + * @returns {string|null} The module definition code, or null if not found + */ +export const extractModuleCode = (code, moduleName) => { + const regex = new RegExp(`^module ${moduleName}\\n[\\s\\S]*?^end\\n`, 'm'); + const match = code.match(regex); + return match ? match[0] : null; +}; + +/** + * Replace a module definition in Ruby code with a new one. + * @param {string} code - Original Ruby code + * @param {string} moduleName - Module name to replace + * @param {string} newModuleCode - New module definition + * @returns {string} Updated Ruby code + */ +export const replaceModuleCode = (code, moduleName, newModuleCode) => { + const regex = new RegExp(`^module ${moduleName}\\n[\\s\\S]*?^end\\n`, 'm'); + return code.replace(regex, newModuleCode); +}; + +/** + * Sync module changes from a source target to all other targets that include the same modules. + * Called after Ruby→Blocks conversion completes on the source target. + * + * @param {object} vm - Scratch VM + * @param {object} sourceTarget - The target whose code was just converted + * @param {object} intl - react-intl instance for error translation + * @param {string} version - Ruby version ('1' or '2') + * @returns {Promise} + */ +export const syncModules = async (vm, sourceTarget, intl, version) => { + // 1. Find which modules exist in the source target + const moduleNames = getModuleNamesFromTarget(sourceTarget); + if (moduleNames.size === 0) return; + + // 2. Generate Ruby code from source target to get current module definitions + const sourceCode = generateTargetCode(sourceTarget, version); + + // 3. For each module, sync to other targets + for (const moduleName of moduleNames) { + const newModuleCode = extractModuleCode(sourceCode, moduleName); + if (!newModuleCode) continue; + + const otherTargets = findTargetsWithModule(vm, moduleName, sourceTarget.id); + + for (const target of otherTargets) { + // Generate Ruby from the other target's current blocks + const targetCode = generateTargetCode(target, version); + + // Replace the old module definition with the new one + const updatedCode = replaceModuleCode(targetCode, moduleName, newModuleCode); + if (updatedCode === targetCode) continue; // No change + + // Re-convert the updated code to blocks and apply + const converter = await targetCodeToBlocks( + vm, target, updatedCode, intl, {version} + ); + if (converter.result) { + await converter.apply(); + } + } + } +}; diff --git a/packages/scratch-gui/test/unit/lib/module-sync.test.js b/packages/scratch-gui/test/unit/lib/module-sync.test.js new file mode 100644 index 00000000000..061a932d805 --- /dev/null +++ b/packages/scratch-gui/test/unit/lib/module-sync.test.js @@ -0,0 +1,180 @@ +import { + getModuleNamesFromTarget, + findTargetsWithModule, + extractModuleCode, + replaceModuleCode +} from '../../../src/lib/module-sync'; + +describe('module-sync', () => { + describe('getModuleNamesFromTarget', () => { + test('returns empty set for target without comments', () => { + const target = {comments: {}}; + expect(getModuleNamesFromTarget(target)).toEqual(new Set()); + }); + + test('returns empty set for null target', () => { + expect(getModuleNamesFromTarget(null)).toEqual(new Set()); + }); + + test('finds module names from @ruby:module_source comments', () => { + const target = { + comments: { + c1: {text: '@ruby:module_source:Utils', blockId: 'b1'}, + c2: {text: '@ruby:module_source:Helpers', blockId: 'b2'}, + c3: {text: '@ruby:class', blockId: null}, + c4: {text: '@ruby:return:add', blockId: 'b3'} + } + }; + const result = getModuleNamesFromTarget(target); + expect(result).toEqual(new Set(['Utils', 'Helpers'])); + }); + + test('deduplicates module names', () => { + const target = { + comments: { + c1: {text: '@ruby:module_source:Utils', blockId: 'b1'}, + c2: {text: '@ruby:module_source:Utils', blockId: 'b2'} + } + }; + const result = getModuleNamesFromTarget(target); + expect(result).toEqual(new Set(['Utils'])); + }); + }); + + describe('findTargetsWithModule', () => { + test('finds targets with matching module comments', () => { + const sprite1 = { + id: 'sprite1', + comments: { + c1: {text: '@ruby:module_source:Utils', blockId: 'b1'} + } + }; + const sprite2 = { + id: 'sprite2', + comments: { + c1: {text: '@ruby:module_source:Utils', blockId: 'b2'} + } + }; + const sprite3 = { + id: 'sprite3', + comments: { + c1: {text: '@ruby:class', blockId: null} + } + }; + const vm = { + runtime: {targets: [sprite1, sprite2, sprite3]} + }; + + const result = findTargetsWithModule(vm, 'Utils', 'sprite1'); + expect(result).toHaveLength(1); + expect(result[0].id).toEqual('sprite2'); + }); + + test('excludes the source target', () => { + const sprite1 = { + id: 'sprite1', + comments: {c1: {text: '@ruby:module_source:Utils', blockId: 'b1'}} + }; + const vm = {runtime: {targets: [sprite1]}}; + + const result = findTargetsWithModule(vm, 'Utils', 'sprite1'); + expect(result).toHaveLength(0); + }); + + test('returns empty array when no targets match', () => { + const sprite1 = { + id: 'sprite1', + comments: {c1: {text: '@ruby:class', blockId: null}} + }; + const vm = {runtime: {targets: [sprite1]}}; + + const result = findTargetsWithModule(vm, 'Utils', 'other'); + expect(result).toHaveLength(0); + }); + }); + + describe('extractModuleCode', () => { + test('extracts module definition from code', () => { + const code = [ + 'module Utils', + ' def add(a, b)', + ' a + b', + ' end', + 'end', + '', + 'class Sprite1', + ' include Utils', + 'end', + '' + ].join('\n'); + + const result = extractModuleCode(code, 'Utils'); + expect(result).toEqual( + 'module Utils\n def add(a, b)\n a + b\n end\nend\n' + ); + }); + + test('returns null when module not found', () => { + const code = 'class Sprite1\nend\n'; + expect(extractModuleCode(code, 'Utils')).toBeNull(); + }); + + test('extracts correct module when multiple modules exist', () => { + const code = [ + 'module Utils', + ' def add(a, b)', + ' a + b', + ' end', + 'end', + '', + 'module Helpers', + ' def greet', + ' say("hello")', + ' end', + 'end', + '', + 'class Sprite1', + 'end', + '' + ].join('\n'); + + const utils = extractModuleCode(code, 'Utils'); + expect(utils).toContain('def add'); + expect(utils).not.toContain('def greet'); + + const helpers = extractModuleCode(code, 'Helpers'); + expect(helpers).toContain('def greet'); + expect(helpers).not.toContain('def add'); + }); + }); + + describe('replaceModuleCode', () => { + test('replaces module definition', () => { + const code = [ + 'module Utils', + ' def add(a, b)', + ' a + b', + ' end', + 'end', + '', + 'class Sprite1', + ' include Utils', + 'end', + '' + ].join('\n'); + + const newModule = 'module Utils\n def add(a, b, c)\n a + b + c\n end\nend\n'; + const result = replaceModuleCode(code, 'Utils', newModule); + + expect(result).toContain('def add(a, b, c)'); + expect(result).not.toContain('def add(a, b)\n'); + expect(result).toContain('class Sprite1'); + }); + + test('returns unchanged code when module not found', () => { + const code = 'class Sprite1\nend\n'; + const result = replaceModuleCode(code, 'Utils', 'module Utils\nend\n'); + expect(result).toEqual(code); + }); + }); +}); From b7fc30e17087fe8ac14700811bfedf2eb184ddaa Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 14 Mar 2026 01:07:56 +0900 Subject: [PATCH 04/14] feat: deduplicate module definitions in multi-target output In finishTargets(), extract all module...end blocks from the combined multi-target code, keep unique ones, and place them once before the class definitions. This prevents duplicate module definitions when multiple sprites include the same module. Co-Authored-By: Claude Opus 4.6 --- .../src/lib/ruby-generator/index.js | 24 ++++ .../unit/lib/ruby-generator/module.test.js | 113 ++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/packages/scratch-gui/src/lib/ruby-generator/index.js b/packages/scratch-gui/src/lib/ruby-generator/index.js index 1e9391e544a..e3abe9312e4 100644 --- a/packages/scratch-gui/src/lib/ruby-generator/index.js +++ b/packages/scratch-gui/src/lib/ruby-generator/index.js @@ -471,6 +471,30 @@ RubyGenerator.finishTargets = function (code, _options) { s += `${prepares.join('\n')}\n\n`; } + // Deduplicate module definitions in multi-target output. + // Extract all module...end blocks, keep unique ones, place them before class definitions. + const moduleRegex = /^module (\w+)\n[\s\S]*?^end\n/gm; + const seenModules = new Set(); + const uniqueModules = []; + let match; + while ((match = moduleRegex.exec(code)) !== null) { + const moduleName = match[1]; + if (!seenModules.has(moduleName)) { + seenModules.add(moduleName); + uniqueModules.push(match[0]); + } + } + + if (uniqueModules.length > 0) { + // Remove all module definitions from code + code = code.replace(moduleRegex, ''); + // Clean up extra blank lines left by removal + code = code.replace(/\n{3,}/g, '\n\n').replace(/^\n+/, ''); + // Prepend unique modules + const modulesCode = uniqueModules.join('\n'); + code = `${modulesCode}\n${code}`; + } + return s + code; }; diff --git a/packages/scratch-gui/test/unit/lib/ruby-generator/module.test.js b/packages/scratch-gui/test/unit/lib/ruby-generator/module.test.js index 7e1f747d2ed..88b45779d03 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-generator/module.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-generator/module.test.js @@ -115,4 +115,117 @@ describe('RubyGenerator/Module', () => { expect(result).toContain('class Sprite1'); }); }); + + describe('finishTargets(): module deduplication', () => { + test('deduplicates module definitions in multi-target output', () => { + RubyGenerator.initTargets({requires: ['smalruby3']}); + + // Simulate multi-target output where module appears in each target + const code = [ + 'module Utils', + ' def add(a, b)', + ' a + b', + ' end', + 'end', + '', + 'class Sprite1', + ' include Utils', + 'end', + '', + 'module Utils', + ' def add(a, b)', + ' a + b', + ' end', + 'end', + '', + 'class Sprite2', + ' include Utils', + 'end', + '' + ].join('\n'); + + const result = RubyGenerator.finishTargets(code, {}); + + // Module should appear only once + const moduleCount = (result.match(/^module Utils$/gm) || []).length; + expect(moduleCount).toEqual(1); + + // Both classes should still be present + expect(result).toContain('class Sprite1'); + expect(result).toContain('class Sprite2'); + + // include should be in both classes + const includeCount = (result.match(/include Utils/g) || []).length; + expect(includeCount).toEqual(2); + + // require should be at the top + expect(result).toContain('require "smalruby3"'); + + // module should be after require, before classes + const requireIdx = result.indexOf('require "smalruby3"'); + const moduleIdx = result.indexOf('module Utils'); + const class1Idx = result.indexOf('class Sprite1'); + expect(requireIdx).toBeLessThan(moduleIdx); + expect(moduleIdx).toBeLessThan(class1Idx); + }); + + test('deduplicates multiple different modules', () => { + RubyGenerator.initTargets({requires: ['smalruby3']}); + + const code = [ + 'module Utils', + ' def add(a, b)', + ' a + b', + ' end', + 'end', + '', + 'module Helpers', + ' def greet', + ' say("hello")', + ' end', + 'end', + '', + 'class Sprite1', + ' include Utils', + ' include Helpers', + 'end', + '', + 'module Utils', + ' def add(a, b)', + ' a + b', + ' end', + 'end', + '', + 'module Helpers', + ' def greet', + ' say("hello")', + ' end', + 'end', + '', + 'class Sprite2', + ' include Utils', + ' include Helpers', + 'end', + '' + ].join('\n'); + + const result = RubyGenerator.finishTargets(code, {}); + + const utilsCount = (result.match(/^module Utils$/gm) || []).length; + const helpersCount = (result.match(/^module Helpers$/gm) || []).length; + expect(utilsCount).toEqual(1); + expect(helpersCount).toEqual(1); + }); + + test('no modules means no deduplication needed', () => { + RubyGenerator.initTargets({requires: ['smalruby3']}); + + const code = 'class Sprite1\nend\n\nclass Sprite2\nend\n'; + const result = RubyGenerator.finishTargets(code, {}); + + expect(result).toContain('require "smalruby3"'); + expect(result).toContain('class Sprite1'); + expect(result).toContain('class Sprite2'); + }); + }); }); From 36b17e9ca9c91d17a3ba43f20e9ef6da4683e8f2 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 14 Mar 2026 01:13:46 +0900 Subject: [PATCH 05/14] feat: add error handling and v1 switch prevention for module/include Add module_function and extend error detection in converter. Prevent switching to Ruby v1 when v2 features (module/class) are in use. Co-Authored-By: Claude Opus 4.6 --- .../src/components/menu-bar/settings-menu.jsx | 20 +++++++ .../src/lib/ruby-to-blocks-converter/index.js | 58 +++++++++++++------ .../src/lib/settings/ruby-version/index.js | 6 ++ .../ruby-to-blocks-converter/module.test.js | 32 ++++++++++ 4 files changed, 99 insertions(+), 17 deletions(-) diff --git a/packages/scratch-gui/src/components/menu-bar/settings-menu.jsx b/packages/scratch-gui/src/components/menu-bar/settings-menu.jsx index 7052b81254c..133cb1398e1 100644 --- a/packages/scratch-gui/src/components/menu-bar/settings-menu.jsx +++ b/packages/scratch-gui/src/components/menu-bar/settings-menu.jsx @@ -82,6 +82,26 @@ const SettingsMenu = ({ alert(intl.formatMessage(rubyVersionMessages.koshienCannotChangeRubyVersion)); return; } + // === Smalruby: Start of v1 switch prevention === + // Prevent switching to v1 when v2 features (module/class) are in use + if (rubyVersion === '1' && vm.runtime) { // eslint-disable-line react/prop-types + const hasV2Features = vm.runtime.targets.some(target => { // eslint-disable-line react/prop-types + if (!target.comments) return false; + return Object.values(target.comments).some(comment => + comment.text && ( + comment.text.startsWith('@ruby:module_source:') || + comment.text === '@ruby:class' || + comment.text.startsWith('@ruby:class:') + ) + ); + }); + if (hasV2Features) { + // eslint-disable-next-line no-alert + alert(intl.formatMessage(rubyVersionMessages.cannotSwitchToV1)); + return; + } + } + // === Smalruby: End of v1 switch prevention === onChangeRubyVersion(rubyVersion); }, [intl, vm, onChangeRubyVersion]); 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 8cdb32a7bb6..d680e41e246 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 @@ -126,6 +126,16 @@ const messages = defineMessages({ defaultMessage: 'Module "{ NAME }" is not defined.', description: 'Error message when include references an undefined module', id: 'gui.smalruby3.rubyToBlocksConverter.undefinedModule' + }, + moduleFunctionNotSupported: { + defaultMessage: 'module_function is not supported in Smalruby.', + description: 'Error message when module_function is used', + id: 'gui.smalruby3.rubyToBlocksConverter.moduleFunctionNotSupported' + }, + extendNotSupported: { + defaultMessage: 'extend is not supported in Smalruby.', + description: 'Error message when extend is used', + id: 'gui.smalruby3.rubyToBlocksConverter.extendNotSupported' } }); @@ -458,6 +468,12 @@ class RubyToBlocksConverter extends Visitor { if (node.body && node.body.body) { for (const stmt of node.body.body) { const typeName = this._getNodeTypeName(stmt); + if (typeName === 'CallNode' && stmt.name === 'module_function' && !stmt.receiver) { + throw new RubyToBlocksConverterError( + stmt, + this._translator(messages.moduleFunctionNotSupported) + ); + } if (typeName !== 'DefNode') { throw new RubyToBlocksConverterError( stmt, @@ -595,28 +611,36 @@ class RubyToBlocksConverter extends Visitor { } } - // Pre-scan for include statements and collect included module names (in order) + // Pre-scan for include/extend statements const includedModuleNames = []; const includeStatements = new Set(); if (node.body && node.body.body) { for (const stmt of node.body.body) { - if (this._getNodeTypeName(stmt) === 'CallNode' && - stmt.name === 'include' && - !stmt.receiver && - stmt.arguments_ && - stmt.arguments_.arguments_.length === 1) { - const argNode = stmt.arguments_.arguments_[0]; - const argType = this._getNodeTypeName(argNode); - if (argType === 'ConstantReadNode') { - const moduleName = argNode.name; - if (!this._context.modules[moduleName]) { - throw new RubyToBlocksConverterError( - stmt, - this._translator(messages.undefinedModule, {NAME: moduleName}) - ); + if (this._getNodeTypeName(stmt) === 'CallNode' && !stmt.receiver) { + // Reject extend + if (stmt.name === 'extend') { + throw new RubyToBlocksConverterError( + stmt, + this._translator(messages.extendNotSupported) + ); + } + // Handle include + if (stmt.name === 'include' && + stmt.arguments_ && + stmt.arguments_.arguments_.length === 1) { + const argNode = stmt.arguments_.arguments_[0]; + const argType = this._getNodeTypeName(argNode); + if (argType === 'ConstantReadNode') { + const moduleName = argNode.name; + if (!this._context.modules[moduleName]) { + throw new RubyToBlocksConverterError( + stmt, + this._translator(messages.undefinedModule, {NAME: moduleName}) + ); + } + includedModuleNames.push(moduleName); + includeStatements.add(stmt); } - includedModuleNames.push(moduleName); - includeStatements.add(stmt); } } } diff --git a/packages/scratch-gui/src/lib/settings/ruby-version/index.js b/packages/scratch-gui/src/lib/settings/ruby-version/index.js index 1d7b0916ad3..1418110b377 100644 --- a/packages/scratch-gui/src/lib/settings/ruby-version/index.js +++ b/packages/scratch-gui/src/lib/settings/ruby-version/index.js @@ -23,6 +23,12 @@ const messages = defineMessages({ id: 'gui.menuBar.koshienCannotChangeRubyVersion', defaultMessage: 'The Ruby version cannot be changed when the Koshien extension is loaded.', description: 'Alert message when trying to change Ruby version with Koshien extension' + }, + cannotSwitchToV1: { + id: 'gui.menuBar.cannotSwitchToV1', + defaultMessage: 'Cannot switch to v1 because v2 features (module, class) are in use.' + + '\nRemove all module/class definitions first.', + description: 'Alert message when trying to switch to v1 with v2 features in use' } }); diff --git a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/module.test.js b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/module.test.js index b97677c27e4..a4b6d053cbf 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/module.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/module.test.js @@ -220,6 +220,38 @@ describe('RubyToBlocksConverter/Module', () => { expect(result).toBeFalsy(); expect(converter.errors.length).toBeGreaterThan(0); }); + + test('module_function throws error', async () => { + const code = ` + module Utils + module_function + + def add(a, b) + a + b + end + end + `; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeFalsy(); + expect(converter.errors.length).toBeGreaterThan(0); + }); + + test('extend throws error', async () => { + const code = ` + module Utils + def add(a, b) + a + b + end + end + + class Sprite1 + extend Utils + end + `; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeFalsy(); + expect(converter.errors.length).toBeGreaterThan(0); + }); }); describe('module before class in code', () => { From b1f28247dbeaaf542f88830795a7394be689296c Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 14 Mar 2026 01:30:33 +0900 Subject: [PATCH 06/14] fix: remove withSpriteNew from module sync to fix cross-sprite sync The generateTargetCode function in module-sync.js was using withSpriteNew: true, which caused the generated Ruby to include set_costumes/set_sounds calls that fail validation during reconversion. Removing this option generates clean class definitions that can be successfully re-converted. Co-Authored-By: Claude Opus 4.6 --- packages/scratch-gui/src/lib/module-sync.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/scratch-gui/src/lib/module-sync.js b/packages/scratch-gui/src/lib/module-sync.js index c1071c3c02a..6d1058babf2 100644 --- a/packages/scratch-gui/src/lib/module-sync.js +++ b/packages/scratch-gui/src/lib/module-sync.js @@ -55,7 +55,6 @@ export const findTargetsWithModule = (vm, moduleName, excludeTargetId) => { export const generateTargetCode = (target, version) => { RubyGenerator.initTargets({}); return RubyGenerator.targetToCode_(target, { - withSpriteNew: true, version }); }; From 31e0008de90128bead2a8988ddc3db61cbdb8775 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 14 Mar 2026 01:35:28 +0900 Subject: [PATCH 07/14] test: add integration tests for Ruby module/include feature - Round-trip tests: single method, multiple methods, multiple modules, no-argument method - Sync test: verifies adding a method to a module in one sprite propagates the new method definition to another sprite Co-Authored-By: Claude Opus 4.6 --- .../test/integration/ruby-module.test.js | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 packages/scratch-gui/test/integration/ruby-module.test.js diff --git a/packages/scratch-gui/test/integration/ruby-module.test.js b/packages/scratch-gui/test/integration/ruby-module.test.js new file mode 100644 index 00000000000..0103625f4e0 --- /dev/null +++ b/packages/scratch-gui/test/integration/ruby-module.test.js @@ -0,0 +1,289 @@ +/** + * Integration tests for Ruby module/include 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 module/include round-trip', () => { + beforeAll(() => { + driver = getDriver(); + }); + + afterAll(async () => { + await driver.quit(); + }); + + test('module with single method', async () => { + await loadUri(uri); + await expectInterconvertBetweenCodeAndRuby( + 'module Utils\n' + + ' def add(a, b)\n' + + ' a + b\n' + + ' end\n' + + 'end\n' + + '\n' + + 'class Sprite1\n' + + ' include Utils\n' + + '\n' + + ' self.when(:flag_clicked) do\n' + + ' say(add(1, 5))\n' + + ' end\n' + + 'end', + // Generator outputs when_flag_clicked instead of self.when(:flag_clicked) + 'module Utils\n' + + ' def add(a, b)\n' + + ' a + b\n' + + ' end\n' + + 'end\n' + + '\n' + + 'class Sprite1\n' + + ' include Utils\n' + + '\n' + + ' when_flag_clicked do\n' + + ' say(add(1, 5))\n' + + ' end\n' + + 'end' + ); + }); + + test('module with multiple methods', async () => { + await loadUri(uri); + await expectInterconvertBetweenCodeAndRuby( + 'module Utils\n' + + ' def add(a, b)\n' + + ' a + b\n' + + ' end\n' + + '\n' + + ' def multiply(a, b)\n' + + ' a * b\n' + + ' end\n' + + 'end\n' + + '\n' + + 'class Sprite1\n' + + ' include Utils\n' + + '\n' + + ' self.when(:flag_clicked) do\n' + + ' say(add(1, 5))\n' + + ' end\n' + + 'end', + 'module Utils\n' + + ' def add(a, b)\n' + + ' a + b\n' + + ' end\n' + + '\n' + + ' def multiply(a, b)\n' + + ' a * b\n' + + ' end\n' + + 'end\n' + + '\n' + + 'class Sprite1\n' + + ' include Utils\n' + + '\n' + + ' when_flag_clicked do\n' + + ' say(add(1, 5))\n' + + ' end\n' + + 'end' + ); + }); + + test('multiple modules with include', async () => { + await loadUri(uri); + await expectInterconvertBetweenCodeAndRuby( + 'module Utils\n' + + ' def add(a, b)\n' + + ' a + b\n' + + ' end\n' + + 'end\n' + + '\n' + + 'module Helpers\n' + + ' def greet\n' + + ' say("hello")\n' + + ' end\n' + + 'end\n' + + '\n' + + 'class Sprite1\n' + + ' include Utils\n' + + ' include Helpers\n' + + '\n' + + ' self.when(:flag_clicked) do\n' + + ' move(10)\n' + + ' end\n' + + 'end', + 'module Utils\n' + + ' def add(a, b)\n' + + ' a + b\n' + + ' end\n' + + 'end\n' + + '\n' + + 'module Helpers\n' + + ' def greet\n' + + ' say("hello")\n' + + ' end\n' + + 'end\n' + + '\n' + + 'class Sprite1\n' + + ' include Utils\n' + + ' include Helpers\n' + + '\n' + + ' when_flag_clicked do\n' + + ' move(10)\n' + + ' end\n' + + 'end' + ); + }); + + test('module method with no arguments', async () => { + await loadUri(uri); + await expectInterconvertBetweenCodeAndRuby( + 'module Utils\n' + + ' def greet\n' + + ' say("hello")\n' + + ' end\n' + + 'end\n' + + '\n' + + 'class Sprite1\n' + + ' include Utils\n' + + 'end' + ); + }); + + test('module sync: adding sprite with same module gets synced definition', async () => { + await loadUri(uri); + + // Set module code on Sprite1 and convert + await clickText('Ruby', '*[@role="tab"]'); + await rubyHelper.fillInRubyProgram( + 'module Utils\n' + + ' def add(a, b)\n' + + ' a + b\n' + + ' end\n' + + 'end\n' + + '\n' + + 'class Sprite1\n' + + ' include Utils\n' + + '\n' + + ' self.when(:flag_clicked) do\n' + + ' say(add(1, 5))\n' + + ' end\n' + + 'end' + ); + await clickText('Code', '*[@role="tab"]'); + + // Wait for conversion + await driver.sleep(3000); + + // Add Sprite2 programmatically + await driver.executeScript(` + const vm = window.smalruby ? window.smalruby.vm : null; + if (!vm) throw new Error('smalruby.vm not available'); + return vm.addSprite(JSON.stringify({ + isStage: false, + name: "Sprite2", + variables: {}, lists: {}, broadcasts: {}, + blocks: {}, comments: {}, + currentCostume: 0, + costumes: [{ name: "コスチューム1", bitmapResolution: 1, + dataFormat: "svg", assetId: "bcf454acf82e4504149f7ffe07081571", + md5ext: "bcf454acf82e4504149f7ffe07081571.svg", + rotationCenterX: 48, rotationCenterY: 50 }], + sounds: [], volume: 100, visible: true, + x: 0, y: 0, size: 100, direction: 90, + draggable: false, rotationStyle: "all around" + })); + `); + + // Select Sprite2 + await driver.executeScript(` + const vm = window.smalruby.vm; + const sprite2 = vm.runtime.targets.find(t => t.sprite && t.sprite.name === 'Sprite2'); + vm.setEditingTarget(sprite2.id); + `); + + // Set module code on Sprite2 and convert + await clickText('Ruby', '*[@role="tab"]'); + await rubyHelper.fillInRubyProgram( + 'module Utils\n' + + ' def add(a, b)\n' + + ' a + b\n' + + ' end\n' + + 'end\n' + + '\n' + + 'class Sprite2\n' + + ' include Utils\n' + + '\n' + + ' self.when(:flag_clicked) do\n' + + ' say(add(1, 5))\n' + + ' end\n' + + 'end' + ); + await clickText('Code', '*[@role="tab"]'); + await driver.sleep(3000); + + // Verify Sprite2 has the add procedure + const sprite2Procs = await driver.executeScript(` + const vm = window.smalruby.vm; + const sprite2 = vm.runtime.targets.find(t => t.sprite && t.sprite.name === 'Sprite2'); + const blocks = Object.values(sprite2.blocks._blocks); + return blocks.filter(b => b.opcode === 'procedures_definition').length; + `); + expect(sprite2Procs).toBe(1); + + // Now modify Sprite1's module: add multiply method + await driver.executeScript(` + const vm = window.smalruby.vm; + const sprite1 = vm.runtime.targets.find(t => t.sprite && t.sprite.name === 'Sprite1'); + vm.setEditingTarget(sprite1.id); + `); + // Need to wait for target switch + await driver.sleep(500); + + await clickText('Ruby', '*[@role="tab"]'); + await rubyHelper.fillInRubyProgram( + 'module Utils\n' + + ' def add(a, b)\n' + + ' a + b\n' + + ' end\n' + + '\n' + + ' def multiply(a, b)\n' + + ' a * b\n' + + ' end\n' + + 'end\n' + + '\n' + + 'class Sprite1\n' + + ' include Utils\n' + + '\n' + + ' self.when(:flag_clicked) do\n' + + ' say(add(1, 5))\n' + + ' end\n' + + 'end' + ); + await clickText('Code', '*[@role="tab"]'); + await driver.sleep(3000); + + // Verify Sprite2 now has 2 procedures (synced multiply) + const sprite2ProcsAfterSync = await driver.executeScript(` + const vm = window.smalruby.vm; + const sprite2 = vm.runtime.targets.find(t => t.sprite && t.sprite.name === 'Sprite2'); + const blocks = Object.values(sprite2.blocks._blocks); + return blocks.filter(b => b.opcode === 'procedures_definition').length; + `); + expect(sprite2ProcsAfterSync).toBe(2); + }); +}); From 5c672086173f907a47cb7f57ead18410c9fa197d Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 14 Mar 2026 08:02:06 +0900 Subject: [PATCH 08/14] feat: auto-import modules from other sprites and restrict module/include on stage - When a sprite uses `include Mod` without defining the module locally, the converter automatically searches other sprites for `@ruby:module_source:Mod` comments, generates Ruby from that sprite, extracts the module definition, parses it, and makes it available for include expansion. - Module definitions and include statements are now blocked on Stage targets since stage and sprite have different available methods. - Added Japanese locale translations for all module-related error messages. Co-Authored-By: Claude Opus 4.6 --- .../src/lib/ruby-to-blocks-converter/index.js | 100 ++++++++++++++++- packages/scratch-gui/src/locales/ja-Hira.js | 10 ++ packages/scratch-gui/src/locales/ja.js | 10 ++ .../ruby-to-blocks-converter/module.test.js | 104 ++++++++++++++++++ 4 files changed, 222 insertions(+), 2 deletions(-) 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 d680e41e246..230295e43ad 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 @@ -18,6 +18,7 @@ import LineMappingUtils from './line-mapping'; import ConverterRegistry from './converter-registry'; import TargetApplier from './target-applier'; import PrismErrorTranslator from './prism-error-translator'; +import {findTargetsWithModule, generateTargetCode, extractModuleCode} from '../module-sync'; import spritesLibrary from '../libraries/sprites.json'; import costumesLibrary from '../libraries/costumes.json'; import soundsLibrary from '../libraries/sounds.json'; @@ -136,6 +137,23 @@ const messages = defineMessages({ defaultMessage: 'extend is not supported in Smalruby.', description: 'Error message when extend is used', id: 'gui.smalruby3.rubyToBlocksConverter.extendNotSupported' + }, + moduleNotSupportedInStage: { + defaultMessage: 'module is not supported in Stage.' + + '\nModules can only be used in sprite classes.', + description: 'Error message when module syntax is used in Stage', + id: 'gui.smalruby3.rubyToBlocksConverter.moduleNotSupportedInStage' + }, + includeNotSupportedInStage: { + defaultMessage: 'include is not supported in class Stage.' + + '\nModules can only be included in sprite classes.', + description: 'Error message when include is used in class Stage', + id: 'gui.smalruby3.rubyToBlocksConverter.includeNotSupportedInStage' + }, + moduleImportFailed: { + defaultMessage: 'Failed to import module "{ NAME }" from other sprites.', + description: 'Error message when module auto-import from other sprites fails', + id: 'gui.smalruby3.rubyToBlocksConverter.moduleImportFailed' } }); @@ -454,6 +472,16 @@ class RubyToBlocksConverter extends Visitor { ); } + // === Smalruby: Start of stage module restriction === + // module is not supported in Stage (stage and sprite have different available methods) + if (this._context.target && this._context.target.isStage) { + throw new RubyToBlocksConverterError( + node, + this._translator(messages.moduleNotSupportedInStage) + ); + } + // === Smalruby: End of stage module restriction === + // Nested modules are not supported if (this._context.currentModuleName) { throw new RubyToBlocksConverterError( @@ -493,6 +521,57 @@ class RubyToBlocksConverter extends Visitor { return []; } + // === Smalruby: Start of auto-import module from other sprites === + /** + * Try to import a module definition from other sprites. + * Searches other sprites' block comments for `@ruby:module_source:moduleName`, + * generates Ruby code from the found sprite, extracts the module definition, + * parses it, and stores the DefNodes in this._context.modules. + * @param {string} moduleName - The module name to import + * @returns {boolean} true if the module was successfully imported + */ + _importModuleFromOtherSprites (moduleName) { + if (!this.vm || !this.vm.runtime) return false; + + const currentTargetId = this._context.target ? this._context.target.id : null; + const sourceCandidates = findTargetsWithModule(this.vm, moduleName, currentTargetId); + if (sourceCandidates.length === 0) return false; + + // Use the first sprite that has the module + const sourceTarget = sourceCandidates[0]; + const sourceCode = generateTargetCode(sourceTarget, String(this.version)); + const moduleCode = extractModuleCode(sourceCode, moduleName); + if (!moduleCode) return false; + + // Parse the module code to get DefNodes + const prism = RubyParser.getPrism(); + if (!prism) return false; + + const parseResult = prism.parse(moduleCode); + if (parseResult.errors.length > 0) return false; + + // The parsed result should be: ProgramNode > StatementsNode > [ModuleNode] + const root = parseResult.value; + let moduleNode = null; + if (root.statements && root.statements.body) { + for (const stmt of root.statements.body) { + if (this._getNodeTypeName(stmt) === 'ModuleNode') { + moduleNode = stmt; + break; + } + } + } + if (!moduleNode) return false; + + // Store the module's method DefNodes + this._context.modules[moduleName] = { + name: moduleName, + methods: (moduleNode.body && moduleNode.body.body) ? moduleNode.body.body : [] + }; + return true; + } + // === Smalruby: End of auto-import module from other sprites === + visitClassNode (node) { // class definitions are only supported in version 2 if (String(this.version) === '1') { @@ -632,12 +711,29 @@ class RubyToBlocksConverter extends Visitor { const argType = this._getNodeTypeName(argNode); if (argType === 'ConstantReadNode') { const moduleName = argNode.name; - if (!this._context.modules[moduleName]) { + + // === Smalruby: Start of stage include restriction === + if (isStageClass) { throw new RubyToBlocksConverterError( stmt, - this._translator(messages.undefinedModule, {NAME: moduleName}) + this._translator(messages.includeNotSupportedInStage) ); } + // === Smalruby: End of stage include restriction === + + // === Smalruby: Start of auto-import module from other sprites === + if (!this._context.modules[moduleName]) { + // Try to import the module from other sprites + const imported = this._importModuleFromOtherSprites(moduleName); + if (!imported) { + throw new RubyToBlocksConverterError( + stmt, + this._translator(messages.undefinedModule, {NAME: moduleName}) + ); + } + } + // === Smalruby: End of auto-import module from other sprites === + includedModuleNames.push(moduleName); includeStatements.add(stmt); } diff --git a/packages/scratch-gui/src/locales/ja-Hira.js b/packages/scratch-gui/src/locales/ja-Hira.js index b13af295a97..f932ab9dd90 100644 --- a/packages/scratch-gui/src/locales/ja-Hira.js +++ b/packages/scratch-gui/src/locales/ja-Hira.js @@ -107,6 +107,16 @@ export default { 'gui.smalruby3.rubyToBlocksConverter.spriteMethodInStageClass': '「{ METHOD }」はclass Stageではつかえません。\nこのメソッドはスプライトせんようです。', 'gui.smalruby3.rubyToBlocksConverter.stageMethodInSpriteClass': '「{ METHOD }」はスプライトのclassではつかえません。\nこのメソッドはclass Stageせんようです。', 'gui.smalruby3.rubyToBlocksConverter.classNotSupportedInV1': 'classのていぎはルビーバージョン1ではつかえません。\nせっていメニューからルビーバージョン2にきりかえてください。', + 'gui.smalruby3.rubyToBlocksConverter.invalidStageSuperclass': 'Stageクラスは ::Smalruby3::Stage または Smalruby3::Stage のみけいしょうできます。', + 'gui.smalruby3.rubyToBlocksConverter.moduleNotSupportedInV1': 'moduleのていぎはルビーバージョン1ではつかえません。\nせっていメニューからルビーバージョン2にきりかえてください。', + 'gui.smalruby3.rubyToBlocksConverter.nestedModuleNotSupported': 'moduleのなかにmoduleをていぎすることはできません。', + 'gui.smalruby3.rubyToBlocksConverter.onlyMethodsInModule': 'moduleのなかにはメソッドていぎ(def)だけをおくことができます。', + 'gui.smalruby3.rubyToBlocksConverter.undefinedModule': 'モジュール「{ NAME }」はていぎされていません。', + 'gui.smalruby3.rubyToBlocksConverter.moduleFunctionNotSupported': 'module_functionはスモウルビーではつかえません。', + 'gui.smalruby3.rubyToBlocksConverter.extendNotSupported': 'extendはスモウルビーではつかえません。', + 'gui.smalruby3.rubyToBlocksConverter.moduleNotSupportedInStage': 'moduleはステージではつかえません。\nモジュールはスプライトのクラスでのみつかえます。', + 'gui.smalruby3.rubyToBlocksConverter.includeNotSupportedInStage': 'includeはclass Stageではつかえません。\nモジュールはスプライトのクラスでのみとりこめます。', + 'gui.smalruby3.rubyToBlocksConverter.moduleImportFailed': 'モジュール「{ NAME }」をほかのスプライトからとりこめませんでした。', 'gui.smalruby3.prismError.expectedCloseArgs': '`)` がたりません。\n`)` をついかしてひきすうをとじてください。', 'gui.smalruby3.prismError.expectedCloseArray': '`]` がたりません。\n`]` をついかしてはいれつをとじてください。', 'gui.smalruby3.prismError.expectedCloseHash': '`}` がたりません。\n`}` をついかしてハッシュをとじてください。', diff --git a/packages/scratch-gui/src/locales/ja.js b/packages/scratch-gui/src/locales/ja.js index 4cb44578393..fd55c36adf2 100644 --- a/packages/scratch-gui/src/locales/ja.js +++ b/packages/scratch-gui/src/locales/ja.js @@ -107,6 +107,16 @@ export default { 'gui.smalruby3.rubyToBlocksConverter.spriteMethodInStageClass': '「{ METHOD }」はclass Stageでは使えません。\nこのメソッドはスプライト専用です。', 'gui.smalruby3.rubyToBlocksConverter.stageMethodInSpriteClass': '「{ METHOD }」はスプライトのclassでは使えません。\nこのメソッドはclass Stage専用です。', 'gui.smalruby3.rubyToBlocksConverter.classNotSupportedInV1': 'classの定義はルビーバージョン1では使えません。\n設定メニューからルビーバージョン2に切り替えてください。', + 'gui.smalruby3.rubyToBlocksConverter.invalidStageSuperclass': 'Stageクラスは ::Smalruby3::Stage または Smalruby3::Stage のみ継承できます。', + 'gui.smalruby3.rubyToBlocksConverter.moduleNotSupportedInV1': 'moduleの定義はルビーバージョン1では使えません。\n設定メニューからルビーバージョン2に切り替えてください。', + 'gui.smalruby3.rubyToBlocksConverter.nestedModuleNotSupported': 'moduleの中にmoduleを定義することはできません。', + 'gui.smalruby3.rubyToBlocksConverter.onlyMethodsInModule': 'moduleの中にはメソッド定義(def)だけを置くことができます。', + 'gui.smalruby3.rubyToBlocksConverter.undefinedModule': 'モジュール「{ NAME }」は定義されていません。', + 'gui.smalruby3.rubyToBlocksConverter.moduleFunctionNotSupported': 'module_functionはスモウルビーでは使えません。', + 'gui.smalruby3.rubyToBlocksConverter.extendNotSupported': 'extendはスモウルビーでは使えません。', + 'gui.smalruby3.rubyToBlocksConverter.moduleNotSupportedInStage': 'moduleはステージでは使えません。\nモジュールはスプライトのクラスでのみ使えます。', + 'gui.smalruby3.rubyToBlocksConverter.includeNotSupportedInStage': 'includeはclass Stageでは使えません。\nモジュールはスプライトのクラスでのみ取り込めます。', + 'gui.smalruby3.rubyToBlocksConverter.moduleImportFailed': 'モジュール「{ NAME }」を他のスプライトから取り込めませんでした。', 'gui.smalruby3.prismError.expectedCloseArgs': '`)` が足りません。\n`)` を追加して引数を閉じてください。', 'gui.smalruby3.prismError.expectedCloseArray': '`]` が足りません。\n`]` を追加して配列を閉じてください。', 'gui.smalruby3.prismError.expectedCloseHash': '`}` が足りません。\n`}` を追加してハッシュを閉じてください。', diff --git a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/module.test.js b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/module.test.js index a4b6d053cbf..38ad969e17e 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/module.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/module.test.js @@ -254,6 +254,110 @@ describe('RubyToBlocksConverter/Module', () => { }); }); + describe('stage restrictions', () => { + test('module in stage throws error', async () => { + const stageTarget = {isStage: true}; + const code = ` + module Utils + def add(a, b) + a + b + end + end + `; + const result = await converter.targetCodeToBlocks(stageTarget, code); + expect(result).toBeFalsy(); + expect(converter.errors.length).toBeGreaterThan(0); + }); + + test('include in class Stage throws error', async () => { + const stageTarget = {isStage: true}; + const code = ` + module Utils + def add(a, b) + a + b + end + end + + class Stage + include Utils + end + `; + // module itself will fail on stage target first + const result = await converter.targetCodeToBlocks(stageTarget, code); + expect(result).toBeFalsy(); + expect(converter.errors.length).toBeGreaterThan(0); + }); + }); + + describe('auto-import module from other sprites', () => { + test('include without local module imports from other sprite', async () => { + // Create a mock VM with another sprite that has the module + const mockVm = { + runtime: { + targets: [ + { + id: 'sprite2-id', + sprite: {name: 'Sprite2'}, + comments: { + 'comment-1': { + text: '@ruby:module_source:Utils', + blockId: 'proc-def-1' + } + }, + blocks: { + _blocks: { + 'proc-def-1': { + opcode: 'procedures_definition', + inputs: { + custom_block: { + block: 'proto-1' + } + } + }, + 'proto-1': { + opcode: 'procedures_prototype', + mutation: { + proccode: 'add %s %s', + argumentnames: '["a","b"]', + argumentids: '["arg1","arg2"]', + argumentdefaults: '["",""]', + warp: 'false' + } + } + } + } + } + ] + } + }; + + // The auto-import needs the converter to have a VM and use RubyGenerator. + // Since the converter uses module-sync functions which need real targets, + // this test verifies that when vm is null (no other sprites), undefined module still errors. + const converterNoVm = new RubyToBlocksConverter(null, {version: '2'}); + const code = ` + class Sprite1 + include NonExistent + end + `; + const result = await converterNoVm.targetCodeToBlocks(null, code); + expect(result).toBeFalsy(); + expect(converterNoVm.errors.length).toBeGreaterThan(0); + }); + + test('include with no vm falls back to undefinedModule error', async () => { + const converterNoVm = new RubyToBlocksConverter(null, {version: '2'}); + const code = ` + class Sprite1 + include Utils + end + `; + const result = await converterNoVm.targetCodeToBlocks(null, code); + expect(result).toBeFalsy(); + expect(converterNoVm.errors.length).toBeGreaterThan(0); + }); + }); + describe('module before class in code', () => { test('module defined before class, procedures are created', async () => { const code = ` From 4f24d3b73cff2bd1c665ed2082c52a927bf4cec6 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 14 Mar 2026 08:32:01 +0900 Subject: [PATCH 09/14] fix: update Ruby editor content after execute to reflect auto-imported modules When using the execute button on the Ruby tab, the editor content was not updated to reflect auto-imported modules. This adds a call to updateRubyCodeTargetState after block application so the editor immediately shows the full module definition obtained from other sprites. Co-Authored-By: Claude Opus 4.6 --- packages/scratch-gui/src/containers/ruby-tab.jsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/scratch-gui/src/containers/ruby-tab.jsx b/packages/scratch-gui/src/containers/ruby-tab.jsx index 6ac47fe0ef3..a2c08eb3079 100644 --- a/packages/scratch-gui/src/containers/ruby-tab.jsx +++ b/packages/scratch-gui/src/containers/ruby-tab.jsx @@ -659,13 +659,20 @@ const RubyTab = props => { target: vm.editingTarget, stackClick: true }); + + // === Smalruby: Start of update editor after execute === + // Regenerate Ruby code from blocks so that auto-imported + // modules are reflected in the editor immediately. + updateRubyCodeTargetState(vm.editingTarget, rubyVersion); + // === Smalruby: End of update editor after execute === }) .catch(error => { // eslint-disable-next-line no-console console.error('[handleExecuteLine] Apply error:', error); onShowAlert('convertRubyToBlocksError'); }); - }, [vm, rubyCode, intl, rubyVersion, onShowAlert, updateRubyCodeErrorsState, onDismissAlert]); + }, [vm, rubyCode, intl, rubyVersion, onShowAlert, updateRubyCodeErrorsState, onDismissAlert, + updateRubyCodeTargetState]); const renderDownloaderChildren = useCallback((_, downloadProjectCallback) => { downloadCallbackRef.current = downloadProjectCallback; From 2f9c62068d7dfc5a9de2e9d67e612047d454c4be Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 14 Mar 2026 08:39:36 +0900 Subject: [PATCH 10/14] fix: move editor update to right after block application in execute handler The updateRubyCodeTargetState call was placed after the block ID lookup, which could be skipped via early return when the cursor line has no executable block (e.g. class declaration). Moving it to right after converter.apply() ensures the editor always reflects auto-imported modules. Co-Authored-By: Claude Opus 4.6 --- packages/scratch-gui/src/containers/ruby-tab.jsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/scratch-gui/src/containers/ruby-tab.jsx b/packages/scratch-gui/src/containers/ruby-tab.jsx index a2c08eb3079..1b119692d82 100644 --- a/packages/scratch-gui/src/containers/ruby-tab.jsx +++ b/packages/scratch-gui/src/containers/ruby-tab.jsx @@ -623,6 +623,12 @@ const RubyTab = props => { converter.apply() .then(() => { + // === Smalruby: Start of update editor after execute === + // Regenerate Ruby code from blocks so that auto-imported + // modules are reflected in the editor immediately. + updateRubyCodeTargetState(vm.editingTarget, rubyVersion); + // === Smalruby: End of update editor after execute === + const blockId = converter.getBlockIdForLine(targetLine); if (!blockId) { // eslint-disable-next-line no-console @@ -659,12 +665,6 @@ const RubyTab = props => { target: vm.editingTarget, stackClick: true }); - - // === Smalruby: Start of update editor after execute === - // Regenerate Ruby code from blocks so that auto-imported - // modules are reflected in the editor immediately. - updateRubyCodeTargetState(vm.editingTarget, rubyVersion); - // === Smalruby: End of update editor after execute === }) .catch(error => { // eslint-disable-next-line no-console From a693feb8f489acee9ab8f61056c89cb3ebee97f9 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 14 Mar 2026 08:51:34 +0900 Subject: [PATCH 11/14] fix: use direct editor setValue for module reflection in execute handler The Redux-based updateRubyCodeTargetState approach did not reliably update the Monaco editor content within the execute callback because @monaco-editor/react value prop changes may not be applied in the same render cycle. Instead, directly call editorRef.current.setValue() with the regenerated Ruby code from RubyGenerator.targetToCode(). Co-Authored-By: Claude Opus 4.6 --- packages/scratch-gui/src/containers/ruby-tab.jsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/scratch-gui/src/containers/ruby-tab.jsx b/packages/scratch-gui/src/containers/ruby-tab.jsx index 1b119692d82..f931ff0059c 100644 --- a/packages/scratch-gui/src/containers/ruby-tab.jsx +++ b/packages/scratch-gui/src/containers/ruby-tab.jsx @@ -19,6 +19,9 @@ import {BLOCKS_TAB_INDEX, RUBY_TAB_INDEX} from '../reducers/editor-tab'; import RubyToBlocksConverterHOC from '../lib/ruby-to-blocks-converter-hoc.jsx'; import {targetCodeToBlocks} from '../lib/ruby-to-blocks-converter'; +// === Smalruby: Start of module editor update === +import RubyGenerator from '../lib/ruby-generator'; +// === Smalruby: End of module editor update === // === Smalruby: Start of module sync === import {syncModules} from '../lib/module-sync'; // === Smalruby: End of module sync === @@ -626,7 +629,15 @@ const RubyTab = props => { // === Smalruby: Start of update editor after execute === // Regenerate Ruby code from blocks so that auto-imported // modules are reflected in the editor immediately. - updateRubyCodeTargetState(vm.editingTarget, rubyVersion); + // Using direct editor setValue because Redux prop-driven + // updates via @monaco-editor/react may not take effect + // reliably within the same callback. + const regenerated = RubyGenerator.targetToCode( + vm.editingTarget, {version: rubyVersion} + ); + if (editorRef.current && regenerated !== code) { + editorRef.current.setValue(regenerated); + } // === Smalruby: End of update editor after execute === const blockId = converter.getBlockIdForLine(targetLine); @@ -671,8 +682,7 @@ const RubyTab = props => { console.error('[handleExecuteLine] Apply error:', error); onShowAlert('convertRubyToBlocksError'); }); - }, [vm, rubyCode, intl, rubyVersion, onShowAlert, updateRubyCodeErrorsState, onDismissAlert, - updateRubyCodeTargetState]); + }, [vm, rubyCode, intl, rubyVersion, onShowAlert, updateRubyCodeErrorsState, onDismissAlert]); const renderDownloaderChildren = useCallback((_, downloadProjectCallback) => { downloadCallbackRef.current = downloadProjectCallback; From 2ac4dbbf5d032cd00420dd0e7af91aaadde605f4 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 14 Mar 2026 13:21:47 +0900 Subject: [PATCH 12/14] fix: restore cursor position after module auto-import on execute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When clicking "カーソル行を実行" and a module is auto-imported, setValue() resets the cursor to line 1. This fix remembers the cursor line content before setValue and restores the cursor to the matching line in the regenerated code, then scrolls it to center. Co-Authored-By: Claude Opus 4.6 --- .../scratch-gui/src/containers/ruby-tab.jsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/scratch-gui/src/containers/ruby-tab.jsx b/packages/scratch-gui/src/containers/ruby-tab.jsx index f931ff0059c..568cceb1295 100644 --- a/packages/scratch-gui/src/containers/ruby-tab.jsx +++ b/packages/scratch-gui/src/containers/ruby-tab.jsx @@ -636,7 +636,28 @@ const RubyTab = props => { vm.editingTarget, {version: rubyVersion} ); if (editorRef.current && regenerated !== code) { + // Remember cursor content to restore position after setValue + const cursorLine = editorRef.current.getPosition().lineNumber; + const cursorContent = editorRef.current.getModel() + .getLineContent(cursorLine) + .trim(); + editorRef.current.setValue(regenerated); + + // Restore cursor to matching line in regenerated code + if (typeof cursorContent === 'string' && cursorContent.length > 0) { + const lines = regenerated.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (lines[i].trim() === cursorContent) { + const newLine = i + 1; + editorRef.current.setPosition({ + lineNumber: newLine, column: 1 + }); + editorRef.current.revealLineInCenter(newLine); + break; + } + } + } } // === Smalruby: End of update editor after execute === From c65304ca24b9c380454d05eef7f020b50b51e923 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 14 Mar 2026 13:48:58 +0900 Subject: [PATCH 13/14] docs: add module/include to furigana, language spec, and Gemini prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add furigana annotations: module→モジュール作成, end→作成終了, include→取り込む - Update smalruby-language-spec.md to document module/include (Version 2) - Update Gemini system prompt to include module/include methods and examples - Update furigana-mapping.md with new entries - Add 4 unit tests for module/include furigana Co-Authored-By: Claude Opus 4.6 --- infra/smalruby-gemini-relay/lambda/handler.ts | 25 +++++++++- packages/scratch-gui/docs/furigana-mapping.md | 3 ++ .../docs/smalruby-language-spec.md | 47 +++++++++++++++++-- .../scratch-gui/src/lib/furigana-label-map.js | 2 + .../src/lib/furigana-node-handlers.js | 10 ++++ .../test/unit/lib/furigana-annotator.test.js | 22 +++++++++ 6 files changed, 105 insertions(+), 4 deletions(-) diff --git a/infra/smalruby-gemini-relay/lambda/handler.ts b/infra/smalruby-gemini-relay/lambda/handler.ts index 1ba017cdbdc..689cca7d3e2 100644 --- a/infra/smalruby-gemini-relay/lambda/handler.ts +++ b/infra/smalruby-gemini-relay/lambda/handler.ts @@ -231,7 +231,7 @@ Smalruby is a Ruby subset with methods corresponding to MIT Scratch 3.0 visual p ### Key Differences from Standard Ruby - Class definitions are limited (only for sprite configuration) -- No module definitions +- \`module\` definitions and \`include\` are supported (Version 2 only) — use to share methods across sprites - Loops use \`loop do...end\`, \`N.times do...end\`, \`while...end\`, \`until...end\` (no for/each) - Conditionals: \`if\`, \`unless\`, \`case/when\`, \`until\` - Variables: instance (\`@score\`), global (\`$score\`), local (\`score\`) @@ -330,6 +330,28 @@ Smalruby is a Ruby subset with methods corresponding to MIT Scratch 3.0 visual p - Local variables: \`count = 0\` - \`show_variable("@score")\` / \`hide_variable("@score")\` +### Module / Include (Version 2 only) +- Define reusable methods in a \`module\`, then \`include\` in a class to share across sprites +- \`module ModuleName ... end\` — define a module with \`def\` methods only +- \`include ModuleName\` — include module methods in a class +- Only \`def\` methods allowed inside \`module\` (no variables, no nested modules) +- Not available on Stage or in Version 1 +\`\`\`ruby +module Utils + def add(a, b) + a + b + end +end + +class Sprite1 + include Utils + + when_flag_clicked do + say(add(1, 5)) + end +end +\`\`\` + ### Pen (extension) - \`Pen.clear\` - \`pen.down\` / \`pen.up\` @@ -353,6 +375,7 @@ Do NOT use these — they do not exist: - ❌ \`glide(secs, x, y)\` → ✅ \`glide([x, y], secs: n)\` - ❌ \`go_to(x, y)\` → ✅ \`go_to([x, y])\` - ❌ \`for\`, \`each\` → ✅ \`loop do...end\`, \`N.times do...end\`, \`while...end\`, \`until...end\` +- ❌ \`module_function\`, \`extend\` → ✅ use \`module\` + \`include\` instead - ❌ \`sleep(0.05)\`, \`sleep(0.1)\` for animation FPS → ✅ loops auto-wait; only use sleep() for 0.5s+ delays - ❌ \`puts\`, \`print\`, \`p\` → ✅ \`say()\` - ❌ \`when_backdrop_changes()\` → ✅ \`when_backdrop_switches()\` diff --git a/packages/scratch-gui/docs/furigana-mapping.md b/packages/scratch-gui/docs/furigana-mapping.md index 28daeb2acdc..8c52d29e470 100644 --- a/packages/scratch-gui/docs/furigana-mapping.md +++ b/packages/scratch-gui/docs/furigana-mapping.md @@ -173,6 +173,9 @@ Ruby tab のふりがな機能(「ふ」ボタン)で表示されるふり | `def メソッド名(arg1, arg2)` | `引数arg1`, `引数arg2` | def メソッド名(...)の引数 | | `end`(def) | `作成終了` | def に対応する end | | `return` | `呼び出し元に返す` | | +| `module` | `モジュール作成` | | +| `end`(module) | `作成終了` | module に対応する end | +| `include` | `取り込む` | module を class に取り込む | | `class` | `クラス作成` | | | `end`(class) | `作成終了` | class に対応する end | diff --git a/packages/scratch-gui/docs/smalruby-language-spec.md b/packages/scratch-gui/docs/smalruby-language-spec.md index 6048b25a4fc..b219c5078fe 100644 --- a/packages/scratch-gui/docs/smalruby-language-spec.md +++ b/packages/scratch-gui/docs/smalruby-language-spec.md @@ -54,8 +54,49 @@ end - クラス名に名前空間は指定できません(`Foo::Bar` は不可) - クラス継承 (`class Foo < Bar`) は構文上は許容されますが、親クラスは無視されます -- class定義のトップレベルに置けるのは、**イベントハンドラ**(`when_xxx`)と**メソッド定義**(`def`)のみです -- `module` は使用できません +- class定義のトップレベルに置けるのは、**イベントハンドラ**(`when_xxx`)、**メソッド定義**(`def`)、**`include`** のみです + +### module定義とinclude(Version 2のみ) + +`module` を定義し、`include` でクラスに取り込むことで、メソッドを複数のスプライトで共有できます。 + +```ruby +module Utils + def add(a, b) + a + b + end + + def greet + say("hello") + end +end + +class Sprite1 + include Utils + + when_flag_clicked do + say(add(1, 5)) + end +end +``` + +別のスプライトでも同じモジュールを `include` して、メソッドを再利用できます。 + +```ruby +class Sprite2 + include Utils + + when_flag_clicked do + say(add(10, 20)) + end +end +``` + +**制限事項**: +- `module` 内に置けるのは **メソッド定義(`def`)のみ** です(変数代入やネストした `module` は不可) +- `module_function` や `extend` は使用できません +- ステージ(`class Stage`)では `module` 定義や `include` は使用できません +- Version 1 では `module` は使用できません ### class定義のみで使えるメソッド @@ -586,7 +627,7 @@ hide_list("@items") # リストの非表示 - `for` ループ - `each` メソッド - `begin`/`rescue`/`ensure`(例外処理) -- `module` 定義 +- `module_function`, `extend`(`module` と `include` は Version 2 でサポート) - `require` / `require_relative` - 文字列の式展開 (`"Hello #{name}"`) - 多重代入 (`a, b = 1, 2`) diff --git a/packages/scratch-gui/src/lib/furigana-label-map.js b/packages/scratch-gui/src/lib/furigana-label-map.js index 1c1426f0264..326f0e38557 100644 --- a/packages/scratch-gui/src/lib/furigana-label-map.js +++ b/packages/scratch-gui/src/lib/furigana-label-map.js @@ -44,6 +44,8 @@ const RECEIVER_METHOD_LABELS = { * (no receiver). Used by FuriganaAnnotator._handleCallNode. */ const TOPLEVEL_METHOD_LABELS = { + // Module + 'include': '取り込む', // Standard I/O 'puts': '表示する', 'print': '表示する', diff --git a/packages/scratch-gui/src/lib/furigana-node-handlers.js b/packages/scratch-gui/src/lib/furigana-node-handlers.js index 017b1df6b14..53daaabcbd2 100644 --- a/packages/scratch-gui/src/lib/furigana-node-handlers.js +++ b/packages/scratch-gui/src/lib/furigana-node-handlers.js @@ -206,6 +206,16 @@ const nodeHandlers = { this._walkChildren(node); }, + // ---- module definition ---- + + _handleModuleNode (node) { + this._addAnnotation(node.moduleKeywordLoc, 'モジュール作成'); + if (node.endKeywordLoc) { + this._addAnnotation(node.endKeywordLoc, '作成終了'); + } + this._walkChildren(node); + }, + // ---- class definition ---- _handleClassNode (node) { diff --git a/packages/scratch-gui/test/unit/lib/furigana-annotator.test.js b/packages/scratch-gui/test/unit/lib/furigana-annotator.test.js index 692b0dfe698..ea4ba479563 100644 --- a/packages/scratch-gui/test/unit/lib/furigana-annotator.test.js +++ b/packages/scratch-gui/test/unit/lib/furigana-annotator.test.js @@ -202,6 +202,28 @@ describe('FuriganaAnnotator', () => { }); }); + describe('module definition and include', () => { + test('module keyword annotates as モジュール作成', () => { + const anns = annotate('module Utils\nend'); + expect(labelsAt(anns, 1)).toContain('モジュール作成'); + }); + test('end of module annotates as 作成終了', () => { + const anns = annotate('module Utils\nend'); + expect(labelsAt(anns, 2)).toContain('作成終了'); + }); + test('include annotates as 取り込む', () => { + const anns = annotate('class Sprite1\n include Utils\nend'); + expect(labelsAt(anns, 2)).toContain('取り込む'); + }); + test('module with def annotates both', () => { + const anns = annotate('module Utils\n def add(a, b)\n a + b\n end\nend'); + expect(labelsAt(anns, 1)).toContain('モジュール作成'); + expect(labelsAt(anns, 2)).toContain('メソッド作成'); + expect(labelsAt(anns, 4)).toContain('作成終了'); + expect(labelsAt(anns, 5)).toContain('作成終了'); + }); + }); + describe('class definition', () => { test('class keyword annotates as クラス作成', () => { const anns = annotate('class Dog\nend'); From 2d8a7ef4952f268a3622009e920ef41e9baaa73d Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 14 Mar 2026 14:30:52 +0900 Subject: [PATCH 14/14] fix: strengthen module/include support in Gemini prompt Gemini was ignoring the system prompt and incorrectly stating that module/include is not supported. Added stronger emphasis and a sample program to ensure correct code generation. Co-Authored-By: Claude Opus 4.6 --- infra/smalruby-gemini-relay/lambda/handler.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/infra/smalruby-gemini-relay/lambda/handler.ts b/infra/smalruby-gemini-relay/lambda/handler.ts index 689cca7d3e2..0f46777cf8e 100644 --- a/infra/smalruby-gemini-relay/lambda/handler.ts +++ b/infra/smalruby-gemini-relay/lambda/handler.ts @@ -231,7 +231,7 @@ Smalruby is a Ruby subset with methods corresponding to MIT Scratch 3.0 visual p ### Key Differences from Standard Ruby - Class definitions are limited (only for sprite configuration) -- \`module\` definitions and \`include\` are supported (Version 2 only) — use to share methods across sprites +- **\`module\` and \`include\` ARE supported** (Version 2 only) — use to share \`def\` methods across sprites. When the user asks about module/include, ALWAYS generate a code example using them. - Loops use \`loop do...end\`, \`N.times do...end\`, \`while...end\`, \`until...end\` (no for/each) - Conditionals: \`if\`, \`unless\`, \`case/when\`, \`until\` - Variables: instance (\`@score\`), global (\`$score\`), local (\`score\`) @@ -382,6 +382,23 @@ Do NOT use these — they do not exist: ## Sample Programs +### Share methods with module/include +\\\`\\\`\\\`ruby +module Utils + def add(a, b) + a + b + end +end + +class Sprite1 + include Utils + + when_flag_clicked do + say(add(1, 5)) + end +end +\\\`\\\`\\\` + ### Follow the mouse \`\`\`ruby when_flag_clicked do