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
80 changes: 56 additions & 24 deletions packages/scratch-gui/src/lib/ruby-generator/data-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,34 @@ export default function (Generator) {
return `${index} - 1`;
};

const isV2 = () => String(Generator.version) === '2';

/**
* Render a list reference respecting the Ruby version.
* - v1: `list("$name")` (Smalruby3::List wrapper). The v1 converter rejects
* plain array syntax `$name[...]`, so list ops must use this wrapper form.
* - v2: `$name` (plain global/instance array variable; current behavior).
* @param {string} list - The resolved list name (e.g. "$名前").
* @returns {string} The list expression as written in source.
*/
const renderList = function (list) {
return isV2() ? list : `list(${Generator.quote_(list)})`;
};

/**
* Get the element index respecting the Ruby version.
* - v1: keep the raw 1-indexed Scratch value (`list("$a")[1]` style).
* - v2: convert to 0-indexed Ruby array index via getListIndex (`$a[0]`).
* @param {object} block - The block containing the INDEX input.
* @returns {(string|number)} The index expression.
*/
const getVersionedIndex = function (block) {
if (isV2()) {
return getListIndex(block);
}
return Generator.valueToCode(block, 'INDEX', Generator.ORDER_NONE) || 1;
};

/**
* Get the raw text value from a block's text input.
* @param {object} block - The block containing the input.
Expand Down Expand Up @@ -110,7 +138,7 @@ export default function (Generator) {

const item = Generator.valueToCode(block, 'ITEM', Generator.ORDER_NONE) || '0';
const list = getListName(block);
return `${list}.push(${Generator.nosToCode(item)})\n`;
return `${renderList(list)}.push(${Generator.nosToCode(item)})\n`;
};

Generator.data_deleteoflist = function (block) {
Expand Down Expand Up @@ -167,24 +195,25 @@ export default function (Generator) {
}

const list = getListName(block);
const listExpr = renderList(list);

// 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`;
return `${listExpr}.delete_at(-1)\n`;
}
if (comment && comment.includes('@ruby:array:delete_at:random')) {
return `${list}.delete_at(rand(0...${list}.length))\n`;
return `${listExpr}.delete_at(rand(0...${listExpr}.length))\n`;
}

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

Generator.data_deletealloflist = function (block) {
Expand Down Expand Up @@ -267,42 +296,43 @@ export default function (Generator) {
return `${hashVarName} = {${entries.join(', ')}}\n`;
}

return `${list}.clear\n`;
return `${renderList(list)}.clear\n`;
};

Generator.data_insertatlist = function (block) {
const list = getListName(block);
const listExpr = renderList(list);
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`;
return `${listExpr}.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 randExpr = `rand(0..${listExpr}.length)`;
return `${listExpr}.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`;
return `${listExpr}.push(${Generator.nosToCode(item)})\n`;
}
if (rawIndex === 'random') {
const randExpr = `rand(0..${list}.length)`;
return `${list}.insert(${randExpr}, ${Generator.nosToCode(item)})\n`;
const randExpr = `rand(0..${listExpr}.length)`;
return `${listExpr}.insert(${randExpr}, ${Generator.nosToCode(item)})\n`;
}
const index = getListIndex(block);
return `${list}.insert(${index}, ${Generator.nosToCode(item)})\n`;
const index = getVersionedIndex(block);
return `${listExpr}.insert(${index}, ${Generator.nosToCode(item)})\n`;
};

Generator.data_replaceitemoflist = function (block) {
const index = getListIndex(block);
const index = getVersionedIndex(block);
const item = Generator.valueToCode(block, 'ITEM', Generator.ORDER_NONE) || '0';
const list = getListName(block);
return `${list}[${index}] = ${Generator.nosToCode(item)}\n`;
return `${renderList(list)}[${index}] = ${Generator.nosToCode(item)}\n`;
};

Generator.data_itemoflist = function (block) {
Expand Down Expand Up @@ -344,9 +374,9 @@ export default function (Generator) {
}
}

const index = getListIndex(block);
const index = getVersionedIndex(block);
const list = getListName(block);
return [`${list}[${index}]`, Generator.ORDER_FUNCTION_CALL];
return [`${renderList(list)}[${index}]`, Generator.ORDER_FUNCTION_CALL];
};

Generator.data_itemnumoflist = function (block) {
Expand All @@ -357,25 +387,27 @@ export default function (Generator) {
}
const item = Generator.valueToCode(block, 'ITEM', Generator.ORDER_NONE) || '0';
const list = getListName(block);
return [`${list}.index(${Generator.nosToCode(item)})`, Generator.ORDER_FUNCTION_CALL];
return [`${renderList(list)}.index(${Generator.nosToCode(item)})`, Generator.ORDER_FUNCTION_CALL];
};

Generator.data_lengthoflist = function (block) {
const list = getListName(block);
const comment = Generator.getCommentText(block);
if (comment && comment.startsWith('@ruby:method:empty?:')) {
const index = comment.substring(20);
Generator.emptyCallCache_[index] = list;
// Store the version-aware list expression so operator_equals renders
// list("$a").empty? in v1 and $a.empty? in v2.
Generator.emptyCallCache_[index] = renderList(list);
return [`@ruby:method:empty?:${index}`, Generator.ORDER_FUNCTION_CALL];
}
return [`${list}.length`, Generator.ORDER_FUNCTION_CALL];
return [`${renderList(list)}.length`, Generator.ORDER_FUNCTION_CALL];
};

Generator.data_listcontainsitem = function (block) {
const order = Generator.ORDER_FUNCTION_CALL;
const item = Generator.valueToCode(block, 'ITEM', order) || '0';
const list = getListName(block);
return [`${list}.include?(${Generator.nosToCode(item)})`, order];
return [`${renderList(list)}.include?(${Generator.nosToCode(item)})`, order];
};

Generator.data_showlist = function (block) {
Expand Down
7 changes: 6 additions & 1 deletion packages/scratch-gui/src/lib/ruby-generator/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,12 @@ export default function (Generator) {
}
}
const list = Generator.listName(Generator.getFieldId(block, 'LIST'));
return [list, Generator.ORDER_COLLECTION];
// v1 uses the `list("$name")` wrapper (Smalruby3::List); v2 uses the
// plain global/instance array variable `$name`.
if (String(Generator.version) === '2') {
return [list, Generator.ORDER_COLLECTION];
}
return [`list(${Generator.quote_(list)})`, Generator.ORDER_COLLECTION];
};

// Register list operation generators
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,13 @@ export const rubyToBlocksToRuby = async (converter, target, code, options = {})
}
await converter.applyTargetBlocks(target);
RubyGenerator.currentTarget = target;
return RubyGenerator.targetToCode(target, options).trim();
// Keep the generator's Ruby version aligned with the converter's by default,
// mirroring production where both share one version setting. Tests that set
// the converter to v2 (makeConverter(..., {version: 2})) then get v2 output
// without having to also pass {version} to expectRoundTrip. Explicit
// options.version still wins.
const genOptions = {version: converter.version, ...options};
return RubyGenerator.targetToCode(target, genOptions).trim();
};

/**
Expand Down
100 changes: 100 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 @@ -10,6 +10,9 @@ describe('RubyGenerator/Data', () => {
RubyGenerator.functionNames_ = {};
RubyGenerator.emptyCallCache_ = {};
RubyGenerator.currentTarget = null;
// Default to v2 (plain $name array syntax) for the list tests below.
// The v1 list("$name") wrapper form is covered in its own describe block.
RubyGenerator.version = '2';
DataBlocks(RubyGenerator);
});

Expand Down Expand Up @@ -419,4 +422,101 @@ describe('RubyGenerator/Data', () => {
.toEqual('@my_list.insert(rand(0..@my_list.length), "thing")\n');
});
});

// #839: in v1, list operations must emit the list("$name") wrapper form with
// 1-indexed element access. The plain array syntax $name[...] (0-indexed) is
// v2-only and is rejected by the v1 Ruby -> Blocks converter.
describe('list operations - list() syntax (v1)', () => {
const makeListBlock = (opcode, extraInputs = {}) => ({
id: 'block-id',
opcode: opcode,
fields: {LIST: {id: 'list-id', value: 'my list'}},
inputs: extraInputs
});

beforeEach(() => {
RubyGenerator.version = '1';
RubyGenerator.listName = jest.fn().mockReturnValue('$my_list');
RubyGenerator.getFieldId = jest.fn().mockReturnValue('list-id');
RubyGenerator.quote_ = jest.fn(v => `"${v}"`);
RubyGenerator.nosToCode = jest.fn(v => v);
});

test('data_listcontents returns list() wrapper', () => {
const block = makeListBlock('data_listcontents');
expect(RubyGenerator.data_listcontents(block))
.toEqual(['list("$my_list")', RubyGenerator.ORDER_COLLECTION]);
});

test('data_addtolist generates list().push', () => {
const block = makeListBlock('data_addtolist', {ITEM: {block: 'item-block-id'}});
RubyGenerator.valueToCode = jest.fn().mockReturnValue('"thing"');
expect(RubyGenerator.data_addtolist(block))
.toEqual('list("$my_list").push("thing")\n');
});

test('data_deleteoflist keeps 1-indexed inside list()', () => {
const block = makeListBlock('data_deleteoflist', {INDEX: {block: 'index-block-id'}});
RubyGenerator.valueToCode = jest.fn().mockReturnValue('1');
expect(RubyGenerator.data_deleteoflist(block))
.toEqual('list("$my_list").delete_at(1)\n');
});

test('data_deletealloflist generates list().clear', () => {
const block = makeListBlock('data_deletealloflist');
expect(RubyGenerator.data_deletealloflist(block))
.toEqual('list("$my_list").clear\n');
});

test('data_insertatlist keeps 1-indexed inside list()', () => {
const block = makeListBlock('data_insertatlist', {
INDEX: {block: 'index-block-id'},
ITEM: {block: 'item-block-id'}
});
RubyGenerator.valueToCode = jest.fn()
.mockReturnValueOnce('1')
.mockReturnValueOnce('"thing"');
expect(RubyGenerator.data_insertatlist(block))
.toEqual('list("$my_list").insert(1, "thing")\n');
});

test('data_replaceitemoflist keeps 1-indexed inside list()', () => {
const block = makeListBlock('data_replaceitemoflist', {
INDEX: {block: 'index-block-id'},
ITEM: {block: 'item-block-id'}
});
RubyGenerator.valueToCode = jest.fn()
.mockReturnValueOnce('1')
.mockReturnValueOnce('"thing"');
expect(RubyGenerator.data_replaceitemoflist(block))
.toEqual('list("$my_list")[1] = "thing"\n');
});

test('data_itemoflist keeps 1-indexed inside list()', () => {
const block = makeListBlock('data_itemoflist', {INDEX: {block: 'index-block-id'}});
RubyGenerator.valueToCode = jest.fn().mockReturnValue('2');
expect(RubyGenerator.data_itemoflist(block))
.toEqual(['list("$my_list")[2]', RubyGenerator.ORDER_FUNCTION_CALL]);
});

test('data_itemnumoflist generates list().index', () => {
const block = makeListBlock('data_itemnumoflist', {ITEM: {block: 'item-block-id'}});
RubyGenerator.valueToCode = jest.fn().mockReturnValue('"thing"');
expect(RubyGenerator.data_itemnumoflist(block))
.toEqual(['list("$my_list").index("thing")', RubyGenerator.ORDER_FUNCTION_CALL]);
});

test('data_lengthoflist generates list().length', () => {
const block = makeListBlock('data_lengthoflist');
expect(RubyGenerator.data_lengthoflist(block))
.toEqual(['list("$my_list").length', RubyGenerator.ORDER_FUNCTION_CALL]);
});

test('data_listcontainsitem generates list().include?', () => {
const block = makeListBlock('data_listcontainsitem', {ITEM: {block: 'item-block-id'}});
RubyGenerator.valueToCode = jest.fn().mockReturnValue('"thing"');
expect(RubyGenerator.data_listcontainsitem(block))
.toEqual(['list("$my_list").include?("thing")', RubyGenerator.ORDER_FUNCTION_CALL]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,8 @@ describe('Ruby Roundtrip: Book code compatibility (v1)', () => {
// --- Lists / Variables ---

test('Program 6: prefecture quiz with list()', async () => {
// v1 known incompatibility: list("$xxx")[index] is accepted by the converter
// but the generator outputs $xxx[index - 1] (0-based array access).
// TODO: #363 - make v1 generator preserve list() function format
// v1 preserves the list("$xxx")[index] function format (1-indexed) on
// round-trip; the plain array syntax $xxx[index] is v2-only (#839 / #363).
await expectRoundTrip(converter, target, dedent`
self.when(:flag_clicked) do
loop do
Expand All @@ -121,19 +120,6 @@ describe('Ruby Roundtrip: Book code compatibility (v1)', () => {
wait
end
end
`, dedent`
self.when(:flag_clicked) do
loop do
$えらんだけんめい = rand(1..18)
ask($けんめい[$えらんだけんのばんごう - 1] + "のけんちょうしょざいちのけんはどこかな")
if answer == $けんちょうしょざいち[$えらんだけんのばんごう - 1]
say("正解!", 2)
else
say($けんちょうしょざいち[$えらんだけんのばんごう - 1] + "がせいかいだよ", 2)
end
wait
end
end
`);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,24 @@ describe('Ruby Roundtrip (v1): Koshien stays flat (backward compatible)', () =>
{version: 1}
);
});

// #839: v1 list element read must round-trip using list("$name")[1-indexed],
// not the v2 array syntax $name[0-indexed] (which the v1 converter rejects).
test('list element read round-trips with list() syntax (1-indexed)', async () => {
const {target, runtime} = makeSpriteTarget();
setupRubyGenerator();
const converter = makeConverter(target, runtime, {version: 1});
await expectRoundTrip(
converter,
target,
dedent`
koshien.connect_game(name: "player1")
koshien.calc_route(result: list("$最短経路"))
koshien.move_to(list("$最短経路")[2])
koshien.turn_over
`,
null,
{version: 1}
);
});
});
Loading
Loading