diff --git a/src/nodeJs/build.fan b/src/nodeJs/build.fan new file mode 100644 index 000000000..2be8d27d3 --- /dev/null +++ b/src/nodeJs/build.fan @@ -0,0 +1,45 @@ +#! /usr/bin/env fan +// +// Copyright (c) 2006, Brian Frank and Andy Frank +// Licensed under the Academic Free License version 3.0 +// +// History: +// 17 Jul 2023 Matthew Giannini creation +// + +using build + +** +** Build: nodeJs +** +class Build : BuildPod +{ + new make() + { + podName = "nodeJs" + summary = "Utilities for running Fantom in Node JS" + meta = ["org.name": "Fantom", + "org.uri": "https://fantom.org/", + "proj.name": "Fantom Core", + "proj.uri": "https://fantom.org/", + "license.name": "Academic Free License 3.0", + "vcs.name": "Git", + "vcs.uri": "https://github.com/fantom-lang/fantom", + ] + depends = ["sys 1.0", + "compiler 1.0", + "compilerEs 1.0", + "fandoc 1.0", + "util 1.0", + ] + srcDirs = [ + `fan/`, + `fan/cmd/`, + // `fan/ts/`, + ] + resDirs = [ + `res/`, + ] + docApi = false + } +} \ No newline at end of file diff --git a/src/nodeJs/fan/EmitUtil.fan b/src/nodeJs/fan/EmitUtil.fan new file mode 100644 index 000000000..9089136f0 --- /dev/null +++ b/src/nodeJs/fan/EmitUtil.fan @@ -0,0 +1,202 @@ +// +// Copyright (c) 2023, SkyFoundry LLC +// All Rights Reserved +// +// History: +// 27 Jul 2023 Matthew Giannini Creation +// + +using compilerEs +using util + +** +** Utility for emitting various JS code +** +internal class EmitUtil +{ + +////////////////////////////////////////////////////////////////////////// +// Constructor +////////////////////////////////////////////////////////////////////////// + + new make(NodeJsCmd cmd) + { + this.cmd = cmd + } + + private NodeJsCmd cmd + private Pod[] depends := [Pod.find("sys")] + private File? scriptJs := null + private ModuleSystem ms() { cmd.ms } + private Bool isCjs() { ms.moduleType == "cjs" } + +////////////////////////////////////////////////////////////////////////// +// Configure Dependencies +////////////////////////////////////////////////////////////////////////// + + ** Configure the pod dependencies before emitting any code + This withDepends(Pod[] pods) + { + this.depends = Pod.orderByDepends(Pod.flattenDepends(pods)) + return this + } + + ** Configure the script js for a Fantom script + This withScript(File scriptJs) + { + this.scriptJs = scriptJs + return this + } + +////////////////////////////////////////////////////////////////////////// +// Util +////////////////////////////////////////////////////////////////////////// + + private File? podJsFile(Pod pod, Str name := pod.name) + { + ext := isCjs ? "js" : "mjs" + script := "${name}.${ext}" + return pod.file(`/js/$script`, false) + } + +////////////////////////////////////////////////////////////////////////// +// Emit +////////////////////////////////////////////////////////////////////////// + + Void writePackageJson([Str:Obj?] json := [:]) + { + if (json["name"] == null) json["name"] = "@fantom/fan" + if (json["version"] == null) json["version"] = Pod.find("sys").version.toStr + ms.writePackageJson(json) + } + + ** Copy all pod js files into '/node_modules/'. + // ** Also copies in mime.js, units.js, and indexed-props.js + Void writeNodeModules() + { + writeFanJs + writeNode + writeDepends + writeScriptJs + writeMimeJs + writeUnitsJs + // TODO: indexed-props? + } + + ** Write 'es6.js' (grab it from sys.js) + Void writeFanJs() + { + out := ms.file("fan").out + podJsFile(Pod.find("sys"), "fan").in.pipe(out) + out.flush.close + } + + + ** Write 'es6.js' (grab it from sys.js) + Void writeEs6() + { + out := ms.file("es6").out + podJsFile(Pod.find("sys"), "es6").in.pipe(out) + out.flush.close + } + + ** Write 'node.js' + Void writeNode() + { + modules := ["os", "path", "fs", "crypto", "url", "zlib"] + out := ms.file("node").out + ms.writeBeginModule(out) + modules.each |m, i| { ms.writeInclude(out, m) } + ms.writeExports(out, modules) + ms.writeEndModule(out).flush.close + } + + ** Write js from configured pod dependencies + Void writeDepends() + { + copyOpts := ["overwrite": true] + + this.depends.each |pod| + { + file := podJsFile(pod) + target := ms.file(pod.name) + if (file != null) + { + file.copyTo(target, copyOpts) + // if (pod.name == "sys") + // { + // out := target.out + // file.in.pipe(out) + // out.flush.close + // } + // else file.copyTo(target, copyOpts) + } + } + } + + ** Write the fantom script if one was configured + Void writeScriptJs() + { + if (scriptJs == null) return + out := ms.file(scriptJs.basename).out + try + { + scriptJs.in.pipe(out) + } + finally out.flush.close + } + + ** Write the code for configuring MIME types to 'fan_mime.js' + Void writeMimeJs() + { + out := ms.file("fan_mime").out + JsExtToMime(ms).write(out) + out.flush.close + } + + ** Write the unit database to 'fan_units.js' + Void writeUnitsJs() + { + out := ms.file("fan_units").out + JsUnitDatabase(ms).write(out) + out.flush.close + } + +////////////////////////////////////////////////////////////////////////// +// Str +////////////////////////////////////////////////////////////////////////// + + ** Get a Str with all the include statements for the configured + ** dependencies that is targetted for the current module system + ** This method assumes the script importing the modules is in + ** the parent directory. + Str includeStatements() + { + baseDir := "./${ms.moduleDir.name}/" + buf := Buf() + this.depends.each |pod| + { + if ("sys" == pod.name) + { + // need explicit js ext because node has built-in lib named sys + ms.writeInclude(buf.out, "sys.ext", baseDir) + ms.writeInclude(buf.out, "fan_mime.ext", baseDir) + } + else ms.writeInclude(buf.out, "${pod.name}.ext", baseDir) + } + if (scriptJs != null) throw Err("TODO: script js") + // if (scriptJs != null) + // buf.add("import * as ${scriptJs.basename} from './${ms.moduleType}/${scriptJs.name}';\n") + return buf.flip.readAllStr + } + + ** Get the JS code to configure the Env home, work and temp directories. + Str envDirs() + { + buf := StrBuf() + buf.add(" sys.Env.cur().__homeDir = sys.File.os(${Env.cur.homeDir.pathStr.toCode});\n") + buf.add(" sys.Env.cur().__workDir = sys.File.os(${Env.cur.workDir.pathStr.toCode});\n") + buf.add(" sys.Env.cur().__tempDir = sys.File.os(${Env.cur.tempDir.pathStr.toCode});\n") + return buf.toStr + } +} \ No newline at end of file diff --git a/src/nodeJs/fan/Main.fan b/src/nodeJs/fan/Main.fan new file mode 100644 index 000000000..76a92032c --- /dev/null +++ b/src/nodeJs/fan/Main.fan @@ -0,0 +1,31 @@ +// +// Copyright (c) 2023, SkyFoundry LLC +// All Rights Reserved +// +// History: +// 27 Jul 2023 Matthew Giannini Creation +// + +using util + +** +** Command line main +** +class Main +{ + static Int main(Str[] args) + { + // lookup command + if (args.isEmpty) args = ["help"] + name := args.first + cmd := NodeJsCmd.find(name) + if (cmd == null) + { + echo("ERROR: unknown nodeJs command '$name'") + return 1 + } + + // strip commandname from args and process as util::AbstractMain + return cmd.main(args.dup[1..-1]) + } +} diff --git a/src/nodeJs/fan/NodeJsCmd.fan b/src/nodeJs/fan/NodeJsCmd.fan new file mode 100644 index 000000000..c68249865 --- /dev/null +++ b/src/nodeJs/fan/NodeJsCmd.fan @@ -0,0 +1,99 @@ +// +// Copyright (c) 2023, SkyFoundry LLC +// All Rights Reserved +// +// History: +// 27 Jul 2023 Matthew Giannini Creation +// + +using compilerEs +using util + +** +** NodeJs command target +** +abstract class NodeJsCmd : AbstractMain +{ + ** Find a specific target or return null + static NodeJsCmd? find(Str name) + { + list.find |t| { t.name == name || t.aliases.contains(name) } + } + + ** List installed commands + static NodeJsCmd[] list() + { + NodeJsCmd[] acc := NodeJsCmd#.pod.types.mapNotNull |t->NodeJsCmd?| + { + if (t.isAbstract || !t.fits(NodeJsCmd#)) return null + return t.make + } + acc.sort |a, b| { a.name <=> b.name } + return acc + } + + ** App name is "nodeJs {name}" + override final Str appName() { "nodeJs ${name}" } + + ** Log name is "nodeJs" + override Log log() { Log.get("nodeJs") } + + ** Command name + abstract Str name() + + ** Command aliases/shortcuts + virtual Str[] aliases() { Str[,] } + + ** Run the command. Return zero on success + abstract override Int run() + + ** Single line summary of the command for help + abstract Str summary() + + @Opt { help="Verbose debug output"; aliases=["v"] } + Bool verbose + + @Opt { help = "Root directory for staging Node.js environment"; aliases = ["d"] } + virtual File dir := Env.cur.tempDir.plus(`nodeJs/`) + + @Opt { help = "Emit CommonJs" } + Bool cjs := false + +////////////////////////////////////////////////////////////////////////// +// NodeJs +////////////////////////////////////////////////////////////////////////// + + protected Bool checkForNode() + { + cmd := ["which", "-s", "node"] + if ("win32" == Env.cur.os) cmd = ["where", "node"] + if (Process(cmd) { it.out = null }.run.join != 0) + { + err("Node not found") + printLine("Please ensure Node.js is installed and available in your PATH") + return false + } + return true + } + + ** Get the module system environment + once ModuleSystem ms() + { + return this.cjs ? CommonJs(this.dir) : Esm(this.dir) + } + + ** Get the JS emit utility + internal once EmitUtil emit() { EmitUtil(this) } + +////////////////////////////////////////////////////////////////////////// +// Console +////////////////////////////////////////////////////////////////////////// + + ** Print a line to stdout + Void printLine(Str line := "") { Env.cur.out.printLine(line) } + + ** Print error message and return 1 + Int err(Str msg) { printLine("ERROR: ${msg}"); return 1 } + + +} diff --git a/src/nodeJs/fan/cmd/HelpCmd.fan b/src/nodeJs/fan/cmd/HelpCmd.fan new file mode 100644 index 000000000..4be6c99f0 --- /dev/null +++ b/src/nodeJs/fan/cmd/HelpCmd.fan @@ -0,0 +1,58 @@ +// +// Copyright (c) 2023, SkyFoundry LLC +// All Rights Reserved +// +// History: +// 27 Jul 2023 Matthew Giannini Creation +// + +using util + +internal class HelpCmd : NodeJsCmd +{ + override Str name() { "help" } + + override Str[] aliases() { ["-h", "-?"] } + + override Str summary() { "Print listing of available commands" } + + @Arg Str[] commandName := [,] + + override Int run() + { + // if we have a command name, print its usage + if (commandName.size > 0) + { + cmdName :=commandName[0] + cmd := find(cmdName) + if (cmd == null) return err("Unknown help command '${cmdName}'") + printLine + printLine(cmd.summary) + printLine + if (!cmd.aliases.isEmpty) + { + printLine("Aliases:") + printLine(" " + cmd.aliases.join(" ")) + } + ret := cmd.usage + printLine + return ret + } + + // show summary for all commands; find longest command name + cmds := list + maxName := 4 + cmds.each |cmd| { maxName = maxName.max(cmd.name.size) } + + // print help + printLine + printLine("nodeJs commands:") + printLine + list.each |cmd| + { + printLine(cmd.name.padr(maxName) + " " + cmd.summary) + } + printLine + return 0 + } +} diff --git a/src/nodeJs/fan/cmd/InitCmd.fan b/src/nodeJs/fan/cmd/InitCmd.fan new file mode 100644 index 000000000..c1b4997e2 --- /dev/null +++ b/src/nodeJs/fan/cmd/InitCmd.fan @@ -0,0 +1,62 @@ +// +// Copyright (c) 2023, SkyFoundry LLC +// All Rights Reserved +// +// History: +// 27 Jul 2023 Matthew Giannini Creation +// + +using compiler +using util + +internal class InitCmd : NodeJsCmd +{ + override Str name() { "init" } + + override Str summary() { "Initialize Node.js environment for running Fantom modules" } + + @Opt { help = "Root directory for staging Node.js environment"; aliases = ["-d"] } + override File dir := Env.cur.homeDir.plus(`lib/es/`) + + override Int run() + { + emit.writePackageJson + emit.writeNodeModules + writeFantomJs + writeTsDecl + log.info("Initialized Node.js in: ${this.dir}") + return 0 + } + + ** Write 'fantom.js' which ensures that all the supporting + ** sys libraries are run. + private Void writeFantomJs() + { + out := ms.file("fantom").out + ms.writeBeginModule(out) + ["sys", "fan_mime", "fan_units"].each |m| { ms.writeInclude(out, "${m}.ext") } + ms.writeExports(out, ["sys"]) + ms.writeEndModule(out).flush.close + } + + ** Write 'sys.t.ds' + private Void writeTsDecl() + { + sysDecl := ms.moduleDir.plus(`sys.d.ts`) + sysDir := Env.cur.homeDir.plus(`src/sys/`) + ci := CompilerInput() + ci.podName = "sys" + ci.summary = "synthetic sys build" + ci.version = Pod.find("sys").version + ci.depends = Depend[,] + ci.inputLoc = Loc.makeFile(sysDir.plus(`build.fan`)) + ci.baseDir = sysDir + ci.srcFiles = [sysDir.plus(`fan/`).uri] + ci.mode = CompilerInputMode.file + ci.output = CompilerOutputMode.podFile + ci.includeDoc = true + c := Compiler(ci) + c.frontend + sysDecl.out.writeChars(c.tsDecl).flush.close + } +} diff --git a/src/nodeJs/fan/cmd/RunCmd.fan b/src/nodeJs/fan/cmd/RunCmd.fan new file mode 100644 index 000000000..4df5ffe9e --- /dev/null +++ b/src/nodeJs/fan/cmd/RunCmd.fan @@ -0,0 +1,85 @@ +// +// Copyright (c) 2023, SkyFoundry LLC +// All Rights Reserved +// +// History: +// 28 Jul 2023 Matthew Giannini Creation +// + +using compiler +using util + +internal class RunCmd : NodeJsCmd +{ + override Str name() { "run" } + + override Str summary() { "Run a Fantom script in Node.js" } + + @Opt { help = "Don't delete Node.js environment when done" } + Bool keep + + @Arg { help = "Fantom script" } + File? script + + const Str tempPod := "temp${Duration.now.toMillis}" + + override Int run() + { + if (!script.exists) return err("${script} not found") + + // compile the script + emit.withScript(this.compile) + + // write modules + emit.writePackageJson(["name":"scriptRunner", "main":"scriptRunner.js"]) + emit.writeNodeModules + + // generate scriptRunner.js + template := this.typeof.pod.file(`/res/scriptRunnerTemplate.js`).readAllStr + template = template.replace("//{{include}}", emit.includeStatements) + template = template.replace("{{tempPod}}", tempPod) + template = template.replace("//{{envDirs}}", emit.envDirs) + + f := this.dir.plus(`scriptRunner.js`) + f.out.writeChars(template).flush.close + + // invoke node to run the script + Process(["node", "${f.normalize.osPath}"]).run.join + + if (!keep) this.dir.delete + + return 0 + } + + private File compile() + { + input := CompilerInput() + input.podName = tempPod + input.summary = "" + input.version = Version("0") + input.log.level = LogLevel.silent + input.isScript = true + input.srcStr = this.script.in.readAllStr + input.srcStrLoc = Loc("") + input.mode = CompilerInputMode.str + input.output = CompilerOutputMode.transientPod + + // compile the source + compiler := Compiler(input) + CompilerOutput? co := null + try co = compiler.compile; catch {} + if (co == null) + { + buf := StrBuf() + compiler.errs.each |err| { buf.add("$err.line:$err.col:$err.msg\n") } + throw Err.make(buf.toStr) + } + + // configure the dependencies + emit.withDepends(compiler.depends.map { Pod.find(it.name) }) + + // return generated js + return compiler.cjs.toBuf.toFile(`${tempPod}.js`) + } +} + diff --git a/src/nodeJs/fan/cmd/TestCmd.fan b/src/nodeJs/fan/cmd/TestCmd.fan new file mode 100644 index 000000000..567e63253 --- /dev/null +++ b/src/nodeJs/fan/cmd/TestCmd.fan @@ -0,0 +1,110 @@ +// +// Copyright (c) 2023, SkyFoundry LLC +// All Rights Reserved +// +// History: +// 27 Jul 2023 Matthew Giannini Creation +// + +using util + +internal class TestCmd : NodeJsCmd +{ + override Str name() { "test" } + + override Str summary() { "Run tests in Node.js" } + + @Opt { help = "Don't delete Node.js environment when done" } + Bool keep + + @Arg { help = "[::[.]]" } + Str? spec + + override Int run() + { + if (!checkForNode) return 1 + + pod := this.spec + type := "*" + method := "*" + + // check for type + if (pod.contains("::")) + { + i := pod.index("::") + type = pod[i+2..-1] + pod = pod[0..i-1] + } + + // check for method + if (type.contains(".")) + { + i := type.index(".") + method = type[i+1..-1] + type = type[0..i-1] + } + + p := Pod.find(pod) + emit.withDepends([p]) + emit.writePackageJson(["name":"testRunner", "main":"testRunner.js"]) + emit.writeNodeModules + testRunner(p, type, method) + + if (!keep) this.dir.delete + + return 0 + } + + private Void testRunner(Pod pod, Str type, Str method) + { + template := this.typeof.pod.file(`/res/testRunnerTemplate.js`).readAllStr + template = template.replace("//{{include}}", emit.includeStatements) + template = template.replace("//{{tests}}", testList(pod, type, method)) + template = template.replace("//{{envDirs}}", emit.envDirs) + + // write test runner + f := this.dir.plus(`testRunner.js`) + f.out.writeChars(template).flush.close + + // invoke node to run tests + t1 := Duration.now + Process(["node", "${f.normalize.osPath}"]).run.join + t2 := Duration.now + + printLine + printLine("Time: ${(t2-t1).toLocale}") + printLine + } + + private Str testList(Pod pod, Str type, Str method) + { + buf := StrBuf() + buf.add("const tests = [\n") + + types := type == "*" ? pod.types : [pod.type(type)] + types.findAll { it.fits(Test#) && it.hasFacet(Js#) }.each |t| + { + buf.add(" {'type': ${pod.name}.${t.name},\n") + .add(" 'qname': '${t.qname}',\n") + .add(" 'methods': [") + methods(t, method).each { buf.add("'${it.name}',") } ; buf.add("]\n") + buf.add(" },\n") + } + return buf.add("];\n").toStr + } + + private static Method[] methods(Type type, Str methodName) + { + return type.methods.findAll |Method m->Bool| + { + if (m.isAbstract) return false + if (m.name.startsWith("test")) + { + if (methodName == "*") return true + return methodName == m.name + } + return false + } + } + +} \ No newline at end of file diff --git a/src/nodeJs/res/scriptRunnerTemplate.js b/src/nodeJs/res/scriptRunnerTemplate.js new file mode 100644 index 000000000..0065503ee --- /dev/null +++ b/src/nodeJs/res/scriptRunnerTemplate.js @@ -0,0 +1,19 @@ +const __require = (typeof require !== 'undefined') ? require : null; + +//{{include}} + +try { +//{{envDirs}} + {{tempPod}}.Main.main(); +} catch (err) { + console.log('ERROR: ' + err + '\n'); + console.log(err.stack); + if (err == undefined) print('Undefined error\n'); + else if (err.trace) err.trace(); + else + { + var file = err.fileName; if (file == null) file = 'Unknown'; + var line = err.lineNumber; if (line == null) line = 'Unknown'; + sys.Env.cur().out().printLine(err + ' (' + file + ':' + line + ')\n'); + } +} \ No newline at end of file diff --git a/src/nodeJs/res/testRunnerTemplate.js b/src/nodeJs/res/testRunnerTemplate.js new file mode 100644 index 000000000..ed9a36a2d --- /dev/null +++ b/src/nodeJs/res/testRunnerTemplate.js @@ -0,0 +1,80 @@ +const __require = (typeof require !== 'undefined') ? require : null; + +//{{include}} + +//{{tests}} + +let methodCount = 0; +let totalVerifyCount = 0; +let failures = 0; +let failureNames = []; + +const testRunner = function(type, method) +{ + let test; + const doCatchErr = function(err) + { + if (err == undefined) print('Undefined error\n'); + else if (err.trace) err.trace(); + else + { + let file = err.fileName; if (file == null) file = 'Unknown'; + let line = err.lineNumber; if (line == null) line = 'Unknown'; + // sys.Env.cur().out().printLine(err + ' (' + file + ':' + line + ')\n'); + console.log(err + ' (' + file + ':' + line + ')\n'); + } + } + + try + { +//{{envDirs}} + test = type.make(); + test.setup(); + test[method](); + return test.verifyCount$(); + } + catch (err) + { + doCatchErr(err); + return -1; + } + finally + { + try { test.teardown(); } + catch (err) { doCatchErr(err); } + } +} + +tests.forEach(function (test) { + console.log(''); + test.methods.forEach(function (method) { + var qname = test.qname + '.' + method; + var verifyCount = -1; + console.log('-- Run: ' + qname + '...'); + verifyCount = testRunner(test.type, method); + if (verifyCount < 0) { + failures++; + failureNames.push(qname); + } else { + console.log(' Pass: ' + qname + ' [' + verifyCount + ']'); + methodCount++; + totalVerifyCount += verifyCount; + } + }); +}); + +if (failureNames.length > 0) { + console.log(''); + console.log("Failed:"); + failureNames.forEach(function (qname) { + console.log(' ' + qname); + }); + console.log(''); +} + +console.log(''); +console.log('***'); +console.log('*** ' + + (failures == 0 ? 'All tests passed!' : '' + failures + ' FAILURES') + + ' [' + tests.length + ' tests , ' + methodCount + ' methods, ' + totalVerifyCount + ' verifies]'); +console.log('***');