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
10 changes: 10 additions & 0 deletions docs/smalruby-language-spec-v1-diff.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@
- `super` — モジュールのメソッドをオーバーライドしたクラスメソッドから呼び出す
- ファイル保存時の `class Stage` 自動補完

## リスト(データ)

| 機能 | Version 2 | Version 1 |
|---|---|---|
| リスト参照 | `@items.push(...)` (配列を直接操作) | `list("@items").push(...)` (ラッパー関数) |
| インデックス | 0起点: `@items[0]` | 1起点: `list("@items")[1]` |
| 初期化 | `@items = [1, 2, 3]` (配列リテラル) | 使用不可 |
| ハッシュ | `$a[:key]`, `$a["key"]` | 使用不可 |
| 空チェック | `@items.empty?` | 使用不可 |

## 調べる(Sensing)

| 機能 | Version 2 | Version 1 |
Expand Down
10 changes: 10 additions & 0 deletions docs/smalruby-language-spec-v1-diff.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ The following features are **only available in Version 2**:
- `super` — calling module methods from overriding class methods
- Automatic `class Stage` completion on file save

## Lists (Data)

| Feature | Version 2 | Version 1 |
|---|---|---|
| List reference | `@items.push(...)` (direct array) | `list("@items").push(...)` (wrapper) |
| Index | 0-indexed: `@items[0]` | 1-indexed: `list("@items")[1]` |
| Initialization | `@items = [1, 2, 3]` (array literal) | Not available |
| Hash | `$a[:key]`, `$a["key"]` | Not available |
| Empty check | `@items.empty?` | Not available |

## Sensing

| Feature | Version 2 | Version 1 |
Expand Down
30 changes: 18 additions & 12 deletions docs/smalruby-language-spec.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -661,20 +661,26 @@ show_variable("@score")
hide_variable("@score")
```

#### リストの使用
#### リスト(配列)の使用

```ruby
list("@items").push("りんご") # 追加
list("@items").delete_at(1) # 削除
list("@items").clear # 全削除
list("@items").insert(1, "バナナ") # 挿入
list("@items")[1] = "みかん" # 置換
list("@items")[1] # 取得
list("@items").index("りんご") # 検索
list("@items").length # 長さ
list("@items").include?("りんご") # 含むか
show_list("@items") # リストの表示
hide_list("@items") # リストの非表示
# 配列リテラルで初期化
@items = ["りんご", "バナナ", "さくらんぼ"]

# 操作(0起点インデックス)
@items.push("りんご") # 末尾に追加
@items.delete_at(0) # インデックス指定で削除(0起点)
@items.delete_at(-1) # 末尾を削除
@items.clear # 全削除
@items.insert(0, "バナナ") # インデックス指定で挿入(0起点)
@items[0] = "みかん" # インデックス指定で置換(0起点)
@items[0] # インデックス指定で取得(0起点)
@items.index("りんご") # 検索(0起点のインデックスを返す、見つからない場合は0)
@items.length # 長さ
@items.include?("りんご") # 含むか
@items.empty? # 空か
show_list("@items") # リストの表示
hide_list("@items") # リストの非表示
```

## 5. サポートされていないRuby構文
Expand Down
30 changes: 18 additions & 12 deletions docs/smalruby-language-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -624,20 +624,26 @@ show_variable("@score")
hide_variable("@score")
```

#### Lists
#### Lists (Arrays)

```ruby
list("@items").push("apple") # Add
list("@items").delete_at(1) # Delete
list("@items").clear # Delete all
list("@items").insert(1, "banana") # Insert
list("@items")[1] = "orange" # Replace
list("@items")[1] # Get
list("@items").index("apple") # Search
list("@items").length # Length
list("@items").include?("apple") # Contains?
show_list("@items") # Show list
hide_list("@items") # Hide list
# Initialize with array literal
@items = ["apple", "banana", "cherry"]

# Operations (0-indexed)
@items.push("apple") # Add to end
@items.delete_at(0) # Delete at index (0-indexed)
@items.delete_at(-1) # Delete last
@items.clear # Delete all
@items.insert(0, "banana") # Insert at index (0-indexed)
@items[0] = "orange" # Replace at index (0-indexed)
@items[0] # Get at index (0-indexed)
@items.index("apple") # Search (returns 0-based index, 0 if not found)
@items.length # Length
@items.include?("apple") # Contains?
@items.empty? # Empty?
show_list("@items") # Show list
hide_list("@items") # Hide list
```

