diff --git a/src/nodeJs/fan/ts/CompileTsPlugin.fan b/src/nodeJs/fan/ts/CompileTsPlugin.fan new file mode 100644 index 000000000..c355e1bd5 --- /dev/null +++ b/src/nodeJs/fan/ts/CompileTsPlugin.fan @@ -0,0 +1,295 @@ +// +// Copyright (c) 2023, Brian Frank and Andy Frank +// Licensed under the Academic Free License version 3.0 +// +// History: +// 27 Apr 2023 Matthew Giannini Creation +// 2 Jun 2023 Kiera O'Flynn Implemented +// + +using compiler +using compilerEs +using fandoc + +** +** Generate TypeScript declaration file for a pod +** +class CompileTsPlugin : CompilerStep +{ + new make(Compiler compiler) : super(compiler) + { + this.c = compiler + docParser = FandocParser() + } + + private Compiler c + private OutStream? out + private FandocParser docParser + private TsDocWriter? docWriter + +////////////////////////////////////////////////////////////////////////// +// Main writing method +////////////////////////////////////////////////////////////////////////// + + override Void run() + { + buf := Buf() + out = buf.out + + // Write dependencies + deps := pod.depends.map |CDepend dep->Str| { dep.name } + docWriter = TsDocWriter(out, deps) + + deps.each |dep| + { + out.print("import * as ${dep} from './${dep}.js';\n") + } + if (pod.name == "sys") printJsObj + out.write('\n') + + // Write declaration for each type + pod.typeDefs.findAll { !it.isSynthetic }.each |type| + { + // TODO: for now generate declaration for all types regardless of whether + // they have the @Js facet or not + // if (!type.hasFacet(jsFacet)) return + if (type.isInternal) return + if (type.signature == "sys::Func") return + + setupDoc(pod.name, type.name) + + // Parameterization of List & Map + classParams := "" + if (type.signature == "sys::List") + classParams = "" + if (type.signature == "sys::Map") + classParams = "" + + abstr := type.isMixin ? "abstract " : "" + extends := "" + if (type.base != null) + extends = "extends ${getNamespacedType(type.base.name, type.base.pod.name, pod)} " + if (!type.mixins.isEmpty) + { + implement := type.mixins.map { getNamespacedType(it.name, it.pod.name, this.pod) }.join(", ") + extends += "implements $implement " + } + + // Write class documentation & header + printDoc(type.doc, 0) + out.print("export ${abstr}class $type.name$classParams $extends{\n") + + hasItBlockCtor := type.ctors.any |CMethod m->Bool| { + m.params.any |CParam p->Bool| { p.paramType.isFunc } + } + + // Write fields + fields := type.fields.findAll |field| + { + field.isPublic && + (field is FieldDef || + (type.mixins.any |m| { m.slot(field.name)?.isPublic == true } && + type.base?.slot(field.name) == null)) + } + fields.each |field| + { + name := JsNode.pickleName(field.name, deps) + staticStr := field.isStatic ? "static " : "" + typeStr := getJsType(field.fieldType, pod, field.isStatic ? type : null) + + if (field is FieldDef) + printDoc(field->doc, 2) + + out.print(" $staticStr$name(): $typeStr\n") + if (!field.isConst) + out.print(" $staticStr$name(it: $typeStr): void\n") + else if (hasItBlockCtor) + out.print(" ${staticStr}__$name(it: $typeStr): void\n") + } + + // Write methods + methods := type.methods.findAll |method| + { + method.isPublic && + (method is MethodDef || + (type.mixins.any |m| { m.slot(method.name)?.isPublic == true } && + type.base?.slot(method.name) == null)) + } + methods.each |method| + { + isStatic := method.isStatic || method.isCtor || pmap.containsKey(type.signature) + staticStr := isStatic ? "static " : "" + name := JsNode.pickleName(method.name, deps) + + inputList := method.params.map |CParam p->Str| { + paramName := JsNode.pickleName(p.name, deps) + if (p.hasDefault) + paramName += "?" + paramType := getJsType(p.paramType, pod, isStatic ? type : null) + return "$paramName: $paramType" + } + if (!method.isStatic && !method.isCtor && pmap.containsKey(type.signature)) + inputList.insert(0, "self: ${pmap[type.signature]}") + if (method.isCtor) + inputList.add("...args: unknown[]") + inputs := inputList.join(", ") + + output := method.isCtor ? type.name : getJsType(method.returnType, pod, pmap.containsKey(type.signature) ? type : null) + if (method.qname == "sys::Obj.toImmutable" || + method.qname == "sys::List.ro" || + method.qname == "sys::Map.ro") + output = "Readonly<$output>" + + if (method is MethodDef) + printDoc(method->doc, 2) + out.print(" $staticStr$name($inputs): $output\n") + } + + out.print("}\n") + } + if (pod.name == "sys") printObjUtil + + buf.seek(0) + c.tsDecl = buf.readAllStr + } + +////////////////////////////////////////////////////////////////////////// +// Utils +////////////////////////////////////////////////////////////////////////// + + ** Gets the name of the given type in JS. For example, a map type + ** could show up as Map, sys.Map, Map, etc. + ** + ** 'thisPod' is the pod you are writing the type in; if 'type' is + ** from a different pod, it will have its pod name prepended to it, + ** e.g. sys.Map rather than just Map. + ** + ** 'thisType' should only be non-null if instances of sys::This should + ** be written as that type instead of "this". For example, Int methods + ** which are non-static in Fantom but static in JS cannot use the "this" + ** type. + private Str getJsType(CType type, CPod thisPod, CType? thisType := null) + { + // Built-in type + if (pmap.containsKey(type.signature)) + return pmap[type.signature] + + // Nullable type + if (type.isNullable) + return "${getJsType(type.toNonNullable, thisPod, thisType)} | null" + + // This + if (type.isThis) + return thisType == null ? "this" : thisType.name + + // Generic parameters + if (type.isGenericParameter) + switch (type.name) + { + case "L": return "List" + case "M": return "Map" + } + + // List/map types + if (type.isList || type.isMap) + { + if (type is TypeRef) type = type.deref + + res := getNamespacedType(type.name, "sys", thisPod) + if (!type.isGeneric) + { + k := type is MapType ? "${getJsType(type->k, thisPod, thisType)}, " : "" + v := getJsType(type->v, thisPod, thisType) + res += "<$k$v>" + } + return res + } + + // Function types + if (type.isFunc) + { + if (type is TypeRef) type = type.deref + if (!(type is FuncType)) //isGeneric + return "Function" + + CType[] args := type->params->dup + inputs := args.map |CType t, Int i->Str| { "arg$i: ${getJsType(t, thisPod, thisType)}" } + .join(", ") + output := getJsType(type->ret, thisPod, thisType) + return "(($inputs) => $output)" + } + + // Obj + if (type.signature == "sys::Obj") + return getNamespacedType("JsObj", "sys", thisPod) + + // Regular types + return getNamespacedType(type.name, type.pod.name, thisPod) + } + + ** Gets the name of the type with, when necessary, the pod name prepended to it. + ** e.g. could return "TimeZone" or "sys.TimeZone" based on the current pod. + private Str getNamespacedType(Str typeName, Str typePod, CPod currentPod) + { + if (typePod == currentPod.name) + return typeName + return "${typePod}.${typeName}" + } + + private Void setupDoc(Str pod, Str type) + { + docWriter.pod = pod + docWriter.type = type + } + + private Void printDoc(DocDef? doc, Int indent) + { + if (doc == null) return + + docWriter.indent = indent + docParser.parse("Doc", doc.lines.join("\n").in).write(docWriter) + } + + private Void printJsObj() + { + out.print("export type JsObj = Obj | number | string | boolean | Function\n") + } + + private Void printObjUtil() + { + out.print( """export class ObjUtil { + static hash(obj: any): number + static equals(a: any, b: JsObj | null): boolean + static compare(a: any, b: JsObj | null, op?: boolean): number + static compareNE(a: any, b: JsObj | null): boolean + static compareLT(a: any, b: JsObj | null): boolean + static compareLE(a: any, b: JsObj | null): boolean + static compareGE(a: any, b: JsObj | null): boolean + static compareGT(a: any, b: JsObj | null): boolean + static is(obj: any, type: Type): boolean + static as(obj: any, type: Type): any + static coerce(obj: any, type: Type): any + static typeof\$(obj: any): Type + static trap(obj: any, name: string, args: List | null): JsObj | null + static doTrap(obj: any, name: string, args: List | null, type: Type): JsObj | null + static isImmutable(obj: any): boolean + static toImmutable(obj: any): JsObj | null + static with\$(self: T, f: (() => T)): T + static toStr(obj: any): string + static echo(obj: any): void + } + """) + } + + private const Str:Str pmap := + [ + "sys::Bool": "boolean", + "sys::Decimal": "number", + "sys::Float": "number", + "sys::Int": "number", + "sys::Num": "number", + "sys::Str": "string", + "sys::Void": "void" + ] + +} \ No newline at end of file diff --git a/src/nodeJs/fan/ts/TsDocWriter.fan b/src/nodeJs/fan/ts/TsDocWriter.fan new file mode 100644 index 000000000..0d2f6e6de --- /dev/null +++ b/src/nodeJs/fan/ts/TsDocWriter.fan @@ -0,0 +1,358 @@ +// +// Copyright (c) 2023, Brian Frank and Andy Frank +// Licensed under the Academic Free License version 3.0 +// +// History: +// 24 Jun 23 Kiera O'Flynn Creation +// + +using compilerEs +using fandoc + +** +** Writes Fantom documentation in Typescript markdown style. +** +class TsDocWriter : DocWriter +{ + Int indent := 0 + { set { ind = " " * it; &indent = it } } + private Str ind := "" + + Str pod := "" + Str type := "" + + private OutStream out + private Str[] deps + + private Bool started + private ListIndex[] listIndexes := [,] + private Bool inPre + private Bool inBlockquote + + private Int lineWidth := 0 + private const Int maxWidth := 60 + + new make(OutStream out, Str[] deps) + { + this.out = out + this.deps = deps + } + + ** + ** Enter a document. + ** + override Void docStart(Doc doc) + { + out.print("$ind/**\n$ind * ") + started = false + } + + ** + ** Exit a document. + ** + override Void docEnd(Doc doc) + { + out.print("\n$ind */\n") + } + + ** + ** Enter an element. + ** + override Void elemStart(DocElem elem) + { + switch (elem.id) + { + case DocNodeId.doc: + return + + case DocNodeId.emphasis: + out.writeChar('*') + + case DocNodeId.strong: + out.print("**") + + case DocNodeId.code: + out.writeChar('`') + + case DocNodeId.link: + link := (Link) elem + onLink(link) + if (link.isCode) + out.print("{@link $link.uri | ") + else + out.writeChar('[') + + case DocNodeId.image: + img := (Image) elem + str := "![${img.alt}" + lineWidth += str.size + out.print(str) + + case DocNodeId.heading: + head := (Heading) elem + printLine + printLine + out.print(Str.defVal.padl(head.level, '#')).writeChar(' ') + + case DocNodeId.para: + para := (Para) elem + if (!listIndexes.isEmpty) + { + indent := listIndexes.size * 2 + printLine + printLine + out.print(Str.defVal.padl(indent)) + } + else if (started) + { + printLine + printLine + } + + if (inBlockquote) + out.print("> ") + if (para.admonition != null) + out.print("${para.admonition}: ") + lineWidth = 0 + + case DocNodeId.pre: + printLine + out.print("```") + printLine + inPre = true + + case DocNodeId.blockQuote: + inBlockquote = true + + case DocNodeId.unorderedList: + listIndexes.push(ListIndex()) + + case DocNodeId.orderedList: + ol := (OrderedList) elem + listIndexes.push(ListIndex(ol.style)) + + case DocNodeId.listItem: + printLine + indent := (listIndexes.size - 1) * 2 + out.print(Str.defVal.padl(indent)) + out.print(listIndexes.peek) + listIndexes.peek.increment + lineWidth = 0 + + case DocNodeId.hr: + printLine + printLine + out.print("---") + } + started = true + } + + ** + ** Exit an element. + ** + override Void elemEnd(DocElem elem) + { + switch (elem.id) + { + case DocNodeId.emphasis: + out.writeChar('*') + + case DocNodeId.strong: + out.print("**") + + case DocNodeId.code: + out.writeChar('`') + + case DocNodeId.link: + link := (Link) elem + if (link.isCode) + { + lineWidth += "{@link $link.uri | ".size + out.writeChar('}') + } + else + { + str := "](${link.uri})" + lineWidth += str.size + out.print(str) + } + + case DocNodeId.image: + img := (Image) elem + str := "](${img.uri})" + lineWidth += str.size + out.print(str) + + case DocNodeId.pre: + printLine + out.print("```") + inPre = false + + case DocNodeId.blockQuote: + inBlockquote = false + + case DocNodeId.unorderedList: + case DocNodeId.orderedList: + listIndexes.pop + } + } + + ** + ** Write text node. + ** + override Void text(DocText text) + { + if (inPre) + // just print + return out.print(text.toStr.replace("\n", "\n$ind * ").replace("*/", "*\\/")) + + // Otherwise, make line breaks + innerInd := "" + if (!listIndexes.isEmpty) + innerInd = " " * (listIndexes.size * 2) + + str := (text.toStr + // Split into tokens by spaces + .split(' ') + // Collect tokens into lines + .reduce(Str[,]) |Str[] acc, Str s, Int i->Str[]| + { + if (i == 0) + { + // Beginning of text + lineWidth += s.size + acc.add(s) + } + else if (lineWidth + s.size >= maxWidth) + { + // New line in text block + lineWidth = s.size + acc.add(s) + } + else + { + // Continue existing line + newStr := acc.size > 1 && acc[-1] == "" ? s : " $s" + lineWidth += newStr.size + acc[-1] += newStr + } + return acc + } as Str[]) + // Combine lines + .join("\n") + .replace("\n", "\n$ind * $innerInd") + .replace("*/", "*\\/") + out.print(str) + } + + private Void printLine(Str line := "") + { + out.print("$line\n$ind * ") + } + + private Void onLink(Link link) + { + slotMatcher := Regex("(((.+)::)?(.+)\\.)?(.+)", "g").matcher(link.uri) + typeMatcher := Regex("((.+)::)?(.+)", "g").matcher(link.uri) + docMatcher := Regex("(doc.+)::(.+)", "g").matcher(link.uri) + + if (slotMatcher.matches) + { + p := slotMatcher.group(3) ?: pod + t := slotMatcher.group(4) ?: type + s := slotMatcher.group(5) + + if (Slot.find("$p::${t}.$s", false) != null) + { + s = JsNode.pickleName(s, deps) + if (p != pod) link.uri = "${p}.${t}.$s" + else if (t != type) link.uri = "${t}.$s" + else link.uri = s + + link.isCode = true + return + } + } + + if (typeMatcher.matches) + { + p := typeMatcher.group(2) ?: pod + t := typeMatcher.group(3) + + if (Type.find("$p::$t", false) != null) + { + if (p != pod) link.uri = "${p}.$t" + else link.uri = t + + link.isCode = true + return + } + } + + if (docMatcher.matches) + link.uri = "https://fantom.org/doc/${docMatcher.group(1)}/${docMatcher.group(2)}" + } + +} + +// Taken from FandocDocWriter.fan +internal class ListIndex +{ + private static const Int:Str romans := sortr([1000:"M", 900:"CM", 500:"D", 400:"CD", 100:"C", 90:"XC", 50:"L", 40:"XL", 10:"X", 9:"IX", 5:"V", 4:"IV", 1:"I"]) + + OrderedListStyle? style + Int index := 1 + + new make(OrderedListStyle? style := null) + { + this.style = style + } + + This increment() + { + index++ + return this + } + + override Str toStr() + { + switch (style) + { + case null: + return "- " + case OrderedListStyle.number: + return "${index}. " + case OrderedListStyle.lowerAlpha: + return "${toB26(index).lower}. " + case OrderedListStyle.upperAlpha: + return "${toB26(index).upper}. " + case OrderedListStyle.lowerRoman: + return "${toRoman(index).lower}. " + case OrderedListStyle.upperRoman: + return "${toRoman(index).upper}. " + } + throw Err("Unsupported List Style: $style") + } + + private static Str toB26(Int int) + { + int-- + dig := ('A' + (int % 26)).toChar + return (int >= 26) ? toB26(int / 26) + dig : dig + } + + private static Str toRoman(Int int) + { + l := romans.keys.find { it <= int } + return (int > l) ? romans[l] + toRoman(int - l) : romans[l] + } + + private static Int:Str sortr(Int:Str unordered) + { + // no ordered literal map... grr... + // https://fantom.org/forum/topic/1837#c14431 + sorted := [:] { it.ordered = true } + unordered.keys.sortr.each { sorted[it] = unordered[it] } + return sorted + } +} \ No newline at end of file