From 14f6e9924fee57064e24d77aa05f48a4fc26abc5 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Mon, 16 Mar 2026 09:41:36 +0900 Subject: [PATCH 1/6] feat: add hash literal converter and generator (Phase 1) Implement Ruby hash literal syntax ($a = {name: "Alice", age: 30}) using dual-list storage (_hash__keys_ + _hash__values_). Supports symbol keys, string keys, hash rocket syntax, empty hashes, and global/instance variable scopes. Part of #315. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/ruby-generator/data.js | 88 +++++ .../variable-utils.js | 36 ++ .../lib/ruby-to-blocks-converter/variables.js | 131 ++++++++ .../ruby-roundtrip/variables-v2-hash.test.js | 61 ++++ .../variables/variables-hash-global.test.js | 317 ++++++++++++++++++ 5 files changed, 633 insertions(+) create mode 100644 packages/scratch-gui/test/unit/lib/ruby-roundtrip/variables-v2-hash.test.js create mode 100644 packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-hash-global.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 b4fe62080e7..742cf8046ea 100644 --- a/packages/scratch-gui/src/lib/ruby-generator/data.js +++ b/packages/scratch-gui/src/lib/ruby-generator/data.js @@ -175,6 +175,44 @@ export default function (Generator) { return `${index} - 1`; }; + /** + * Get the raw text value from a block's text input. + * @param {object} block - The block containing the input. + * @param {string} inputName - The name of the input (e.g. 'ITEM'). + * @returns {string} The raw text value. + */ + const getTextInputValue = function (block, inputName) { + const input = block.inputs && block.inputs[inputName]; + if (!input) return ''; + const textBlock = Generator.getBlock(input.block); + if (!textBlock || !textBlock.fields || !textBlock.fields.TEXT) return ''; + return textBlock.fields.TEXT.value; + }; + + /** + * Derive the Ruby hash variable name from a keys list name. + * E.g. '$_hash_a_keys_' → '$a', '@_hash_a_keys_' → '@a', '_hash_a_keys_' → 'a' + * @param {string} keysListName - The keys list name. + * @returns {string} The Ruby variable name. + */ + const getHashVarName = function (keysListName) { + let prefix = ''; + let name = keysListName; + if (name[0] === '$') { + prefix = '$'; + name = name.slice(1); + } else if (name[0] === '@') { + prefix = '@'; + name = name.slice(1); + } + // Remove _hash_ prefix and _keys_ suffix + const match = name.match(/^_hash_(.+)_keys_$/); + if (match) { + return `${prefix}${match[1]}`; + } + return keysListName; + }; + Generator.data_listcontents = function (block) { const list = getListName(block); return [list, Generator.ORDER_COLLECTION]; @@ -186,6 +224,11 @@ export default function (Generator) { // Suppressed: handled by data_deletealloflist array literal pattern return ''; } + if (comment && (comment.includes('@ruby:hash:literal:key:') || + comment.includes('@ruby:hash:literal:value'))) { + // Suppressed: handled by data_deletealloflist hash literal pattern + return ''; + } const item = Generator.valueToCode(block, 'ITEM', Generator.ORDER_NONE) || '0'; const list = getListName(block); @@ -216,6 +259,51 @@ export default function (Generator) { return `${list} = [${values.join(', ')}]\n`; } + if (comment && comment === '@ruby:hash:literal:values') { + // Suppressed: handled by the keys clear block above + return ''; + } + + const hashLiteralMatch = comment ? comment.match(/@ruby:hash:literal:(\d+)/) : null; + if (hashLiteralMatch) { + const count = parseInt(hashLiteralMatch[1], 10); + // Skip the next block (clear values list) + let nextId = block.next; + const clearValuesBlock = Generator.getBlock(nextId); + nextId = clearValuesBlock.next; + + // Derive the variable name from the keys list name + const hashVarName = getHashVarName(list); + + const entries = []; + for (let i = 0; i < count; i++) { + // Read key block - get raw text value from ITEM input + const keyBlock = Generator.getBlock(nextId); + const keyComment = Generator.getCommentText(keyBlock); + const rawKey = getTextInputValue(keyBlock, 'ITEM'); + nextId = keyBlock.next; + + // Read value block + const valueBlock = Generator.getBlock(nextId); + const value = Generator.valueToCode(valueBlock, 'ITEM', Generator.ORDER_NONE) || '0'; + nextId = valueBlock.next; + + if (keyComment && keyComment.includes('@ruby:hash:literal:key:sym')) { + // Symbol key: ":name" → generate {name: value} syntax + const symName = rawKey.slice(1); // remove leading ":" + entries.push(`${symName}: ${Generator.nosToCode(value)}`); + } else { + // String key: generate {"name" => value} syntax + entries.push(`"${rawKey}" => ${Generator.nosToCode(value)}`); + } + } + + if (entries.length === 0) { + return `${hashVarName} = {}\n`; + } + return `${hashVarName} = {${entries.join(', ')}}\n`; + } + return `${list}.clear\n`; }; 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 b6461c66057..0bf628547f5 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 @@ -258,6 +258,42 @@ const VariableUtils = { return block; }, + /** + * Get the keys list name for a hash variable. + * @param {string} prefixedName - The prefixed variable name (e.g. '$a', '@a', 'a'). + * @returns {string} The keys list name (e.g. '$_hash_a_keys_'). + */ + _hashKeysListName (prefixedName) { + let prefix = ''; + let varName = prefixedName; + if (prefixedName[0] === '$') { + prefix = '$'; + varName = prefixedName.slice(1); + } else if (prefixedName[0] === '@') { + prefix = '@'; + varName = prefixedName.slice(1); + } + return `${prefix}_hash_${varName}_keys_`; + }, + + /** + * Get the values list name for a hash variable. + * @param {string} prefixedName - The prefixed variable name (e.g. '$a', '@a', 'a'). + * @returns {string} The values list name (e.g. '$_hash_a_values_'). + */ + _hashValuesListName (prefixedName) { + let prefix = ''; + let varName = prefixedName; + if (prefixedName[0] === '$') { + prefix = '$'; + varName = prefixedName.slice(1); + } else if (prefixedName[0] === '@') { + prefix = '@'; + varName = prefixedName.slice(1); + } + return `${prefix}_hash_${varName}_values_`; + }, + _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 fa6cef9bd0b..3021a90f124 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 @@ -21,6 +21,12 @@ const messages = defineMessages({ '\nPlease switch to Ruby version 2 from the settings menu.', description: 'Error message when array literal ($a = [1, 2, 3]) is used in Ruby version 1', id: 'gui.smalruby3.rubyToBlocksConverter.arrayLiteralNotAvailableInV1' + }, + hashSyntaxNotAvailableInV1: { + defaultMessage: 'Hash syntax is only available in Ruby version 2.' + + '\nPlease switch to Ruby version 2 from the settings menu.', + description: 'Error message when hash syntax ($a = {key: value}) is used in Ruby version 1', + id: 'gui.smalruby3.rubyToBlocksConverter.hashSyntaxNotAvailableInV1' } }); @@ -677,6 +683,131 @@ const VariablesConverter = { return converter._linkBlocks(blocks); } + if ((scope === 'global' || scope === 'instance' || + (scope === 'local' && !variable.isArgument)) && + converter._isHash(rh)) { + if (converter.version < 2) { + throw new RubyToBlocksConverterError( + converter._context.currentNode, + converter._translator(messages.hashSyntaxNotAvailableInV1) + ); + } + const hashEntries = rh.value; // Map + let prefixedName; + if (variable.scope === 'global') { + prefixedName = `$${variable.name}`; + } else if (variable.scope === 'instance') { + prefixedName = `@${variable.name}`; + } else { + prefixedName = variable.originalName; + } + const keysListName = converter._hashKeysListName(prefixedName); + const valuesListName = converter._hashValuesListName(prefixedName); + const keysList = converter._lookupOrCreateList(keysListName); + const valuesList = converter._lookupOrCreateList(valuesListName); + + // Create clear block for keys + const clearKeysBlock = converter._createBlock('data_deletealloflist', 'statement', { + fields: { + LIST: { + name: 'LIST', + id: keysList.id, + value: keysList.name, + variableType: keysList.type + } + } + }); + let hashLiteralComment = `@ruby:hash:literal:${hashEntries.size}`; + if (scope === 'local') { + hashLiteralComment = + `@ruby:lvar:${variable.originalName}:${variable.scopeIndex},${hashLiteralComment}`; + } + clearKeysBlock.comment = converter._createComment( + hashLiteralComment, clearKeysBlock.id + ); + + // Create clear block for values + const clearValuesBlock = converter._createBlock('data_deletealloflist', 'statement', { + fields: { + LIST: { + name: 'LIST', + id: valuesList.id, + value: valuesList.name, + variableType: valuesList.type + } + } + }); + clearValuesBlock.comment = converter._createComment( + '@ruby:hash:literal:values', clearValuesBlock.id + ); + + const blocks = [clearKeysBlock, clearValuesBlock]; + + // Create push blocks for each key-value pair + hashEntries.forEach((value, key) => { + let keyStr; + let keyComment; + if (converter._isSymbol(key)) { + const symName = converter._getSymbolValue(key); + keyStr = `:${symName}`; + keyComment = '@ruby:hash:literal:key:sym'; + } else if (converter._isString(key)) { + keyStr = converter._isPrimitive(key) ? key.value : key; + keyComment = '@ruby:hash:literal:key:str'; + } else { + return; // skip unsupported key types + } + + // Push key + const pushKeyBlock = converter._createBlock('data_addtolist', 'statement', { + fields: { + LIST: { + name: 'LIST', + id: keysList.id, + value: keysList.name, + variableType: keysList.type + } + } + }); + converter._addTextInput(pushKeyBlock, 'ITEM', keyStr, 'thing'); + pushKeyBlock.comment = converter._createComment(keyComment, pushKeyBlock.id); + blocks.push(pushKeyBlock); + + // Push value - handle symbol values via _symbolToBlock + let valueItem; + if (converter._isPrimitive(value) && value.type === 'sym') { + valueItem = converter._symbolToBlock(value.value, value.node); + } else if (converter._isNumber(value)) { + valueItem = converter._isPrimitive(value) ? value.value.toString() : value.toString(); + } else if (converter._isString(value)) { + valueItem = converter._isPrimitive(value) ? value.value : value; + } else { + valueItem = value; + } + + const pushValueBlock = converter._createBlock('data_addtolist', 'statement', { + fields: { + LIST: { + name: 'LIST', + id: valuesList.id, + value: valuesList.name, + variableType: valuesList.type + } + } + }); + converter._addTextInput( + pushValueBlock, 'ITEM', + converter._isNumber(valueItem) ? valueItem.toString() : valueItem, 'thing' + ); + pushValueBlock.comment = converter._createComment( + '@ruby:hash:literal:value', pushValueBlock.id + ); + blocks.push(pushValueBlock); + }); + + return converter._linkBlocks(blocks); + } + if (scope === 'global' || scope === 'instance') { if (converter._isNumberOrBlock(rh) || converter._isStringOrBlock(rh)) { const block = converter._createBlock('data_setvariableto', 'statement', { diff --git a/packages/scratch-gui/test/unit/lib/ruby-roundtrip/variables-v2-hash.test.js b/packages/scratch-gui/test/unit/lib/ruby-roundtrip/variables-v2-hash.test.js new file mode 100644 index 00000000000..be40d7b402b --- /dev/null +++ b/packages/scratch-gui/test/unit/lib/ruby-roundtrip/variables-v2-hash.test.js @@ -0,0 +1,61 @@ +// === Smalruby: This file is Smalruby-specific (v2 hash syntax roundtrip tests) === +/** + * V2 hash syntax roundtrip tests. + * Verifies that Ruby → Blocks → Ruby produces correct output + * for hash operations using dual-list (keys + values) storage. + */ +import dedent from 'dedent'; +import { + makeSpriteTarget, + makeConverter, + setupRubyGenerator, + expectRoundTrip +} from '../../helpers/ruby-roundtrip-helper'; + +describe('Ruby Roundtrip: V2 hash syntax', () => { + let target, runtime, converter; + + beforeEach(() => { + ({target, runtime} = makeSpriteTarget()); + setupRubyGenerator(); + converter = makeConverter(target, runtime, {version: '2'}); + }); + + test('global hash literal with symbol keys', async () => { + await expectRoundTrip(converter, target, dedent` + $a = {name: "Alice", age: 30} + `); + }); + + test('instance hash literal with symbol key', async () => { + await expectRoundTrip(converter, target, dedent` + @a = {x: 1} + `); + }); + + test('empty hash literal', async () => { + await expectRoundTrip(converter, target, dedent` + $a = {} + `); + }); + + test('hash literal with string keys', async () => { + await expectRoundTrip(converter, target, dedent` + $a = {"foo" => "bar"} + `); + }); + + test('hash literal with hash rocket symbol key normalizes to shorthand', async () => { + await expectRoundTrip(converter, target, dedent` + $a = {:name => "Alice"} + `, dedent` + $a = {name: "Alice"} + `); + }); + + test('mixed symbol and string keys', async () => { + await expectRoundTrip(converter, target, dedent` + $a = {name: "Alice", "age" => 30} + `); + }); +}); diff --git a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-hash-global.test.js b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-hash-global.test.js new file mode 100644 index 00000000000..a6d3d9fb5e1 --- /dev/null +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-hash-global.test.js @@ -0,0 +1,317 @@ +// === Smalruby: This file is Smalruby-specific (Ruby hash syntax for Scratch lists) === +import RubyToBlocksConverter from '../../../../../src/lib/ruby-to-blocks-converter'; +import { + convertAndExpectToEqualBlocks, + expectedInfo +} from '../../../../helpers/expect-to-equal-blocks'; + +describe('RubyToBlocksConverter/Variables/HashSyntax', () => { + let converter; + let target; + + beforeEach(() => { + converter = new RubyToBlocksConverter(null, {version: '2'}); + target = null; + }); + + describe('$a - global hash with symbol keys', () => { + const varName = '$a'; + + test('hash literal $a = {name: "Alice", age: 30} generates clear + push blocks', async () => { + const code = `${varName} = {name: "Alice", age: 30}`; + const expected = [ + { + opcode: 'data_deletealloflist', + fields: [ + { + name: 'LIST', + list: '$_hash_a_keys_' + } + ], + comment: {text: '@ruby:hash:literal:2', minimized: true}, + next: { + opcode: 'data_deletealloflist', + fields: [ + { + name: 'LIST', + list: '$_hash_a_values_' + } + ], + comment: {text: '@ruby:hash:literal:values', minimized: true}, + next: { + opcode: 'data_addtolist', + fields: [ + { + name: 'LIST', + list: '$_hash_a_keys_' + } + ], + inputs: [ + { + name: 'ITEM', + block: expectedInfo.makeText(':name') + } + ], + comment: {text: '@ruby:hash:literal:key:sym', minimized: true}, + next: { + opcode: 'data_addtolist', + fields: [ + { + name: 'LIST', + list: '$_hash_a_values_' + } + ], + inputs: [ + { + name: 'ITEM', + block: expectedInfo.makeText('Alice') + } + ], + comment: {text: '@ruby:hash:literal:value', minimized: true}, + next: { + opcode: 'data_addtolist', + fields: [ + { + name: 'LIST', + list: '$_hash_a_keys_' + } + ], + inputs: [ + { + name: 'ITEM', + block: expectedInfo.makeText(':age') + } + ], + comment: {text: '@ruby:hash:literal:key:sym', minimized: true}, + next: { + opcode: 'data_addtolist', + fields: [ + { + name: 'LIST', + list: '$_hash_a_values_' + } + ], + inputs: [ + { + name: 'ITEM', + block: expectedInfo.makeText('30') + } + ], + comment: {text: '@ruby:hash:literal:value', minimized: true} + } + } + } + } + } + } + ]; + await convertAndExpectToEqualBlocks(converter, target, code, expected); + }); + + test('empty hash literal $a = {} generates clear blocks only', async () => { + const code = `${varName} = {}`; + const expected = [ + { + opcode: 'data_deletealloflist', + fields: [ + { + name: 'LIST', + list: '$_hash_a_keys_' + } + ], + comment: {text: '@ruby:hash:literal:0', minimized: true}, + next: { + opcode: 'data_deletealloflist', + fields: [ + { + name: 'LIST', + list: '$_hash_a_values_' + } + ], + comment: {text: '@ruby:hash:literal:values', minimized: true} + } + } + ]; + await convertAndExpectToEqualBlocks(converter, target, code, expected); + }); + + test('hash literal with string keys $a = {"foo" => "bar"}', async () => { + const code = `${varName} = {"foo" => "bar"}`; + const expected = [ + { + opcode: 'data_deletealloflist', + fields: [ + { + name: 'LIST', + list: '$_hash_a_keys_' + } + ], + comment: {text: '@ruby:hash:literal:1', minimized: true}, + next: { + opcode: 'data_deletealloflist', + fields: [ + { + name: 'LIST', + list: '$_hash_a_values_' + } + ], + comment: {text: '@ruby:hash:literal:values', minimized: true}, + next: { + opcode: 'data_addtolist', + fields: [ + { + name: 'LIST', + list: '$_hash_a_keys_' + } + ], + inputs: [ + { + name: 'ITEM', + block: expectedInfo.makeText('foo') + } + ], + comment: {text: '@ruby:hash:literal:key:str', minimized: true}, + next: { + opcode: 'data_addtolist', + fields: [ + { + name: 'LIST', + list: '$_hash_a_values_' + } + ], + inputs: [ + { + name: 'ITEM', + block: expectedInfo.makeText('bar') + } + ], + comment: {text: '@ruby:hash:literal:value', minimized: true} + } + } + } + } + ]; + await convertAndExpectToEqualBlocks(converter, target, code, expected); + }); + + test('hash literal with hash rocket symbol key $a = {:name => "Alice"}', async () => { + const code = `${varName} = {:name => "Alice"}`; + const expected = [ + { + opcode: 'data_deletealloflist', + fields: [ + { + name: 'LIST', + list: '$_hash_a_keys_' + } + ], + comment: {text: '@ruby:hash:literal:1', minimized: true}, + next: { + opcode: 'data_deletealloflist', + fields: [ + { + name: 'LIST', + list: '$_hash_a_values_' + } + ], + comment: {text: '@ruby:hash:literal:values', minimized: true}, + next: { + opcode: 'data_addtolist', + fields: [ + { + name: 'LIST', + list: '$_hash_a_keys_' + } + ], + inputs: [ + { + name: 'ITEM', + block: expectedInfo.makeText(':name') + } + ], + comment: {text: '@ruby:hash:literal:key:sym', minimized: true}, + next: { + opcode: 'data_addtolist', + fields: [ + { + name: 'LIST', + list: '$_hash_a_values_' + } + ], + inputs: [ + { + name: 'ITEM', + block: expectedInfo.makeText('Alice') + } + ], + comment: {text: '@ruby:hash:literal:value', minimized: true} + } + } + } + } + ]; + await convertAndExpectToEqualBlocks(converter, target, code, expected); + }); + }); + + describe('@a - instance hash', () => { + test('hash literal @a = {x: 1} generates correct list names', async () => { + const code = '@a = {x: 1}'; + const expected = [ + { + opcode: 'data_deletealloflist', + fields: [ + { + name: 'LIST', + list: '@_hash_a_keys_' + } + ], + comment: {text: '@ruby:hash:literal:1', minimized: true}, + next: { + opcode: 'data_deletealloflist', + fields: [ + { + name: 'LIST', + list: '@_hash_a_values_' + } + ], + comment: {text: '@ruby:hash:literal:values', minimized: true}, + next: { + opcode: 'data_addtolist', + fields: [ + { + name: 'LIST', + list: '@_hash_a_keys_' + } + ], + inputs: [ + { + name: 'ITEM', + block: expectedInfo.makeText(':x') + } + ], + comment: {text: '@ruby:hash:literal:key:sym', minimized: true}, + next: { + opcode: 'data_addtolist', + fields: [ + { + name: 'LIST', + list: '@_hash_a_values_' + } + ], + inputs: [ + { + name: 'ITEM', + block: expectedInfo.makeText('1') + } + ], + comment: {text: '@ruby:hash:literal:value', minimized: true} + } + } + } + } + ]; + await convertAndExpectToEqualBlocks(converter, target, code, expected); + }); + }); +}); From 898d7ae290b63a1f119a3a5e13fa2d533f88c554 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Mon, 16 Mar 2026 09:44:07 +0900 Subject: [PATCH 2/6] feat: add hash read converter and generator (Phase 2) Implement $a[:name] and $a["name"] hash bracket read using data_itemoflist(data_itemnumoflist(key, keys_list), values_list). Also pre-implements convertHashSet helper for Phase 3. Part of #315. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/ruby-generator/data.js | 26 ++ .../lib/ruby-to-blocks-converter/variables.js | 228 ++++++++++++++++++ .../ruby-roundtrip/variables-v2-hash.test.js | 14 ++ .../variables/variables-hash-global.test.js | 45 ++++ 4 files changed, 313 insertions(+) diff --git a/packages/scratch-gui/src/lib/ruby-generator/data.js b/packages/scratch-gui/src/lib/ruby-generator/data.js index 742cf8046ea..90bac3286b0 100644 --- a/packages/scratch-gui/src/lib/ruby-generator/data.js +++ b/packages/scratch-gui/src/lib/ruby-generator/data.js @@ -327,6 +327,32 @@ export default function (Generator) { const index = Generator.valueToCode(block, 'INDEX', Generator.ORDER_NONE); return [index, Generator.ORDER_ATOMIC]; } + + // Hash get: data_itemoflist with @ruby:hash:get:sym or @ruby:hash:get:str comment + if (comment && comment.startsWith('@ruby:hash:get:')) { + const valuesListName = getListName(block); + const hashVarName = getHashVarName( + valuesListName.replace(/_values_$/, '_keys_') + ); + + // Get the key from the nested data_itemnumoflist block + const indexBlockId = block.inputs && block.inputs.INDEX && block.inputs.INDEX.block; + if (indexBlockId) { + const numBlock = Generator.getBlock(indexBlockId); + if (numBlock && numBlock.opcode === 'data_itemnumoflist') { + const rawKey = getTextInputValue(numBlock, 'ITEM'); + if (comment === '@ruby:hash:get:sym') { + // Symbol key: ":name" → $a[:name] + const symName = rawKey.slice(1); // remove leading ":" + return [`${hashVarName}[:${symName}]`, Generator.ORDER_FUNCTION_CALL]; + } + // String key: "foo" → $a["foo"] + return [`${hashVarName}["${rawKey}"]`, Generator.ORDER_FUNCTION_CALL]; + + } + } + } + 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/variables.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variables.js index 3021a90f124..d06c0360e59 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 @@ -106,6 +106,222 @@ const VariablesConverter = { return addBlock; }; + /** + * Convert a hash bracket read: $a[:key] or $a["key"]. + * Generates data_itemoflist(INDEX: data_itemnumoflist(key, keys_list), values_list). + * @param {object} receiver - The receiver block (data_variable). + * @param {*} keyArg - The key argument (symbol or string). + * @returns {object|null} The generated block, or null on failure. + */ + const convertHashGet = function (receiver, keyArg) { + if (!converter._isBlock(receiver) || receiver.opcode !== 'data_variable') return null; + + const varName = receiver.fields.VARIABLE.value; + const variable = converter._context.variables[varName] || + converter._context.localVariables[varName]; + if (!variable) return null; + + let prefixedName; + if (variable.scope === 'global') { + prefixedName = `$${varName}`; + } else if (variable.scope === 'instance') { + prefixedName = `@${varName}`; + } else if (variable.scope === 'local') { + prefixedName = variable.originalName; + } else { + return null; + } + + const keysListName = converter._hashKeysListName(prefixedName); + const valuesListName = converter._hashValuesListName(prefixedName); + const keysList = converter._lookupOrCreateList(keysListName); + const valuesList = converter._lookupOrCreateList(valuesListName); + + let keyStr; + let commentMarker; + if (converter._isSymbol(keyArg)) { + const symName = converter._getSymbolValue(keyArg); + keyStr = `:${symName}`; + commentMarker = '@ruby:hash:get:sym'; + } else { + keyStr = converter._isPrimitive(keyArg) ? keyArg.value : keyArg; + commentMarker = '@ruby:hash:get:str'; + } + + // Create data_itemnumoflist block for key lookup + const numBlock = converter._createBlock('data_itemnumoflist', 'value', { + fields: { + LIST: { + name: 'LIST', + id: keysList.id, + value: keysList.name, + variableType: keysList.type + } + } + }); + converter._addTextInput(numBlock, 'ITEM', keyStr, 'thing'); + + // Create data_itemoflist block for value retrieval + const block = converter._changeBlock(receiver, 'data_itemoflist', 'value'); + delete block.fields.VARIABLE; + block.fields.LIST = { + name: 'LIST', + id: valuesList.id, + value: valuesList.name, + variableType: valuesList.type + }; + converter._addNumberInput(block, 'INDEX', 'math_integer', numBlock, 1); + block.comment = converter._createComment(commentMarker, block.id); + + return block; + }; + + /** + * Convert a hash bracket write (upsert): $a[:key] = value. + * Generates delete+push pattern (4 blocks). + * @param {object} receiver - The receiver block (data_variable). + * @param {*} keyArg - The key argument (symbol or string). + * @param {*} valueArg - The value to set. + * @returns {object|null} The generated block chain, or null on failure. + */ + const convertHashSet = function (receiver, keyArg, valueArg) { + if (!converter._isBlock(receiver) || receiver.opcode !== 'data_variable') return null; + + const varName = receiver.fields.VARIABLE.value; + const variable = converter._context.variables[varName] || + converter._context.localVariables[varName]; + if (!variable) return null; + + let prefixedName; + if (variable.scope === 'global') { + prefixedName = `$${varName}`; + } else if (variable.scope === 'instance') { + prefixedName = `@${varName}`; + } else if (variable.scope === 'local') { + prefixedName = variable.originalName; + } else { + return null; + } + + const keysListName = converter._hashKeysListName(prefixedName); + const valuesListName = converter._hashValuesListName(prefixedName); + const keysList = converter._lookupOrCreateList(keysListName); + const valuesList = converter._lookupOrCreateList(valuesListName); + + let keyStr; + let commentMarker; + if (converter._isSymbol(keyArg)) { + const symName = converter._getSymbolValue(keyArg); + keyStr = `:${symName}`; + commentMarker = '@ruby:hash:set:sym'; + } else { + keyStr = converter._isPrimitive(keyArg) ? keyArg.value : keyArg; + commentMarker = '@ruby:hash:set:str'; + } + + // Handle symbol values + let valueItem; + if (converter._isPrimitive(valueArg) && valueArg.type === 'sym') { + valueItem = converter._symbolToBlock(valueArg.value, valueArg.node); + } else if (converter._isNumber(valueArg)) { + valueItem = converter._isPrimitive(valueArg) ? + valueArg.value.toString() : valueArg.toString(); + } else if (converter._isString(valueArg)) { + valueItem = converter._isPrimitive(valueArg) ? valueArg.value : valueArg; + } else { + valueItem = valueArg; + } + + // Block 1: delete from values list + const deleteValuesBlock = converter._changeBlock(receiver, 'data_deleteoflist', 'statement'); + delete deleteValuesBlock.fields.VARIABLE; + deleteValuesBlock.fields.LIST = { + name: 'LIST', + id: valuesList.id, + value: valuesList.name, + variableType: valuesList.type + }; + const numBlock1 = converter._createBlock('data_itemnumoflist', 'value', { + fields: { + LIST: { + name: 'LIST', + id: keysList.id, + value: keysList.name, + variableType: keysList.type + } + } + }); + converter._addTextInput(numBlock1, 'ITEM', keyStr, 'thing'); + converter._addNumberInput(deleteValuesBlock, 'INDEX', 'math_integer', numBlock1, 1); + deleteValuesBlock.comment = converter._createComment(commentMarker, deleteValuesBlock.id); + + // Block 2: delete from keys list + const deleteKeysBlock = converter._createBlock('data_deleteoflist', 'statement', { + fields: { + LIST: { + name: 'LIST', + id: keysList.id, + value: keysList.name, + variableType: keysList.type + } + } + }); + const numBlock2 = converter._createBlock('data_itemnumoflist', 'value', { + fields: { + LIST: { + name: 'LIST', + id: keysList.id, + value: keysList.name, + variableType: keysList.type + } + } + }); + converter._addTextInput(numBlock2, 'ITEM', keyStr, 'thing'); + converter._addNumberInput(deleteKeysBlock, 'INDEX', 'math_integer', numBlock2, 1); + deleteKeysBlock.comment = converter._createComment( + '@ruby:hash:set:delete:key', deleteKeysBlock.id + ); + + // Block 3: push key + const pushKeyBlock = converter._createBlock('data_addtolist', 'statement', { + fields: { + LIST: { + name: 'LIST', + id: keysList.id, + value: keysList.name, + variableType: keysList.type + } + } + }); + converter._addTextInput(pushKeyBlock, 'ITEM', keyStr, 'thing'); + pushKeyBlock.comment = converter._createComment( + '@ruby:hash:set:push:key', pushKeyBlock.id + ); + + // Block 4: push value + const pushValueBlock = converter._createBlock('data_addtolist', 'statement', { + fields: { + LIST: { + name: 'LIST', + id: valuesList.id, + value: valuesList.name, + variableType: valuesList.type + } + } + }); + converter._addTextInput( + pushValueBlock, 'ITEM', + converter._isNumber(valueItem) ? valueItem.toString() : valueItem, 'thing' + ); + pushValueBlock.comment = converter._createComment( + '@ruby:hash:set:push:value', pushValueBlock.id + ); + + return converter._linkBlocks([ + deleteValuesBlock, deleteKeysBlock, pushKeyBlock, pushValueBlock + ]); + }; + converter.registerOnSend('self', 'show_variable', 1, params => { const {args} = params; if (!converter._isString(args[0])) return null; @@ -306,6 +522,18 @@ const VariablesConverter = { converter.registerOnSend('variable', '[]', 1, params => { const {receiver, args} = params; + + // Hash access: $a[:key] or $a["key"] + if (converter._isSymbol(args[0]) || converter._isString(args[0])) { + if (converter.version < 2) { + throw new RubyToBlocksConverterError( + converter._context.currentNode, + converter._translator(messages.hashSyntaxNotAvailableInV1) + ); + } + return convertHashGet(receiver, args[0]); + } + if (!converter._isNumberOrBlock(args[0])) return null; const {block: listBlock, converted} = convertToListBlock(receiver); diff --git a/packages/scratch-gui/test/unit/lib/ruby-roundtrip/variables-v2-hash.test.js b/packages/scratch-gui/test/unit/lib/ruby-roundtrip/variables-v2-hash.test.js index be40d7b402b..e94dc668e7f 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-roundtrip/variables-v2-hash.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-roundtrip/variables-v2-hash.test.js @@ -58,4 +58,18 @@ describe('Ruby Roundtrip: V2 hash syntax', () => { $a = {name: "Alice", "age" => 30} `); }); + + test('hash read with symbol key', async () => { + await expectRoundTrip(converter, target, dedent` + $a = {name: "Alice"} + say($a[:name]) + `); + }); + + test('hash read with string key', async () => { + await expectRoundTrip(converter, target, dedent` + $a = {"foo" => "bar"} + say($a["foo"]) + `); + }); }); diff --git a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-hash-global.test.js b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-hash-global.test.js index a6d3d9fb5e1..672e7edfc12 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-hash-global.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-hash-global.test.js @@ -254,6 +254,51 @@ describe('RubyToBlocksConverter/Variables/HashSyntax', () => { }); }); + describe('$a - hash read with symbol key', () => { + test('$a[:name] generates data_itemoflist with data_itemnumoflist', async () => { + const code = '$a = {name: "Alice"}\nsay($a[:name])'; + const res = await converter.targetCodeToBlocks(target, code); + expect(converter.errors).toHaveLength(0); + expect(res).toBeTruthy(); + + const blockIds = Object.keys(converter.blocks); + const blocks = blockIds.map(id => converter.blocks[id]); + + // Find data_itemoflist block (hash get) + const itemBlock = blocks.find(b => b.opcode === 'data_itemoflist'); + expect(itemBlock).toBeTruthy(); + + // Verify it has @ruby:hash:get:sym comment + const itemComment = converter._context.comments[itemBlock.comment]; + expect(itemComment.text).toBe('@ruby:hash:get:sym'); + + // Verify it references the values list + expect(itemBlock.fields.LIST.value).toBe('_hash_a_values_'); + + // Find data_itemnumoflist block (key lookup) + const numBlock = blocks.find(b => b.opcode === 'data_itemnumoflist'); + expect(numBlock).toBeTruthy(); + // Verify it references the keys list + expect(numBlock.fields.LIST.value).toBe('_hash_a_keys_'); + }); + + test('$a["foo"] generates data_itemoflist with string key', async () => { + const code = '$a = {"foo" => "bar"}\nsay($a["foo"])'; + const res = await converter.targetCodeToBlocks(target, code); + expect(converter.errors).toHaveLength(0); + expect(res).toBeTruthy(); + + const blockIds = Object.keys(converter.blocks); + const blocks = blockIds.map(id => converter.blocks[id]); + + const itemBlock = blocks.find(b => b.opcode === 'data_itemoflist'); + expect(itemBlock).toBeTruthy(); + + const itemComment = converter._context.comments[itemBlock.comment]; + expect(itemComment.text).toBe('@ruby:hash:get:str'); + }); + }); + describe('@a - instance hash', () => { test('hash literal @a = {x: 1} generates correct list names', async () => { const code = '@a = {x: 1}'; From af6bd4b49f2cc4cd88ab02f8f796723193ec1883 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Mon, 16 Mar 2026 09:45:44 +0900 Subject: [PATCH 3/6] feat: add hash write (upsert) converter and generator (Phase 3) Implement $a[:name] = "Bob" using delete+push pattern (4 blocks). Leverages Scratch index 0 = LIST_INVALID = no-op for if-less upsert. Supports both symbol and string keys. Part of #315. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/ruby-generator/data.js | 50 ++++++++++++++++ .../lib/ruby-to-blocks-converter/variables.js | 14 +++++ .../ruby-roundtrip/variables-v2-hash.test.js | 21 +++++++ .../variables/variables-hash-global.test.js | 60 +++++++++++++++++++ 4 files changed, 145 insertions(+) diff --git a/packages/scratch-gui/src/lib/ruby-generator/data.js b/packages/scratch-gui/src/lib/ruby-generator/data.js index 90bac3286b0..cfa143ebdaa 100644 --- a/packages/scratch-gui/src/lib/ruby-generator/data.js +++ b/packages/scratch-gui/src/lib/ruby-generator/data.js @@ -229,6 +229,11 @@ export default function (Generator) { // Suppressed: handled by data_deletealloflist hash literal pattern return ''; } + if (comment && (comment === '@ruby:hash:set:push:key' || + comment === '@ruby:hash:set:push:value')) { + // Suppressed: handled by data_deleteoflist hash set pattern + return ''; + } const item = Generator.valueToCode(block, 'ITEM', Generator.ORDER_NONE) || '0'; const list = getListName(block); @@ -236,6 +241,51 @@ export default function (Generator) { }; Generator.data_deleteoflist = function (block) { + const comment = Generator.getCommentText(block); + + // Hash set: delete+push pattern + if (comment && comment.startsWith('@ruby:hash:set:')) { + if (comment === '@ruby:hash:set:delete:key') { + // Suppressed: handled by the first delete block + return ''; + } + + // This is the first block of the delete+push pattern + const valuesListName = getListName(block); + const hashVarName = getHashVarName( + valuesListName.replace(/_values_$/, '_keys_') + ); + + // Get the key from the nested data_itemnumoflist + const indexBlockId = block.inputs && block.inputs.INDEX && block.inputs.INDEX.block; + let rawKey = ''; + if (indexBlockId) { + const numBlock = Generator.getBlock(indexBlockId); + if (numBlock && numBlock.opcode === 'data_itemnumoflist') { + rawKey = getTextInputValue(numBlock, 'ITEM'); + } + } + + // Skip to the push:value block (3 blocks ahead: delete:key, push:key, push:value) + let nextId = block.next; + // delete:key + const deleteKeyBlock = Generator.getBlock(nextId); + nextId = deleteKeyBlock.next; + // push:key + const pushKeyBlock = Generator.getBlock(nextId); + nextId = pushKeyBlock.next; + // push:value + const pushValueBlock = Generator.getBlock(nextId); + const value = Generator.valueToCode(pushValueBlock, 'ITEM', Generator.ORDER_NONE) || '0'; + + if (comment === '@ruby:hash:set:sym') { + const symName = rawKey.slice(1); // remove leading ":" + return `${hashVarName}[:${symName}] = ${Generator.nosToCode(value)}\n`; + } + // @ruby:hash:set:str + return `${hashVarName}["${rawKey}"] = ${Generator.nosToCode(value)}\n`; + } + const index = getListIndex(block); const list = getListName(block); return `${list}.delete_at(${Generator.nosToCode(index)})\n`; 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 d06c0360e59..eb9bd31b3db 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 @@ -504,6 +504,20 @@ const VariablesConverter = { converter.registerOnSend('variable', '[]=', 2, params => { const {receiver, args} = params; + + // Hash write: $a[:key] = value or $a["key"] = value + if (converter._isSymbol(args[0]) || converter._isString(args[0])) { + if (converter.version < 2) { + throw new RubyToBlocksConverterError( + converter._context.currentNode, + converter._translator(messages.hashSyntaxNotAvailableInV1) + ); + } + if (!converter._isStringOrBlock(args[1]) && !converter._isNumberOrBlock(args[1]) && + !(converter._isPrimitive(args[1]) && args[1].type === 'sym')) return null; + return convertHashSet(receiver, args[0], args[1]); + } + if (!converter._isNumberOrBlock(args[0])) return null; if (!converter._isStringOrBlock(args[1]) && !converter._isNumberOrBlock(args[1])) return null; diff --git a/packages/scratch-gui/test/unit/lib/ruby-roundtrip/variables-v2-hash.test.js b/packages/scratch-gui/test/unit/lib/ruby-roundtrip/variables-v2-hash.test.js index e94dc668e7f..fe0b4928871 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-roundtrip/variables-v2-hash.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-roundtrip/variables-v2-hash.test.js @@ -72,4 +72,25 @@ describe('Ruby Roundtrip: V2 hash syntax', () => { say($a["foo"]) `); }); + + test('hash write with symbol key (update existing)', async () => { + await expectRoundTrip(converter, target, dedent` + $a = {name: "Alice"} + $a[:name] = "Bob" + `); + }); + + test('hash write with string key', async () => { + await expectRoundTrip(converter, target, dedent` + $a = {"foo" => "bar"} + $a["foo"] = "baz" + `); + }); + + test('hash write new key', async () => { + await expectRoundTrip(converter, target, dedent` + $a = {name: "Alice"} + $a[:age] = 30 + `); + }); }); diff --git a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-hash-global.test.js b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-hash-global.test.js index 672e7edfc12..6b47bc1976f 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-hash-global.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-hash-global.test.js @@ -299,6 +299,66 @@ describe('RubyToBlocksConverter/Variables/HashSyntax', () => { }); }); + describe('$a - hash write (upsert) with symbol key', () => { + test('$a[:name] = "Bob" generates delete+push pattern', async () => { + const code = '$a = {name: "Alice"}\n$a[:name] = "Bob"'; + const res = await converter.targetCodeToBlocks(target, code); + expect(converter.errors).toHaveLength(0); + expect(res).toBeTruthy(); + + const blockIds = Object.keys(converter.blocks); + const blocks = blockIds.map(id => converter.blocks[id]); + + // Should have 2 delete blocks and 2 push blocks (+ literal blocks) + const deleteBlocks = blocks.filter(b => b.opcode === 'data_deleteoflist'); + expect(deleteBlocks).toHaveLength(2); + + // First delete should have @ruby:hash:set:sym comment + const setBlock = deleteBlocks.find(b => { + const c = converter._context.comments[b.comment]; + return c && c.text === '@ruby:hash:set:sym'; + }); + expect(setBlock).toBeTruthy(); + + // Second delete should have @ruby:hash:set:delete:key comment + const deleteKeyBlock = deleteBlocks.find(b => { + const c = converter._context.comments[b.comment]; + return c && c.text === '@ruby:hash:set:delete:key'; + }); + expect(deleteKeyBlock).toBeTruthy(); + + // Push key and push value blocks + const addBlocks = blocks.filter(b => b.opcode === 'data_addtolist'); + const pushKeyBlock = addBlocks.find(b => { + const c = converter._context.comments[b.comment]; + return c && c.text === '@ruby:hash:set:push:key'; + }); + expect(pushKeyBlock).toBeTruthy(); + + const pushValueBlock = addBlocks.find(b => { + const c = converter._context.comments[b.comment]; + return c && c.text === '@ruby:hash:set:push:value'; + }); + expect(pushValueBlock).toBeTruthy(); + }); + + test('$a["foo"] = "baz" generates delete+push with string key', async () => { + const code = '$a = {"foo" => "bar"}\n$a["foo"] = "baz"'; + const res = await converter.targetCodeToBlocks(target, code); + expect(converter.errors).toHaveLength(0); + expect(res).toBeTruthy(); + + const blockIds = Object.keys(converter.blocks); + const blocks = blockIds.map(id => converter.blocks[id]); + + const setBlock = blocks.find(b => { + const c = converter._context.comments[b.comment]; + return c && c.text === '@ruby:hash:set:str'; + }); + expect(setBlock).toBeTruthy(); + }); + }); + describe('@a - instance hash', () => { test('hash literal @a = {x: 1} generates correct list names', async () => { const code = '@a = {x: 1}'; From 737db3274ee09ad64e1379cee879b6446b6c3728 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Mon, 16 Mar 2026 09:46:55 +0900 Subject: [PATCH 4/6] feat: add v1 version gating and locale messages for hash syntax (Phase 4) Hash literal, read, and write operations now throw descriptive errors when used in Ruby version 1. Added Japanese and hiragana locale strings. Part of #315. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/scratch-gui/src/locales/ja-Hira.js | 1 + packages/scratch-gui/src/locales/ja.js | 1 + .../variables/variables-hash-global.test.js | 29 +++++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/packages/scratch-gui/src/locales/ja-Hira.js b/packages/scratch-gui/src/locales/ja-Hira.js index 3181571d7a4..745bf90f074 100644 --- a/packages/scratch-gui/src/locales/ja-Hira.js +++ b/packages/scratch-gui/src/locales/ja-Hira.js @@ -109,6 +109,7 @@ export default { 'gui.smalruby3.rubyToBlocksConverter.arraySyntaxNotAvailableInV1': 'はいれつのかきかたはルビーバージョン2でのみつかえます。\nせっていメニューからルビーバージョン2にきりかえるか、\nlist()のかきかたをつかってください。', 'gui.smalruby3.rubyToBlocksConverter.listSyntaxNotAvailableInV2': 'list()のかきかたはルビーバージョン1でのみつかえます。\nはいれつのかきかた($a.push()、$a[0]など)をつかってください。', 'gui.smalruby3.rubyToBlocksConverter.arrayLiteralNotAvailableInV1': 'はいれつリテラルのかきかたはルビーバージョン2でのみつかえます。\nせっていメニューからルビーバージョン2にきりかえてください。', + 'gui.smalruby3.rubyToBlocksConverter.hashSyntaxNotAvailableInV1': 'ハッシュのかきかたはルビーバージョン2でのみつかえます。\nせっていメニューからルビーバージョン2にきりかえてください。', 'gui.smalruby3.rubyToBlocksConverter.classNotSupportedInV1': 'classのていぎはルビーバージョン1ではつかえません。\nせっていメニューからルビーバージョン2にきりかえてください。', 'gui.smalruby3.rubyToBlocksConverter.invalidStageSuperclass': 'Stageクラスは ::Smalruby3::Stage または Smalruby3::Stage のみけいしょうできます。', 'gui.smalruby3.rubyToBlocksConverter.moduleNotSupportedInV1': 'moduleのていぎはルビーバージョン1ではつかえません。\nせっていメニューからルビーバージョン2にきりかえてください。', diff --git a/packages/scratch-gui/src/locales/ja.js b/packages/scratch-gui/src/locales/ja.js index f2108e784df..020c12f5df9 100644 --- a/packages/scratch-gui/src/locales/ja.js +++ b/packages/scratch-gui/src/locales/ja.js @@ -109,6 +109,7 @@ export default { 'gui.smalruby3.rubyToBlocksConverter.arraySyntaxNotAvailableInV1': '配列の書き方はルビーバージョン2でのみ使えます。\n設定メニューからルビーバージョン2に切り替えるか、\nlist()の書き方を使ってください。', 'gui.smalruby3.rubyToBlocksConverter.listSyntaxNotAvailableInV2': 'list()の書き方はルビーバージョン1でのみ使えます。\n配列の書き方($a.push()、$a[0]など)を使ってください。', 'gui.smalruby3.rubyToBlocksConverter.arrayLiteralNotAvailableInV1': '配列リテラルの書き方はルビーバージョン2でのみ使えます。\n設定メニューからルビーバージョン2に切り替えてください。', + 'gui.smalruby3.rubyToBlocksConverter.hashSyntaxNotAvailableInV1': 'ハッシュの書き方はルビーバージョン2でのみ使えます。\n設定メニューからルビーバージョン2に切り替えてください。', 'gui.smalruby3.rubyToBlocksConverter.classNotSupportedInV1': 'classの定義はルビーバージョン1では使えません。\n設定メニューからルビーバージョン2に切り替えてください。', 'gui.smalruby3.rubyToBlocksConverter.invalidStageSuperclass': 'Stageクラスは ::Smalruby3::Stage または Smalruby3::Stage のみ継承できます。', 'gui.smalruby3.rubyToBlocksConverter.moduleNotSupportedInV1': 'moduleの定義はルビーバージョン1では使えません。\n設定メニューからルビーバージョン2に切り替えてください。', diff --git a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-hash-global.test.js b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-hash-global.test.js index 6b47bc1976f..a218681e5c3 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-hash-global.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-hash-global.test.js @@ -359,6 +359,35 @@ describe('RubyToBlocksConverter/Variables/HashSyntax', () => { }); }); + describe('v1 version gating', () => { + let v1Converter; + + beforeEach(() => { + v1Converter = new RubyToBlocksConverter(null, {version: '1'}); + }); + + test('hash literal in v1 throws error', async () => { + const code = '$a = {name: "Alice"}'; + await v1Converter.targetCodeToBlocks(null, code); + expect(v1Converter.errors).toHaveLength(1); + expect(v1Converter.errors[0].text).toMatch(/Hash syntax is only available in Ruby version 2/); + }); + + test('hash read in v1 throws error', async () => { + const code = '$a[:name]'; + await v1Converter.targetCodeToBlocks(null, code); + expect(v1Converter.errors).toHaveLength(1); + expect(v1Converter.errors[0].text).toMatch(/Hash syntax is only available in Ruby version 2/); + }); + + test('hash write in v1 throws error', async () => { + const code = '$a[:name] = "Bob"'; + await v1Converter.targetCodeToBlocks(null, code); + expect(v1Converter.errors).toHaveLength(1); + expect(v1Converter.errors[0].text).toMatch(/Hash syntax is only available in Ruby version 2/); + }); + }); + describe('@a - instance hash', () => { test('hash literal @a = {x: 1} generates correct list names', async () => { const code = '@a = {x: 1}'; From 94bc790999196473f72add79203cbbe31713e7c5 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Mon, 16 Mar 2026 09:51:02 +0900 Subject: [PATCH 5/6] feat: add local variable support and comprehensive roundtrip tests (Phase 5) Handle @ruby:lvar prefix in hash get/set comments for local variables. Generator now extracts variable name from lvar comment when present. Add locale messages and comprehensive roundtrip tests for all scenarios. Part of #315. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/ruby-generator/data.js | 40 +++++++++++++------ .../lib/ruby-to-blocks-converter/variables.js | 12 ++++++ .../ruby-roundtrip/variables-v2-hash.test.js | 37 +++++++++++++++++ 3 files changed, 76 insertions(+), 13 deletions(-) diff --git a/packages/scratch-gui/src/lib/ruby-generator/data.js b/packages/scratch-gui/src/lib/ruby-generator/data.js index cfa143ebdaa..6ffee33634b 100644 --- a/packages/scratch-gui/src/lib/ruby-generator/data.js +++ b/packages/scratch-gui/src/lib/ruby-generator/data.js @@ -244,17 +244,24 @@ export default function (Generator) { const comment = Generator.getCommentText(block); // Hash set: delete+push pattern - if (comment && comment.startsWith('@ruby:hash:set:')) { - if (comment === '@ruby:hash:set:delete:key') { + if (comment && comment.includes('@ruby:hash:set:')) { + if (comment.includes('@ruby:hash:set:delete:key')) { // Suppressed: handled by the first delete block return ''; } // This is the first block of the delete+push pattern - const valuesListName = getListName(block); - const hashVarName = getHashVarName( - valuesListName.replace(/_values_$/, '_keys_') - ); + // Extract variable name: use @ruby:lvar if present, else derive from list name + const lvarMatch = comment.match(/@ruby:lvar:([^:,\s]+)/); + let hashVarName; + if (lvarMatch) { + hashVarName = lvarMatch[1]; + } else { + const valuesListName = getListName(block); + hashVarName = getHashVarName( + valuesListName.replace(/_values_$/, '_keys_') + ); + } // Get the key from the nested data_itemnumoflist const indexBlockId = block.inputs && block.inputs.INDEX && block.inputs.INDEX.block; @@ -278,7 +285,7 @@ export default function (Generator) { const pushValueBlock = Generator.getBlock(nextId); const value = Generator.valueToCode(pushValueBlock, 'ITEM', Generator.ORDER_NONE) || '0'; - if (comment === '@ruby:hash:set:sym') { + if (comment.includes('@ruby:hash:set:sym')) { const symName = rawKey.slice(1); // remove leading ":" return `${hashVarName}[:${symName}] = ${Generator.nosToCode(value)}\n`; } @@ -379,11 +386,18 @@ export default function (Generator) { } // Hash get: data_itemoflist with @ruby:hash:get:sym or @ruby:hash:get:str comment - if (comment && comment.startsWith('@ruby:hash:get:')) { - const valuesListName = getListName(block); - const hashVarName = getHashVarName( - valuesListName.replace(/_values_$/, '_keys_') - ); + if (comment && comment.includes('@ruby:hash:get:')) { + // Extract variable name: use @ruby:lvar if present, else derive from list name + const lvarMatch = comment.match(/@ruby:lvar:([^:,\s]+)/); + let hashVarName; + if (lvarMatch) { + hashVarName = lvarMatch[1]; + } else { + const valuesListName = getListName(block); + hashVarName = getHashVarName( + valuesListName.replace(/_values_$/, '_keys_') + ); + } // Get the key from the nested data_itemnumoflist block const indexBlockId = block.inputs && block.inputs.INDEX && block.inputs.INDEX.block; @@ -391,7 +405,7 @@ export default function (Generator) { const numBlock = Generator.getBlock(indexBlockId); if (numBlock && numBlock.opcode === 'data_itemnumoflist') { const rawKey = getTextInputValue(numBlock, 'ITEM'); - if (comment === '@ruby:hash:get:sym') { + if (comment.includes('@ruby:hash:get:sym')) { // Symbol key: ":name" → $a[:name] const symName = rawKey.slice(1); // remove leading ":" return [`${hashVarName}[:${symName}]`, Generator.ORDER_FUNCTION_CALL]; 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 eb9bd31b3db..dd697a748a7 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 @@ -171,6 +171,12 @@ const VariablesConverter = { variableType: valuesList.type }; converter._addNumberInput(block, 'INDEX', 'math_integer', numBlock, 1); + + // Include @ruby:lvar prefix for local variables + if (variable.scope === 'local') { + commentMarker = + `@ruby:lvar:${variable.originalName}:${variable.scopeIndex},${commentMarker}`; + } block.comment = converter._createComment(commentMarker, block.id); return block; @@ -253,6 +259,12 @@ const VariablesConverter = { }); converter._addTextInput(numBlock1, 'ITEM', keyStr, 'thing'); converter._addNumberInput(deleteValuesBlock, 'INDEX', 'math_integer', numBlock1, 1); + + // Include @ruby:lvar prefix for local variables + if (variable.scope === 'local') { + commentMarker = + `@ruby:lvar:${variable.originalName}:${variable.scopeIndex},${commentMarker}`; + } deleteValuesBlock.comment = converter._createComment(commentMarker, deleteValuesBlock.id); // Block 2: delete from keys list diff --git a/packages/scratch-gui/test/unit/lib/ruby-roundtrip/variables-v2-hash.test.js b/packages/scratch-gui/test/unit/lib/ruby-roundtrip/variables-v2-hash.test.js index fe0b4928871..f54a6f850ff 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-roundtrip/variables-v2-hash.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-roundtrip/variables-v2-hash.test.js @@ -93,4 +93,41 @@ describe('Ruby Roundtrip: V2 hash syntax', () => { $a[:age] = 30 `); }); + + test('local variable hash literal', async () => { + await expectRoundTrip(converter, target, dedent` + a = {x: 1} + `); + }); + + test('local variable hash read', async () => { + await expectRoundTrip(converter, target, dedent` + a = {x: 1} + say(a[:x]) + `); + }); + + test('local variable hash write', async () => { + await expectRoundTrip(converter, target, dedent` + a = {x: 1} + a[:y] = 2 + `); + }); + + test('instance variable hash full workflow', async () => { + await expectRoundTrip(converter, target, dedent` + @h = {name: "Alice", age: 30} + say(@h[:name]) + @h[:age] = 31 + `); + }); + + test('hash with multiple operations', async () => { + await expectRoundTrip(converter, target, dedent` + $a = {x: 1, y: 2} + say($a[:x]) + $a[:z] = 3 + say($a[:z]) + `); + }); }); From 01819dd0dd4bdeeae8c4edfdf510d791ee3554ed Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Mon, 16 Mar 2026 10:08:18 +0900 Subject: [PATCH 6/6] fix: use toJSON().type instead of constructor.name for AssocNode check constructor.name is mangled by terser in production builds, causing visitHashNode and visitKeywordHashNode to produce empty hash Maps. This fixes hash literal entries being lost in production. Part of #315. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ruby-to-blocks-converter/ast-handlers/expressions.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 02bc4496a6a..82edb4c29f0 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 @@ -176,9 +176,10 @@ const ExpressionHandlers = { visitHashNode (node) { // Prism HashNode has elements which are AssocNode or AssocSplatNode + // Use toJSON().type instead of constructor.name for production build compatibility const elements = new Map(); node.elements.forEach(element => { - if (element.constructor.name === 'AssocNode') { + if (element.toJSON().type === 'AssocNode') { elements.set(this.visit(element.key), this.visit(element.value)); } }); @@ -188,9 +189,10 @@ const ExpressionHandlers = { visitKeywordHashNode (node) { // Prism KeywordHashNode is used for keyword arguments without braces, e.g. foo(secs: 5) // Elements are AssocNode with SymbolNode keys + // Use toJSON().type instead of constructor.name for production build compatibility const elements = new Map(); node.elements.forEach(element => { - if (element.constructor.name === 'AssocNode') { + if (element.toJSON().type === 'AssocNode') { elements.set(this.visit(element.key), this.visit(element.value)); } });