Janitor for truscript?

###
[Janitor]
TruScript port of howmanysmall's Janitor(originally by Validark)
Janitor holds things that need cleaning up later (instances, connections, functions, other objects), and cleans them all with one call. Objects are made of closures, so call members with a dot: janitor.Add(...)

TruScript differences from Roblox:
- The cleanup method can be true (call the object), "Destroy", "Disconnect", "Cancel", "Stop", any other method name of a table, or a function that receives the object, such as func(tween): tween.Cancel()
  Engine objects cannot be indexed by name in TruScript, so use a function for their other methods.
- Left out, the method is guessed, so functions are called, connections are disconnected, and everything else is destroyed.
- RemoveList and RemoveListNoClean take an array of indices instead of variadic arguments
- There is no __call, but janitor.Cleanup is already bound, so pass it anywhere a callback is expected.
- Threads and Promises are not part of TruScript, so AddPromise and thread cancelling are left out.
- Cleanup runs newest-first, and one failing cleanup is reported with warn() instead of stopping the rest

USAGES:
local Janitor = import("/Libraries/Janitor")

Example usage:
local janitor = Janitor.New()
janitor.Add(new Part() { Anchored = true, Parent = world }, "Destroy")
janitor.Add(part.Touched.Connect(on_touch))
janitor.Add(func(): print("cleaned up"), true)
janitor.Add(Tween.Create(panel, params, goals), func(tween): tween.Cancel(), "CurrentTween")
janitor.Remove("CurrentTween")
janitor.LinkToInstance(character)
janitor.Cleanup()
janitor.Destroy()
###

local Janitor = {}
local janitor_mark = {}
local link_key = {}

local call_object = 0
local destroy_object = 1
local disconnect_object = 2
local cancel_object = 3
local stop_object = 4
local named_method = 5
local cleanup_function = 6

#bs code for old typedefaults
local guess_kind = func(object):
    local kind = destroy_object
    try:
        if object.IsA:
            kind = destroy_object
        elif object.Disconnect:
            kind = disconnect_object
    catch not_an_instance:
        try:
            if object.Disconnect:
                kind = disconnect_object
        catch not_a_connection:
            kind = call_object
    return kind

#Turns Add into a kind number, warning early when an object lacks the method
local resolve_kind = func(object, method_name):
    if method_name == nil:
        return guess_kind(object)
    if method_name == true:
        return call_object
    local kind = named_method
    if method_name == "Destroy":
        kind = destroy_object
    elif method_name == "Disconnect":
        kind = disconnect_object
    elif method_name == "Cancel":
        kind = cancel_object
    elif method_name == "Stop":
        kind = stop_object
    else:
        try:
            local name_length = method_name.length
        catch not_a_name:
            kind = cleanup_function
    if kind != named_method:
        return kind
    try:
        if not object[method_name]:
            warn("Janitor: " + tostring(object) + " has no method " + method_name + ", are you sure you want to add it?")
    catch no_such_member:
        warn("Janitor: " + tostring(object) + " cannot be cleaned with " + method_name + ", pass a function instead")
    return kind

local run_cleanup = func(janitor, object, kind, extra):
    if kind == call_object:
        object()
    elif kind == destroy_object:
        if janitor.SuppressInstanceReDestroy:
            try:
                object.Destroy()
            catch already_destroyed:
                kind = destroy_object
        else:
            object.Destroy()
    elif kind == disconnect_object:
        object.Disconnect()
    elif kind == cancel_object:
        object.Cancel()
    elif kind == stop_object:
        object.Stop()
    elif kind == named_method:
        object[extra]()
    else:
        extra(object)

# Janitor.Is(object) -> true when object was made by Janitor.New
Janitor.Is = func(object):
    local result = false
    try:
        result = object[janitor_mark] == true
    catch not_a_table:
        result = false
    return result

