Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion infra/smalruby-gemini-relay/lambda/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\`)
Expand Down Expand Up @@ -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\`
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/scratch-gui/docs/furigana-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
47 changes: 44 additions & 3 deletions packages/scratch-gui/docs/smalruby-language-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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定義のみで使えるメソッド

Expand Down Expand Up @@ -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`)
Expand Down
20 changes: 20 additions & 0 deletions packages/scratch-gui/src/components/menu-bar/settings-menu.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down
69 changes: 67 additions & 2 deletions packages/scratch-gui/src/containers/ruby-tab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/scratch-gui/src/lib/furigana-label-map.js
Original file line number Diff line number Diff line change
Expand Up @@ -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': '表示する',
Expand Down
10 changes: 10 additions & 0 deletions packages/scratch-gui/src/lib/furigana-node-handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading