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
1 change: 1 addition & 0 deletions .claude/rules/scratch-gui/smalruby-prettier-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ upstream (Scratch) ファイルは対象外。
- `test/unit/lib/removed-trademarks.test.js`
- `test/unit/lib/ruby-generator-procedure-arguments.test.js`
- `test/unit/lib/ruby-generator-version.test.jsx`
- `test/unit/lib/ruby-roundtrip-class-attr-accessor.test.js`
- `test/unit/lib/ruby-roundtrip-class-assets.test.js`
- `test/unit/lib/ruby-roundtrip-class-stage.test.js`
- `test/unit/lib/ruby-roundtrip-class-superclass.test.js`
Expand Down
1 change: 1 addition & 0 deletions packages/scratch-gui/.prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ test/unit/lib/*
!test/unit/lib/removed-trademarks.test.js
!test/unit/lib/ruby-generator-procedure-arguments.test.js
!test/unit/lib/ruby-generator-version.test.jsx
!test/unit/lib/ruby-roundtrip-class-attr-accessor.test.js
!test/unit/lib/ruby-roundtrip-class-assets.test.js
!test/unit/lib/ruby-roundtrip-class-stage.test.js
!test/unit/lib/ruby-roundtrip-class-superclass.test.js
Expand Down
36 changes: 35 additions & 1 deletion packages/scratch-gui/src/lib/ruby-generator/class-wrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,37 @@ export default function (Generator) {
includeCode = includeNames.map(name => `${this.INDENT}include ${name}\n`).join('');
}

// Generate attr_accessor/reader/writer statements
let attrAccessorCode = '';
const attrLines = [];
for (let i = allowedAttributes.length - 1; i >= 0; i--) {
const attrMatch = allowedAttributes[i].match(/^attr_(accessor|reader|writer)=(.+)$/);
if (attrMatch) {
const kind = attrMatch[1];
const names = attrMatch[2].split('+');
const syms = names.map(n => `:${n}`).join(', ');
attrLines.push(`attr_${kind} ${syms}`);
allowedAttributes.splice(i, 1);
}
}
if (attrLines.length > 0) {
attrAccessorCode = attrLines
.map(line => `${this.INDENT}${line}\n`)
.join('');
}

// Store attr accessor info for variable name resolution
this._attrAccessorNames = new Set();
for (const line of attrLines) {
const match = line.match(/^attr_(?:accessor|reader|writer)\s+(.+)$/);
if (match) {
match[1].split(',').forEach(s => {
const name = s.trim().replace(/^:/, '');
this._attrAccessorNames.add(name);
});
}
}

let outsideCode = '';
if (forFileOutput && code.length > 0) {
// Split code into top-level sections (separated by blank lines)
Expand Down Expand Up @@ -178,7 +209,7 @@ export default function (Generator) {
code = this.prefixLines(code, this.INDENT);
}
// Build the inner class content with separators
const innerParts = [setCode, initCode, includeCode, code].filter(p => p.length > 0);
const innerParts = [setCode, initCode, includeCode, attrAccessorCode, code].filter(p => p.length > 0);
const innerCode = innerParts.join('\n');
let inheritance = '';
if (superclassPath) {
Expand Down Expand Up @@ -254,6 +285,9 @@ export default function (Generator) {
if (RETURN_PATTERN.test(variable.name)) continue;
if (LOCAL_PATTERN.test(variable.name)) continue;

// Skip attr_accessor variables (managed by accessor, not initialize)
if (this._attrAccessorNames && this._attrAccessorNames.has(variable.name)) continue;

const isList = variable.type === 'list';
let valueCode;
if (isList) {
Expand Down
17 changes: 17 additions & 0 deletions packages/scratch-gui/src/lib/ruby-generator/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ export default function (Generator) {
variable = `@${variable}`;
}
}
// === Smalruby: Start of attr_accessor getter ===
// If the variable is an instance variable with a known accessor, output without @
if (variable.startsWith('@') && Generator._attrAccessorNames) {
const baseName = variable.slice(1);
if (Generator._attrAccessorNames.has(baseName)) {
return [baseName, Generator.ORDER_ATOMIC];
}
}
// === Smalruby: End of attr_accessor getter ===
return [variable, Generator.ORDER_ATOMIC];
};

Expand Down Expand Up @@ -148,6 +157,14 @@ export default function (Generator) {
}

const value = Generator.valueToCode(block, 'VALUE', Generator.ORDER_NONE) || '0';
// === Smalruby: Start of attr_accessor setter ===
if (variable.startsWith('@') && Generator._attrAccessorNames) {
const baseName = variable.slice(1);
if (Generator._attrAccessorNames.has(baseName)) {
return `self.${baseName} = ${Generator.nosToCode(value)}\n`;
}
}
// === Smalruby: End of attr_accessor setter ===
return `${variable} = ${Generator.nosToCode(value)}\n`;
};

Expand Down
22 changes: 22 additions & 0 deletions packages/scratch-gui/src/lib/ruby-generator/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,28 @@ RubyGenerator.init = function (options) {
} else {
this.variableDB_ = new Blockly.Names(RubyGenerator.RESERVED_WORDS_);
}

// === Smalruby: Start of attr_accessor early detection ===
// Parse attr_accessor names from @ruby:class comment before code generation
// so that data_variable and data_setvariableto can output accessor syntax.
this._attrAccessorNames = new Set();
if (this.currentTarget_) {
const commentTexts = this.cache_.targetCommentTexts || [];
for (const text of commentTexts) {
if (text && text.startsWith('@ruby:class:')) {
const parts = text.slice('@ruby:class:'.length).split(',');
for (const part of parts) {
const match = part.match(/^attr_(?:accessor|reader|writer)=(.+)$/);
if (match) {
match[1].split('+').forEach(name => {
this._attrAccessorNames.add(name);
});
}
}
}
}
}
// === Smalruby: End of attr_accessor early detection ===
};

RubyGenerator.spriteName = function () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,49 @@ const ExpressionHandlers = {
...ExpressionsLiterals,

visitCallNode (node) {
// === Smalruby: Start of attr_accessor getter/setter resolution ===
const attrAccessors = this._context.attrAccessors;
if (attrAccessors) {
const recvType = node.receiver ? this._getNodeTypeName(node.receiver) : null;
const isSelfOrNone = !node.receiver || recvType === 'SelfNode';

// Getter: foo or self.foo (no args, no block)
if (isSelfOrNone &&
(!node.arguments_ || node.arguments_.arguments_.length === 0) &&
!node.block) {
const attrKind = attrAccessors[node.name];
if (attrKind === 'accessor' || attrKind === 'reader') {
return this._onVar(`@${node.name}`, 'instance', node);
}
}

// Setter: self.foo = val (name ends with =, 1 arg)
if (node.name.endsWith('=') &&
recvType === 'SelfNode' &&
node.arguments_ && node.arguments_.arguments_.length === 1) {
const baseName = node.name.slice(0, -1);
const attrKind = attrAccessors[baseName];
if (attrKind === 'accessor' || attrKind === 'writer') {
const variable = this._lookupOrCreateVariable(`@${baseName}`);
const savedIsValue = this._context.isValue;
this._context.isValue = true;
let rh = this.visit(node.arguments_.arguments_[0]);
this._context.isValue = savedIsValue;
const s = this._splitPreBlocksAndValue(rh);
rh = s.value;
const preBlks = s.preBlocks;
const block = this._callConvertersHandler('onVasgn', 'instance', variable, rh);
if (block) {
if (preBlks.length > 0) {
return [...preBlks, ...(_.isArray(block) ? block : [block])];
}
return block;
}
}
}
}
// === Smalruby: End of attr_accessor getter/setter resolution ===

const saved = this._saveContext();

const preBlocks = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,37 @@ const ClassVisitor = {
];
const ATTR_ORDER = isStageClass ? STAGE_ATTR_ORDER : SPRITE_ATTR_ORDER;

// Pre-scan class body for attr_accessor/attr_reader/attr_writer
// { varName: 'accessor' | 'reader' | 'writer' }
const attrAccessors = {};
const attrStatements = new Set();
if (node.body && node.body.body) {
for (const stmt of node.body.body) {
if (this._getNodeTypeName(stmt) === 'CallNode' &&
!stmt.receiver &&
(stmt.name === 'attr_accessor' ||
stmt.name === 'attr_reader' ||
stmt.name === 'attr_writer') &&
stmt.arguments_ &&
stmt.arguments_.arguments_.length >= 1) {

const kind = stmt.name.replace('attr_', '');
for (const argNode of stmt.arguments_.arguments_) {
if (this._getNodeTypeName(argNode) === 'SymbolNode') {
const unescaped = argNode.unescaped;
const symName = typeof unescaped === 'object' ? unescaped.value : unescaped;
attrAccessors[symName] = kind;
// Create instance variable
this._lookupOrCreateVariable(`@${symName}`);
}
}
attrStatements.add(stmt);
}
}
}
// Store attr info in context for getter/setter resolution
this._context.attrAccessors = attrAccessors;

// Pre-scan class body for set_xxx calls
const classInfo = {};
const setMethodNames = new Set();
Expand Down Expand Up @@ -299,6 +330,17 @@ const ClassVisitor = {
}
});
}
// Add attr_accessor/reader/writer parts
const attrByKind = { accessor: [], reader: [], writer: [] };
for (const [name, kind] of Object.entries(attrAccessors)) {
attrByKind[kind].push(name);
}
for (const [kind, names] of Object.entries(attrByKind)) {
if (names.length > 0) {
commentParts.push(`attr_${kind}=${names.join('+')}`);
}
}

// Add include= parts for each included module (in order)
includedModuleNames.forEach(moduleName => {
commentParts.push(`include=${moduleName}`);
Expand Down Expand Up @@ -385,6 +427,10 @@ const ClassVisitor = {
if (includeStatements.has(stmt)) {
return false;
}
// Filter out attr_accessor/reader/writer (processed in pre-scan)
if (attrStatements.has(stmt)) {
return false;
}
// Filter out def initialize (already processed above)
if (initializeNodes.has(stmt)) {
return false;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import dedent from 'dedent';
import RubyGenerator from '../../../src/lib/ruby-generator';
import { makeSpriteTarget, makeConverter, setupRubyGenerator } from '../helpers/ruby-roundtrip-helper';

/**
* Round trip: Ruby → Blocks → apply → Ruby (version 2, class syntax with attr_accessor)
*/
const classRoundTrip = async (converter, target, code, options = {}) => {
const result = await converter.targetCodeToBlocks(target, code);
if (!result) {
throw new Error(
`Failed to convert Ruby to blocks.\nErrors: ${JSON.stringify(converter.errors)}\nCode:\n${code}`,
);
}
await converter.applyTargetBlocks(target);
RubyGenerator.currentTarget = target;
return RubyGenerator.targetToCode(target, {
version: '2',
...options,
}).trim();
};

