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
21 changes: 20 additions & 1 deletion packages/scratch-gui/src/lib/ruby-generator/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,24 @@ RubyGenerator._wrapWithClass = function (code, classComment, forFileOutput) {
// Support name=ClassName format for preserving class names
let allowedAttributes = [];
let explicitClassName = null;
let superclassPath = null;
if (classComment.startsWith('@ruby:class:')) {
const attrPart = classComment.slice('@ruby:class:'.length);
allowedAttributes = attrPart.split(',');

// Check for <=superclass in the attributes
const superAttrIndex = allowedAttributes.findIndex(a => a.startsWith('<='));
if (superAttrIndex >= 0) {
const encoded = allowedAttributes[superAttrIndex].slice(2);
// Decode: leading // → ::, then / → ::
if (encoded.startsWith('//')) {
superclassPath = `::${encoded.slice(2).replace(/\//g, '::')}`;
} else {
superclassPath = encoded.replace(/\//g, '::');
}
allowedAttributes.splice(superAttrIndex, 1);
}

// Check for name=ClassName in the attributes
const nameAttrIndex = allowedAttributes.findIndex(a => a.startsWith('name='));
if (nameAttrIndex >= 0) {
Expand Down Expand Up @@ -324,7 +338,12 @@ RubyGenerator._wrapWithClass = function (code, classComment, forFileOutput) {
code = this.prefixLines(code, this.INDENT);
}
const separator = setCode.length > 0 && code.length > 0 ? '\n' : '';
const inheritance = forFileOutput ? ' < ::Smalruby3::Sprite' : '';
let inheritance = '';
if (superclassPath) {
inheritance = ` < ${superclassPath}`;
} else if (forFileOutput) {
inheritance = ' < ::Smalruby3::Sprite';
}
code = `class ${className}${inheritance}\n${setCode}${separator}${code}end\n`;

if (outsideCode.length > 0) {
Expand Down
70 changes: 63 additions & 7 deletions packages/scratch-gui/src/lib/ruby-to-blocks-converter/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ const messages = defineMessages({
'\nPlease switch to Ruby version 2 from the settings menu.',
description: 'Error message when class syntax is used in Ruby version 1',
id: 'gui.smalruby3.rubyToBlocksConverter.classNotSupportedInV1'
},
invalidStageSuperclass: {
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'
}
});

Expand Down Expand Up @@ -383,6 +388,28 @@ class RubyToBlocksConverter extends Visitor {
return handlerName ? handlerName.slice('visit'.length) : null;
}

/**
* Convert a ConstantReadNode or ConstantPathNode to its full path string.
* e.g. ConstantReadNode "Foo" -> "Foo"
* e.g. ConstantPathNode(ConstantPathNode(null, "Smalruby3"), "Sprite") -> "::Smalruby3::Sprite"
* @param {object} node - A prism constant node
* @returns {string} The full constant path
*/
_constantNodeToPath (node) {
const typeName = this._getNodeTypeName(node);
if (typeName === 'ConstantReadNode') {
return node.name;
}
if (typeName === 'ConstantPathNode') {
if (node.parent) {
return `${this._constantNodeToPath(node.parent)}::${node.name}`;
}
// Leading :: (root scope)
return `::${node.name}`;
}
return String(node.name || '');
}

visitProgramNode (node) {
return this.visit(node.statements);
}
Expand All @@ -400,7 +427,23 @@ class RubyToBlocksConverter extends Visitor {
const isSpriteIndexName = /^Sprite\d+$/.test(className);
const isStageClass = className === 'Stage';

// Accept optional superclass `< ::Smalruby3::Sprite` (ignored, purely for readability)
// Extract superclass path (e.g. "::Smalruby3::Sprite", "Foo")
let superclassPath = null;
if (node.superclass) {
superclassPath = this._constantNodeToPath(node.superclass);
}

// Stage only accepts no superclass, ::Smalruby3::Stage, or Smalruby3::Stage
if (isStageClass && superclassPath !== null) {
if (superclassPath !== '::Smalruby3::Stage' && superclassPath !== 'Smalruby3::Stage') {
throw new RubyToBlocksConverterError(
node.superclass,
this._translator(messages.invalidStageSuperclass)
);
}
// Accepted Stage superclass — don't store it (Stage is always Stage)
superclassPath = null;
}

// Set of recognized set_xxx class methods (sprite-specific)
const SPRITE_SET_METHODS = {
Expand Down Expand Up @@ -546,17 +589,30 @@ class RubyToBlocksConverter extends Visitor {

// Generate comment text
// For non-Sprite\d+ class names (excluding Stage), use name=ClassName format to preserve the class name
// Superclass is encoded as <=path with :: replaced by /
let commentText;
const commentParts = [];
if (superclassPath) {
let encodedSuperclass;
if (superclassPath.startsWith('::')) {
encodedSuperclass = `//${superclassPath.slice(2).replace(/::/g, '/')}`;
} else {
encodedSuperclass = superclassPath.replace(/::/g, '/');
}
commentParts.push(`<=${encodedSuperclass}`);
}
if (attributeNames.length > 0) {
const commentParts = attributeNames.map(attr => {
attributeNames.forEach(attr => {
if (attr === 'name' && !isSpriteIndexName && !isStageClass) {
return `name=${className}`;
}
if (attr === 'sprite') {
return `sprite=${classInfo.sprite}`;
commentParts.push(`name=${className}`);
} else if (attr === 'sprite') {
commentParts.push(`sprite=${classInfo.sprite}`);
} else {
commentParts.push(attr);
}
return attr;
});
}
if (commentParts.length > 0) {
commentText = `@ruby:class:${commentParts.join(',')}`;
} else {
commentText = '@ruby:class';
Expand Down
157 changes: 157 additions & 0 deletions packages/scratch-gui/test/unit/lib/ruby-generator/class.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -906,4 +906,161 @@ describe('RubyGenerator/Class', () => {
expect(result).not.toContain('Sprite.new');
});
});

describe('superclass inheritance from <=', () => {
test('<=//Smalruby3/Sprite outputs < ::Smalruby3::Sprite (non-file output)', () => {
const {target, runtime} = makeMockTarget('Sprite1', 1);
target.runtime = runtime;
RubyGenerator.currentTarget_ = target;
RubyGenerator.cache_.targetCommentTexts = ['@ruby:class:<=//Smalruby3/Sprite'];

const code = 'self.when(:flag_clicked) do\n move(10)\nend\n';
const result = RubyGenerator.finish(code, {});

expect(result).toContain('class Sprite1 < ::Smalruby3::Sprite');
});

test('<=Smalruby3/Sprite outputs < Smalruby3::Sprite (non-file output)', () => {
const {target, runtime} = makeMockTarget('Sprite1', 1);
target.runtime = runtime;
RubyGenerator.currentTarget_ = target;
RubyGenerator.cache_.targetCommentTexts = ['@ruby:class:<=Smalruby3/Sprite'];

const code = 'self.when(:flag_clicked) do\n move(10)\nend\n';
const result = RubyGenerator.finish(code, {});

expect(result).toContain('class Sprite1 < Smalruby3::Sprite');
});

test('<=Foo outputs < Foo (non-file output)', () => {
const {target, runtime} = makeMockTarget('Sprite1', 1);
target.runtime = runtime;
RubyGenerator.currentTarget_ = target;
RubyGenerator.cache_.targetCommentTexts = ['@ruby:class:<=Foo'];

const code = 'self.when(:flag_clicked) do\n move(10)\nend\n';
const result = RubyGenerator.finish(code, {});

expect(result).toContain('class Sprite1 < Foo');
});

test('<=Sprite outputs < Sprite (non-file output)', () => {
const {target, runtime} = makeMockTarget('Sprite1', 1);
target.runtime = runtime;
RubyGenerator.currentTarget_ = target;
RubyGenerator.cache_.targetCommentTexts = ['@ruby:class:<=Sprite'];

const code = 'self.when(:flag_clicked) do\n move(10)\nend\n';
const result = RubyGenerator.finish(code, {});

expect(result).toContain('class Sprite1 < Sprite');
});

test('no <= omits superclass (non-file output)', () => {
const {target, runtime} = makeMockTarget('Sprite1', 1);
target.runtime = runtime;
RubyGenerator.currentTarget_ = target;
RubyGenerator.cache_.targetCommentTexts = ['@ruby:class'];

const code = 'self.when(:flag_clicked) do\n move(10)\nend\n';
const result = RubyGenerator.finish(code, {});

expect(result).toContain('class Sprite1\n');
expect(result).not.toContain(' < ');
});

test('<=//Smalruby3/Sprite in file output uses specified superclass', () => {
const {target, runtime} = makeMockTarget('Sprite1', 1);
target.runtime = runtime;
target.x = 0;
target.y = 0;
target.direction = 90;
target.visible = true;
target.size = 100;
target.currentCostume = 0;
target.rotationStyle = 'all around';
target.isStage = false;
target.sprite.costumes = [];
target.variables = {};
RubyGenerator.currentTarget_ = target;
RubyGenerator.cache_.targetCommentTexts = ['@ruby:class:<=//Smalruby3/Sprite'];

const code = 'self.when(:flag_clicked) do\n move(10)\nend\n';
const result = RubyGenerator.finish(code, {withSpriteNew: true, version: '2'});

expect(result).toContain('class Sprite1 < ::Smalruby3::Sprite');
});

test('<=Foo in file output uses Foo as superclass', () => {
const {target, runtime} = makeMockTarget('Sprite1', 1);
target.runtime = runtime;
target.x = 0;
target.y = 0;
target.direction = 90;
target.visible = true;
target.size = 100;
target.currentCostume = 0;
target.rotationStyle = 'all around';
target.isStage = false;
target.sprite.costumes = [];
target.variables = {};
RubyGenerator.currentTarget_ = target;
RubyGenerator.cache_.targetCommentTexts = ['@ruby:class:<=Foo'];

const code = 'self.when(:flag_clicked) do\n move(10)\nend\n';
const result = RubyGenerator.finish(code, {withSpriteNew: true, version: '2'});

expect(result).toContain('class Sprite1 < Foo');
});

test('no <= in file output defaults to ::Smalruby3::Sprite', () => {
const {target, runtime} = makeMockTarget('Sprite1', 1);
target.runtime = runtime;
target.x = 0;
target.y = 0;
target.direction = 90;
target.visible = true;
target.size = 100;
target.currentCostume = 0;
target.rotationStyle = 'all around';
target.isStage = false;
target.sprite.costumes = [];
target.variables = {};
RubyGenerator.currentTarget_ = target;
RubyGenerator.cache_.targetCommentTexts = ['@ruby:class'];

const code = 'self.when(:flag_clicked) do\n move(10)\nend\n';
const result = RubyGenerator.finish(code, {withSpriteNew: true, version: '2'});

expect(result).toContain('class Sprite1 < ::Smalruby3::Sprite');
});

test('<=Foo with other attributes preserves both', () => {
const {target, runtime} = makeMockTarget('Sprite1', 1);
target.runtime = runtime;
target.x = 10;
RubyGenerator.currentTarget_ = target;
RubyGenerator.cache_.targetCommentTexts = ['@ruby:class:<=Foo,x'];

const code = 'self.when(:flag_clicked) do\n move(10)\nend\n';
const result = RubyGenerator.finish(code, {});

expect(result).toContain('class Sprite1 < Foo');
expect(result).toContain('set_x 10');
});

test('Stage class does not use <= (always no superclass in non-file output)', () => {
const stage = {isStage: true, sprite: {name: 'Stage'}};
const runtime = {targets: [stage]};
stage.runtime = runtime;
RubyGenerator.currentTarget_ = stage;
RubyGenerator.cache_.targetCommentTexts = ['@ruby:class'];

const code = 'self.when(:flag_clicked) do\n move(10)\nend\n';
const result = RubyGenerator.finish(code, {});

expect(result).toContain('class Stage\n');
expect(result).not.toContain(' < ');
});
});
});
Loading
Loading