diff --git a/TODO b/TODO index e9b369e..e9e8230 100644 --- a/TODO +++ b/TODO @@ -14,6 +14,7 @@ Misc cannot be used (# is not a valid token) - Class/Interface naming - Will be especially helpful for error messages + - Class module is becoming too large; refactor Property Keywords - Restrictions; throw exceptions when unknown keywords are used diff --git a/lib/class.js b/lib/class.js index 60ef10b..c43fc93 100644 --- a/lib/class.js +++ b/lib/class.js @@ -60,46 +60,20 @@ var class_instance = {}; */ module.exports = function() { - var def = {}, - name = '', - type = typeof arguments[ 0 ] + var type = typeof arguments[ 0 ], + result = null ; switch ( type ) { // anonymous class case 'object': - def = arguments[ 0 ]; - - // ensure we have the proper number of arguments (if they passed in - // too many, it may signify that they don't know what they're doing, - // and likely they're not getting the result they're looking for) - if ( arguments.length > 1 ) - { - throw Error( - "Expecting one argument for Class definition; " + - arguments.length + " given." - ); - } - + result = createAnonymousClass.apply( null, arguments ); break; // named class case 'string': - name = arguments[ 0 ]; - def = arguments[ 1 ]; - - // add the name to the definition - def.__name = name; - - // the definition must be an object - if ( typeof def !== 'object' ) - { - throw TypeError( - "Unexpected value for named class definition" - ); - } - + result = createNamedClass.apply( null, arguments ); break; default: @@ -109,7 +83,7 @@ module.exports = function() ); } - return extend( def ); + return result; }; @@ -136,12 +110,11 @@ module.exports.extend = function( base ) */ module.exports.implement = function() { - var args = Array.prototype.slice.call( arguments ); - - // apply to an empty (new) object - args.unshift( module.exports.extend() ); - - return implement.apply( this, args ); + // implement on empty base + return createImplement( + module.exports.extend(), + Array.prototype.slice.call( arguments ) + ); }; @@ -251,6 +224,151 @@ module.exports.isA = module.exports.isInstanceOf; function Class() {}; +/** + * Creates a new anonymous Class from the given class definition + * + * @param {Object} def class definition + * + * @return {Class} new anonymous class + */ +function createAnonymousClass( def ) +{ + // ensure we have the proper number of arguments (if they passed in + // too many, it may signify that they don't know what they're doing, + // and likely they're not getting the result they're looking for) + if ( arguments.length > 1 ) + { + throw Error( + "Expecting one argument for anonymous Class definition; " + + arguments.length + " given." + ); + } + + return extend( def ); +} + + +/** + * Creates a new named Class from the given class definition + * + * @param {string} name class name + * @param {Object} def class definition + * + * @return {Class} new named class + */ +function createNamedClass( name, def ) +{ + // if too many arguments were provided, it's likely that they're + // expecting some result that they're not going to get + if ( arguments.length > 2 ) + { + throw Error( + "Expecting two arguments for named Class definition; " + + arguments.length + " given." + ); + } + + // if no definition was given, return a staging object, to apply the name to + // the class once it is actually created + if ( def === undefined ) + { + return createStaging( name ); + } + // the definition must be an object + else if ( typeof def !== 'object' ) + { + throw TypeError( + "Unexpected value for named class definition; object expected" + ); + } + + // add the name to the definition + def.__name = name; + + return extend( def ); +} + + +/** + * Creates a staging object to stage a class name + * + * The class name will be applied to the class generated by operations performed + * on the staging object. This allows applying names to classes that need to be + * extended or need to implement interfaces. + * + * @param {string} cname desired class name + * + * @return {Object} object staging the given class name + */ +function createStaging( cname ) +{ + return { + extend: function() + { + var args = Array.prototype.slice.apply( arguments ); + + // extend() takes a maximum of two arguments. If only one + // argument is provided, then it is to be the class definition. + // Otherwise, the first argument is the supertype and the second + // argument is the class definition. Either way you look at it, + // the class definition is always the final argument. + // + // We want to add the name to the definition. + args[ args.length - 1 ].__name = cname; + + return extend.apply( null, args ); + }, + + implement: function() + { + // implement on empty base, providing the class name to be used once + // extended + return createImplement( + extend( {} ), + Array.prototype.slice.call( arguments ), + cname + ); + }, + }; +} + + +/** + * Creates an intermediate object to permit implementing interfaces + * + * This object defers processing until extend() is called. This intermediate + * object ensures that a usable class is not generated until after extend() is + * called, as it does not make sense to create a class without any + * body/definition. + * + * @param {Object} base base class to implement atop of + * @param {Array} ifaces interfaces to implement + * @param {string=} cname optional class name once extended + * + * @return {Object} intermediate implementation object + */ +function createImplement( base, ifaces, cname ) +{ + ifaces.push( base ); + + // Defer processing until after extend(). This also ensures that implement() + // returns nothing usable. + return { + extend: function( def ) + { + // if a name was provided, use it + if ( cname ) + { + def.__name = cname; + } + + return extend.apply( null, [ + implement.apply( this, ifaces ), + def + ] ); + }, + }; +} /** @@ -519,7 +637,7 @@ var implement = function() { var args = Array.prototype.slice.call( arguments ), dest = {}, - base = args.shift(), + base = args.pop(), len = args.length, arg = null, @@ -730,6 +848,8 @@ function attachExtend( func ) /** * Attaches implement method to the given function (class) * + * Please see the implement() export of this module for more information. + * * @param {function()} func function (class) to attach method to * * @return {undefined} @@ -738,10 +858,10 @@ function attachImplement( func ) { util.defineSecureProp( func, 'implement', function() { - var args = Array.prototype.slice.call( arguments ); - args.unshift( func ); - - return implement.apply( this, args ); + return createImplement( + func, + Array.prototype.slice.call( arguments ) + ); }); } diff --git a/lib/interface.js b/lib/interface.js index 1ae2ce3..6271930 100644 --- a/lib/interface.js +++ b/lib/interface.js @@ -42,38 +42,20 @@ var util = require( './util' ), */ module.exports = function() { - var def = {}, - name = '', - type = typeof arguments[ 0 ] + var type = typeof arguments[ 0 ] + result = null ; switch ( type ) { // anonymous interface case 'object': - def = arguments[ 0 ]; - - // ensure we have the proper number of arguments (if they passed in - // too many, it may signify that they don't know what they're doing, - // and likely they're not getting the result they're looking for) - if ( arguments.length > 1 ) - { - throw Error( - "Expecting one argument for Interface definition; " + - arguments.length + " given." - ); - } - + result = createAnonymousInterface.apply( null, arguments ); break; // named class case 'string': - name = arguments[ 0 ]; - def = arguments[ 1 ]; - - // add the name to the definition - def.__name = name; - + result = createNamedInterface.apply( null, arguments ); break; default: @@ -84,7 +66,7 @@ module.exports = function() ); } - return extend( def ); + return result; }; @@ -127,6 +109,65 @@ module.exports.isInterface = function( obj ) function Interface() {} +/** + * Creates a new anonymous Interface from the given interface definition + * + * @param {Object} def interface definition + * + * @return {Interface} new anonymous interface + */ +function createAnonymousInterface( def ) +{ + // ensure we have the proper number of arguments (if they passed in + // too many, it may signify that they don't know what they're doing, + // and likely they're not getting the result they're looking for) + if ( arguments.length > 1 ) + { + throw Error( + "Expecting one argument for Interface definition; " + + arguments.length + " given." + ); + } + + return extend( def ); +} + + +/** + * Creates a new named interface from the given interface definition + * + * @param {string} name interface name + * @param {Object} def interface definition + * + * @return {Interface} new named interface + */ +function createNamedInterface( name, def ) +{ + // if too many arguments were provided, it's likely that they're + // expecting some result that they're not going to get + if ( arguments.length > 2 ) + { + throw Error( + "Expecting two arguments for named Interface definition; " + + arguments.length + " given." + ); + } + + // the definition must be an object + if ( typeof def !== 'object' ) + { + throw TypeError( + "Unexpected value for named class definition; object expected" + ); + } + + // add the name to the definition + def.__name = name; + + return extend( def ); +} + + var extend = ( function( extending ) { return function extend() diff --git a/test/test-class-implement.js b/test/test-class-implement.js index cfb973a..ff46d61 100644 --- a/test/test-class-implement.js +++ b/test/test-class-implement.js @@ -64,19 +64,41 @@ var Type = Interface.extend( { { assert.doesNotThrow( function() { - Foo = Class.implement( Type, Type2 ); + Class.implement( Type, Type2 ); }, Error, "Class can implement interfaces" ); +} )(); - assert.ok( - ( Class.isClass( Foo ) ), - "Class returned from implementing interfaces on an empty base is a " + - "valid class" + +/** + * Initially, the implement() method returned an abstract class. However, it + * doesn't make sense to create a class without any actual definition (and + * there's other implementation considerations that caused this route to be + * taken). One wouldn't do "class Foo implements Type", and not provide any + * body. + * + * Therefore, implement() should return nothing useful until extend() is called + * on it. + */ +( function testResultOfImplementIsNotUsableAsAClass() +{ + var result = Class.implement( Type ); + + assert.equal( + ( Class.isClass( result ) ), + false, + "Result of implement operation on class is not usable as a Class" ); } )(); +/** + * As a consequence of the above, we must extend with an empty definition + * (base) in order to get our abstract class. + */ ( function testAbstractMethodsCopiedIntoNewClassUsingEmptyBase() { + Foo = Class.implement( Type, Type2 ).extend( {} ); + assert.ok( ( ( Foo.prototype.foo instanceof Function ) && ( Foo.prototype.foo2 instanceof Function ) @@ -90,19 +112,32 @@ var Type = Interface.extend( { { assert.doesNotThrow( function() { - PlainFoo2 = PlainFoo.implement( Type, Type2 ); + PlainFoo.implement( Type, Type2 ); }, Error, "Classes can implement interfaces" ); +} )(); - assert.ok( - ( Class.isClass( PlainFoo2 ) ), - "Class returned from implementing interfaces on an existing base is a " + - "valid class" + +/** + * Ensure the same system mentioned above also applies to the extend() method on + * existing classes + */ +( function testImplementingInterfaceAtopExistingClassIsNotUsableByDefault() +{ + var result = PlainFoo.implement( Type ); + + assert.equal( + ( Class.isClass( result ) ), + false, + "Result of implementing interfaces on an existing base is not " + + "usable as a Class" ); } )(); ( function testAbstractMethodsCopiedIntoNewClassUsingExistingBase() { + PlainFoo2 = PlainFoo.implement( Type, Type2 ).extend( {} ); + assert.ok( ( ( PlainFoo2.prototype.foo instanceof Function ) && ( PlainFoo2.prototype.foo2 instanceof Function ) diff --git a/test/test-class-name.js b/test/test-class-name.js index aa05a5b..4d40508 100644 --- a/test/test-class-name.js +++ b/test/test-class-name.js @@ -24,7 +24,9 @@ var common = require( './common' ), assert = require( 'assert' ), - Class = common.require( 'class' ) + + Class = common.require( 'class' ), + Interface = common.require( 'interface' ) ; @@ -49,6 +51,27 @@ var common = require( './common' ), { Class( 'Foo', 'Bar' ); }, TypeError, "Second argument to named class must be the definition" ); + + // we should be permitted only two arguments + var args = [ 'Foo', {}, 'extra' ]; + try + { + Class.apply( null, args ); + + // we should not get to this line (an exception should be thrown due to + // too many arguments) + assert.fail( + "Should accept only two arguments when creating named class" + ); + } + catch ( e ) + { + assert.notEqual( + e.toString().match( args.length + ' given' ), + null, + "Named class error should provide number of given arguments" + ); + } } )(); @@ -128,3 +151,48 @@ var common = require( './common' ), ); } )(); + +/** + * In order to accommodate syntax such as extending classes, ease.js supports + * staging class names. This will return an object that operates exactly like + * the normal Class module, but will result in a named class once the class is + * created. + */ +( function testCanCreateNamedClassUsingStagingMethod() +{ + var name = 'Foo', + named = Class( name ).extend( {} ) + namedi = Class( name ).implement( Interface( {} ) ).extend( {} ) + ; + + // ensure what was returned is a valid class + assert.equal( + Class.isClass( named ), + true, + "Named class generated via staging method is considered to be a " + + "valid class" + ); + + // was the name set? + assert.equal( + named.toString(), + '[object Class <' + name + '>]', + "Name is set on named clas via staging method" + ); + + + // we should also be able to implement interfaces + assert.equal( + Class.isClass( namedi ), + true, + "Named class generated via staging method, implementing an " + + "interface, is considered to be a valid class" + ); + + assert.equal( + namedi.toString(), + '[object Class <' + name + '>]', + "Name is set on named class via staging method when implementing" + ); +} )(); + diff --git a/test/test-interface-name.js b/test/test-interface-name.js index cfe5689..804678b 100644 --- a/test/test-interface-name.js +++ b/test/test-interface-name.js @@ -50,6 +50,27 @@ var common = require( './common' ), { Interface( 'Foo', 'Bar' ); }, TypeError, "Second argument to named interface must be the definition" ); + + // we should be permitted only two arguments + var args = [ 'Foo', {}, 'extra' ]; + try + { + Interface.apply( null, args ); + + // we should not get to this line (an exception should be thrown due to + // too many arguments) + assert.fail( + "Should accept only two arguments when creating named interface" + ); + } + catch ( e ) + { + assert.notEqual( + e.toString().match( args.length + ' given' ), + null, + "Named interface error should provide number of given arguments" + ); + } } )();