From aaecf6cba5a434e831cff5517f948ef2f80b7d2f Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sun, 15 Mar 2026 23:21:38 +0900 Subject: [PATCH 01/11] feat: add $_symbols_ list management infrastructure for Ruby symbols Add symbol collection mechanism (_collectSymbol) and $_symbols_ list creation (_createSymbolsList) as foundation for Ruby symbol support. Refs #313 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ruby-to-blocks-converter/context-utils.js | 3 +- .../variable-utils.js | 9 +++ .../variables/variables-symbol-list.test.js | 76 +++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-symbol-list.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 15230ab6681..708775387b5 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 @@ -39,7 +39,8 @@ const ContextUtils = { processDepth: 0, rootNode: null, modules: {}, - currentModuleName: null + currentModuleName: null, + symbols: new Set() }; 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/variable-utils.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variable-utils.js index 2ca09d76cd6..1a694ba690c 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variable-utils.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variable-utils.js @@ -209,6 +209,15 @@ const VariableUtils = { return procedure; }, + _collectSymbol (name) { + this._context.symbols.add(`:${name}`); + }, + + _createSymbolsList () { + if (this._context.symbols.size === 0) return; + this._lookupOrCreateList('$_symbols_'); + }, + _changeToBooleanArgument (varName) { varName = varName.toString(); const variable = this._context.localVariables[varName]; diff --git a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-symbol-list.test.js b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-symbol-list.test.js new file mode 100644 index 00000000000..fab4ff85c4a --- /dev/null +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-symbol-list.test.js @@ -0,0 +1,76 @@ +import RubyToBlocksConverter from '../../../../../src/lib/ruby-to-blocks-converter'; + +describe('RubyToBlocksConverter/Variables/SymbolList', () => { + let converter; + let target; + + beforeEach(() => { + converter = new RubyToBlocksConverter(null, {version: '2'}); + target = null; + }); + + describe('_collectSymbol', () => { + test('adds symbol to the symbols set with colon prefix', () => { + converter.reset(); + converter._collectSymbol('foo'); + expect(converter._context.symbols).toContain(':foo'); + }); + + test('preserves insertion order', () => { + converter.reset(); + converter._collectSymbol('bar'); + converter._collectSymbol('foo'); + converter._collectSymbol('baz'); + expect(Array.from(converter._context.symbols)).toEqual([':bar', ':foo', ':baz']); + }); + + test('does not duplicate existing symbols', () => { + converter.reset(); + converter._collectSymbol('foo'); + converter._collectSymbol('foo'); + expect(converter._context.symbols.size).toBe(1); + }); + }); + + describe('symbols set in context', () => { + test('is initialized as empty Set on reset', () => { + converter.reset(); + expect(converter._context.symbols).toBeInstanceOf(Set); + expect(converter._context.symbols.size).toBe(0); + }); + + test('is cleared on each reset', () => { + converter.reset(); + converter._collectSymbol('foo'); + converter.reset(); + expect(converter._context.symbols.size).toBe(0); + }); + }); + + describe('$_symbols_ list creation via _createSymbolsList', () => { + test('creates $_symbols_ list when symbols are collected', () => { + converter.reset(); + converter._collectSymbol('foo'); + converter._collectSymbol('bar'); + converter._createSymbolsList(); + expect(converter.lists).toHaveProperty('_symbols_'); + const list = converter.lists['_symbols_']; + expect(list.name).toBe('_symbols_'); + expect(list.scope).toBe('global'); + }); + + test('does not create $_symbols_ list when no symbols are collected', () => { + converter.reset(); + converter._createSymbolsList(); + expect(converter.lists).not.toHaveProperty('_symbols_'); + }); + + test('$_symbols_ list has correct type (list)', () => { + converter.reset(); + converter._collectSymbol('foo'); + converter._createSymbolsList(); + const list = converter.lists['_symbols_']; + expect(list.type).toBe('list'); + }); + }); +}); From a395ad794527c3797af43ce1657de42d94d635dc Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sun, 15 Mar 2026 23:30:23 +0900 Subject: [PATCH 02/11] feat: convert symbol references to data_itemnumoflist blocks Symbols used as values (variable assignment, comparison) are converted to data_itemnumoflist blocks referencing the $_symbols_ global list. Add @ruby:symbol:name comments for round-trip conversion. Refs #313 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ruby-to-blocks-converter/context-utils.js | 4 + .../ruby-to-blocks-converter/node-utils.js | 16 +- .../lib/ruby-to-blocks-converter/operators.js | 44 +++++ .../variable-utils.js | 19 ++ .../lib/ruby-to-blocks-converter/variables.js | 8 + .../variables/variables-symbol-ref.test.js | 168 ++++++++++++++++++ 6 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-symbol-ref.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 708775387b5..8e0b8a3e1b2 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 @@ -187,6 +187,10 @@ const ContextUtils = { return 'nil'; } + if (this._isSymbol(receiver)) { + return 'symbol'; + } + if (this._isConst(receiver)) { return receiver.toString(); } diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/node-utils.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/node-utils.js index 59bd3c2cfb8..b93e5a54179 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/node-utils.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/node-utils.js @@ -122,12 +122,17 @@ const NodeUtils = { if (this._isPrimitive(value)) { return value.type === 'sym'; } + if (this._isBlock(value) && value.comment) { + const comment = this._context.comments[value.comment]; + return comment && comment.text.startsWith('@ruby:symbol:'); + } return value && value.constructor.name === 'SymbolNode'; }, /** * Get the string value of a symbol node. - * Works for Prism SymbolNode instances ({unescaped: {value: '...'}}). + * Works for Prism SymbolNode instances ({unescaped: {value: '...'}}), + * Primitive('sym', value), and blocks with @ruby:symbol: comments. * @param {object} node - A symbol node. * @returns {string|null} The symbol value, or null if not a symbol. */ @@ -136,7 +141,13 @@ const NodeUtils = { if (this._isPrimitive(node) && node.type === 'sym') { return node.value; } - if (node.constructor.name === 'SymbolNode') { + if (this._isBlock(node) && node.comment) { + const comment = this._context.comments[node.comment]; + if (comment && comment.text.startsWith('@ruby:symbol:')) { + return comment.text.slice('@ruby:symbol:'.length); + } + } + if (node.constructor && node.constructor.name === 'SymbolNode') { return node.unescaped ? node.unescaped.value : null; } return null; @@ -270,6 +281,7 @@ const NodeUtils = { if (this._isString(value)) return 'string'; if (this._isNumber(value)) return 'number'; if (this._isTrue(value) || this._isFalse(value)) return 'boolean'; + if (this._isSymbol(value)) return 'symbol'; if (this._isBlock(value)) return this._getBlockDataType(value); return null; }, diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/operators.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/operators.js index 9390bc0c475..b8280924cdb 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/operators.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/operators.js @@ -250,6 +250,50 @@ const OperatorsConverter = { }); }); + // === Smalruby: Start of symbol comparison === + ['>', '<', '=='].forEach(operator => { + converter.registerOnSend('symbol', operator, 1, params => { + const {receiver, args} = params; + let rh = args[0]; + if (_.isArray(rh)) { + if (rh.length !== 1) return null; + rh = rh[0]; + } + + const receiverBlock = converter._symbolToBlock( + converter._getSymbolValue(receiver), receiver.node + ); + if (converter._isPrimitive(rh) && rh.type === 'sym') { + rh = converter._symbolToBlock(rh.value, rh.node); + } + + let opcode; + if (operator === '>') { + opcode = 'operator_gt'; + } else if (operator === '<') { + opcode = 'operator_lt'; + } else { + opcode = 'operator_equals'; + } + + const block = converter._createBlock(opcode, 'value_boolean'); + converter._addInput( + block, 'OPERAND1', receiverBlock, converter._createTextBlock('') + ); + if (converter._isBlock(rh)) { + converter._addInput( + block, 'OPERAND2', rh, converter._createTextBlock('50') + ); + } else { + converter._addTextInput( + block, 'OPERAND2', converter._isNumber(rh) ? rh.toString() : rh, '50' + ); + } + return block; + }); + }); + // === Smalruby: End of symbol comparison === + converter.registerOnSend(['variable', 'boolean', 'block'], '!', 0, params => { const {receiver} = params; if (!converter._isFalseOrBooleanBlock(receiver)) return null; diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variable-utils.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variable-utils.js index 1a694ba690c..789a8dc7f01 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variable-utils.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variable-utils.js @@ -218,6 +218,25 @@ const VariableUtils = { this._lookupOrCreateList('$_symbols_'); }, + _symbolToBlock (symbolName, node) { + this._collectSymbol(symbolName); + const list = this._lookupOrCreateList('$_symbols_'); + const block = this._createBlock('data_itemnumoflist', 'value', { + fields: { + LIST: { + name: 'LIST', + id: list.id, + value: list.name, + variableType: list.type + } + } + }); + block.node = node; + this._addTextInput(block, 'ITEM', `:${symbolName}`, 'thing'); + block.comment = this._createComment(`@ruby:symbol:${symbolName}`, block.id); + return block; + }, + _changeToBooleanArgument (varName) { varName = varName.toString(); const variable = this._context.localVariables[varName]; diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variables.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variables.js index b0fa773592b..d0cabb7c9ab 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variables.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variables.js @@ -631,6 +631,14 @@ const VariablesConverter = { }); converter.registerOnVasgn((scope, variable, rh) => { + // === Smalruby: Start of symbol assignment === + if ((scope === 'global' || scope === 'instance' || + (scope === 'local' && !variable.isArgument)) && + converter._isPrimitive(rh) && rh.type === 'sym') { + rh = converter._symbolToBlock(rh.value, rh.node); + } + // === Smalruby: End of symbol assignment === + // === Smalruby: Start of array syntax === if ((scope === 'global' || scope === 'instance' || (scope === 'local' && !variable.isArgument)) && diff --git a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-symbol-ref.test.js b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-symbol-ref.test.js new file mode 100644 index 00000000000..edffe8caa93 --- /dev/null +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-symbol-ref.test.js @@ -0,0 +1,168 @@ +import RubyToBlocksConverter from '../../../../../src/lib/ruby-to-blocks-converter'; + +describe('RubyToBlocksConverter/Variables/SymbolReference', () => { + let converter; + let target; + + beforeEach(() => { + converter = new RubyToBlocksConverter(null, {version: '2'}); + target = null; + }); + + describe('_symbolToBlock', () => { + test('creates data_itemnumoflist block', () => { + converter.reset(); + const block = converter._symbolToBlock('foo', null); + expect(block.opcode).toBe('data_itemnumoflist'); + }); + + test('adds @ruby:symbol:name comment', () => { + converter.reset(); + const block = converter._symbolToBlock('foo', null); + const comment = converter._context.comments[block.comment]; + expect(comment).toBeDefined(); + expect(comment.text).toBe('@ruby:symbol:foo'); + }); + + test('collects symbol', () => { + converter.reset(); + converter._symbolToBlock('foo', null); + expect(converter._context.symbols).toContain(':foo'); + }); + + test('creates $_symbols_ list', () => { + converter.reset(); + converter._symbolToBlock('foo', null); + expect(converter.lists).toHaveProperty('_symbols_'); + }); + + test('has ITEM input with colon-prefixed symbol name', () => { + converter.reset(); + const block = converter._symbolToBlock('foo', null); + expect(block.inputs).toHaveProperty('ITEM'); + const itemBlockId = block.inputs.ITEM.block; + const itemBlock = converter._context.blocks[itemBlockId]; + expect(itemBlock.fields.TEXT.value).toBe(':foo'); + }); + + test('has LIST field referencing $_symbols_', () => { + converter.reset(); + converter._symbolToBlock('foo', null); + const list = converter.lists['_symbols_']; + const block = converter._symbolToBlock('bar', null); + expect(block.fields.LIST.value).toBe('_symbols_'); + expect(block.fields.LIST.id).toBe(list.id); + }); + }); + + describe('variable assignment with symbol', () => { + test('$a = :foo creates data_setvariableto with data_itemnumoflist', async () => { + const code = '$a = :foo'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + + const blocks = Object.values(converter.blocks); + const setVarBlock = blocks.find(b => b.opcode === 'data_setvariableto'); + expect(setVarBlock).toBeDefined(); + + // The VALUE input should be a data_itemnumoflist block + const valueBlockId = setVarBlock.inputs.VALUE.block; + const valueBlock = converter.blocks[valueBlockId]; + expect(valueBlock.opcode).toBe('data_itemnumoflist'); + + // Check symbol comment + const comment = converter._context.comments[valueBlock.comment]; + expect(comment.text).toBe('@ruby:symbol:foo'); + }); + + test('$a = :foo sets dataType to symbol', async () => { + const code = '$a = :foo'; + await converter.targetCodeToBlocks(target, code); + expect(converter.variables['a'].dataType).toBe('symbol'); + }); + + test('a = :foo (local var) creates data_setvariableto with data_itemnumoflist', async () => { + const code = 'a = :foo'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + + const blocks = Object.values(converter.blocks); + const setVarBlock = blocks.find(b => b.opcode === 'data_setvariableto'); + expect(setVarBlock).toBeDefined(); + + const valueBlockId = setVarBlock.inputs.VALUE.block; + const valueBlock = converter.blocks[valueBlockId]; + expect(valueBlock.opcode).toBe('data_itemnumoflist'); + }); + + test('$_symbols_ list is created', async () => { + const code = '$a = :foo'; + await converter.targetCodeToBlocks(target, code); + expect(converter.lists).toHaveProperty('_symbols_'); + }); + + test('symbols are collected', async () => { + const code = [ + '$a = :foo', + '$b = :bar' + ].join('\n'); + await converter.targetCodeToBlocks(target, code); + expect(Array.from(converter._context.symbols)).toEqual([':foo', ':bar']); + }); + }); + + describe('comparison with symbols', () => { + test(':foo == :bar creates operator_equals with data_itemnumoflist', async () => { + const code = ':foo == :bar'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + + const blocks = Object.values(converter.blocks); + const equalsBlock = blocks.find(b => b.opcode === 'operator_equals'); + expect(equalsBlock).toBeDefined(); + + // Both operands should be data_itemnumoflist blocks + const op1BlockId = equalsBlock.inputs.OPERAND1.block; + const op1Block = converter.blocks[op1BlockId]; + expect(op1Block.opcode).toBe('data_itemnumoflist'); + + const op2BlockId = equalsBlock.inputs.OPERAND2.block; + const op2Block = converter.blocks[op2BlockId]; + expect(op2Block.opcode).toBe('data_itemnumoflist'); + }); + + test('both symbols are collected', async () => { + const code = ':foo == :bar'; + await converter.targetCodeToBlocks(target, code); + expect(Array.from(converter._context.symbols)).toEqual([':foo', ':bar']); + }); + }); + + describe('event handlers still work', () => { + test('self.when(:flag_clicked) works without error', async () => { + const code = 'self.when(:flag_clicked) { move(10) }'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + + const blocks = Object.values(converter.blocks); + const hatBlock = blocks.find(b => b.opcode === 'event_whenflagclicked'); + expect(hatBlock).toBeDefined(); + }); + + test('event handler symbols are not collected', async () => { + const code = 'self.when(:flag_clicked) { move(10) }'; + await converter.targetCodeToBlocks(target, code); + expect(converter._context.symbols.size).toBe(0); + }); + + test('$_symbols_ list is not created for event handler only', async () => { + const code = 'self.when(:flag_clicked) { move(10) }'; + await converter.targetCodeToBlocks(target, code); + expect(converter.lists).not.toHaveProperty('_symbols_'); + }); + }); +}); From 98e4475a490730e010e4022100e437c787bb5ef6 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sun, 15 Mar 2026 23:33:27 +0900 Subject: [PATCH 03/11] feat: support :symbol.to_s conversion to operator_join block Add symbol receiver handler for .to_s that creates operator_join("foo", "") with @ruby:symbol:foo comment. Call _createSymbolsList at end of conversion. Refs #313 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/ruby-to-blocks-converter/index.js | 3 + .../lib/ruby-to-blocks-converter/operators.js | 15 +++++ .../operators/symbol-to-s.test.js | 65 +++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/operators/symbol-to-s.test.js 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 232252e000e..d2b5e879174 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 @@ -328,6 +328,9 @@ class RubyToBlocksConverter extends Visitor { } }); + // Create $_symbols_ list if symbols were collected + this._createSymbolsList(); + // Associate source comments with blocks this._associateSourceComments(parseResult, code); diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/operators.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/operators.js index b8280924cdb..d8ab3129ac5 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/operators.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/operators.js @@ -315,6 +315,21 @@ const OperatorsConverter = { return block; }); + // === Smalruby: Start of symbol to_s === + converter.registerOnSend('symbol', 'to_s', 0, params => { + const {receiver} = params; + const symbolName = converter._getSymbolValue(receiver); + if (!symbolName) return null; + + converter._collectSymbol(symbolName); + const block = converter._createBlock('operator_join', 'value'); + converter._addTextInput(block, 'STRING1', symbolName, ''); + converter._addTextInput(block, 'STRING2', '', ''); + block.comment = converter._createComment(`@ruby:symbol:${symbolName}`, block.id); + return block; + }); + // === Smalruby: End of symbol to_s === + converter.registerOnSend(['variable', 'number', 'string', 'block'], 'to_s', 0, params => { const {receiver} = params; diff --git a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/operators/symbol-to-s.test.js b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/operators/symbol-to-s.test.js new file mode 100644 index 00000000000..0e7fcf05853 --- /dev/null +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/operators/symbol-to-s.test.js @@ -0,0 +1,65 @@ +import RubyToBlocksConverter from '../../../../../src/lib/ruby-to-blocks-converter'; + +describe('RubyToBlocksConverter/Operators/SymbolToS', () => { + let converter; + let target; + + beforeEach(() => { + converter = new RubyToBlocksConverter(null, {version: '2'}); + target = null; + }); + + describe(':symbol.to_s', () => { + test(':foo.to_s creates operator_join with symbol name', async () => { + const code = 'say(:foo.to_s)'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + + const blocks = Object.values(converter.blocks); + const joinBlock = blocks.find(b => b.opcode === 'operator_join'); + expect(joinBlock).toBeDefined(); + + // STRING1 should be "foo" (symbol name without colon) + const str1BlockId = joinBlock.inputs.STRING1.block; + const str1Block = converter.blocks[str1BlockId]; + expect(str1Block.fields.TEXT.value).toBe('foo'); + + // STRING2 should be empty + const str2BlockId = joinBlock.inputs.STRING2.block; + const str2Block = converter.blocks[str2BlockId]; + expect(str2Block.fields.TEXT.value).toBe(''); + }); + + test(':foo.to_s has @ruby:symbol:foo comment', async () => { + const code = 'say(:foo.to_s)'; + await converter.targetCodeToBlocks(target, code); + + const blocks = Object.values(converter.blocks); + const joinBlock = blocks.find(b => b.opcode === 'operator_join'); + const comment = converter._context.comments[joinBlock.comment]; + expect(comment.text).toBe('@ruby:symbol:foo'); + }); + + test(':foo.to_s collects symbol', async () => { + const code = 'say(:foo.to_s)'; + await converter.targetCodeToBlocks(target, code); + expect(converter._context.symbols).toContain(':foo'); + }); + + test(':foo.to_s creates $_symbols_ list', async () => { + const code = 'say(:foo.to_s)'; + await converter.targetCodeToBlocks(target, code); + expect(converter.lists).toHaveProperty('_symbols_'); + }); + + test('multiple :symbol.to_s collects all symbols', async () => { + const code = [ + 'say(:foo.to_s)', + 'say(:bar.to_s)' + ].join('\n'); + await converter.targetCodeToBlocks(target, code); + expect(Array.from(converter._context.symbols)).toEqual([':foo', ':bar']); + }); + }); +}); From 91ec44af37739a9eec9cfb1f547b224e65468fa6 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sun, 15 Mar 2026 23:39:08 +0900 Subject: [PATCH 04/11] feat: implicit symbol-to-string conversion for say/think/puts/p/print Symbol arguments in display methods are implicitly converted to their string name. E.g., say(:foo) displays "foo" with @ruby:symbol:foo comment. Refs #313 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/ruby-to-blocks-converter/looks.js | 102 ++++++++++++++++++ .../looks/looks1.test.js | 3 - .../looks/looks2.test.js | 1 - .../looks/symbol-implicit.test.js | 100 +++++++++++++++++ 4 files changed, 202 insertions(+), 4 deletions(-) create mode 100644 packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/looks/symbol-implicit.test.js diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/looks.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/looks.js index 4df66fcb545..6dd128984b2 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/looks.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/looks.js @@ -97,8 +97,110 @@ const validateBackdrop = function (converter, backdropName, args) { /** * Looks converter */ +/** + * Convert a symbol Primitive to its string name and collect it. + * Returns the symbol name (without colon) or null if not a symbol. + */ +const resolveSymbolArg = function (converter, arg) { + if (converter._isPrimitive(arg) && arg.type === 'sym') { + converter._collectSymbol(arg.value); + return arg.value; + } + return null; +}; + const LooksConverter = { register: function (converter) { + // === Smalruby: Start of symbol implicit conversion === + // say/think with symbol argument - sprite-only + ['say', 'think'].forEach(methodName => { + const opcodes1 = {say: 'looks_say', think: 'looks_think'}; + const opcodes2 = {say: 'looks_sayforsecs', think: 'looks_thinkforsecs'}; + const defaults = {say: 'Hello!', think: 'Hmm...'}; + + converter.registerOnSend('sprite', methodName, [1, 2], params => { + const {receiver, args} = params; + if (!converter._isSelf(receiver) && receiver !== null) return null; + const symbolName = resolveSymbolArg(converter, args[0]); + if (!symbolName) return null; + + if (args.length === 1) { + const block = converter._createBlock(opcodes1[methodName], 'statement'); + converter._addTextInput(block, 'MESSAGE', symbolName, defaults[methodName]); + block.comment = converter._createComment( + `@ruby:symbol:${symbolName}`, block.id + ); + return block; + } + + if (args.length === 2) { + let secs = args[1]; + if (converter._isHash(secs) && secs.size === 1) { + secs = secs.get('sym:secs'); + } + if (converter._isNumberOrBlock(secs)) { + const block = converter._createBlock(opcodes2[methodName], 'statement'); + converter._addTextInput(block, 'MESSAGE', symbolName, defaults[methodName]); + converter._addNumberInput(block, 'SECS', 'math_number', secs, 2); + block.comment = converter._createComment( + `@ruby:symbol:${symbolName}`, block.id + ); + return block; + } + } + return null; + }); + }); + + // print/puts/p with symbol arguments - sprite-only + ['print', 'puts', 'p'].forEach(methodName => { + converter.registerOnSend('sprite', methodName, -1, params => { + const {args} = params; + if (args.length === 0) return null; + if (!args.every(arg => + converter._isNumberOrStringOrBlock(arg) || + (converter._isPrimitive(arg) && arg.type === 'sym') + )) return null; + // Only handle if at least one symbol arg + if (!args.some(arg => converter._isPrimitive(arg) && arg.type === 'sym')) return null; + + let firstBlock = null; + let lastBlock = null; + + args.forEach(arg => { + const block = converter._createBlock('looks_sayforsecs', 'statement'); + const symbolName = resolveSymbolArg(converter, arg); + if (symbolName) { + converter._addTextInput(block, 'MESSAGE', symbolName, 'Hello!'); + block.comment = converter._createComment( + `@ruby:symbol:${symbolName},@ruby:method:${methodName}`, block.id + ); + } else { + converter._addTextInput( + block, 'MESSAGE', + converter._isNumber(arg) ? arg.toString() : arg, 'Hello!' + ); + block.comment = converter.createComment( + `@ruby:method:${methodName}`, block.id, 200, 0 + ); + } + converter._addNumberInput(block, 'SECS', 'math_number', 1, 1); + + if (!firstBlock) { + firstBlock = block; + } + if (lastBlock) { + lastBlock.next = block.id; + block.parent = lastBlock.id; + } + lastBlock = block; + }); + + return firstBlock; + }); + }); + // === Smalruby: End of symbol implicit conversion === + // print/puts/p - sprite-only, mapped to looks_sayforsecs ['print', 'puts', 'p'].forEach(methodName => { converter.registerOnSend('sprite', methodName, -1, params => { diff --git a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/looks/looks1.test.js b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/looks/looks1.test.js index 705187b475d..235169ec501 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/looks/looks1.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/looks/looks1.test.js @@ -103,7 +103,6 @@ describe('RubyToBlocksConverter/Looks', () => { { for (const c of [ 'say("Hello!", "2")', 'say("Hello!", 2, 3)', - 'say(:symbol, 2)', 'say("Hello!", :symbol)' ]) { await convertAndExpectRubyBlockError(converter, target, c); @@ -160,7 +159,6 @@ describe('RubyToBlocksConverter/Looks', () => { test('invalid', async () => { { for (const c of [ 'say', - 'say(:symbol)', 'say(1, 2, 1)' ]) { await convertAndExpectRubyBlockError(converter, target, c); @@ -250,7 +248,6 @@ describe('RubyToBlocksConverter/Looks', () => { { for (const c of [ 'think("Hello!", "2")', 'think("Hello!", 2, 3)', - 'think(:symbol, 2)', 'think("Hello!", :symbol)' ]) { await convertAndExpectRubyBlockError(converter, target, c); diff --git a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/looks/looks2.test.js b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/looks/looks2.test.js index 0d4330dd71f..00bc6635d53 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/looks/looks2.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/looks/looks2.test.js @@ -70,7 +70,6 @@ describe('RubyToBlocksConverter/Looks', () => { test('invalid', async () => { { for (const c of [ 'think', - 'think(:symbol)', 'think(1, 2, 1)' ]) { await convertAndExpectRubyBlockError(converter, target, c); diff --git a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/looks/symbol-implicit.test.js b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/looks/symbol-implicit.test.js new file mode 100644 index 00000000000..18c1d5fc6e1 --- /dev/null +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/looks/symbol-implicit.test.js @@ -0,0 +1,100 @@ +import RubyToBlocksConverter from '../../../../../src/lib/ruby-to-blocks-converter'; + +describe('RubyToBlocksConverter/Looks/SymbolImplicit', () => { + let converter; + let target; + + beforeEach(() => { + converter = new RubyToBlocksConverter(null, {version: '2'}); + target = null; + }); + + describe('say(:symbol)', () => { + test('say(:foo) creates looks_say with "foo" message', async () => { + const code = 'say(:foo)'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + + const blocks = Object.values(converter.blocks); + const sayBlock = blocks.find(b => b.opcode === 'looks_say'); + expect(sayBlock).toBeDefined(); + + // MESSAGE should be "foo" (symbol name without colon) + const msgBlockId = sayBlock.inputs.MESSAGE.block; + const msgBlock = converter.blocks[msgBlockId]; + expect(msgBlock.fields.TEXT.value).toBe('foo'); + }); + + test('say(:foo) has @ruby:symbol:foo comment', async () => { + const code = 'say(:foo)'; + await converter.targetCodeToBlocks(target, code); + + const blocks = Object.values(converter.blocks); + const sayBlock = blocks.find(b => b.opcode === 'looks_say'); + const comment = converter._context.comments[sayBlock.comment]; + expect(comment.text).toBe('@ruby:symbol:foo'); + }); + + test('say(:foo) collects symbol', async () => { + const code = 'say(:foo)'; + await converter.targetCodeToBlocks(target, code); + expect(converter._context.symbols).toContain(':foo'); + }); + + test('say(:foo, 2) creates looks_sayforsecs with "foo" message', async () => { + const code = 'say(:foo, 2)'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + + const blocks = Object.values(converter.blocks); + const sayBlock = blocks.find(b => b.opcode === 'looks_sayforsecs'); + expect(sayBlock).toBeDefined(); + + const msgBlockId = sayBlock.inputs.MESSAGE.block; + const msgBlock = converter.blocks[msgBlockId]; + expect(msgBlock.fields.TEXT.value).toBe('foo'); + }); + }); + + describe('puts/p/print with symbol', () => { + test('puts(:foo) creates looks_sayforsecs with "foo" message', async () => { + const code = 'puts(:foo)'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + + const blocks = Object.values(converter.blocks); + const sayBlock = blocks.find(b => b.opcode === 'looks_sayforsecs'); + expect(sayBlock).toBeDefined(); + }); + + test('p(:foo) creates looks_sayforsecs with "foo" message', async () => { + const code = 'p(:foo)'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + }); + + test('print(:foo) creates looks_sayforsecs with "foo" message', async () => { + const code = 'print(:foo)'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + }); + }); + + describe('think(:symbol)', () => { + test('think(:foo) creates looks_think with "foo" message', async () => { + const code = 'think(:foo)'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + + const blocks = Object.values(converter.blocks); + const thinkBlock = blocks.find(b => b.opcode === 'looks_think'); + expect(thinkBlock).toBeDefined(); + }); + }); +}); From 80583eead7959bf7892b28b2c4452212b1d5dcb7 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sun, 15 Mar 2026 23:51:06 +0900 Subject: [PATCH 05/11] feat: add symbolNeedsToS error for unsupported symbol contexts When a symbol is used where .to_s is needed (e.g., move(:foo)), show a specific error suggesting to add .to_s instead of generic "wrong instruction" error. Refs #313 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ast-handlers/expressions.js | 19 +++++ .../src/lib/ruby-to-blocks-converter/index.js | 23 ++++++ packages/scratch-gui/src/locales/ja-Hira.js | 1 + packages/scratch-gui/src/locales/ja.js | 1 + .../test/helpers/expect-to-equal-blocks.js | 2 +- .../operators/symbol-error.test.js | 74 +++++++++++++++++++ 6 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/operators/symbol-error.test.js diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/ast-handlers/expressions.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/ast-handlers/expressions.js index ff0e6dae8c2..877607a5ad0 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/ast-handlers/expressions.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/ast-handlers/expressions.js @@ -1,5 +1,6 @@ import _ from 'lodash'; import Primitive from '../primitive'; +import {RubyToBlocksConverterError} from '../errors'; /** * Expression AST handlers for RubyToBlocksConverter. @@ -58,6 +59,24 @@ const ExpressionHandlers = { if (!block) { this._restoreContext(saved); + // === Smalruby: Start of symbol needs to_s error === + const symbolArg = args.find(a => this._isPrimitive(a) && a.type === 'sym'); + if (symbolArg) { + const source = this._truncateSource(this._getSource(node)); + const suggestion = source.replace( + `:${symbolArg.value}`, + `:${symbolArg.value}.to_s` + ); + throw new RubyToBlocksConverterError( + node, + this._translator(this._symbolNeedsToSMessage(), { + SOURCE: source, + SUGGESTION: suggestion + }) + ); + } + // === Smalruby: End of symbol needs to_s error === + if (node.block) { block = this._createBlock('ruby_statement_with_block', 'statement'); block.node = node; 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 d2b5e879174..e7d907233f3 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 @@ -154,6 +154,12 @@ const messages = defineMessages({ 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' + }, + symbolNeedsToS: { + defaultMessage: '"{ SOURCE }" — symbols need .to_s to be used as a string.' + + '\nWrite { SUGGESTION } instead.', + description: 'Error message when a symbol is used where a string is expected without .to_s', + id: 'gui.smalruby3.rubyToBlocksConverter.symbolNeedsToS' } }); @@ -237,6 +243,10 @@ class RubyToBlocksConverter extends Visitor { return this._context.broadcastMsgs; } + _symbolNeedsToSMessage () { + return messages.symbolNeedsToS; + } + setTranslatorFunction (translator) { this._translator = translator; this._prismErrorTranslator = new PrismErrorTranslator(translator); @@ -290,6 +300,19 @@ class RubyToBlocksConverter extends Visitor { } else { const Primitive = require('./primitive').default; if (block instanceof Primitive) { + // === Smalruby: Start of symbol error === + if (block.type === 'sym') { + const source = this._truncateSource(this._getSource(block.node)); + const suggestion = `${source}.to_s`; + throw new RubyToBlocksConverterError( + block.node, + this._translator( + messages.symbolNeedsToS, + {SOURCE: source, SUGGESTION: suggestion} + ) + ); + } + // === Smalruby: End of symbol error === throw new RubyToBlocksConverterError( block.node, this._translator( diff --git a/packages/scratch-gui/src/locales/ja-Hira.js b/packages/scratch-gui/src/locales/ja-Hira.js index 34a6978a504..fedff18034c 100644 --- a/packages/scratch-gui/src/locales/ja-Hira.js +++ b/packages/scratch-gui/src/locales/ja-Hira.js @@ -120,6 +120,7 @@ export default { 'gui.smalruby3.rubyToBlocksConverter.moduleNotSupportedInStage': 'moduleはステージではつかえません。\nモジュールはスプライトのクラスでのみつかえます。', 'gui.smalruby3.rubyToBlocksConverter.includeNotSupportedInStage': 'includeはclass Stageではつかえません。\nモジュールはスプライトのクラスでのみとりこめます。', 'gui.smalruby3.rubyToBlocksConverter.moduleImportFailed': 'モジュール「{ NAME }」をほかのスプライトからとりこめませんでした。', + 'gui.smalruby3.rubyToBlocksConverter.symbolNeedsToS': '「{ SOURCE }」— シンボルには .to_s をつけてください。\n{ SUGGESTION } とかいてください。', '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 7532ce4443f..13a9111b6c1 100644 --- a/packages/scratch-gui/src/locales/ja.js +++ b/packages/scratch-gui/src/locales/ja.js @@ -120,6 +120,7 @@ export default { 'gui.smalruby3.rubyToBlocksConverter.moduleNotSupportedInStage': 'moduleはステージでは使えません。\nモジュールはスプライトのクラスでのみ使えます。', 'gui.smalruby3.rubyToBlocksConverter.includeNotSupportedInStage': 'includeはclass Stageでは使えません。\nモジュールはスプライトのクラスでのみ取り込めます。', 'gui.smalruby3.rubyToBlocksConverter.moduleImportFailed': 'モジュール「{ NAME }」を他のスプライトから取り込めませんでした。', + 'gui.smalruby3.rubyToBlocksConverter.symbolNeedsToS': '「{ SOURCE }」— シンボルには .to_s を付けてください。\n{ SUGGESTION } と書いてください。', 'gui.smalruby3.prismError.expectedCloseArgs': '`)` が足りません。\n`)` を追加して引数を閉じてください。', 'gui.smalruby3.prismError.expectedCloseArray': '`]` が足りません。\n`]` を追加して配列を閉じてください。', 'gui.smalruby3.prismError.expectedCloseHash': '`}` が足りません。\n`}` を追加してハッシュを閉じてください。', diff --git a/packages/scratch-gui/test/helpers/expect-to-equal-blocks.js b/packages/scratch-gui/test/helpers/expect-to-equal-blocks.js index caa971dfd67..904a9495279 100644 --- a/packages/scratch-gui/test/helpers/expect-to-equal-blocks.js +++ b/packages/scratch-gui/test/helpers/expect-to-equal-blocks.js @@ -249,7 +249,7 @@ const convertAndExpectToEqualBlocks = async function (converter, target, code, e const convertAndExpectRubyBlockError = async function (converter, target, code) { await converter.targetCodeToBlocks(target, code); expect(converter.errors).toHaveLength(1); - expect(converter.errors[0].text).toMatch(/ is the wrong instruction\.|condition is not boolean: |include not statement blocks/); + expect(converter.errors[0].text).toMatch(/ is the wrong instruction\.|condition is not boolean: |include not statement blocks|\.to_s/); }; const expectToEqualRubyStatement = function (converter, expectedStatement) { diff --git a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/operators/symbol-error.test.js b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/operators/symbol-error.test.js new file mode 100644 index 00000000000..0fee384a418 --- /dev/null +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/operators/symbol-error.test.js @@ -0,0 +1,74 @@ +import RubyToBlocksConverter from '../../../../../src/lib/ruby-to-blocks-converter'; + +describe('RubyToBlocksConverter/Operators/SymbolError', () => { + let converter; + let target; + + beforeEach(() => { + converter = new RubyToBlocksConverter(null, {version: '2'}); + target = null; + }); + + describe('symbol without .to_s in unsupported context', () => { + test('move(:foo) produces symbolNeedsToS error', async () => { + const code = 'move(:foo)'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeFalsy(); + expect(converter.errors).toHaveLength(1); + expect(converter.errors[0].text).toContain('.to_s'); + }); + + test(':foo standalone produces symbolNeedsToS error', async () => { + const code = ':foo'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeFalsy(); + expect(converter.errors).toHaveLength(1); + expect(converter.errors[0].text).toContain('.to_s'); + }); + + test('turn_right(:foo) produces symbolNeedsToS error', async () => { + const code = 'turn_right(:foo)'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeFalsy(); + expect(converter.errors).toHaveLength(1); + expect(converter.errors[0].text).toContain('.to_s'); + }); + }); + + describe('symbol in valid contexts does not error', () => { + test('say(:foo) does not error', async () => { + const code = 'say(:foo)'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + }); + + test(':foo.to_s does not error', async () => { + const code = 'say(:foo.to_s)'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + }); + + test('$a = :foo does not error', async () => { + const code = '$a = :foo'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + }); + + test(':foo == :bar does not error', async () => { + const code = ':foo == :bar'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + }); + + test('self.when(:flag_clicked) does not error', async () => { + const code = 'self.when(:flag_clicked) { move(10) }'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + }); + }); +}); From 5ef8893fddee12a7d74e46a24b4d667d00e5a68a Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sun, 15 Mar 2026 23:56:08 +0900 Subject: [PATCH 06/11] feat: blocks-to-ruby generator for symbol literals Detect @ruby:symbol: comments on blocks and generate :foo (reference), :foo.to_s (conversion), say(:foo)/think(:foo) (implicit conversion). Refs #313 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/ruby-generator/data.js | 7 + .../src/lib/ruby-generator/looks.js | 14 ++ .../src/lib/ruby-generator/operators.js | 6 + .../ruby-generator/symbol-generator.test.js | 127 ++++++++++++++++++ 4 files changed, 154 insertions(+) create mode 100644 packages/scratch-gui/test/unit/lib/ruby-generator/symbol-generator.test.js diff --git a/packages/scratch-gui/src/lib/ruby-generator/data.js b/packages/scratch-gui/src/lib/ruby-generator/data.js index 0f0b3da9652..5982d9ee3f7 100644 --- a/packages/scratch-gui/src/lib/ruby-generator/data.js +++ b/packages/scratch-gui/src/lib/ruby-generator/data.js @@ -244,6 +244,13 @@ export default function (Generator) { }; Generator.data_itemnumoflist = function (block) { + // === Smalruby: Start of symbol reference === + const comment = Generator.getCommentText(block); + if (comment && comment.startsWith('@ruby:symbol:')) { + const symbolName = comment.slice('@ruby:symbol:'.length); + return [`:${symbolName}`, Generator.ORDER_ATOMIC]; + } + // === Smalruby: End of symbol reference === const item = Generator.valueToCode(block, 'ITEM', Generator.ORDER_NONE) || '0'; const list = getListName(block); return [`${list}.index(${Generator.nosToCode(item)})`, Generator.ORDER_FUNCTION_CALL]; diff --git a/packages/scratch-gui/src/lib/ruby-generator/looks.js b/packages/scratch-gui/src/lib/ruby-generator/looks.js index 817dad104b4..28f178b8ca4 100644 --- a/packages/scratch-gui/src/lib/ruby-generator/looks.js +++ b/packages/scratch-gui/src/lib/ruby-generator/looks.js @@ -36,6 +36,13 @@ export default function (Generator) { }; Generator.looks_say = function (block) { + // === Smalruby: Start of symbol implicit conversion === + const sayComment = Generator.getCommentText(block); + if (sayComment && sayComment.startsWith('@ruby:symbol:')) { + const symbolName = sayComment.slice('@ruby:symbol:'.length); + return `say(:${symbolName})\n`; + } + // === Smalruby: End of symbol implicit conversion === const message = Generator.valueToCode(block, 'MESSAGE', Generator.ORDER_NONE) || Generator.quote_(''); return `say(${message})\n`; }; @@ -47,6 +54,13 @@ export default function (Generator) { }; Generator.looks_think = function (block) { + // === Smalruby: Start of symbol implicit conversion === + const thinkComment = Generator.getCommentText(block); + if (thinkComment && thinkComment.startsWith('@ruby:symbol:')) { + const symbolName = thinkComment.slice('@ruby:symbol:'.length); + return `think(:${symbolName})\n`; + } + // === Smalruby: End of symbol implicit conversion === const message = Generator.valueToCode(block, 'MESSAGE', Generator.ORDER_NONE) || Generator.quote_(''); return `think(${message})\n`; }; diff --git a/packages/scratch-gui/src/lib/ruby-generator/operators.js b/packages/scratch-gui/src/lib/ruby-generator/operators.js index 732ff78a23b..d22f3ff0ebd 100644 --- a/packages/scratch-gui/src/lib/ruby-generator/operators.js +++ b/packages/scratch-gui/src/lib/ruby-generator/operators.js @@ -230,6 +230,12 @@ export default function (Generator) { Generator.operator_join = function (block) { const comment = Generator.getCommentText(block); + // === Smalruby: Start of symbol to_s === + if (comment && comment.startsWith('@ruby:symbol:')) { + const symbolName = comment.slice('@ruby:symbol:'.length); + return [`:${symbolName}.to_s`, Generator.ORDER_FUNCTION_CALL]; + } + // === Smalruby: End of symbol to_s === if (comment === '@ruby:method:to_s') { const value = Generator.valueToCode(block, 'STRING1', Generator.ORDER_FUNCTION_CALL) || Generator.quote_(''); diff --git a/packages/scratch-gui/test/unit/lib/ruby-generator/symbol-generator.test.js b/packages/scratch-gui/test/unit/lib/ruby-generator/symbol-generator.test.js new file mode 100644 index 00000000000..d73bf208758 --- /dev/null +++ b/packages/scratch-gui/test/unit/lib/ruby-generator/symbol-generator.test.js @@ -0,0 +1,127 @@ +import RubyGenerator from '../../../../src/lib/ruby-generator'; +import DataBlocks from '../../../../src/lib/ruby-generator/data'; +import OperatorsBlocks from '../../../../src/lib/ruby-generator/operators'; +import LooksBlocks from '../../../../src/lib/ruby-generator/looks'; + +describe('RubyGenerator/Symbol', () => { + beforeEach(() => { + RubyGenerator.cache_ = { + comments: {}, + targetCommentTexts: [] + }; + RubyGenerator.definitions_ = {}; + RubyGenerator.functionNames_ = {}; + RubyGenerator.emptyCallCache_ = {}; + RubyGenerator.currentTarget = null; + DataBlocks(RubyGenerator); + OperatorsBlocks(RubyGenerator); + LooksBlocks(RubyGenerator); + }); + + describe('data_itemnumoflist with @ruby:symbol comment', () => { + test('generates :foo for @ruby:symbol:foo comment', () => { + const block = { + id: 'block-id', + opcode: 'data_itemnumoflist', + fields: { + LIST: {id: 'list-id', value: '_symbols_'} + }, + inputs: { + ITEM: {block: 'item-block-id'} + } + }; + RubyGenerator.cache_.comments['block-id'] = {text: '@ruby:symbol:foo'}; + RubyGenerator.valueToCode = jest.fn().mockReturnValue('":foo"'); + RubyGenerator.getFieldId = jest.fn().mockReturnValue('list-id'); + RubyGenerator.variableName = jest.fn().mockReturnValue('$_symbols_'); + + const result = RubyGenerator.data_itemnumoflist(block); + expect(result[0]).toBe(':foo'); + }); + + test('generates normal list.index() without @ruby:symbol comment', () => { + const block = { + id: 'block-id', + opcode: 'data_itemnumoflist', + fields: { + LIST: {id: 'list-id', value: 'my_list'} + }, + inputs: { + ITEM: {block: 'item-block-id'} + } + }; + RubyGenerator.valueToCode = jest.fn().mockReturnValue('"thing"'); + RubyGenerator.getFieldId = jest.fn().mockReturnValue('list-id'); + RubyGenerator.variableName = jest.fn().mockReturnValue('$my_list'); + + const result = RubyGenerator.data_itemnumoflist(block); + expect(result[0]).toContain('.index('); + }); + }); + + describe('operator_join with @ruby:symbol comment', () => { + test('generates :foo.to_s for @ruby:symbol:foo comment', () => { + const block = { + id: 'block-id', + opcode: 'operator_join', + inputs: { + STRING1: {}, + STRING2: {} + } + }; + RubyGenerator.cache_.comments['block-id'] = {text: '@ruby:symbol:foo'}; + RubyGenerator.valueToCode = jest.fn().mockReturnValue('"foo"'); + + const result = RubyGenerator.operator_join(block); + expect(result[0]).toBe(':foo.to_s'); + }); + }); + + describe('looks_say with @ruby:symbol comment', () => { + test('generates say(:foo) for @ruby:symbol:foo comment', () => { + const block = { + id: 'block-id', + opcode: 'looks_say', + inputs: { + MESSAGE: {} + } + }; + RubyGenerator.cache_.comments['block-id'] = {text: '@ruby:symbol:foo'}; + RubyGenerator.valueToCode = jest.fn().mockReturnValue('"foo"'); + + const result = RubyGenerator.looks_say(block); + expect(result).toBe('say(:foo)\n'); + }); + + test('generates say("hello") without @ruby:symbol comment', () => { + const block = { + id: 'block-id', + opcode: 'looks_say', + inputs: { + MESSAGE: {} + } + }; + RubyGenerator.valueToCode = jest.fn().mockReturnValue('"hello"'); + + const result = RubyGenerator.looks_say(block); + expect(result).toBe('say("hello")\n'); + }); + }); + + describe('looks_think with @ruby:symbol comment', () => { + test('generates think(:foo) for @ruby:symbol:foo comment', () => { + const block = { + id: 'block-id', + opcode: 'looks_think', + inputs: { + MESSAGE: {} + } + }; + RubyGenerator.cache_.comments['block-id'] = {text: '@ruby:symbol:foo'}; + RubyGenerator.valueToCode = jest.fn().mockReturnValue('"foo"'); + + const result = RubyGenerator.looks_think(block); + expect(result).toBe('think(:foo)\n'); + }); + }); +}); From 725099d06955c9afb62f021fed96e6ecd095c547 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sun, 15 Mar 2026 23:59:10 +0900 Subject: [PATCH 07/11] feat: add furigana annotation for symbol literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit :foo displays as シンボル「foo」 with furigana rendering. Refs #313 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../scratch-gui/src/lib/furigana-node-handlers.js | 7 +++++++ .../test/unit/lib/furigana-annotator.test.js | 11 +++++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/scratch-gui/src/lib/furigana-node-handlers.js b/packages/scratch-gui/src/lib/furigana-node-handlers.js index 53daaabcbd2..8b4bfb61eae 100644 --- a/packages/scratch-gui/src/lib/furigana-node-handlers.js +++ b/packages/scratch-gui/src/lib/furigana-node-handlers.js @@ -111,6 +111,13 @@ const nodeHandlers = { this._addAnnotation(node.location, '偽'); }, + _handleSymbolNode (node) { + const unescaped = node.unescaped; + const content = (unescaped && typeof unescaped === 'object') ? + unescaped.value : unescaped; + this._addAnnotation(node.location, `シンボル「${content}」`); + }, + _handleStringNode (node) { const unescaped = node.unescaped; const content = (unescaped && typeof unescaped === 'object') ? 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 ea4ba479563..22a56c1301f 100644 --- a/packages/scratch-gui/test/unit/lib/furigana-annotator.test.js +++ b/packages/scratch-gui/test/unit/lib/furigana-annotator.test.js @@ -1340,4 +1340,15 @@ describe('FuriganaAnnotator', () => { expect(labels).toContain('x:-1,y:3'); }); }); + + describe('symbol literals', () => { + test(':foo annotates as シンボル「foo」', () => { + const anns = annotate('x = :foo'); + expect(labelsAt(anns, 1)).toContain('シンボル「foo」'); + }); + test(':bar_baz annotates as シンボル「bar_baz」', () => { + const anns = annotate('x = :bar_baz'); + expect(labelsAt(anns, 1)).toContain('シンボル「bar_baz」'); + }); + }); }); From 35536c7f89da0292f29d718552f11014d0f5e9d9 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Mon, 16 Mar 2026 00:17:06 +0900 Subject: [PATCH 08/11] test: add round-trip integration tests for symbol support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test Ruby → Blocks → Ruby conversion for :symbol.to_s, variable assignment, comparison, and implicit say/think conversion. Refs #313 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../round-trip.test.js | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/round-trip.test.js b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/round-trip.test.js index 30816e45629..43bf3b4e5a6 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/round-trip.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/round-trip.test.js @@ -510,4 +510,26 @@ end }); }); // === Smalruby: End of array syntax === + + // === Smalruby: Start of symbol support === + test('symbol .to_s round-trip', async () => { + await expectRoundTrip('say(:foo.to_s)'); + await expectRoundTrip('say(:bar_baz.to_s)'); + }); + + test('symbol variable assignment round-trip', async () => { + await expectRoundTrip('$a = :foo'); + await expectRoundTrip('@x = :bar'); + await expectRoundTrip('a = :baz'); + }); + + test('symbol comparison round-trip', async () => { + await expectRoundTrip(':foo == :bar'); + }); + + test('symbol say implicit round-trip', async () => { + await expectRoundTrip('say(:foo)'); + await expectRoundTrip('think(:foo)'); + }); + // === Smalruby: End of symbol support === }); From 00c99f8c0023724eb2abd8538a6c4d1dd13d025f Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Mon, 16 Mar 2026 00:55:12 +0900 Subject: [PATCH 09/11] feat: resolve symbol variables in say/think/puts via data_itemoflist When a variable with dataType='symbol' is used in say/think/puts/p/print, wrap it in data_itemoflist to look up the symbol name from $_symbols_ list. Also change $_symbols_ to store names without colon prefix. Refs #313 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/ruby-generator/data.js | 7 ++ .../src/lib/ruby-to-blocks-converter/looks.js | 47 +++++-- .../variable-utils.js | 23 +++- .../ruby-generator/symbol-generator.test.js | 20 +++ .../round-trip.test.js | 5 + .../variables/variables-symbol-ref.test.js | 2 +- .../variables-symbol-var-say.test.js | 118 ++++++++++++++++++ 7 files changed, 207 insertions(+), 15 deletions(-) create mode 100644 packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-symbol-var-say.test.js diff --git a/packages/scratch-gui/src/lib/ruby-generator/data.js b/packages/scratch-gui/src/lib/ruby-generator/data.js index 5982d9ee3f7..a81ccd83622 100644 --- a/packages/scratch-gui/src/lib/ruby-generator/data.js +++ b/packages/scratch-gui/src/lib/ruby-generator/data.js @@ -238,6 +238,13 @@ export default function (Generator) { }; Generator.data_itemoflist = function (block) { + // === Smalruby: Start of symbol variable lookup === + const comment = Generator.getCommentText(block); + if (comment === '@ruby:symbol:var') { + const index = Generator.valueToCode(block, 'INDEX', Generator.ORDER_NONE); + return [index, Generator.ORDER_ATOMIC]; + } + // === Smalruby: End of symbol variable lookup === const index = getListIndex(block); const list = getListName(block); return [`${list}[${index}]`, Generator.ORDER_FUNCTION_CALL]; diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/looks.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/looks.js index 6dd128984b2..c5c48069e37 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/looks.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/looks.js @@ -122,14 +122,19 @@ const LooksConverter = { const {receiver, args} = params; if (!converter._isSelf(receiver) && receiver !== null) return null; const symbolName = resolveSymbolArg(converter, args[0]); - if (!symbolName) return null; + const symbolVarBlock = symbolName ? null : converter._resolveSymbolVariable(args[0]); + if (!symbolName && !symbolVarBlock) return null; + + const message = symbolName || symbolVarBlock; if (args.length === 1) { const block = converter._createBlock(opcodes1[methodName], 'statement'); - converter._addTextInput(block, 'MESSAGE', symbolName, defaults[methodName]); - block.comment = converter._createComment( - `@ruby:symbol:${symbolName}`, block.id - ); + converter._addTextInput(block, 'MESSAGE', message, defaults[methodName]); + if (symbolName) { + block.comment = converter._createComment( + `@ruby:symbol:${symbolName}`, block.id + ); + } return block; } @@ -140,11 +145,13 @@ const LooksConverter = { } if (converter._isNumberOrBlock(secs)) { const block = converter._createBlock(opcodes2[methodName], 'statement'); - converter._addTextInput(block, 'MESSAGE', symbolName, defaults[methodName]); + converter._addTextInput(block, 'MESSAGE', message, defaults[methodName]); converter._addNumberInput(block, 'SECS', 'math_number', secs, 2); - block.comment = converter._createComment( - `@ruby:symbol:${symbolName}`, block.id - ); + if (symbolName) { + block.comment = converter._createComment( + `@ruby:symbol:${symbolName}`, block.id + ); + } return block; } } @@ -157,12 +164,20 @@ const LooksConverter = { converter.registerOnSend('sprite', methodName, -1, params => { const {args} = params; if (args.length === 0) return null; + + const isSymbolArg = arg => + (converter._isPrimitive(arg) && arg.type === 'sym'); + const isSymbolVar = arg => { + if (!converter._isBlock(arg)) return false; + const v = converter.lookupVariableFromVariableBlock(arg); + return v && v.dataType === 'symbol'; + }; + if (!args.every(arg => - converter._isNumberOrStringOrBlock(arg) || - (converter._isPrimitive(arg) && arg.type === 'sym') + converter._isNumberOrStringOrBlock(arg) || isSymbolArg(arg) )) return null; - // Only handle if at least one symbol arg - if (!args.some(arg => converter._isPrimitive(arg) && arg.type === 'sym')) return null; + // Only handle if at least one symbol arg or symbol variable + if (!args.some(arg => isSymbolArg(arg) || isSymbolVar(arg))) return null; let firstBlock = null; let lastBlock = null; @@ -170,11 +185,17 @@ const LooksConverter = { args.forEach(arg => { const block = converter._createBlock('looks_sayforsecs', 'statement'); const symbolName = resolveSymbolArg(converter, arg); + const symbolVar = symbolName ? null : converter._resolveSymbolVariable(arg); if (symbolName) { converter._addTextInput(block, 'MESSAGE', symbolName, 'Hello!'); block.comment = converter._createComment( `@ruby:symbol:${symbolName},@ruby:method:${methodName}`, block.id ); + } else if (symbolVar) { + converter._addTextInput(block, 'MESSAGE', symbolVar, 'Hello!'); + block.comment = converter.createComment( + `@ruby:method:${methodName}`, block.id, 200, 0 + ); } else { converter._addTextInput( block, 'MESSAGE', diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variable-utils.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variable-utils.js index 789a8dc7f01..b6461c66057 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variable-utils.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variable-utils.js @@ -232,11 +232,32 @@ const VariableUtils = { } }); block.node = node; - this._addTextInput(block, 'ITEM', `:${symbolName}`, 'thing'); + this._addTextInput(block, 'ITEM', symbolName, 'thing'); block.comment = this._createComment(`@ruby:symbol:${symbolName}`, block.id); return block; }, + _resolveSymbolVariable (value) { + if (!this._isBlock(value)) return null; + const variable = this.lookupVariableFromVariableBlock(value); + if (!variable || variable.dataType !== 'symbol') return null; + + const list = this._lookupOrCreateList('$_symbols_'); + const block = this._createBlock('data_itemoflist', 'value', { + fields: { + LIST: { + name: 'LIST', + id: list.id, + value: list.name, + variableType: list.type + } + } + }); + this._addNumberInput(block, 'INDEX', 'math_integer', value, 1); + block.comment = this._createComment('@ruby:symbol:var', block.id); + return block; + }, + _changeToBooleanArgument (varName) { varName = varName.toString(); const variable = this._context.localVariables[varName]; diff --git a/packages/scratch-gui/test/unit/lib/ruby-generator/symbol-generator.test.js b/packages/scratch-gui/test/unit/lib/ruby-generator/symbol-generator.test.js index d73bf208758..b66072d33e3 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-generator/symbol-generator.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-generator/symbol-generator.test.js @@ -124,4 +124,24 @@ describe('RubyGenerator/Symbol', () => { expect(result).toBe('think(:foo)\n'); }); }); + + describe('data_itemoflist with @ruby:symbol:var comment', () => { + test('generates variable name for @ruby:symbol:var comment', () => { + const block = { + id: 'block-id', + opcode: 'data_itemoflist', + fields: { + LIST: {id: 'list-id', value: '_symbols_'} + }, + inputs: { + INDEX: {block: 'index-block-id'} + } + }; + RubyGenerator.cache_.comments['block-id'] = {text: '@ruby:symbol:var'}; + RubyGenerator.valueToCode = jest.fn().mockReturnValue('$a'); + + const result = RubyGenerator.data_itemoflist(block); + expect(result[0]).toBe('$a'); + }); + }); }); diff --git a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/round-trip.test.js b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/round-trip.test.js index 43bf3b4e5a6..d414f67752b 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/round-trip.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/round-trip.test.js @@ -531,5 +531,10 @@ end await expectRoundTrip('say(:foo)'); await expectRoundTrip('think(:foo)'); }); + + test('symbol variable say round-trip', async () => { + await expectRoundTrip('$a = :foo\nsay($a)'); + await expectRoundTrip('$a = :foo\nthink($a)'); + }); // === Smalruby: End of symbol support === }); diff --git a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-symbol-ref.test.js b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-symbol-ref.test.js index edffe8caa93..17c9a09a77b 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-symbol-ref.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-symbol-ref.test.js @@ -42,7 +42,7 @@ describe('RubyToBlocksConverter/Variables/SymbolReference', () => { expect(block.inputs).toHaveProperty('ITEM'); const itemBlockId = block.inputs.ITEM.block; const itemBlock = converter._context.blocks[itemBlockId]; - expect(itemBlock.fields.TEXT.value).toBe(':foo'); + expect(itemBlock.fields.TEXT.value).toBe('foo'); }); test('has LIST field referencing $_symbols_', () => { diff --git a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-symbol-var-say.test.js b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-symbol-var-say.test.js new file mode 100644 index 00000000000..5e8b3d87e97 --- /dev/null +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-symbol-var-say.test.js @@ -0,0 +1,118 @@ +import RubyToBlocksConverter from '../../../../../src/lib/ruby-to-blocks-converter'; + +describe('RubyToBlocksConverter/Variables/SymbolVarSay', () => { + let converter; + let target; + + beforeEach(() => { + converter = new RubyToBlocksConverter(null, {version: '2'}); + target = null; + }); + + describe('say($a) where $a is a symbol', () => { + test('creates looks_say with data_itemoflist wrapping variable', async () => { + const code = '$a = :foo\nsay($a)'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + + const blocks = Object.values(converter.blocks); + const sayBlock = blocks.find(b => b.opcode === 'looks_say'); + expect(sayBlock).toBeDefined(); + + // MESSAGE should be a data_itemoflist block (not direct variable) + const msgBlockId = sayBlock.inputs.MESSAGE.block; + const msgBlock = converter.blocks[msgBlockId]; + expect(msgBlock.opcode).toBe('data_itemoflist'); + + // data_itemoflist should have @ruby:symbol:var comment + const comment = converter._context.comments[msgBlock.comment]; + expect(comment.text).toBe('@ruby:symbol:var'); + + // INDEX should be the variable block + const indexBlockId = msgBlock.inputs.INDEX.block; + const indexBlock = converter.blocks[indexBlockId]; + expect(indexBlock.opcode).toBe('data_variable'); + }); + + test('say($a, 2) with symbol variable wraps in data_itemoflist', async () => { + const code = '$a = :foo\nsay($a, 2)'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + + const blocks = Object.values(converter.blocks); + const sayBlock = blocks.find(b => b.opcode === 'looks_sayforsecs'); + expect(sayBlock).toBeDefined(); + + const msgBlockId = sayBlock.inputs.MESSAGE.block; + const msgBlock = converter.blocks[msgBlockId]; + expect(msgBlock.opcode).toBe('data_itemoflist'); + }); + + test('think($a) with symbol variable wraps in data_itemoflist', async () => { + const code = '$a = :foo\nthink($a)'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + + const blocks = Object.values(converter.blocks); + const thinkBlock = blocks.find(b => b.opcode === 'looks_think'); + expect(thinkBlock).toBeDefined(); + + const msgBlockId = thinkBlock.inputs.MESSAGE.block; + const msgBlock = converter.blocks[msgBlockId]; + expect(msgBlock.opcode).toBe('data_itemoflist'); + }); + + test('puts($a) with symbol variable wraps in data_itemoflist', async () => { + const code = '$a = :foo\nputs($a)'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + + const blocks = Object.values(converter.blocks); + const sayForSecsBlock = blocks.find(b => b.opcode === 'looks_sayforsecs'); + expect(sayForSecsBlock).toBeDefined(); + + const msgBlockId = sayForSecsBlock.inputs.MESSAGE.block; + const msgBlock = converter.blocks[msgBlockId]; + expect(msgBlock.opcode).toBe('data_itemoflist'); + }); + }); + + describe('say($a) where $a is NOT a symbol', () => { + test('say($a) with string variable does NOT wrap in data_itemoflist', async () => { + const code = '$a = "hello"\nsay($a)'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + + const blocks = Object.values(converter.blocks); + const sayBlock = blocks.find(b => b.opcode === 'looks_say'); + expect(sayBlock).toBeDefined(); + + const msgBlockId = sayBlock.inputs.MESSAGE.block; + const msgBlock = converter.blocks[msgBlockId]; + expect(msgBlock.opcode).toBe('data_variable'); + }); + }); + + describe('$_symbols_ stores symbol names without colon', () => { + test('_symbolToBlock ITEM is "foo" not ":foo"', () => { + converter.reset(); + const block = converter._symbolToBlock('foo', null); + const itemBlockId = block.inputs.ITEM.block; + const itemBlock = converter._context.blocks[itemBlockId]; + expect(itemBlock.fields.TEXT.value).toBe('foo'); + }); + }); + + describe('round-trip with symbol variable', () => { + test('$a = :foo then say($a) round-trips', async () => { + const code = '$a = :foo\nsay($a)'; + const result = await converter.targetCodeToBlocks(target, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + }); + }); +}); From fc01dc7b5d7d1e89a07911dcdb828025e0cf187a Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Mon, 16 Mar 2026 07:41:44 +0900 Subject: [PATCH 10/11] feat: error on symbol arithmetic and mixed-type comparison Symbols cannot be used in arithmetic (+, -, *, /, %, **) or compared with non-symbols using >, <, >=, <=. Add specific error messages with .to_s suggestion. Matches Ruby's NoMethodError/ArgumentError behavior. Refs #313 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ast-handlers/expressions.js | 35 +++++++- .../src/lib/ruby-to-blocks-converter/index.js | 20 +++++ .../lib/ruby-to-blocks-converter/operators.js | 32 ++++++- packages/scratch-gui/src/locales/ja-Hira.js | 2 + packages/scratch-gui/src/locales/ja.js | 2 + .../operators/symbol-arithmetic.test.js | 85 +++++++++++++++++++ 6 files changed, 171 insertions(+), 5 deletions(-) create mode 100644 packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/operators/symbol-arithmetic.test.js diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/ast-handlers/expressions.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/ast-handlers/expressions.js index 877607a5ad0..6bf2343157f 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/ast-handlers/expressions.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/ast-handlers/expressions.js @@ -59,8 +59,39 @@ const ExpressionHandlers = { if (!block) { this._restoreContext(saved); - // === Smalruby: Start of symbol needs to_s error === + // === Smalruby: Start of symbol error checks === + const arithmeticOps = ['+', '-', '*', '/', '%', '**']; + const comparisonOps = ['>', '<', '>=', '<=']; + const isSymReceiver = this._isPrimitive(receiver) && receiver.type === 'sym'; const symbolArg = args.find(a => this._isPrimitive(a) && a.type === 'sym'); + + // Symbol in arithmetic → specific error + if (arithmeticOps.indexOf(name) >= 0 && (isSymReceiver || symbolArg)) { + const source = this._truncateSource(this._getSource(node)); + const sym = isSymReceiver ? receiver : symbolArg; + const suggestion = source.replace( + `:${sym.value}`, + `:${sym.value}.to_s` + ); + throw new RubyToBlocksConverterError( + node, + this._translator(this._symbolCannotArithmeticMessage(), { + SOURCE: source, + SUGGESTION: suggestion + }) + ); + } + + // Symbol in comparison with non-symbol → specific error + if (comparisonOps.indexOf(name) >= 0 && (isSymReceiver || symbolArg)) { + const source = this._truncateSource(this._getSource(node)); + throw new RubyToBlocksConverterError( + node, + this._translator(this._symbolCannotCompareMessage(), {SOURCE: source}) + ); + } + + // Symbol in other contexts → needs .to_s if (symbolArg) { const source = this._truncateSource(this._getSource(node)); const suggestion = source.replace( @@ -75,7 +106,7 @@ const ExpressionHandlers = { }) ); } - // === Smalruby: End of symbol needs to_s error === + // === Smalruby: End of symbol error checks === if (node.block) { block = this._createBlock('ruby_statement_with_block', 'statement'); 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 e7d907233f3..8cbce09094f 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 @@ -160,6 +160,18 @@ const messages = defineMessages({ '\nWrite { SUGGESTION } instead.', description: 'Error message when a symbol is used where a string is expected without .to_s', id: 'gui.smalruby3.rubyToBlocksConverter.symbolNeedsToS' + }, + symbolCannotArithmetic: { + defaultMessage: '"{ SOURCE }" — symbols cannot be used in arithmetic (+, -, *, /).' + + '\nUse .to_s to convert first, e.g. { SUGGESTION }.', + description: 'Error message when a symbol is used in arithmetic operation', + id: 'gui.smalruby3.rubyToBlocksConverter.symbolCannotArithmetic' + }, + symbolCannotCompare: { + defaultMessage: '"{ SOURCE }" — symbols can only be compared with other symbols using >, <, >=, <=.' + + '\nUse == instead, or convert with .to_s.', + description: 'Error message when a symbol is compared with non-symbol using >, <, >=, <=', + id: 'gui.smalruby3.rubyToBlocksConverter.symbolCannotCompare' } }); @@ -247,6 +259,14 @@ class RubyToBlocksConverter extends Visitor { return messages.symbolNeedsToS; } + _symbolCannotArithmeticMessage () { + return messages.symbolCannotArithmetic; + } + + _symbolCannotCompareMessage () { + return messages.symbolCannotCompare; + } + setTranslatorFunction (translator) { this._translator = translator; this._prismErrorTranslator = new PrismErrorTranslator(translator); diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/operators.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/operators.js index d8ab3129ac5..ca56a8071d8 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/operators.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/operators.js @@ -1,4 +1,5 @@ import _ from 'lodash'; +import {RubyToBlocksConverterError} from './errors'; const Math = '::Math'; const MathE = '::Math::E'; @@ -225,13 +226,27 @@ const OperatorsConverter = { ['>', '<', '=='].forEach(operator => { converter.registerOnSend('any', operator, 1, params => { - const {receiver, args} = params; + const {receiver, args, node} = params; let rh = args[0]; if (_.isArray(rh)) { if (rh.length !== 1) return null; rh = rh[0]; } + // === Smalruby: Start of symbol comparison guard === + // For >, <: reject symbol args (Ruby raises ArgumentError for mixed types) + if (operator !== '==' && + converter._isPrimitive(rh) && rh.type === 'sym') { + const source = converter._truncateSource(converter._getSource(node)); + throw new RubyToBlocksConverterError( + node, + converter._translator( + converter._symbolCannotCompareMessage(), {SOURCE: source} + ) + ); + } + // === Smalruby: End of symbol comparison guard === + let opcode; if (operator === '>') { opcode = 'operator_gt'; @@ -253,17 +268,28 @@ const OperatorsConverter = { // === Smalruby: Start of symbol comparison === ['>', '<', '=='].forEach(operator => { converter.registerOnSend('symbol', operator, 1, params => { - const {receiver, args} = params; + const {receiver, args, node} = params; let rh = args[0]; if (_.isArray(rh)) { if (rh.length !== 1) return null; rh = rh[0]; } + const rhIsSymbol = converter._isPrimitive(rh) && rh.type === 'sym'; + + // For >, <: both sides must be symbols (Ruby raises ArgumentError for mixed types) + if (operator !== '==' && !rhIsSymbol) { + const source = converter._truncateSource(converter._getSource(node)); + throw new RubyToBlocksConverterError( + node, + converter._translator(converter._symbolCannotCompareMessage(), {SOURCE: source}) + ); + } + const receiverBlock = converter._symbolToBlock( converter._getSymbolValue(receiver), receiver.node ); - if (converter._isPrimitive(rh) && rh.type === 'sym') { + if (rhIsSymbol) { rh = converter._symbolToBlock(rh.value, rh.node); } diff --git a/packages/scratch-gui/src/locales/ja-Hira.js b/packages/scratch-gui/src/locales/ja-Hira.js index fedff18034c..3181571d7a4 100644 --- a/packages/scratch-gui/src/locales/ja-Hira.js +++ b/packages/scratch-gui/src/locales/ja-Hira.js @@ -121,6 +121,8 @@ export default { 'gui.smalruby3.rubyToBlocksConverter.includeNotSupportedInStage': 'includeはclass Stageではつかえません。\nモジュールはスプライトのクラスでのみとりこめます。', 'gui.smalruby3.rubyToBlocksConverter.moduleImportFailed': 'モジュール「{ NAME }」をほかのスプライトからとりこめませんでした。', 'gui.smalruby3.rubyToBlocksConverter.symbolNeedsToS': '「{ SOURCE }」— シンボルには .to_s をつけてください。\n{ SUGGESTION } とかいてください。', + 'gui.smalruby3.rubyToBlocksConverter.symbolCannotArithmetic': '「{ SOURCE }」— シンボルは +、-、*、/ などのけいさんにはつかえません。\n{ SUGGESTION } のように .to_s するとけいさんにつかえます。', + 'gui.smalruby3.rubyToBlocksConverter.symbolCannotCompare': '「{ SOURCE }」— シンボルとシンボルいがいのあたいは >、<、>=、<= でくらべられません。\n== でくらべるか、.to_s でもじれつにへんかんしてください。', '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 13a9111b6c1..f2108e784df 100644 --- a/packages/scratch-gui/src/locales/ja.js +++ b/packages/scratch-gui/src/locales/ja.js @@ -121,6 +121,8 @@ export default { 'gui.smalruby3.rubyToBlocksConverter.includeNotSupportedInStage': 'includeはclass Stageでは使えません。\nモジュールはスプライトのクラスでのみ取り込めます。', 'gui.smalruby3.rubyToBlocksConverter.moduleImportFailed': 'モジュール「{ NAME }」を他のスプライトから取り込めませんでした。', 'gui.smalruby3.rubyToBlocksConverter.symbolNeedsToS': '「{ SOURCE }」— シンボルには .to_s を付けてください。\n{ SUGGESTION } と書いてください。', + 'gui.smalruby3.rubyToBlocksConverter.symbolCannotArithmetic': '「{ SOURCE }」— シンボルは +、-、*、/ などの計算には使えません。\n{ SUGGESTION } のように .to_s すると計算に使えます。', + 'gui.smalruby3.rubyToBlocksConverter.symbolCannotCompare': '「{ SOURCE }」— シンボルとシンボル以外の値は >、<、>=、<= で比較できません。\n== で比較するか、.to_s で文字列に変換してください。', '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/operators/symbol-arithmetic.test.js b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/operators/symbol-arithmetic.test.js new file mode 100644 index 00000000000..1870ee74734 --- /dev/null +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/operators/symbol-arithmetic.test.js @@ -0,0 +1,85 @@ +import RubyToBlocksConverter from '../../../../../src/lib/ruby-to-blocks-converter'; + +describe('RubyToBlocksConverter/Operators/SymbolArithmetic', () => { + let converter; + + beforeEach(() => { + converter = new RubyToBlocksConverter(null, {version: '2'}); + }); + + describe('symbol as receiver in arithmetic → error', () => { + test.each([ + [':foo + 1'], + [':foo - 1'], + [':foo * 2'], + [':foo / 2'], + [':foo % 2'], + [':foo ** 2'], + [':foo + "a"'], + [':foo + :bar'] + ])('%s produces symbolCannotArithmetic error', async (code) => { + const result = await converter.targetCodeToBlocks(null, code); + expect(result).toBeFalsy(); + expect(converter.errors).toHaveLength(1); + expect(converter.errors[0].text).toContain('.to_s'); + expect(converter.errors[0].text).toMatch(/[+\-*/]/); + }); + }); + + describe('symbol as argument in arithmetic → error', () => { + test.each([ + ['1 + :foo'], + ['1 - :foo'], + ['1 * :foo'], + ['1 / :foo'], + ['"a" + :foo'] + ])('%s produces symbolCannotArithmetic error', async (code) => { + const result = await converter.targetCodeToBlocks(null, code); + expect(result).toBeFalsy(); + expect(converter.errors).toHaveLength(1); + expect(converter.errors[0].text).toContain('.to_s'); + expect(converter.errors[0].text).toMatch(/[+\-*/]/); + }); + }); + + describe('symbol with non-symbol in >/< comparison → error', () => { + test.each([ + [':foo > 1'], + [':foo < 1'], + [':foo > "a"'], + [':foo < true'], + ['1 > :foo'], + ['1 < :foo'], + ['"a" > :foo'] + ])('%s produces symbolCannotCompare error', async (code) => { + const result = await converter.targetCodeToBlocks(null, code); + expect(result).toBeFalsy(); + expect(converter.errors).toHaveLength(1); + expect(converter.errors[0].text).toMatch(/[><]/); + }); + }); + + describe('symbol == any type → OK (no error)', () => { + test.each([ + [':foo == :bar'], + [':foo == 1'], + [':foo == "a"'], + ['1 == :foo'] + ])('%s does not error', async (code) => { + const result = await converter.targetCodeToBlocks(null, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + }); + }); + + describe('symbol >/< symbol → OK (no error)', () => { + test.each([ + [':foo > :bar'], + [':foo < :bar'] + ])('%s does not error', async (code) => { + const result = await converter.targetCodeToBlocks(null, code); + expect(result).toBeTruthy(); + expect(converter.errors).toHaveLength(0); + }); + }); +}); From f0927da91f8fa6ea3b613da2a291f7c50bbcaeb5 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Mon, 16 Mar 2026 08:58:10 +0900 Subject: [PATCH 11/11] refactor: remove inline Smalruby markers from Smalruby-specific files These files are entirely Smalruby-specific, so inline Start/End markers are redundant. File-level markers at the top are sufficient. Refs #313 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/ruby-generator/data.js | 10 ------ .../src/lib/ruby-generator/looks.js | 4 --- .../src/lib/ruby-generator/operators.js | 4 --- .../ast-handlers/expressions.js | 2 -- .../src/lib/ruby-to-blocks-converter/index.js | 10 ------ .../src/lib/ruby-to-blocks-converter/looks.js | 2 -- .../lib/ruby-to-blocks-converter/operators.js | 6 ---- .../lib/ruby-to-blocks-converter/variables.js | 34 ------------------- .../round-trip.test.js | 4 --- 9 files changed, 76 deletions(-) diff --git a/packages/scratch-gui/src/lib/ruby-generator/data.js b/packages/scratch-gui/src/lib/ruby-generator/data.js index a81ccd83622..a68299574db 100644 --- a/packages/scratch-gui/src/lib/ruby-generator/data.js +++ b/packages/scratch-gui/src/lib/ruby-generator/data.js @@ -132,7 +132,6 @@ export default function (Generator) { return `hide_variable(${Generator.quote_(variable)})\n`; }; - // === Smalruby: Start of array syntax === const getListName = function (block) { const comment = Generator.getCommentText(block); if (comment) { @@ -173,7 +172,6 @@ export default function (Generator) { // Expression: wrap with "- 1" return `${index} - 1`; }; - // === Smalruby: End of array syntax === Generator.data_listcontents = function (block) { const list = getListName(block); @@ -181,13 +179,11 @@ export default function (Generator) { }; Generator.data_addtolist = function (block) { - // === Smalruby: Start of array syntax === const comment = Generator.getCommentText(block); if (comment && comment.includes('@ruby:array:literal:element')) { // Suppressed: handled by data_deletealloflist array literal pattern return ''; } - // === Smalruby: End of array syntax === const item = Generator.valueToCode(block, 'ITEM', Generator.ORDER_NONE) || '0'; const list = getListName(block); @@ -203,7 +199,6 @@ export default function (Generator) { Generator.data_deletealloflist = function (block) { const list = getListName(block); - // === Smalruby: Start of array syntax === const comment = Generator.getCommentText(block); const arrayLiteralMatch = comment ? comment.match(/@ruby:array:literal:(\d+)/) : null; if (arrayLiteralMatch) { @@ -218,7 +213,6 @@ export default function (Generator) { } return `${list} = [${values.join(', ')}]\n`; } - // === Smalruby: End of array syntax === return `${list}.clear\n`; }; @@ -238,26 +232,22 @@ export default function (Generator) { }; Generator.data_itemoflist = function (block) { - // === Smalruby: Start of symbol variable lookup === const comment = Generator.getCommentText(block); if (comment === '@ruby:symbol:var') { const index = Generator.valueToCode(block, 'INDEX', Generator.ORDER_NONE); return [index, Generator.ORDER_ATOMIC]; } - // === Smalruby: End of symbol variable lookup === const index = getListIndex(block); const list = getListName(block); return [`${list}[${index}]`, Generator.ORDER_FUNCTION_CALL]; }; Generator.data_itemnumoflist = function (block) { - // === Smalruby: Start of symbol reference === const comment = Generator.getCommentText(block); if (comment && comment.startsWith('@ruby:symbol:')) { const symbolName = comment.slice('@ruby:symbol:'.length); return [`:${symbolName}`, Generator.ORDER_ATOMIC]; } - // === Smalruby: End of symbol reference === const item = Generator.valueToCode(block, 'ITEM', Generator.ORDER_NONE) || '0'; const list = getListName(block); return [`${list}.index(${Generator.nosToCode(item)})`, Generator.ORDER_FUNCTION_CALL]; diff --git a/packages/scratch-gui/src/lib/ruby-generator/looks.js b/packages/scratch-gui/src/lib/ruby-generator/looks.js index 28f178b8ca4..3c2d67d827a 100644 --- a/packages/scratch-gui/src/lib/ruby-generator/looks.js +++ b/packages/scratch-gui/src/lib/ruby-generator/looks.js @@ -36,13 +36,11 @@ export default function (Generator) { }; Generator.looks_say = function (block) { - // === Smalruby: Start of symbol implicit conversion === const sayComment = Generator.getCommentText(block); if (sayComment && sayComment.startsWith('@ruby:symbol:')) { const symbolName = sayComment.slice('@ruby:symbol:'.length); return `say(:${symbolName})\n`; } - // === Smalruby: End of symbol implicit conversion === const message = Generator.valueToCode(block, 'MESSAGE', Generator.ORDER_NONE) || Generator.quote_(''); return `say(${message})\n`; }; @@ -54,13 +52,11 @@ export default function (Generator) { }; Generator.looks_think = function (block) { - // === Smalruby: Start of symbol implicit conversion === const thinkComment = Generator.getCommentText(block); if (thinkComment && thinkComment.startsWith('@ruby:symbol:')) { const symbolName = thinkComment.slice('@ruby:symbol:'.length); return `think(:${symbolName})\n`; } - // === Smalruby: End of symbol implicit conversion === const message = Generator.valueToCode(block, 'MESSAGE', Generator.ORDER_NONE) || Generator.quote_(''); return `think(${message})\n`; }; diff --git a/packages/scratch-gui/src/lib/ruby-generator/operators.js b/packages/scratch-gui/src/lib/ruby-generator/operators.js index d22f3ff0ebd..a4bf46368d4 100644 --- a/packages/scratch-gui/src/lib/ruby-generator/operators.js +++ b/packages/scratch-gui/src/lib/ruby-generator/operators.js @@ -18,14 +18,12 @@ export default function (Generator) { }; Generator.operator_subtract = function (block) { - // === Smalruby: Start of array syntax === const comment = Generator.getCommentText(block); if (comment && comment.includes('@ruby:array:index')) { // Round-trip pattern: subtract(itemnumoflist, 1) → pass through .index() result const num1 = Generator.valueToCode(block, 'NUM1', Generator.ORDER_FUNCTION_CALL) || 0; return [num1, Generator.ORDER_FUNCTION_CALL]; } - // === Smalruby: End of array syntax === const order = Generator.ORDER_ADDITIVE; const num1 = Generator.valueToCode(block, 'NUM1', order) || 0; @@ -230,12 +228,10 @@ export default function (Generator) { Generator.operator_join = function (block) { const comment = Generator.getCommentText(block); - // === Smalruby: Start of symbol to_s === if (comment && comment.startsWith('@ruby:symbol:')) { const symbolName = comment.slice('@ruby:symbol:'.length); return [`:${symbolName}.to_s`, Generator.ORDER_FUNCTION_CALL]; } - // === Smalruby: End of symbol to_s === if (comment === '@ruby:method:to_s') { const value = Generator.valueToCode(block, 'STRING1', Generator.ORDER_FUNCTION_CALL) || Generator.quote_(''); diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/ast-handlers/expressions.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/ast-handlers/expressions.js index 6bf2343157f..02bc4496a6a 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/ast-handlers/expressions.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/ast-handlers/expressions.js @@ -59,7 +59,6 @@ const ExpressionHandlers = { if (!block) { this._restoreContext(saved); - // === Smalruby: Start of symbol error checks === const arithmeticOps = ['+', '-', '*', '/', '%', '**']; const comparisonOps = ['>', '<', '>=', '<=']; const isSymReceiver = this._isPrimitive(receiver) && receiver.type === 'sym'; @@ -106,7 +105,6 @@ const ExpressionHandlers = { }) ); } - // === Smalruby: End of symbol error checks === if (node.block) { block = this._createBlock('ruby_statement_with_block', 'statement'); 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 8cbce09094f..0e7ef9346f6 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 @@ -320,7 +320,6 @@ class RubyToBlocksConverter extends Visitor { } else { const Primitive = require('./primitive').default; if (block instanceof Primitive) { - // === Smalruby: Start of symbol error === if (block.type === 'sym') { const source = this._truncateSource(this._getSource(block.node)); const suggestion = `${source}.to_s`; @@ -332,7 +331,6 @@ class RubyToBlocksConverter extends Visitor { ) ); } - // === Smalruby: End of symbol error === throw new RubyToBlocksConverterError( block.node, this._translator( @@ -748,7 +746,6 @@ 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( @@ -756,7 +753,6 @@ class RubyToBlocksConverter extends Visitor { this._translator(messages.moduleNotSupportedInStage) ); } - // === Smalruby: End of stage module restriction === // Nested modules are not supported if (this._context.currentModuleName) { @@ -797,7 +793,6 @@ 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`, @@ -846,7 +841,6 @@ class RubyToBlocksConverter extends Visitor { }; return true; } - // === Smalruby: End of auto-import module from other sprites === visitClassNode (node) { // class definitions are only supported in version 2 @@ -988,16 +982,13 @@ class RubyToBlocksConverter extends Visitor { if (argType === 'ConstantReadNode') { const moduleName = argNode.name; - // === Smalruby: Start of stage include restriction === if (isStageClass) { throw new RubyToBlocksConverterError( stmt, 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); @@ -1008,7 +999,6 @@ class RubyToBlocksConverter extends Visitor { ); } } - // === Smalruby: End of auto-import module from other sprites === includedModuleNames.push(moduleName); includeStatements.add(stmt); diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/looks.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/looks.js index c5c48069e37..724de088639 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/looks.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/looks.js @@ -111,7 +111,6 @@ const resolveSymbolArg = function (converter, arg) { const LooksConverter = { register: function (converter) { - // === Smalruby: Start of symbol implicit conversion === // say/think with symbol argument - sprite-only ['say', 'think'].forEach(methodName => { const opcodes1 = {say: 'looks_say', think: 'looks_think'}; @@ -220,7 +219,6 @@ const LooksConverter = { return firstBlock; }); }); - // === Smalruby: End of symbol implicit conversion === // print/puts/p - sprite-only, mapped to looks_sayforsecs ['print', 'puts', 'p'].forEach(methodName => { diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/operators.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/operators.js index ca56a8071d8..7773769d09b 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/operators.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/operators.js @@ -233,7 +233,6 @@ const OperatorsConverter = { rh = rh[0]; } - // === Smalruby: Start of symbol comparison guard === // For >, <: reject symbol args (Ruby raises ArgumentError for mixed types) if (operator !== '==' && converter._isPrimitive(rh) && rh.type === 'sym') { @@ -245,7 +244,6 @@ const OperatorsConverter = { ) ); } - // === Smalruby: End of symbol comparison guard === let opcode; if (operator === '>') { @@ -265,7 +263,6 @@ const OperatorsConverter = { }); }); - // === Smalruby: Start of symbol comparison === ['>', '<', '=='].forEach(operator => { converter.registerOnSend('symbol', operator, 1, params => { const {receiver, args, node} = params; @@ -318,7 +315,6 @@ const OperatorsConverter = { return block; }); }); - // === Smalruby: End of symbol comparison === converter.registerOnSend(['variable', 'boolean', 'block'], '!', 0, params => { const {receiver} = params; @@ -341,7 +337,6 @@ const OperatorsConverter = { return block; }); - // === Smalruby: Start of symbol to_s === converter.registerOnSend('symbol', 'to_s', 0, params => { const {receiver} = params; const symbolName = converter._getSymbolValue(receiver); @@ -354,7 +349,6 @@ const OperatorsConverter = { block.comment = converter._createComment(`@ruby:symbol:${symbolName}`, block.id); return block; }); - // === Smalruby: End of symbol to_s === converter.registerOnSend(['variable', 'number', 'string', 'block'], 'to_s', 0, params => { const {receiver} = params; diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variables.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variables.js index d0cabb7c9ab..7f211a011ce 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variables.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variables.js @@ -1,6 +1,5 @@ import {defineMessages} from 'react-intl'; import _ from 'lodash'; -// === Smalruby: Start of array syntax === import {RubyToBlocksConverterError} from './errors'; const messages = defineMessages({ @@ -24,14 +23,12 @@ const messages = defineMessages({ id: 'gui.smalruby3.rubyToBlocksConverter.arrayLiteralNotAvailableInV1' } }); -// === Smalruby: End of array syntax === /** * Variables converter */ const VariablesConverter = { register: function (converter) { - // === Smalruby: Start of array syntax === /** * Convert a data_variable block to data_listcontents for list operations. * When $a (global) or @a (instance) is used with array methods like .push(), @@ -100,7 +97,6 @@ const VariablesConverter = { addBlock.comment = converter._createComment('@ruby:array:index', addBlock.id); return addBlock; }; - // === Smalruby: End of array syntax === converter.registerOnSend('self', 'show_variable', 1, params => { const {args} = params; @@ -146,14 +142,12 @@ const VariablesConverter = { const {args} = params; if (!converter._isString(args[0])) return null; - // === Smalruby: Start of array syntax === if (converter.version >= 2) { throw new RubyToBlocksConverterError( params.node, converter._translator(messages.listSyntaxNotAvailableInV2) ); } - // === Smalruby: End of array syntax === const variable = converter._lookupOrCreateList(args[0]); if (variable.scope === 'global' || variable.scope === 'instance') { @@ -215,11 +209,9 @@ const VariablesConverter = { const {receiver, args} = params; if (!converter._isStringOrBlock(args[0]) && !converter._isNumberOrBlock(args[0])) return null; - // === Smalruby: Start of array syntax === const {block: listBlock, converted} = convertToListBlock(receiver); const recv = converted ? listBlock : receiver; if (!recv) return null; - // === Smalruby: End of array syntax === const block = converter._changeBlock(recv, 'data_addtolist', 'statement'); converter._addTextInput( @@ -228,7 +220,6 @@ const VariablesConverter = { return block; }); - // === Smalruby: Start of array syntax === converter.registerOnSend('variable', '<<', 1, params => { const {receiver, args} = params; if (!converter._isStringOrBlock(args[0]) && !converter._isNumberOrBlock(args[0])) return null; @@ -244,18 +235,15 @@ const VariablesConverter = { ); return block; }); - // === Smalruby: End of array syntax === converter.registerOnSend('variable', 'delete_at', 1, params => { const {receiver, args} = params; if (!converter._isNumberOrBlock(args[0])) return null; - // === Smalruby: Start of array syntax === const {block: listBlock, converted} = convertToListBlock(receiver); const recv = converted ? listBlock : receiver; if (!recv) return null; const index = adjustIndex(args[0], converted); - // === Smalruby: End of array syntax === const block = converter._changeBlock(recv, 'data_deleteoflist', 'statement'); converter._addNumberInput(block, 'INDEX', 'math_integer', index, 1); @@ -265,11 +253,9 @@ const VariablesConverter = { converter.registerOnSend('variable', 'clear', 0, params => { const {receiver} = params; - // === Smalruby: Start of array syntax === const {block: listBlock, converted} = convertToListBlock(receiver); const recv = converted ? listBlock : receiver; if (!recv) return null; - // === Smalruby: End of array syntax === return converter._changeBlock(recv, 'data_deletealloflist', 'statement'); }); @@ -279,12 +265,10 @@ const VariablesConverter = { if (!converter._isNumberOrBlock(args[0])) return null; if (!converter._isStringOrBlock(args[1]) && !converter._isNumberOrBlock(args[1])) return null; - // === Smalruby: Start of array syntax === const {block: listBlock, converted} = convertToListBlock(receiver); const recv = converted ? listBlock : receiver; if (!recv) return null; const index = adjustIndex(args[0], converted); - // === Smalruby: End of array syntax === const block = converter._changeBlock(recv, 'data_insertatlist', 'statement'); converter._addNumberInput(block, 'INDEX', 'math_integer', index, 1); @@ -299,12 +283,10 @@ const VariablesConverter = { if (!converter._isNumberOrBlock(args[0])) return null; if (!converter._isStringOrBlock(args[1]) && !converter._isNumberOrBlock(args[1])) return null; - // === Smalruby: Start of array syntax === const {block: listBlock, converted} = convertToListBlock(receiver); const recv = converted ? listBlock : receiver; if (!recv) return null; const index = adjustIndex(args[0], converted); - // === Smalruby: End of array syntax === const block = converter._changeBlock(recv, 'data_replaceitemoflist', 'statement'); converter._addNumberInput(block, 'INDEX', 'math_integer', index, 1); @@ -318,7 +300,6 @@ const VariablesConverter = { const {receiver, args} = params; if (!converter._isNumberOrBlock(args[0])) return null; - // === Smalruby: Start of array syntax === const {block: listBlock, converted} = convertToListBlock(receiver); if (converted && listBlock) { const index = adjustIndex(args[0], true); @@ -326,7 +307,6 @@ const VariablesConverter = { converter._addNumberInput(block, 'INDEX', 'math_integer', index, 1); return block; } - // === Smalruby: End of array syntax === if (converter._isBlock(receiver) && converter.isListBlock(receiver)) { const block = converter._changeBlock(receiver, 'data_itemoflist', 'value'); @@ -341,18 +321,15 @@ const VariablesConverter = { const {receiver, args} = params; if (!converter._isStringOrBlock(args[0]) && !converter._isNumberOrBlock(args[0])) return null; - // === Smalruby: Start of array syntax === const {block: listBlock, converted} = convertToListBlock(receiver); const recv = converted ? listBlock : receiver; if (!recv) return null; - // === Smalruby: End of array syntax === const block = converter._changeBlock(recv, 'data_itemnumoflist', 'value'); converter._addTextInput( block, 'ITEM', converter._isNumber(args[0]) ? args[0].toString() : args[0], 'thing' ); - // === Smalruby: Start of array syntax === // Wrap in operator_subtract(result, 1) for 0-indexed return value if (converted) { const subtractBlock = converter._createBlock('operator_subtract', 'value'); @@ -363,7 +340,6 @@ const VariablesConverter = { ); return subtractBlock; } - // === Smalruby: End of array syntax === return block; }); @@ -371,12 +347,10 @@ const VariablesConverter = { converter.registerOnSend('variable', 'length', 0, params => { const {receiver} = params; - // === Smalruby: Start of array syntax === const {block: listBlock, converted} = convertToListBlock(receiver); if (converted && listBlock) { return converter._changeBlock(listBlock, 'data_lengthoflist', 'value'); } - // === Smalruby: End of array syntax === if (converter._isBlock(receiver) && converter.isListBlock(receiver)) { return converter._changeBlock(receiver, 'data_lengthoflist', 'value'); @@ -388,14 +362,12 @@ const VariablesConverter = { const {receiver, args} = params; if (!converter._isStringOrBlock(args[0]) && !converter._isNumberOrBlock(args[0])) return null; - // === Smalruby: Start of array syntax === let recv = receiver; if (converter.version >= 2) { const {block: listBlock, converted} = convertToListBlock(receiver); recv = converted ? listBlock : receiver; if (!recv) return null; } - // === Smalruby: End of array syntax === const block = converter._changeBlock(recv, 'data_listcontainsitem', 'value_boolean'); converter._addTextInput( @@ -404,7 +376,6 @@ const VariablesConverter = { return block; }); - // === Smalruby: Start of array syntax === converter.registerOnSend('variable', 'empty?', 0, params => { if (converter.version < 2) return null; @@ -427,7 +398,6 @@ const VariablesConverter = { block.comment = converter._createComment(commentText, block.id); return block; }); - // === Smalruby: End of array syntax === // Operator to opcode mapping for compound assignments const COMPOUND_OPERATOR_MAP = { @@ -631,15 +601,12 @@ const VariablesConverter = { }); converter.registerOnVasgn((scope, variable, rh) => { - // === Smalruby: Start of symbol assignment === if ((scope === 'global' || scope === 'instance' || (scope === 'local' && !variable.isArgument)) && converter._isPrimitive(rh) && rh.type === 'sym') { rh = converter._symbolToBlock(rh.value, rh.node); } - // === Smalruby: End of symbol assignment === - // === Smalruby: Start of array syntax === if ((scope === 'global' || scope === 'instance' || (scope === 'local' && !variable.isArgument)) && converter._isArray(rh)) { @@ -707,7 +674,6 @@ const VariablesConverter = { // Link blocks return converter._linkBlocks(blocks); } - // === Smalruby: End of array syntax === if (scope === 'global' || scope === 'instance') { if (converter._isNumberOrBlock(rh) || converter._isStringOrBlock(rh)) { diff --git a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/round-trip.test.js b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/round-trip.test.js index d414f67752b..0826c747422 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/round-trip.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/round-trip.test.js @@ -452,7 +452,6 @@ end }); }); - // === Smalruby: Start of array syntax === describe('array syntax round trip', () => { test('push via method call', async () => { await expectRoundTrip('$a.push("hello")'); @@ -509,9 +508,7 @@ end await expectRoundTrip('@items.length'); }); }); - // === Smalruby: End of array syntax === - // === Smalruby: Start of symbol support === test('symbol .to_s round-trip', async () => { await expectRoundTrip('say(:foo.to_s)'); await expectRoundTrip('say(:bar_baz.to_s)'); @@ -536,5 +533,4 @@ end await expectRoundTrip('$a = :foo\nsay($a)'); await expectRoundTrip('$a = :foo\nthink($a)'); }); - // === Smalruby: End of symbol support === });