├── .gitignore ├── .gitmodules ├── .luacheckrc ├── .luacov ├── .pre-commit-config.yaml ├── .prettierrc.js ├── .vscode └── launch.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Jenkinsfile ├── LICENSE ├── README.md ├── Rodash.rbxlx ├── Rodash.rbxmx ├── build.sh ├── default.project.json ├── docs ├── ReplicatedStorage.png ├── docublox.css └── logo.png ├── docs_source ├── getting-started.md ├── index.md └── types.md ├── package.json ├── place.project.json ├── rodash.code-workspace ├── setup.sh ├── spec ├── Array.spec.lua ├── Async.spec.lua ├── Classes.spec.lua ├── Functions.spec.lua ├── Strings.spec.lua ├── Tables.spec.lua └── init.spec.lua ├── spec_source └── Clock.lua ├── spec_studio ├── Example.server.lua └── Strings.spec.lua ├── src ├── Arrays.lua ├── Async.lua ├── Classes.lua ├── Functions.lua ├── Strings.lua ├── Tables.lua └── init.lua ├── test.sh ├── tools ├── buildDocs.sh ├── checkFormat.sh ├── docublox │ ├── LuaTypes.ts │ ├── astTypings.ts │ ├── generateMakeDocsYml.ts │ ├── generateMd.ts │ ├── index.ts │ ├── typeGrammar.peg │ └── typeParser.js ├── format.sh ├── luacheck.sh └── testInit.lua ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Lua sources 2 | luac.out 3 | 4 | # luarocks build files 5 | *.src.rock 6 | *.zip 7 | *.tar.gz 8 | 9 | # Object files 10 | *.o 11 | *.os 12 | *.ko 13 | *.obj 14 | *.elf 15 | 16 | # Precompiled Headers 17 | *.gch 18 | *.pch 19 | 20 | # Libraries 21 | *.lib 22 | *.a 23 | *.la 24 | *.lo 25 | *.def 26 | *.exp 27 | 28 | # Shared objects (inc. Windows DLLs) 29 | *.dll 30 | *.so 31 | *.so.* 32 | *.dylib 33 | 34 | # Executables 35 | *.exe 36 | *.out 37 | *.app 38 | *.i*86 39 | *.x86_64 40 | *.hex 41 | 42 | # Coverage 43 | /luacov.report.out.index 44 | /cobertura.xml 45 | 46 | # Local Lua installation 47 | /lua_install 48 | 49 | # Local python virtualenv 50 | /tools/venv 51 | 52 | # JUnit test report 53 | /testReport.xml 54 | 55 | /site 56 | mkdocs.yml 57 | /docs/api 58 | /docs/*.md 59 | 60 | node_modules 61 | 62 | # System files 63 | .DS_Store -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "modules/t"] 2 | path = modules/t 3 | url = https://github.com/osyrisrblx/t 4 | [submodule "modules/roblox-lua-promise"] 5 | path = modules/roblox-lua-promise 6 | url = git@github.com:LPGhatguy/roblox-lua-promise.git 7 | [submodule "modules/luassert"] 8 | path = modules/luassert 9 | url = git@github.com:Olivine-Labs/luassert.git 10 | -------------------------------------------------------------------------------- /.luacov: -------------------------------------------------------------------------------- 1 | return { 2 | include = { 3 | "lib", 4 | } 5 | } -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | fail_fast: true 2 | repos: 3 | - repo: local 4 | hooks: 5 | - id: luacheck 6 | name: luacheck 7 | entry: ./tools/luacheck.sh 8 | language: script 9 | files: \.lua$ 10 | - id: lua-fmt 11 | name: lua-fmt 12 | entry: ./tools/format.sh 13 | language: script 14 | files: \.lua$ 15 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | useTabs: true, 3 | printWidth: 100, 4 | singleQuote: true, 5 | trailingComma: 'all', 6 | overrides: [ 7 | { 8 | files: '*.html', 9 | options: { 10 | trailingComma: 'none', 11 | }, 12 | }, 13 | ], 14 | }; 15 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "lrdb", 9 | "request": "attach", 10 | "name": "Lua Attach", 11 | "host": "localhost", 12 | "port": 21110, 13 | "sourceRoot": "${workspaceRoot}", 14 | "stopOnEntry": true 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | ## [2019-09-25] 9 | 10 | * Released v1.1.0 11 | * Added dash.construct as a better alternative to dash.returns for class constructors 12 | 13 | ## [2019-09-23] 14 | 15 | * Released v1.0.0 -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Setup 2 | 3 | Note that you only need to follow these steps if you want to contribute to Rodash. Follow the installation instructions in the documentation if you simply want to use the library. 4 | 5 | 1. Ensure you have Git installed: 6 | 7 | ## Windows 8 | 9 | a. Git for Windows - https://gitforwindows.org/ 10 | 11 | b. Use a Bash terminal (such as Git BASH which comes with Git for Windows) to run `sh` scripts. 12 | 13 | ## Mac 14 | 15 | Already installed! 16 | 17 | 2. Clone the repo locally with `git clone git@github.com:CodeKingdomsTeam/rodash.git`. 18 | 3. Install the OS-specific dependencies: 19 | 20 | ## Windows 21 | 22 | a. Install Python - https://www.python.org/downloads/windows/ 23 | 24 | b. Install Yarn - https://yarnpkg.com/lang/en/docs/install/#windows-stable 25 | 26 | ## Mac 27 | 28 | ```bash 29 | # Install Homebrew (see https://brew.sh/) 30 | /usr/bin/ruby -e "\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" 31 | 32 | # Install package managers 33 | brew install python yarn 34 | ``` 35 | 36 | 4. Run setup.sh 37 | 38 | | Name | Notes | 39 | | -------------------- | -------------------------------------------------------------- | 40 | | `setup.sh` | Installs Lua 5.1 and dependencies locally. | 41 | | `build.sh` | Runs setup, luacheck, tests with coverage and builds the docs. | 42 | | `test.sh` | Run the unit tests. | 43 | | `tools/buildDocs.sh` | Build the docs. | 44 | | `tools/format.sh` | Format the code with `lua-fmt`. | 45 | | `tools/luacheck.sh` | Runs `luacheck` against all source. | 46 | 47 | # Dependencies 48 | 49 | Rodash installs a number of dependencies for managing development and builds in a few different languages & their package managers. Notable dependencies include: 50 | 51 | * Python with pip 52 | * virtualenv - allows Lua 5.1 to be run in a virtual environment to prevent any clash with existing installations. 53 | * hererocks - installs the correct version of Lua in a local directory to avoid clash with existing installations. 54 | * mkdocs - allows the docs website to be generated from markdown files 55 | * precommit - automatically lints and formats lua code during commit 56 | * Typescript with Yarn 57 | * luaparse - parses the Lua source code allowing docs to be automatically generated 58 | * lua-fmt - formats lua source code 59 | * Lua with luarocks 60 | * busted - a unit test harness 61 | * luacov - a coverage tool 62 | * luacheck - a linter for lua 63 | 64 | # Development 65 | 66 | We use [Rojo](https://rojo.space/docs/0.5.x/) to test Rodash during development, and suggest the following workflow: 67 | 68 | 1. Open `Rodash.rbxlx` in Roblox Studio. 69 | 2. Run `rojo serve place.project.json`. 70 | 3. Make sure you have the Rojo plugin installed, and connect to the rojo server. 71 | 4. Test any functions in the `Example` script provided. 72 | 5. Once changes have been made, commit them and make PR to this repo. 73 | 6. Use `rojo build` to use the updated library in your own projects. 74 | 75 | # Versioning 76 | 77 | - We use semver 78 | - Major: Breaking change 79 | - Minor: New features that do not break the existing API 80 | - Patch: Fixes 81 | - We maintain a [CHANGELOG](CHANGELOG.md) 82 | - A branch is maintained for each previous major version so that fixes can be backported. 83 | - E.g. `v1`, `v2` if we are on v3 84 | - Releases are cut from master and tagged when ready e.g. `v1.0.0`. 85 | 86 | # Branching 87 | 88 | - Development should always be done on a branch like `dev-some-descriptive-name`. 89 | 90 | # Discussion 91 | 92 | Please [join the discussion](https://discord.gg/PyaNeN5) on the Studio+ discord server! 93 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | pipeline { 2 | 3 | agent any 4 | 5 | options { 6 | ansiColor('xterm') 7 | } 8 | 9 | stages { 10 | 11 | stage('Setup') { 12 | steps { 13 | sh './setup.sh' 14 | } 15 | } 16 | 17 | stage('Luacheck') { 18 | steps { 19 | sh './tools/luacheck.sh' 20 | } 21 | post { 22 | failure { 23 | githubNotify description: 'Luacheck failed', status: 'FAILURE', context: 'luacheck' 24 | } 25 | success { 26 | githubNotify description: 'Luacheck passed.', status: 'SUCCESS', context: 'luacheck' 27 | } 28 | } 29 | } 30 | 31 | stage('Code style check') { 32 | steps { 33 | sh './tools/checkFormat.sh' 34 | } 35 | post { 36 | failure { 37 | githubNotify description: 'Code style check failed', status: 'FAILURE', context: 'codestyle' 38 | } 39 | success { 40 | githubNotify description: 'Code style check passed.', status: 'SUCCESS', context: 'codestyle' 41 | } 42 | } 43 | } 44 | 45 | stage('Tests') { 46 | steps { 47 | sh 'rm -f luacov.stats.* luacov.report.* testReport.xml cobertura.xml && ./test.sh --verbose --coverage --output junit > testReport.xml && ./lua_install/bin/luacov-cobertura -o cobertura.xml' 48 | } 49 | post { 50 | failure { 51 | githubNotify description: 'Tests failed', status: 'FAILURE', context: 'tests' 52 | } 53 | success { 54 | githubNotify description: 'Tests passed.', status: 'SUCCESS', context: 'tests' 55 | } 56 | } 57 | } 58 | 59 | stage('Record Coverage') { 60 | when { branch 'master' } 61 | steps { 62 | script { 63 | currentBuild.result = 'SUCCESS' 64 | } 65 | step([$class: 'MasterCoverageAction', scmVars: [GIT_URL: env.GIT_URL]]) 66 | } 67 | } 68 | 69 | stage('PR Coverage to Github') { 70 | when { allOf {not { branch 'master' }; expression { return env.CHANGE_ID != null }} } 71 | steps { 72 | script { 73 | currentBuild.result = 'SUCCESS' 74 | } 75 | step([$class: 'CompareCoverageAction', scmVars: [GIT_URL: env.GIT_URL]]) 76 | } 77 | } 78 | } 79 | 80 | post { 81 | always { 82 | junit "testReport.xml" 83 | cobertura coberturaReportFile: 'cobertura.xml' 84 | } 85 | failure { 86 | githubNotify description: 'Build failed.', status: 'ERROR' 87 | } 88 | unstable { 89 | githubNotify description: 'Status checks failed.', status: 'FAILURE' 90 | } 91 | success { 92 | githubNotify description: 'Status checks passed.', status: 'SUCCESS' 93 | } 94 | } 95 | 96 | } 97 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Ceebr Ltd. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![logo](docs/logo.png) 2 | 3 | Rodash is a collection of core functions expanding the capability of Lua in Roblox. 4 | 5 | # [Getting Started](https://codekingdomsteam.github.io/rodash/) 6 | 7 | Please [read the docs](https://codekingdomsteam.github.io/rodash/) to learn how to use Rodash in your games. 8 | 9 | # Contributing 10 | 11 | Please help improve Rodash by submitting any bugs to the [Issue Tracker](https://github.com/CodeKingdomsTeam/rodash/issues) and making a pull request with any new functionality. 12 | 13 | See [Contributing to this project](CONTRIBUTING.md). 14 | 15 | # Roadmap 16 | 17 | Rodash is intended to serve as a standard library for core functionality that Lua is lacking, similar to those found in Typescript and Rust. 18 | 19 | As such, the aim of development is to maintain existing functionality and add standalone helper functions over time, but to avoid adding larger components that would be better suited in a standalone library. 20 | 21 | Examples of ideas for future releases include: 22 | * Math functions (i.e. `round`, `equalWithin`, `withPrecision` etc.) 23 | * More array operations (i.e. `sumBy`, `maxOf` etc.) 24 | * `deserialize` 25 | 26 | Examples of library functions which would be better suited to other libraries: 27 | * Roblox instance management 28 | * Color manipulation 29 | * Advanced logging tools 30 | * Translation tools 31 | * Parsing / lexing tools 32 | 33 | If you have any feature requests, please get in touch! 34 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o nounset 4 | set -o errexit 5 | set -o pipefail 6 | 7 | ./setup.sh 8 | 9 | ./tools/luacheck.sh 10 | 11 | ./test.sh --verbose --coverage "$@" 12 | 13 | luacov-console 14 | luacov-console -s 15 | 16 | ./tools/buildDocs.sh -------------------------------------------------------------------------------- /default.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Packages", 3 | "tree": { 4 | "$className": "Folder", 5 | "Rodash": { 6 | "$path": "src" 7 | }, 8 | "Promise": { 9 | "$path": "modules/roblox-lua-promise/lib" 10 | }, 11 | "t": { 12 | "$path": "modules/t/lib/t.lua" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /docs/ReplicatedStorage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeKingdomsTeam/rodash/aed5efbc8feb8a2745ac09965a2f9a98d0e1c5db/docs/ReplicatedStorage.png -------------------------------------------------------------------------------- /docs/docublox.css: -------------------------------------------------------------------------------- 1 | .docublox-trait { 2 | float: right; 3 | background: #e0eaff; 4 | border-radius: 0.4em; 5 | padding: 0.5em 1em; 6 | font-size: 0.8em; 7 | margin-top: 1em; 8 | } 9 | .md-typeset h1 { 10 | margin: 2em 0 1em; 11 | } 12 | 13 | [data-md-color-primary='grey'] .md-typeset a { 14 | color: #005fe8; 15 | } 16 | -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeKingdomsTeam/rodash/aed5efbc8feb8a2745ac09965a2f9a98d0e1c5db/docs/logo.png -------------------------------------------------------------------------------- /docs_source/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ## Using this documentation 4 | 5 | If you are looking for a specific function check out the [Glossary](/rodash/glossary). 6 | 7 | If you want to simplify your code using a specific programming concept, Rodash functions are grouped by subject: 8 | 9 | | Subject | Description | 10 | | --- | --- | 11 | | [Arrays](/rodash/api/Arrays) | Functions that operate specifically on arrays | 12 | | [Async](/rodash/api/Async) | Functions that improve the experience of working with asynchronous code in Roblox | 13 | | [Classes](/rodash/api/Classes) | Functions that provide implementations of and functions for higher-order abstractions such as classes, enumerations and symbols | 14 | | [Functions](/rodash/api/Functions) | Utility functions and building blocks for functional programming styles | 15 | | [Strings](/rodash/api/Strings) | Useful functions to manipulate strings, based on similar implementations in other standard libraries | 16 | | [Tables](/rodash/api/Tables) | Functions that operate on all kinds of Lua tables | 17 | 18 | The documentation of functions is automatically generated from the source code using comments and type annotations. Rodash uses a complete type langauge for Lua. See the [Types page](/rodash/Types) to learn more about types in Rodash. 19 | 20 | ## Examples 21 | 22 | To understand how Rodash can be helpful in your game, here is an example code snippet which periodically prints the names of players online. We'll simplify it by using Rodash functions: 23 | 24 | ```lua 25 | spawn(function() 26 | while true do 27 | local playerNames = {} 28 | for player in pairs(game.Players:GetPlayers()) do 29 | table.insert(playerNames, player.Name) 30 | end 31 | local nameList = table.concat(playerNames, ",") 32 | print(string.format("Players online = %s: %s", #playerNames, nameList)) 33 | wait(1) 34 | end 35 | end) 36 | ``` 37 | 38 | Running a piece of code periodically is simplest with `dash.setInterval`: 39 | 40 | ```lua 41 | local dash = require(game.ReplicatedStorage.Rodash) 42 | 43 | dash.setInterval(function() 44 | local playerNames = {} 45 | for player in pairs(game.Players:GetPlayers()) do 46 | table.insert(playerNames, player.Name) 47 | end 48 | local nameList = table.concat(playerNames, ",") 49 | print(string.format("Players online = %s: %s", #playerNames, nameList)) 50 | end, 1) 51 | ``` 52 | 53 | You can also cancel an interval when you need to, or use `dash.setTimeout` if you want to run a function after a delay that you can cancel. 54 | 55 | A cleaner way to get the player names from the list of players is using `map`: 56 | 57 | ```lua 58 | local dash = require(game.ReplicatedStorage.Rodash) 59 | 60 | dash.setInterval(function() 61 | local playerNames = dash.map(game.Players:GetPlayers(), function(player) 62 | return player.Name 63 | end) 64 | local nameList = table.concat(playerNames, ",") 65 | print(string.format("Players online = %s: %s", #playerNames, nameList)) 66 | end, 1) 67 | ``` 68 | 69 | Rodash has lots of different methods to operate on tables and arrays. Some other examples are `dash.filter`, `dash.find`, `dash.groupBy` and `dash.slice`. 70 | 71 | Rodash also has lots of primitive functions such as `dash.noop`, `dash.id`, `dash.get` and `dash.bindTail`. We can use these to simplify small functions you write all the time: 72 | 73 | ```lua 74 | local dash = require(game.ReplicatedStorage.Rodash) 75 | 76 | dash.setInterval(function() 77 | local playerNames = dash.map(game.Players:GetPlayers(), dash.bindTail(dash.get, "Name")) 78 | local nameList = table.concat(playerNames, ",") 79 | print(string.format("Players online = %s: %s", #playerNames, nameList)) 80 | end, 1) 81 | ``` 82 | 83 | Here `dash.bindTail` takes `dash.get` which looks up a key (or array of keys) in an object, and returns a function which will get the "Name" property of any object passed to it. This seems unnecessary, but it's often useful to separate functions from the data they act on as the functions can then be used with different inputs. 84 | 85 | Fortunately, we can write this much more simply. All of the Rodash functions which act on data such as `dash.map`, `dash.filter` and `dash.get` are available beneath `dash.fn` as chained functions, which means that they can be strung together in a concise way to form a function which performs the desired action on any input. 86 | 87 | ```lua 88 | local dash = require(game.ReplicatedStorage.Rodash) 89 | local fn = dash.fn 90 | 91 | dash.setInterval(function() 92 | local getNames = fn:map(fn:get("Name")) 93 | local playerNames = getNames(game.Players:GetPlayers()) 94 | local nameList = table.concat(playerNames, ",") 95 | print(string.format("Players online = %s: %s", #playerNames, nameList)) 96 | end, 1) 97 | ``` 98 | 99 | The function `dash.format` can be used to quickly print values that you need from Lua. Specifically, format can print variables using `{}` regardless of what type they are. Here, we can quickly get the length of the playerNames array, and then print the array with `dash.pretty` using the `#?` formatter: 100 | 101 | ```lua 102 | local dash = require(game.ReplicatedStorage.Rodash) 103 | local fn = dash.fn 104 | 105 | dash.setInterval(function() 106 | local getNames = fn:map(fn:get("Name")) 107 | local playerNames = getNames(game.Players:GetPlayers()) 108 | print(dash.format("Players online = {#}: {1:#?}", playerNames)) 109 | end, 1) 110 | ``` 111 | 112 | For example, this might print `Players online = 1: {"builderman"}` every second. 113 | -------------------------------------------------------------------------------- /docs_source/index.md: -------------------------------------------------------------------------------- 1 | ![logo](logo.png) 2 | 3 | # Home 4 | 5 | Rodash is a collection of core functions expanding the capabilities of Lua in Roblox. It borrows ideas from [lodash](https://lodash.com) in JS, some simpler functionality of [Penlight](https://github.com/stevedonovan/Penlight) and standalone helper scripts in circulation among the Roblox community. 6 | 7 | See the [Getting Started](getting-started) page for examples of how you can use Rodash. 8 | 9 | ## Installation 10 | 11 | ## Using the latest release 12 | 13 | 1. Download the latest _rbxmx_ model from the [Github releases page](https://github.com/CodeKingdomsTeam/rodash/releases). 14 | 2. Drag the model file from your _Downloads_ folder into a Roblox Studio project. 15 | 3. Open the `Packages` folder which is created and drag `Rodash` and its siblings into `ReplicatedStorage`. 16 | 17 | ![ReplicatedStorage](ReplicatedStorage.png) 18 | 19 | ### Using Rojo 20 | 21 | If you are familiar with Git and [Rojo](https://rojo.space/docs/0.5.x/) you can also clone the [Rodash repo](https://github.com/CodeKingdomsTeam/rodash/) and incorporate the dependencies from the `default.project.json` file into your own project. 22 | 23 | ## Usage 24 | 25 | Require Rodash in any of your scripts: 26 | 27 | ```lua 28 | local dash = require(game.ReplicatedStorage.Rodash) 29 | 30 | local list = {"cheese"} 31 | dash.append(list, {"nachos"}, {}, {"chillies", "bbq sauce"}) 32 | list --> {"cheese", "nachos", "chillies", "bbq sauce"} 33 | ``` 34 | 35 | If you prefer, you can alias specific Rodash functions yourself: 36 | 37 | ```lua 38 | local dash = require(game.ReplicatedStorage.Rodash) 39 | local append = dash.append 40 | ``` 41 | 42 | 43 | ## Discussion 44 | 45 | If you have any queries or feedback, please [join the discussion](https://discord.gg/PyaNeN5) on the Studio+ discord server! 46 | 47 | Please report any bugs to the [Issue Tracker](https://github.com/CodeKingdomsTeam/rodash/issues). 48 | 49 | ## Design Principles 50 | 51 | The Rodash design principles make it quick and easy to use the library to write concise operations, or incrementally simplify existing Roblox code. 52 | 53 | Functions: 54 | 55 | - **Avoid abstractions**, working on native lua types to avoid enforcing specific coding styles 56 | - **Only do one thing** by avoiding parameter overloading or flags 57 | - **Enforce type safety** to avoid silent error propagation 58 | - **Prefer immutability** to promote functional design and reduce race conditions 59 | - **Avoid duplication**, mimicking existing functionality or aliasing other functions 60 | - **Maintain backwards compatibility** with older versions of the library 61 | -------------------------------------------------------------------------------- /docs_source/types.md: -------------------------------------------------------------------------------- 1 | Every function in Rodash is dynamically and statically typed. 2 | 3 | This means you know what arguments Rodash functions can take and what they can return when you write your code, and Rodash will also check that you've passed in valid values when you run it. 4 | 5 | ## Dynamic Typing 6 | 7 | Dynamic typing means checking that the values passed into a function are valid when the function is run. Rodash functions will throw a `BadInput` error if any arguments are invalid, allowing you to catch errors quickly and fail fast during development. 8 | 9 | Rodash uses [the "t" library](https://github.com/osyrisrblx/t) by Osyris to perform runtime type assertions, which we recommend using in your own code during both development and production. 10 | 11 | ## Static Typing 12 | 13 | Lua is a dynamically-typed language, which means that you can't tell from normal Lua source code what type of values you should use when calling functions, unless you understand how the function internals work. 14 | 15 | Rodash uses a type language that borrows heavily from the Typescript type language. 16 | 17 | Types are added using optional annotations, which are added using `--:`. For example: 18 | 19 | ```lua 20 | --: string, string -> bool 21 | function endsWith(str, suffix) 22 | ``` 23 | 24 | This states that `dash.endsWith` takes two string arguments and returns a boolean. 25 | 26 | ## Lua primitives 27 | 28 | These types correspond to basic Lua types: 29 | 30 | | Name | Type | Description | 31 | | --- | --- | --- | 32 | | Number | `number` | A Lua number | 33 | | String | `string` | A Lua string | 34 | | Boolean | `bool` | A Lua boolean | 35 | | Nil | `nil` | The Lua nil | 36 | | Userdata | `userdata` | A Lua userdata object | 37 | | Table | `table` | A Lua table i.e. with `type(value) == "table"` | 38 | 39 | ## Extended primitives 40 | 41 | | Name | Usage | Description | 42 | | --- | --- | --- | 43 | | Any | `any` | A type representing all of the valid types (excluding fail) | 44 | | Some | `some` | A type representing all of the valid non-nil types | 45 | | Character | `char` | A single character of a string | 46 | | Pattern | `pattern` | A string representing a valid Lua pattern | 47 | | Integer | `int` | An integer | 48 | | Unsigned integer | `uint` | An unsigned (positive) integer | 49 | | Float | `float` | A floating point number | 50 | | Never | `never` | A Promise that never resolves | 51 | | Void | `void` or `()` | An empty tuple, typically to represent an empty return type or empty function-arguments | 52 | | Fail | `fail` | A return value which means that the function will always throw an error | 53 | 54 | ## Structural types 55 | 56 | Like many scripting languages, Lua has a general structure type `table` which can be used to represent arrays, dictionaries, sets, classes, and many other data types. The type language uses a strict set of definitions for different ways tables can be used: 57 | 58 | | Name | Usage | Description | 59 | | --- | --- | --- | 60 | | Tuple | `(X, Y, Z)` | Values of particular types separated by commas, such as an argument or return list in a Lua function | 61 | | Array | `X[]` | A Lua table that has a finite number of [Ordered](/rodash/types/#ordered) keys with values of type `X` | 62 | | Fixed array | `{X, Y, Z}` | An array with a fixed number of values, where the first value has type `X`, the second type `Y` etc. | 63 | | Table | `{}` | a Lua table with no specific keys or values | 64 | | Dictionary | `X{}` | a Lua table which has values of type `X` | 65 | | Map | `{[X]: Y}` | A Lua table with keys of type `X` mapping to values of type `Y` | 66 | | Multi-dimensional table | `X[][]`
`X{}{}`
`X{}[]{}` | A 2d or higher dimensional table, with values being arrays, dictionaries or multi-dimensional tables themselves | 67 | 68 | Rodash methods use more general types where possible, specifically [Ordered](/rodash/types/#ordered) and [Iterable](/rodash/types/#iterable) values. This let's you operate on iterators as well as tables. 69 | 70 | ## Function types 71 | 72 | | Name | Type | Description | 73 | | --- | --- | --- | 74 | | Function | `X, Y -> Z` | A callable value taking parameters with type `X` and `Y` and returning a value of type `Z` | 75 | | Multiple returns | `X -> Y, Z` | A callable value taking a parameter of type `X` and returning two values of type `Y` and `Z` respectively | 76 | | Void function | `() -> ()` | A function that takes no parameters and returns nothing | 77 | | Rest function | `...X -> ...Y` | A function that takes any number of parameters of type `X` and returns any number values of type `Y` | 78 | 79 | ## Modifying types 80 | 81 | These types can be used in function signatures: 82 | 83 | | Name | Usage | Description | 84 | | --- | --- | --- | 85 | | Mutable parameter | `mut X` | A function which takes a value of type `mut X` may modify the value of type `X` during execution | 86 | | Yield return | `yield X` | A function which returns `yield X` may yield when executed before returning a value of type `X` | 87 | | Self | `self X` | A function which takes `self X` as a parameter must be defined using `:` and called as a method on a value of type `X`. Only required if the method should be called on a different type to the one it is defined under | 88 | 89 | ### Callable 90 | 91 | Any value that can be called has a function type. In practice, a value is callable if and only if it is one of the following: 92 | 93 | * A Lua `function` 94 | * A Lua `CFunction` 95 | * A Lua `table` with a metatable that has a valid `__call` property defined 96 | 97 | This is more general than just checking if a value has a function type. 98 | 99 | **Usage** 100 | 101 | You can test if a value is callable using `dash.isCallable`. 102 | 103 | ## Composite types 104 | 105 | | Name | Type | Description | 106 | | --- | --- | --- | 107 | | Optional | `X?` | Equivalent to the type `X | nil`, meaning a value of this type either has type `X` or `nil` | 108 | | Union | `X | Y` | Values of this type can either be of type `X` or of type `Y` (or both) | 109 | | Intersection | `X & Y` | Values of this type must be of both types `X` and `Y`. For example, if `X` and `Y` are interfaces the value must implement both of them. | 110 | 111 | ### Type definitions 112 | 113 | There a few different ways to define new types in documented code: 114 | 115 | | Name | Usage | Description | 116 | | --- | --- | --- | 117 | | Marker type | `type X = Y` | This creates a type called `X` that you can refer to which is the same as the type `Y`. For example, a [Constructor](/rodash/types/#constructor) is just a specific type function, but using `Constructor` also indicates how the function should be used beyond its type | 118 | | Interface | `interface X` | This creates a type called `X` based on functions in a Lua table and a `t` interface | 119 | | Class | `class X` | This creates a type `X` based on methods in a Lua table and fields in a `t` interface, or use `dash.classWithInterface` | 120 | | Enum | `enum X` | This creates a type `X` based on keys of a Lua array, or use `dash.enum` | 121 | | Symbol | `symbol X` | This creates a type `X` based on a Lua table definition, or use `dash.symbol` | 122 | 123 | ## Generic types 124 | 125 | Generics allow the same function to be used with different types. Known types are replaced with type variables, which are usually single capital letters such as `T` or `K`, but can be any word beginning with a capital letter. 126 | 127 | When a generic function is called, the same type variable must refer to the same type everywhere it appears in the type definition. 128 | 129 | | Name | Type | Description | 130 | | --- | --- | --- | 131 | | Generic function | `(T -> T)` | A generic function that takes a parameter of type `T` and returns a value of the same type `T` | 132 | | Generic arguments | `(...A -> A)` | A generic function that takes any number of parameters of type `A` and returns a value of the same type `A` | 133 | | Generic bounds | `(T -> T)` | A function that has a type variable `T` which must satisfy the type `X` | 134 | | Parameterized object | `X` | An object of type `X` that is parameterized to type `T` | 135 | 136 | **Examples** 137 | 138 | The `dash.last` function returns the last element of an array, and has a type signature of `(T[] -> T)`. If you call it with an array of strings with type `string[]`, then `T = string` and the function becomes parameterized, meaning its type is `string[] -> string`. This shows you that the function will return a `string`. If you simply used `any[] -> any` without using a generic type, you couldn't know that the function always returned a value of the same type. 139 | 140 | Like `T[]` was parameterized as a string array when `T = string`, any structural types like dictionaries or [Classes](/rodash/types/#Classes) can also parameterized. 141 | 142 | **Usage** 143 | 144 | Note that using `...X` when `X` refers to a tuple expands the elements from the tuple in-order, as a value can't have a tuple type itself. For example, `dash.id` has the type signature `(...A -> ...A)`. If you call `id(1, "Hello")` then `A = (number, string)` and the function becomes typed as: `number, string -> number, string`. 145 | 146 | ## Iterable types 147 | 148 | ### Iterators 149 | 150 | An iterator is a function which returns `(key, value)` pairs when it is called. You might not use them often in your own code but they are very common in Lua - any loop you write takes an iterator. For example `ipairs(object)` in the code: 151 | ```lua 152 | for key, value in ipairs(object) do 153 | ``` 154 | 155 | They are more abstract than using a concrete table to store data which means you can use them to: 156 | 157 | - Represent an infinite list, such as countable sequences of numbers like the naturals. 158 | - Represent a stream, such as a random stream or events coming from an input source. 159 | - Avoid calculating all the elements of a list at once, such as a lazy list retrieving elements from an HTTP endpoint 160 | 161 | You cannot modify the source of values in an iterator, so they are safer to use if you don't want a function changing the underlying source. 162 | 163 | **Type** 164 | 165 | `type Iterator = (props: any, previousKey: K?) -> K, V` 166 | 167 | **Generics** 168 | 169 | > __K__ - `some` - the primary key type (can be any non-nil value) 170 | > 171 | > __V__ - `some` - the secondary key type (can be any non-nil value) 172 | 173 | ### Stateful Iterator 174 | 175 | ```lua 176 | function statefulIterator() --> K, V 177 | ``` 178 | 179 | Stateful iterators take no arguments and simply return the next `(key, value)` pair when called. These are simple to make and prevent code from skipping or seeking to arbitrary elements in the underlying source. 180 | 181 | You can use `dash.iterator` to create your own iterator for a table. This is useful where you cannot use `pairs` or `ipairs` natively such as when using read-only objects - see `dash.freeze`. 182 | 183 | **Type** 184 | 185 | `(() -> K, V)` 186 | 187 | **Generics** 188 | 189 | > __K__ - `some` - the primary key type (can be any non-nil value) 190 | > 191 | > __V__ - `some` - the secondary key type (can be any non-nil value) 192 | 193 | **Examples** 194 | 195 | ```lua 196 | -- Calling range returns a stateful iterator that counts from a to b. 197 | function range(a, b) 198 | local key = 0 199 | return function() 200 | local value = a + key 201 | key = key + 1 202 | if value <= b then 203 | return key, value 204 | end 205 | end 206 | end 207 | ``` 208 | 209 | **Usage** 210 | 211 | Stateful iterators that you write can be used in any Rodash function that takes an [Iterable](/rodash/types/#iterable). 212 | 213 | ### Stateless Iterator 214 | 215 | ```lua 216 | function statelessIterator(props, previousKey) --> K, V 217 | ``` 218 | 219 | Stateless iterators take two arguments `(props, previousKey)` which are used to address a `(key, value)` pair to return. 220 | 221 | A stateless iterator should return the first `(key, value)` pair if the _previousKey_ `nil` is passed in. 222 | 223 | **Type** 224 | 225 | `((any, K?) -> K, V)` 226 | 227 | **Properties** 228 | 229 | > __props__ - `any` - any value - the static properties that the iterator uses 230 | > __previousKey__ - `K?` - the iterator state type (optional) - (default = `nil`) the previous key acts as the state for the iterator so it doesn't need to store its own state 231 | 232 | **Generics** 233 | 234 | > __K__ - `some` - the primary key type (can be any non-nil value) 235 | > 236 | > __V__ - `some` - the secondary key type (can be any non-nil value) 237 | > 238 | > __S__ - `some` - the iterator state type (can be any non-nil value) 239 | 240 | **Examples** 241 | 242 | ```lua 243 | -- This function is a stateless iterator 244 | function evenNumbers(_, previousKey) 245 | local key = (previousKey or 0) + 1 246 | return key, key * 2 247 | end 248 | ``` 249 | 250 | **Usage** 251 | 252 | Stateless iterators that you write can be used in any Rodash function that takes an [Iterable](/rodash/types/#iterable). 253 | 254 | ### Iterable 255 | 256 | An iterable value is either a dictionary or an [Iterator](/rodash/types/#Iterator). Many Rodash functions can operate on iterator functions as well as tables. 257 | 258 | **Type** 259 | 260 | `type Iterable = {[K]:V} | Iterator` 261 | 262 | **Generics** 263 | 264 | > __K__ - `any` - the primary key type (can be any value) 265 | > 266 | > __V__ - `any` - the primary value type (can be any value) 267 | 268 | **See** 269 | 270 | - [Iterator](/rodash/types/#Iterator) 271 | 272 | ### Ordered 273 | 274 | An ordered value is either an array or an ordered [Iterator](/rodash/types/#Iterator). For something to be ordered, the keys returned during iteration must be the natural number sequence. This means the first key must be `1`, the second `2`, the third `3`, etc. 275 | 276 | Functions that operate on ordered values will ignore any additional keys, including numeric keys after a _hole_, which corresponds with how `ipairs` natively iterates through a table. For example, in the table `{10, 20, [4] = 40, [5] = 50}`, only the first two values are considered ordered. 277 | 278 | **Type** 279 | 280 | `type Ordered = V[] | Iterator` 281 | 282 | **Generics** 283 | 284 | > __V__ - `any` - the primary value type (can be any value) 285 | 286 | **Usage** 287 | 288 | For example, you could write an ordered iterator of numbers, and `dash.first` will to return the first number which matches a particular condition. 289 | 290 | **See** 291 | 292 | - [Iterator](/rodash/types/#Iterator) 293 | 294 | ## Asynchronous types 295 | 296 | ### Promise 297 | 298 | Any promise value created using the [Roblox Lua Promise](https://github.com/LPGhatguy/roblox-lua-promise) library has `Promise` type. See the documentation of this library for examples on how to use promises. 299 | 300 | **Type** 301 | 302 | `interface Promise` 303 | 304 | **Generics** 305 | 306 | > __T__ - `any` - the primary type (can be any value) 307 | 308 | ### Yieldable 309 | 310 | A marker type for a function which may yield. We recommend you use promises instead of writing your own yielding functions as they can have unpredictable behavior, such as causing threads to block outside your control. 311 | 312 | Because of this, only functions which are marked as yieldable are assumed to be capable of yielding. 313 | 314 | **Type** 315 | 316 | `type Yieldable = ... -> yield T` 317 | 318 | **Generics** 319 | 320 | > __T__ - `any` - the primary type (can be any value) 321 | 322 | ### Async 323 | 324 | A marker type for a function which returns a promise. 325 | 326 | **Type** 327 | 328 | `type Async = ... -> Promise` 329 | 330 | **Generics** 331 | 332 | > __T__ - `any` - the primary type (can be any value) 333 | 334 | ## Class types 335 | 336 | ### Class 337 | 338 | Classes are the building block of object-oriented programming and are represented as tables in Lua. Every class instance has a metatable which points back to its class, allowing it to inherit class methods. 339 | 340 | A class definition defines the unique type `T`. Any instance of the class satisfies the type `T`. 341 | 342 | **Type** 343 | 344 | `interface Class` 345 | 346 | **Generics** 347 | 348 | > __T__ - `{}` - the class instance type (can be a table) 349 | 350 | **Properties** 351 | 352 | | Property | Type | Description | 353 | |---|---|---| 354 | | **name** | `string` | the name of the class | 355 | | **new(...)** | `Constructor` | returns a new instance of the class | 356 | 357 | **See** 358 | 359 | * [Classes](/rodash/api/Classes) - for a full list of methods on a class created with `dash.class`. 360 | 361 | ### Constructor 362 | 363 | A marker type for functions which return a new instance of a particular class. 364 | 365 | **Type** 366 | 367 | `type Constructor = ... -> T` 368 | 369 | **Generics** 370 | 371 | > __T__ - `{}` - the class instance type (can be a table) 372 | 373 | ### Enum 374 | 375 | A marker type for an enumeration. An enumeration is a fixed dictionary of unique values that allow 376 | you to name different states that a value can take. 377 | 378 | An enum definition defines the unique type `T`. Any value which is equal to a value in the 379 | enumeration satisfies the type `T`. 380 | 381 | **Type** 382 | 383 | `type Enum = {[key:string]:T}` 384 | 385 | **Generics** 386 | 387 | > __T__ - `some` - the unique type of the enum (can be any non-nil value) 388 | 389 | **See** 390 | 391 | * `dash.enum` - to create and use your own string enums. 392 | 393 | ### Symbol 394 | 395 | A marker type for a symbol. 396 | 397 | An symbol definition defines the unique type `T`. Only the value of the symbol satisfies the type 398 | `T`. 399 | 400 | **Type** 401 | 402 | `type Symbol` 403 | 404 | **Generics** 405 | 406 | > __T__ - `some` - the unique type of the symbol (can be any non-nil value) 407 | 408 | **See** 409 | 410 | * `dash.symbol` - to create and use your own symbols. 411 | 412 | ## Decorator types 413 | 414 | ### Decorator 415 | 416 | A marker type for decorator functions. A decorator is a function that takes a class and returns a 417 | modified version of the class which new behavior. 418 | 419 | **Type** 420 | 421 | `type Decorator = Class -> Class` 422 | 423 | **Generics** 424 | 425 | > __T__ - `{}` - the class instance type (can be a table) 426 | 427 | ### Cloneable 428 | 429 | An interface that lets implementors be cloned. 430 | 431 | **Type** 432 | 433 | `interface Cloneable` 434 | 435 | **Generics** 436 | 437 | > __T__ - `{}` - the class instance type (can be a table) 438 | 439 | **Properties** 440 | 441 | | Property | Type | Description | 442 | |---|---|---| 443 | | **clone()** | `T` | returns a copy of the instance | 444 | 445 | **See** 446 | 447 | * `dash.Cloneable` - to derive a generated implementation of this interface. 448 | 449 | ### Ord 450 | 451 | An interface that means implementors of the same type `T` form a total order, similar to the 452 | [Rust Ord](https://doc.rust-lang.org/std/cmp/trait.Ord.html) trait. 453 | 454 | **Type** 455 | 456 | `interface Ord` 457 | 458 | **Generics** 459 | 460 | > __T__ - `{}` - the class instance type (can be a table) 461 | 462 | **Properties** 463 | 464 | Implementors can be compared using the ordering operators such as `<`, `<=`, and `==`. 465 | 466 | An ordering means that you can compare any elements `a`, `b` and `c`, and these 467 | properties are satisfied: 468 | 469 | * Two elements are always ordered i.e. exactly one of `a < b`, `a == b` or `a > b` is true 470 | * Transitive - if `a < b` and `b < c` then `a < c` 471 | 472 | **See** 473 | 474 | * `dash.Ord` - to derive a generated implementation of this interface. 475 | 476 | ### Eq 477 | 478 | An interface that means implementors of the same type `T` form an equivalence relation, similar to 479 | the [Rust Eq](https://doc.rust-lang.org/std/cmp/trait.Eq.html) trait. 480 | 481 | **Type** 482 | 483 | `interface Eq` 484 | 485 | **Generics** 486 | 487 | > __T__ - `{}` - the class instance type (can be a table) 488 | 489 | **Properties** 490 | 491 | Implementors can be compared using the equality operators such as `==` and `~=`. 492 | 493 | An equivalence relation means that you can compare any elements `a`, `b` and `c`, and these 494 | properties are satisfied: 495 | 496 | * Reflexive - `a == a` is always true 497 | * Symmetric - if `a == b` then `b == a` 498 | * Transitive - if `a == b` and `b == c` then `a == c` 499 | 500 | **See** 501 | 502 | * `dash.ShallowEq` - to derive a generated implementation of this interface. 503 | 504 | ## Chaining types 505 | 506 | ### Chain 507 | 508 | A chain is a function which can operate on a _subject_ and return a value. They are built by 509 | composing a "chain" of functions together, which means that when the chain is called, each 510 | function runs on the subject in order, with the result of one function passed to the next. 511 | 512 | A chain also provides methods based on the [Chainable](/rodash/types/#chainable) functions that the 513 | chain is created with. Each one takes the additional arguments of the chainable and returns a new 514 | chain that has the operation of the function called "queued" to the end of it. 515 | 516 | **Type** 517 | 518 | `type Chain{}> = Chainable & (... -> Chain){}` 519 | 520 | **Generics** 521 | 522 | > __S__ - `any` - the subject type (can be any value) 523 | > 524 | > __T__ - `Chainable{}` - the chain's interface type (can be a dictionary (of [Chainables](/rodash/types/#chainable) (of the subject type))) 525 | 526 | **See** 527 | 528 | * `dash.chain` - to make you own chains 529 | * `dash.fn` - to make a chain with Rodash functions 530 | 531 | ### Chainable 532 | 533 | A marker type for any function that is chainable, meaning it takes a _subject_ as its first 534 | argument and "operates" on that subject in some way using any additional arguments. 535 | 536 | **Type** 537 | 538 | `type Chainable = S, ... -> S` 539 | 540 | **Generics** 541 | 542 | > __S__ - `any` - the subject type (can be any value) 543 | 544 | ### Actor 545 | 546 | A marker type for a function that acts as an actor in a chain. An actor is a function that is 547 | called to determine how each function in the chain should evaluate its arguments and the _subject_ 548 | value. 549 | 550 | By default, a chain simply invokes the function with the subject and additional arguments. 551 | 552 | Actors can be used to transform the types of subjects that functions can naturally deal with, 553 | without having to change the definition of the functions themselves. 554 | 555 | For example the `dash.maybe` actor factory allows functions to be skipped if the subject is `nil`, 556 | and `dash.continue` allows functions to act on promises once they have resolved. 557 | 558 | **Type** 559 | 560 | `type Actor = (S -> S), S, ... -> S` 561 | 562 | **See** 563 | 564 | * `dash.chain` 565 | * `dash.maybe` 566 | * `dash.continue` 567 | 568 | ## Library types 569 | 570 | ### Clearable 571 | 572 | A stateful object with a `clear` method that resets the object state. 573 | 574 | **Type** 575 | 576 | `interface Clearable` 577 | 578 | **Generics** 579 | 580 | > __A__ - `any` - the primary arguments 581 | 582 | **Properties** 583 | 584 | | Property | Type | Description | 585 | |---|---|---| 586 | | **clear(...)** | `...A -> ()` | reset the object state addressed by the arguments provided | 587 | 588 | **See** 589 | 590 | * `dash.setTimeout` 591 | * `dash.setInterval` 592 | * `dash.memoize` 593 | 594 | ### AllClearable 595 | 596 | A stateful object with a `clearAll` method that resets the all parts of the object state. 597 | 598 | **Type** 599 | `interface AllClearable` 600 | 601 | **Properties** 602 | 603 | | Property | Type | Description | 604 | |---|---|---| 605 | | **clearAll()** | `() -> ()` | reset the entire object state | 606 | 607 | **See** 608 | 609 | * `dash.memoize` 610 | 611 | ### DisplayString 612 | 613 | A DisplayString is a `string` that is a valid argument to `dash.formatValue`. Examples include: 614 | 615 | * `#b` - prints a number in its binary representation 616 | * `#?` - pretty prints a table using `dash.pretty` 617 | 618 | **Usage** 619 | 620 | See `dash.format` for a full description of valid display strings. 621 | 622 | 623 | ### SerializeOptions 624 | 625 | Customize how `dash.serialize`, `dash.serializeDeep` and `dash.pretty` convert objects into strings using these options: 626 | 627 | **Type** 628 | 629 | `interface SeralizeOptions>` 630 | 631 | **Generics** 632 | 633 | > __K__ - `any` - the primary key type (can be any value) 634 | > 635 | > __V__ - `any` - the primary value type (can be any value) 636 | > 637 | > __T__ - `Iterable` - An [Iterable](/rodash/types/#iterable) (of the primary key type and the primary value type) 638 | 639 | **Properties** 640 | 641 | | Property | Type | Description | 642 | |---|---|---| 643 | | **keys** | `K[]?` | (optional) if defined, only the keys present in this array will be serialized | 644 | | **omitKeys** | `K[]?` | (optional) an array of keys which should not be serialized | 645 | | **serializeValue(value)** | `V -> string` | (default = `dash.defaultSerializer`) returns the string representation for a value in the object | 646 | | **serializeKey(key)** | `K -> string` | (default = `dash.defaultSerializer`) returns the string representation for a key in the object | 647 | | **serializeElement(keyString, valueString, options)** | `string, string, SerializeOptions -> string` | returns the string representation for a serialized key-value pair | 648 | | **serializeTable(contents, ref, options)** | `string[], string?, SerializeOptions -> string` | (default = returns "{elements}") given an array of serialized table elements and an optional reference, this returns the string representation for a table | 649 | | **keyDelimiter** | `":"` | The string that is put between a serialized key and value pair | 650 | | **valueDelimiter** | `","` | The string that is put between each element of the object | 651 | 652 | ### BackoffOptions 653 | 654 | Customize the function of `dash.retryWithBackoff` using these options: 655 | 656 | **Type** 657 | 658 | `interface SeralizeOptions` 659 | 660 | **Generics** 661 | 662 | > __T__ - `any` - the primary type 663 | 664 | **Properties** 665 | 666 | | Property | Type | Description | 667 | |---|---|---| 668 | | **maxTries** | `int` | how many tries (including the first one) the function should be called | 669 | | **retryExponentInSeconds** | `number` | customize the backoff exponent | 670 | | **retryConstantInSeconds** | `number` | customize the backoff constant | 671 | | **randomStream** | `Random` | use a Roblox "Random" instance to control the backoff | 672 | | **shouldRetry(response)** | `T -> bool` | called if maxTries > 1 to determine whether a retry should occur | 673 | | **onRetry(waitTime, errorMessage)** | `(number, string) -> nil` | a hook for when a retry is triggered, with the delay before retry and error message which caused the failure | 674 | | **onDone(response, durationInSeconds)** | `(T, number) -> nil` | a hook for when the promise resolves | 675 | | **onFail(errorMessage)** | `string -> nil` | a hook for when the promise has failed and no more retries are allowed | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rodash", 3 | "version": "1.0.0", 4 | "main": "build.sh", 5 | "repository": "git@github.com:CodeKingdomsTeam/rodash.git", 6 | "author": "Andrew Moss ", 7 | "license": "MIT", 8 | "devDependencies": { 9 | "argparse": "^1.0.10", 10 | "fs-extra": "^8.1.0", 11 | "lua-fmt": "^2.6.0", 12 | "luaparse": "^0.2.1", 13 | "ts-node": "^8.3.0", 14 | "typescript": "^3.5.3", 15 | "lodash": "^4.17.15" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /place.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Rodash", 3 | "tree": { 4 | "$className": "DataModel", 5 | "HttpService": { 6 | "$className": "HttpService", 7 | "$properties": { 8 | "HttpEnabled": true 9 | } 10 | }, 11 | "Lighting": { 12 | "$className": "Lighting", 13 | "$properties": { 14 | "Ambient": [0.0, 0.0, 0.0], 15 | "Technology": "Voxel", 16 | "Outlines": false, 17 | "GlobalShadows": true, 18 | "Brightness": 2.0 19 | } 20 | }, 21 | "ReplicatedStorage": { 22 | "$className": "ReplicatedStorage", 23 | "Rodash": { 24 | "$path": "src" 25 | }, 26 | "Promise": { 27 | "$path": "modules/roblox-lua-promise/lib" 28 | }, 29 | "t": { 30 | "$path": "modules/t/lib/t.lua" 31 | } 32 | }, 33 | "ServerScriptService": { 34 | "$className": "ServerScriptService", 35 | "$path": "spec_studio" 36 | }, 37 | "SoundService": { 38 | "$className": "SoundService", 39 | "$properties": { 40 | "RespectFilteringEnabled": true 41 | } 42 | }, 43 | "Workspace": { 44 | "$className": "Workspace", 45 | "$properties": { 46 | "FilteringEnabled": true 47 | }, 48 | "Baseplate": { 49 | "$className": "Part", 50 | "$properties": { 51 | "Anchored": true, 52 | "Locked": true, 53 | "Position": [0.0, -10.0, 0.0], 54 | "Size": [512.0, 20.0, 512.0], 55 | "Color": [0.38823, 0.37254, 0.38823] 56 | } 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /rodash.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ] 7 | } -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o nounset 4 | set -o errexit 5 | set -o pipefail 6 | 7 | # Inspiration from https://github.com/LPGhatguy/lemur/blob/master/.travis.yml 8 | 9 | git submodule sync --recursive 10 | git submodule update --init --recursive 11 | 12 | yarn install --frozen-lockfile --non-interactive 13 | 14 | export LUA="lua=5.1" 15 | 16 | # Use homebrew pip on OS X 17 | $(which pip2.7 || which pip) install virtualenv 18 | 19 | # Add user local bin for Jenkins 20 | export PATH="$HOME/.local/bin:$PATH" 21 | 22 | virtualenv tools/venv 23 | VIRTUAL_ENV_DISABLE_PROMPT=true source tools/venv/bin/activate 24 | 25 | $(which pip2.7 || which pip) install hererocks==0.19.0 26 | 27 | hererocks lua_install -r^ --$LUA 28 | 29 | export PATH="$PWD/lua_install/bin:$PATH" 30 | 31 | ROCKS=('busted 2.0.rc12-1' 'luacov 0.13.0-1' 'luacov-console 1.1.0-1' 'luacov-cobertura 0.2-1' 'luacheck 0.22.1-1') 32 | 33 | for ROCK in "${ROCKS[@]}" 34 | do 35 | ROCKS_ARGS=($ROCK) 36 | if ! luarocks show "${ROCKS_ARGS[@]}" > /dev/null 37 | then 38 | luarocks install "${ROCKS_ARGS[@]}" 39 | fi 40 | done 41 | 42 | pip install pre-commit==1.8.2 mkdocs mkdocs-material 43 | pre-commit install 44 | -------------------------------------------------------------------------------- /spec/Array.spec.lua: -------------------------------------------------------------------------------- 1 | local Arrays = require "Arrays" 2 | 3 | local function getIteratorForRange(firstNumber, lastNumber) 4 | local i = 0 5 | return function() 6 | local currentNumber = firstNumber + i 7 | if currentNumber > lastNumber then 8 | return 9 | else 10 | local currentIndex = i 11 | i = i + 1 12 | return currentIndex, currentNumber 13 | end 14 | end 15 | end 16 | 17 | describe( 18 | "Array", 19 | function() 20 | describe( 21 | "slice", 22 | function() 23 | it( 24 | "slices", 25 | function() 26 | local x = {"h", "e", "l", "l", "o"} 27 | 28 | assert.are.same({"h", "e", "l"}, Arrays.slice(x, 1, 3)) 29 | end 30 | ) 31 | it( 32 | "slices with gap", 33 | function() 34 | local x = {"h", "e", "l", "l", "o"} 35 | 36 | assert.are.same({"h", "l", "o"}, Arrays.slice(x, 1, 5, 2)) 37 | end 38 | ) 39 | end 40 | ) 41 | describe( 42 | "first", 43 | function() 44 | it( 45 | "works for no handler", 46 | function() 47 | local x = {20, 30, 40, 10} 48 | assert.are.same({20, 1}, {Arrays.first(x)}) 49 | end 50 | ) 51 | it( 52 | "works for a simple handler", 53 | function() 54 | local x = {20, 30, 40, 10} 55 | assert.are.same( 56 | {30, 2}, 57 | { 58 | Arrays.first( 59 | x, 60 | function(value) 61 | return value > 25 62 | end 63 | ) 64 | } 65 | ) 66 | end 67 | ) 68 | it( 69 | "works for a missing element", 70 | function() 71 | local x = {20, 30, 40, 10} 72 | assert.is_nil( 73 | Arrays.first( 74 | x, 75 | function(value) 76 | return value > 45 77 | end 78 | ) 79 | ) 80 | end 81 | ) 82 | it( 83 | "works for a handler on a key", 84 | function() 85 | local x = {20, 30, 40, 10} 86 | assert.are.same( 87 | {10, 4}, 88 | { 89 | Arrays.first( 90 | x, 91 | function(value, key) 92 | return key > 3 93 | end 94 | ) 95 | } 96 | ) 97 | end 98 | ) 99 | it( 100 | "doesn't detect unnatural keys", 101 | function() 102 | local x = {20, nil, nil, 10} 103 | assert.is_nil( 104 | Arrays.first( 105 | x, 106 | function(value, key) 107 | return key > 3 108 | end 109 | ) 110 | ) 111 | end 112 | ) 113 | end 114 | ) 115 | 116 | describe( 117 | "last", 118 | function() 119 | it( 120 | "works for no handler", 121 | function() 122 | local x = {20, 30, 40, 10} 123 | assert.are.same({10, 4}, {Arrays.last(x)}) 124 | end 125 | ) 126 | it( 127 | "works for a simple handler", 128 | function() 129 | local x = {20, 30, 40, 10} 130 | assert.are.same( 131 | {40, 3}, 132 | { 133 | Arrays.last( 134 | x, 135 | function(value) 136 | return value > 25 137 | end 138 | ) 139 | } 140 | ) 141 | end 142 | ) 143 | it( 144 | "works for a missing element", 145 | function() 146 | local x = {20, 30, 40, 10} 147 | assert.is_nil( 148 | Arrays.last( 149 | x, 150 | function(value) 151 | return value > 45 152 | end 153 | ) 154 | ) 155 | end 156 | ) 157 | it( 158 | "works for a handler on a key", 159 | function() 160 | local x = {20, 30, 40, 10} 161 | assert.are.same( 162 | {30, 2}, 163 | { 164 | Arrays.last( 165 | x, 166 | function(value, key) 167 | return key < 3 168 | end 169 | ) 170 | } 171 | ) 172 | end 173 | ) 174 | end 175 | ) 176 | 177 | describe( 178 | "shuffle", 179 | function() 180 | it( 181 | "uses math.random to randomize the order of elements in an array", 182 | function() 183 | local x = {20, 30, 40, 10} 184 | local i = 0 185 | local oldRandom = math.random 186 | -- luacheck: push ignore 122 187 | math.random = function() 188 | i = i + 1 189 | return i 190 | end 191 | assert.are.same({20, 30, 40, 10}, Arrays.shuffle(x)) 192 | math.random = oldRandom 193 | -- luacheck: pop 194 | end 195 | ) 196 | end 197 | ) 198 | 199 | describe( 200 | "sort", 201 | function() 202 | local cases = { 203 | { 204 | input = {1, 3, 2}, 205 | expected = {1, 2, 3}, 206 | name = "with no comparator" 207 | }, 208 | { 209 | input = {1, "mission", 3, "impossible", 2, true}, 210 | expected = {true, 1, 2, 3, "impossible", "mission"}, 211 | name = "elements of different types" 212 | }, 213 | { 214 | input = {"use", "the", "force", "Luke"}, 215 | expected = {"Luke", "force", "the", "use"}, 216 | name = "with strings" 217 | }, 218 | { 219 | input = {1, 3, 2}, 220 | expected = {3, 2, 1}, 221 | comparator = function(a, b) 222 | return a > b 223 | end, 224 | name = "with a comparator" 225 | }, 226 | { 227 | input = {1, 3, 2}, 228 | expected = {3, 2, 1}, 229 | comparator = function(a, b) 230 | return b - a 231 | end, 232 | name = "with a numeric comparator" 233 | } 234 | } 235 | 236 | for _, case in ipairs(cases) do 237 | it( 238 | case.name, 239 | function() 240 | local result = Arrays.sort(case.input, case.comparator) 241 | assert.are.same(case.expected, result) 242 | end 243 | ) 244 | end 245 | 246 | it( 247 | "throws if the comparator returns a bad value", 248 | function() 249 | assert.has_errors( 250 | function() 251 | Arrays.sort( 252 | {1, 3, 2}, 253 | function(a, b) 254 | return "throws" 255 | end 256 | ) 257 | end 258 | ) 259 | end 260 | ) 261 | end 262 | ) 263 | 264 | describe( 265 | "reduce", 266 | function() 267 | it( 268 | "returns the base case for an empty array", 269 | function() 270 | assert.are.same( 271 | "f", 272 | Arrays.reduce( 273 | {}, 274 | function(prev, next) 275 | return prev .. next 276 | end, 277 | "f" 278 | ) 279 | ) 280 | end 281 | ) 282 | it( 283 | "applies an iterator to reduce a table", 284 | function() 285 | assert.are.same( 286 | "fabcde", 287 | Arrays.reduce( 288 | {"a", "b", "c", "d", "e"}, 289 | function(prev, next) 290 | return prev .. next 291 | end, 292 | "f" 293 | ) 294 | ) 295 | end 296 | ) 297 | it( 298 | "can operate on the index", 299 | function() 300 | assert.are.same( 301 | "f1a2b3c4d5e", 302 | Arrays.reduce( 303 | {"a", "b", "c", "d", "e"}, 304 | function(prev, next, i) 305 | return (prev or "f") .. i .. next 306 | end 307 | ) 308 | ) 309 | end 310 | ) 311 | it( 312 | "works when passed an iterator", 313 | function() 314 | assert.are.same( 315 | 15, 316 | Arrays.reduce( 317 | getIteratorForRange(1, 5), 318 | function(prev, next, i) 319 | return prev + next 320 | end, 321 | 0 322 | ) 323 | ) 324 | end 325 | ) 326 | end 327 | ) 328 | 329 | describe( 330 | "reverse", 331 | function() 332 | it( 333 | "reverses an array", 334 | function() 335 | assert.are.same({1, 2, 3, 4, 5}, Arrays.reverse({5, 4, 3, 2, 1})) 336 | end 337 | ) 338 | end 339 | ) 340 | 341 | describe( 342 | "append", 343 | function() 344 | it( 345 | "concatenates ordered elements, ignoring unnatural keys", 346 | function() 347 | local a = {7, 4} 348 | local b = function(_, prevValue) 349 | if prevValue == nil then 350 | local key = 1 351 | local value = 9 352 | return key, value 353 | end 354 | end 355 | local c = {[1] = 5, x = 12} 356 | 357 | assert.are.same({7, 4, 9, 5}, Arrays.append(a, b, c)) 358 | assert.are.same({7, 4, 9, 5}, a) 359 | end 360 | ) 361 | it( 362 | "adds no values onto an array", 363 | function() 364 | local target = {"a", "b"} 365 | Arrays.append(target, {}) 366 | assert.are.same({"a", "b"}, target) 367 | end 368 | ) 369 | it( 370 | "adds values onto an empty array", 371 | function() 372 | local target = {} 373 | Arrays.append(target, {"a", "b"}) 374 | assert.are.same({"a", "b"}, target) 375 | end 376 | ) 377 | end 378 | ) 379 | end 380 | ) 381 | -------------------------------------------------------------------------------- /spec/Async.spec.lua: -------------------------------------------------------------------------------- 1 | local Async = require "Async" 2 | local Functions = require "Functions" 3 | local Promise = require "roblox-lua-promise" 4 | local match = require "luassert.match" 5 | local Clock = require "spec_source.Clock" 6 | 7 | describe( 8 | "Async", 9 | function() 10 | local clock, advanceAndAssertPromiseResolves, advanceAndAssertPromiseRejects 11 | 12 | before_each( 13 | function() 14 | clock = Clock.setup() 15 | advanceAndAssertPromiseResolves = function(promise, assertion) 16 | local andThen = 17 | spy.new( 18 | function(...) 19 | local ok, error = pcall(assertion, ...) 20 | if not ok then 21 | print("[Assertion Error]", error) 22 | end 23 | return ok 24 | end 25 | ) 26 | local err = spy.new(warn) 27 | promise:andThen(andThen):catch(err) 28 | if tick() == 0 then 29 | assert.spy(andThen).was_not_called() 30 | wait(1) 31 | clock:process() 32 | end 33 | assert.spy(andThen).was.returned_with(true) 34 | assert.spy(err).was_not_called() 35 | end 36 | 37 | advanceAndAssertPromiseRejects = function(promise, message) 38 | local err = spy.new(warn) 39 | promise:catch(err) 40 | if tick() == 0 then 41 | assert.spy(err).was_not_called() 42 | wait(1) 43 | clock:process() 44 | end 45 | assert(err.calls[1].vals[1]:find(message)) 46 | end 47 | end 48 | ) 49 | after_each( 50 | function() 51 | clock:teardown() 52 | end 53 | ) 54 | describe( 55 | "parallel", 56 | function() 57 | it( 58 | "resolves for an array of promises", 59 | function() 60 | local one = Async.resolve(1) 61 | local two = Async.resolve(2) 62 | local three = 63 | Async.delay(1):andThen( 64 | function() 65 | return 3 66 | end 67 | ) 68 | advanceAndAssertPromiseResolves( 69 | Async.parallel({one, two, three}), 70 | function(result) 71 | assert.equal(tick(), 1) 72 | assert.are_same({1, 2, 3}, result) 73 | end 74 | ) 75 | end 76 | ) 77 | it( 78 | "resolves for a mixed array", 79 | function() 80 | local one = 1 81 | local two = 2 82 | local three = 83 | Async.delay(1):andThen( 84 | function() 85 | return 3 86 | end 87 | ) 88 | advanceAndAssertPromiseResolves( 89 | Async.parallel({one, two, three}), 90 | function(result) 91 | assert.equal(tick(), 1) 92 | assert.are_same({1, 2, 3}, result) 93 | end 94 | ) 95 | end 96 | ) 97 | it( 98 | "resolves for an empty array", 99 | function() 100 | wait(1) 101 | advanceAndAssertPromiseResolves( 102 | Async.parallel({}), 103 | function(result) 104 | assert.equal(0, #result) 105 | end 106 | ) 107 | end 108 | ) 109 | it( 110 | "rejects if any promise rejects", 111 | function() 112 | wait(1) 113 | local one = Async.resolve(1) 114 | local two = Promise.reject("ExpectedError") 115 | local three = 116 | Async.delay(1):andThen( 117 | function() 118 | return 3 119 | end 120 | ) 121 | advanceAndAssertPromiseRejects(Async.parallel({one, two, three}), "ExpectedError") 122 | end 123 | ) 124 | end 125 | ) 126 | describe( 127 | "resolve", 128 | function() 129 | it( 130 | "resolves for multiple return values", 131 | function() 132 | local andThen = 133 | spy.new( 134 | function() 135 | end 136 | ) 137 | Async.resolve(1, 2, 3):andThen(andThen) 138 | assert.spy(andThen).called_with(1, 2, 3) 139 | end 140 | ) 141 | it( 142 | "resolves a promise", 143 | function() 144 | local andThen = 145 | spy.new( 146 | function() 147 | end 148 | ) 149 | Async.resolve(Async.resolve(1)):andThen(andThen) 150 | assert.spy(andThen).called_with(1) 151 | end 152 | ) 153 | end 154 | ) 155 | describe( 156 | "parallelAll", 157 | function() 158 | it( 159 | "resolves for an object of promises", 160 | function() 161 | local one = Async.resolve(1) 162 | local two = Async.resolve(2) 163 | local three = 164 | Async.delay(1):andThen( 165 | function() 166 | return 3 167 | end 168 | ) 169 | advanceAndAssertPromiseResolves( 170 | Async.parallelAll({one = one, two = two, three = three}), 171 | function(result) 172 | assert.equal(tick(), 1) 173 | assert.are_same({one = 1, two = 2, three = 3}, result) 174 | end 175 | ) 176 | end 177 | ) 178 | it( 179 | "resolves for a mixed object", 180 | function() 181 | local one = 1 182 | local two = 2 183 | local three = 184 | Async.delay(1):andThen( 185 | function() 186 | return 3 187 | end 188 | ) 189 | advanceAndAssertPromiseResolves( 190 | Async.parallelAll({one = one, two = two, three = three}), 191 | function(result) 192 | assert.equal(tick(), 1) 193 | assert.are_same({one = 1, two = 2, three = 3}, result) 194 | end 195 | ) 196 | end 197 | ) 198 | it( 199 | "rejects if any promise rejects", 200 | function() 201 | wait(1) 202 | local one = Async.resolve(1) 203 | local two = Promise.reject("ExpectedError") 204 | local three = 205 | Async.delay(1):andThen( 206 | function() 207 | return 3 208 | end 209 | ) 210 | advanceAndAssertPromiseRejects(Async.parallelAll({one = one, two = two, three = three}), "ExpectedError") 211 | end 212 | ) 213 | end 214 | ) 215 | describe( 216 | "delay", 217 | function() 218 | it( 219 | "promise which resolves after a delay", 220 | function() 221 | advanceAndAssertPromiseResolves( 222 | Async.delay(1), 223 | function() 224 | assert.equal(tick(), 1) 225 | end 226 | ) 227 | end 228 | ) 229 | end 230 | ) 231 | describe( 232 | "finally", 233 | function() 234 | it( 235 | "run after a resolution", 236 | function() 237 | local handler = 238 | spy.new( 239 | function() 240 | end 241 | ) 242 | Async.finally(Async.resolve("ok"), handler) 243 | assert.spy(handler).called_with(true, "ok") 244 | end 245 | ) 246 | it( 247 | "run after a rejection", 248 | function() 249 | local handler = 250 | spy.new( 251 | function() 252 | end 253 | ) 254 | Async.finally(Promise.reject("bad"), handler) 255 | assert.spy(handler).called_with(false, "bad") 256 | end 257 | ) 258 | end 259 | ) 260 | describe( 261 | "async", 262 | function() 263 | it( 264 | "can wrap a function that returns in a promise", 265 | function() 266 | local function cooks(food) 267 | wait(1) 268 | return "hot-" .. food 269 | end 270 | advanceAndAssertPromiseResolves( 271 | Async.async(cooks)("bread"), 272 | function(result) 273 | assert.equal("hot-bread", result) 274 | end 275 | ) 276 | end 277 | ) 278 | it( 279 | "can wrap a function that errors in a promise", 280 | function() 281 | local function burns(food) 282 | wait(1) 283 | error("Burnt " .. food) 284 | end 285 | advanceAndAssertPromiseRejects(Async.async(burns)("bread"), "Burnt bread") 286 | end 287 | ) 288 | end 289 | ) 290 | describe( 291 | "race", 292 | function() 293 | it( 294 | "resolves immediately for no promises", 295 | function() 296 | local andThen = 297 | spy.new( 298 | function() 299 | end 300 | ) 301 | Async.race({}, 0):andThen(andThen) 302 | assert.spy(andThen).called_with(match.is_same({})) 303 | end 304 | ) 305 | it( 306 | "resolves first promise", 307 | function() 308 | advanceAndAssertPromiseResolves( 309 | Async.race( 310 | { 311 | Async.delay(1):andThen(Functions.returns("One")), 312 | Async.delay(2):andThen(Functions.returns("Two")), 313 | Async.delay(3):andThen(Functions.returns("Three")) 314 | } 315 | ), 316 | function(result) 317 | assert.are_same({"One"}, result) 318 | end 319 | ) 320 | end 321 | ) 322 | it( 323 | "resolves two promises", 324 | function() 325 | advanceAndAssertPromiseResolves( 326 | Async.race( 327 | { 328 | Async.delay(0.6):andThen(Functions.returns("One")), 329 | Async.delay(3):andThen(Functions.throws("UnexpectedError")), 330 | Async.delay(0.5):andThen(Functions.returns("Three")) 331 | }, 332 | 2 333 | ), 334 | function(result) 335 | assert.are_same({"Three", "One"}, result) 336 | end 337 | ) 338 | end 339 | ) 340 | it( 341 | "rejects when not enough promises found", 342 | function() 343 | advanceAndAssertPromiseRejects( 344 | Async.race( 345 | { 346 | Async.delay(0.8):andThen(Functions.returns("One")), 347 | Async.delay(0.6):andThen(Functions.throws("ExpectedError")), 348 | Async.delay(0.5):andThen(Functions.returns("Three")) 349 | }, 350 | 2 351 | ), 352 | "ExpectedError" 353 | ) 354 | end 355 | ) 356 | end 357 | ) 358 | describe( 359 | "timeout", 360 | function() 361 | it( 362 | "can timeout after a delay", 363 | function() 364 | local promise = Async.delay(2) 365 | local timeout = Async.timeout(promise, 1, "ExpectedError") 366 | advanceAndAssertPromiseRejects(timeout, "ExpectedError") 367 | end 368 | ) 369 | it( 370 | "can resolve within delay", 371 | function() 372 | local promise = Async.delay(1):andThen(Functions.returns("Ok")) 373 | local timeout = Async.timeout(promise, 2) 374 | advanceAndAssertPromiseResolves(timeout, Functions.noop) 375 | end 376 | ) 377 | it( 378 | "can reject", 379 | function() 380 | local promise = Async.delay(1):andThen(Functions.throws("ExpectedError")) 381 | local timeout = Async.timeout(promise, 10) 382 | advanceAndAssertPromiseRejects(timeout, "ExpectedError") 383 | end 384 | ) 385 | end 386 | ) 387 | describe( 388 | "retryWithBackoff", 389 | function() 390 | it( 391 | "calls a fn repeatedly until it fails", 392 | function() 393 | local n = 0 394 | local function getPromise() 395 | n = n + 1 396 | return Promise.reject("Fail " .. n) 397 | end 398 | local shouldRetry = 399 | spy.new( 400 | function() 401 | return true 402 | end 403 | ) 404 | local onRetry = 405 | spy.new( 406 | function() 407 | end 408 | ) 409 | local onDone = 410 | spy.new( 411 | function() 412 | end 413 | ) 414 | local onFail = 415 | spy.new( 416 | function() 417 | end 418 | ) 419 | local andThen = 420 | spy.new( 421 | function() 422 | end 423 | ) 424 | local err = 425 | spy.new( 426 | function() 427 | end 428 | ) 429 | Async.retryWithBackoff( 430 | getPromise, 431 | { 432 | maxTries = 3, 433 | retryExponentInSeconds = 1, 434 | retryConstantInSeconds = 1, 435 | randomStream = Random.new(), 436 | shouldRetry = shouldRetry, 437 | onRetry = onRetry, 438 | onDone = onDone, 439 | onFail = onFail 440 | } 441 | ):andThen(andThen):catch(err) 442 | assert.spy(andThen).was_not_called() 443 | assert.spy(onRetry).was_called_with(2, "Fail 1") 444 | wait(2) 445 | clock:process() 446 | assert.spy(andThen).was_not_called() 447 | assert.spy(onRetry).was_called_with(3, "Fail 2") 448 | wait(3) 449 | clock:process() 450 | assert.spy(andThen).was_not_called() 451 | assert.equal(2, #onRetry.calls) 452 | assert.spy(andThen).was_not_called() 453 | assert.spy(onFail).was_called_with("Fail 3") 454 | assert.spy(onDone).was_not_called() 455 | assert(err.calls[1].vals[1]:find("Fail 3")) 456 | assert.equal(2, #onRetry.calls) 457 | end 458 | ) 459 | it( 460 | "calls a fn repeatedly until it succeeds", 461 | function() 462 | local n = 0 463 | local function getPromise() 464 | n = n + 1 465 | if n < 4 then 466 | return Promise.reject("Fail " .. n) 467 | else 468 | return Async.resolve("Success") 469 | end 470 | end 471 | local shouldRetry = 472 | spy.new( 473 | function() 474 | return true 475 | end 476 | ) 477 | local onRetry = 478 | spy.new( 479 | function() 480 | end 481 | ) 482 | local onDone = 483 | spy.new( 484 | function() 485 | end 486 | ) 487 | local onFail = 488 | spy.new( 489 | function() 490 | end 491 | ) 492 | local andThen = 493 | spy.new( 494 | function() 495 | end 496 | ) 497 | Async.retryWithBackoff( 498 | getPromise, 499 | { 500 | maxTries = 5, 501 | retryExponentInSeconds = 1, 502 | retryConstantInSeconds = 1, 503 | randomStream = Random.new(), 504 | shouldRetry = shouldRetry, 505 | onRetry = onRetry, 506 | onDone = onDone, 507 | onFail = onFail 508 | } 509 | ):andThen(andThen) 510 | assert.spy(andThen).was_not_called() 511 | assert.spy(onRetry).was_called_with(2, "Fail 1") 512 | wait(2) 513 | clock:process() 514 | assert.spy(andThen).was_not_called() 515 | assert.spy(onRetry).was_called_with(3, "Fail 2") 516 | wait(3) 517 | clock:process() 518 | assert.spy(andThen).was_not_called() 519 | assert.spy(onRetry).was_called_with(4, "Fail 3") 520 | wait(4) 521 | clock:process() 522 | assert.spy(andThen).was_called_with("Success") 523 | assert.spy(onFail).was_not_called() 524 | assert.spy(onDone).was_called_with("Success", 9) 525 | assert.equal(3, #onRetry.calls) 526 | end 527 | ) 528 | end 529 | ) 530 | end 531 | ) 532 | -------------------------------------------------------------------------------- /spec/Functions.spec.lua: -------------------------------------------------------------------------------- 1 | local Functions = require "Functions" 2 | local Clock = require "spec_source.Clock" 3 | 4 | describe( 5 | "Functions", 6 | function() 7 | local callSpy, clock 8 | before_each( 9 | function() 10 | callSpy = 11 | spy.new( 12 | function(...) 13 | return {..., n = select("#", ...)} 14 | end 15 | ) 16 | clock = Clock.setup() 17 | end 18 | ) 19 | after_each( 20 | function() 21 | clock:teardown() 22 | end 23 | ) 24 | describe( 25 | "id", 26 | function() 27 | it( 28 | "passes through args", 29 | function() 30 | assert.are.same({1, 2, 3}, {Functions.id(1, 2, 3)}) 31 | end 32 | ) 33 | end 34 | ) 35 | describe( 36 | "noop", 37 | function() 38 | it( 39 | "eliminates args", 40 | function() 41 | assert.are.same({}, {Functions.noop(1, 2, 3)}) 42 | end 43 | ) 44 | end 45 | ) 46 | describe( 47 | "returns", 48 | function() 49 | it( 50 | "returns as expected", 51 | function() 52 | assert.are.same({1, 2, 3}, {Functions.returns(1, 2, 3)(4, 5, 6)}) 53 | end 54 | ) 55 | end 56 | ) 57 | describe( 58 | "throws", 59 | function() 60 | it( 61 | "throws as expected", 62 | function() 63 | assert.errors( 64 | function() 65 | Functions.throws("ExpectedError")(4, 5, 6) 66 | end, 67 | "ExpectedError" 68 | ) 69 | end 70 | ) 71 | end 72 | ) 73 | describe( 74 | "once", 75 | function() 76 | it( 77 | "runs once as expected", 78 | function() 79 | local count = 0 80 | local once = 81 | Functions.once( 82 | function(amount) 83 | count = count + amount 84 | return count 85 | end 86 | ) 87 | assert.equal(3, once(3)) 88 | assert.equal(3, once(42)) 89 | assert.equal(3, once(1337)) 90 | end 91 | ) 92 | end 93 | ) 94 | describe( 95 | "compose", 96 | function() 97 | it( 98 | "composes functions as expected", 99 | function() 100 | local function fry(item) 101 | return "fried " .. item 102 | end 103 | local function cheesify(item) 104 | return "cheesy " .. item 105 | end 106 | local prepare = Functions.compose(fry, cheesify) 107 | assert.equal("cheesy fried nachos", prepare("nachos")) 108 | end 109 | ) 110 | end 111 | ) 112 | describe( 113 | "bind", 114 | function() 115 | it( 116 | "binds functions as expected", 117 | function() 118 | local function fry(item, amount) 119 | return "fry " .. item .. " " .. amount .. " times" 120 | end 121 | local fryChips = Functions.bind(fry, "chips") 122 | assert.equal("fry chips 10 times", fryChips(10)) 123 | end 124 | ) 125 | end 126 | ) 127 | 128 | describe( 129 | "isCallable", 130 | function() 131 | it( 132 | "true for a function", 133 | function() 134 | assert( 135 | Functions.isCallable( 136 | function() 137 | end 138 | ) 139 | ) 140 | end 141 | ) 142 | 143 | it( 144 | "true for a table with a __call metamethod", 145 | function() 146 | local tbl = {} 147 | 148 | setmetatable( 149 | tbl, 150 | { 151 | __call = function() 152 | end 153 | } 154 | ) 155 | 156 | assert(Functions.isCallable(tbl)) 157 | end 158 | ) 159 | 160 | it( 161 | "false for a table without a __call metamethod", 162 | function() 163 | assert(not Functions.isCallable({})) 164 | end 165 | ) 166 | end 167 | ) 168 | 169 | describe( 170 | "memoize", 171 | function() 172 | it( 173 | "Can memoize with default rule", 174 | function() 175 | local c = 10 176 | local memoizedSum = 177 | Functions.memoize( 178 | function(a, b) 179 | return a + b + c 180 | end 181 | ) 182 | assert.equal(13, memoizedSum(1, 2)) 183 | c = 20 184 | assert.equal(13, memoizedSum(1, 2)) 185 | end 186 | ) 187 | 188 | it( 189 | "Can memoize with a custom cache key generator", 190 | function() 191 | local c = 10 192 | local memoizedSum = 193 | Functions.memoize( 194 | function(a, b) 195 | return a + b + c 196 | end, 197 | function(args) 198 | return args[1] 199 | end 200 | ) 201 | assert.equal(13, memoizedSum(1, 2)) 202 | c = 20 203 | assert.equal(13, memoizedSum(1, 10)) 204 | assert.equal(33, memoizedSum(2, 11)) 205 | end 206 | ) 207 | 208 | it( 209 | "Can clear the cache with a custom cache key generator", 210 | function() 211 | local c = 10 212 | local memoizedSum = 213 | Functions.memoize( 214 | function(a, b) 215 | return a + b + c 216 | end, 217 | function(args) 218 | return args[1] 219 | end 220 | ) 221 | assert.equal(13, memoizedSum(1, 2)) 222 | c = 20 223 | assert.equal(13, memoizedSum(1, 10)) 224 | memoizedSum:clear(1) 225 | assert.equal(31, memoizedSum(1, 10)) 226 | end 227 | ) 228 | end 229 | ) 230 | describe( 231 | "setTimeout", 232 | function() 233 | it( 234 | "calls a function after a delay", 235 | function() 236 | Functions.setTimeout(callSpy, 2) 237 | assert.spy(callSpy).was_not_called() 238 | wait(3) 239 | clock:process() 240 | assert.spy(callSpy).was_called() 241 | end 242 | ) 243 | it( 244 | "can be cleared", 245 | function() 246 | local timeout = Functions.setTimeout(callSpy, 2) 247 | assert.spy(callSpy).was_not_called() 248 | timeout:clear() 249 | wait(3) 250 | clock:process() 251 | assert.spy(callSpy).was_not_called() 252 | end 253 | ) 254 | it( 255 | "can be cleared inside the timeout", 256 | function() 257 | local clearCallSpy = 258 | spy.new( 259 | function(timeout) 260 | timeout:clear() 261 | end 262 | ) 263 | local timeout = Functions.setTimeout(clearCallSpy, 2) 264 | assert.spy(clearCallSpy).was_not_called() 265 | timeout:clear() 266 | wait(3) 267 | clock:process() 268 | assert.spy(clearCallSpy).was_not_called() 269 | end 270 | ) 271 | end 272 | ) 273 | 274 | describe( 275 | "setInterval", 276 | function() 277 | it( 278 | "calls a function repeatedly at intervals", 279 | function() 280 | Functions.setInterval(callSpy, 2) 281 | assert.spy(callSpy).was_not_called() 282 | wait(3) 283 | clock:process() 284 | assert.spy(callSpy).called(1) 285 | wait(2) 286 | clock:process() 287 | assert.spy(callSpy).called(2) 288 | wait(2) 289 | clock:process() 290 | assert.spy(callSpy).called(3) 291 | end 292 | ) 293 | it( 294 | "can be called immediately", 295 | function() 296 | Functions.setInterval(callSpy, 2, 0) 297 | wait(0) 298 | clock:process() 299 | assert.spy(callSpy).was_called() 300 | end 301 | ) 302 | it( 303 | "can be cleared", 304 | function() 305 | local interval = Functions.setInterval(callSpy, 2) 306 | assert.spy(callSpy).was_not_called() 307 | wait(3) 308 | clock:process() 309 | assert.spy(callSpy).called(1) 310 | wait(2) 311 | clock:process() 312 | assert.spy(callSpy).called(2) 313 | interval:clear() 314 | wait(2) 315 | clock:process() 316 | assert.spy(callSpy).called(2) 317 | end 318 | ) 319 | it( 320 | "can be cleared inside the function", 321 | function() 322 | local clearCallSpy = 323 | spy.new( 324 | function(interval) 325 | interval:clear() 326 | end 327 | ) 328 | Functions.setInterval(clearCallSpy, 2) 329 | assert.spy(clearCallSpy).was_not_called() 330 | wait(3) 331 | clock:process() 332 | assert.spy(clearCallSpy).called(1) 333 | wait(2) 334 | clock:process() 335 | assert.spy(clearCallSpy).called(1) 336 | end 337 | ) 338 | end 339 | ) 340 | 341 | describe( 342 | "Debounce", 343 | function() 344 | local debounced 345 | 346 | before_each( 347 | function() 348 | debounced = Functions.debounce(callSpy, 100) 349 | end 350 | ) 351 | 352 | it( 353 | "should not call before the delay has elapsed", 354 | function() 355 | debounced("hi") 356 | 357 | assert.spy(callSpy).was_not_called() 358 | 359 | wait(99) 360 | clock:process() 361 | 362 | assert.spy(callSpy).was_not_called() 363 | end 364 | ) 365 | 366 | it( 367 | "should call after the delay", 368 | function() 369 | debounced("hi") 370 | 371 | wait(100) 372 | clock:process() 373 | 374 | assert.spy(callSpy).was_called(1) 375 | assert.spy(callSpy).was_called_with("hi") 376 | end 377 | ) 378 | 379 | it( 380 | "should group calls and call the debounced function with the last arguments", 381 | function() 382 | local result = debounced("hi") 383 | 384 | assert.are.same(result, nil) 385 | 386 | local result2 = debounced("guys") 387 | 388 | assert.are.same(result2, nil) 389 | 390 | wait(100) 391 | clock:process() 392 | 393 | assert.spy(callSpy).was_called(1) 394 | assert.spy(callSpy).was_called_with("guys") 395 | 396 | local result3 = debounced("stuff") 397 | 398 | assert.are.same({[1] = "guys", n = 1}, result3) 399 | end 400 | ) 401 | end 402 | ) 403 | describe( 404 | "Throttle", 405 | function() 406 | local throttled 407 | 408 | before_each( 409 | function() 410 | throttled = Functions.throttle(callSpy, 100) 411 | end 412 | ) 413 | 414 | it( 415 | "should be called immediately", 416 | function() 417 | assert.spy(callSpy).was_not_called() 418 | local result = throttled("hi") 419 | assert.spy(callSpy).was_called() 420 | assert.are.same( 421 | { 422 | n = 1, 423 | [1] = "hi" 424 | }, 425 | result 426 | ) 427 | end 428 | ) 429 | 430 | it( 431 | "should not be called subsequently but return available args", 432 | function() 433 | local result = throttled("hi") 434 | 435 | assert.are.same( 436 | { 437 | n = 1, 438 | [1] = "hi" 439 | }, 440 | result 441 | ) 442 | 443 | wait(50) 444 | clock:process() 445 | 446 | result = throttled("ho") 447 | 448 | assert.spy(callSpy).was_called(1) 449 | assert.are.same( 450 | { 451 | n = 1, 452 | [1] = "hi" 453 | }, 454 | result 455 | ) 456 | end 457 | ) 458 | 459 | it( 460 | "should be called again after the timeout", 461 | function() 462 | local result = throttled("hi") 463 | 464 | assert.are.same( 465 | { 466 | n = 1, 467 | [1] = "hi" 468 | }, 469 | result 470 | ) 471 | 472 | wait(50) 473 | clock:process() 474 | 475 | result = throttled("ho") 476 | 477 | assert.spy(callSpy).was_called(1) 478 | assert.are.same( 479 | { 480 | n = 1, 481 | [1] = "hi" 482 | }, 483 | result 484 | ) 485 | 486 | wait(100) 487 | clock:process() 488 | 489 | throttled("twit") 490 | result = throttled("twoo") 491 | 492 | assert.spy(callSpy).was_called(2) 493 | assert.are.same( 494 | { 495 | n = 1, 496 | [1] = "twit" 497 | }, 498 | result 499 | ) 500 | end 501 | ) 502 | end 503 | ) 504 | end 505 | ) 506 | -------------------------------------------------------------------------------- /spec/Strings.spec.lua: -------------------------------------------------------------------------------- 1 | local Strings = require "Strings" 2 | local Classes = require "Classes" 3 | 4 | describe( 5 | "Strings", 6 | function() 7 | describe( 8 | "camelCase", 9 | function() 10 | it( 11 | "from snake-case", 12 | function() 13 | assert.are.same("pepperoniPizza", Strings.camelCase("__PEPPERONI_PIZZA__")) 14 | end 15 | ) 16 | it( 17 | "from kebab-case", 18 | function() 19 | assert.are.same("pepperoniPizza", Strings.camelCase("--pepperoni-pizza--")) 20 | end 21 | ) 22 | it( 23 | "from normal-case", 24 | function() 25 | assert.are.same("pepperoniPizza", Strings.camelCase("Pepperoni Pizza")) 26 | end 27 | ) 28 | end 29 | ) 30 | 31 | describe( 32 | "kebabCase", 33 | function() 34 | it( 35 | "from snake-case", 36 | function() 37 | assert.are.same("strong-stilton", Strings.kebabCase("__STRONG_STILTON__")) 38 | end 39 | ) 40 | it( 41 | "from camel-case", 42 | function() 43 | assert.are.same("strong-stilton", Strings.kebabCase("strongStilton")) 44 | end 45 | ) 46 | it( 47 | "from normal-case", 48 | function() 49 | assert.are.same("strong-stilton", Strings.kebabCase(" Strong Stilton ")) 50 | end 51 | ) 52 | end 53 | ) 54 | 55 | describe( 56 | "snakeCase", 57 | function() 58 | it( 59 | "from camel-case", 60 | function() 61 | assert.are.same("SWEET_CHICKEN_CURRY", Strings.snakeCase("sweetChickenCurry")) 62 | end 63 | ) 64 | it( 65 | "from kebab-case", 66 | function() 67 | assert.are.same("SWEET_CHICKEN__CURRY", Strings.snakeCase("--sweet-chicken--curry--")) 68 | end 69 | ) 70 | it( 71 | "from normal-case", 72 | function() 73 | assert.are.same("SWEET_CHICKEN__CURRY", Strings.snakeCase(" Sweet Chicken Curry ")) 74 | end 75 | ) 76 | end 77 | ) 78 | 79 | describe( 80 | "titleCase", 81 | function() 82 | it( 83 | "from plain words", 84 | function() 85 | assert.are.same("Jello World", Strings.titleCase("jello world")) 86 | end 87 | ) 88 | it( 89 | "from kebabs", 90 | function() 91 | assert.are.same("Yellow-jello With_sprinkles", Strings.titleCase("yellow-jello with_sprinkles")) 92 | end 93 | ) 94 | it( 95 | "from phrases with apostrophes", 96 | function() 97 | assert.are.same("Yellow Jello's Don’t Mellow", Strings.titleCase("yellow jello's don’t mellow")) 98 | end 99 | ) 100 | end 101 | ) 102 | 103 | describe( 104 | "splitOn", 105 | function() 106 | it( 107 | "with char delimiter", 108 | function() 109 | local x = "one.two::flour" 110 | 111 | assert.are.same({{"one", "two", "", "flour"}, {".", ":", ":"}}, {Strings.splitOn(x, "[.:]")}) 112 | end 113 | ) 114 | it( 115 | "with empty delimiter", 116 | function() 117 | local x = "rice" 118 | 119 | assert.are.same({"r", "i", "c", "e"}, Strings.splitOn(x)) 120 | end 121 | ) 122 | it( 123 | "with pattern delimiter", 124 | function() 125 | local x = "one:*two:@pea" 126 | 127 | assert.are.same({{"one", "two", "pea"}, {":*", ":@"}}, {Strings.splitOn(x, ":.")}) 128 | end 129 | ) 130 | end 131 | ) 132 | 133 | describe( 134 | "format", 135 | function() 136 | it( 137 | "with basic types", 138 | function() 139 | assert.are.same( 140 | "It's true, there are 5 a's in this string: aaaaa", 141 | Strings.format("It's {}, there are {} a's in this string: {}", true, 5, "aaaaa") 142 | ) 143 | end 144 | ) 145 | it( 146 | "with escaped curly braces", 147 | function() 148 | assert.are.same( 149 | "A value, just braces {}, just {braces}, an {item} in braces", 150 | Strings.format("A {}, just braces {{}}, just {{braces}}, an {{{}}} in braces", "value", "item") 151 | ) 152 | end 153 | ) 154 | it( 155 | "with args in any order", 156 | function() 157 | assert.are.same( 158 | "An item, just {braces}, a {value} and a value -> item", 159 | Strings.format("An {2}, just {{braces}}, a {{{1}}} and a {} -> {}", "value", "item") 160 | ) 161 | end 162 | ) 163 | it( 164 | "with serialized sparse array", 165 | function() 166 | local item = {"a", "b", [4] = "c"} 167 | assert.are.same('A formatted object: {1:"a",2:"b",4:"c"}', Strings.format("A formatted object: {:?}", item)) 168 | end 169 | ) 170 | it( 171 | "with serialized args", 172 | function() 173 | local item = {one = 1, two = 2} 174 | item.three = item 175 | assert.are.same( 176 | 'A formatted object: <1>{"one":1,"three":&1,"two":2}', 177 | Strings.format("A formatted object: {:?}", item) 178 | ) 179 | end 180 | ) 181 | it( 182 | "with pretty args", 183 | function() 184 | local result = {apple = {fire = "fox"}, badger = {id = 1}, cactus = "crumpet"} 185 | result.badger.donkey = result 186 | result.donkey = result.badger 187 | result.cactus = result.apple 188 | assert.are.same( 189 | [[A pretty object: <1>{ 190 | apple = <2>{fire = "fox"}, 191 | badger = <3>{donkey = &1, id = 1}, 192 | cactus = &2, 193 | donkey = &3 194 | }]], 195 | Strings.format("A pretty object: {:#?}", result) 196 | ) 197 | end 198 | ) 199 | it( 200 | "using a string.format formatter", 201 | function() 202 | assert.are.same("The color blue is #0000FF", Strings.format("The color blue is #{:06X}", 255)) 203 | end 204 | ) 205 | it( 206 | "using a pretty string.format formatter", 207 | function() 208 | assert.are.same("An octal number: 0257411", Strings.format("An octal number: {:#o}", 89865)) 209 | end 210 | ) 211 | it( 212 | "with long binary", 213 | function() 214 | assert.are.same("A binary number: 0b110100", Strings.format("A binary number: {:#b}", 125)) 215 | end 216 | ) 217 | it( 218 | "a padded number", 219 | function() 220 | assert.are.same("A padded number: 00056", Strings.format("A padded number: {:05}", 56)) 221 | end 222 | ) 223 | it( 224 | "with scientific precision", 225 | function() 226 | assert.are.same( 227 | "A number with scientific precision: 8.986500e+04", 228 | Strings.format("A number with scientific precision: {:e}", 89865) 229 | ) 230 | end 231 | ) 232 | it( 233 | "with upper scientific precision", 234 | function() 235 | assert.are.same( 236 | "A number with scientific precision: 8.986500E+04", 237 | Strings.format("A number with scientific precision: {:E}", 89865) 238 | ) 239 | end 240 | ) 241 | it( 242 | "with specific precision", 243 | function() 244 | assert.are.same( 245 | "A number with scientific precision: 8986.500", 246 | Strings.format("A number with scientific precision: {:.3}", 8986.5) 247 | ) 248 | end 249 | ) 250 | end 251 | ) 252 | 253 | describe( 254 | "trim", 255 | function() 256 | it( 257 | "trims from start and end", 258 | function() 259 | local x = " greetings friend " 260 | 261 | assert.are.same("greetings friend", Strings.trim(x)) 262 | end 263 | ) 264 | end 265 | ) 266 | 267 | describe( 268 | "startsWith", 269 | function() 270 | it( 271 | "returns correctly", 272 | function() 273 | local x = "roblox" 274 | 275 | assert.True(Strings.startsWith(x, "rob")) 276 | assert.False(Strings.startsWith(x, "x")) 277 | end 278 | ) 279 | end 280 | ) 281 | 282 | describe( 283 | "endsWith", 284 | function() 285 | it( 286 | "returns correctly", 287 | function() 288 | local x = "roblox" 289 | 290 | assert.False(Strings.endsWith(x, "rob")) 291 | assert.True(Strings.endsWith(x, "x")) 292 | end 293 | ) 294 | end 295 | ) 296 | 297 | describe( 298 | "leftPad", 299 | function() 300 | it( 301 | "repeats correctly", 302 | function() 303 | assert.are.same(" nice", Strings.leftPad("nice", 8)) 304 | end 305 | ) 306 | it( 307 | "doesn't add extra if string is too long", 308 | function() 309 | assert.are.same("nice", Strings.leftPad("nice", 2)) 310 | end 311 | ) 312 | it( 313 | "pads with different character", 314 | function() 315 | assert.are.same("00000nice", Strings.leftPad("nice", 9, "0")) 316 | end 317 | ) 318 | it( 319 | "pads with a string", 320 | function() 321 | assert.are.same(":):):toast", Strings.leftPad("toast", 10, ":)")) 322 | end 323 | ) 324 | end 325 | ) 326 | 327 | describe( 328 | "rightPad", 329 | function() 330 | it( 331 | "repeats correctly", 332 | function() 333 | assert.are.same("nice ", Strings.rightPad("nice", 8)) 334 | end 335 | ) 336 | it( 337 | "doesn't add extra if string is too long", 338 | function() 339 | assert.are.same("nice", Strings.rightPad("nice", 2)) 340 | end 341 | ) 342 | it( 343 | "pads with different character", 344 | function() 345 | assert.are.same("nice00000", Strings.rightPad("nice", 9, "0")) 346 | end 347 | ) 348 | it( 349 | "pads with a string", 350 | function() 351 | assert.are.same("toast:):):", Strings.rightPad("toast", 10, ":)")) 352 | end 353 | ) 354 | end 355 | ) 356 | 357 | describe( 358 | "pretty", 359 | function() 360 | it( 361 | "works for a table", 362 | function() 363 | assert.equal("{a = 1, b = {d = 4, e = 5}, c = 3}", Strings.pretty({a = 1, c = 3, b = {d = 4, e = 5}})) 364 | end 365 | ) 366 | it( 367 | "works for an array", 368 | function() 369 | assert.equal("{{1, 2}, {d = 2, e = 4}, 3}", Strings.pretty({{1, 2}, {d = 2, e = 4}, 3})) 370 | end 371 | ) 372 | it( 373 | "works for other natural types", 374 | function() 375 | local result = { 376 | child = { 377 | child = { 378 | a = true, 379 | c = 'hello\\" world', 380 | b = function() 381 | end, 382 | child = {1, 2, 3} 383 | } 384 | } 385 | } 386 | assert.equal( 387 | [[{ 388 | child = { 389 | child = { 390 | a = true, 391 | b = , 392 | c = "hello\\\" world", 393 | child = {1, 2, 3} 394 | } 395 | } 396 | }]], 397 | Strings.pretty(result):gsub("0x[0-9a-f]+", "0x000000") 398 | ) 399 | end 400 | ) 401 | it( 402 | "works for cycles", 403 | function() 404 | local result = {a = {f = 4}, b = {id = 1}, c = 3} 405 | result.b.d = result 406 | result.d = result.b 407 | result.c = result.a 408 | assert.equal("<1>{a = <2>{f = 4}, b = <3>{d = &1, id = 1}, c = &2, d = &3}", Strings.pretty(result)) 409 | end 410 | ) 411 | it( 412 | "works for a small table", 413 | function() 414 | assert.equal("{a = 1, b = {d = 4, e = 5}, c = 3}", Strings.pretty({a = 1, c = 3, b = {d = 4, e = 5}})) 415 | end 416 | ) 417 | it( 418 | "works for a small array", 419 | function() 420 | assert.equal("{{1, 2}, {d = 2, e = 4}, 3}", Strings.pretty({{1, 2}, {d = 2, e = 4}, 3})) 421 | end 422 | ) 423 | it( 424 | "works for other natural types", 425 | function() 426 | local result = { 427 | child = { 428 | child = { 429 | a = true, 430 | c = 'hello\\" world', 431 | b = function() 432 | end, 433 | child = {1, 2, 3} 434 | } 435 | } 436 | } 437 | assert.equal( 438 | [[{ 439 | child = { 440 | child = { 441 | a = true, 442 | b = , 443 | c = "hello\\\" world", 444 | child = {1, 2, 3} 445 | } 446 | } 447 | }]], 448 | Strings.pretty(result):gsub("0x[0-9a-f]+", "0x0000000") 449 | ) 450 | end 451 | ) 452 | it( 453 | "works for longer cycles", 454 | function() 455 | local result = {apple = {fire = "fox"}, badger = {id = 1}, cactus = "crumpet"} 456 | result.badger.donkey = result 457 | result.donkey = result.badger 458 | result.cactus = result.apple 459 | assert.equal( 460 | [[<1>{ 461 | apple = <2>{fire = "fox"}, 462 | badger = <3>{donkey = &1, id = 1}, 463 | cactus = &2, 464 | donkey = &3 465 | }]], 466 | Strings.pretty(result) 467 | ) 468 | end 469 | ) 470 | it( 471 | "works for classes and class instances", 472 | function() 473 | local Animal = 474 | Classes.class( 475 | "Animal", 476 | function(name) 477 | return {name = name} 478 | end 479 | ) 480 | local fox = Animal.new("fox") 481 | local badger = Animal.new("badger") 482 | local donkey = Animal.new("donkey kong: revisited") 483 | local result = {apple = {fire = fox}, [badger] = {id = 1}, cactus = "crumpet"} 484 | result[badger][donkey] = result 485 | result[donkey] = result[badger] 486 | result.cactus = result.apple 487 | assert.equal( 488 | [[<1>{ 489 | [Animal {name = "badger"}] = 490 | <2>{[Animal {name = "donkey kong: revisited"}] = &1, id = 1}, 491 | [Animal {name = "donkey kong: revisited"}] = &2, 492 | apple = <3>{fire = Animal {name = "fox"}}, 493 | cactus = &3 494 | }]], 495 | Strings.pretty(result) 496 | ) 497 | end 498 | ) 499 | end 500 | ) 501 | end 502 | ) 503 | -------------------------------------------------------------------------------- /spec/init.spec.lua: -------------------------------------------------------------------------------- 1 | local dash = require "init" 2 | local Clock = require "spec_source.Clock" 3 | 4 | describe( 5 | "macro-level functions", 6 | function() 7 | local clock 8 | before_each( 9 | function() 10 | clock = Clock.setup() 11 | getmetatable(dash.fn).__index = function(self, key) 12 | return dash.chain(dash)[key] 13 | end 14 | end 15 | ) 16 | after_each( 17 | function() 18 | clock:teardown() 19 | end 20 | ) 21 | describe( 22 | "chain", 23 | function() 24 | it( 25 | "works with vanilla functions", 26 | function() 27 | local numberChain = 28 | dash.chain( 29 | { 30 | addN = function(list, n) 31 | return dash.map( 32 | list, 33 | function(element) 34 | return element + n 35 | end 36 | ) 37 | end, 38 | sum = function(list) 39 | return dash.sum(list) 40 | end 41 | } 42 | ) 43 | local op = numberChain:addN(2):sum() 44 | assert.are.same(12, op({1, 2, 3})) 45 | end 46 | ) 47 | it( 48 | "works with functions using chainFn", 49 | function() 50 | local numberChain = 51 | dash.chain( 52 | { 53 | addN = dash.chainFn( 54 | function(n) 55 | return function(list) 56 | return dash.map( 57 | list, 58 | function(element) 59 | return element + n 60 | end 61 | ) 62 | end 63 | end 64 | ), 65 | sum = dash.chainFn( 66 | function() 67 | return function(list) 68 | return dash.sum(list) 69 | end 70 | end 71 | ) 72 | } 73 | ) 74 | local op = numberChain:addN(2):sum() 75 | assert.are.same(12, op({1, 2, 3})) 76 | end 77 | ) 78 | it( 79 | "works with functions using chainFn and rodash function", 80 | function() 81 | local numberChain = 82 | dash.chain( 83 | { 84 | addN = dash.chainFn( 85 | function(n) 86 | return dash.fn:map( 87 | function(element) 88 | return element + n 89 | end 90 | ) 91 | end 92 | ), 93 | sum = dash.chainFn( 94 | function() 95 | return dash.fn:sum() 96 | end 97 | ) 98 | } 99 | ) 100 | local op = numberChain:addN(2):sum() 101 | assert.are.same(12, op({1, 2, 3})) 102 | end 103 | ) 104 | it( 105 | "works with rodash functions", 106 | function() 107 | local fn = 108 | dash.fn:map( 109 | function(value) 110 | return value + 2 111 | end 112 | ):filter( 113 | function(value) 114 | return value >= 5 115 | end 116 | ):sum() 117 | assert.equals("fn::map::filter::sum", tostring(fn)) 118 | assert.are.same(12, fn({1, 3, 5})) 119 | end 120 | ) 121 | it( 122 | "works with custom functors built with .fn", 123 | function() 124 | local chain = 125 | dash.chain( 126 | { 127 | addTwo = dash.fn:map( 128 | function(value) 129 | return value + 2 130 | end 131 | ), 132 | sumGteFive = dash.fn:filter( 133 | function(value) 134 | return value >= 5 135 | end 136 | ):sum() 137 | } 138 | ) 139 | local fn = chain:addTwo():sumGteFive() 140 | assert.equals("Chain::addTwo::sumGteFive", tostring(fn)) 141 | assert.are.same(12, fn({1, 3, 5})) 142 | end 143 | ) 144 | it( 145 | "works with composed functions", 146 | function() 147 | local getName = function(player) 148 | return player.Name 149 | end 150 | local players = 151 | dash.chain( 152 | { 153 | filterHurt = dash.fn:filter( 154 | function(player) 155 | return player.Health < 100 156 | end 157 | ), 158 | filterBaggins = dash.fn:filter(dash.fn:call(getName):endsWith("Baggins")) 159 | } 160 | ) 161 | local hurtHobbits = players:filterHurt():filterBaggins() 162 | local mapNames = dash.fn:map(getName) 163 | local filterHurtBagginsNames = dash.compose(hurtHobbits, mapNames) 164 | local crew = { 165 | { 166 | Name = "Frodo Baggins", 167 | Health = 50 168 | }, 169 | { 170 | Name = "Bilbo Baggins", 171 | Health = 100 172 | }, 173 | { 174 | Name = "Boromir", 175 | Health = 0 176 | } 177 | } 178 | assert.are.same({"Frodo Baggins"}, filterHurtBagginsNames(crew)) 179 | end 180 | ) 181 | end 182 | ) 183 | describe( 184 | "continue", 185 | function() 186 | it( 187 | "works with a mix of sync and async primitives", 188 | function() 189 | local getName = function(player) 190 | return dash.delay(1):andThen(dash.returns(player.Name)) 191 | end 192 | local players 193 | players = 194 | dash.chain( 195 | { 196 | -- Any chainable function can be used 197 | filter = dash.filter, 198 | -- A chain which evaluates a promise of the player names 199 | mapNames = dash.fn:map(getName):parallel(), 200 | filterHurt = dash.fn:filter( 201 | function(player) 202 | return player.Health < 100 203 | end 204 | ), 205 | mapNameIf = dash.chainFn( 206 | function(expectedName) 207 | -- Methods on self work as expected 208 | return players:mapNames():filter(dash.fn:endsWith(expectedName)) 209 | end 210 | ) 211 | }, 212 | dash.continue() 213 | ) 214 | local filterHurtHobbitNames = players:filterHurt():mapNameIf("Baggins") 215 | local crew = { 216 | { 217 | Name = "Frodo Baggins", 218 | Health = 50 219 | }, 220 | { 221 | Name = "Bilbo Baggins", 222 | Health = 100 223 | }, 224 | { 225 | Name = "Boromir", 226 | Health = 0 227 | } 228 | } 229 | local andThen = 230 | spy.new( 231 | function(result) 232 | assert.are.same({"Frodo Baggins"}, result) 233 | end 234 | ) 235 | filterHurtHobbitNames(crew):andThen(andThen) 236 | assert.spy(andThen).not_called() 237 | clock:process() 238 | -- Ensure that delays are set for the lookup of the hurt names 239 | assert.are.same({1, 1}, dash.fn:map(dash.fn:get("time"))(clock.events)) 240 | wait(1) 241 | clock:process() 242 | -- Ensure spy called 243 | assert.spy(andThen).called() 244 | end 245 | ) 246 | it( 247 | "rejections pass-through", 248 | function() 249 | local getName = function(player) 250 | return dash.delay(1):andThen(dash.throws("NoNameError")) 251 | end 252 | local players = 253 | dash.chain( 254 | { 255 | -- Any chainable function can be used 256 | filter = dash.filter, 257 | -- A chain which evaluates a promise of the player names 258 | mapNames = dash.fn:map(getName):parallel() 259 | }, 260 | dash.continue() 261 | ) 262 | local filterHobbitNames = players:mapNames():filter(dash.fn:endsWith("Baggins")) 263 | local crew = { 264 | { 265 | Name = "Frodo Baggins", 266 | Health = 50 267 | }, 268 | { 269 | Name = "Bilbo Baggins", 270 | Health = 100 271 | }, 272 | { 273 | Name = "Boromir", 274 | Health = 0 275 | } 276 | } 277 | local andThen = 278 | spy.new( 279 | function() 280 | end 281 | ) 282 | local catch = 283 | spy.new( 284 | function(message) 285 | assert(message:find("NoNameError")) 286 | end 287 | ) 288 | filterHobbitNames(crew):andThen(andThen):catch(catch) 289 | assert.spy(andThen).not_called() 290 | assert.spy(catch).not_called() 291 | clock:process() 292 | -- Ensure that delays are set for the lookup of the hurt names 293 | assert.are.same({1, 1, 1}, dash.fn:map(dash.fn:get("time"))(clock.events)) 294 | wait(1) 295 | clock:process() 296 | 297 | assert.spy(andThen).not_called() 298 | assert.spy(catch).called() 299 | end 300 | ) 301 | end 302 | ) 303 | describe( 304 | "maybe", 305 | function() 306 | it( 307 | "works with methods that return nil", 308 | function() 309 | local maybeFn = dash.chain(dash, dash.maybe()) 310 | local getName = function(player) 311 | return player.Name 312 | end 313 | local players = 314 | dash.chain( 315 | { 316 | filterHurt = dash.fn:filter( 317 | function(player) 318 | return player.Health < 100 319 | end 320 | ), 321 | filterBaggins = dash.chainFn( 322 | function() 323 | -- Methods on self work as expected 324 | return dash.fn:filter(maybeFn:call(getName):endsWith("Baggins")) 325 | end 326 | ) 327 | } 328 | ) 329 | local hurtHobbits = players:filterHurt():filterBaggins() 330 | local mapNames = dash.fn:map(getName) 331 | local filterHurtBagginsNames = dash.compose(hurtHobbits, mapNames) 332 | local crew = { 333 | { 334 | Name = "Frodo Baggins", 335 | Health = 50 336 | }, 337 | { 338 | Name = "Bilbo Baggins", 339 | Health = 100 340 | }, 341 | { 342 | Health = 0 343 | } 344 | } 345 | assert.are.same({"Frodo Baggins"}, filterHurtBagginsNames(crew)) 346 | end 347 | ) 348 | end 349 | ) 350 | end 351 | ) 352 | -------------------------------------------------------------------------------- /spec_source/Clock.lua: -------------------------------------------------------------------------------- 1 | local function setup() 2 | local restoreFns = {} 3 | local clock = { 4 | time = 0, 5 | events = {} 6 | } 7 | function clock:process() 8 | local events = {} 9 | table.sort( 10 | self.events, 11 | function(a, b) 12 | return a.time < b.time 13 | end 14 | ) 15 | for _, event in ipairs(self.events) do 16 | if event.time <= self.time then 17 | event.fn() 18 | else 19 | table.insert(events, event) 20 | end 21 | end 22 | self.events = events 23 | end 24 | function clock:teardown() 25 | self.time = 0 26 | self.events = {} 27 | for name, fn in pairs(restoreFns) do 28 | _G[name] = fn 29 | end 30 | end 31 | local stubs = { 32 | tick = function() 33 | return clock.time 34 | end, 35 | spawn = function(fn) 36 | table.insert(clock.events, {time = clock.time, fn = fn}) 37 | end, 38 | delay = function(delayInSeconds, fn) 39 | table.insert(clock.events, {time = clock.time + delayInSeconds, fn = fn}) 40 | end, 41 | wait = function(delayInSeconds) 42 | clock.time = clock.time + delayInSeconds 43 | end 44 | } 45 | for name, fn in pairs(stubs) do 46 | restoreFns[name] = _G[name] 47 | _G[name] = fn 48 | end 49 | return clock 50 | end 51 | 52 | return { 53 | setup = setup 54 | } 55 | -------------------------------------------------------------------------------- /spec_studio/Example.server.lua: -------------------------------------------------------------------------------- 1 | print("Examples of using Rodash in your project:") 2 | 3 | local dash = require(game.ReplicatedStorage.Rodash) 4 | local fn = dash.fn 5 | 6 | dash.setInterval( 7 | function() 8 | local getNames = fn:map(fn:get("Name")) 9 | local playerNames = getNames(game.Players:GetPlayers()) 10 | print(dash.format("Players online ({#1}): {:#?}", playerNames)) 11 | end, 12 | 1 13 | ) 14 | 15 | print(dash.encodeUrlComponent("https://example.com/Egg+Fried Rice!?🤷🏼‍♀️")) 16 | -------------------------------------------------------------------------------- /spec_studio/Strings.spec.lua: -------------------------------------------------------------------------------- 1 | return function() 2 | local Strings = require(script.Parent.Strings) 3 | describe( 4 | "Strings and UTF8", 5 | function() 6 | describe( 7 | "charToHex", 8 | function() 9 | it( 10 | "encodes correctly", 11 | function() 12 | assert.equal("3C", Strings.charToHex("<")) 13 | end 14 | ) 15 | it( 16 | "encodes utf8 correctly", 17 | function() 18 | assert.equal("1F60F", Strings.charToHex("😏")) 19 | end 20 | ) 21 | it( 22 | "encodes utf8 correctly with formatting", 23 | function() 24 | assert.equal("0x1F60F", Strings.charToHex("😏", "0x{}")) 25 | end 26 | ) 27 | it( 28 | "encodes utf8 with multiple code points correctly", 29 | function() 30 | assert.equal("🤷🏼‍♀️", Strings.charToHex("🤷🏼‍♀️", "&#x{};")) 31 | end 32 | ) 33 | it( 34 | "encodes utf8 bytes correctly", 35 | function() 36 | assert.equal("%F0%9F%A4%B7%F0%9F%8F%BC%E2%80%8D%E2%99%80%EF%B8%8F", Strings.charToHex("🤷🏼‍♀️", "%{}", true)) 37 | end 38 | ) 39 | end 40 | ) 41 | 42 | describe( 43 | "hexToChar", 44 | function() 45 | it( 46 | "decodes correctly", 47 | function() 48 | assert.equal("_", Strings.hexToChar("%5F")) 49 | end 50 | ) 51 | it( 52 | "throws for an invalid encoding", 53 | function() 54 | assert.errors( 55 | function() 56 | Strings.hexToChar("nope") 57 | end 58 | ) 59 | end 60 | ) 61 | end 62 | ) 63 | 64 | describe( 65 | "encodeUrlComponent", 66 | function() 67 | it( 68 | "encodes correctly", 69 | function() 70 | assert.equal( 71 | "https%3A%2F%2Fexample.com%2FEgg%2BFried%20Rice!%3F", 72 | Strings.encodeUrlComponent("https://example.com/Egg+Fried Rice!?") 73 | ) 74 | end 75 | ) 76 | end 77 | ) 78 | describe( 79 | "encodeUrl", 80 | function() 81 | it( 82 | "encodes correctly", 83 | function() 84 | assert.equal("https://example.com/Egg+Fried%20Rice!?", Strings.encodeUrl("https://example.com/Egg+Fried Rice!?")) 85 | end 86 | ) 87 | end 88 | ) 89 | describe( 90 | "decodeUrlComponent", 91 | function() 92 | it( 93 | "decodes correctly", 94 | function() 95 | assert.equal( 96 | "https://example.com/Egg+Fried Rice!?", 97 | Strings.decodeUrlComponent("https%3A%2F%2Fexample.com%2FEgg%2BFried%20Rice!%3F") 98 | ) 99 | end 100 | ) 101 | end 102 | ) 103 | describe( 104 | "decodeUrl", 105 | function() 106 | it( 107 | "decodes correctly", 108 | function() 109 | assert.equal("https://example.com/Egg+Fried Rice!?", Strings.decodeUrl("https://example.com/Egg+Fried%20Rice!?")) 110 | end 111 | ) 112 | end 113 | ) 114 | describe( 115 | "makeQueryString", 116 | function() 117 | it( 118 | "makes query", 119 | function() 120 | assert.equal( 121 | "?biscuits=hob+nobs&time=11&chocolatey=true", 122 | Strings.encodeQueryString( 123 | { 124 | time = 11, 125 | biscuits = "hob nobs", 126 | chocolatey = true 127 | } 128 | ) 129 | ) 130 | end 131 | ) 132 | end 133 | ) 134 | 135 | describe( 136 | "encodeHtml", 137 | function() 138 | it( 139 | "characters", 140 | function() 141 | assert.are.same( 142 | "Peas < Bacon > "Fish" & 'Chips'", 143 | Strings.encodeHtml([[Peas < Bacon > "Fish" & 'Chips']]) 144 | ) 145 | end 146 | ) 147 | end 148 | ) 149 | 150 | describe( 151 | "decodeHtml", 152 | function() 153 | it( 154 | "html entities", 155 | function() 156 | assert.are.same( 157 | [["Smashed" 'Avocado' 😏]], 158 | Strings.decodeHtml("<b>"Smashed"</b> 'Avocado' 😏") 159 | ) 160 | end 161 | ) 162 | it( 163 | "conflated ampersand", 164 | function() 165 | assert.are.same("Ampersand is &", Strings.decodeHtml("Ampersand is &amp;")) 166 | end 167 | ) 168 | end 169 | ) 170 | end 171 | ) 172 | end 173 | -------------------------------------------------------------------------------- /src/Arrays.lua: -------------------------------------------------------------------------------- 1 | --[=[ 2 | A collection of functions that operate specifically on arrays, defined as tables with just keys _1..n_. 3 | 4 | ```lua 5 | -- Examples of valid arrays: 6 | {} 7 | {"red", "green", "blue"} 8 | {"winter", {is = "coming"}, [3] = "again"} 9 | {1966, nil, nil} 10 | -- Examples of invalid arrays: 11 | {1994, nil, 2002} 12 | {you = {"know", "nothing"}} 13 | {[5] = "gold rings"} 14 | 42 15 | ``` 16 | 17 | These functions can iterate over any [Ordered](/rodash/types#Ordered) values. 18 | ]=] 19 | local t = require(script.Parent.Parent.t) 20 | local Tables = require(script.Parent.Tables) 21 | 22 | local Arrays = {} 23 | 24 | local function assertHandlerIsFn(handler) 25 | local Functions = require(script.Parent.Functions) 26 | assert(Functions.isCallable(handler), "BadInput: handler must be a function") 27 | end 28 | local function assertPredicateIsFn(handler) 29 | local Functions = require(script.Parent.Functions) 30 | assert(Functions.isCallable(handler), "BadInput: handler must be a function") 31 | end 32 | 33 | local typeIndex = { 34 | boolean = 1, 35 | number = 2, 36 | string = 3, 37 | ["function"] = 4, 38 | ["CFunction"] = 5, 39 | userdata = 6, 40 | table = 7 41 | } 42 | 43 | --[[ 44 | Given two values _a_ and _b_, this function return `true` if _a_ is typically considered lower 45 | than _b_. 46 | 47 | The default comparator is used by `dash.sort` and can sort elements of different types, in the 48 | order: boolean, number, string, function, CFunction, userdata, and table. 49 | 50 | Elements which cannot be sorted naturally will be sorted by their string value. 51 | 52 | @see `dash.sort` 53 | ]] 54 | --: ((T, T) -> bool) 55 | function Arrays.defaultComparator(a, b) 56 | if type(a) ~= type(b) then 57 | return typeIndex[type(a)] - typeIndex[type(b)] 58 | end 59 | local ok, result = 60 | pcall( 61 | function() 62 | return a < b 63 | end 64 | ) 65 | if ok then 66 | return result 67 | else 68 | return tostring(a) < tostring(b) 69 | end 70 | end 71 | 72 | --[[ 73 | Returns a sorted array from the _input_ array, based on a _comparator_ function. 74 | 75 | Unlike `table.sort`, the comparator to `dash.sort` is optional, but if defined it can also 76 | return a numeric weight or nil as well as a boolean to provide an ordering of the elements. 77 | 78 | @param comparator should return `true` or `n < 0` if the first element should be before the second in the resulting array, or `0` or `nil` if the elements have the same order. 79 | 80 | @example dash.sort({2, 5, 3}) --> {2, 3, 5} 81 | @example dash.sort({"use", "the", "force", "Luke"}) --> {"Luke", "force", "the", "use"} 82 | @example 83 | dash.sort({ 84 | name = "Luke", 85 | health = 50 86 | }, { 87 | name = "Yoda", 88 | health = 9001 89 | }, { 90 | name = "Jar Jar Binks", 91 | health = 0 92 | }, function(a, b) 93 | return a.health < b.health 94 | end) --> the characters sorted in ascending order by their health 95 | ]] 96 | --: (T[], (T -> bool | number | nil)? -> T[]) 97 | function Arrays.sort(input, comparator) 98 | assert(t.table(input), "BadInput: input must be an array") 99 | 100 | local Functions = require(script.Parent.Functions) 101 | assert(comparator == nil or Functions.isCallable(comparator), "BadInput: comparator must be callable or nil") 102 | 103 | comparator = comparator or Arrays.defaultComparator 104 | 105 | table.sort( 106 | input, 107 | function(a, b) 108 | local result = comparator(a, b) 109 | 110 | if type(result) ~= "number" and type(result) ~= "boolean" and result ~= nil then 111 | error("BadResult: comparator must return a boolean, a number or nil") 112 | end 113 | 114 | return result == true or (type(result) == "number" and result < 0) 115 | end 116 | ) 117 | 118 | return input 119 | end 120 | 121 | --[[ 122 | Returns a copied portion of the _source_, between the _first_ and _last_ elements inclusive and 123 | jumping _step_ each time if provided. 124 | 125 | @param first (default = 1) The index of the first element to include. 126 | @param last (default = `#source`) The index of the last element to include. 127 | @param step (default = 1) What amount to step the index by during iteration. 128 | @example dash.slice({10, 20, 30, 40}) --> {10, 20, 30, 40} 129 | @example dash.slice({10, 20, 30, 40}, 2) --> {20, 30, 40} 130 | @example dash.slice({10, 20, 30, 40}, 2, 3) --> {20, 30} 131 | @example dash.slice({10, 20, 30, 40}, 2, 4, 2) --> {20, 40} 132 | ]] 133 | --: (T[], int?, int?, int? -> T[]) 134 | function Arrays.slice(source, first, last, step) 135 | assert(t.table(source), "BadInput: source must be an array") 136 | assert(t.optional(t.number)(first), "BadInput: first must be an int") 137 | assert(t.optional(t.number)(last), "BadInput: last must be an int") 138 | assert(t.optional(t.number)(step), "BadInput: step must be an int") 139 | local sliced = {} 140 | 141 | for i = first or 1, last or #source, step or 1 do 142 | sliced[#sliced + 1] = source[i] 143 | end 144 | 145 | return sliced 146 | end 147 | 148 | --[[ 149 | Returns a new array with the order of the values from _source_ randomized. 150 | @example 151 | local teamColors = {"red", "red", "red", "blue", "blue", "blue"} 152 | -- (in some order) 153 | dash.shuffle(teamColors) --> {"blue", "blue", "red", "blue", "red", "red"} 154 | ]] 155 | --: (T[] -> T[]) 156 | function Arrays.shuffle(source) 157 | assert(t.table(source), "BadInput: source must be an array") 158 | local result = Tables.clone(source) 159 | for i = #result, 1, -1 do 160 | local j = math.random(i) 161 | result[i], result[j] = result[j], result[i] 162 | end 163 | return result 164 | end 165 | 166 | --[[ 167 | Runs the _handler_ on each element of _source_ in turn, passing the result of the previous call 168 | (or _initial_ for the first element) as the first argument, and the current element as a value 169 | and key as subsequent arguments. 170 | @example 171 | local sum = dash.reduce({1, 2, 3}, function(result, value) 172 | return result + value 173 | end, 0) 174 | sum --> 6 175 | @example 176 | local recipe = {first = "cheese", second = "nachos", third = "chillies"} 177 | local unzipRecipe = dash.reduce(recipe, function(result, value, key) 178 | table.insert(result[1], key) 179 | table.insert(result[2], value) 180 | return result 181 | end, {{}, {}}) 182 | -- (in some order) 183 | unzipRecipe --> {{"first", "third", "second"}, {"cheese", "chillies", "nachos"}} 184 | ]] 185 | --: (Ordered, (result: R, value: T, key: int -> R), R) -> R 186 | function Arrays.reduce(source, handler, initial) 187 | local result = initial 188 | for i, v in Tables.iterator(source, true) do 189 | result = handler(result, v, i) 190 | end 191 | return result 192 | end 193 | 194 | --[[ 195 | Inserts into the _target_ array the elements from all subsequent arguments in order. 196 | @param ... any number of other arrays 197 | @example dash.append({}, {1, 2, 3}, {4, 5, 6}) --> {1, 2, 3, 4, 5, 6} 198 | @example dash.append({1, 2, 3}) --> {1, 2, 3} 199 | @example 200 | local list = {"cheese"} 201 | dash.append(list, {"nachos"}, {}, {"chillies"}) 202 | list --> {"cheese", "nachos", "chillies"} 203 | ]] 204 | --: (mut T[], ...Ordered -> T[]) 205 | function Arrays.append(target, ...) 206 | for i = 1, select("#", ...) do 207 | local x = select(i, ...) 208 | for _, y in Tables.iterator(x, true) do 209 | table.insert(target, y) 210 | end 211 | end 212 | 213 | return target 214 | end 215 | 216 | --[[ 217 | Sums all the values in the _source_ array. 218 | @example dash.sum({1, 2, 3}) --> 6 219 | ]] 220 | --: Ordered -> number 221 | function Arrays.sum(source) 222 | return Arrays.reduce( 223 | source, 224 | function(current, value) 225 | return current + value 226 | end, 227 | 0 228 | ) 229 | end 230 | 231 | --[[ 232 | Swaps the order of elements in _source_. 233 | @example dash.reverse({1, 2, 4, 3, 5}) --> {5, 3, 4, 2, 1} 234 | ]] 235 | --: (T[] -> T[]) 236 | function Arrays.reverse(source) 237 | local output = {} 238 | for i = #source, 1, -1 do 239 | table.insert(output, source[i]) 240 | end 241 | return output 242 | end 243 | 244 | --[[ 245 | Returns the earliest value from the array that _predicate_ returns `true` for. 246 | 247 | If the _predicate_ is not specified, `dash.first` simply returns the first element of the array. 248 | @param predicate (default = `dash.returns(true)`) 249 | @example 250 | local names = { 251 | "Boromir", 252 | "Frodo", 253 | "Bilbo" 254 | } 255 | 256 | dash.first(names) --> "Boromir", 1 257 | 258 | -- Find a particular value: 259 | local firstNameWithF = dash.first(names, function(name) 260 | return dash.startsWith(name, "F") 261 | end) 262 | firstNameWithF --> "Frodo", 2 263 | 264 | -- What about a value which doesn't exist? 265 | local firstNameWithC = dash.first(names, function(name) 266 | return dash.startsWith(name, "C") 267 | end) 268 | firstNameWithC --> nil 269 | 270 | -- Find the index of a value: 271 | local _, index = dash.first(names, dash.fn:matches("Bilbo")) 272 | index --> 2 273 | @see `dash.find` 274 | @usage If you need to find a value in a table which isn't an array, use `dash.find`. 275 | ]] 276 | --: >(T, (element: V, key: K -> bool) -> V?) 277 | function Arrays.first(source, predicate) 278 | predicate = predicate or function() 279 | return true 280 | end 281 | assertPredicateIsFn(predicate) 282 | for i, v in Tables.iterator(source, true) do 283 | if (predicate(v, i)) then 284 | return v, i 285 | end 286 | end 287 | end 288 | 289 | --[[ 290 | Returns the last value from the array that _predicate_ returns `true` for. 291 | 292 | If the _predicate_ is not specified, `dash.last` simply returns the last element of the array. 293 | @param predicate (default = `dash.returns(true)`) 294 | @example 295 | local names = { 296 | "Boromir", 297 | "Frodo", 298 | "Bilbo" 299 | } 300 | 301 | dash.last(names) --> "Bilbo", 3 302 | 303 | local lastNameWithB = dash.last(names, dash.fn:startsWith("B")) 304 | lastNameWithB --> "Bilbo", 3 305 | 306 | local _, key = dash.last(names, dash.fn:matches("Frodo")) 307 | key --> 2 308 | @see `dash.find` 309 | @see `dash.first` 310 | ]] 311 | --: >(T, (element: V, key: K -> bool) -> V?) 312 | function Arrays.last(source, predicate) 313 | predicate = predicate or function() 314 | return true 315 | end 316 | assertHandlerIsFn(predicate) 317 | for i = #source, 1, -1 do 318 | local value = source[i] 319 | if (predicate(value, i)) then 320 | return value, i 321 | end 322 | end 323 | end 324 | 325 | return Arrays 326 | -------------------------------------------------------------------------------- /src/Async.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Building upon the functionality of [Roblox Lua Promise](https://github.com/LPGhatguy/roblox-lua-promise) 3 | and borrowing ideas from [Bluebird](http://bluebirdjs.com/docs/getting-started.html), 4 | these functions improve the experience of working with asynchronous code in Roblox. 5 | 6 | Promises can be thought of as a variable whose value might not be known immediately when they 7 | are defined. They allow you to pass around a "promise" to the value, rather than yielding or 8 | waiting until the value has resolved. This means you can write functions which pass any 9 | promises to right places in your code, and delay running any code which requires the value 10 | until it is ready. 11 | ]] 12 | local t = require(script.Parent.Parent.t) 13 | local Tables = require(script.Parent.Tables) 14 | local Functions = require(script.Parent.Functions) 15 | local Promise = require(script.Parent.Parent.Promise) 16 | local Async = {} 17 | 18 | local baseRandomStream = Random.new() 19 | 20 | --[[ 21 | Yields completion of a promise `promise:await()`, but returns immediately with the value if it 22 | isn't a promise. 23 | @trait Yieldable 24 | @example 25 | local heat = function(item) 26 | return dash.delay(1).returns("hot " .. item) 27 | end 28 | local recipe = {"wrap", heat("steak"), heat("rice")} 29 | local burrito = dash.map(recipe, dash.await) 30 | dash.debug("{:#?}", burrito) 31 | -->> {"wrap", "hot steak", "hot rice"} (2 seconds) 32 | ]] 33 | --: (Promise | T -> yield T) 34 | function Async.await(value) 35 | if Async.isPromise(value) then 36 | return value:await() 37 | end 38 | return value 39 | end 40 | 41 | --[[ 42 | Wraps `Promise.is` but catches any errors thrown in attempting to ascertain if _value_ is a 43 | promise, which will occur if the value throws when trying to access missing keys. 44 | ]] 45 | --: (T -> bool) 46 | function Async.isPromise(value) 47 | local ok, isPromise = 48 | pcall( 49 | function() 50 | return Promise.is(value) 51 | end 52 | ) 53 | return ok and isPromise 54 | end 55 | 56 | --[[ 57 | Given an _array_ of values, this function returns a promise which 58 | resolves once all of the array elements have resolved, or rejects 59 | if any of the array elements reject. 60 | 61 | @returns an array mapping the input to resolved elements. 62 | @example 63 | local heat = function(item) 64 | local oven = dash.parallel({item, dash.delay(1)}) 65 | return oven:andThen(function(result) 66 | return "hot-" .. result[1] 67 | end) 68 | end 69 | local meal =dash.parallel({heat("cheese"), "tomato"}) 70 | meal:await() --> {"hot-cheese", "tomato"} (1 second later) 71 | @rejects passthrough 72 | @usage This function is like `Promise.all` but allows objects in the array which aren't 73 | promises. These are considered resolved immediately. 74 | @usage Promises that return nil values will cause the return array to be sparse. 75 | @see [Promise](https://github.com/LPGhatguy/roblox-lua-promise) 76 | ]] 77 | --: ((Promise | T)[] -> Promise) 78 | function Async.parallel(array) 79 | assert(t.table(array), "BadInput: array must be an array") 80 | local promises = 81 | Tables.map( 82 | array, 83 | function(object) 84 | if Async.isPromise(object) then 85 | return object 86 | else 87 | return Promise.resolve(object) 88 | end 89 | end 90 | ) 91 | return Promise.all(promises) 92 | end 93 | 94 | --[[ 95 | Given a _dictionary_ of values, this function returns a promise which 96 | resolves once all of the values in the dictionary have resolved, or rejects 97 | if any of them are promises that reject. 98 | 99 | @returns a dictionary mapping the input to resolved elements. 100 | @rejects passthrough 101 | @example 102 | local heat = function(item) 103 | local oven = dash.parallel({item, dash.delay(1)}) 104 | return oven:andThen(function(result) 105 | return "hot-" .. result[1] 106 | end) 107 | end 108 | local toastie = dash.parallelAll({ 109 | bread = "brown", 110 | filling = heat("cheese") 111 | }) 112 | toastie:await() --> {bread = "brown", filling = "hot-cheese"} (1 second later) 113 | @example 114 | local fetch = dash.async(function(url) 115 | local HttpService = game:GetService("HttpService") 116 | return HttpService:GetAsync(url) 117 | end) 118 | dash.parallelAll({ 119 | main = fetch("http://example.com/burger"), 120 | side = fetch("http://example.com/fries") 121 | }):andThen(function(meal) 122 | print("Meal", dash.pretty(meal)) 123 | end) 124 | @usage Values which are not promises are considered resolved immediately. 125 | ]] 126 | --: ((Promise | T){}) -> Promise 127 | function Async.parallelAll(dictionary) 128 | assert(t.table(dictionary), "BadInput: dictionary must be a table") 129 | local keys = Tables.keys(dictionary) 130 | local values = 131 | Tables.map( 132 | keys, 133 | function(key) 134 | return dictionary[key] 135 | end 136 | ) 137 | return Async.parallel(values):andThen( 138 | function(output) 139 | return Tables.keyBy( 140 | output, 141 | function(value, i) 142 | return keys[i] 143 | end 144 | ) 145 | end 146 | ) 147 | end 148 | 149 | --[[ 150 | Like `Promise.resolve` but can take any number of arguments. 151 | @example 152 | local function mash( veg ) 153 | return dash.resolve("mashed", veg) 154 | end 155 | mash("potato"):andThen(function(style, veg) 156 | dash.debug("{} was {}", veg, style) 157 | end) 158 | -- >> potato was mashed 159 | @usage As `dash.resolve(promise) --> promise`, this function can also be used to ensure a value is a promise. 160 | ]] 161 | --: T -> Promise 162 | function Async.resolve(...) 163 | local args = {...} 164 | return Promise.new( 165 | function(resolve) 166 | resolve(unpack(args)) 167 | end 168 | ) 169 | end 170 | 171 | --[[ 172 | Returns a promise which completes after the first promise in the _array_ input completes, or 173 | first _n_ promises if specified. If any promise rejects, race rejects with the first rejection. 174 | @param n the number of promises required (default = 1) 175 | @returns an array containing the first n resolutions, in the order that they resolved. 176 | @rejects passthrough 177 | @throws OutOfBoundsError - if the number of required promises is greater than the input length. 178 | @example 179 | -- Here promise resolves to the result of fetch, or resolves to "No burger for you" if the 180 | -- fetch takes more than 2 seconds. 181 | local fetch = dash.async(function(url) 182 | local HttpService = game:GetService("HttpService") 183 | return HttpService:GetAsync(url) 184 | end) 185 | local promise = dash.race( 186 | dash.delay(2):andThen(dash.returns("No burger for you"), 187 | fetch("http://example.com/burger") 188 | ) 189 | @usage Note that Promises which return nil values will produce a sparse array. 190 | @usage The size of _array_ must be equal to or larger than _n_. 191 | @see `dash.async` 192 | ]] 193 | --: (Promise[], uint?) -> Promise 194 | function Async.race(array, n) 195 | n = n or 1 196 | assert(n >= 0, "BadInput: n must be an integer >= 0") 197 | assert(#array >= n, "OutOfBoundsError: n must be less than #array") 198 | local function handler(resolve, reject) 199 | local results = {} 200 | local function finally(ok, result) 201 | if #results < n then 202 | if ok then 203 | table.insert(results, result) 204 | if #results == n then 205 | resolve(results) 206 | end 207 | else 208 | reject(result) 209 | end 210 | end 211 | end 212 | local function awaitElement(promise) 213 | Async.finally(promise, finally) 214 | end 215 | Tables.map(array, awaitElement) 216 | if n == 0 then 217 | resolve(results) 218 | end 219 | end 220 | return Promise.new(handler) 221 | end 222 | 223 | --[[ 224 | Returns a promise which completes after the _promise_ input has completed, regardless of 225 | whether it has resolved or rejected. The _fn_ is passed `true` if the promise did not error, 226 | otherwise `false`, and the promise's _result_ as the second argument. 227 | @param fn _function(ok, result)_ 228 | @example 229 | local getHunger = dash.async(function(player) 230 | if player.health == 0 then 231 | error("Player is dead!") 232 | else 233 | return game.ReplicatedStorage.GetHunger:InvokeServer( player ) 234 | end 235 | end) 236 | local localPlayer = game.Players.LocalPlayer 237 | local isHungry = getHunger( localPlayer ):finally(function(isAlive, result) 238 | return isAlive and result < 5 239 | end) 240 | ]] 241 | --: (Promise, (bool, T) -> R) -> Promise 242 | function Async.finally(promise, fn) 243 | assert(Async.isPromise(promise), "BadInput: promise must be a promise") 244 | return promise:andThen( 245 | function(...) 246 | return fn(true, ...) 247 | end 248 | ):catch( 249 | function(...) 250 | return fn(false, ...) 251 | end 252 | ) 253 | end 254 | 255 | --[[ 256 | Returns a promise which never resolves or rejects. 257 | @usage Useful in combination with `dash.race` where a resolution or rejection should be ignored. 258 | ]] 259 | --: () -> never 260 | function Async.never() 261 | return Promise.new(Functions.noop) 262 | end 263 | 264 | --[[ 265 | Resolves to the result of `promise` if it resolves before the deadline, otherwise rejects with 266 | an error, which can be optionally customized. 267 | @param timeoutMessage (default = "TimeoutError") 268 | @rejects **TimeoutError** - or _timeoutMessage_ 269 | @example 270 | let eatGreens = function() return dash.never end 271 | dash.timeout(eatGreens(), 10, "TasteError"):await() 272 | --> throws "TasteError" (after 10s) 273 | ]] 274 | --: (Promise, number, string?) -> Promise 275 | function Async.timeout(promise, deadlineInSeconds, timeoutMessage) 276 | return Async.race( 277 | { 278 | promise, 279 | Async.delay(deadlineInSeconds):andThen(Functions.throws(timeoutMessage or "TimeoutError")) 280 | } 281 | ) 282 | end 283 | 284 | --[[ 285 | Like `dash.compose` but takes functions that can return a promise. Returns a promise that resolves 286 | once all functions have resolved. Like compose, functions receive the resolution of the 287 | previous promise as argument(s). 288 | @example 289 | local function fry(item) 290 | return dash.delay(1):andThen(dash.returns("fried " .. item)) 291 | end 292 | local function cheesify(item) 293 | return dash.delay(1):andThen(dash.returns("cheesy " .. item)) 294 | end 295 | local prepare = dash.series(fry, cheesify) 296 | prepare("nachos"):await() --> "cheesy fried nachos" (after 2s) 297 | @see `dash.parallel` 298 | @see `dash.delay` 299 | ]] 300 | --: ((...A -> Promise)[]) -> ...A -> Promise 301 | function Async.series(...) 302 | local fnCount = select("#", ...) 303 | local fns = {...} 304 | return Async.async( 305 | function(...) 306 | local result = {fns[1](...)} 307 | for i = 2, fnCount do 308 | result = {Async.resolve(fns[i](unpack(result))):await()} 309 | end 310 | return unpack(result) 311 | end 312 | ) 313 | end 314 | 315 | --[[ 316 | Returns a promise which resolves after the given delayInSeconds. 317 | @example dash.delay(1):andThen(function() print("Delivered") end) 318 | -->> Delivered (1 second later) 319 | ]] 320 | --: number -> Promise 321 | function Async.delay(delayInSeconds) 322 | assert(t.number(delayInSeconds), "BadInput: delayInSeconds must be a number") 323 | return Promise.new( 324 | function(resolve) 325 | delay(delayInSeconds, resolve) 326 | end 327 | ) 328 | end 329 | 330 | --[[ 331 | Wraps a function which may yield in a promise. When run, async calls the 332 | the function in a coroutine and resolves with the output of the function 333 | after any asynchronous actions, and rejects if the function throws an error. 334 | @rejects passthrough 335 | @example 336 | local fetch = dash.async(function(url) 337 | local HttpService = game:GetService("HttpService") 338 | return HttpService:GetAsync(url) 339 | end) 340 | fetch("http://example.com/burger"):andThen(function(meal) 341 | print("Meal:", meal) 342 | end) 343 | -->> Meal: Cheeseburger (ideal response) 344 | @usage With `promise:await` the `dash.async` function can be used just like the async-await pattern in languages like JS. 345 | @see `dash.parallel` 346 | ]] 347 | --: (Yieldable) -> ...A -> Promise 348 | function Async.async(fn) 349 | assert(Functions.isCallable(fn), "BadInput: fn must be callable") 350 | return function(...) 351 | local callArgs = {...} 352 | return Promise.new( 353 | function(resolve, reject) 354 | coroutine.wrap( 355 | function() 356 | local ok, result = pcall(fn, unpack(callArgs)) 357 | if ok then 358 | resolve(result) 359 | else 360 | reject(result) 361 | end 362 | end 363 | )() 364 | end 365 | ) 366 | end 367 | end 368 | 369 | --[[ 370 | Wraps any functions in _dictionary_ with `dash.async`, returning a new dictionary containing 371 | functions that return promises when called rather than yielding. 372 | @example 373 | local http = dash.asyncAll(game:GetService("HttpService")) 374 | http:GetAsync("http://example.com/burger"):andThen(function(meal) 375 | print("Meal", meal) 376 | end) 377 | -->> Meal: Cheeseburger (some time later) 378 | @example 379 | local buyDinner = dash.async(function() 380 | local http = dash.asyncAll(game:GetService("HttpService")) 381 | local order = dash.parallelAll({ 382 | main = http:GetAsync("http://example.com/burger"), 383 | side = http:GetAsync("http://example.com/fries") 384 | }) 385 | return http:PostAsync("http://example.com/purchase", order:await()) 386 | end) 387 | buyDinner():await() --> "Purchased!" (some time later) 388 | @see `dash.async` 389 | @see `dash.parallelAll` 390 | ]] 391 | --: (Yieldable{}) -> (...A -> Promise){} 392 | function Async.asyncAll(dictionary) 393 | assert(t.table(dictionary), "BadInput: dictionary must be a table") 394 | local result = 395 | Tables.map( 396 | dictionary, 397 | function(value) 398 | if Functions.isCallable(value) then 399 | return Async.async(value) 400 | else 401 | return value 402 | end 403 | end 404 | ) 405 | setmetatable(result, getmetatable(dictionary)) 406 | return result 407 | end 408 | 409 | --[[ 410 | Try running a function which returns a promise and retry if the function throws 411 | and error or the promise rejects. The retry behavior can be adapted using 412 | backoffOptions, which can customize the maximum number of retries and the backoff 413 | timing of the form `[0, x^attemptNumber] + y` where _x_ is an exponent that produces 414 | a random exponential delay and _y_ is a constant delay. 415 | 416 | @rejects passthrough 417 | @example 418 | -- Use dash.retryWithBackoff to retry a GET request repeatedly. 419 | local fetchPizza = dash.async(function() 420 | local HttpService = game:GetService("HttpService") 421 | return HttpService:GetAsync("https://example.com/pizza") 422 | end) 423 | dash.retryWithBackoff(fetchPizza, { 424 | maxTries = 3, 425 | onRetry = function(waitTime, errorMessage) 426 | print("Failed to fetch due to", errorMessage) 427 | print("Retrying in ", waitTime) 428 | end 429 | }):andThen(function(resultingPizza) 430 | print("Great, you have: ", resultingPizza) 431 | end) 432 | ]] 433 | --: (Async, BackoffOptions) -> Promise 434 | function Async.retryWithBackoff(asyncFn, backoffOptions) 435 | assert(Functions.isCallable(asyncFn), "BadInput: asyncFn must be callable") 436 | local function backoffThenRetry(errorMessage) 437 | local waitTime = 438 | (backoffOptions.retryExponentInSeconds ^ backoffOptions.attemptNumber) * backoffOptions.randomStream:NextNumber() + 439 | backoffOptions.retryConstantInSeconds 440 | backoffOptions.onRetry(waitTime, errorMessage) 441 | return Async.delay(waitTime):andThen( 442 | function() 443 | return Async.retryWithBackoff( 444 | asyncFn, 445 | Tables.assign( 446 | {}, 447 | backoffOptions, 448 | { 449 | maxTries = backoffOptions.maxTries - 1, 450 | attemptNumber = backoffOptions.attemptNumber + 1 451 | } 452 | ) 453 | ) 454 | end 455 | ) 456 | end 457 | 458 | local function getDurationInSeconds() 459 | return tick() - backoffOptions.startTime 460 | end 461 | 462 | backoffOptions = 463 | Tables.assign( 464 | { 465 | startTime = tick(), 466 | maxTries = 5, 467 | attemptNumber = 0, 468 | retryExponentInSeconds = 5, 469 | retryConstantInSeconds = 2, 470 | randomStream = baseRandomStream, 471 | onRetry = function() 472 | end, 473 | onDone = function() 474 | end, 475 | onFail = function() 476 | end, 477 | shouldRetry = function() 478 | return true 479 | end 480 | }, 481 | backoffOptions 482 | ) 483 | assert(backoffOptions.maxTries > 0, "BadInput: maxTries must be > 0") 484 | 485 | local function shouldRetry(response) 486 | return backoffOptions.maxTries > 1 and backoffOptions.shouldRetry(response) 487 | end 488 | 489 | local function retryIfShouldElseCallOnFailAndReturn(response, failHandler) 490 | if shouldRetry(response) then 491 | return backoffThenRetry(response) 492 | else 493 | backoffOptions.onFail(response) 494 | return failHandler(response) 495 | end 496 | end 497 | 498 | local function callOnDoneAndReturnPromise(response) 499 | backoffOptions.onDone(response, getDurationInSeconds()) 500 | return Async.isPromise(response) and response or Promise.resolve(response) 501 | end 502 | 503 | local ok, response = 504 | pcall( 505 | function() 506 | return asyncFn() 507 | end 508 | ) 509 | 510 | if ok then 511 | if Async.isPromise(response) then 512 | return response:catch( 513 | function(response) 514 | return retryIfShouldElseCallOnFailAndReturn(response, error) 515 | end 516 | ):andThen(callOnDoneAndReturnPromise) 517 | else 518 | return callOnDoneAndReturnPromise(response) 519 | end 520 | else 521 | return retryIfShouldElseCallOnFailAndReturn(response, Promise.reject) 522 | end 523 | end 524 | 525 | return Async 526 | -------------------------------------------------------------------------------- /src/Strings.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Useful functions to manipulate strings, based on similar implementations in other standard libraries. 3 | ]] 4 | local t = require(script.Parent.Parent.t) 5 | local Functions = require(script.Parent.Functions) 6 | local Tables = require(script.Parent.Tables) 7 | local Strings = {} 8 | local insert = table.insert 9 | local concat = table.concat 10 | 11 | local function assertStrIsString(str) 12 | assert(t.string(str), "BadInput: str must be a string") 13 | end 14 | 15 | --[[ 16 | Convert `str` to camel-case. 17 | @example dash.camelCase('Pepperoni Pizza') --> 'pepperoniPizza' 18 | @example dash.camelCase('--pepperoni-pizza--') --> 'pepperoniPizza' 19 | @example dash.camelCase('__PEPPERONI_PIZZA') --> 'pepperoniPizza' 20 | @trait Chainable 21 | ]] 22 | --: string -> string 23 | function Strings.camelCase(str) 24 | assertStrIsString(str) 25 | return str:gsub( 26 | "(%a)([%w]*)", 27 | function(head, tail) 28 | return head:upper() .. tail:lower() 29 | end 30 | ):gsub("%A", ""):gsub("^%u", string.lower) 31 | end 32 | 33 | --[[ 34 | Convert `str` to kebab-case, making all letters lowercase. 35 | @example dash.kebabCase('strongStilton') --> 'strong-stilton' 36 | @example dash.kebabCase(' Strong Stilton ') --> 'strong-stilton' 37 | @example dash.kebabCase('__STRONG_STILTON__') --> 'strong-stilton' 38 | @usage Chain with `:upper()` if you need an upper kebab-case string. 39 | @trait Chainable 40 | ]] 41 | --: string -> string 42 | function Strings.kebabCase(str) 43 | assertStrIsString(str) 44 | return str:gsub( 45 | "(%l)(%u)", 46 | function(a, b) 47 | return a .. "-" .. b 48 | end 49 | ):gsub("%A", "-"):gsub("^%-+", ""):gsub("%-+$", ""):lower() 50 | end 51 | 52 | --[[ 53 | Convert `str` to snake-case, making all letters uppercase. 54 | @example dash.snakeCase('sweetChickenCurry') --> 'SWEET_CHICKEN_CURRY' 55 | @example dash.snakeCase(' Sweet Chicken Curry ') --> 'SWEET_CHICKEN__CURRY' 56 | @example dash.snakeCase('--sweet-chicken--curry--') --> 'SWEET_CHICKEN__CURRY' 57 | @usage Chain with `:lower()` if you need a lower snake-case string. 58 | @trait Chainable 59 | ]] 60 | --: string -> string 61 | function Strings.snakeCase(str) 62 | assertStrIsString(str) 63 | return str:gsub( 64 | "(%l)(%u)", 65 | function(a, b) 66 | return a .. "_" .. b 67 | end 68 | ):gsub("%A", "_"):gsub("^_+", ""):gsub("_+$", ""):upper() 69 | end 70 | 71 | --[[ 72 | Convert `str` to title-case, where the first letter of each word is capitalized. 73 | @example dash.titleCase("jello world") --> "Jello World" 74 | @example dash.titleCase("yellow-jello with_sprinkles") --> "Yellow-jello With_sprinkles" 75 | @example dash.titleCase("yellow jello's don’t mellow") --> "Yellow Jello's Dont’t Mellow" 76 | @usage Dashes, underscores and apostraphes don't break words. 77 | @trait Chainable 78 | ]] 79 | --: string -> string 80 | function Strings.titleCase(str) 81 | assertStrIsString(str) 82 | return str:gsub( 83 | "(%a)([%w_%-'’]*)", 84 | function(head, tail) 85 | return head:upper() .. tail 86 | end 87 | ) 88 | end 89 | 90 | --[[ 91 | Capitalize the first letter of `str`. 92 | @example dash.capitalize("hello mould") --> "Hello mould" 93 | @trait Chainable 94 | ]] 95 | --: string -> string 96 | function Strings.capitalize(str) 97 | assertStrIsString(str) 98 | return str:gsub("^%l", string.upper) 99 | end 100 | 101 | --[==[ 102 | Converts the characters `&<>"'` in `str` to their corresponding HTML entities. 103 | @example dash.encodeHtml([[Pease < Bacon > "Fish" & 'Chips']]) --> "Peas < Bacon > "Fish" & 'Chips'" 104 | @trait Chainable 105 | ]==] 106 | --: string -> string 107 | function Strings.encodeHtml(str) 108 | assertStrIsString(str) 109 | local entities = {["<"] = "lt", [">"] = "gt", ["&"] = "amp", ['"'] = "quot", ["'"] = "apos"} 110 | local result = 111 | str:gsub( 112 | ".", 113 | function(char) 114 | return entities[char] and ("&" .. entities[char] .. ";") or char 115 | end 116 | ) 117 | return result 118 | end 119 | 120 | --[==[ 121 | The inverse of `dash.encodeHtml`. 122 | Converts any HTML entities in `str` to their corresponding characters. 123 | @example dash.decodeHtml("<b>"Smashed"</b> 'Avocado' 😏") --> [["Smashed" 'Avocado' 😏]] 124 | @trait Chainable 125 | ]==] 126 | --: string -> string 127 | function Strings.decodeHtml(str) 128 | assertStrIsString(str) 129 | local entities = {lt = "<", gt = ">", amp = "&", quot = '"', apos = "'"} 130 | local result = 131 | str:gsub( 132 | "(&(#?x?)([%d%a]+);)", 133 | function(original, hashPrefix, code) 134 | return (hashPrefix == "" and entities[code]) or 135 | (hashPrefix == "#x" and tonumber(code, 16)) and utf8.char(tonumber(code, 16)) or 136 | (hashPrefix == "#" and tonumber(code)) and utf8.char(code) or 137 | original 138 | end 139 | ) 140 | return result 141 | end 142 | 143 | --[[ 144 | Splits `str` into parts based on a pattern delimiter and returns a table of the parts, followed 145 | by a table of the matched delimiters. 146 | @example dash.splitOn("rice") --> {"r", "i", "c", "e"}, {"", "", "", ""} 147 | @example dash.splitOn("one.two::flour", "[.:]") --> {"one", "two", "", "flour"}, {".", ":", ":"} 148 | @usage This method is useful only when you need a _pattern_ as a delimiter. 149 | @usage Use the Roblox native `string.split` if you are splitting on a simple string. 150 | @param delimiter (default = "") 151 | @trait Chainable 152 | ]] 153 | --: string, pattern -> string[], string[] 154 | function Strings.splitOn(str, pattern) 155 | assertStrIsString(str) 156 | assert(t.optional(t.string)(pattern), "BadInput: pattern must be a string or nil") 157 | local parts = {} 158 | local delimiters = {} 159 | local from = 1 160 | if not pattern then 161 | for i = 1, #str do 162 | insert(parts, str:sub(i, i)) 163 | end 164 | return parts 165 | end 166 | local delimiterStart, delimiterEnd = str:find(pattern, from) 167 | while delimiterStart do 168 | insert(delimiters, str:sub(delimiterStart, delimiterEnd)) 169 | insert(parts, str:sub(from, delimiterStart - 1)) 170 | from = delimiterEnd + 1 171 | delimiterStart, delimiterEnd = str:find(pattern, from) 172 | end 173 | insert(parts, str:sub(from)) 174 | return parts, delimiters 175 | end 176 | 177 | --[[ 178 | Removes any spaces from the start and end of `str`. 179 | @example dash.trim(" roast veg ") --> "roast veg" 180 | @trait Chainable 181 | ]] 182 | --: string -> string 183 | function Strings.trim(str) 184 | assertStrIsString(str) 185 | return str:match("^%s*(.-)%s*$") 186 | end 187 | 188 | --[[ 189 | Checks if `str` starts with the string `start`. 190 | @example dash.startsWith("Fun Roblox Games", "Fun") --> true 191 | @example dash.startsWith("Chess", "Fun") --> false 192 | @trait Chainable 193 | ]] 194 | --: string, string -> bool 195 | function Strings.startsWith(str, prefix) 196 | assertStrIsString(str) 197 | assert(t.string(prefix), "BadInput: prefix must be a string") 198 | return str:sub(1, prefix:len()) == prefix 199 | end 200 | 201 | --[[ 202 | Checks if `str` ends with the string `suffix`. 203 | @example dash.endsWith("Fun Roblox Games", "Games") --> true 204 | @example dash.endsWith("Bad Roblox Memes", "Games") --> false 205 | @trait Chainable 206 | ]] 207 | --: string, string -> bool 208 | function Strings.endsWith(str, suffix) 209 | assertStrIsString(str) 210 | assert(t.string(suffix), "BadInput: suffix must be a string") 211 | return str:sub(-suffix:len()) == suffix 212 | end 213 | 214 | --[[ 215 | Makes a string of `length` from `str` by repeating characters from `prefix` at the start of the string. 216 | @example dash.leftPad("toast", 6) --> " toast" 217 | @example dash.leftPad("2", 2, "0") --> "02" 218 | @example dash.leftPad("toast", 10, ":)") --> ":):):toast" 219 | @param prefix (default = `" "`) 220 | @trait Chainable 221 | ]] 222 | --: string, number, string -> string 223 | function Strings.leftPad(str, length, prefix) 224 | assertStrIsString(str) 225 | assert(t.number(length), "BadInput: length must be a number") 226 | assert(t.optional(t.string)(prefix), "BadInput: prefix must be a string or nil") 227 | prefix = prefix or " " 228 | local padLength = length - #str 229 | local remainder = padLength % #prefix 230 | local repetitions = (padLength - remainder) / #prefix 231 | return string.rep(prefix or " ", repetitions) .. prefix:sub(1, remainder) .. str 232 | end 233 | 234 | --[[ 235 | Makes a string of `length` from `str` by repeating characters from `suffix` at the end of the string. 236 | @example dash.rightPad("toast", 6) --> "toast " 237 | @example dash.rightPad("2", 2, "!") --> "2!" 238 | @example dash.rightPad("toast", 10, ":)") --> "toast:):):" 239 | @param suffix (default = `" "`) 240 | @trait Chainable 241 | ]] 242 | --: string, number, string -> string 243 | function Strings.rightPad(str, length, suffix) 244 | assertStrIsString(str) 245 | assert(t.number(length), "BadInput: length must be a number") 246 | assert(t.optional(t.string)(suffix), "BadInput: suffix must be a string or nil") 247 | suffix = suffix or " " 248 | local padLength = length - #str 249 | local remainder = padLength % #suffix 250 | local repetitions = (padLength - remainder) / #suffix 251 | return str .. string.rep(suffix or " ", repetitions) .. suffix:sub(1, remainder) 252 | end 253 | 254 | --[[ 255 | This function first calls `dash.format` on the arguments provided and then outputs the response 256 | to the debug target, set using `dash.setDebug`. By default, this function does nothing, allowing 257 | developers to leave the calls in the source code if that is beneficial. 258 | @param format the format match string 259 | @example 260 | -- During development: 261 | dash.setDebug() 262 | -- At any point in the code: 263 | dash.debug("Hello {}", game.Players.LocalPlayer) 264 | -->> Hello builderman (for example) 265 | @usage A common pattern would be to `dash.setDebug()` to alias to `print` during local development, 266 | and send debug messages to an HTTP server on a production build to allow remote debugging. 267 | @see `dash.setDebug` 268 | ]] 269 | --: string, ... -> string 270 | function Strings.debug(format, ...) 271 | if Strings.debugTarget == nil then 272 | return 273 | end 274 | Strings.debugTarget(Strings.format(format, ...)) 275 | end 276 | 277 | --[[ 278 | Hooks up any debug methods to invoke _fn_. By default, `dash.debug` does nothing. 279 | @param fn (default = `print`) 280 | @usage Calling `dash.setDebug()` will simply print all calls to `dash.debug` with formatted arguments. 281 | @example 282 | local postMessage = dash.async(function(message) 283 | HttpService.PostAsync("https://example.com/log", message) 284 | end 285 | -- During production: 286 | dash.setDebug(postMessage) 287 | -- At any point in the code: 288 | dash.debug("Hello is printed") 289 | -- "Hello is printed" is posted to the server 290 | @see `dash.debug` 291 | @see `dash.async` 292 | ]] 293 | --: (...A -> ()) 294 | function Strings.setDebug(fn) 295 | Strings.debugTarget = fn 296 | end 297 | 298 | --[[ 299 | Converts _char_ into a hex representation 300 | @param format (optional) a string passed to `dash.format` which formats the hex value of each of the character's code points. 301 | @param useBytes (default = false) whether to use the character's bytes, rather than UTF-8 code points. 302 | @example dash.charToHex("<") --> "3C" 303 | @example dash.charToHex("<", "&#{};") --> "C;" 304 | @example dash.charToHex("😏") --> "1F60F" 305 | @example dash.charToHex("😏", "0x{}") --> "0x1F60F" 306 | @example dash.charToHex("🤷🏼‍♀️", "&#x{};") --> "🤷🏼‍♀️" 307 | @example dash.charToHex("🤷🏼‍♀️", "%{}", true) --> "%F0%9F%A4%B7%F0%9F%8F%BC%E2%80%8D%E2%99%80%EF%B8%8F" 308 | ]] 309 | --: char, string?, boolean? -> string 310 | function Strings.charToHex(char, format, useBytes) 311 | assert(t.string(char), "BadInput: char must be a single utf8 character string") 312 | local values = {} 313 | if useBytes then 314 | for i = 1, char:len() do 315 | insert(values, char:byte(i)) 316 | end 317 | else 318 | for position, codePoint in utf8.codes(char) do 319 | insert(values, codePoint) 320 | end 321 | end 322 | return concat( 323 | Tables.map( 324 | values, 325 | function(value) 326 | local hexValue = string.format("%X", value) 327 | return format and Strings.format(format, hexValue) or hexValue 328 | end, 329 | "" 330 | ) 331 | ) 332 | end 333 | 334 | --[[ 335 | Generates a character from its _hex_ representation. 336 | @example dash.hexToChar("1F60F") --> "😏" 337 | @example dash.hexToChar("%1F60F") --> "😏" 338 | @example dash.hexToChar("#1F60F") --> "😏" 339 | @example dash.hexToChar("0x1F60F") --> "😏" 340 | @throws _MalformedInput_ if _char_ is not a valid encoding. 341 | ]] 342 | --: str -> char 343 | function Strings.hexToChar(hex) 344 | assert(t.string(hex), "BadInput: hex must be a string") 345 | if hex:sub(0, 1) == "%" or hex:sub(0, 1) == "#" then 346 | hex = hex:sub(2) 347 | elseif hex:sub(0, 2) == "0x" then 348 | hex = hex:sub(3) 349 | end 350 | return utf8.char(tonumber(hex, 16)) or error("MalformedInput") 351 | end 352 | 353 | --[[ 354 | Encodes _str_ for use as a URL, for example when calling an HTTP endpoint. 355 | 356 | Note that, unlike this function, `HttpService.EncodeUrl` actually attempts to encode a string 357 | for purposes as a URL component rather than an entire URL, and as such will not produce a valid 358 | URL. 359 | 360 | @trait Chainable 361 | @example 362 | dash.encodeUrl("https://example.com/Egg+Fried Rice!?🤷🏼‍♀️") 363 | --> "https://example.com/Egg+Fried%20Rice!?%F0%9F%A4%B7%F0%9F%8F%BC%E2%80%8D%E2%99%80%EF%B8%8F" 364 | @usage 365 | This method is designed to act like `encodeURI` in JavaScript. 366 | @see `dash.encodeUrlComponent` 367 | ]] 368 | --: string -> string 369 | function Strings.encodeUrl(str) 370 | assertStrIsString(str) 371 | local result = {} 372 | for _, codePoint in utf8.codes(str) do 373 | local char = utf8.char(codePoint) 374 | if char:match("^[%;%,%/%?%:%@%&%=%+%$%w%-%_%.%!%~%*%'%(%)%#]$") then 375 | table.insert(result, char) 376 | else 377 | table.insert(result, Strings.charToHex(char, "%{}", true)) 378 | end 379 | end 380 | return table.concat(result, "") 381 | end 382 | 383 | --[[ 384 | Encodes _str_ for use in a URL, for example as a query parameter of a call to an HTTP endpoint. 385 | @trait Chainable 386 | @example 387 | dash.encodeUrlComponent("https://example.com/Egg+Fried Rice!?🤷🏼‍♀️") 388 | --> "https%3A%2F%2Fexample.com%2FEgg%2BFried%20Rice!%3F%F0%9F%A4%B7%F0%9F%8F%BC%E2%80%8D%E2%99%80%EF%B8%8F" 389 | @usage 390 | This method is designed to act like `encodeURIComponent` in JavaScript. 391 | @usage 392 | This is very similar to `HttpService.EncodeUrl`, but is included for parity and conforms closer to the standard (e.g. EncodeUrl unnecessarily encodes `!`). 393 | ]] 394 | --: string -> string 395 | function Strings.encodeUrlComponent(str) 396 | assertStrIsString(str) 397 | local result = {} 398 | for _, codePoint in utf8.codes(str) do 399 | local char = utf8.char(codePoint) 400 | if char:match("^[%;%,%/%?%:%@%&%=%+%$%w%-%_%.%!%~%*%'%(%)%#]$") then 401 | table.insert(result, char) 402 | else 403 | table.insert(result, Strings.charToHex(char, "%{}", true)) 404 | end 405 | end 406 | return table.concat(result, "") 407 | end 408 | 409 | local calculateDecodeUrlExceptions = 410 | Functions.once( 411 | function() 412 | local exceptions = {} 413 | for char in ("#$&+,/:;=?@"):gmatch(".") do 414 | exceptions[string.byte(char)] = true 415 | end 416 | return exceptions 417 | end 418 | ) 419 | 420 | --[[ 421 | The inverse of `dash.encodeUrl`. Use this to turn a URL which has been encoded for use in a 422 | HTTP request back into its original form. 423 | @trait Chainable 424 | @example 425 | dash.decodeUrl("https://Egg+Fried%20Rice!?") 426 | --> "https://Egg+Fried Rice!?" 427 | @usage 428 | This method is designed to act like `decodeURI` in JavaScript. 429 | ]] 430 | --: string -> string 431 | function Strings.decodeUrl(str) 432 | assertStrIsString(str) 433 | local exceptions = calculateDecodeUrlExceptions() 434 | return str:gsub( 435 | "%%(%x%x)", 436 | function(term) 437 | local charId = tonumber(term, 16) 438 | if not exceptions[charId] then 439 | return utf8.char(charId) 440 | end 441 | end 442 | ) 443 | end 444 | 445 | --[[ 446 | The inverse of `dash.encodeUrlComponent`. Use this to turn a string which has been encoded for 447 | use as a component of a url back into its original form. 448 | @trait Chainable 449 | @example 450 | dash.decodeUrlComponent("https%3A%2F%2FEgg%2BFried%20Rice!%3F") 451 | --> "https://Egg+Fried Rice!?" 452 | @usage This method is designed to act like `decodeURIComponent` in JavaScript. 453 | @throws _MalformedInput_ if _str_ contains characters encoded incorrectly. 454 | ]] 455 | --: string -> string 456 | function Strings.decodeUrlComponent(str) 457 | assertStrIsString(str) 458 | return str:gsub("%%(%x%x)", Strings.hexToChar) 459 | end 460 | 461 | --[[ 462 | Takes a _query_ dictionary of key-value pairs and builds a query string that can be concatenated 463 | to the end of a url. 464 | 465 | @example 466 | dash.encodeQueryString({ 467 | time = 11, 468 | biscuits = "hob nobs", 469 | chocolatey = true 470 | })) --> "?biscuits=hob+nobs&time=11&chocolatey=true" 471 | 472 | @usage A query string which contains duplicate keys with different values is technically valid, but this function doesn't provide a way to produce them. 473 | ]] 474 | --: (Iterable -> string) 475 | function Strings.encodeQueryString(query) 476 | assert(t.table(query), "BadInput: query must be a table") 477 | local fields = 478 | Tables.mapValues( 479 | query, 480 | function(value, key) 481 | return Strings.encodeUrlComponent(tostring(key)) .. "=" .. Strings.encodeUrlComponent(tostring(value)) 482 | end 483 | ) 484 | return ("?" .. concat(fields, "&")) 485 | end 486 | 487 | --[[ 488 | Returns the _format_ string with placeholders `{...}` substituted with readable representations 489 | of the subsequent arguments. 490 | 491 | This function is a simpler & more powerful version of `string.format`, inspired by `format!` 492 | in Rust. 493 | 494 | * `{}` formats and prints the next argument using `:format()` if available, or a suitable 495 | default representation depending on its type. 496 | * `{2}` formats and prints the 2nd argument. 497 | * `{#2}` prints the length of the 2nd argument. 498 | 499 | Display parameters can be combined after a `:` in the curly braces. Any format parameters used 500 | in `string.format` can be used here, along with these extras: 501 | 502 | * `{:?}` formats any value using `dash.serializeDeep`. 503 | * `{:#?}` formats any value using `dash.pretty`. 504 | * `{:b}` formats a number in its binary representation. 505 | @example 506 | local props = {"teeth", "claws", "whiskers", "tail"} 507 | dash.format("{:?} is in {:#?}", "whiskers", props) 508 | -> '"whiskers" is in {"teeth", "claws", "whiskers", "tail"}' 509 | @example 510 | dash.format("{} in binary is {1:b}", 125) -> "125 in binary is 110100" 511 | @example 512 | dash.format("The time is {:02}:{:02}", 2, 4) -> "The time is 02:04" 513 | @example 514 | dash.format("The color blue is #{:06X}", 255) -> "The color blue is #0000FF" 515 | @usage Escape `{` with `{{` and `}` similarly with `}}`. 516 | @usage See [https://developer.roblox.com/articles/Format-String](https://developer.roblox.com/articles/Format-String) 517 | for complete list of formating options and further use cases. 518 | @see `dash.serializeDeep` 519 | @see `dash.pretty` 520 | ]] 521 | --: string, ... -> string 522 | function Strings.format(format, ...) 523 | local args = {...} 524 | local argIndex = 1 525 | local texts, subs = Strings.splitOn(format, "{[^{}]*}") 526 | local result = {} 527 | for i, text in pairs(texts) do 528 | local unescaped = text:gsub("{{", "{"):gsub("}}", "}") 529 | insert(result, unescaped) 530 | local placeholder = subs[i] and subs[i]:sub(2, -2) 531 | if placeholder then 532 | local escapeMatch = text:gmatch("{+$")() 533 | local isEscaped = escapeMatch and #escapeMatch % 2 == 1 534 | if not isEscaped then 535 | local placeholderSplit = Strings.splitOn(placeholder, ":") 536 | local isLength = Strings.startsWith(placeholderSplit[1], "#") 537 | local argString = isLength and placeholderSplit[1]:sub(2) or placeholderSplit[1] 538 | local nextIndex = tonumber(argString) 539 | local displayString = placeholderSplit[2] 540 | local arg 541 | if nextIndex then 542 | arg = args[nextIndex] 543 | else 544 | arg = args[argIndex] 545 | argIndex = argIndex + 1 546 | end 547 | if isLength then 548 | arg = #arg 549 | end 550 | insert(result, Strings.formatValue(arg, displayString or "")) 551 | else 552 | local unescapedSub = placeholder 553 | insert(result, unescapedSub) 554 | end 555 | end 556 | end 557 | return table.concat(result, "") 558 | end 559 | 560 | local function decimalToBinary(number) 561 | local binaryEight = { 562 | ["1"] = "000", 563 | ["2"] = "001", 564 | ["3"] = "010", 565 | ["4"] = "011", 566 | ["5"] = "100", 567 | ["6"] = "101", 568 | ["7"] = "110", 569 | ["8"] = "111" 570 | } 571 | return string.format("%o", number):gsub( 572 | ".", 573 | function(char) 574 | return binaryEight[char] 575 | end 576 | ):gsub("^0+", "") 577 | end 578 | 579 | --[[ 580 | Format a specific _value_ using the specified _displayString_. 581 | @example 582 | dash.formatValue(255, ":06X") --> 0000FF 583 | @see `dash.format` - for a full description of valid display strings. 584 | ]] 585 | --: any, DisplayString -> string 586 | function Strings.formatValue(value, displayString) 587 | local displayTypeStart, displayTypeEnd = displayString:find("[A-Za-z#?]+") 588 | if displayTypeStart then 589 | local displayType = displayString:sub(displayTypeStart, displayTypeEnd) 590 | local formatAsString = 591 | "%" .. displayString:sub(1, displayTypeStart - 1) .. displayString:sub(displayTypeEnd + 1) .. "s" 592 | if displayType == "#?" then 593 | return string.format(formatAsString, Strings.pretty(value)) 594 | elseif displayType == "?" then 595 | return string.format(formatAsString, Tables.serializeDeep(value)) 596 | elseif displayType == "#b" then 597 | local result = decimalToBinary(value) 598 | return string.format(formatAsString, "0b" .. result) 599 | elseif displayType == "b" then 600 | local result = decimalToBinary(value) 601 | return string.format(formatAsString, result) 602 | end 603 | return string.format("%" .. displayString, value) 604 | else 605 | local displayType = "s" 606 | if type(value) == "number" then 607 | local _, fraction = math.modf(value) 608 | displayType = fraction == 0 and "d" or "f" 609 | end 610 | return string.format("%" .. displayString .. displayType, tostring(value)) 611 | end 612 | end 613 | 614 | --[[ 615 | Returns a human-readable string for the given _value_. The string will be formatted across 616 | multiple lines if a descendant element gets longer than `80` characters. 617 | 618 | Optionally a table of [SerializeOptions](/rodash/types#SerializeOptions) can be passed which will pass 619 | to the underlying `dash.serialize` function so you can customise what is displayed. 620 | 621 | @example 622 | local fox = { 623 | name = "Mr. Fox", 624 | color = "red" 625 | } 626 | print(dash.pretty(fox)) 627 | -->> {color = "red", name = "Mr. Fox"} 628 | @example 629 | local fox = { 630 | name = "Mr. Fox", 631 | color = "red" 632 | } 633 | print(dash.pretty(fox, {omitKeys = {"name"}})) 634 | -->> {color = "red"} 635 | 636 | @see `dash.serializeDeep` for a compact alternative. 637 | ]] 638 | --: (T, SerializeOptions? -> string) 639 | function Strings.pretty(value, serializeOptions) 640 | local function serializeValue(value, options) 641 | if type(value) == "table" then 642 | local className = "" 643 | if value.Class then 644 | className = value.Class.name .. " " 645 | end 646 | return className .. Tables.serialize(value, options) 647 | else 648 | return Tables.defaultSerializer(value, options) 649 | end 650 | end 651 | 652 | local MAX_LINE = 80 653 | 654 | return Tables.serialize( 655 | value, 656 | Tables.assign( 657 | { 658 | serializeValue = serializeValue, 659 | serializeKey = function(key, options) 660 | if type(key) == "string" then 661 | return key 662 | else 663 | return "[" .. serializeValue(key, options) .. "]" 664 | end 665 | end, 666 | serializeElement = function(key, value) 667 | local shortString = key .. " = " .. value 668 | if #shortString < MAX_LINE or shortString:match("\n") then 669 | return shortString 670 | end 671 | return key .. " =\n\t" .. value 672 | end or nil, 673 | serializeTable = function(contents, ref, options) 674 | local shortString = ref .. "{" .. table.concat(contents, ", ") .. "}" 675 | if #shortString < MAX_LINE then 676 | return shortString 677 | end 678 | return ref .. 679 | "{\n" .. 680 | table.concat( 681 | Tables.map( 682 | contents, 683 | function(element) 684 | return "\t" .. element:gsub("\n", "\n\t") 685 | end 686 | ), 687 | ",\n" 688 | ) .. 689 | "\n}" 690 | end or nil, 691 | keyDelimiter = " = ", 692 | valueDelimiter = ", ", 693 | omitKeys = {"Class"} 694 | }, 695 | serializeOptions or {} 696 | ) 697 | ) 698 | end 699 | 700 | return Strings 701 | -------------------------------------------------------------------------------- /src/init.lua: -------------------------------------------------------------------------------- 1 | local Async = require(script.Async) 2 | local Classes = require(script.Classes) 3 | local Functions = require(script.Functions) 4 | local Strings = require(script.Strings) 5 | local Arrays = require(script.Arrays) 6 | local Tables = require(script.Tables) 7 | 8 | local dash = Tables.assign({}, Async, Classes, Functions, Strings, Arrays, Tables) 9 | return dash 10 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o nounset 4 | set -o errexit 5 | set -o pipefail 6 | 7 | export PATH="$PWD/lua_install/bin:$PATH" 8 | 9 | if [ -n "${DEBUG:-}" ] 10 | then 11 | echo "Testing with debug mode..." 12 | fi 13 | 14 | set -o xtrace 15 | busted --helper tools/testInit.lua -Xhelper "${DEBUG:+debug}" -m 'modules/?/lib/t.lua' -m 'modules/?/lib/init.lua' -m './lib/?.lua' -m './src/?.lua' -p "%.spec" spec ${COVERAGE:-} "$@" 16 | 17 | if [ -n "${COVERAGE:-}" ] 18 | then 19 | echo "Generating coverage..." 20 | ./lua_install/bin/luacov-cobertura -o cobertura-coverage.xml 21 | sed -i.bak -E "s%%$(pwd)%p" cobertura-coverage.xml 22 | rm cobertura-coverage.xml.bak 23 | fi -------------------------------------------------------------------------------- /tools/buildDocs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o nounset 4 | set -o errexit 5 | set -o pipefail 6 | 7 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 8 | cd "$SCRIPT_DIR" 9 | cd .. 10 | 11 | mkdir -p docs/api 12 | node node_modules/ts-node/dist/bin.js tools/docublox --output docs --libName "dash" --rootUrl "/rodash/" src docs_source 13 | mkdocs build --clean 14 | -------------------------------------------------------------------------------- /tools/checkFormat.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o nounset 4 | set -o errexit 5 | set -o pipefail 6 | 7 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 8 | cd "$SCRIPT_DIR" 9 | 10 | if [ -n "$(git status --porcelain)" ]; then 11 | echo "There are uncommitted changes in the work directory - this would prevent the code style check from working" 12 | exit 1 13 | fi 14 | 15 | ./format.sh 16 | 17 | if [ -n "$(git status --porcelain)" ]; then 18 | echo "The code style is invalid in the following files (run format.sh before committing):" 19 | git status 20 | exit 1 21 | fi -------------------------------------------------------------------------------- /tools/docublox/LuaTypes.ts: -------------------------------------------------------------------------------- 1 | import { stringify } from 'querystring'; 2 | 3 | export enum TypeKind { 4 | DEFINITION = 'definition', 5 | TABLE = 'table', 6 | ARRAY = 'array', 7 | DICTIONARY = 'dictionary', 8 | TUPLE = 'tuple', 9 | ALIAS = 'alias', 10 | FAIL = 'fail', 11 | FUNCTION = 'function', 12 | ANY = 'any', 13 | CHAR = 'char', 14 | STRING = 'string', 15 | NUMBER = 'number', 16 | INT = 'int', 17 | UINT = 'uint', 18 | FLOAT = 'float', 19 | BOOL = 'bool', 20 | NIL = 'nil', 21 | NEVER = 'never', 22 | VOID = 'void', 23 | UNION = 'union', 24 | OPTIONAL = 'optional', 25 | INTERSECTION = 'intersection', 26 | GENERIC = 'generic', 27 | } 28 | export interface Type { 29 | typeKind: TypeKind; 30 | isRestParameter?: boolean; 31 | genericParameters?: Type[]; 32 | tag?: string; 33 | isMutable?: boolean; 34 | } 35 | export interface GenericType { 36 | typeKind: TypeKind.GENERIC; 37 | tag: string; 38 | extendingType: Type; 39 | } 40 | export interface TupleType extends Type { 41 | typeKind: TypeKind.TUPLE; 42 | elementTypes: Type[]; 43 | genericTypes?: GenericType[]; 44 | } 45 | export interface FunctionType extends Type { 46 | typeKind: TypeKind.FUNCTION; 47 | parameterTypes: Type[]; 48 | returnType: ReturnType; 49 | genericTypes?: GenericType[]; 50 | } 51 | export interface UnionType extends Type { 52 | typeKind: TypeKind.UNION; 53 | allowedTypes: Type[]; 54 | } 55 | export interface IntersectionType extends Type { 56 | typeKind: TypeKind.INTERSECTION; 57 | requiredTypes: Type[]; 58 | } 59 | export interface OptionalType extends Type { 60 | typeKind: TypeKind.OPTIONAL; 61 | optionalType: Type; 62 | } 63 | export interface ReturnType extends Type { 64 | isYielding?: boolean; 65 | } 66 | export interface ArrayType extends Type { 67 | typeKind: TypeKind.ARRAY; 68 | valueType: Type; 69 | } 70 | export interface DictionaryType extends Type { 71 | typeKind: TypeKind.DICTIONARY; 72 | keyType: Type; 73 | valueType: Type; 74 | } 75 | export interface AliasType extends Type { 76 | typeKind: TypeKind.ALIAS; 77 | aliasName: string; 78 | } 79 | export interface TableType extends Type { 80 | typeKind: TypeKind.TABLE; 81 | dimensions: { isArray: boolean }[]; 82 | elementType: Type; 83 | } 84 | 85 | export function stringifyType(type: Type) { 86 | let typeString; 87 | switch (type.typeKind) { 88 | case TypeKind.ALIAS: 89 | typeString = (type as AliasType).aliasName; 90 | break; 91 | case TypeKind.OPTIONAL: 92 | typeString = stringifyType((type as OptionalType).optionalType) + '?'; 93 | break; 94 | case TypeKind.TABLE: 95 | const tableType = type as TableType; 96 | typeString = 97 | stringifyType(tableType.elementType) + 98 | tableType.dimensions.map(dim => (dim.isArray ? '[]' : '{}')).join(''); 99 | break; 100 | case TypeKind.FUNCTION: 101 | const functionType = type as FunctionType; 102 | typeString = 103 | '(' + 104 | functionType.parameterTypes.map(type => stringifyType(type)).join(', ') + 105 | ') -> ' + 106 | stringifyType(functionType.returnType); 107 | break; 108 | case TypeKind.ARRAY: 109 | const arrayType = type as ArrayType; 110 | typeString = '{' + (arrayType.valueType ? stringifyType(arrayType.valueType) : '') + '}'; 111 | break; 112 | case TypeKind.TUPLE: 113 | const tupleType = type as TupleType; 114 | typeString = tupleType.elementTypes.map(type => stringifyType(type)).join(', '); 115 | break; 116 | case TypeKind.UNION: 117 | const unionType = type as UnionType; 118 | typeString = unionType.allowedTypes.map(type => stringifyType(type)).join(' | '); 119 | break; 120 | case TypeKind.INTERSECTION: 121 | const intersectionType = type as IntersectionType; 122 | typeString = intersectionType.requiredTypes.map(type => stringifyType(type)).join(' & '); 123 | break; 124 | case TypeKind.DICTIONARY: 125 | const dictionaryType = type as DictionaryType; 126 | typeString = 127 | '{[' + 128 | stringifyType(dictionaryType.keyType) + 129 | ']: ' + 130 | stringifyType(dictionaryType.valueType) + 131 | '}'; 132 | break; 133 | default: 134 | typeString = type.typeKind; 135 | break; 136 | } 137 | return ( 138 | (type.tag ? type.tag + ': ' : '') + 139 | (type.isMutable ? 'mut ' : '') + 140 | (type.isRestParameter ? '...' : '') + 141 | typeString + 142 | (type.genericParameters 143 | ? '<' + type.genericParameters.map(param => stringifyType(param)).join(', ') + '>' 144 | : '') 145 | ); 146 | } 147 | 148 | export enum PLURALITY { 149 | SINGULAR, 150 | PLURAL, 151 | } 152 | 153 | export interface MetaDescription { 154 | rootUrl: string; 155 | generics: { [genericKey: string]: string }; 156 | } 157 | 158 | function pluralizeName(name: string, plurality?: PLURALITY) { 159 | const useAn = name.match(/(^[aeiou].)|(^[aefhilmnorsx][0-9]*$)/i); 160 | switch (plurality) { 161 | case PLURALITY.SINGULAR: 162 | return (useAn ? 'an ' : 'a ') + name; 163 | case PLURALITY.PLURAL: 164 | const commonPluralizations = { 165 | dictionary: 'dictionaries', 166 | strategy: 'strategies', 167 | }; 168 | return commonPluralizations[name] ? commonPluralizations[name] : name + 's'; 169 | default: 170 | return name; 171 | } 172 | } 173 | function joinList(values: string[]) { 174 | const output = []; 175 | if (values.length === 0) { 176 | return 'nothing'; 177 | } 178 | for (let i = 0; i < values.length - 2; i++) { 179 | output.push(values[i] + ', '); 180 | } 181 | for (let i = Math.max(values.length - 2, 0); i < values.length - 1; i++) { 182 | output.push(values[i] + ' and '); 183 | } 184 | output.push(values[values.length - 1]); 185 | return output.join(''); 186 | } 187 | 188 | export function getCommonNameForTypeVariable(name: string) { 189 | const commonNames = { 190 | A: 'the primary arguments', 191 | A2: 'the secondary arguments', 192 | K: 'the primary key type', 193 | K2: 'the secondary key type', 194 | R: 'the result type', 195 | S: 'the subject type', 196 | T: 'the primary type', 197 | T2: 'the secondary type', 198 | V: 'the primary value type', 199 | V2: 'the secondary value type', 200 | }; 201 | return commonNames[name]; 202 | } 203 | 204 | export function describeGeneric(type: GenericType, meta?: MetaDescription) { 205 | const commonName = getCommonNameForTypeVariable(type.tag); 206 | const name = commonName ? commonName : type.tag; 207 | if (meta) { 208 | return name + ' (extends ' + describeType(type.extendingType, meta, PLURALITY.SINGULAR) + ')'; 209 | } else { 210 | return name; 211 | } 212 | } 213 | 214 | export function describeType(type: Type, meta: MetaDescription, plurality?: PLURALITY): string { 215 | let typeString: string; 216 | if (type.isRestParameter) { 217 | plurality = PLURALITY.PLURAL; 218 | } 219 | switch (type.typeKind) { 220 | case TypeKind.ALIAS: 221 | const name = (type as AliasType).aliasName; 222 | if (meta.generics[name]) { 223 | typeString = meta.generics[name]; 224 | } else { 225 | typeString = `[${pluralizeName(name, plurality)}](${ 226 | meta.rootUrl 227 | }types/#${name.toLowerCase()})`; 228 | } 229 | break; 230 | case TypeKind.OPTIONAL: 231 | const optionalType = describeType((type as OptionalType).optionalType, meta, plurality); 232 | typeString = optionalType + ' (optional)'; 233 | break; 234 | case TypeKind.TABLE: 235 | const tableType = type as TableType; 236 | const elementType = describeType(tableType.elementType, meta, PLURALITY.PLURAL); 237 | if (tableType.dimensions.length === 1) { 238 | const dim = tableType.dimensions[0]; 239 | const name = pluralizeName(dim.isArray ? 'array' : 'dictionary', plurality); 240 | typeString = name + ' (of ' + elementType + ')'; 241 | } else { 242 | const dimensionString = pluralizeName(tableType.dimensions.length + 'd', plurality); 243 | const namePlurality = plurality === PLURALITY.SINGULAR ? undefined : plurality; 244 | const name = pluralizeName( 245 | tableType.dimensions[0].isArray ? 'array' : 'dictionary', 246 | namePlurality, 247 | ); 248 | typeString = dimensionString + ' ' + name + ' (of ' + elementType + ')'; 249 | } 250 | break; 251 | case TypeKind.FUNCTION: 252 | const functionType = type as FunctionType; 253 | typeString = 254 | pluralizeName('function', plurality) + 255 | ' (taking ' + 256 | joinList( 257 | functionType.parameterTypes.map(type => describeType(type, meta, PLURALITY.SINGULAR)), 258 | ) + 259 | ', and returning ' + 260 | describeType(functionType.returnType, meta, PLURALITY.SINGULAR) + 261 | ')'; 262 | break; 263 | case TypeKind.ARRAY: 264 | const arrayType = type as ArrayType; 265 | if (arrayType.valueType) { 266 | typeString = 267 | pluralizeName('array', plurality) + 268 | ' (of ' + 269 | describeType(arrayType.valueType, meta, PLURALITY.PLURAL) + 270 | ')'; 271 | } else { 272 | typeString = pluralizeName('table', plurality); 273 | } 274 | break; 275 | case TypeKind.TUPLE: 276 | const tupleType = type as TupleType; 277 | typeString = 278 | pluralizeName('tuple', plurality) + 279 | ' (' + 280 | joinList(tupleType.elementTypes.map(type => describeType(type, meta, PLURALITY.SINGULAR))) + 281 | ')'; 282 | break; 283 | case TypeKind.UNION: 284 | const unionType = type as UnionType; 285 | typeString = unionType.allowedTypes 286 | .map(type => describeType(type, meta, plurality)) 287 | .join(' or '); 288 | break; 289 | case TypeKind.INTERSECTION: 290 | const intersectionType = type as IntersectionType; 291 | typeString = 292 | pluralizeName('intersection', plurality) + 293 | ' (of ' + 294 | joinList( 295 | intersectionType.requiredTypes.map(type => describeType(type, meta, PLURALITY.SINGULAR)), 296 | ) + 297 | ')'; 298 | break; 299 | case TypeKind.DICTIONARY: 300 | const dictionaryType = type as DictionaryType; 301 | typeString = 302 | 'a dictionary mapping ' + 303 | describeType(dictionaryType.keyType, meta, PLURALITY.PLURAL) + 304 | ' to ' + 305 | describeType(dictionaryType.valueType, meta, PLURALITY.PLURAL); 306 | break; 307 | case TypeKind.GENERIC: 308 | typeString = describeGeneric(type as GenericType); 309 | break; 310 | case TypeKind.ANY: 311 | typeString = plurality === PLURALITY.PLURAL ? 'any values' : 'any value'; 312 | break; 313 | case TypeKind.NIL: 314 | typeString = 'nil'; 315 | break; 316 | case TypeKind.VOID: 317 | typeString = 'nothing'; 318 | break; 319 | case TypeKind.NEVER: 320 | typeString = 'a promise that never resolves'; 321 | break; 322 | case TypeKind.BOOL: 323 | typeString = pluralizeName('boolean', plurality); 324 | break; 325 | case TypeKind.INT: 326 | typeString = pluralizeName('integer', plurality); 327 | break; 328 | case TypeKind.UINT: 329 | typeString = pluralizeName('unsigned integer', plurality); 330 | break; 331 | case TypeKind.FAIL: 332 | typeString = pluralizeName('failure state', plurality); 333 | break; 334 | default: 335 | typeString = pluralizeName(type.typeKind, plurality); 336 | break; 337 | } 338 | const useTag = type.tag && type.typeKind !== TypeKind.GENERIC; 339 | return ( 340 | (useTag ? '_' + type.tag + '_ (' : '') + 341 | typeString + 342 | (type.genericParameters 343 | ? ' (of ' + 344 | joinList( 345 | type.genericParameters.map(param => describeType(param, meta, PLURALITY.SINGULAR)), 346 | ) + 347 | ')' 348 | : '') + 349 | (type.isMutable ? ' (which can be mutated)' : '') + 350 | (useTag ? ')' : '') 351 | ); 352 | } 353 | 354 | export function getMetaDescription(type: Type, meta: MetaDescription) { 355 | switch (type.typeKind) { 356 | case TypeKind.FUNCTION: 357 | const functionType = type as FunctionType; 358 | if (functionType.genericTypes) { 359 | for (const param of functionType.genericTypes) { 360 | if (param.tag) { 361 | meta.generics[param.tag] = describeGeneric(param); 362 | getMetaDescription(param.extendingType, meta); 363 | } 364 | } 365 | } 366 | break; 367 | } 368 | if (type.genericParameters) { 369 | for (const param of type.genericParameters) { 370 | if (type.typeKind === TypeKind.ALIAS) { 371 | const name = (param as AliasType).aliasName; 372 | const commonName = getCommonNameForTypeVariable(name); 373 | if (commonName) { 374 | meta.generics[name] = commonName; 375 | } 376 | } 377 | } 378 | } 379 | return meta; 380 | } 381 | -------------------------------------------------------------------------------- /tools/docublox/astTypings.ts: -------------------------------------------------------------------------------- 1 | export interface Node { 2 | type: string; 3 | loc: { 4 | start: { 5 | line: number; 6 | }; 7 | }; 8 | } 9 | export interface Identifier extends Node { 10 | name: string; 11 | } 12 | export interface MemberExpression extends Node { 13 | base: Identifier; 14 | identifier: Identifier | MemberExpression; 15 | } 16 | export interface FunctionDeclaration extends Node { 17 | identifier: Identifier | MemberExpression; 18 | parameters: Identifier[]; 19 | } 20 | export interface Comment extends Node { 21 | raw: string; 22 | value: string; 23 | } 24 | 25 | export interface AssignmentStatement extends Node { 26 | variables: Node[]; 27 | } 28 | -------------------------------------------------------------------------------- /tools/docublox/generateMakeDocsYml.ts: -------------------------------------------------------------------------------- 1 | import { basename } from 'path'; 2 | 3 | export function generateMakeDocsYml(files) { 4 | return ` 5 | site_name: Rodash Documentation 6 | site_url: https://codekingdomsteam.github.io/rodash/ 7 | repo_name: CodeKingdomsTeam/rodash 8 | repo_url: https://github.com/CodeKingdomsTeam/rodash 9 | 10 | theme: 11 | name: material 12 | palette: 13 | primary: 'Grey' 14 | accent: 'Blue' 15 | 16 | nav: 17 | - Home: index.md 18 | - Getting Started: getting-started.md 19 | - API Reference: 20 | ${files.map(name => ` - ${basename(name, '.md')}: api/${name}`).join('\n')} 21 | - Types: types.md 22 | - Glossary: glossary.md 23 | 24 | extra_css: 25 | - docublox.css 26 | 27 | markdown_extensions: 28 | - admonition 29 | - codehilite: 30 | guess_lang: false 31 | - toc: 32 | permalink: true 33 | - pymdownx.superfences 34 | `; 35 | } 36 | -------------------------------------------------------------------------------- /tools/docublox/generateMd.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Node, 3 | Comment, 4 | MemberExpression, 5 | FunctionDeclaration, 6 | Identifier, 7 | AssignmentStatement, 8 | } from './astTypings'; 9 | import { keyBy } from 'lodash'; 10 | import { GlossaryMap } from './index'; 11 | import * as parser from './typeParser'; 12 | import { 13 | describeType, 14 | stringifyType, 15 | FunctionType, 16 | TypeKind, 17 | PLURALITY, 18 | getMetaDescription, 19 | describeGeneric, 20 | } from './LuaTypes'; 21 | 22 | interface DocEntry { 23 | tag: string; 24 | content: string; 25 | } 26 | 27 | interface Doc { 28 | typeString: string; 29 | typing: FunctionType; 30 | comments: string[]; 31 | entries: DocEntry[]; 32 | } 33 | export interface MdDoc { 34 | name: string; 35 | content: string; 36 | sortName: string; 37 | comments: string[]; 38 | } 39 | 40 | export interface LibraryProps { 41 | libName: string; 42 | fileName: string; 43 | glossaryMap: GlossaryMap; 44 | rootUrl: string; 45 | } 46 | 47 | export interface Nodes { 48 | [line: string]: Node; 49 | } 50 | 51 | export function generateMd(libraryProps: LibraryProps, nodes: Nodes, maxLine: number) { 52 | let topComment = ''; 53 | let inHeader = true; 54 | const functions: MdDoc[] = []; 55 | const members: MdDoc[] = []; 56 | for (let i = 0; i <= maxLine; i++) { 57 | if (!nodes[i]) { 58 | continue; 59 | } 60 | const node = nodes[i]; 61 | if (inHeader) { 62 | if (node.type === 'Comment') { 63 | const { nodeText } = getCommentTextAndEntries(node as Comment); 64 | topComment += nodeText + '\n'; 65 | } else { 66 | inHeader = false; 67 | } 68 | } 69 | if (node.type === 'FunctionDeclaration' || node.type === 'AssignmentStatement') { 70 | const doc = getDocAtLocation(node.loc.start.line, nodes); 71 | if (!doc.typing) { 72 | console.log('Skipping untyped method:', doc.comments); 73 | } else { 74 | if (node.type === 'AssignmentStatement') { 75 | const member = getMemberDoc(libraryProps, node as AssignmentStatement, doc); 76 | if (member) { 77 | if (!member.comments.length) { 78 | console.log('Skipping undocumented method:', member.sortName); 79 | } else { 80 | members.push(member); 81 | } 82 | } 83 | } else { 84 | const fn = getFnDoc(libraryProps, node as FunctionDeclaration, doc); 85 | if (fn) { 86 | if (!fn.comments.length) { 87 | console.log('Skipping undocumented method:', fn.sortName); 88 | } else { 89 | functions.push(fn); 90 | } 91 | } 92 | } 93 | } 94 | } 95 | functions.sort((a, b) => (a.sortName.toLowerCase() < b.sortName.toLowerCase() ? -1 : 1)); 96 | members.sort((a, b) => (a.sortName.toLowerCase() < b.sortName.toLowerCase() ? -1 : 1)); 97 | } 98 | 99 | const functionDocs = functions.length 100 | ? `## Functions 101 | 102 | ${functions.map(fn => fn.content).join('\n\n---\n\n')}` 103 | : ''; 104 | 105 | const memberDocs = members.length 106 | ? `## Members 107 | 108 | ${members.map(fn => fn.content).join('\n\n---\n\n')}` 109 | : ''; 110 | 111 | return ` 112 | # ${libraryProps.fileName} 113 | 114 | ${topComment} 115 | 116 | ${functionDocs} 117 | 118 | ${memberDocs} 119 | 120 | `; 121 | } 122 | 123 | function getDocAtLocation(loc: number, nodes: Nodes): Doc { 124 | let typing: FunctionType; 125 | let typeString: string; 126 | const comments = []; 127 | const entries = []; 128 | // Work backwards from the location to find comments above the specified point, which will form 129 | // documentation for the node at the location specified. 130 | for (let i = loc - 1; i >= 0; i--) { 131 | const node = nodes[i]; 132 | if (!node) { 133 | continue; 134 | } 135 | if (node.type === 'Comment') { 136 | const comment = node as Comment; 137 | if (comment.raw.match(/^\-\-\:/)) { 138 | const type = comment.value.substring(1); 139 | try { 140 | typeString = type.trim(); 141 | typing = parser.parse(typeString) as FunctionType; 142 | } catch (e) { 143 | console.warn('BadType:', type, e); 144 | } 145 | } else { 146 | const { nodeText, nodeEntries } = getCommentTextAndEntries(comment); 147 | comments.push(nodeText); 148 | entries.push(...nodeEntries); 149 | } 150 | } else { 151 | break; 152 | } 153 | } 154 | return { 155 | typeString, 156 | typing, 157 | comments: comments.reverse(), 158 | entries: entries, 159 | }; 160 | } 161 | 162 | function getCommentTextAndEntries(commentNode: Comment) { 163 | const nodeEntries = []; 164 | let lastEntry; 165 | let content: string[] = []; 166 | commentNode.value.split('\n').forEach(line => { 167 | const lineWithoutIndent = line.replace(/^[\s\t][\s\t]?/g, ''); 168 | const entryMatch = lineWithoutIndent.match(/^\@([a-z]+)\s?(.*)/); 169 | if (entryMatch) { 170 | lastEntry = { 171 | tag: entryMatch[1], 172 | content: entryMatch[2], 173 | }; 174 | nodeEntries.push(lastEntry); 175 | } else if (lastEntry) { 176 | lastEntry.content += '\n' + lineWithoutIndent; 177 | } else { 178 | content.push(lineWithoutIndent); 179 | } 180 | }); 181 | 182 | return { 183 | nodeText: content.join('\n'), 184 | nodeEntries, 185 | }; 186 | } 187 | 188 | function getMemberDoc( 189 | libraryProps: LibraryProps, 190 | node: AssignmentStatement, 191 | doc: Doc, 192 | ): MdDoc | undefined { 193 | const member = node.variables[0] as MemberExpression; 194 | const name = (member.identifier as Identifier).name; 195 | const baseName = member.base.name; 196 | const prefixName = baseName === libraryProps.fileName ? libraryProps.libName : baseName; 197 | const sortName = baseName === libraryProps.fileName ? name : baseName + '.' + name; 198 | const lines = []; 199 | lines.push( 200 | `### ${name} \n`, 201 | '```lua' + 202 | ` 203 | ${prefixName}.${name} -- ${doc.typeString} 204 | ` + 205 | '```', 206 | doc.comments, 207 | ); 208 | const examples = filterEntries(doc.entries, 'example'); 209 | if (examples.length) { 210 | lines.push( 211 | '\n**Examples**\n', 212 | ...examples.map(example => '```lua\n' + example.content + '\n```\n\n'), 213 | ); 214 | } 215 | return { 216 | name, 217 | sortName, 218 | content: lines.join('\n'), 219 | comments: doc.comments, 220 | }; 221 | } 222 | 223 | function getFnDoc( 224 | libraryProps: LibraryProps, 225 | node: FunctionDeclaration, 226 | doc: Doc, 227 | ): MdDoc | undefined { 228 | const lines = []; 229 | if (node.identifier && node.identifier.type === 'MemberExpression') { 230 | const member = node.identifier as MemberExpression; 231 | const name = (member.identifier as Identifier).name; 232 | const baseName = member.base.name; 233 | const params = node.parameters.map(id => (id.type === 'VarargLiteral' ? '...' : id.name)); 234 | const prefixName = baseName === libraryProps.fileName ? libraryProps.libName : baseName; 235 | const sortName = baseName === libraryProps.fileName ? name : baseName + '.' + name; 236 | 237 | const returnType = doc.typing.returnType || { typeKind: TypeKind.ANY, isRestParameter: true }; 238 | const returnTypeString = stringifyType(returnType); 239 | 240 | const traits = filterEntries(doc.entries, 'trait'); 241 | if (traits.length) { 242 | lines.push( 243 | `
${traits.map(entry => entry.content).join(' ')}
`, 244 | ); 245 | } 246 | lines.push( 247 | `### ${sortName} \n`, 248 | '```lua' + 249 | ` 250 | function ${prefixName}.${name}(${params.join(', ')}) --> ${returnTypeString} 251 | ` + 252 | '```', 253 | doc.comments, 254 | ); 255 | const paramEntries = filterEntries(doc.entries, 'param'); 256 | const paramMap = keyBy( 257 | paramEntries.map(entry => entry.content.match(/^\s*([A-Za-z.]+)\s(.*)/)), 258 | entry => entry && entry[1], 259 | ); 260 | lines.push('\n**Type**\n', '`' + doc.typeString + '`'); 261 | 262 | const metaDescription = getMetaDescription(doc.typing, { 263 | generics: { 264 | T: 'the type of _self_', 265 | }, 266 | rootUrl: libraryProps.rootUrl, 267 | }); 268 | 269 | if (doc.typing.genericTypes) { 270 | lines.push( 271 | '\n**Generics**\n', 272 | ...doc.typing.genericTypes.map( 273 | generic => 274 | `\n> __${generic.tag}__ - \`${stringifyType( 275 | generic.extendingType, 276 | )}\` - ${describeGeneric(generic, metaDescription)}`, 277 | ), 278 | ); 279 | } 280 | const parameterTypes = doc.typing.parameterTypes || []; 281 | if (params.length) { 282 | lines.push( 283 | '\n**Parameters**\n', 284 | ...params.map((param, i) => { 285 | const parameterType = parameterTypes[i] || { 286 | typeKind: TypeKind.ANY, 287 | }; 288 | return `> __${param}__ - \`${stringifyType(parameterType)}\` - ${describeType( 289 | parameterType, 290 | metaDescription, 291 | PLURALITY.SINGULAR, 292 | )} ${paramMap[param] && paramMap[param][2] ? ' - ' + paramMap[param][2] : ''}\n>`; 293 | }), 294 | ); 295 | } 296 | const returns = filterEntries(doc.entries, 'returns'); 297 | lines.push('\n**Returns**\n'); 298 | const returnTypeDescription = describeType(returnType, metaDescription, PLURALITY.SINGULAR); 299 | if (returns.length) { 300 | lines.push( 301 | ...returns.map( 302 | ({ content }) => `\n> \`${returnTypeString}\` - ${returnTypeDescription} - ${content}`, 303 | ), 304 | ); 305 | } else { 306 | lines.push(`\n> \`${returnTypeString}\` - ${returnTypeDescription}`); 307 | } 308 | const throws = filterEntries(doc.entries, 'throws'); 309 | if (throws.length) { 310 | lines.push('\n**Throws**\n'); 311 | lines.push(...formatList(throws)); 312 | } 313 | 314 | const rejects = filterEntries(doc.entries, 'rejects'); 315 | if (rejects.length) { 316 | lines.push('\n**Rejects**\n'); 317 | lines.push( 318 | ...formatList(rejects, line => { 319 | switch (line) { 320 | case 'passthrough': 321 | return '_passthrough_ - The returned promise will reject if promises passed as arguments reject.'; 322 | default: 323 | return line; 324 | } 325 | }), 326 | ); 327 | } 328 | 329 | const examples = filterEntries(doc.entries, 'example'); 330 | if (examples.length) { 331 | lines.push( 332 | '\n**Examples**\n', 333 | ...examples.map(example => '```lua\n' + example.content + '\n```\n\n'), 334 | ); 335 | } 336 | const usage = filterEntries(doc.entries, 'usage'); 337 | if (usage.length) { 338 | lines.push('\n**Usage**\n', ...formatList(usage)); 339 | } 340 | const see = filterEntries(doc.entries, 'see'); 341 | if (see.length) { 342 | lines.push('\n**See**\n', ...see.map(({ content }) => `\n* ${content}`)); 343 | } 344 | return { 345 | name, 346 | sortName, 347 | content: lines.join('\n'), 348 | comments: doc.comments, 349 | }; 350 | } 351 | } 352 | 353 | function formatList(entries: DocEntry[], modifier?: (line: string) => string) { 354 | return entries.map(({ content }) => '\n* ' + (modifier ? modifier(content) : content)); 355 | } 356 | 357 | function filterEntries(entries: DocEntry[], tag: string) { 358 | return entries.filter(entry => entry.tag === tag); 359 | } 360 | -------------------------------------------------------------------------------- /tools/docublox/index.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'luaparse'; 2 | import { readdir, readFile, writeFile } from 'fs-extra'; 3 | import { basename, extname, join } from 'path'; 4 | import { ArgumentParser } from 'argparse'; 5 | import { generateMd, Nodes, LibraryProps } from './generateMd'; 6 | import { generateMakeDocsYml } from './generateMakeDocsYml'; 7 | import { 8 | FunctionDeclaration, 9 | MemberExpression, 10 | Identifier, 11 | AssignmentStatement, 12 | } from './astTypings'; 13 | import { uniq } from 'lodash'; 14 | const parser = new ArgumentParser({ 15 | version: '1.0.0', 16 | addHelp: true, 17 | description: 'Generate markdown docs for lua files', 18 | }); 19 | parser.addArgument(['-o', '--output'], { 20 | help: 'The directory where md files should be written to', 21 | defaultValue: '.', 22 | }); 23 | parser.addArgument(['-l', '--libName'], { 24 | help: 'The name of the library', 25 | }); 26 | parser.addArgument(['-r', '--rootUrl'], { 27 | help: 'The root URL library', 28 | }); 29 | parser.addArgument('source', { 30 | help: 'The directory where any lua files should be read from', 31 | defaultValue: '.', 32 | }); 33 | parser.addArgument('docsSource', { 34 | help: 'The directory where any md files should be read from', 35 | defaultValue: '.', 36 | }); 37 | 38 | interface FileParse { 39 | name: string; 40 | maxLines: number; 41 | nodesByLine: Nodes; 42 | docNames: string[]; 43 | } 44 | export interface Glossary { 45 | [module: string]: string[]; 46 | } 47 | 48 | export interface Options { 49 | libName: string; 50 | rootUrl: string; 51 | source: string; 52 | docsSource: string; 53 | output: string; 54 | } 55 | 56 | export function substituteLinks(libraryProps: LibraryProps, text: string) { 57 | return text.replace(/`([A-Za-z]+)\.([A-Za-z]+)`/g, (match, libName, docName) => { 58 | const glossaryLink = libraryProps.glossaryMap[docName]; 59 | if (!glossaryLink) { 60 | console.log('Missing glossary link', match); 61 | return match; 62 | } else { 63 | return glossaryLink.fullText; 64 | } 65 | }); 66 | } 67 | 68 | async function processSourceFiles(options: Options) { 69 | const { source, docsSource, libName, rootUrl, output } = options; 70 | const files = await readdir(source); 71 | const fileParses: FileParse[] = await Promise.all( 72 | files 73 | .filter(file => extname(file) === '.lua' && basename(file) !== 'init.lua') 74 | .map(async file => { 75 | const text = await readFile(join(source, file), 'utf8'); 76 | const nodesByLine: Nodes = {}; 77 | let maxLines = 0; 78 | parse(text, { 79 | comments: true, 80 | locations: true, 81 | onCreateNode: node => { 82 | const line = node.loc.start.line; 83 | const currentNode = nodesByLine[line]; 84 | if (!currentNode || currentNode.type !== 'Comment') { 85 | nodesByLine[line] = node; 86 | } 87 | maxLines = Math.max(line, maxLines); 88 | }, 89 | }); 90 | const name = basename(file, '.lua'); 91 | const docNames = getDocNames(nodesByLine, maxLines); 92 | return { name, nodesByLine, maxLines, docNames }; 93 | }), 94 | ); 95 | const glossary: Glossary = {}; 96 | for (const fileParse of fileParses) { 97 | glossary[fileParse.name] = fileParse.docNames; 98 | } 99 | 100 | const glossaryLinks = getGlossaryLinks(options, glossary); 101 | const glossaryWritePromise = writeFile(join(output, 'glossary.md'), getGlossary(glossaryLinks)); 102 | const sourceFilesWritePromise = Promise.all( 103 | fileParses.map(async ({ name, nodesByLine, maxLines }) => { 104 | const outputName = name + '.md'; 105 | const libraryProps = { 106 | fileName: name, 107 | libName, 108 | glossaryMap: glossaryLinks, 109 | rootUrl, 110 | }; 111 | const mdText = generateMd(libraryProps, nodesByLine, maxLines); 112 | const mdTextWithLinks = substituteLinks(libraryProps, mdText); 113 | await writeFile(join(output, 'api', outputName), mdTextWithLinks); 114 | console.log('Built md:', outputName); 115 | return outputName; 116 | }), 117 | ); 118 | const mdFilesWritePromise = processMdSourceFiles(docsSource, output, glossaryLinks); 119 | 120 | const filePromises = [ 121 | glossaryWritePromise, 122 | sourceFilesWritePromise.then(mdFiles => writeFile('mkdocs.yml', generateMakeDocsYml(mdFiles))), 123 | mdFilesWritePromise, 124 | ]; 125 | 126 | return Promise.all(filePromises); 127 | } 128 | 129 | async function processMdSourceFiles(docsSource: string, output: string, glossaryMap: GlossaryMap) { 130 | const files = await readdir(docsSource); 131 | return files 132 | .filter(file => extname(file) === '.md') 133 | .map(async file => { 134 | const libraryProps = { 135 | fileName: file, 136 | libName: 'dash', 137 | glossaryMap, 138 | rootUrl: '/rodash/', 139 | }; 140 | const text = await readFile(join(docsSource, file), 'utf8'); 141 | const textWithLinks = substituteLinks(libraryProps, text); 142 | return writeFile(join(output, file), textWithLinks); 143 | }); 144 | } 145 | 146 | export function getDocNames(nodes: Nodes, maxLine: number): string[] { 147 | const names = []; 148 | for (const line in nodes) { 149 | const node = nodes[line]; 150 | if (node.type === 'FunctionDeclaration') { 151 | const fnNode = node as FunctionDeclaration; 152 | if (fnNode.identifier && fnNode.identifier.type === 'MemberExpression') { 153 | const member = fnNode.identifier as MemberExpression; 154 | const name = (member.identifier as Identifier).name; 155 | names.push(member.base.name + '.' + name); 156 | } 157 | } else if (node.type === 'AssignmentStatement') { 158 | const assignmentNode = node as AssignmentStatement; 159 | const member = assignmentNode.variables[0] as MemberExpression; 160 | if (member && member.type === 'MemberExpression') { 161 | const name = (member.identifier as Identifier).name; 162 | names.push(member.base.name + '.' + name); 163 | } 164 | } 165 | } 166 | return names; 167 | } 168 | 169 | interface GlossaryLink { 170 | name: string; 171 | text: string; 172 | fullText: string; 173 | link: string; 174 | } 175 | 176 | export interface GlossaryMap { 177 | [name: string]: GlossaryLink; 178 | } 179 | 180 | function getGlossaryLinks(options: Options, glossary: Glossary) { 181 | const glossaryMap: GlossaryMap = {}; 182 | for (const fileName in glossary) { 183 | for (const docName of glossary[fileName]) { 184 | const [memberName, idName] = docName.split('.'); 185 | const shortName = memberName === fileName ? idName : docName; 186 | const link = `${options.rootUrl}api/${fileName}/#${shortName.toLowerCase()}`; 187 | glossaryMap[shortName] = { 188 | name: shortName, 189 | link, 190 | text: `[${shortName}](${link})`, 191 | fullText: `[${options.libName}.${shortName}](${link})`, 192 | }; 193 | } 194 | } 195 | return glossaryMap; 196 | } 197 | 198 | function getGlossary(glossaryMap: GlossaryMap) { 199 | const textLinks = Object.values(glossaryMap).sort((a: GlossaryLink, b: GlossaryLink) => 200 | a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1, 201 | ); 202 | const list = uniq(textLinks.map(link => '* ' + link.text)).join('\n'); 203 | return ` 204 | # Glossary 205 | 206 | ${list} 207 | `; 208 | } 209 | 210 | (async function() { 211 | try { 212 | const args = parser.parseArgs(); 213 | await processSourceFiles(args), console.log('Done!'); 214 | } catch (e) { 215 | console.error(e); 216 | process.exit(1); 217 | } 218 | })(); 219 | -------------------------------------------------------------------------------- /tools/docublox/typeGrammar.peg: -------------------------------------------------------------------------------- 1 | Type "Type" 2 | = TypeDef 3 | / _ type:GenericFunction _ { 4 | return type; 5 | } 6 | 7 | TypeDef "TypeDef" 8 | = "type" _ left:Type _ "=" _ right:Type { 9 | return { 10 | typeKind: 'definition', 11 | left, 12 | right 13 | } 14 | } 15 | 16 | GenericFunction "GenericFunction" 17 | = generics:GenericTuple _ tuple:Tuple? { 18 | if ( tuple ) { 19 | tuple.genericTypes = generics; 20 | return tuple; 21 | } 22 | return { 23 | typeKind: 'tuple', 24 | elementTypes: [], 25 | genericTypes: generics 26 | } 27 | } 28 | / Tuple 29 | 30 | GenericTuple "GenericTuple" 31 | = "<" _ left:Generic right:(_ "," _ Generic)* _ ">" { 32 | const generics = []; 33 | generics.push(left); 34 | right.map(key => { 35 | generics.push(key[3]); 36 | }) 37 | return generics; 38 | } 39 | 40 | Generic "Generic" 41 | = isRestParameter:"..."? _ left:GenericName right:(_ ":" _ UnitaryType)? { 42 | var generic = { 43 | tag: left, 44 | typeKind: 'generic', 45 | extendingType: right ? right[3] : { 46 | typeKind: 'any' 47 | } 48 | }; 49 | if (isRestParameter) { 50 | generic.isRestParameter = true 51 | } 52 | return generic; 53 | } 54 | 55 | Tuple "Tuple" 56 | = left:Union right:(_ "," _ Union)* functionType:(_ "->" _ ReturnType)? { 57 | let elementTypes = [left].concat( right.map(key => key[3]) ); 58 | if ( functionType ) { 59 | if ( !right.length && left.typeKind === 'tuple') { 60 | elementTypes = left.elementTypes 61 | } 62 | let fn = { 63 | typeKind: 'function', 64 | parameterTypes: elementTypes, 65 | returnType: functionType[3] 66 | }; 67 | return fn; 68 | } 69 | if ( !right.length ) { 70 | return left; 71 | } 72 | return { 73 | typeKind: 'tuple', 74 | elementTypes 75 | }; 76 | } 77 | 78 | ReturnType "ReturnType" 79 | = isYield:"yield"? type:Type { 80 | if (isYield) { 81 | type.isYielding = true; 82 | } 83 | return type; 84 | } 85 | 86 | Union "Union" 87 | = left:Intersection right:(_ "|" _ Intersection)* { 88 | if ( !right.length ) { 89 | return left; 90 | } 91 | return { 92 | typeKind: 'union', 93 | allowedTypes: [left].concat( right.map(key => key[3]) ) 94 | } 95 | } 96 | 97 | Intersection "Intersection" 98 | = left:UnitaryType right:(_ "&" _ UnitaryType)* { 99 | if ( !right.length ) { 100 | return left; 101 | } 102 | return { 103 | typeKind: 'intersection', 104 | requiredTypes: [left].concat( right.map(key => key[3]) ) 105 | } 106 | } 107 | 108 | UnitaryType "UnitaryType" 109 | = mutType:("mut" _)? tag:(ParameterTag _ ":")? restParameter:("..." _)? type:FixedType generics:(_ "<" _ GenericArgs _ ">")? capture:(_ "[]" / _ "{}")* optional:(_ "?")? { 110 | if ( generics ) { 111 | type.genericParameters = generics[3]; 112 | } 113 | if (capture.length) { 114 | type = { typeKind: 'table', dimensions: capture.map(function(match) { return {isArray:match[1] === "[]"} }), elementType: type } 115 | } 116 | if (restParameter) { 117 | type.isRestParameter = true; 118 | } 119 | if (mutType) { 120 | type.isMutable = true; 121 | } 122 | if (optional) { 123 | type = { 124 | typeKind: 'optional', 125 | optionalType: type 126 | } 127 | } 128 | if ( tag ) { 129 | type.tag = tag[0]; 130 | } 131 | return type; 132 | } 133 | / "..." { 134 | return { 135 | isRestParameter: true, 136 | typeKind: 'any' 137 | }; 138 | } 139 | 140 | GenericArgs "GenericArgs" 141 | = left:UnitaryType right:(_ "," _ GenericArgs)? { 142 | return [left].concat(right ? right[3] : []); 143 | } 144 | 145 | FixedType "FixedType" 146 | = "{" _ tuple:Tuple?_ "}" { 147 | return { 148 | typeKind: 'array', 149 | valueType: tuple 150 | }; 151 | } 152 | / "{" _ iterator:IteratorType _ "}" { 153 | return { 154 | typeKind: 'dictionary', 155 | keyType: iterator.keyType, 156 | valueType: iterator.valueType 157 | } 158 | } 159 | / TypeName 160 | / "(" _ type:Type? _ ")" { 161 | return type || { 162 | typeKind: 'void' 163 | }; 164 | } 165 | 166 | IteratorType "IteratorType" 167 | = "[" _ key:UnitaryType _ "]" _ ":" _ value:Tuple { 168 | return { 169 | keyType: key, 170 | valueType: value 171 | }; 172 | } 173 | 174 | GenericName "GenericName" 175 | = _ [A-Za-z_][A-Za-z0-9_]* { 176 | return text(); 177 | } 178 | 179 | ParameterTag "ParameterTag" 180 | = _ [A-Za-z_][A-Za-z0-9_]* { 181 | return text(); 182 | } 183 | 184 | TypeName "TypeName" 185 | = _ name:([A-Za-z][A-Za-z0-9._]*) { 186 | 187 | name = name[0] + name[1].join(''); 188 | 189 | switch (name) { 190 | 191 | case 'char': 192 | case 'string': 193 | case 'number': 194 | case 'int': 195 | case 'uint': 196 | case 'float': 197 | case 'number': 198 | case 'bool': 199 | case 'nil': 200 | case 'never': 201 | case 'fail': 202 | case 'any': 203 | case 'void': 204 | 205 | return { 206 | typeKind: name 207 | }; 208 | 209 | default: 210 | 211 | return { 212 | typeKind: 'alias', 213 | aliasName: name 214 | } 215 | 216 | } 217 | } 218 | 219 | _ "whitespace" 220 | = [ \t\n\r]* -------------------------------------------------------------------------------- /tools/format.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o nounset 4 | set -o errexit 5 | set -o pipefail 6 | 7 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 8 | cd "$SCRIPT_DIR" 9 | cd .. 10 | 11 | if [ $# -eq 0 ] 12 | then 13 | find src spec -name '*.lua' -exec node_modules/lua-fmt/dist/bin/luafmt.js --use-tabs --write-mode replace {} \; 14 | else 15 | for FILE in "$@" 16 | do 17 | node_modules/lua-fmt/dist/bin/luafmt.js --use-tabs --write-mode replace "$FILE" 18 | done 19 | fi 20 | -------------------------------------------------------------------------------- /tools/luacheck.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o nounset 4 | set -o errexit 5 | set -o pipefail 6 | 7 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 8 | cd "$SCRIPT_DIR" 9 | cd .. 10 | 11 | export PATH="lua_install/bin:$PATH" 12 | 13 | if [ $# -eq 0 ] 14 | then 15 | luacheck src spec tools 16 | else 17 | luacheck "$@" 18 | fi 19 | 20 | -------------------------------------------------------------------------------- /tools/testInit.lua: -------------------------------------------------------------------------------- 1 | if arg[1] == "debug" then 2 | package.cpath = package.cpath .. ";tools/?.dylib" 3 | local lrdb = require("lrdb_server") 4 | print("Waiting for debugger to attach...") 5 | lrdb.activate(21110) 6 | end 7 | 8 | script = { 9 | Async = "Async", 10 | Tables = "Tables", 11 | Classes = "Classes", 12 | Functions = "Functions", 13 | Arrays = "Arrays", 14 | Strings = "Strings", 15 | Parent = { 16 | Async = "Async", 17 | Tables = "Tables", 18 | Classes = "Classes", 19 | Functions = "Functions", 20 | Arrays = "Arrays", 21 | Strings = "Strings", 22 | Parent = { 23 | t = "t", 24 | Promise = "roblox-lua-promise", 25 | luassert = "luassert" 26 | } 27 | } 28 | } 29 | Random = { 30 | new = function() 31 | local n = 0 32 | return { 33 | NextNumber = function() 34 | n = n + 1 35 | return n 36 | end 37 | } 38 | end 39 | } 40 | warn = function(...) 41 | print("[WARN]", ...) 42 | end 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "pretty": true, 4 | "target": "es2017" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@types/commander@^2.3.31": 6 | version "2.12.2" 7 | resolved "https://registry.yarnpkg.com/@types/commander/-/commander-2.12.2.tgz#183041a23842d4281478fa5d23c5ca78e6fd08ae" 8 | dependencies: 9 | commander "*" 10 | 11 | "@types/diff@^3.2.0": 12 | version "3.5.1" 13 | resolved "https://registry.yarnpkg.com/@types/diff/-/diff-3.5.1.tgz#30253f6e177564ad7da707b1ebe46d3eade71706" 14 | 15 | "@types/get-stdin@^5.0.0": 16 | version "5.0.1" 17 | resolved "https://registry.yarnpkg.com/@types/get-stdin/-/get-stdin-5.0.1.tgz#46afbcaf09e94fe025afa07ae994ac3168adbdf3" 18 | dependencies: 19 | "@types/node" "*" 20 | 21 | "@types/node@*": 22 | version "10.9.4" 23 | resolved "https://registry.yarnpkg.com/@types/node/-/node-10.9.4.tgz#0f4cb2dc7c1de6096055357f70179043c33e9897" 24 | 25 | arg@^4.1.0: 26 | version "4.1.1" 27 | resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.1.tgz#485f8e7c390ce4c5f78257dbea80d4be11feda4c" 28 | integrity sha512-SlmP3fEA88MBv0PypnXZ8ZfJhwmDeIE3SP71j37AiXQBXYosPV0x6uISAaHYSlSVhmHOVkomen0tbGk6Anlebw== 29 | 30 | argparse@^1.0.10: 31 | version "1.0.10" 32 | resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" 33 | integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== 34 | dependencies: 35 | sprintf-js "~1.0.2" 36 | 37 | buffer-from@^1.0.0: 38 | version "1.1.1" 39 | resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" 40 | integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== 41 | 42 | commander@*, commander@^2.9.0: 43 | version "2.17.1" 44 | resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf" 45 | 46 | diff@^3.3.0: 47 | version "3.5.0" 48 | resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" 49 | 50 | diff@^4.0.1: 51 | version "4.0.1" 52 | resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.1.tgz#0c667cb467ebbb5cea7f14f135cc2dba7780a8ff" 53 | integrity sha512-s2+XdvhPCOF01LRQBC8hf4vhbVmI2CGS5aZnxLJlT5FtdhPCDFq80q++zK2KlrVorVDdL5BOGZ/VfLrVtYNF+Q== 54 | 55 | fs-extra@^8.1.0: 56 | version "8.1.0" 57 | resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" 58 | integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== 59 | dependencies: 60 | graceful-fs "^4.2.0" 61 | jsonfile "^4.0.0" 62 | universalify "^0.1.0" 63 | 64 | get-stdin@^5.0.1: 65 | version "5.0.1" 66 | resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-5.0.1.tgz#122e161591e21ff4c52530305693f20e6393a398" 67 | 68 | graceful-fs@^4.1.6, graceful-fs@^4.2.0: 69 | version "4.2.0" 70 | resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.0.tgz#8d8fdc73977cb04104721cb53666c1ca64cd328b" 71 | integrity sha512-jpSvDPV4Cq/bgtpndIWbI5hmYxhQGHPC4d4cqBPb4DLniCfhJokdXhwhaDuLBGLQdvvRum/UiX6ECVIPvDXqdg== 72 | 73 | jsonfile@^4.0.0: 74 | version "4.0.0" 75 | resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" 76 | integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss= 77 | optionalDependencies: 78 | graceful-fs "^4.1.6" 79 | 80 | lodash@^4.17.15: 81 | version "4.17.15" 82 | resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" 83 | integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== 84 | 85 | lua-fmt@^2.6.0: 86 | version "2.6.0" 87 | resolved "https://registry.yarnpkg.com/lua-fmt/-/lua-fmt-2.6.0.tgz#ef9ac0573d1da7330dca09c022c39a33aed347a3" 88 | dependencies: 89 | "@types/commander" "^2.3.31" 90 | "@types/diff" "^3.2.0" 91 | "@types/get-stdin" "^5.0.0" 92 | commander "^2.9.0" 93 | diff "^3.3.0" 94 | get-stdin "^5.0.1" 95 | luaparse oxyc/luaparse#ac42a00ebf4020b8c9d3219e4b0f84bf7ce6e802 96 | 97 | luaparse@^0.2.1, luaparse@oxyc/luaparse#ac42a00ebf4020b8c9d3219e4b0f84bf7ce6e802: 98 | version "0.2.1" 99 | resolved "https://codeload.github.com/oxyc/luaparse/tar.gz/ac42a00ebf4020b8c9d3219e4b0f84bf7ce6e802" 100 | 101 | make-error@^1.1.1: 102 | version "1.3.5" 103 | resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.5.tgz#efe4e81f6db28cadd605c70f29c831b58ef776c8" 104 | integrity sha512-c3sIjNUow0+8swNwVpqoH4YCShKNFkMaw6oH1mNS2haDZQqkeZFlHS3dhoeEbKKmJB4vXpJucU6oH75aDYeE9g== 105 | 106 | source-map-support@^0.5.6: 107 | version "0.5.12" 108 | resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.12.tgz#b4f3b10d51857a5af0138d3ce8003b201613d599" 109 | integrity sha512-4h2Pbvyy15EE02G+JOZpUCmqWJuqrs+sEkzewTm++BPi7Hvn/HwcqLAcNxYAyI0x13CpPPn+kMjl+hplXMHITQ== 110 | dependencies: 111 | buffer-from "^1.0.0" 112 | source-map "^0.6.0" 113 | 114 | source-map@^0.6.0: 115 | version "0.6.1" 116 | resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" 117 | integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== 118 | 119 | sprintf-js@~1.0.2: 120 | version "1.0.3" 121 | resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" 122 | integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= 123 | 124 | ts-node@^8.3.0: 125 | version "8.3.0" 126 | resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-8.3.0.tgz#e4059618411371924a1fb5f3b125915f324efb57" 127 | integrity sha512-dyNS/RqyVTDcmNM4NIBAeDMpsAdaQ+ojdf0GOLqE6nwJOgzEkdRNzJywhDfwnuvB10oa6NLVG1rUJQCpRN7qoQ== 128 | dependencies: 129 | arg "^4.1.0" 130 | diff "^4.0.1" 131 | make-error "^1.1.1" 132 | source-map-support "^0.5.6" 133 | yn "^3.0.0" 134 | 135 | typescript@^3.5.3: 136 | version "3.5.3" 137 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.5.3.tgz#c830f657f93f1ea846819e929092f5fe5983e977" 138 | integrity sha512-ACzBtm/PhXBDId6a6sDJfroT2pOWt/oOnk4/dElG5G33ZL776N3Y6/6bKZJBFpd+b05F3Ct9qDjMeJmRWtE2/g== 139 | 140 | universalify@^0.1.0: 141 | version "0.1.2" 142 | resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" 143 | integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== 144 | 145 | yn@^3.0.0: 146 | version "3.1.0" 147 | resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.0.tgz#fcbe2db63610361afcc5eb9e0ac91e976d046114" 148 | integrity sha512-kKfnnYkbTfrAdd0xICNFw7Atm8nKpLcLv9AZGEt+kczL/WQVai4e2V6ZN8U/O+iI6WrNuJjNNOyu4zfhl9D3Hg== 149 | --------------------------------------------------------------------------------