diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d83653e2a..be463f88f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -219,3 +219,19 @@ jobs: run: npm publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Copy analysis binaries to tools folder + run: cp -r server/analysis_binaries/* tools/analysis_binaries + + - name: Build @rescript/tools package + working-directory: tools + run: | + npm ci + npm run build + + - name: Publish @rescript/tools package + if: ${{ startsWith(github.event.head_commit.message, 'publish tools') && (github.ref == 'refs/heads/master') }} + working-directory: tools + run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 8590b9e9a..eec4dd08d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ analysis/_build analysis/tests/.merlin analysis/rescript-editor-analysis.exe analysis/_opam +tools/node_modules +tools/lib +tools/**/*.bs.js diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 000000000..6882908fa --- /dev/null +++ b/tools/README.md @@ -0,0 +1,46 @@ +# ReScript Tools + +## Install + +```sh +npm install --save-dev @rescript/tools +``` + +## CLI Usage + +```sh +restools --help +``` + +### Generate documentation + +Print JSON: + +```sh +restools doc src/EntryPointLibFile.res +``` + +Write JSON: + +```sh +restools doc src/EntryPointLibFile.res > doc.json +``` + +### Reanalyze + +```sh +restools reanalyze --help +``` + +## Decode JSON + +Add to `bs-dev-dependencies`: + +```json +"bs-dev-dependencies": ["@rescript/tools"] +``` + +```rescript +// Read JSON file and parse with `Js.Json.parseExn` +json->RescriptTools.Docgen.decodeFromJson +``` diff --git a/tools/analysis_binaries/.gitkeep b/tools/analysis_binaries/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/tools/package-lock.json b/tools/package-lock.json new file mode 100644 index 000000000..2e319de32 --- /dev/null +++ b/tools/package-lock.json @@ -0,0 +1,43 @@ +{ + "name": "@rescript/tools", + "version": "0.1.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "@rescript/tools", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "rescript": "^11.0.0-rc.4" + }, + "bin": { + "restools": "src/cli.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/rescript": { + "version": "11.0.0-rc.4", + "resolved": "https://registry.npmjs.org/rescript/-/rescript-11.0.0-rc.4.tgz", + "integrity": "sha512-yh82o30J2/B/IwyPZjM+mc82FdyPkXRjXxlsUVEoyc5Z/snlq3Za2PhJhlfacwk+ui5lcOZsH8SndWPiI21vXg==", + "hasInstallScript": true, + "bin": { + "bsc": "bsc", + "bstracing": "lib/bstracing", + "rescript": "rescript" + }, + "engines": { + "node": ">=10" + } + } + }, + "dependencies": { + "rescript": { + "version": "11.0.0-rc.4", + "resolved": "https://registry.npmjs.org/rescript/-/rescript-11.0.0-rc.4.tgz", + "integrity": "sha512-yh82o30J2/B/IwyPZjM+mc82FdyPkXRjXxlsUVEoyc5Z/snlq3Za2PhJhlfacwk+ui5lcOZsH8SndWPiI21vXg==" + } + } +} diff --git a/tools/package.json b/tools/package.json new file mode 100644 index 000000000..bd9094c22 --- /dev/null +++ b/tools/package.json @@ -0,0 +1,40 @@ +{ + "name": "@rescript/tools", + "description": "ReScript Tools", + "version": "0.1.0", + "author": "chenglou", + "license": "MIT", + "bin": { + "restools": "./src/Cli.bs.js" + }, + "keywords": [ + "ReScript", + "Tools", + "Docgen" + ], + "files": [ + "src/Cli.bs.js", + "src/*.res", + "src/*.resi", + "analysis_binaries/", + "README.md" + ], + "engines": { + "node": "*" + }, + "homepage": "https://github.com/rescript-lang/rescript-vscode/tools/README.md", + "repository": { + "type": "git", + "url": "https://github.com/rescript-lang/rescript-vscode", + "directory": "tools" + }, + "bugs": { + "url": "https://github.com/rescript-lang/rescript-vscode/issues" + }, + "scripts": { + "build": "rescript build" + }, + "dependencies": { + "rescript": "^11.0.0-rc.4" + } +} diff --git a/tools/rescript.json b/tools/rescript.json new file mode 100644 index 000000000..bca259355 --- /dev/null +++ b/tools/rescript.json @@ -0,0 +1,15 @@ +{ + "name": "@rescript/tools", + "version": "0.1.0", + "sources": [ + { + "dir": "src", + "public": ["RescriptTools"] + } + ], + "suffix": ".bs.js", + "package-specs": { + "module": "commonjs", + "in-source": true + } +} diff --git a/tools/src/Cli.res b/tools/src/Cli.res new file mode 100644 index 000000000..332fc71d6 --- /dev/null +++ b/tools/src/Cli.res @@ -0,0 +1,102 @@ +@@directive("#!/usr/bin/env node") + +@module("fs") external readFileSync: string => string = "readFileSync" +@variadic @module("path") external join: array => string = "join" +@module("path") external dirname: string => string = "dirname" +@val external __dirname: string = "__dirname" + +module Buffer = { + type t + + @send external toString: t => string = "toString" +} + +type spawnSyncResult = { + stdout: Buffer.t, + stderr: Buffer.t, + status: Js.Null.t, +} +@module("child_process") +external spawnSync: (string, array) => spawnSyncResult = "spawnSync" + +@val @scope("process") +external exit: int => unit = "exit" + +@val +external process: {"arch": string, "platform": string, "argv": array} = "process" + +let argv = process["argv"] + +let args = argv->Js.Array2.slice(~start=2, ~end_=Js.Array2.length(argv)) + +let platformDir = + process["arch"] === "arm64" ? process["platform"] ++ process["arch"] : process["platform"] + +let analysisProdPath = join([ + dirname(__dirname), + "analysis_binaries", + platformDir, + "rescript-editor-analysis.exe", +]) + +let docHelp = `ReScript Tools + +Output documentation to standard output + +Usage: restools doc + +Example: restools doc ./path/to/EntryPointLib.res` + +let help = `ReScript Tools + +Usage: restools [command] + +Commands: + +doc Generate documentation +reanalyze Reanalyze +-v, --version Print version +-h, --help Print help` + +let logAndExit = (~log, ~code) => { + Js.log(log) + exit(code) +} + +switch args->Belt.List.fromArray { +| list{"doc", ...rest} => + switch rest { + | list{"-h" | "--help"} => logAndExit(~log=docHelp, ~code=0) + | list{filePath} => + let spawn = spawnSync(analysisProdPath, ["extractDocs", filePath]) + + switch spawn.status->Js.Null.toOption { + | Some(code) if code !== 0 => logAndExit(~log=spawn.stderr->Buffer.toString, ~code) + | Some(code) => logAndExit(~log=spawn.stdout->Buffer.toString, ~code) + | None => logAndExit(~log=`error: unexpected error to extract docs for ${filePath}`, ~code=1) + } + | _ => logAndExit(~log=docHelp, ~code=1) + } +| list{"reanalyze", ...rest} => + let args = ["reanalyze"]->Js.Array2.concat(Belt.List.toArray(rest)) + let spawn = spawnSync(analysisProdPath, args) + + switch spawn.status->Js.Null.toOption { + | Some(code) if code !== 0 => logAndExit(~log=spawn.stderr->Buffer.toString, ~code) + | Some(code) => logAndExit(~log=spawn.stdout->Buffer.toString, ~code) + | None => + logAndExit( + ~log=`error: unexpected error to run reanalyze with arguments: ${args->Js.Array2.joinWith( + " ", + )}`, + ~code=1, + ) + } +| list{"-h" | "--help"} => logAndExit(~log=help, ~code=0) +| list{"-v" | "--version"} => + switch readFileSync("./package.json")->Js.Json.parseExn->Js.Json.decodeObject { + | None => logAndExit(~log="error: failed to find version in package.json", ~code=1) + | Some(dict) => logAndExit(~log=dict->Js.Dict.unsafeGet("version"), ~code=0) + } +| _ => logAndExit(~log=help, ~code=1) +} diff --git a/tools/src/RescriptTools.res b/tools/src/RescriptTools.res new file mode 100644 index 000000000..b06443a5e --- /dev/null +++ b/tools/src/RescriptTools.res @@ -0,0 +1 @@ +module Docgen = Tools_Docgen diff --git a/tools/src/Tools_Docgen.res b/tools/src/Tools_Docgen.res new file mode 100644 index 000000000..2b9cf316b --- /dev/null +++ b/tools/src/Tools_Docgen.res @@ -0,0 +1,238 @@ +type field = { + name: string, + docstrings: array, + signature: string, + optional: bool, + deprecated: option, +} + +type constructor = { + name: string, + docstrings: array, + signature: string, + deprecated: option, +} + +@tag("kind") +type detail = + | @as("record") Record(array) + | @as("variant") Variant(array) + +@tag("kind") +type rec item = + | @as("value") + Value({ + id: string, + docstrings: array, + signature: string, + name: string, + deprecated: option, + }) + | @as("type") + Type({ + id: string, + docstrings: array, + signature: string, + name: string, + deprecated: option, + /** Additional documentation for constructors and record fields, if available. */ + detail: option, + }) + | @as("module") Module(docsForModule) + | @as("moduleAlias") + ModuleAlias({ + id: string, + docstrings: array, + name: string, + items: array, + }) +and docsForModule = { + id: string, + docstrings: array, + deprecated: option, + name: string, + items: array, +} + +let decodeDocstrings = item => { + open Js.Json + switch item->Js.Dict.get("docstrings") { + | Some(Array(arr)) => + arr->Js.Array2.map(s => + switch s { + | String(s) => s + | _ => assert(false) + } + ) + | _ => [] + } +} + +let decodeStringByField = (item, field) => { + open Js.Json + switch item->Js.Dict.get(field) { + | Some(String(s)) => s + | _ => assert(false) + } +} + +let decodeDepreacted = item => { + open Js.Json + switch item->Js.Dict.get("deprecated") { + | Some(String(s)) => Some(s) + | _ => None + } +} + +let decodeRecordFields = fields => { + open Js.Json + let fields = fields->Js.Array2.map(field => { + switch field { + | Object(doc) => { + let name = doc->decodeStringByField("name") + let docstrings = doc->decodeDocstrings + let signature = doc->decodeStringByField("signature") + let deprecated = doc->decodeDepreacted + let optional = switch Js.Dict.get(doc, "optional") { + | Some(Boolean(bool)) => bool + | _ => assert(false) + } + + {name, docstrings, signature, optional, deprecated} + } + + | _ => assert(false) + } + }) + Record(fields) +} + +let decodeConstructorFields = fields => { + open Js.Json + let fields = fields->Js.Array2.map(field => { + switch field { + | Object(doc) => { + let name = doc->decodeStringByField("name") + let docstrings = doc->decodeDocstrings + let signature = doc->decodeStringByField("signature") + let deprecated = doc->decodeDepreacted + + {name, docstrings, signature, deprecated} + } + + | _ => assert(false) + } + }) + Variant(fields) +} + +let decodeDetail = detail => { + open Js.Json + + switch detail { + | Object(detail) => + switch (detail->Js.Dict.get("kind"), detail->Js.Dict.get("items")) { + | (Some(String(kind)), Some(Array(items))) => + switch kind { + | "record" => decodeRecordFields(items) + | "variant" => decodeConstructorFields(items) + | _ => assert(false) + } + | _ => assert(false) + } + + | _ => assert(false) + } +} + +let rec decodeValue = item => { + let id = item->decodeStringByField("id") + let signature = item->decodeStringByField("signature") + let name = item->decodeStringByField("name") + let deprecated = item->decodeDepreacted + let docstrings = item->decodeDocstrings + Value({id, docstrings, signature, name, deprecated}) +} +and decodeType = item => { + let id = item->decodeStringByField("id") + let signature = item->decodeStringByField("signature") + let name = item->decodeStringByField("name") + let deprecated = item->decodeDepreacted + let docstrings = item->decodeDocstrings + let detail = switch item->Js_dict.get("detail") { + | Some(field) => decodeDetail(field)->Some + | None => None + } + Type({id, docstrings, signature, name, deprecated, detail}) +} +and decodeModuleAlias = item => { + open Js.Json + let id = item->decodeStringByField("id") + let name = item->decodeStringByField("name") + let docstrings = item->decodeDocstrings + let items = switch Js.Dict.get(item, "items") { + | Some(Array(items)) => items->Js.Array2.map(item => decodeItem(item)) + | _ => assert(false) + } + ModuleAlias({id, items, name, docstrings}) +} +and decodeModule = item => { + open Js.Json + let id = item->decodeStringByField("id") + let name = item->decodeStringByField("name") + let deprecated = item->decodeDepreacted + let docstrings = item->decodeDocstrings + let items = switch Js.Dict.get(item, "items") { + | Some(Array(items)) => items->Js.Array2.map(item => decodeItem(item)) + | _ => assert(false) + } + Module({id, name, docstrings, deprecated, items}) +} +and decodeItem = item => { + open Js.Json + switch item { + | Object(value) => + switch Js.Dict.get(value, "kind") { + | Some(String(kind)) => + switch kind { + | "type" => decodeType(value) + | "value" => decodeValue(value) + | "module" => decodeModule(value) + | "moduleAlias" => decodeModuleAlias(value) + | _ => assert(false) + } + | _ => assert(false) + } + | _ => assert(false) + } +} + +type doc = { + name: string, + deprecated: option, + docstrings: array, + items: array, +} + +/** +`decodeFromJson(json)` parse JSON generated from `restool doc` command +*/ +let decodeFromJson = json => { + open Js.Json + + switch json { + | Object(mod) => { + let name = mod->decodeStringByField("name") + let deprecated = mod->decodeDepreacted + let docstrings = mod->decodeDocstrings + let items = switch Js.Dict.get(mod, "items") { + | Some(Array(items)) => items->Js.Array2.map(item => decodeItem(item)) + | _ => assert(false) + } + + {name, deprecated, docstrings, items} + } + + | _ => assert(false) + } +} diff --git a/tools/src/Tools_Docgen.resi b/tools/src/Tools_Docgen.resi new file mode 100644 index 000000000..964b1ab34 --- /dev/null +++ b/tools/src/Tools_Docgen.resi @@ -0,0 +1,41 @@ +type field = { + name: string, + docstrings: array, + signature: string, + optional: bool, + deprecated: option, +} +type constructor = { + name: string, + docstrings: array, + signature: string, + deprecated: option, +} +type detail = Record(array) | Variant(array) +type rec item = + | Value({ + id: string, + docstrings: array, + signature: string, + name: string, + deprecated: option, + }) + | Type({ + id: string, + docstrings: array, + signature: string, + name: string, + deprecated: option, + detail: option, + }) + | Module(docsForModule) + | ModuleAlias({id: string, docstrings: array, name: string, items: array}) +and docsForModule = { + id: string, + docstrings: array, + deprecated: option, + name: string, + items: array, +} +type doc = {name: string, deprecated: option, docstrings: array, items: array} +let decodeFromJson: Js.Json.t => doc