diff --git a/lib/codegen/fromcto/rust/rustvisitor.js b/lib/codegen/fromcto/rust/rustvisitor.js index a356bf05..98636fb2 100644 --- a/lib/codegen/fromcto/rust/rustvisitor.js +++ b/lib/codegen/fromcto/rust/rustvisitor.js @@ -314,13 +314,32 @@ class RustVisitor { `pub struct ${classDeclaration.getName()} {` ); - this.visitField( - { - name: '$class', - type: 'String', - }, - parameters - ); + if ( + parameters.flattenSubclassesToUnion && + this.isUnionVariant(classDeclaration) + ) { + // A union is tagged by `$class`, and serde hands the variant its + // fields without the tag, so a struct that is a union variant + // must not require `$class`. It still reads and writes one when + // used on its own. + parameters.fileWriter.writeLine(1, '#[serde('); + parameters.fileWriter.writeLine(2, 'rename = "$class",'); + parameters.fileWriter.writeLine(2, 'default,'); + parameters.fileWriter.writeLine( + 2, + 'skip_serializing_if = "String::is_empty",' + ); + parameters.fileWriter.writeLine(1, ')]'); + parameters.fileWriter.writeLine(1, 'pub _class: String,'); + } else { + this.visitField( + { + name: '$class', + type: 'String', + }, + parameters + ); + } classDeclaration.getProperties().forEach((property) => { parameters.fileWriter.writeLine(1, ''); @@ -334,14 +353,12 @@ class RustVisitor { // Only generate unions when explicitly requested via flattenSubclassesToUnion parameter // and when all subclasses are also in the current model file if (parameters.flattenSubclassesToUnion) { - const subclasses = classDeclaration.getDirectSubclasses?.(); - const currentModelFile = classDeclaration.getModelFile(); - - // Filter subclasses to only include those in the same model file - const localSubclasses = - subclasses?.filter( - (subclass) => subclass.getModelFile() === currentModelFile - ) || []; + // Every concrete type that extends this one, directly or not, + // in the same model file: an abstract type can never be the + // `$class` of an instance. + const localSubclasses = this.getLocalSubclasses( + classDeclaration + ).filter((subclass) => !subclass.isAbstract?.()); if (localSubclasses.length > 0) { const unionName = `${classDeclaration.getName()}Union`; @@ -616,6 +633,10 @@ class RustVisitor { parameters.fileWriter.writeLine(1, '#[serde('); parameters.fileWriter.writeLine(2, `rename = "${field.name}",`); + if (this.hasRustDefault(field)) { + // The field may be absent, and then takes its default value. + parameters.fileWriter.writeLine(2, 'default,'); + } if (field.isOptional?.()) { parameters.fileWriter.writeLine( 2, @@ -675,6 +696,71 @@ class RustVisitor { return null; } + /** + * The sub types of a class declaration in its own model file, directly + * or not, depth first in declaration order. + * @param {ClassDeclaration} classDeclaration - the class declaration + * @return {ClassDeclaration[]} the sub types + * @private + */ + getLocalSubclasses(classDeclaration) { + const modelFile = classDeclaration.getModelFile(); + return (classDeclaration.getDirectSubclasses?.() || []) + .filter((subclass) => subclass.getModelFile() === modelFile) + .flatMap((subclass) => [ + subclass, + ...this.getLocalSubclasses(subclass), + ]); + } + + /** + * Whether a class declaration is a variant of a union generated with + * `flattenSubclassesToUnion`: a concrete type that extends a type in its + * own model file, or that has sub types there itself. + * @param {ClassDeclaration} classDeclaration - the class declaration + * @return {boolean} whether it is a union variant + * @private + */ + isUnionVariant(classDeclaration) { + if (classDeclaration.isAbstract?.()) { + return false; + } + const superType = classDeclaration.getSuperTypeDeclaration?.(); + if ( + superType && + superType.getModelFile() === classDeclaration.getModelFile() + ) { + return true; + } + return this.getLocalSubclasses(classDeclaration).length > 0; + } + + /** + * Whether a field's default value is its Rust type's `Default`, so that + * `#[serde(default)]` gives the same value when the field is absent. + * @param {Field} field - the field + * @return {boolean} whether the default value is the type's default + * @private + */ + hasRustDefault(field) { + if (field.isOptional?.() || field.isArray?.()) { + return false; + } + const defaultValue = field.getDefaultValue?.(); + switch (field.type) { + case 'Boolean': + return defaultValue === false; + case 'Integer': + case 'Long': + case 'Double': + return defaultValue === 0; + case 'String': + return defaultValue === ''; + default: + return false; + } + } + /** * @param {String} type - the type to be wrapped as option * @return {String} - the wrapped type diff --git a/test/codegen/fromcto/rust/rustvisitor.js b/test/codegen/fromcto/rust/rustvisitor.js index 313856c3..99ddef0a 100644 --- a/test/codegen/fromcto/rust/rustvisitor.js +++ b/test/codegen/fromcto/rust/rustvisitor.js @@ -506,6 +506,133 @@ describe('RustVisitor', function () { 'pub enum SimpleClassUnion {' ).called.should.be.false; }); + + it('should list every concrete descendant in the union, skipping abstract ones', () => { + let mockDog = sinon.createStubInstance(ClassDeclaration); + mockDog.getName.returns('Dog'); + mockDog.getFullyQualifiedName.returns('org.example.Dog'); + mockDog.isEnum.returns(false); + mockDog.isAbstract.returns(false); + mockDog.getModelFile.returns(mockModelFile); + mockDog.getDirectSubclasses.returns([]); + + let mockMammal = sinon.createStubInstance(ClassDeclaration); + mockMammal.getName.returns('Mammal'); + mockMammal.getFullyQualifiedName.returns('org.example.Mammal'); + mockMammal.isEnum.returns(false); + mockMammal.isAbstract.returns(true); + mockMammal.getModelFile.returns(mockModelFile); + mockMammal.getDirectSubclasses.returns([mockDog]); + + let mockBird = sinon.createStubInstance(ClassDeclaration); + mockBird.getName.returns('Bird'); + mockBird.getFullyQualifiedName.returns('org.example.Bird'); + mockBird.isEnum.returns(false); + mockBird.isAbstract.returns(false); + mockBird.getModelFile.returns(mockModelFile); + mockBird.getDirectSubclasses.returns([]); + + let mockClassDeclaration = + sinon.createStubInstance(ClassDeclaration); + mockClassDeclaration.isClassDeclaration.returns(true); + mockClassDeclaration.getProperties.returns([]); + mockClassDeclaration.getName.returns('Animal'); + mockClassDeclaration.getFullyQualifiedName.returns( + 'org.example.Animal' + ); + mockClassDeclaration.getDirectSubclasses.returns([ + mockMammal, + mockBird, + ]); + mockClassDeclaration.isAbstract.returns(true); + mockClassDeclaration.getModelFile.returns(mockModelFile); + + param.flattenSubclassesToUnion = true; + + rustVisitor.visitClassDeclaration(mockClassDeclaration, param); + + const lines = param.fileWriter.writeLine + .getCalls() + .map((call) => call.args); + const start = lines.findIndex( + ([, line]) => line === 'pub enum AnimalUnion {' + ); + lines.slice(start, start + 6).should.deep.equal([ + [0, 'pub enum AnimalUnion {'], + [1, '#[serde(rename = "org.example.Dog")]'], + [1, 'Dog(Dog),'], + [0, ''], + [1, '#[serde(rename = "org.example.Bird")]'], + [1, 'Bird(Bird),'], + ]); + param.fileWriter.writeLine.withArgs(1, 'Mammal(Mammal),').called + .should.be.false; + }); + + it('should not require $class on a struct that is a union variant', () => { + let mockSuperType = sinon.createStubInstance(ClassDeclaration); + mockSuperType.getModelFile.returns(mockModelFile); + + let mockClassDeclaration = + sinon.createStubInstance(ClassDeclaration); + mockClassDeclaration.isClassDeclaration.returns(true); + mockClassDeclaration.getProperties.returns([]); + mockClassDeclaration.getName.returns('Dog'); + mockClassDeclaration.isAbstract.returns(false); + mockClassDeclaration.getSuperTypeDeclaration.returns( + mockSuperType + ); + mockClassDeclaration.getDirectSubclasses.returns([]); + mockClassDeclaration.getModelFile.returns(mockModelFile); + + param.flattenSubclassesToUnion = true; + + rustVisitor.visitClassDeclaration(mockClassDeclaration, param); + + param.fileWriter.writeLine + .getCalls() + .map((call) => call.args) + .should.deep.equal([ + [0, '#[derive(Debug, Clone, Serialize, Deserialize)]'], + [0, 'pub struct Dog {'], + [1, '#[serde('], + [2, 'rename = "$class",'], + [2, 'default,'], + [2, 'skip_serializing_if = "String::is_empty",'], + [1, ')]'], + [1, 'pub _class: String,'], + [0, '}'], + [0, ''], + ]); + }); + + it('should require $class on a struct whose super type is in another model file', () => { + let mockSuperType = sinon.createStubInstance(ClassDeclaration); + mockSuperType.getModelFile.returns( + sinon.createStubInstance(ModelFile) + ); + + let mockClassDeclaration = + sinon.createStubInstance(ClassDeclaration); + mockClassDeclaration.isClassDeclaration.returns(true); + mockClassDeclaration.getProperties.returns([]); + mockClassDeclaration.getName.returns('Dog'); + mockClassDeclaration.isAbstract.returns(false); + mockClassDeclaration.getSuperTypeDeclaration.returns( + mockSuperType + ); + mockClassDeclaration.getDirectSubclasses.returns([]); + mockClassDeclaration.getModelFile.returns(mockModelFile); + + param.flattenSubclassesToUnion = true; + + rustVisitor.visitClassDeclaration(mockClassDeclaration, param); + + param.fileWriter.writeLine.withArgs(2, 'default,').called.should.be + .false; + param.fileWriter.writeLine.withArgs(1, 'pub _class: String,') + .calledOnce.should.be.ok; + }); }); describe('visitField', () => { @@ -515,6 +642,45 @@ describe('RustVisitor', function () { fileWriter: mockFileWriter, }; }); + + it('should let a field whose default is its Rust default be absent', () => { + const mockField = sinon.createStubInstance(Field); + mockField.name = 'isArray'; + mockField.type = 'Boolean'; + mockField.isPrimitive.returns(true); + mockField.getDefaultValue.returns(false); + rustVisitor.visitField(mockField, param); + param.fileWriter.writeLine + .getCalls() + .map((call) => call.args) + .should.deep.equal([ + [1, '#[serde('], + [2, 'rename = "isArray",'], + [2, 'default,'], + [1, ')]'], + [1, 'pub is_array: bool,'], + ]); + }); + + it('should default a field only when its default is its Rust default', () => { + const cases = [ + ['Boolean', true, false, false], + ['Integer', 0, false, true], + ['Long', 1, false, false], + ['Double', 0, false, true], + ['String', '', false, true], + ['String', 'x', false, false], + ['Boolean', false, true, false], + ['DateTime', false, false, false], + ]; + cases.forEach(([type, defaultValue, optional, expected]) => { + const mockField = sinon.createStubInstance(Field); + mockField.type = type; + mockField.getDefaultValue.returns(defaultValue); + mockField.isOptional.returns(optional); + rustVisitor.hasRustDefault(mockField).should.equal(expected); + }); + }); it('should write a line for primitive field name and type', () => { const mockField = sinon.createStubInstance(Field); mockField.isPrimitive.returns(false);