## 5. Unsupported Ruby Syntax
Expand Down
38 changes: 28 additions & 10 deletions infra/smalruby-rubytee-relay/lambda/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,9 +316,9 @@ Smalruby is a Ruby subset with methods corresponding to MIT Scratch 3.0 visual p
- \`touching_color?("#rrggbb")\`
- \`distance("_mouse_")\`
- \`ask("question")\` / \`answer\`
- \`Keyboard.pressed?("key")\`
- \`Mouse.down?\` / \`Mouse.x\` / \`Mouse.y\`
- \`Timer.value\` / \`Timer.reset\`
- \`keyboard.pressed?("key")\`
- \`mouse.down?\` / \`mouse.x\` / \`mouse.y\`
- \`timer.value\` / \`timer.reset\`
- \`loudness\`

### Operators
Expand All @@ -334,6 +334,21 @@ 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")\`

### Lists (Arrays)
- Initialize with array literal: \`@items = ["apple", "banana"]\`
- \`@items.push("cherry")\` — add to end
- \`@items.delete_at(0)\` — delete at index (0-indexed)
- \`@items.delete_at(-1)\` — delete last
- \`@items.clear\` — delete all
- \`@items.insert(0, "grape")\` — insert at index (0-indexed)
- \`@items[0] = "orange"\` — replace at index (0-indexed)
- \`@items[0]\` — get at index (0-indexed)
- \`@items.index("apple")\` — search (returns 0-based index, 0 if not found)
- \`@items.length\` — length
- \`@items.include?("apple")\` — contains?
- \`@items.empty?\` — empty?
- \`show_list("@items")\` / \`hide_list("@items")\` — show/hide list monitor

### 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
Expand Down Expand Up @@ -395,10 +410,12 @@ Do NOT use these — they do not exist:
- ❌ \`set_x()\`, \`set_y()\`, \`change_x()\`, \`change_y()\` → ✅ \`self.x =\`, \`self.y =\`, \`self.x +=\`, \`self.y +=\`
- ❌ \`set_direction()\` → ✅ \`self.direction =\`
- ❌ \`set_size()\`, \`change_size()\` → ✅ \`self.size =\`, \`self.size +=\`
- ❌ \`mouse_x\`, \`mouse_y\` → ✅ \`Mouse.x\`, \`Mouse.y\`
- ❌ \`mouse_down?\` → ✅ \`Mouse.down?\`
- ❌ \`key_pressed?()\` → ✅ \`Keyboard.pressed?()\`
- ❌ \`timer\`, \`reset_timer\` → ✅ \`Timer.value\`, \`Timer.reset\`
- ❌ \`mouse_x\`, \`mouse_y\` → ✅ \`mouse.x\`, \`mouse.y\`
- ❌ \`mouse_down?\` → ✅ \`mouse.down?\`
- ❌ \`key_pressed?()\`, \`Keyboard.pressed?()\` → ✅ \`keyboard.pressed?()\`
- ❌ \`timer\`, \`reset_timer\`, \`Timer.value\`, \`Timer.reset\` → ✅ \`timer.value\`, \`timer.reset\`
- ❌ \`Mouse.x\`, \`Mouse.y\`, \`Mouse.down?\` → ✅ \`mouse.x\`, \`mouse.y\`, \`mouse.down?\`
- ❌ \`list("@items")\` → ✅ \`@items\` (use array directly: \`@items.push(...)\`, \`@items[0]\`)
- ❌ \`touching?("_mouse_pointer_")\` → ✅ \`touching?("_mouse_")\`
- ❌ \`play_sound()\`, \`stop_sounds\` → ✅ \`play()\`, \`stop_all_sounds\`
- ❌ \`clear_effects\` → ✅ \`clear_graphic_effects\`
Expand Down Expand Up @@ -586,9 +603,10 @@ When using \`set_sprite\`, only the following sprite names are available:
These are the most common mistakes. **Always verify your output against these rules**:

1. **\`self.x =\` NOT \`set_x()\`**: Outside class definitions, always use \`self.x = value\`, \`self.y = value\`, \`self.size = value\`, etc. The \`set_x()\`, \`set_y()\`, \`set_size()\` methods are ONLY valid at class definition top-level.
2. **\`Keyboard.pressed?\` NOT \`key_pressed?\`**: Always use \`Keyboard.pressed?("key")\`, never \`key_pressed?\`.
3. **\`Mouse.x\` NOT \`mouse_x\`**: Always use \`Mouse.x\`, \`Mouse.y\`, \`Mouse.down?\`.
4. **\`Timer.value\` NOT \`timer\`**: Always use \`Timer.value\` and \`Timer.reset\`.
2. **\`keyboard.pressed?\` NOT \`key_pressed?\`**: Always use \`keyboard.pressed?("key")\`, never \`key_pressed?\` or \`Keyboard.pressed?\`.
3. **\`mouse.x\` NOT \`mouse_x\`**: Always use \`mouse.x\`, \`mouse.y\`, \`mouse.down?\` (lowercase).
4. **\`timer.value\` NOT \`timer\`**: Always use \`timer.value\` and \`timer.reset\` (lowercase).
13. **\`@items.push()\` NOT \`list("@items").push()\`**: Use arrays directly. \`list()\` does not exist.
5. **\`touching?("_mouse_")\` NOT \`touching?("_mouse_pointer_")\`**: The target name is \`"_mouse_"\`, not \`"_mouse_pointer_"\`.
6. **\`glide([x, y], secs: n)\` NOT \`glide(n, x, y)\`**: Coordinates in array, seconds as keyword argument.
7. **\`go_to([x, y])\` NOT \`go_to(x, y)\`**: Coordinates must be in an array.
Expand Down
43 changes: 40 additions & 3 deletions packages/scratch-gui/src/lib/ruby-generator/data-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,24 @@ export default function (Generator) {
return `${hashVarName}["${rawKey}"] = ${Generator.nosToCode(value)}\n`;
}

const index = getListIndex(block);
const list = getListName(block);

// Check for round-trip comments (stored as block comments, not inline)
if (comment && comment.includes('@ruby:array:delete_at:last')) {
return `${list}.delete_at(-1)\n`;
}
if (comment && comment.includes('@ruby:array:delete_at:random')) {
return `${list}.delete_at(rand(0...${list}.length))\n`;
}

const rawIndex = Generator.valueToCode(block, 'INDEX', Generator.ORDER_NONE) || 1;
if (rawIndex === 'last') {
return `${list}.delete_at(-1)\n`;
}
if (rawIndex === 'random') {
return `${list}.delete_at(rand(0...${list}.length))\n`;
}
const index = getListIndex(block);
return `${list}.delete_at(${Generator.nosToCode(index)})\n`;
};

Expand Down Expand Up @@ -233,9 +249,30 @@ export default function (Generator) {
};

Generator.data_insertatlist = function (block) {
const index = getListIndex(block);
const item = Generator.valueToCode(block, 'ITEM', Generator.ORDER_NONE) || '0';
const list = getListName(block);
const comment = Generator.getCommentText(block);

// Check for round-trip comments (stored as block comments, not inline)
if (comment && comment.includes('@ruby:array:insert:last')) {
const item = Generator.valueToCode(block, 'ITEM', Generator.ORDER_NONE) || '0';
return `${list}.push(${Generator.nosToCode(item)})\n`;
}
if (comment && comment.includes('@ruby:array:insert:random')) {
const item = Generator.valueToCode(block, 'ITEM', Generator.ORDER_NONE) || '0';
const randExpr = `rand(0..${list}.length)`;
return `${list}.insert(${randExpr}, ${Generator.nosToCode(item)})\n`;
}

const rawIndex = Generator.valueToCode(block, 'INDEX', Generator.ORDER_NONE) || 1;
const item = Generator.valueToCode(block, 'ITEM', Generator.ORDER_NONE) || '0';
if (rawIndex === 'last') {
return `${list}.push(${Generator.nosToCode(item)})\n`;
}
if (rawIndex === 'random') {
const randExpr = `rand(0..${list}.length)`;
return `${list}.insert(${randExpr}, ${Generator.nosToCode(item)})\n`;
}
const index = getListIndex(block);
return `${list}.insert(${index}, ${Generator.nosToCode(item)})\n`;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,17 @@ export const registerListOperations = function (
const {block: listBlock, converted} = convertToListBlock(converter, messages, receiver);
const recv = converted ? listBlock : receiver;
if (!recv) return null;

// Detect delete_at(-1) as "last" special value
if (converter._isNumber(args[0]) && Number(args[0]) === -1) {
const block = converter._changeBlock(recv, 'data_deleteoflist', 'statement');
converter._addNumberInput(block, 'INDEX', 'math_integer', 1, 1);
block.comment = converter._createComment(
'@ruby:array:delete_at:last', block.id
);
return block;
}

const index = adjustIndex(converter, args[0], converted);

const block = converter._changeBlock(recv, 'data_deleteoflist', 'statement');
Expand Down
68 changes: 68 additions & 0 deletions packages/scratch-gui/test/unit/lib/ruby-generator/data.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -351,4 +351,72 @@ describe('RubyGenerator/Data', () => {
.toEqual('');
});
});

describe('delete_at / insert special values', () => {
beforeEach(() => {
RubyGenerator.listName = jest.fn().mockReturnValue('@my_list');
RubyGenerator.getFieldId = jest.fn().mockReturnValue('list-id');
RubyGenerator.nosToCode = jest.fn(v => v);
});

test('data_deleteoflist with INDEX "last" generates delete_at(-1)', () => {
const block = {
id: 'block-id',
opcode: 'data_deleteoflist',
fields: {LIST: {id: 'list-id', value: 'my list'}},
inputs: {INDEX: {block: 'index-block-id'}}
};
RubyGenerator.valueToCode = jest.fn().mockReturnValue('last');
RubyGenerator.getBlock = jest.fn().mockReturnValue(null);
expect(RubyGenerator.data_deleteoflist(block))
.toEqual('@my_list.delete_at(-1)\n');
});

test('data_deleteoflist with INDEX "random" generates delete_at(rand(...))', () => {
const block = {
id: 'block-id',
opcode: 'data_deleteoflist',
fields: {LIST: {id: 'list-id', value: 'my list'}},
inputs: {INDEX: {block: 'index-block-id'}}
};
RubyGenerator.valueToCode = jest.fn().mockReturnValue('random');
RubyGenerator.getBlock = jest.fn().mockReturnValue(null);
expect(RubyGenerator.data_deleteoflist(block))
.toEqual('@my_list.delete_at(rand(0...@my_list.length))\n');
});

test('data_insertatlist with INDEX "last" generates push', () => {
const block = {
id: 'block-id',
opcode: 'data_insertatlist',
fields: {LIST: {id: 'list-id', value: 'my list'}},
inputs: {
INDEX: {block: 'index-block-id'},
ITEM: {block: 'item-block-id'}
}
};
RubyGenerator.valueToCode = jest.fn()
.mockReturnValueOnce('last')
.mockReturnValueOnce('"thing"');
expect(RubyGenerator.data_insertatlist(block))
.toEqual('@my_list.push("thing")\n');
});

test('data_insertatlist with INDEX "random" generates insert(rand(...))', () => {
const block = {
id: 'block-id',
opcode: 'data_insertatlist',
fields: {LIST: {id: 'list-id', value: 'my list'}},
inputs: {
INDEX: {block: 'index-block-id'},
ITEM: {block: 'item-block-id'}
}
};
RubyGenerator.valueToCode = jest.fn()
.mockReturnValueOnce('random')
.mockReturnValueOnce('"thing"');
expect(RubyGenerator.data_insertatlist(block))
.toEqual('@my_list.insert(rand(0..@my_list.length), "thing")\n');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,19 @@ end
await expectRoundTrip('@items[0]');
await expectRoundTrip('@items.length');
});

test('delete_at(-1) round-trips as last', async () => {
await expectRoundTrip('$a.delete_at(-1)');
});

test('empty? method', async () => {
await expectRoundTrip('$a.empty?');
});

test('show_list and hide_list', async () => {
await expectRoundTrip('show_list("@items")');
await expectRoundTrip('hide_list("@items")');
});
});

test('symbol .to_s round-trip', async () => {
Expand Down
Loading
Loading