From a7e1d2ad701650ebe69384d112a64cb6b97e6e27 Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Fri, 15 Jul 2016 00:15:11 -0400 Subject: [PATCH] Error constructor integration into ClassBuilder This introduces the transparent subtyping. * doc/classes.texi (Error Subtyping): Section added * lib/ClassBuilder.js (ClassBuilder): Accepts ErrorCtor instance (build): Transparent Error subtyping * lib/class.js: Provide ErrorCtor instance to ClassBuilder * test/ClassBuilder/ErrorExtendTest.js: Add test case for transparent error subtyping --- doc/classes.texi | 118 +++++++++++++++++ lib/ClassBuilder.js | 27 +++- lib/class.js | 5 +- test/ClassBuilder/ErrorExtendTest.js | 191 +++++++++++++++++++++++++++ 4 files changed, 336 insertions(+), 5 deletions(-) create mode 100644 test/ClassBuilder/ErrorExtendTest.js diff --git a/doc/classes.texi b/doc/classes.texi index 652461f..9fd510b 100644 --- a/doc/classes.texi +++ b/doc/classes.texi @@ -700,6 +700,7 @@ classes for their conciseness. one-another * Visibility Escalation:: Increasing visibility of inherited members +* Error Subtypes:: Transparent Error subtyping * Final Classes:: Classes that cannot be inherited from @end menu @@ -1068,6 +1069,123 @@ Let's take a look at an example. Note that, in the above example, making the public @var{cannotMakeProtected} method protected would throw an error. + +@node Error Subtypes +@subsection Error Subtypes +Extending ECMAScript's built-in @var{Error} type is a bit cumbersome (to +say the least)---it involves not only the traditional prototype chain, +but also setting specific properties within the constructor. Further, +different environments support different features (e.g. stack traces and +column numbers), and values are relative to the stack frame of the +@var{Error} subtype constructor itself. + +With GNU ease.js, error subtyping is transparent: + +@float Figure, f:error-extend +@verbatim + var MyError = Class( 'MyError' ) + .extend( Error, {} ); + + var e = MyError( 'Foo' ); + e.message; // Foo + e.name; // MyError + + // -- if supported by environment -- + e.stack; // stack beginning at caller + e.fileName; // caller filename + e.lineNumber; // caller line number + e.columnNumber; // caller column number + + // general case + throw MyError( 'Foo' ); +@end verbatim +@caption{Transparent @var{Error} extending in ease.js} +@end float + +If ease.js detects that you are extending an @var{Error} object or any +of its subtypes, it will handle a number of things for you, depending on +environment: + +@enumerate +@item Produce a default constructor method (@pxref{Constructors}) that +assigns the error message to the string passed as the first argument; + +@item Sets the error name to the class name; + +@item Provides a stack trace via @var{stack}, if supported by the +environment, stripping itself from the head of the stack; and + +@item Sets any of @var{fileName}, @var{lineNumber}, and/or +@var{columnNumber} when supported by the environment. +@end enumerate + +If a constructor method is provided in the class definition +(@pxref{Constructors}), then it will be invoked immediately after the +error object is initialized by the aforementioned default +constructor.@footnote{The reason that ease.js +does not permit overriding the generated constructor is an +implementation detail: the generated constructor is not on the +supertype, so there is not anything to actually override. Further, the +generated constructor provides a sane default behavior that should be +implicit in error classes anyway; that behavior can be overridden simply +be re-assigning the values that are assigned for you (e.g. name or line +number).} @var{this.__super} in that context refers to the constructor +of the supertype (as would be expected), @emph{not} the default error +constructor. + +ease.js will automatically detect what features are supported by the +current environment, and will @emph{only} set respective values if the +environment itself would normally set them. For example, if ease.js can +determine a column number from the stack trace, but the environment does +not normally set @var{columnNumber} on @var{Error} objects, then neither +will ease.js; this leads to predictable and consistent behavior. + +ease.js makes its best attempt to strip itself from the head of the +stack trace. To see why this is important, consider the generally +recommended way of creating an @var{Error} subtype in ECMAScript: + +@float Figure, f:ecma-error-extend +@verbatim + function ErrorSubtype( message ) + { + var err = new Error(); + + this.name = 'ErrorSubtype'; + this.message = message || 'Error'; + this.stack = err.stack; + this.lineNumber = err.lineNumber; + this.columnNumber = err.columnNumber; + this.fileName = err.fileName; + } + + ErrorSubtype.prototype = new Error(); + ErrorSubtype.prototype.constructor = ErrorSubtype; +@end verbatim +@caption{@var{Error} subtyping in plain ECMAScript 3} +@end float + +Not only is @ref{f:ecma-error-extend} all boilerplate and messy, but +it's not entirely truthful: To get a stack trace, @var{Error} is +instantiated within the constructor @var{ErrorSubtype}; this ensures +that the stack trace will actually include the caller. Unfortunately, +it also includes the @emph{current frame}; the topmost frame in the +stack trace will be @var{ErrorSubtype} itself. To make matters worse, +all of @var{lineNumber}, @var{columNumber}, and @var{fileName} (if +defined) will be set to the stack frame of our constructor, @emph{not} +the caller. + +ease.js will set each of those values to represent the caller. To do +so, it parses common stack trace formats. Should it fail, it simply +falls back to the default behavior of including itself in the stack +frame. + +The end result of all of this is---hopefully---concise @var{Error} +subtypes that actually function as you would expect of an @var{Error}, +without any boilerplate at all. The @var{Error} subtypes created with +ease.js can be extended like the built-ins, and may extend any of the +built-in error types (e.g. @var{TypeError} and @var{SyntaxError}). + + @node Final Classes @subsection Final Classes @table @code diff --git a/lib/ClassBuilder.js b/lib/ClassBuilder.js index 21bcc1e..68de6a3 100644 --- a/lib/ClassBuilder.js +++ b/lib/ClassBuilder.js @@ -1,7 +1,7 @@ /** * Handles building of classes * - * Copyright (C) 2011, 2012, 2013, 2014, 2015 Free Software Foundation, Inc. + * Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016 Free Software Foundation, Inc. * * This file is part of GNU ease.js. * @@ -116,14 +116,14 @@ var util = require( './util' ), * @constructor */ module.exports = exports = -function ClassBuilder( warn_handler, member_builder, visibility_factory ) +function ClassBuilder( warn_handler, member_builder, visibility_factory, ector ) { // allow ommitting the 'new' keyword if ( !( this instanceof exports ) ) { // module.exports for Closure Compiler return new module.exports( - warn_handler, member_builder, visibility_factory + warn_handler, member_builder, visibility_factory, ector ); } @@ -145,6 +145,11 @@ function ClassBuilder( warn_handler, member_builder, visibility_factory ) */ this._visFactory = visibility_factory; + /** + * Error constructor generator + * @type {ErrorCtor} + */ + this._ector = ector; /** * Class id counter, to be increment on each new definition @@ -408,6 +413,22 @@ exports.prototype.build = function extend( _, __ ) } } + // we transparently handle extending errors in a sane manner, which is + // traditionally a huge mess (you're welcome) + if ( this._ector && this._ector.isError( base ) ) + { + // declare public properties (otherwise, they'll be confined to the + // private visibility object in ES5+ environments) + props.message = ''; + props.stack = ''; + + // user-provided constructor + var ector_own = props.__construct; + + // everything else is handled by the constructor + props.__construct = this._ector.createCtor( base, cname, ector_own ); + } + // increment class identifier this._classId++; diff --git a/lib/class.js b/lib/class.js index 65351e9..e67d5db 100644 --- a/lib/class.js +++ b/lib/class.js @@ -1,7 +1,7 @@ /** * Contains basic inheritance mechanism * - * Copyright (C) 2010, 2011, 2012, 2013, 2014, 2015 Free Software Foundation, Inc. + * Copyright (C) 2010, 2011, 2012, 2013, 2014, 2015, 2016 Free Software Foundation, Inc. * * This file is part of GNU ease.js. * @@ -57,7 +57,8 @@ var util = require( './util' ), ) ), require( './VisibilityObjectFactoryFactory' ) - .fromEnvironment() + .fromEnvironment(), + require( './ctor/ErrorCtor' )( Error ) ) ; diff --git a/test/ClassBuilder/ErrorExtendTest.js b/test/ClassBuilder/ErrorExtendTest.js new file mode 100644 index 0000000..5bd6007 --- /dev/null +++ b/test/ClassBuilder/ErrorExtendTest.js @@ -0,0 +1,191 @@ +/** + * Tests special handling of Error subtyping + * + * Copyright (C) 2016 Free Software Foundation, Inc. + * + * This file is part of GNU ease.js. + * + * ease.js is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +require( 'common' ).testCase( +{ + caseSetUp: function() + { + this.Sut = this.require( 'ClassBuilder' ); + this.MethodWrapperFactory = this.require( 'MethodWrapperFactory' ); + + this.wrappers = this.require( 'MethodWrappers' ).standard; + this.util = this.require( 'util' ); + + this.errtypes = [ + Error, + TypeError, + SyntaxError, + ReferenceError, + EvalError, + RangeError, + URIError, + ]; + }, + + + setUp: function() + { + this.stubEctor = { + createCtor: function() {}, + isError: function() { return true; }, + }; + + // XXX: get rid of this disgusting mess; we're mid-refactor and all + // these dependencies should not be necessary for testing + this.builder = this.Sut( + this.require( 'warn' ).DismissiveHandler(), + this.require( '/MemberBuilder' )( + this.MethodWrapperFactory( this.wrappers.wrapNew ), + this.MethodWrapperFactory( this.wrappers.wrapOverride ), + this.MethodWrapperFactory( this.wrappers.wrapProxy ), + this.getMock( 'MemberBuilderValidator' ) + ), + this.require( '/VisibilityObjectFactoryFactory' ).fromEnvironment(), + this.stubEctor + ); + }, + + + /** + * Any determination as to whether we're extending an error should be + * left to the error constructor. + * + * Note that this test only ensures that the SUT will recognizs + * non-errors as such; the other tests that follow implicitly test the + * reverse. + */ + 'Uses constructor generator for error extension determination': function() + { + var called = false; + + this.stubEctor.isError = function() { return false; }; + + // should not be called + this.stubEctor.createCtor = function() + { + called = true; + }; + + // will invoke createCtor if the isError check fails + this.builder.build( Error, {} )(); + + this.assertOk( !called ); + }, + + + /** + * Simple verification that we're passing the correct data to the error + * constructor. + */ + '@each(errtypes) Produces error constructor': function( Type ) + { + this.stubEctor.createCtor = function( supertype, name ) + { + return function() + { + this.givenSupertype = supertype; + this.givenName = name; + }; + }; + + var expected_name = 'ename', + result = this.builder.build( Type, { + __name: expected_name, + givenSupertype: '', + givenName: '', + } )(); + + this.assertEqual( Type, result.givenSupertype ); + this.assertEqual( expected_name, result.givenName ); + }, + + + /** + * This is obvious, but since Error is a special case, let's just be + * sure. + */ + '@each(errtypes) Error subtype is instanceof parent': function( Type ) + { + this.assertOk( + this.builder.build( Type, {} )() instanceof Type + ); + }, + + + /** + * By default, in ES5+ environments that support visibility objects will + * write to the private visibility object by default, unless the property + * is declared public. + */ + 'Message and stack are public': function() + { + var expected_msg = 'expected msg', + expected_stack = 'expected stack'; + + this.stubEctor.createCtor = function( supertype, name ) + { + return function() + { + this.message = expected_msg; + this.stack = expected_stack; + }; + }; + + var result = this.builder.build( {}, {} )(); + + // will only be visible (in ES5 environments at least) if the + // properties are actually public + this.assertEqual( expected_msg, result.message ); + this.assertEqual( expected_stack, result.stack ); + }, + + + /** + * The default constructor cannot be overridden---it isn't a method on + * the supertype at all; it's rather just a default + * implementation. However, a user can provide a method to be invoked + * after the generated constructor. + */ + 'Can override generated constructor': function() + { + var called_gen = false, + called_own = false; + + this.stubEctor.createCtor = function( supertype, name, after ) + { + return function() + { + called_gen = true; + after(); + }; + }; + + var result = this.builder.build( {}, { + __construct: function() + { + called_own = true; + }, + } )(); + + this.assertOk( called_gen ); + this.assertOk( called_own ); + }, +} );