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