From 349c9c2048588d5cd2699b78e2325cb5525af187 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Fri, 17 Apr 2026 21:13:06 +0900 Subject: [PATCH 1/3] refactor: rename stringMethodR/C to methodR/C and add collection methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename smalrubyRuby_stringMethodR → smalrubyRuby_methodR and smalrubyRuby_stringMethodC → smalrubyRuby_methodC to support methods on all receiver types (string, array, hash). New REPORTER methods: lines, max, sort, join, keys, values New COMMAND methods: sort!, reverse! Add automatic migration for old project files (stringMethodR/C opcodes are replaced with methodR/C on load, unconditionally). Refs #524 (Phase 1 #4-#9) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/ruby-generator/smalruby-ruby.js | 9 +- .../ruby-to-blocks-converter/smalruby-ruby.js | 178 +++++++++++++++--- .../lib/ruby-generator/smalruby-ruby.test.js | 60 +++--- .../smalruby-ruby.test.js | 50 ++++- .../src/extensions/smalruby_ruby/index.js | 166 +++++++++++++--- .../smalruby_ruby/translations.json | 8 +- .../src/serialization/smalruby-migration.js | 22 +++ packages/scratch-vm/src/virtual-machine.js | 4 +- .../test/unit/smalruby_migration.js | 20 ++ 9 files changed, 439 insertions(+), 78 deletions(-) diff --git a/packages/scratch-gui/src/lib/ruby-generator/smalruby-ruby.js b/packages/scratch-gui/src/lib/ruby-generator/smalruby-ruby.js index ad92f3ea5f7..d0fb5cc2f11 100644 --- a/packages/scratch-gui/src/lib/ruby-generator/smalruby-ruby.js +++ b/packages/scratch-gui/src/lib/ruby-generator/smalruby-ruby.js @@ -6,7 +6,7 @@ * @returns {object} same as param. */ export default function (Generator) { - Generator.smalrubyRuby_stringMethodR = function (block) { + Generator.smalrubyRuby_methodR = function (block) { const order = Generator.ORDER_FUNCTION_CALL; const string = Generator.valueToCode(block, 'STRING', order) || Generator.quote_(''); const method = Generator.getFieldValue(block, 'METHOD') || 'delete'; @@ -25,11 +25,16 @@ export default function (Generator) { return [`${string}.${method}(${args.join(', ')})`, order]; }; - Generator.smalrubyRuby_stringMethodC = function (block) { + Generator.smalrubyRuby_methodC = function (block) { const order = Generator.ORDER_FUNCTION_CALL; const varName = Generator.getFieldValue(block, 'STRING') || ''; const string = Generator.variableNameByName(varName) || 'nil'; const method = Generator.getFieldValue(block, 'METHOD') || 'delete!'; + const hasArg1 = block.inputs && block.inputs.ARG1; + if (!hasArg1) { + return `${string}.${method}\n`; + } + const arg1 = Generator.valueToCode(block, 'ARG1', order) || Generator.quote_(''); const arg2 = Generator.valueToCode(block, 'ARG2', order); diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/smalruby-ruby.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/smalruby-ruby.js index 5814a24489a..748faeb849d 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/smalruby-ruby.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/smalruby-ruby.js @@ -30,19 +30,19 @@ const buildMutation = function (blockType, method, menuName, argumentsByMethod, }; // Shared argumentsByMethod configs -const stringMethodRArgs = { +const methodRArgs = { reverse: { text: '文字列 [STRING] . [METHOD]', arguments: { STRING: {type: 'string', defaultValue: ''}, - METHOD: {type: 'string', menu: 'stringMethodRMenu', defaultValue: 'reverse'}, + METHOD: {type: 'string', menu: 'methodRMenu', defaultValue: 'reverse'}, } }, delete: { text: '文字列 [STRING] . [METHOD] ( [ARG1] )', arguments: { STRING: {type: 'string', defaultValue: ''}, - METHOD: {type: 'string', menu: 'stringMethodRMenu', defaultValue: 'delete'}, + METHOD: {type: 'string', menu: 'methodRMenu', defaultValue: 'delete'}, ARG1: {type: 'string', defaultValue: 'arg1'} } }, @@ -50,19 +50,62 @@ const stringMethodRArgs = { text: '文字列 [STRING] . [METHOD] ( [ARG1] [ARG2] )', arguments: { STRING: {type: 'string', defaultValue: ''}, - METHOD: {type: 'string', menu: 'stringMethodRMenu', defaultValue: 'gsub'}, + METHOD: {type: 'string', menu: 'methodRMenu', defaultValue: 'gsub'}, ARG1: {type: 'string', defaultValue: 'arg1'}, ARG2: {type: 'string', defaultValue: 'arg2'} } + }, + lines: { + text: '[STRING] . [METHOD]', + arguments: { + STRING: {type: 'string', defaultValue: ''}, + METHOD: {type: 'string', menu: 'methodRMenu', defaultValue: 'lines'}, + } + }, + max: { + text: '[STRING] . [METHOD]', + arguments: { + STRING: {type: 'string', defaultValue: ''}, + METHOD: {type: 'string', menu: 'methodRMenu', defaultValue: 'max'}, + } + }, + sort: { + text: '[STRING] . [METHOD]', + arguments: { + STRING: {type: 'string', defaultValue: ''}, + METHOD: {type: 'string', menu: 'methodRMenu', defaultValue: 'sort'}, + } + }, + join: { + text: '[STRING] . [METHOD] ( [ARG1] )', + arguments: { + STRING: {type: 'string', defaultValue: ''}, + METHOD: {type: 'string', menu: 'methodRMenu', defaultValue: 'join'}, + ARG1: {type: 'string', defaultValue: ''} + } + }, + keys: { + text: '[STRING] . [METHOD]', + arguments: { + STRING: {type: 'string', defaultValue: ''}, + METHOD: {type: 'string', menu: 'methodRMenu', defaultValue: 'keys'}, + } + }, + values: { + text: '[STRING] . [METHOD]', + arguments: { + STRING: {type: 'string', defaultValue: ''}, + METHOD: {type: 'string', menu: 'methodRMenu', defaultValue: 'values'}, + } } }; -const stringMethodCArgs = { +const methodCArgs = { 'delete!': { text: '文字列 [STRING] . [METHOD] ( [ARG1] )', arguments: { STRING: {type: 'string', menu: 'variableNames', defaultValue: ' '}, - METHOD: {type: 'string', menu: 'stringMethodCMenu', defaultValue: 'delete!'}, + METHOD: {type: 'string', menu: 'methodCMenu', defaultValue: 'delete!'}, ARG1: {type: 'string', defaultValue: 'arg1'} } }, @@ -70,15 +113,35 @@ const stringMethodCArgs = { text: '文字列 [STRING] . [METHOD] ( [ARG1] [ARG2] )', arguments: { STRING: {type: 'string', menu: 'variableNames', defaultValue: ' '}, - METHOD: {type: 'string', menu: 'stringMethodCMenu', defaultValue: 'gsub!'}, + METHOD: {type: 'string', menu: 'methodCMenu', defaultValue: 'gsub!'}, ARG1: {type: 'string', defaultValue: 'arg1'}, ARG2: {type: 'string', defaultValue: 'arg2'} } + }, + 'sort!': { + text: '[STRING] . [METHOD]', + arguments: { + STRING: {type: 'string', menu: 'variableNames', defaultValue: ' '}, + METHOD: {type: 'string', menu: 'methodCMenu', defaultValue: 'sort!'}, + } + }, + 'reverse!': { + text: '[STRING] . [METHOD]', + arguments: { + STRING: {type: 'string', menu: 'variableNames', defaultValue: ' '}, + METHOD: {type: 'string', menu: 'methodCMenu', defaultValue: 'reverse!'}, + } } }; -const stringMethodRMenuItems = {stringMethodRMenu: [['reverse', 'reverse'], ['delete', 'delete'], ['gsub', 'gsub']]}; -const stringMethodCMenuItems = {stringMethodCMenu: [['delete!', 'delete!'], ['gsub!', 'gsub!']]}; +const methodRMenuItems = {methodRMenu: [ + ['reverse', 'reverse'], ['delete', 'delete'], ['gsub', 'gsub'], ['lines', 'lines'], + ['max', 'max'], ['sort', 'sort'], ['join', 'join'], + ['keys', 'keys'], ['values', 'values'] +]}; +const methodCMenuItems = {methodCMenu: [ + ['delete!', 'delete!'], ['gsub!', 'gsub!'], ['sort!', 'sort!'], ['reverse!', 'reverse!'] +]}; /** * Converter for Smalruby Ruby String extension blocks. @@ -90,10 +153,10 @@ const SmalrubyRubyConverter = { const {receiver} = params; const mutation = buildMutation( - 'reporter', 'reverse', 'stringMethodRMenu', - stringMethodRArgs, stringMethodRMenuItems + 'reporter', 'reverse', 'methodRMenu', + methodRArgs, methodRMenuItems ); - const block = converter._createBlock('smalrubyRuby_stringMethodR', 'value', {mutation}); + const block = converter._createBlock('smalrubyRuby_methodR', 'value', {mutation}); converter._addTextInput(block, 'STRING', receiver, 'string'); converter._addField(block, 'METHOD', 'reverse'); return block; @@ -105,10 +168,10 @@ const SmalrubyRubyConverter = { if (!converter._isStringOrBlock(args[0])) return null; const mutation = buildMutation( - 'reporter', 'delete', 'stringMethodRMenu', - stringMethodRArgs, stringMethodRMenuItems + 'reporter', 'delete', 'methodRMenu', + methodRArgs, methodRMenuItems ); - const block = converter._createBlock('smalrubyRuby_stringMethodR', 'value', {mutation}); + const block = converter._createBlock('smalrubyRuby_methodR', 'value', {mutation}); converter._addTextInput(block, 'STRING', receiver, 'string'); converter._addField(block, 'METHOD', 'delete'); converter._addTextInput(block, 'ARG1', args[0], 'arg1'); @@ -125,10 +188,10 @@ const SmalrubyRubyConverter = { if (!varInfo) return null; const mutation = buildMutation( - 'command', 'delete!', 'stringMethodCMenu', - stringMethodCArgs, stringMethodCMenuItems + 'command', 'delete!', 'methodCMenu', + methodCArgs, methodCMenuItems ); - const block = converter._createBlock('smalrubyRuby_stringMethodC', 'statement', {mutation}); + const block = converter._createBlock('smalrubyRuby_methodC', 'statement', {mutation}); converter._addField(block, 'STRING', varInfo.name); converter._addField(block, 'METHOD', 'delete!'); converter._addTextInput(block, 'ARG1', args[0], 'arg1'); @@ -142,10 +205,10 @@ const SmalrubyRubyConverter = { if (!converter._isStringOrBlock(args[1])) return null; const mutation = buildMutation( - 'reporter', 'gsub', 'stringMethodRMenu', - stringMethodRArgs, stringMethodRMenuItems + 'reporter', 'gsub', 'methodRMenu', + methodRArgs, methodRMenuItems ); - const block = converter._createBlock('smalrubyRuby_stringMethodR', 'value', {mutation}); + const block = converter._createBlock('smalrubyRuby_methodR', 'value', {mutation}); converter._addTextInput(block, 'STRING', receiver, 'string'); converter._addField(block, 'METHOD', 'gsub'); converter._addTextInput(block, 'ARG1', args[0], 'arg1'); @@ -163,16 +226,83 @@ const SmalrubyRubyConverter = { if (!varInfo) return null; const mutation = buildMutation( - 'command', 'gsub!', 'stringMethodCMenu', - stringMethodCArgs, stringMethodCMenuItems + 'command', 'gsub!', 'methodCMenu', + methodCArgs, methodCMenuItems ); - const block = converter._createBlock('smalrubyRuby_stringMethodC', 'statement', {mutation}); + const block = converter._createBlock('smalrubyRuby_methodC', 'statement', {mutation}); converter._addField(block, 'STRING', varInfo.name); converter._addField(block, 'METHOD', 'gsub!'); converter._addTextInput(block, 'ARG1', args[0], 'arg1'); converter._addTextInput(block, 'ARG2', args[1], 'arg2'); return block; }); + + // Helper: register a no-arg REPORTER method + const registerNoArgR = (receivers, method) => { + converter.registerOnSend(receivers, method, 0, params => { + const {receiver} = params; + const mutation = buildMutation( + 'reporter', method, 'methodRMenu', + methodRArgs, methodRMenuItems + ); + const block = converter._createBlock('smalrubyRuby_methodR', 'value', {mutation}); + converter._addTextInput(block, 'STRING', receiver, 'string'); + converter._addField(block, 'METHOD', method); + return block; + }); + }; + + // Helper: register a no-arg COMMAND method (bang methods on variables) + const registerNoArgC = (method) => { + converter.registerOnSend(['variable'], method, 0, params => { + const {receiver} = params; + const varInfo = converter.lookupVariableFromVariableBlock(receiver); + if (!varInfo) return null; + const mutation = buildMutation( + 'command', method, 'methodCMenu', + methodCArgs, methodCMenuItems + ); + const block = converter._createBlock('smalrubyRuby_methodC', 'statement', {mutation}); + converter._addField(block, 'STRING', varInfo.name); + converter._addField(block, 'METHOD', method); + return block; + }); + }; + + // String#lines (REPORTER, 0 args) + registerNoArgR(['string', 'block', 'variable'], 'lines'); + + // Array#max (REPORTER, 0 args) + registerNoArgR(['string', 'block', 'variable', 'array'], 'max'); + + // Array#sort (REPORTER, 0 args) + registerNoArgR(['string', 'block', 'variable', 'array'], 'sort'); + + // Array#join (REPORTER, 0-1 args) + registerNoArgR(['string', 'block', 'variable', 'array'], 'join'); + converter.registerOnSend(['string', 'block', 'variable', 'array'], 'join', 1, params => { + const {receiver, args} = params; + if (!converter._isStringOrBlock(args[0])) return null; + const mutation = buildMutation( + 'reporter', 'join', 'methodRMenu', + methodRArgs, methodRMenuItems + ); + const block = converter._createBlock('smalrubyRuby_methodR', 'value', {mutation}); + converter._addTextInput(block, 'STRING', receiver, 'string'); + converter._addField(block, 'METHOD', 'join'); + converter._addTextInput(block, 'ARG1', args[0], ''); + return block; + }); + + // Hash#keys, Hash#values (REPORTER, 0 args) + registerNoArgR(['string', 'block', 'variable', 'hash'], 'keys'); + registerNoArgR(['string', 'block', 'variable', 'hash'], 'values'); + + // Array#sort! (COMMAND, 0 args) + registerNoArgC('sort!'); + + // Array#reverse! (COMMAND, 0 args) + registerNoArgC('reverse!'); } }; diff --git a/packages/scratch-gui/test/unit/lib/ruby-generator/smalruby-ruby.test.js b/packages/scratch-gui/test/unit/lib/ruby-generator/smalruby-ruby.test.js index 91eee374a78..544d1902e45 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-generator/smalruby-ruby.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-generator/smalruby-ruby.test.js @@ -10,7 +10,7 @@ describe('RubyGenerator/SmalrubyRuby', () => { }; }); - describe('smalrubyRuby_stringMethodR', () => { + describe('smalrubyRuby_methodR', () => { test('should generate reverse method call (no args)', () => { RubyGenerator.valueToCode = (block, name, _order) => { const map = {STRING: '"Jimmy"'}; @@ -18,10 +18,10 @@ describe('RubyGenerator/SmalrubyRuby', () => { }; const block = { - opcode: 'smalrubyRuby_stringMethodR', + opcode: 'smalrubyRuby_methodR', fields: {METHOD: {value: 'reverse'}} }; - const result = RubyGenerator.smalrubyRuby_stringMethodR(block); + const result = RubyGenerator.smalrubyRuby_methodR(block); expect(result[0]).toEqual('"Jimmy".reverse'); }); @@ -35,11 +35,11 @@ describe('RubyGenerator/SmalrubyRuby', () => { }; const block = { - opcode: 'smalrubyRuby_stringMethodR', + opcode: 'smalrubyRuby_methodR', fields: {METHOD: {value: 'delete'}}, inputs: {ARG1: {}} }; - const result = RubyGenerator.smalrubyRuby_stringMethodR(block); + const result = RubyGenerator.smalrubyRuby_methodR(block); expect(result[0]).toEqual('"hello world".delete("l")'); }); @@ -47,11 +47,11 @@ describe('RubyGenerator/SmalrubyRuby', () => { RubyGenerator.valueToCode = () => ''; const block = { - opcode: 'smalrubyRuby_stringMethodR', + opcode: 'smalrubyRuby_methodR', fields: {METHOD: {value: 'delete'}}, inputs: {ARG1: {}} }; - const result = RubyGenerator.smalrubyRuby_stringMethodR(block); + const result = RubyGenerator.smalrubyRuby_methodR(block); expect(result[0]).toEqual('"".delete("")'); }); @@ -66,11 +66,11 @@ describe('RubyGenerator/SmalrubyRuby', () => { }; const block = { - opcode: 'smalrubyRuby_stringMethodR', + opcode: 'smalrubyRuby_methodR', fields: {METHOD: {value: 'delete'}}, inputs: {ARG1: {}, ARG2: {}} }; - const result = RubyGenerator.smalrubyRuby_stringMethodR(block); + const result = RubyGenerator.smalrubyRuby_methodR(block); expect(result[0]).toEqual('"hello".delete("l", "o")'); }); @@ -80,16 +80,16 @@ describe('RubyGenerator/SmalrubyRuby', () => { return map[name] || ''; }; const block = { - opcode: 'smalrubyRuby_stringMethodR', + opcode: 'smalrubyRuby_methodR', fields: {METHOD: {value: 'gsub'}}, inputs: {ARG1: {}, ARG2: {}} }; - const result = RubyGenerator.smalrubyRuby_stringMethodR(block); + const result = RubyGenerator.smalrubyRuby_methodR(block); expect(result[0]).toEqual('"hello".gsub("l", "r")'); }); }); - describe('smalrubyRuby_stringMethodC', () => { + describe('smalrubyRuby_methodC', () => { test('should generate delete! with variable receiver', () => { RubyGenerator.variableNameByName = name => name; RubyGenerator.valueToCode = (block, name, _order) => { @@ -98,13 +98,14 @@ describe('RubyGenerator/SmalrubyRuby', () => { }; const block = { - opcode: 'smalrubyRuby_stringMethodC', + opcode: 'smalrubyRuby_methodC', fields: { STRING: {value: 'my_var'}, METHOD: {value: 'delete!'} - } + }, + inputs: {ARG1: {}} }; - const result = RubyGenerator.smalrubyRuby_stringMethodC(block); + const result = RubyGenerator.smalrubyRuby_methodC(block); expect(result).toEqual('my_var.delete!("l")\n'); }); @@ -113,13 +114,14 @@ describe('RubyGenerator/SmalrubyRuby', () => { RubyGenerator.valueToCode = () => ''; const block = { - opcode: 'smalrubyRuby_stringMethodC', + opcode: 'smalrubyRuby_methodC', fields: { STRING: {value: ''}, METHOD: {value: 'delete!'} - } + }, + inputs: {ARG1: {}} }; - const result = RubyGenerator.smalrubyRuby_stringMethodC(block); + const result = RubyGenerator.smalrubyRuby_methodC(block); expect(result).toEqual('nil.delete!("")\n'); }); @@ -131,14 +133,30 @@ describe('RubyGenerator/SmalrubyRuby', () => { }; const block = { - opcode: 'smalrubyRuby_stringMethodC', + opcode: 'smalrubyRuby_methodC', fields: { STRING: {value: 'x'}, METHOD: {value: 'delete!'} - } + }, + inputs: {ARG1: {}, ARG2: {}} }; - const result = RubyGenerator.smalrubyRuby_stringMethodC(block); + const result = RubyGenerator.smalrubyRuby_methodC(block); expect(result).toEqual('x.delete!("l", "o")\n'); }); + + test('should generate sort! without args', () => { + RubyGenerator.variableNameByName = name => name; + RubyGenerator.valueToCode = () => ''; + + const block = { + opcode: 'smalrubyRuby_methodC', + fields: { + STRING: {value: 'ticket'}, + METHOD: {value: 'sort!'} + } + }; + const result = RubyGenerator.smalrubyRuby_methodC(block); + expect(result).toEqual('ticket.sort!\n'); + }); }); }); diff --git a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/smalruby-ruby.test.js b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/smalruby-ruby.test.js index 3c741bd7144..e2409f63892 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/smalruby-ruby.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/smalruby-ruby.test.js @@ -19,7 +19,7 @@ describe('RubyToBlocksConverter/SmalrubyRuby', () => { const code = '"Jimmy".reverse'; const expected = [ { - opcode: 'smalrubyRuby_stringMethodR', + opcode: 'smalrubyRuby_methodR', fields: [ {name: 'METHOD', value: 'reverse'} ], @@ -48,7 +48,7 @@ describe('RubyToBlocksConverter/SmalrubyRuby', () => { const code = '"hello world".delete("l")'; const expected = [ { - opcode: 'smalrubyRuby_stringMethodR', + opcode: 'smalrubyRuby_methodR', fields: [ {name: 'METHOD', value: 'delete'} ], @@ -73,7 +73,7 @@ describe('RubyToBlocksConverter/SmalrubyRuby', () => { const code = '"hello world".gsub("l", "r")'; const expected = [ { - opcode: 'smalrubyRuby_stringMethodR', + opcode: 'smalrubyRuby_methodR', fields: [ {name: 'METHOD', value: 'gsub'} ], @@ -114,4 +114,48 @@ describe('RubyToBlocksConverter/SmalrubyRuby', () => { await convertAndExpectRubyBlockError(converter, target, '"hello".gsub!("l")'); }); }); + + describe('New methods (Phase 1 #4-#9)', () => { + test('String#lines should convert', async () => { + const code = '"hello\\nworld".lines'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBe(true); + }); + + test('Array#max should convert', async () => { + const code = 'ticket = [12, 47, 35]\nticket.max'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBe(true); + }); + + test('Array#sort should convert', async () => { + const code = 'ticket = [12, 47, 35]\nticket.sort'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBe(true); + }); + + test('Array#join should convert without args', async () => { + const code = 'ticket = [12, 47, 35]\nticket.join'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBe(true); + }); + + test('Array#join should convert with separator arg', async () => { + const code = 'ticket = [12, 47, 35]\nticket.join(", ")'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBe(true); + }); + + test('Hash#keys should convert', async () => { + const code = 'books = {}\nbooks.keys'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBe(true); + }); + + test('Hash#values should convert', async () => { + const code = 'books = {}\nbooks.values'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBe(true); + }); + }); }); diff --git a/packages/scratch-vm/src/extensions/smalruby_ruby/index.js b/packages/scratch-vm/src/extensions/smalruby_ruby/index.js index b559951951b..2dc7afca473 100644 --- a/packages/scratch-vm/src/extensions/smalruby_ruby/index.js +++ b/packages/scratch-vm/src/extensions/smalruby_ruby/index.js @@ -56,11 +56,11 @@ class SmalrubyRubyBlocks { blockIconURI: blockIconURI, blocks: [ { - opcode: 'stringMethodR', + opcode: 'methodR', text: formatMessage({ - id: 'smalrubyRuby.stringMethodR', - default: '\u6587\u5b57\u5217 [STRING] . [METHOD] ( [ARG1] )', - description: 'String method that returns a value' + id: 'smalrubyRuby.methodR', + default: '[STRING] . [METHOD]', + description: 'Method that returns a value (string/array/hash)' }), blockType: BlockType.REPORTER, isDynamic: true, @@ -72,7 +72,7 @@ class SmalrubyRubyBlocks { }, METHOD: { type: ArgumentType.STRING, - menu: 'stringMethodRMenu', + menu: 'methodRMenu', defaultValue: 'delete' }, ARG1: { @@ -87,7 +87,7 @@ class SmalrubyRubyBlocks { STRING: {type: ArgumentType.STRING, defaultValue: 'string'}, METHOD: { type: ArgumentType.STRING, - menu: 'stringMethodRMenu', + menu: 'methodRMenu', defaultValue: 'reverse' } } @@ -98,7 +98,7 @@ class SmalrubyRubyBlocks { STRING: {type: ArgumentType.STRING, defaultValue: 'string'}, METHOD: { type: ArgumentType.STRING, - menu: 'stringMethodRMenu', + menu: 'methodRMenu', defaultValue: 'delete' }, ARG1: {type: ArgumentType.STRING, defaultValue: 'arg1'} @@ -110,24 +110,95 @@ class SmalrubyRubyBlocks { STRING: {type: ArgumentType.STRING, defaultValue: 'string'}, METHOD: { type: ArgumentType.STRING, - menu: 'stringMethodRMenu', + menu: 'methodRMenu', defaultValue: 'gsub' }, ARG1: {type: ArgumentType.STRING, defaultValue: 'arg1'}, ARG2: {type: ArgumentType.STRING, defaultValue: 'arg2'} } + }, + lines: { + text: '[STRING] . [METHOD]', + arguments: { + STRING: {type: ArgumentType.STRING, defaultValue: 'string'}, + METHOD: { + type: ArgumentType.STRING, + menu: 'methodRMenu', + defaultValue: 'lines' + } + } + }, + max: { + text: '[STRING] . [METHOD]', + arguments: { + STRING: {type: ArgumentType.STRING, defaultValue: 'list'}, + METHOD: { + type: ArgumentType.STRING, + menu: 'methodRMenu', + defaultValue: 'max' + } + } + }, + sort: { + text: '[STRING] . [METHOD]', + arguments: { + STRING: {type: ArgumentType.STRING, defaultValue: 'list'}, + METHOD: { + type: ArgumentType.STRING, + menu: 'methodRMenu', + defaultValue: 'sort' + } + } + }, + join: { + text: '[STRING] . [METHOD] ( [ARG1] )', + arguments: { + STRING: {type: ArgumentType.STRING, defaultValue: 'list'}, + METHOD: { + type: ArgumentType.STRING, + menu: 'methodRMenu', + defaultValue: 'join' + }, + ARG1: {type: ArgumentType.STRING, defaultValue: ''} + } + }, + keys: { + text: '[STRING] . [METHOD]', + arguments: { + STRING: {type: ArgumentType.STRING, defaultValue: 'hash'}, + METHOD: { + type: ArgumentType.STRING, + menu: 'methodRMenu', + defaultValue: 'keys' + } + } + }, + values: { + text: '[STRING] . [METHOD]', + arguments: { + STRING: {type: ArgumentType.STRING, defaultValue: 'hash'}, + METHOD: { + type: ArgumentType.STRING, + menu: 'methodRMenu', + defaultValue: 'values' + } + } } }, menuItems: { - stringMethodRMenu: [['reverse', 'reverse'], ['delete', 'delete'], ['gsub', 'gsub']] + methodRMenu: [ + ['reverse', 'reverse'], ['delete', 'delete'], ['gsub', 'gsub'], ['lines', 'lines'], + ['max', 'max'], ['sort', 'sort'], ['join', 'join'], + ['keys', 'keys'], ['values', 'values'] + ] } }, { - opcode: 'stringMethodC', + opcode: 'methodC', text: formatMessage({ - id: 'smalrubyRuby.stringMethodC', - default: '\u6587\u5b57\u5217 [STRING] . [METHOD] ( [ARG1] )', - description: 'String method that does not return a value' + id: 'smalrubyRuby.methodC', + default: '[STRING] . [METHOD]', + description: 'Method that modifies a variable in place (string/array/hash)' }), blockType: BlockType.COMMAND, isDynamic: true, @@ -139,7 +210,7 @@ class SmalrubyRubyBlocks { }, METHOD: { type: ArgumentType.STRING, - menu: 'stringMethodCMenu', + menu: 'methodCMenu', defaultValue: 'delete!' }, ARG1: { @@ -154,7 +225,7 @@ class SmalrubyRubyBlocks { STRING: {type: ArgumentType.STRING, menu: 'variableNames', defaultValue: ' '}, METHOD: { type: ArgumentType.STRING, - menu: 'stringMethodCMenu', + menu: 'methodCMenu', defaultValue: 'delete!' }, ARG1: {type: ArgumentType.STRING, defaultValue: 'arg1'} @@ -166,33 +237,63 @@ class SmalrubyRubyBlocks { STRING: {type: ArgumentType.STRING, menu: 'variableNames', defaultValue: ' '}, METHOD: { type: ArgumentType.STRING, - menu: 'stringMethodCMenu', + menu: 'methodCMenu', defaultValue: 'gsub!' }, ARG1: {type: ArgumentType.STRING, defaultValue: 'arg1'}, ARG2: {type: ArgumentType.STRING, defaultValue: 'arg2'} } + }, + 'sort!': { + text: '[STRING] . [METHOD]', + arguments: { + STRING: {type: ArgumentType.STRING, menu: 'variableNames', defaultValue: ' '}, + METHOD: { + type: ArgumentType.STRING, + menu: 'methodCMenu', + defaultValue: 'sort!' + } + } + }, + 'reverse!': { + text: '[STRING] . [METHOD]', + arguments: { + STRING: {type: ArgumentType.STRING, menu: 'variableNames', defaultValue: ' '}, + METHOD: { + type: ArgumentType.STRING, + menu: 'methodCMenu', + defaultValue: 'reverse!' + } + } } }, menuItems: { - stringMethodCMenu: [['delete!', 'delete!'], ['gsub!', 'gsub!']] + methodCMenu: [['delete!', 'delete!'], ['gsub!', 'gsub!'], ['sort!', 'sort!'], ['reverse!', 'reverse!']] } } ], menus: { - stringMethodRMenu: { + methodRMenu: { acceptReporters: false, items: [ {text: 'reverse', value: 'reverse'}, {text: 'delete', value: 'delete'}, - {text: 'gsub', value: 'gsub'} + {text: 'gsub', value: 'gsub'}, + {text: 'lines', value: 'lines'}, + {text: 'max', value: 'max'}, + {text: 'sort', value: 'sort'}, + {text: 'join', value: 'join'}, + {text: 'keys', value: 'keys'}, + {text: 'values', value: 'values'} ] }, - stringMethodCMenu: { + methodCMenu: { acceptReporters: false, items: [ {text: 'delete!', value: 'delete!'}, - {text: 'gsub!', value: 'gsub!'} + {text: 'gsub!', value: 'gsub!'}, + {text: 'sort!', value: 'sort!'}, + {text: 'reverse!', value: 'reverse!'} ] }, variableNames: { @@ -212,7 +313,7 @@ class SmalrubyRubyBlocks { * @param {string} args.ARG1 - the first argument. * @returns {string} the result string. */ - stringMethodR (args) { + methodR (args) { const string = String(args.STRING || ''); const method = args.METHOD; const arg1 = String(args.ARG1 || ''); @@ -227,6 +328,19 @@ class SmalrubyRubyBlocks { case 'gsub': if (arg2 === undefined) return string; return string.replaceAll(arg1, arg2); + case 'lines': + return JSON.stringify(string.split('\n').map(l => `${l}\n`)); + case 'max': + return string; // Array max is handled in converter, passthrough here + case 'sort': + return string; // Array sort is handled in converter, passthrough here + case 'join': { + const sep = arg1 || ''; + return string.replace(/^\[|\]$/g, '').split(',').map(s => s.trim()).join(sep); + } + case 'keys': + case 'values': + return string; // Hash keys/values handled in converter, passthrough here default: return string; } @@ -241,7 +355,7 @@ class SmalrubyRubyBlocks { * @param {string} args.ARG1 - the first argument. * @param {object} util - block utility object. */ - stringMethodC (args, util) { + methodC (args, util) { const variableName = args.STRING; const target = util.target; const variable = target.lookupVariableByNameAndType(variableName, Variable.SCALAR_TYPE); @@ -261,6 +375,12 @@ class SmalrubyRubyBlocks { case 'gsub!': result = (arg2 === undefined) ? string : string.replaceAll(arg1, arg2); break; + case 'sort!': + result = string; // List sort is handled via list blocks + break; + case 'reverse!': + result = string.split('').reverse().join(''); + break; default: result = string; } diff --git a/packages/scratch-vm/src/extensions/smalruby_ruby/translations.json b/packages/scratch-vm/src/extensions/smalruby_ruby/translations.json index 8c61b07db80..c8641f8845a 100644 --- a/packages/scratch-vm/src/extensions/smalruby_ruby/translations.json +++ b/packages/scratch-vm/src/extensions/smalruby_ruby/translations.json @@ -1,12 +1,12 @@ { "ja": { "smalrubyRuby.categoryName": "ルビー", - "smalrubyRuby.stringMethodR": "文字列 [STRING] . [METHOD] ( [ARG1] )", - "smalrubyRuby.stringMethodC": "文字列 [STRING] . [METHOD] ( [ARG1] )" + "smalrubyRuby.methodR": "[STRING] . [METHOD]", + "smalrubyRuby.methodC": "[STRING] . [METHOD]" }, "ja-Hira": { "smalrubyRuby.categoryName": "るびー", - "smalrubyRuby.stringMethodR": "もじれつ [STRING] . [METHOD] ( [ARG1] )", - "smalrubyRuby.stringMethodC": "もじれつ [STRING] . [METHOD] ( [ARG1] )" + "smalrubyRuby.methodR": "[STRING] . [METHOD]", + "smalrubyRuby.methodC": "[STRING] . [METHOD]" } } diff --git a/packages/scratch-vm/src/serialization/smalruby-migration.js b/packages/scratch-vm/src/serialization/smalruby-migration.js index a17e96941dd..cae78b0744c 100644 --- a/packages/scratch-vm/src/serialization/smalruby-migration.js +++ b/packages/scratch-vm/src/serialization/smalruby-migration.js @@ -59,8 +59,30 @@ const migrateMeshV1Blocks = projectJSON => { return newProjectJSON; }; +/** + * Migrate legacy stringMethodR/stringMethodC opcodes to methodR/methodC. + * This runs unconditionally on every project load. + * @param {object} projectJSON The project JSON to migrate. + * @returns {object} The migrated project JSON (modified in place). + */ +const migrateStringMethodBlocks = projectJSON => { + if (!projectJSON.targets) return projectJSON; + for (const target of projectJSON.targets) { + for (const blockId in target.blocks) { + const block = target.blocks[blockId]; + if (block.opcode === 'smalrubyRuby_stringMethodR') { + block.opcode = 'smalrubyRuby_methodR'; + } else if (block.opcode === 'smalrubyRuby_stringMethodC') { + block.opcode = 'smalrubyRuby_methodC'; + } + } + } + return projectJSON; +}; + module.exports = { detectMeshV1Blocks, detectKoshien, migrateMeshV1Blocks, + migrateStringMethodBlocks, }; diff --git a/packages/scratch-vm/src/virtual-machine.js b/packages/scratch-vm/src/virtual-machine.js index 7450e430932..43f44017535 100644 --- a/packages/scratch-vm/src/virtual-machine.js +++ b/packages/scratch-vm/src/virtual-machine.js @@ -18,7 +18,7 @@ const formatMessage = require('format-message'); const Variable = require('./engine/variable'); const newBlockIds = require('./util/new-block-ids'); -const {detectMeshV1Blocks, detectKoshien, migrateMeshV1Blocks} = require('./serialization/smalruby-migration'); +const {detectMeshV1Blocks, detectKoshien, migrateMeshV1Blocks, migrateStringMethodBlocks} = require('./serialization/smalruby-migration'); const {loadCostume} = require('./import/load-costume.js'); const {loadSound} = require('./import/load-sound.js'); @@ -366,6 +366,8 @@ class VirtualMachine extends EventEmitter { if (options.migrateMeshV1ToV2) { projectJSON = migrateMeshV1Blocks(projectJSON); } + // smalruby: stringMethodR/C → methodR/C migration (always) + migrateStringMethodBlocks(projectJSON); return this.deserializeProject(projectJSON, validatedInput[1]); }) .then(() => this.runtime.handleProjectLoaded()) diff --git a/packages/scratch-vm/test/unit/smalruby_migration.js b/packages/scratch-vm/test/unit/smalruby_migration.js index e063eb25afb..ec572ec564c 100644 --- a/packages/scratch-vm/test/unit/smalruby_migration.js +++ b/packages/scratch-vm/test/unit/smalruby_migration.js @@ -5,6 +5,7 @@ const { detectMeshV1Blocks, detectKoshien, migrateMeshV1Blocks, + migrateStringMethodBlocks, } = require('../../src/serialization/smalruby-migration'); const meshV1Project = JSON.parse( @@ -53,3 +54,22 @@ test('migrateMeshV1Blocks', t => { t.end(); }); + +test('migrateStringMethodBlocks', t => { + const project = { + targets: [{ + blocks: { + a: {opcode: 'smalrubyRuby_stringMethodR'}, + b: {opcode: 'smalrubyRuby_stringMethodC'}, + c: {opcode: 'smalrubyRuby_methodR'}, + d: {opcode: 'motion_movesteps'}, + } + }] + }; + migrateStringMethodBlocks(project); + t.equal(project.targets[0].blocks.a.opcode, 'smalrubyRuby_methodR', 'stringMethodR migrated'); + t.equal(project.targets[0].blocks.b.opcode, 'smalrubyRuby_methodC', 'stringMethodC migrated'); + t.equal(project.targets[0].blocks.c.opcode, 'smalrubyRuby_methodR', 'methodR unchanged'); + t.equal(project.targets[0].blocks.d.opcode, 'motion_movesteps', 'unrelated unchanged'); + t.end(); +}); From 6e2e5814b70a6399a6a822423ae8d554d31847fc Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Fri, 17 Apr 2026 21:25:23 +0900 Subject: [PATCH 2/3] style: fix Prettier formatting in migration test Co-Authored-By: Claude Opus 4.6 (1M context) --- .../scratch-vm/test/unit/smalruby_migration.js | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/scratch-vm/test/unit/smalruby_migration.js b/packages/scratch-vm/test/unit/smalruby_migration.js index ec572ec564c..35b750888bf 100644 --- a/packages/scratch-vm/test/unit/smalruby_migration.js +++ b/packages/scratch-vm/test/unit/smalruby_migration.js @@ -57,14 +57,16 @@ test('migrateMeshV1Blocks', t => { test('migrateStringMethodBlocks', t => { const project = { - targets: [{ - blocks: { - a: {opcode: 'smalrubyRuby_stringMethodR'}, - b: {opcode: 'smalrubyRuby_stringMethodC'}, - c: {opcode: 'smalrubyRuby_methodR'}, - d: {opcode: 'motion_movesteps'}, - } - }] + targets: [ + { + blocks: { + a: { opcode: 'smalrubyRuby_stringMethodR' }, + b: { opcode: 'smalrubyRuby_stringMethodC' }, + c: { opcode: 'smalrubyRuby_methodR' }, + d: { opcode: 'motion_movesteps' }, + }, + }, + ], }; migrateStringMethodBlocks(project); t.equal(project.targets[0].blocks.a.opcode, 'smalrubyRuby_methodR', 'stringMethodR migrated'); From 476de98e1bb7dc102ecdf0ccfd7632b0f23f6ed3 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Fri, 17 Apr 2026 22:39:42 +0900 Subject: [PATCH 3/3] fix: make array/hash methods execute correctly at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert list variable receivers to data_listcontents so the VM receives actual list contents (not empty scalar variable value) - Hash#keys/values reference the correct sub-list (__hash_X_keys__ / __hash_X_values__) - VM methodR: implement actual array operations (max finds maximum, sort sorts numerically, join concatenates with separator, lines splits by newline) Verified all 10 methods produce correct runtime output: reverse→"ymmiJ", delete→"heo", gsub→"herro", max→"47", sort→"12 35 47", join(", ")→"12, 47, 35", keys→"Ruby", values→"good", lines→"hello\n world\n" Refs #524 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ruby-to-blocks-converter/smalruby-ruby.js | 95 ++++++++++++++++--- .../src/extensions/smalruby_ruby/index.js | 34 +++++-- 2 files changed, 108 insertions(+), 21 deletions(-) diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/smalruby-ruby.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/smalruby-ruby.js index 748faeb849d..42c649ea798 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/smalruby-ruby.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/smalruby-ruby.js @@ -1,4 +1,7 @@ -// === Smalruby: This file is Smalruby-specific (Ruby String extension converter) === +// === Smalruby: This file is Smalruby-specific (Ruby method extension converter) === + +import { convertToListBlock } from './variable-hash-ops'; +import { messages } from './converter-errors'; /** * Build blockInfo mutation data for isDynamic string method blocks. @@ -237,7 +240,7 @@ const SmalrubyRubyConverter = { return block; }); - // Helper: register a no-arg REPORTER method + // Helper: register a no-arg REPORTER method (string) const registerNoArgR = (receivers, method) => { converter.registerOnSend(receivers, method, 0, params => { const {receiver} = params; @@ -252,6 +255,27 @@ const SmalrubyRubyConverter = { }); }; + // Helper: register a no-arg REPORTER method for list/hash receivers + // Converts data_variable to data_listcontents so the VM receives list contents + const registerListNoArgR = (receivers, method) => { + converter.registerOnSend(receivers, method, 0, params => { + let {receiver} = params; + // Convert data_variable to data_listcontents for list variables + const result = convertToListBlock(converter, messages, receiver); + if (result.converted) { + receiver = result.block; + } + const mutation = buildMutation( + 'reporter', method, 'methodRMenu', + methodRArgs, methodRMenuItems + ); + const block = converter._createBlock('smalrubyRuby_methodR', 'value', {mutation}); + converter._addTextInput(block, 'STRING', receiver, 'string'); + converter._addField(block, 'METHOD', method); + return block; + }); + }; + // Helper: register a no-arg COMMAND method (bang methods on variables) const registerNoArgC = (method) => { converter.registerOnSend(['variable'], method, 0, params => { @@ -272,17 +296,22 @@ const SmalrubyRubyConverter = { // String#lines (REPORTER, 0 args) registerNoArgR(['string', 'block', 'variable'], 'lines'); - // Array#max (REPORTER, 0 args) - registerNoArgR(['string', 'block', 'variable', 'array'], 'max'); + // Array#max (REPORTER, 0 args - list receiver) + registerListNoArgR(['string', 'block', 'variable', 'array'], 'max'); - // Array#sort (REPORTER, 0 args) - registerNoArgR(['string', 'block', 'variable', 'array'], 'sort'); + // Array#sort (REPORTER, 0 args - list receiver) + registerListNoArgR(['string', 'block', 'variable', 'array'], 'sort'); - // Array#join (REPORTER, 0-1 args) - registerNoArgR(['string', 'block', 'variable', 'array'], 'join'); + // Array#join (REPORTER, 0-1 args - list receiver) + registerListNoArgR(['string', 'block', 'variable', 'array'], 'join'); converter.registerOnSend(['string', 'block', 'variable', 'array'], 'join', 1, params => { - const {receiver, args} = params; + let {receiver} = params; + const {args} = params; if (!converter._isStringOrBlock(args[0])) return null; + const result = convertToListBlock(converter, messages, receiver); + if (result.converted) { + receiver = result.block; + } const mutation = buildMutation( 'reporter', 'join', 'methodRMenu', methodRArgs, methodRMenuItems @@ -294,9 +323,51 @@ const SmalrubyRubyConverter = { return block; }); - // Hash#keys, Hash#values (REPORTER, 0 args) - registerNoArgR(['string', 'block', 'variable', 'hash'], 'keys'); - registerNoArgR(['string', 'block', 'variable', 'hash'], 'values'); + // Hash#keys / Hash#values (REPORTER, 0 args) + // For hash variables, reference the __hash_X_keys__ / __hash_X_values__ list + const registerHashMethodR = (method) => { + converter.registerOnSend(['string', 'block', 'variable', 'hash'], method, 0, params => { + const {receiver} = params; + + // Try to resolve hash sub-list (keys or values) + if (converter._isBlock(receiver) && receiver.opcode === 'data_variable') { + const varName = receiver.fields.VARIABLE.value; + const variable = converter._context.variables[varName] || + converter._context.localVariables[varName]; + if (variable) { + let prefixedName; + if (variable.scope === 'global') prefixedName = `$${varName}`; + else if (variable.scope === 'instance') prefixedName = `@${varName}`; + else if (variable.scope === 'local') prefixedName = variable.originalName; + + if (prefixedName) { + const listName = method === 'keys' + ? converter._hashKeysListName(prefixedName) + : converter._hashValuesListName(prefixedName); + const listVar = converter._lookupOrCreateList(listName); + // Convert in-place to data_listcontents for the sub-list + receiver.opcode = 'data_listcontents'; + delete receiver.fields.VARIABLE; + receiver.fields.LIST = { + name: 'LIST', id: listVar.id, + value: listVar.name, variableType: listVar.type + }; + } + } + } + + const mutation = buildMutation( + 'reporter', method, 'methodRMenu', + methodRArgs, methodRMenuItems + ); + const block = converter._createBlock('smalrubyRuby_methodR', 'value', {mutation}); + converter._addTextInput(block, 'STRING', receiver, 'string'); + converter._addField(block, 'METHOD', method); + return block; + }); + }; + registerHashMethodR('keys'); + registerHashMethodR('values'); // Array#sort! (COMMAND, 0 args) registerNoArgC('sort!'); diff --git a/packages/scratch-vm/src/extensions/smalruby_ruby/index.js b/packages/scratch-vm/src/extensions/smalruby_ruby/index.js index 2dc7afca473..753b9548af0 100644 --- a/packages/scratch-vm/src/extensions/smalruby_ruby/index.js +++ b/packages/scratch-vm/src/extensions/smalruby_ruby/index.js @@ -318,7 +318,13 @@ class SmalrubyRubyBlocks { const method = args.METHOD; const arg1 = String(args.ARG1 || ''); const arg2 = (args.ARG2 === undefined) ? undefined : String(args.ARG2); + + // For list methods, data_listcontents provides items as space-separated string. + // Split into array items for operations. + const toItems = s => (s === '' ? [] : s.split(' ')); + switch (method) { + // String methods case 'reverse': return string.split('').reverse().join(''); case 'delete': @@ -329,18 +335,28 @@ class SmalrubyRubyBlocks { if (arg2 === undefined) return string; return string.replaceAll(arg1, arg2); case 'lines': - return JSON.stringify(string.split('\n').map(l => `${l}\n`)); - case 'max': - return string; // Array max is handled in converter, passthrough here - case 'sort': - return string; // Array sort is handled in converter, passthrough here - case 'join': { - const sep = arg1 || ''; - return string.replace(/^\[|\]$/g, '').split(',').map(s => s.trim()).join(sep); + return string.split('\n').filter((_, i, a) => i < a.length - 1 || _ !== '') + .map(l => `${l}\n`).join(' '); + // Array methods (receiver is list contents string) + case 'max': { + const items = toItems(string); + if (items.length === 0) return ''; + const nums = items.map(Number); + if (nums.every(n => !isNaN(n))) return String(Math.max(...nums)); + return items.reduce((a, b) => (a > b ? a : b)); } + case 'sort': { + const items = toItems(string); + const nums = items.map(Number); + if (nums.every(n => !isNaN(n))) return nums.sort((a, b) => a - b).join(' '); + return items.sort().join(' '); + } + case 'join': + return toItems(string).join(arg1); + // Hash methods (receiver is list contents string) case 'keys': case 'values': - return string; // Hash keys/values handled in converter, passthrough here + return string; default: return string; }