diff --git a/changelog.txt b/changelog.txt index 8ad3c18..451502f 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,7 @@ +0.1.4: + - :new non-nil custom returns values now replace the usual returned instance + - Custom inherited __index metamethods are now called on the correct class instead of the class it is defined in. + - Added tests. 0.1.3: - Added __name attribute in BaseClass for a more informative __tostring. - Fixed instanciation error when no custom :new method is defined. diff --git a/classtoi.lua b/classtoi.lua index f9e15e0..1bc225f 100644 --- a/classtoi.lua +++ b/classtoi.lua @@ -1,4 +1,4 @@ ---- Reuh's class library version 0.1.3. Lua 5.1-5.3 and LuaJit compatible. +--- Reuh's class library version 0.1.4. Lua 5.1-5.3 and LuaJit compatible. -- Objects and classes behavior are identical, so you can consider this to be somewhat prototype-based. -- Features: -- * Multiple inheritance with class(parents...) or someclass(newstuff...) @@ -8,11 +8,12 @@ -- * Instanciate with class:new(...) -- * Test inheritance relations with class/object.is(thing, isThis) -- * Call object:new(...) on instanciation +-- * If object:new(...) returns non-nil values, they will be returned instead of the instance -- * Call class.__inherit(class, inheritingClass) when creating a class inheriting the previous class. If class.__inherit returns a value, it will -- be used as the parent table instead of class, allowing some pretty fancy behavior (it's like an inheritance metamethod). -- * Implements Class Commons -- * I don't like to do this, but you can redefine every field and metamethod after class creation (except __index and __super). --- Not features (things you may want to know): +-- Not features / Things you may want to know: -- * Will set the metatable of all parent classes/tables if no metatable is set (the table will be its own metatable). -- * You can't redefine __super (any __super you define will be only avaible by searching in the default __super contents). -- * Redefining __super or __index after class creation will break everything (though it should be ok with new, is, __call and everything else). @@ -20,6 +21,8 @@ -- will return the default method and not the one you've defined. However, theses defaults will be replaced by yours automatically on instanciation, -- except __super and __index, but __index should call your __index and act like you expect. __super will however always be the default one -- and doesn't proxy in any way yours. +-- * __index metamethods will be called with an extra third argument, which is the current class being searched in the inheritance tree. +-- You can safely ignore it. -- -- Please also note that the last universal ancestor of the classes (defined here in BaseClass) sets the default __tostring method -- and __name attribute for nice class-name-printing. Unlike the previoulsy described attributes and methods however, it is done in a normal @@ -59,17 +62,22 @@ methods = { -- A new object will only be created if calling the method "class:new(...)", if you call for example "class.new(someTable, ...)", it -- will only execute the constructor defined in the class on someTable. This can be used to execute the parent constructor in a child -- object, for example. + -- It should also be noted that if the new method returns non-nil value(s), they will be returned instead of the object. ["!new"] = function(self, ...) if lastIndexed == self then - local obj = self() + local obj, ret = self(), nil -- Setting class methods to the ones found in parents (we use rawset in order to avoid calling the __newindex metamethod) different = methods["!new"] rawset(obj, "new", obj:__index("new") or nil) different = methods["!is"] rawset(obj, "is", obj:__index("is") or nil) different = methods.__call rawset(obj, "__call", obj:__index("__call") or nil) different = nil -- Call constructor - if obj.new ~= methods["!new"] and type(obj.new) == "function" then obj:new(...) end - return obj + if obj.new ~= methods["!new"] and type(obj.new) == "function" then ret = { obj:new(...) } end + if not ret or #ret == 0 then + return obj + else + return unpack(ret) + end else different = methods["!new"] local new = lastIndexed:__index("new") or nil @@ -104,18 +112,19 @@ methods = { -- When getting a value from the class, it will be first searched in stuff, then in Base1, then in all Base1 parents, -- then in Base2, then in Base2 parents. -- A way to describe this will be search in the latest added tables (from the farthest child to the first parents), from left-to-right. - __index = function(self, k) + -- self always refer to the initial table the metamethod was called on, super refers to the class currently being searched for a value. + __index = function(self, k, super) local proxied = methods["!"..tostring(k)] if proxied ~= nil and proxied ~= different then -- proxied methods lastIndexed = self return proxied end - for _, t in ipairs(self.__super) do -- search in super (will auto-follow __index metamethods) - local val = t[k] + for _, t in ipairs((super or self).__super) do -- search in super (will follow __index metamethods) + local val = rawget(t, k) if val ~= nil and val ~= different then return val end - -- If different search is on and the direct t[k] returns an identical value, force the __index metamethod search. - if different ~= nil and getmetatable(t) and getmetatable(t).__index then - val = getmetatable(t):__index(k) + -- Also covers the case when different search is enabled and the raw t[k] returns an identical value, so the __index metamethod search will be tried for another value. + if getmetatable(t) and getmetatable(t).__index then + val = getmetatable(t).__index(self, k, t) if val ~= nil and val ~= different then return val end end end diff --git a/knife-test.lua b/knife-test.lua new file mode 100644 index 0000000..b4ef754 --- /dev/null +++ b/knife-test.lua @@ -0,0 +1,156 @@ +--[[ +knife.test - A fixture-free test framework. + +https://github.com/airstruck/knife + +The MIT License (MIT) + +Copyright (c) 2015 airstruck + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +]] + +local test, testAssert, testError + +-- Create a node representing a test section +local function createNode (parent, description, process) + return setmetatable({ + parent = parent, + description = description, + process = process, + nodes = {}, + activeNodeIndex = 1, + currentNodeIndex = 0, + assert = testAssert, + error = testError, + }, { __call = test }) +end + +-- Run a node +local function runNode (node) + node.currentNodeIndex = 0 + return node:process() +end + +-- Get the root node for a given node +local function getRootNode (node) + local parent = node.parent + return parent and getRootNode(parent) or node +end + +-- Update the active child node of the given node +local function updateActiveNode (node, description, process) + local activeNodeIndex = node.activeNodeIndex + local nodes = node.nodes + local activeNode = nodes[activeNodeIndex] + + if not activeNode then + activeNode = createNode(node, description, process) + nodes[activeNodeIndex] = activeNode + else + activeNode.process = process + end + + getRootNode(node).lastActiveLeaf = activeNode + + return activeNode +end + +-- Run the active child node of the given node +local function runActiveNode (node, description, process) + local activeNode = updateActiveNode(node, description, process) + return runNode(activeNode) +end + +-- Get ancestors of a node, including the node +local function getAncestors (node) + local ancestors = { node } + for ancestor in function () return node.parent end do + ancestors[#ancestors + 1] = ancestor + node = ancestor + end + return ancestors +end + +-- Print a message describing one execution path in the test scenario +local function printScenario (node) + local ancestors = getAncestors(node) + for i = #ancestors, 1, -1 do + io.stderr:write(ancestors[i].description or '') + io.stderr:write('\n') + end +end + +-- Print a message and stop the test scenario when an assertion fails +local function failAssert (node, description, message) + io.stderr:write(message or '') + io.stderr:write('\n\n') + printScenario(node) + io.stderr:write(description or '') + io.stderr:write('\n\n') + error(message or '', 2) +end + +-- Create a branch node for a test scenario +test = function (node, description, process) + node.currentNodeIndex = node.currentNodeIndex + 1 + if node.currentNodeIndex == node.activeNodeIndex then + return runActiveNode(node, description, process) + end +end + +-- Test an assertion +testAssert = function (self, value, description) + if not value then + return failAssert(self, description, 'Test failed: assertion failed') + end + return value +end + +-- Expect function f to fail +testError = function (self, f, description) + if pcall(f) then + return failAssert(self, description, 'Test failed: expected error') + end +end + +-- Create the root node for a test scenario +local function T (description, process) + local root = createNode(nil, description, process) + + runNode(root) + while root.activeNodeIndex <= #root.nodes do + local lastActiveBranch = root.lastActiveLeaf.parent + lastActiveBranch.activeNodeIndex = lastActiveBranch.activeNodeIndex + 1 + runNode(root) + end + + return root +end + +-- Run any other files passed from CLI. +if arg and arg[0] and arg[0]:gmatch('test.lua') then + _G.T = T + for i = 1, #arg do + dofile(arg[i]) + end + _G.T = nil +end + +return T diff --git a/test.lua b/test.lua new file mode 100644 index 0000000..d4d9121 --- /dev/null +++ b/test.lua @@ -0,0 +1,246 @@ +local T = require("knife-test") + +-- luacheck: ignore T +T("Given the base class", function(T) + local class = require("classtoi") + + -- Inheritance + T("When subclassed with an attribute", function(T) + local Thing = class { + attribute = "thing" + } + + T:assert(Thing.attribute == "thing", "Then the attribute should be set on the subclass") + T:assert(class.attribute == nil, "Then the attribute shouldn't be set on the base class'") + + T:assert(class.is(Thing), "Then the subclass should be a subclass of the base class (class.is)") + T:assert(Thing:is(class), "Then the subclass should be a subclass of the base class (subclass:is)") + + T("When the subclass is instanced", function(T) + local thing = Thing:new() + + T:assert(thing.attribute == "thing", "Then the attribute should be kept") + + T:assert(class.is(thing), "Then the object should be a subclass of the base class (class.is(object))") + T:assert(thing:is(class), "Then the object should be a subclass of the base class (object:is(class))") + T:assert(thing:is(Thing), "Then the object should be a subclass of the subclass (object:is(subclass))") + T:assert(Thing.is(thing), "Then the object should be a subclass of the subclass (subclass.is(object))") + + T("When setting the attribute on the instance", function(T) + thing.attribute = "not the same thing" + + T:assert(thing.attribute == "not the same thing", "Then the attribute should be set for the object") + T:assert(Thing.attribute == "thing", "Then the attribute should be kept for the subclass") + end) + end) + + T("When the subclassed is subclassed with two parents", function(T) + local OtherThing = class { + attribute = "other thing", + other = true + } + + local SubThing = Thing(OtherThing) + + T:assert(SubThing.attribute == "other thing", "Then the last added parent should have priority") + + local SubOtherThing = class(Thing, OtherThing) + + T:assert(SubOtherThing.attribute == "thing", "Then the left-most class should have priority") + + T:assert(SubThing.other and SubOtherThing.other, "Then new attribute should be always inherited") + + T("When adding a method to the first subclass", function(T) + Thing.action = function(self, arg) + self.attribute = arg + end + + T("When calling it on the first subclass", function(T) + Thing:action("new thing") + + T:assert(Thing.attribute == "new thing", "Then it affect the first subclass") + T:assert(SubOtherThing.attribute == "new thing", "Then it affect children wich inherit the modified attribute") + T:assert(SubThing.attribute == "other thing", "Then it doesn't affect children which doesn't inherit the modified attribute") + end) + + T("When calling it on another subclass", function(T) + SubOtherThing:action("new thing") + + T:assert(Thing.attribute == "thing", "Then it doesn't affect the parent subclass") + T:assert(SubOtherThing.attribute == "new thing", "Then it affect the subclass") + T:assert(SubThing.attribute == "other thing", "Then it doesn't affect other subclasses") + end) + end) + end) + end) + + -- Constructor + T("When subclassed with a constructor", function(T) + local Thing = class { + attribute = "class", + + new = function(self, arg) + self.attribute = arg or "object" + end + } + + T:assert(Thing.attribute == "class", "Then the class should not call the constructor itself") + + T("When the subclass is instanced without arguments", function(T) + local thing = Thing:new() + + T:assert(thing.attribute == "object", "Then the constructor should have been called on the object without arguments") + T:assert(Thing.attribute == "class", "Then the constructor should not be called on the class") + end) + + T("When the subclass is instanced without arguments", function(T) + local thing = Thing:new("stuff") + + T:assert(thing.attribute == "stuff", "Then the constructor should have been called on the object with arguments") + T:assert(Thing.attribute == "class", "Then the constructor should not be called on the class") + end) + + T("When the subclass is subclassed and instanced with another constructor", function(T) + local SubThing = Thing { + new = function(self) + self.sub = true + Thing.new(self, "a whole new thing") + end + } + + local subthing = SubThing:new() + + T:assert(subthing.sub, "Then the new constructor is called on the new instance") + T:assert(not SubThing.sub, "Then the new constructor is not called on the class") + + T:assert(subthing.attribute == "a whole new thing", "Then the parent new method was correctly accessed and called on the new instance") + T:assert(SubThing.attribute == "class", "Then the parent new method was not called on the class") + end) + end) + T("When subclassed with a constructor returning non-nil values and instanced", function(T) + local Thing = class { + new = function(self, arg) + return true, arg + end + } + + local thing, other = Thing:new("mostly useless but cool") + + T:assert(thing == true and other == "mostly useless but cool", "Then the constructor return value should be returned instead of an object") + end) + + -- Usual metamethods + T("When subclassed with a metamethod", function(T) + local Thing = class { + value = 0, + __add = function(self, other) + self.value = self.value + other + return self + end + } + + T:assert(Thing.value == 0, "Then the attribute is set on the subclass") + + Thing = Thing + 5 + + T:assert(Thing.value == 5, "Then the metamethod is correctly called on the subclass") + T:assert(class.value == nil, "Then the metamethod doesn't affect the base class") + + T("When the subclass is instancied", function(T) + local thing = Thing:new() + thing = thing + 5 + + T:assert(thing.value == 10, "Then the metamethod is correctly called on the instance") + T:assert(Thing.value == 5, "Then the metamethod doesn't affect the parent class") + end) + end) + + -- Redefining usual special class methods + T("When a subclass redefine one of the default class methods", function(T) + local Thing = class { + is = function(self) + return "mashed potatoes" + end, + + __call = function(self, arg) + return "hot potatoes with "..arg + end + } + + T:assert(Thing:is(class) == true, "Then defaults methods are still used on the class") + T:assert(Thing():is(class) == true, "Then defaults metamethods are still used on the class") + + T("When the subclass is instancied", function(T) + local thing = Thing:new() + + T:assert(thing:is(class) == "mashed potatoes", "Then the redefined method is used on the instance") + T:assert(thing("melted raclette") == "hot potatoes with melted raclette", "Then the redefined metamethod is used on the instance") + end) + end) + + -- Redefining __index + T("When subclassed with an __index metamethod", function(T) + local Thing = class { + value = "other", + + __index = function(self, key) + if key == "attribute" then + return "thing" + elseif key == "another" then + return self.value + end + end + } + + T:assert(Thing.attribute == "thing", "Then the metamethod is called as expected") + T:assert(Thing.value == "other", "Then attribute access still works as expected") + T:assert(Thing.another == "other", "Then the metamethod seems to correctly pass a class as the first argument") + + T("When the subclass is instanced", function(T) + local thing = Thing:new() + + T:assert(thing.attribute == "thing", "Then the metamethod is called as expected") + T:assert(thing.value == "other", "Then attribute access still works as expected") + T:assert(thing.another == "other", "Then the metamethod seems to correctly pass a class as the first argument") + + T("When changing a value returned by the metamethod on the instance", function(T) + thing.value = "new thing" + + T:assert(thing.another == "new thing", "Then the metamethod was correctly called with the instance as the first argument") + end) + end) + end) + + -- Defining __inherit + T("When subclassed with an __inherit method", function(T) + local Thing = class { + attribute = "thing", + replace = false, + __inherit = function(self, inheritingClass) + inheritingClass.works = true + if self.replace then + return { attribute = "other thing" } + end + end + } + + T:assert(Thing.works == true, "Then the __inherit method was correctly called on the class creation") + + T("When subclassing the subclass", function(T) + Thing.works = "ok" + local SubThing = Thing() + + T:assert(SubThing.attribute == "thing", "Then the child class still inherit the subclass") + T:assert(SubThing.works == true, "Then the __inherit method was correctly called on the inheriting class") + T:assert(Thing.works == "ok", "Then the __inherit method was correctly called on the inherited class") + end) + + T("When subclassing the subclass with a return value", function(T) + Thing.replace = true + local SubThing = Thing() + + T:assert(SubThing.attribute == "other thing", "Then the child class inherited the return value") + T:assert(SubThing.works == true, "Then the __inherit method was correctly called on the inheriting class") + end) + end) +end)