From da940fc81af19efb8e8bf5e76f22465713740523 Mon Sep 17 00:00:00 2001 From: Cam <21029087+csqrl@users.noreply.github.com> Date: Thu, 1 Dec 2022 22:54:42 +0000 Subject: [PATCH] v0.1.0 (#1) Initial implementation of Dump Parser. May still have bugs and issues to be resolved, and further features that may need implementing before release. --- .github/FUNDING.yml | 1 + .github/workflows/docs.yml | 40 ++++ .github/workflows/pull_request.yml | 25 +++ .gitignore | 3 + .stylua.toml | 7 + .vscode/settings.json | 23 ++ README.md | 66 +++++- aftman.toml | 10 + default.project.json | 6 + lsp.project.json | 10 + moonwave.toml | 11 + selene.toml | 1 + serve.project.json | 15 ++ src/Class.lua | 228 ++++++++++++++++++++ src/FetchDump.lua | 134 ++++++++++++ src/Filter.lua | 298 ++++++++++++++++++++++++++ src/Util/Array.lua | 40 ++++ src/Util/None.lua | 3 + src/init.d.lua | 159 ++++++++++++++ src/init.lua | 325 +++++++++++++++++++++++++++++ wally.lock | 8 + wally.toml | 7 + 22 files changed, 1418 insertions(+), 2 deletions(-) create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/docs.yml create mode 100644 .github/workflows/pull_request.yml create mode 100644 .gitignore create mode 100644 .stylua.toml create mode 100644 .vscode/settings.json create mode 100644 aftman.toml create mode 100644 default.project.json create mode 100644 lsp.project.json create mode 100644 moonwave.toml create mode 100644 selene.toml create mode 100644 serve.project.json create mode 100644 src/Class.lua create mode 100644 src/FetchDump.lua create mode 100644 src/Filter.lua create mode 100644 src/Util/Array.lua create mode 100644 src/Util/None.lua create mode 100644 src/init.d.lua create mode 100644 src/init.lua create mode 100644 wally.lock create mode 100644 wally.toml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..a66ce77 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: csqrl diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..ab9a9ec --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,40 @@ +name: Deploy Docs + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - "src/**" + - "moonwave.toml" + - "wally.toml" + - "package.json" + - "**/*.md" + +jobs: + deploy: + name: Build and Deploy Docs + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout Repository + uses: actions/checkout@v2 + + - name: Setup Node LTS + uses: actions/setup-node@v2 + with: + node-version: lts/* + + - name: Install Moonwave + run: npm install -g moonwave + + - name: Publish Docs + run: | + git remote set-url origin https://git:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git + git config --global user.email "support+actions@github.com" + git config --global user.name "github-actions-bot" + moonwave build --publish + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml new file mode 100644 index 0000000..ff366e1 --- /dev/null +++ b/.github/workflows/pull_request.yml @@ -0,0 +1,25 @@ +name: Pull Request Checks + +on: + pull_request: + +jobs: + check-moonwave: + name: Check Moonwave Compiles + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout Repository + uses: actions/checkout@v2 + + - name: Setup Node LTS + uses: actions/setup-node@v2 + with: + node-version: lts/* + + - name: Install Dependencies + run: npm install -g moonwave + + - name: Check Moonwave Compiles + run: moonwave build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0384efa --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.rbx* +*packages/ +build/ diff --git a/.stylua.toml b/.stylua.toml new file mode 100644 index 0000000..3ae7657 --- /dev/null +++ b/.stylua.toml @@ -0,0 +1,7 @@ +column_width = 100 +line_endings = "Unix" +indent_type = "Tabs" +indent_width = 4 +quote_style = "AutoPreferDouble" +call_parentheses = "Always" +collapse_simple_statement = "Never" diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b432f04 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,23 @@ +{ + "robloxLsp.diagnostics.severity": { + "unused-local": "Error", + "unused-function": "Error", + "unused-vararg": "Error", + "duplicate-index": "Error" + }, + "[lua]": { + "editor.defaultFormatter": "JohnnyMorganz.stylua" + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "editor.codeActionsOnSave": { + "source.fixAll": true, + "source.organizeImports": true + }, + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "editor.formatOnType": true, + "editor.formatOnSaveMode": "modifications", + "robloxLsp.diagnostics.globals": ["version"] +} diff --git a/README.md b/README.md index 91e846f..1c51fb7 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,64 @@ -# dump-parser - Parses and fetches data from the Roblox API dump +# Dump Parser + +A generic parser for the Roblox API dump. Inspired by [@corecii](https://github.com/Corecii)'s +[API Dump (Static)](https://github.com/corecii/api-dump-static) and +[@raphtalia](https://github.com/raphtalia)'s [RobloxAPI](https://github.com/raphtalia/robloxapi) +libraries. + +## Documentation + +Documentation can be found at https://csqrl.github.io/dump-parser. + +## Quick Start + +Dump Parser is available via [Wally](https://wally.run). + +### Wally + +```toml +# wally.toml + +[dependencies] +DumpParser = "csqrl/dump-parser@0.1.0" +``` + +```bash +$ wally install +``` + +### Manual Installation + +Download a copy of the latest release from the GitHub repo, +and compile it using Rojo. From there, you can drop the +binary directly into your project files or Roblox Studio. + +## Example Usage + +~~~lua +local DumpParser = require(path.to.DumpParser) +local Dump = DumpParser.fetchFromServer() + +local PartClass = Dump:GetClass("Part") + +-- Get a list of all properties on "Part" +print(PartClass:GetProperties()) + +--[[ + Get a list of safe-to-use properties on "Part". This is + functionally equivalent to: + + ```lua + PartClass:GetProperties( + Filter.Invert(Filter.Deprecated), -- Include non-deprecated + Filter.HasSecurity("None"), -- Include properties with no read/write security + Filter.Scriptable -- Include properties that can be set in scripts + ) + ``` + + `GetProperties`, `GetEvents`, `GetFunctions` and `GetCallbacks` + all accept a variable number of filters as arguments. This + allows you to filter down the list of results to only what + you need. +--]] +print(Dump:GetProperties("Part")) +~~~ diff --git a/aftman.toml b/aftman.toml new file mode 100644 index 0000000..947ebb8 --- /dev/null +++ b/aftman.toml @@ -0,0 +1,10 @@ +# This file lists tools managed by Aftman, a cross-platform toolchain manager. +# For more information, see https://github.com/LPGhatguy/aftman + +# To add a new tool, add an entry to this table. +[tools] +selene = "kampfkarren/selene@0.22.0" +rojo = "rojo-rbx/rojo@7.2.1" +stylua = "johnnymorganz/stylua@0.15.2" +wally = "upliftgames/wally@0.3.1" +# rojo = "rojo-rbx/rojo@6.2.0" \ No newline at end of file diff --git a/default.project.json b/default.project.json new file mode 100644 index 0000000..c228b79 --- /dev/null +++ b/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "dump-parser", + "tree": { + "$path": "src" + } +} diff --git a/lsp.project.json b/lsp.project.json new file mode 100644 index 0000000..c275e07 --- /dev/null +++ b/lsp.project.json @@ -0,0 +1,10 @@ +{ + "name": "dump-parser-lsp", + "tree": { + "$className": "Folder", + "$path": "packages", + "Fetch": { + "$path": "default.project.json" + } + } +} diff --git a/moonwave.toml b/moonwave.toml new file mode 100644 index 0000000..31c4ae8 --- /dev/null +++ b/moonwave.toml @@ -0,0 +1,11 @@ +title = "Dump Parser" +gitSourceBranch = "main" +autoSectionPath = "src" + +[[classOrder]] +classes = ["Dump", "Class", "Filter"] + +[[classOrder]] +section = "Internal" +collapsed = true +classes = ["Types", "FetchDump"] diff --git a/selene.toml b/selene.toml new file mode 100644 index 0000000..c4ddb46 --- /dev/null +++ b/selene.toml @@ -0,0 +1 @@ +std = "roblox" diff --git a/serve.project.json b/serve.project.json new file mode 100644 index 0000000..ef8a2b7 --- /dev/null +++ b/serve.project.json @@ -0,0 +1,15 @@ +{ + "name": "dump-parser-serve", + "tree": { + "$className": "DataModel", + "ServerScriptService": { + "Packages": { + "$className": "Folder", + "$path": "packages", + "DumpParser": { + "$path": "default.project.json" + } + } + } + } +} diff --git a/src/Class.lua b/src/Class.lua new file mode 100644 index 0000000..54e063d --- /dev/null +++ b/src/Class.lua @@ -0,0 +1,228 @@ +--!strict +--[=[ + @class Class + + Represents a Class within the API dump. +]=] +local T = require(script.Parent["init.d"]) + +local Array = require(script.Parent.Util.Array) +local Filter = require(script.Parent.Filter) + +local CLASS_CACHE = {} +local Class = {} + +Class.__index = Class + +local ERR_FILTER_INVALID = 'Invalid filter; expected string | GenericFilter, got "%*" (%s)' + +--[=[ + @function new + @within Class + @param classEntry Class + @return Class + + Creates a new Class instance from the given class entry. + Classes are cached, so if a class with the same name has + already been constructed, it will return that instead. +]=] +function Class.new(classEntry: T.Class) + if CLASS_CACHE[classEntry.Name] then + return CLASS_CACHE[classEntry.Name] + end + + local self = setmetatable({}, Class) + CLASS_CACHE[classEntry.Name] = self + +--- @prop Name string +--- @within Class + self.Name = classEntry.Name + +--- @prop Superclass string +--- @within Class + self.Superclass = classEntry.Superclass + +--- @prop Members { Member } +--- @within Class + self.Members = classEntry.Members + +--- @prop Tags { string }? +--- @within Class + self.Tags = classEntry.Tags + +--- @prop Inherits { string } +--- @within Class + self.Inherits = classEntry.Inherits + + return table.freeze(self) +end + +--[=[ + @method filterMembers + @within Class + @private + @param filter { string | GenericFilter } + @return { [string]: T } + + Accepts a table of filters, and returns a table of members that + passed the filter. The keys of the table are the member names, + and the values are the members themselves. +]=] +function Class:filterMembers(filters: { string | T.GenericFilter }) + local initialFilter = table.remove(filters, 1) + local filteredResults: { T } = Array.filter(self.Members, initialFilter) + + if #filters > 0 then + filters = Array.map(filters, function(filter) + local filterType = typeof(filter) + + if filterType == "function" then + return filter + elseif filterType == "string" then + return Filter.Name(filter) + end + + error(ERR_FILTER_INVALID:format(filter, filterType)) + end) + + for _, filter in filters do + filteredResults = Array.filter(filteredResults, filter) + end + end + + local results: { [string]: T } = {} + + for _, result in filteredResults do + results[result.Name] = result + end + + return results +end + +--[=[ + @method GetProperties + @within Class + @param ... (string | GenericFilter)? + @return { [string]: Property } + + Returns a table of all properties that match the given + filters. A filter may be a string (property name), or + a table of filters. + + A property must match all of the given filters in order to + pass. If no filters are given, all properties are returned. + + The resulting table is a dictionary of properties, where + the key is the property name and the value is the property + object from the API dump. +]=] +function Class:GetProperties(...: (string | T.GenericFilter)?) + local results: { [string]: T.Property } = + self:filterMembers({ Filter.MemberType("Property"), ... }) + + return results +end + +--[=[ + @method GetProperty + @within Class + @param name string + @return Property? + + Returns the property with the given name, or nil if it + does not exist. +]=] +function Class:GetProperty(name: string): T.Property? + return self:GetProperties(name)[name] +end + +--[=[ + @method GetEvents + @within Class + @param ... (string | GenericFilter)? + @return { [string]: Event } + + Returns a table of all events that match the given + filters. A filter may be a string (event name), or + a table of filters. +]=] +function Class:GetEvents(...: (string | T.GenericFilter)?) + local results: { [string]: T.Event } = self:filterMembers({ Filter.MemberType("Event"), ... }) + + return results +end + +--[=[ + @method GetEvent + @within Class + @param name string + @return Event? + + Returns the event with the given name, or nil if it + does not exist. +]=] +function Class:GetEvent(name: string): T.Event? + return self:GetEvents(name)[name] +end + +--[=[ + @method GetFunctions + @within Class + @param ... (string | GenericFilter)? + @return { [string]: Function } + + Returns a table of all functions that match the given + filters. A filter may be a string (function name), or + a table of filters. +]=] +function Class:GetFunctions(...: (string | T.GenericFilter)?) + local results: { [string]: T.Function } = + self:filterMembers({ Filter.MemberType("Function"), ... }) + + return results +end + +--[=[ + @method GetFunction + @within Class + @param name string + @return Function? + + Returns the function with the given name, or nil if it + does not exist. +]=] +function Class:GetFunction(name: string): T.Function? + return self:GetFunctions(name)[name] +end + +--[=[ + @method GetCallbacks + @within Class + @param ... (string | GenericFilter)? + @return { [string]: Callback } + + Returns a table of all callbacks that match the given + filters. A filter may be a string (callback name), or + a table of filters. +]=] +function Class:GetCallbacks(...: (string | T.GenericFilter)?) + local results: { [string]: T.Callback } = + self:filterMembers({ Filter.MemberType("Callback"), ... }) + + return results +end + +--[=[ + @method GetCallback + @within Class + @param name string + @return Callback? + + Returns the callback with the given name, or nil if it + does not exist. +]=] +function Class:GetCallback(name: string): T.Callback? + return self:GetCallbacks(name)[name] +end + +return Class diff --git a/src/FetchDump.lua b/src/FetchDump.lua new file mode 100644 index 0000000..fc729d4 --- /dev/null +++ b/src/FetchDump.lua @@ -0,0 +1,134 @@ +--!strict +--[=[ + @class FetchDump + @private + + An internal module that handles fetching the Roblox API dump + from the Roblox API. It is accessed by the [`fetchDump`][Dump.fetchDump] + function on the [`Dump`][Dump] class. +]=] +local HttpService = game:GetService("HttpService") + +local T = require(script.Parent["init.d"]) + +local URL_DEPLOY_HISTORY = "https://s3.amazonaws.com/setup.roblox.com/DeployHistory.txt" +local URL_VERSION_DUMP = "https://s3.amazonaws.com/setup.roblox.com/version-%s-API-Dump.json" +local URL_CURRENT_VERSION = "https://s3.amazonaws.com/setup.roblox.com/versionQTStudio" + +-- selene: allow(undefined_variable) +local RobloxVersion = version() + +local function HttpRequest(url: string, json: boolean?): T + local response = HttpService:RequestAsync({ + Url = url, + }) + + if json then + return HttpService:JSONDecode(response.Body) + end + + return response.Body +end + +--[=[ + @function fetchLatestVersionHash + @within FetchDump + @return string + + Fetches the latest Roblox version hash from the Roblox API. +]=] +local function FetchLatestVersionHash(): string + local versionHash: string = HttpRequest(URL_CURRENT_VERSION) + local hash = versionHash:match("version%-(%x+)") + + return hash +end + +--[=[ + @function fetchVersionHash + @within FetchDump + @param version string? + @return string + + Fetches the Roblox version hash for the given version from the + Roblox API. If no version is provided, it will default to the + current version. +]=] +local function FetchVersionHash(version: string?): string + version = version or RobloxVersion + + local deployHistory: string = HttpRequest(URL_DEPLOY_HISTORY) + local deployments = deployHistory:split("\n") + local versionHash: string = nil + + for lineNumber = #deployments, 1, -1 do + local line = deployments[lineNumber] + + if line:find(version) then + versionHash = line:match("version%-(%x+)") + break + end + + if lineNumber >= 128 then + error("Could not find version hash for version " .. version) + end + end + + return versionHash +end + +--[=[ + @function fetchVersionHashWithFallback + @within FetchDump + @param version string? + @return string + + Fetches the Roblox version hash for the given version from the + Roblox API. If no version is provided, it will default to the + current version. If the version hash cannot be found within the + deployment history, it will fallback to the latest version hash + on the server. +]=] +local function FetchVersionHashWithFallback(version: string?): string + local ok, versionHash = pcall(FetchVersionHash, version) + + if not ok then + versionHash = FetchLatestVersionHash() + end + + return versionHash +end + +--[=[ + @function fetchDump + @within FetchDump + @param hashOrVersion string? + @return APIDump + + Fetches the API dump for the current version of Roblox from the + Roblox API. If a hash or version is provided, it will attempt to + fetch the dump for that hash or version. +]=] +local function FetchDump(hashOrVersion: string?): T.APIDump + local isVersionString = hashOrVersion and hashOrVersion:match("%d+%.") ~= nil + + if hashOrVersion == nil then + hashOrVersion = RobloxVersion + isVersionString = true + end + + if isVersionString then + hashOrVersion = FetchVersionHashWithFallback(hashOrVersion) + end + + local apiDump = HttpRequest(URL_VERSION_DUMP:format(hashOrVersion), true) + + return apiDump +end + +return { + fetchDump = FetchDump, + fetchLatestVersionHash = FetchLatestVersionHash, + fetchVersionHash = FetchVersionHash, + fetchVersionHashWithFallback = FetchVersionHashWithFallback, +} diff --git a/src/Filter.lua b/src/Filter.lua new file mode 100644 index 0000000..a946eb5 --- /dev/null +++ b/src/Filter.lua @@ -0,0 +1,298 @@ +--!strict +--[=[ + @class Filter + + A list of various filters that can be used to filter out API + members from the dump. This is useful for filtering members, such + as those that are deprecated or inaccessible to non-CoreScripts. +]=] +local T = require(script.Parent["init.d"]) +local NONE = require(script.Parent.Util.None) + +local VALUE_TYPE_REMAP = { + boolean = { "bool" }, + number = { "float", "double", "int", "int64" }, + string = { "string" }, + [NONE] = { "void" }, +} + +--[=[ + @interface SecurityLevels + @within Filter + .Read (string | { string })? + .Write (string | { string })? +]=] +type SecurityLevels = { + Read: (string | { string })?, + Write: (string | { string })?, +} + +--[=[ + @function HasTags + @within Filter + @param ... string + @return GenericFilter + + Checks if the given [Item] has any of the given tags. If multiple + tags are given, then the [Item] must have all of the tags in order + to pass the filter. +]=] +local function HasTags(...: string): T.GenericFilter + local tags = { ... } + + return function(object: T.Item) + if object.Tags == nil then + return false + end + + local matchCount = 0 + + for _, tag in tags do + if table.find(object.Tags, tag) ~= nil then + matchCount += 1 + end + end + + return matchCount == #tags + end +end + +--[=[ + @function HasSecurity + @within Filter + @param levels SecurityLevels | string + @return GenericFilter + + Accepts a [SecurityLevels] object or a string. If a string is passed, + both Read and Write will be set to that string. [SecurityLevels] can + also accept an array of strings for Read and Write, which will be + used as an `OR` condition, meaning that the object will be accepted + if it matches any of the strings in the array. + + This filter *can* also be used on [Items] whose `Security` field is + a string, such as [Function]s. +]=] +local function HasSecurity(levels: SecurityLevels | string): T.GenericFilter + if type(levels) == "string" then + levels = { Read = levels, Write = levels } + end + + return function(object: T.Item) + if object.Security == nil then + return false + end + + if typeof(object.Security) == "string" then + return levels.Read == object.Security or levels.Write == object.Security + end + + local read = typeof(levels.Read) == "string" and { levels.Read } or levels.Read + local write = typeof(levels.Write) == "string" and { levels.Write } or levels.Write + + return table.find(read, object.Security.Read) ~= nil + and table.find(write, object.Security.Write) ~= nil + end +end + +--[=[ + @function Invert + @within Filter + @param filter GenericFilter + @return GenericFilter + + Inverts the given filter. If the filter returns `true`, then this + filter will return `false`, and vice versa. For example: + + ```lua + local NonDeprecated = Filter.Invert(Filter.Deprecated) + ``` +]=] +local function Invert(filter: T.GenericFilter): T.GenericFilter + return function(...) + return not filter(...) + end +end + +--[=[ + @prop Deprecated GenericFilter + @within Filter +]=] +local Deprecated: T.GenericFilter = function(object) + return HasTags("Deprecated")(object) +end + +--[=[ + @prop ReadOnly GenericFilter + @within Filter +]=] +local ReadOnly: T.GenericFilter = function(object) + return HasTags("ReadOnly")(object) +end + +--[=[ + @prop Replicated GenericFilter + @within Filter +]=] +local Replicated: T.GenericFilter = Invert(HasTags("NotReplicated")) + +--[=[ + @prop Scriptable GenericFilter + @within Filter +]=] +local Scriptable: T.GenericFilter = Invert(HasTags("NotScriptable")) + +--[=[ + @prop Yields GenericFilter + @within Filter +]=] +local Yields: T.GenericFilter = function(object) + return HasTags("Yields")(object) +end + +--[=[ + @prop ThreadSafe GenericFilter + @within Filter +]=] +local ThreadSafe: T.GenericFilter = function(object) + return object.ThreadSafety == "ThreadSafe" +end + +--[=[ + @prop Readable GenericFilter + @within Filter +]=] +local Readable: T.GenericFilter = function(object) + return HasSecurity({ Read = "None" })(object) +end + +--[=[ + @prop Writable GenericFilter + @within Filter +]=] +local Writable: T.GenericFilter = function(object) + return HasSecurity({ Write = "None" })(object) +end + +--[=[ + @prop Service GenericFilter + @within Filter +]=] +local Service: T.GenericFilter = function(object) + return object.Tags ~= nil and table.find(object.Tags, "Service") ~= nil +end + +--[=[ + @function MemberType + @within Filter + @param memberType string + @return GenericFilter + + Checks if the given [Item] is of the given member type; for example, + when trying to access only members which are of type `"Property"`. +]=] +local function MemberType(memberType: string): T.GenericFilter + return function(object: T.Item) + return object.MemberType == memberType + end +end + +--[=[ + @function Name + @within Filter + @param name string + @return GenericFilter + + Checks if the given [Item]'s name matches the given string. +]=] +local function Name(name: string): T.GenericFilter + return function(object: T.Item) + return object.Name == name + end +end + +--[=[ + @function Any + @within Filter + @param ... GenericFilter + @return GenericFilter + + Combines multiple filters into a single filter. The returned filter + will return `true` if *any* of the given filters return `true`. If + no filters are given, then the returned filter will always return + `true`, but will print a warning to the output. +]=] +local function Any(...: T.GenericFilter): T.GenericFilter + if select("#", ...) == 0 then + warn("Filter.Any was called with no filters") + + return function(_: T.Item): true + return true + end + end + + local filters = { ... } + + return function(object: T.Item) + for _, filter in filters do + if filter(object) then + return true + end + end + + return false + end +end + +--[=[ + @function ValueType + @within Filter + @param type string | nil + @return GenericFilter + + Returns `true` for any [Item] whose `ValueType` field is of the given type. + Primitive Luau types, such as `string` or `number` can be used; they will + automatically be mapped to the corresponding `ValueType` string (e.g. + `number` can refer to `float`, `double`, `int`, etc.). +]=] +local function ValueType(type: string | nil): T.GenericFilter + return function(object: T.Item) + local valueType = object.ValueType + + if not valueType then + return false + end + + if valueType.Category == "Primitive" then + local primitiveRemap = if type == nil + then VALUE_TYPE_REMAP[NONE] + else VALUE_TYPE_REMAP[type] + + if not primitiveRemap then + return valueType.Name == type + end + + return table.find(primitiveRemap, valueType.Name) ~= nil + end + + return valueType.Name == type + end +end + +return { + Any = Any, + Deprecated = Deprecated, + HasSecurity = HasSecurity, + HasTags = HasTags, + Invert = Invert, + MemberType = MemberType, + Name = Name, + Readable = Readable, + ReadOnly = ReadOnly, + Replicated = Replicated, + Scriptable = Scriptable, + Service = Service, + ThreadSafe = ThreadSafe, + ValueType = ValueType, + Writable = Writable, + Yields = Yields, +} diff --git a/src/Util/Array.lua b/src/Util/Array.lua new file mode 100644 index 0000000..1fd24f3 --- /dev/null +++ b/src/Util/Array.lua @@ -0,0 +1,40 @@ +--!strict +local function truthy() + return true +end + +local function filter( + array: { T }, + predicate: ((value: T, index: number, array: { T }) -> boolean?)? +): { T } + local result = {} + + predicate = if type(predicate) == "function" then predicate else truthy + + for index, value in ipairs(array) do + if predicate(value, index, array) then + table.insert(result, value) + end + end + + return result +end + +local function map(array: { T }, mapper: (value: T, index: number, array: { T }) -> U?): { U } + local mapped = {} + + for index, value in ipairs(array) do + local mappedValue = mapper(value, index, array) + + if mappedValue ~= nil then + table.insert(mapped, mappedValue) + end + end + + return mapped +end + +return { + filter = filter, + map = map, +} diff --git a/src/Util/None.lua b/src/Util/None.lua new file mode 100644 index 0000000..c1092dd --- /dev/null +++ b/src/Util/None.lua @@ -0,0 +1,3 @@ +--!strict +local NONE = newproxy(false) +return NONE diff --git a/src/init.d.lua b/src/init.d.lua new file mode 100644 index 0000000..2d94ffc --- /dev/null +++ b/src/init.d.lua @@ -0,0 +1,159 @@ +--!strict +--[=[ + @class Types +]=] + +--[=[ + @type GenericFilter (object: T) -> any? + @within Types + + A generic filter function that takes an object of type T and + returns a boolean value indicating whether or not the object + should be included in the result. +]=] +export type GenericFilter = (object: T) -> any? + +--[=[ + @interface Property + @within Types + .MemberType "Property" + .Category string + .Name string + .Security { Read: string, Write: string } + .Serialization { CanLoad: boolean, CanSave: boolean } + .Tags { string }? + .ThreadSafety string + .ValueType { Category: string, Name: string } +]=] +export type Property = { + MemberType: "Property", + Category: string, + Name: string, + Security: { + Read: string, + Write: string, + }, + Serialization: { + CanLoad: boolean, + CanSave: boolean, + }, + Tags: { string }?, + ThreadSafety: string, + ValueType: { + Category: string, + Name: string, + }, +} + +--[=[ + @interface Function + @within Types + .MemberType "Function" + .Name string + .Parameters {{ Name: string, Type: string, Default: string? }} + .ReturnType { Category: string, Name: string } + .Security string + .Tags { string }? + .ThreadSafety string +]=] +export type Function = { + MemberType: "Function", + Name: string, + Parameters: { { Name: string, Type: string, Default: string? } }, + ReturnType: { Category: string, Name: string }, + Security: string, + Tags: { string }?, + ThreadSafety: string, +} + +--[=[ + @interface Event + @within Types + .MemberType "Event" + .Name string + .Parameters {{ Name: string, Type: string }} + .Security string + .Tags { string }? + .ThreadSafety string +]=] +export type Event = { + MemberType: "Event", + Name: string, + Parameters: { { Name: string, Type: string } }, + Security: string, + Tags: { string }?, + ThreadSafety: string, +} + +--[=[ + @interface Callback + @within Types + .MemberType "Callback" + .Name string + .Parameters {{ Name: string, Type: string }} + .ReturnType { Category: string, Name: string } + .Security string + .Tags { string }? + .ThreadSafety string +]=] +export type Callback = { + MemberType: "Callback", + Name: string, + Parameters: { { Name: string, Type: string } }, + ReturnType: { Category: string, Name: string }, + Security: string, + Tags: { string }?, + ThreadSafety: string, +} + +--[=[ + @type APIDump { Classes: { [any]: any }, [string]: any } + @within Types + + An API dump is a table that contains the raw Roblox API dump + data. As a minimum, the Dump expects an APIDump to contain + a `Classes` array. +]=] +export type APIDump = { + Classes: { [any]: any }, + [string]: any, +} + +--[=[ + @type Member Property | Function | Event | Callback + @within Types +]=] +export type Member = Property | Function | Event | Callback + +--[=[ + @interface Class + @within Types + .Members { Member } + .MemoryCategory string + .Name string + .Superclass string + .Tags { string }? +]=] +export type Class = { + Members: { Member }, + MemoryCategory: string, + Name: string, + Superclass: string, + Tags: { string }?, +} + +--[=[ + @type ClassWithInheritance Class & { Inherits: { string } } + @within Types +]=] +export type ClassWithInheritance = Class & { + Inherits: { string }, +} + +--[=[ + @type Item Member | Class + @within Types +]=] +export type Item = Member | Class + +return {} diff --git a/src/init.lua b/src/init.lua new file mode 100644 index 0000000..98617fd --- /dev/null +++ b/src/init.lua @@ -0,0 +1,325 @@ +--!strict +--[=[ + @class Dump + + An easy to use and functional interface for processing and + accessing Roblox API dump data. +]=] +local T = require(script["init.d"]) +local NONE = require(script.Util.None) + +local FetchDump = require(script.FetchDump) +local Array = require(script.Util.Array) +local Filter = require(script.Filter) +local Class = require(script.Class) + +local Dump = {} + +Dump.__index = Dump + +local INSTANCE_DEFAULTS_CACHE = {} + +local ERR_DUMP_INVALID = 'Invalid API dump; expected table, got "%*" (%s)' +local ERR_DUMP_CLASSES_INVALID = + 'Invalid field "Classes" in API dump; expected table, got "%*" (%s)' +local ERR_CLASS_ARG_INVALID = 'Invalid argument "class"; expected string, got "%*" (%s)' +local ERR_UNKNOWN_CLASSNAME = "Could not find class in dump with name %q" + +--[=[ + @prop Filter Filter + @within Dump + + A frozen list of various filters that can be used to filter out + API members from the dump. This is useful for filtering members, + such as those that are deprecated or inaccessible to + non-CoreScripts. +]=] +Dump.Filter = table.freeze(Filter) + +--[=[ + @function new + @within Dump + @param dump APIDump + @return Dump + + Creates a new Dump instance from the given API dump. We don't + necessarily care where the dump came from, as long as it's + properly formatted. +]=] +function Dump.new(dump: T.APIDump) + if typeof(dump) ~= "table" then + error(ERR_DUMP_INVALID:format(dump, typeof(dump))) + end + + if typeof(dump.Classes) ~= "table" then + error(ERR_DUMP_CLASSES_INVALID:format(dump.Classes, typeof(dump.Classes))) + end + + local self = setmetatable({}, Dump) + + self._dump = dump + + return self +end + +--[=[ + @function fetchRawDump + @within Dump + @param hashOrVersion string? + @return APIDump + + Fetches the API dump for the current version of Roblox from the + Roblox API. If a hash or version is provided, it will attempt to + fetch the dump for that hash or version. +]=] +function Dump.fetchRawDump(hashOrVersion: string?): T.APIDump + local isVersionString = hashOrVersion and hashOrVersion:match("%d+%.") ~= nil + + if isVersionString then + hashOrVersion = FetchDump.fetchVersionHashWithFallback(hashOrVersion) + end + + return FetchDump.fetchDump(hashOrVersion) +end + +--[=[ + @function fetchFromServer + @within Dump + @param hashOrVersion string? + @return Dump + + Performs the same actions as [`fetchDump`][Dump.fetchDump], but returns a + [Dump] instance instead of the raw API data. +]=] +function Dump.fetchFromServer(hashOrVersion: string?) + local apiDump = Dump.fetchRawDump(hashOrVersion) + local dump = Dump.new(apiDump) + + return dump +end + +--[=[ + @method findRawClassEntry + @within Dump + @private + @param className string + @return Class + + An internal method that finds the class entry for the given + class name. If the class entry cannot be found, it will throw + an error. +]=] +function Dump:findRawClassEntry(className: string): T.Class + for _, class: T.Class in self._dump.Classes do + if class.Name == className then + return class + end + end + + error(ERR_UNKNOWN_CLASSNAME:format(className)) +end + +--[=[ + @method constructRawClass + @within Dump + @private + @param class Class + @return ClassWithInheritance + + An internal method that constructs a table of raw class data from + the given class object, merging in all the class ancestors' members. +]=] +function Dump:constructRawClass(class: T.Class): T.ClassWithInheritance + local finalDescendant = class + + local memberAncestry = finalDescendant.Members + local nextAncestorClassName = finalDescendant.Superclass + local ancestorNames = {} + + while nextAncestorClassName and nextAncestorClassName ~= "<<>>" do + local ancestor = self:findRawClassEntry(nextAncestorClassName) + + for _, member in ancestor.Members do + table.insert(memberAncestry, member) + end + + table.insert(ancestorNames, ancestor.Name) + nextAncestorClassName = ancestor.Superclass + end + + local constructed: T.Class = {} + + constructed.Members = memberAncestry + constructed.MemoryCategory = finalDescendant.MemoryCategory + constructed.Name = finalDescendant.Name + constructed.Superclass = finalDescendant.Superclass + constructed.Tags = finalDescendant.Tags + constructed.Inherits = ancestorNames + + return constructed +end + +--[=[ + @method filterClasses + @within Dump + @private + @param filters { string | GenericFilter | Instance } + @return { [string]: Class } + + Accepts a table of filters and returns a table of classes that + match the given filters. The keys of the returned table are the + class names, and the values are the class entries. +]=] +function Dump:filterClasses(filters: { string | T.GenericFilter | Instance }) + local filteredResults: { T.Class } = self._dump.Classes + + if #filters > 0 then + filters = Array.map(filters, function(filter) + local filterType = typeof(filter) + + if filterType == "function" then + return filter + elseif filterType == "string" then + return Filter.Name(filter) + elseif filterType == "Instance" then + return Filter.Name(filter.ClassName) + end + + error("Invalid filter type: " .. filterType) + end) + + for _, filter in filters do + filteredResults = Array.filter(filteredResults, filter) + end + end + + local results = {} + + for _, class in filteredResults do + results[class.Name] = Class.new(self:constructRawClass(class)) + end + + return results +end + +--[=[ + @method GetClasses + @within Dump + @param ... (string | Instance | GenericFilter)? + @return { [string]: Class } + + Gets all the classes from the API dump. If any arguments are + passed, it will filter the classes based on the given arguments. +]=] +function Dump:GetClasses(...: (string | Instance | T.GenericFilter)?) + return self:filterClasses({ ... }) +end + +--[=[ + @method GetClass + @within Dump + @param class string | Instance + @return Class + + Gets the class with the given name from the API dump. If the + class is not found, it will throw an error. If an instance is + passed, it will determine the class name from `Instance.ClassName`. +]=] +function Dump:GetClass(class: string | Instance) + local className = typeof(class) == "Instance" and class.ClassName or class + + if typeof(className) ~= "string" then + error(ERR_CLASS_ARG_INVALID:format(className, typeof(className))) + end + + return self:GetClasses(className)[className] +end + +--[=[ + @method GetProperties + @within Dump + @param class string | Instance + @param ... (string | GenericFilter)? + @return { [string]: Property } + + Gets a list of properties for the given class. If an Instance + is passed, it will determine the class from `Instance.ClassName`. + Additional arguments can be passed to filter the properties. + + This differs from [`Class:GetProperties`][Class.GetProperties] in + that it will return a pre-filtered table of properties, where + properties are not deprecated and are safe to read from normal + scripts. + + Consider adding the `Filter.Invert(Filter.ReadOnly)` filter to + filter out read-only properties. +]=] +function Dump:GetProperties(class: string | Instance, ...: (string | T.GenericFilter)?) + local classInstance = self:GetClass(class) + + return classInstance:GetProperties( + Filter.Invert(Filter.Deprecated), + Filter.HasSecurity("None"), + Filter.Scriptable, + ... + ) +end + +--[=[ + @method GetChangedProperties + @within Dump + @param instance Instance + @param ... (string | GenericFilter)? + @return { [string]: Property } + + Gets a list of properties that have been changed from the + default value for the given instance. +]=] +function Dump:GetChangedProperties(instance: Instance, ...: (string | T.GenericFilter)?) + local className = instance.ClassName + + local properties = self:GetProperties(instance, ...) + local untestedProperties, changedProperties = {}, {} + + if INSTANCE_DEFAULTS_CACHE[className] == nil then + INSTANCE_DEFAULTS_CACHE[className] = {} + end + + for propertyName, property in properties do + local testedValue = INSTANCE_DEFAULTS_CACHE[className][propertyName] + local currentValue = instance[propertyName] + + if testedValue == nil then + untestedProperties[propertyName] = property + continue + end + + if testedValue == NONE then + testedValue = nil + end + + if testedValue ~= currentValue then + changedProperties[propertyName] = property + end + end + + if next(untestedProperties) ~= nil then + local defaultInstance = Instance.new(className) + + for propertyName, property in untestedProperties do + local value = defaultInstance[propertyName] + + if instance[propertyName] ~= value then + changedProperties[propertyName] = property + end + + INSTANCE_DEFAULTS_CACHE[className][propertyName] = if value == nil then NONE else value + end + + defaultInstance:Destroy() + end + + return changedProperties +end + +return Dump diff --git a/wally.lock b/wally.lock new file mode 100644 index 0000000..2a4e184 --- /dev/null +++ b/wally.lock @@ -0,0 +1,8 @@ +# This file is automatically @generated by Wally. +# It is not intended for manual editing. +registry = "test" + +[[package]] +name = "csqrl/dump-parser" +version = "0.1.0" +dependencies = [] diff --git a/wally.toml b/wally.toml new file mode 100644 index 0000000..f8dd8df --- /dev/null +++ b/wally.toml @@ -0,0 +1,7 @@ +[package] +name = "csqrl/dump-parser" +version = "0.1.0" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" + +[dependencies]