diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b06bf3..b461f10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Added `implementProperty` and `implementMethod` to the `roblox` built-in library to fill in missing functionality that Lune does not aim to implement itself. + + Example usage: + + ```lua + local roblox = require("@lune/roblox") + + local part = roblox.Instance.new("Part") + + roblox.implementMethod("BasePart", "TestMethod", function(_, ...) + print("Tried to call TestMethod with", ...) + end) + + part:TestMethod("Hello", "world!") + ``` + ## `0.7.8` - October 5th, 2023 ### Added diff --git a/src/tests.rs b/src/tests.rs index 89ef6dc..c4162fd 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -164,6 +164,10 @@ create_tests! { roblox_instance_classes_workspace: "roblox/instance/classes/Workspace", roblox_instance_classes_terrain: "roblox/instance/classes/Terrain", + roblox_instance_custom_async: "roblox/instance/custom/async", + roblox_instance_custom_methods: "roblox/instance/custom/methods", + roblox_instance_custom_properties: "roblox/instance/custom/properties", + roblox_instance_methods_clear_all_children: "roblox/instance/methods/ClearAllChildren", roblox_instance_methods_clone: "roblox/instance/methods/Clone", roblox_instance_methods_destroy: "roblox/instance/methods/Destroy", diff --git a/tests/roblox/instance/custom/async.luau b/tests/roblox/instance/custom/async.luau new file mode 100644 index 0000000..284567f --- /dev/null +++ b/tests/roblox/instance/custom/async.luau @@ -0,0 +1,12 @@ +local roblox = require("@lune/roblox") + +local game = roblox.Instance.new("DataModel") +local http = game:GetService("HttpService") :: any + +roblox.implementMethod("HttpService", "GetAsync", function() + -- TODO: Fill in method body +end) + +-- TODO: Fill in rest of test cases here + +http:GetAsync() diff --git a/tests/roblox/instance/custom/methods.luau b/tests/roblox/instance/custom/methods.luau new file mode 100644 index 0000000..0797093 --- /dev/null +++ b/tests/roblox/instance/custom/methods.luau @@ -0,0 +1,48 @@ +local roblox = require("@lune/roblox") + +local inst = roblox.Instance.new("Instance") :: any +local part = roblox.Instance.new("Part") :: any + +-- Basic sanity checks for callbacks + +local success = pcall(function() + inst:Wat() +end) +assert(not success, "Nonexistent methods should error") + +roblox.implementMethod("Instance", "Wat", function() end) + +local success2 = pcall(function() + inst:Wat() +end) +assert(success2, "Nonexistent methods should error, unless implemented") + +-- Instance should be passed to callback + +roblox.implementMethod("Instance", "PassingInstanceTest", function(instance) + assert(instance == inst, "Invalid instance was passed to callback") +end) +roblox.implementMethod("Part", "PassingPartTest", function(instance) + assert(instance == part, "Invalid instance was passed to callback") +end) +inst:PassingInstanceTest() +part:PassingPartTest() + +-- Any number of args passed & returned should work + +roblox.implementMethod("Instance", "Echo", function(_, ...) + return ... +end) + +local one, two, three = inst:Echo("one", "two", "three") +assert(one == "one", "implementMethod callback should return proper values") +assert(two == "two", "implementMethod callback should return proper values") +assert(three == "three", "implementMethod callback should return proper values") + +-- Methods implemented by Lune should take precedence + +roblox.implementMethod("Instance", "FindFirstChild", function() + error("unreachable") +end) +inst:FindFirstChild("Test") +part:FindFirstChild("Test") diff --git a/tests/roblox/instance/custom/properties.luau b/tests/roblox/instance/custom/properties.luau new file mode 100644 index 0000000..01ad508 --- /dev/null +++ b/tests/roblox/instance/custom/properties.luau @@ -0,0 +1,64 @@ +local roblox = require("@lune/roblox") + +local inst = roblox.Instance.new("Instance") :: any +local part = roblox.Instance.new("Part") :: any + +-- Basic sanity checks for callbacks + +local success = pcall(function() + local _ = inst.Wat +end) +assert(not success, "Nonexistent properties should error") + +roblox.implementProperty("Instance", "Wat", function() + return nil +end) + +local success2 = pcall(function() + local _ = inst.Wat +end) +assert(success2, "Nonexistent properties should error, unless implemented") + +-- Instance should be passed to callback + +roblox.implementProperty("Instance", "PassingInstanceTest", function(instance) + assert(instance == inst, "Invalid instance was passed to callback") + return nil +end) +roblox.implementProperty("Part", "PassingPartTest", function(instance) + assert(instance == part, "Invalid instance was passed to callback") + return nil +end) +local _ = inst.PassingInstanceTest +local _ = part.PassingPartTest + +-- Any number of args passed & returned should work + +local counters = {} +roblox.implementProperty("Instance", "Counter", function(instance) + -- FIXME: Instances do not make for unique table keys for some reason ... + local value = counters[tostring(instance)] or 0 + value += 1 + counters[tostring(instance)] = value + return value +end, function(instance, value) + counters[tostring(instance)] = value +end) + +assert(inst.Counter == 1, "implementProperty callback should return proper values") +assert(inst.Counter == 2, "implementProperty callback should return proper values") +assert(inst.Counter == 3, "implementProperty callback should return proper values") + +inst.Counter = 10 + +assert(inst.Counter == 11, "implementProperty callback should set proper values") +assert(inst.Counter == 12, "implementProperty callback should return proper values") +assert(inst.Counter == 13, "implementProperty callback should return proper values") + +-- Properties implemented by Lune should take precedence + +roblox.implementProperty("Instance", "Parent", function() + error("unreachable") +end) +local _ = inst.Parent +local _ = part.Parent diff --git a/types/roblox.luau b/types/roblox.luau index 880f060..6a769db 100644 --- a/types/roblox.luau +++ b/types/roblox.luau @@ -391,6 +391,113 @@ function roblox.getReflectionDatabase(): Database return nil :: any end +--[=[ + @within Roblox + + Implements a property for all instances of the given `className`. + + This takes into account class hierarchies, so implementing a property + for the `BasePart` class will also implement it for `Part` and others, + unless a more specific implementation is added to the `Part` class directly. + + ### Behavior + + The given `getter` callback will be called each time the property is + indexed, with the instance as its one and only argument. The `setter` + callback, if given, will be called each time the property should be set, + with the instance as the first argument and the property value as second. + + ### Example usage + + ```lua + local roblox = require("@lune/roblox") + + local part = roblox.Instance.new("Part") + + local propertyValues = {} + roblox.implementProperty( + "BasePart", + "CoolProp", + function(instance) + if propertyValues[instance] == nil then + propertyValues[instance] = 0 + end + propertyValues[instance] += 1 + return propertyValues[instance] + end, + function(instance, value) + propertyValues[instance] = value + end + ) + + print(part.CoolProp) --> 1 + print(part.CoolProp) --> 2 + print(part.CoolProp) --> 3 + + part.CoolProp = 10 + + print(part.CoolProp) --> 11 + print(part.CoolProp) --> 12 + print(part.CoolProp) --> 13 + ``` + + @param className The class to implement the property for. + @param propertyName The name of the property to implement. + @param getter The function which will be called to get the property value when indexed. + @param setter The function which will be called to set the property value when indexed. Defaults to a function that will error with a message saying the property is read-only. +]=] +function roblox.implementProperty( + className: string, + propertyName: string, + getter: (instance: Instance) -> T, + setter: ((instance: Instance, value: T) -> ())? +) + return nil :: any +end + +--[=[ + @within Roblox + + Implements a method for all instances of the given `className`. + + This takes into account class hierarchies, so implementing a method + for the `BasePart` class will also implement it for `Part` and others, + unless a more specific implementation is added to the `Part` class directly. + + ### Behavior + + The given `callback` will be called every time the method is called, + and will receive the instance it was called on as its first argument. + The remaining arguments will be what the caller passed to the method, and + all values returned from the callback will then be returned to the caller. + + ### Example usage + + ```lua + local roblox = require("@lune/roblox") + + local part = roblox.Instance.new("Part") + + roblox.implementMethod("BasePart", "TestMethod", function(instance, ...) + print("Called TestMethod on instance", instance, "with", ...) + end) + + part:TestMethod("Hello", "world!") + --> Called TestMethod on instance Part with Hello, world! + ``` + + @param className The class to implement the method for. + @param methodName The name of the method to implement. + @param callback The function which will be called when the method is called. +]=] +function roblox.implementMethod( + className: string, + methodName: string, + callback: (instance: Instance, ...any) -> ...any +) + return nil :: any +end + -- TODO: Make typedefs for all of the datatypes as well... roblox.Instance = (nil :: any) :: { new: ((className: "DataModel") -> DataModel) & ((className: string) -> Instance),