diff --git a/packages/scratch-gui/src/lib/ruby-generator/data.js b/packages/scratch-gui/src/lib/ruby-generator/data.js index b4fe62080e7..6ffee33634b 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,16 @@ 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 ''; + } + 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); @@ -193,6 +241,58 @@ export default function (Generator) { }; Generator.data_deleteoflist = function (block) { + const comment = Generator.getCommentText(block); + + // Hash set: delete+push pattern + 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 + // 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; + 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.includes('@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`; @@ -216,6 +316,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`; }; @@ -239,6 +384,39 @@ 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.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; + if (indexBlockId) { + const numBlock = Generator.getBlock(indexBlockId); + if (numBlock && numBlock.opcode === 'data_itemnumoflist') { + const rawKey = getTextInputValue(numBlock, 'ITEM'); + 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]; + } + // 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/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)); } }); 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..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 @@ -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' } }); @@ -100,6 +106,234 @@ 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); + + // 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; + }; + + /** + * 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); + + // 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 + 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; @@ -282,6 +516,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; @@ -300,6 +548,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); @@ -677,6 +937,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/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-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..f54a6f850ff --- /dev/null +++ b/packages/scratch-gui/test/unit/lib/ruby-roundtrip/variables-v2-hash.test.js @@ -0,0 +1,133 @@ +// === 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} + `); + }); + + 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"]) + `); + }); + + 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 + `); + }); + + 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]) + `); + }); +}); 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..a218681e5c3 --- /dev/null +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/variables/variables-hash-global.test.js @@ -0,0 +1,451 @@ +// === 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 - 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 - 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('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}'; + 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); + }); + }); +});