# Janitor.New() -> a new, empty janitor
Janitor.New = func():
    local janitor = { ClassName = "Janitor", CurrentlyCleaning = false, SuppressInstanceReDestroy = false }
    janitor[janitor_mark] = true

    # data-oriented storage: parallel arrays hold every object with how to clean it
    # slots maps an object to its array position, and indices maps a namespace index to its object.
    local objects = []
    local kinds = []
    local extras = []
    local slots = {}
    local indices = {}

    local drop_slot = func(slot):
        local last = objects.length - 1
        table.remove(slots, objects[slot])
        if slot != last:
            local moved = objects[last]
            objects[slot] = moved
            kinds[slot] = kinds[last]
            extras[slot] = extras[last]
            slots[moved] = slot
        objects.pop()
        kinds.pop()
        extras.pop()

    local remove_index = func(index, clean):
        if index == nil or not table.has(indices, index):
            return janitor
        local object = indices[index]
        table.remove(indices, index)
        local slot = slots[object]
        if slot == nil:
            return janitor
        local kind = kinds[slot]
        local extra = extras[slot]
        drop_slot(slot)
        if clean:
            run_cleanup(janitor, object, kind, extra)
        return janitor

    # janitor.Add(object, method_name?, index?) -> object
    # method_name - true, a method name or a cleanup function. index: an optional namespace that replaces(and cleans) whatever was stored under it before, and can later be passed to Remove or Get
    janitor.Add = func(object, method_name, index):
        if object == nil:
            throw "Janitor.Add was given nil"
        if index != nil:
            remove_index(index, true)
            indices[index] = object
        local kind = resolve_kind(object, method_name)
        local extra = nil
        if kind == named_method or kind == cleanup_function:
            extra = method_name
        local slot = slots[object]
        if slot != nil:
            kinds[slot] = kind
            extras[slot] = extra
        else:
            slots[object] = objects.length
            objects.push(object)
            kinds.push(kind)
            extras.push(extra)
        return object

    # janitor.AddObject(constructor, method_name?, index?, a, b, c, d) -> constructor.New(a, b, c, d), added
    janitor.AddObject = func(constructor, method_name, index, a, b, c, d):
        return janitor.Add(constructor.New(a, b, c, d), method_name, index)

    # janitor.Remove(index) - cleans the object stored under index and forgets it
    janitor.Remove = func(index):
        return remove_index(index, true)

    # janitor.RemoveNoClean(index) - forgets the object stored under index without cleaning it
    janitor.RemoveNoClean = func(index):
        return remove_index(index, false)

    # janitor.RemoveList([index, ...]) - cleans several namespaces at once
    janitor.RemoveList = func(index_list):
        for index in index_list:
            remove_index(index, true)
        return janitor

    # janitor.RemoveListNoClean([index, ...]) - forgets several namespaces without cleaning them
    janitor.RemoveListNoClean = func(index_list):
        for index in index_list:
            remove_index(index, false)
        return janitor

    # janitor.Get(index) -> the object stored under index, or nil
    janitor.Get = func(index):
        return indices[index]

    # janitor.GetAll() -> a copy of every index and its object
    janitor.GetAll = func():
        return table.map(indices, expr(object): object)

    # janitor.Cleanup() -cleans everything, newest first, and empties the janitor so it can be reused
    janitor.Cleanup = func():
        if janitor.CurrentlyCleaning:
            return
        janitor.CurrentlyCleaning = true
        while objects.length > 0:
            local object = objects.pop()
            local kind = kinds.pop()
            local extra = extras.pop()
            table.remove(slots, object)
            try:
                run_cleanup(janitor, object, kind, extra)
            catch problem:
                warn("Janitor: a cleanup failed: " + tostring(problem))
        table.clear(indices)
        janitor.CurrentlyCleaning = false

    # janitor.Destroy() - cleans everything and makes the janitor unusable
    janitor.Destroy = func():
        janitor.Cleanup()
        table.clear(janitor)

    # janitor.LinkToInstance(instance, allow_multiple?) -> connection
    # cleans Janitor when the instance is destroyed. Without allow_multiple, a new link replaces the old one.
    janitor.LinkToInstance = func(instance, allow_multiple):
        local key = link_key
        if allow_multiple:
            key = {}
        return janitor.Add(instance.Destroyed.Connect(janitor.Cleanup), "Disconnect", key)

    # janitor.LinkToInstances([instance, ...]) -> a new janitor holding the links, so they can be undone together
    janitor.LinkToInstances = func(instance_list):
        local links = Janitor.New()
        for instance in instance_list:
            local is_instance = false
            try:
                is_instance = instance.IsA("Instance")
            catch not_an_instance:
                is_instance = false
            if is_instance:
                links.Add(janitor.LinkToInstance(instance, true), "Disconnect")
        return links

    return janitor

return Janitor

This is very cool! I’d like to hear about your thought process behind creating object-oriented data structures such as this one in TruScript.

Currently TruScript is designed as a procedural language in terms of what users can program, but I want to add support for user classes soon. If you have anything to share about what you’d like to see, I’m very curious.

Since truscript doesn’t have classes yet, I built each object as like a closure, where the constructor makes a table and keeps a state in locals and attaches functions that capture them. There were a few issues when translating the original system from luau to truscript. There’s no “typeof” method, so I used members with try/catch to tell instances, connections and functions apart. Also there are no varargs or multiple returns, so it has limit of 6 values (abcdef) from what I tested

For plr classes, what I’d like to be added personally is maybe methods shared across instances with a self kinda style so every object doesn’t carry its own copy of every function. A built in way to check what class a value is would provably help as well, and varargs would let things like Fire forward any number of arguments

Varargs is a great suggestion. Class functions will be optimized to compile statically and stored in the type header object (even member functions) so they will provide less overhead than wrapping a new closure in each table with your implementation. I will take your other requests into consideration

Thank you very much for your feedback!