describe('Ruby Roundtrip: attr_accessor', () => {
let target, runtime, converter;

beforeEach(() => {
({ target, runtime } = makeSpriteTarget());
target.sprite = { name: 'Sprite1', costumes: [], sounds: [] };
runtime.targets = [runtime.getTargetForStage(), target];
setupRubyGenerator();
converter = makeConverter(target, runtime, { version: '2' });
});

test('attr_accessor with getter and setter', async () => {
const input = dedent`
class Sprite1
attr_accessor :hp

when_flag_clicked do
self.hp = 100
say(hp)
end
end
`;
const result = await classRoundTrip(converter, target, input);
expect(result).toBe(input.trim());
});

test('attr_reader with getter only', async () => {
const input = dedent`
class Sprite1
attr_reader :name

when_flag_clicked do
say(name)
end
end
`;
const result = await classRoundTrip(converter, target, input);
expect(result).toBe(input.trim());
});

test('attr_accessor with multiple symbols', async () => {
const input = dedent`
class Sprite1
attr_accessor :hp, :name

when_flag_clicked do
self.hp = 100
self.name = "hero"
say(hp)
end
end
`;
const result = await classRoundTrip(converter, target, input);
expect(result).toBe(input.trim());
});

test('self.foo getter', async () => {
const input = dedent`
class Sprite1
attr_accessor :hp

when_flag_clicked do
self.hp = 50
say(self.hp)
end
end
`;
const result = await classRoundTrip(converter, target, input);
// self.hp reads as @hp, roundtrip outputs as hp (without self)
const expected = dedent`
class Sprite1
attr_accessor :hp

when_flag_clicked do
self.hp = 50
say(hp)
end
end
`;
expect(result).toBe(expected.trim());
});
});
Loading