diff --git a/generate/index.js b/generate/index.js index 139c0df..6550db1 100644 --- a/generate/index.js +++ b/generate/index.js @@ -4,6 +4,7 @@ var fse = require('fs-extra'); var glob = require('glob').sync; var generatedData = require('./lib/generated_data'); var writeApiDocs = require('./lib/write_api_docs'); +var writeTsDeclarations = require('./lib/write_ts_declarations'); var addConvenienceMethods = require('./lib/add_convenience_methods.js'); var idefsPath = 'generate/nodegit/generate/output/idefs.json'; @@ -14,6 +15,7 @@ var fullData; addConvenienceMethods(baseData).then(function(fullData) { writeApiDocs(fullData); + writeTsDeclarations(fullData); }); // Copy convenience methods in. diff --git a/generate/lib/write_ts_declarations.js b/generate/lib/write_ts_declarations.js new file mode 100644 index 0000000..2fe67a8 --- /dev/null +++ b/generate/lib/write_ts_declarations.js @@ -0,0 +1,332 @@ +/** + * Consumes the API in JSON format, and produces a TypeScript declaration file. + * @author John Vilk + */ +var fs = require('fs-extra'); +var Path = require('path'); + +var writeTsDecls = function(apiData, path) { + var path = path || ''; + path = ("/" + path + "/").replace(/\/+/g, '/'); + + /** + * Given a type from the API docs, produce a TypeScript type. + */ + function getType(type) { + // Remove spaces. + type = type.replace(/\s/g, ''); + // Convert [] to Array<> for generics. + var sqBracketIndex = type.indexOf('['); + if (sqBracketIndex !== -1) { + return "Array<" + getType(type.slice(0, sqBracketIndex)) + ">"; + } + + // If type has < in it already, translate generic argument. + var angleIndex = type.indexOf('<'); + if (angleIndex !== -1) { + type = type.slice(0, angleIndex) + '<' + getType(type.slice(angleIndex + 1, type.indexOf('>'))) + ">"; + } + + switch (type) { + // Weird things. Prune items as we fix docs. + case 'obj': + case 'ConvenientHunk': + case 'lineStats': + case 'RevWalk': + case 'StatusFile': + case 'historyEntry': + case 'DiffList': + return 'any'; + // Untyped function callbacks. + case 'CheckoutNotifyCb': + case 'CheckoutPerfdataCb': + case 'CheckoutProgressCb': + case 'DiffFileCb': + case 'DiffBinaryCb': + case 'DiffHunkCb': + case 'DiffLineCb': + case 'DiffNotifyCb': + case 'CredAcquireCb': + case 'FetchheadForeachCb': + case 'FilterStreamFn': + case 'IndexMatchedPathCb': + case 'NoteForeachCb': + case 'StashCb': + case 'StashApplyProgressCb': + case 'StatusCb': + case 'SubmoduleCb': + case 'TransferProgressCb': + case 'TransportCb': + case 'TransportCertificateCheckCb': + return 'Function'; + // Primitives + case 'String': + return 'string'; + case 'Char': + case 'int': + case 'Number': + return 'number'; + case 'Void': + return 'void'; + case 'bool': + return 'boolean'; + case 'Array': + return 'Array'; + // Avoiding type collusions + case 'Object': + return 'GitObject'; + case 'Blob': + return 'GitBlob'; + // NodeJS types + case 'EventEmitter': + return 'NodeJS.EventEmitter'; + default: + var dotIndex = type.indexOf('.'); + if (dotIndex !== -1) { + if (type[dotIndex + 1] === '<') { + // Sometimes, the docs include a '.' between the type and the generic type argument. + return type.replace(/\./g, ''); + } else { + // Remove '.' from types (e.g. Reference.Type => ReferenceType) as + // we make them part of the outer scope. + // Also, convert the owner of the type properly (e.g. Object.TYPE => GitObjectTYPE). + return getType(type.slice(0, dotIndex)) + type.slice(dotIndex + 1); + } + } else { + // Check for weird things. If there are weird things, punt with 'any'. + if (type.indexOf('(') !== -1 || type.indexOf(':') !== -1) { + return 'any'; + } + + return type; + } + } + } + + /** + * Converts a block of text into a block of JSDoc. Removes empty lines. + */ + function textToJSDoc(text) { + var lines = text.split('\n'); + // Strip empty lines, and begin non-empty lines with " * " + lines = lines.filter(function(line) { + return line.trim() !== ""; + }).map(function(line) { + return " * " + line; + }); + + return "/**\n" + lines.join("\n") + "\n */"; + } + + /** + * Strip illegal characters from identifiers. + */ + function fixIdentifier(name) { + return name.replace(/[\[\]]/g, ''); + } + + /** + * Given a function, returns a function signature. + */ + function getFunctionSignature(name, fcn, params, isStatic) { + var fcnSig = isStatic ? 'public static' : 'public'; + fcnSig += " " + name + "(" + + params.map(function(param) { + var paramName = fixIdentifier(param.name); + // Make each param type a union type if multiple types. + return paramName + (param.optional ? "?" : "") + ": " + (param.types.map(function(type) { return getType(type); }).join(" | ")); + }).join(', ') + "): "; + var returnType = fcn.return ? getType(fcn.return.type) : "void"; + if (fcn.isAsync) { + returnType = "PromiseLike<" + returnType + ">"; + } + return fcnSig + returnType + ";" + } + + /** + * Returns an array of parameter permutations to a function. + */ + function getParamPermutations(params) { + // Figure out which parameters are optional. + var optionalParams = [] + var nonOptionalAfterOptional = false; + var i; + for (i = 0; i < params.length; i++) { + if (params[i].optional) { + optionalParams.push(i); + } else if (optionalParams.length > 0) { + nonOptionalAfterOptional = true; + } + } + + // If no non-optional functions follow optional functions, + // we only need one function signature [common case]. + if (!nonOptionalAfterOptional) { + return [params]; + } else { + var rv = []; + + // Only toggle the ones in the middle. Ignore those at the + // end -- they are benign. + for (i = params.length - 1; i >= 0; i--) { + if (optionalParams[optionalParams.length - 1] === i) { + optionalParams.pop(); + } else { + break; + } + } + + var numOptionalParams = optionalParams.length; + var state = new Array(numOptionalParams); + for (i = 0; i < numOptionalParams; i++) { + state[i] = 0; + } + outer: + while(true) { + // 'Clone' the params object. + var paramVariant = JSON.parse(JSON.stringify(params)); + // Remove 'disabled' optional params, from r2l to avoid messing with + // array indices. + for (i = numOptionalParams - 1; i >= 0; i--) { + var optionalIndex = optionalParams[i]; + if (state[i]) { + paramVariant.splice(optionalIndex, 1); + } else { + paramVariant[optionalIndex].optional = false; + } + } + rv.push(paramVariant); + + // Add '1' to 'state', from L2R. + var digit = 0; + while (state[digit] === 1) { + if ((digit + 1) < numOptionalParams) { + state[digit] = 0; + digit++; + } else { + break outer; + } + } + state[digit] = 1; + } + return rv.reverse(); + } + } + + /** + * Converts a function in JSON format into a TypeScript function + * declaration. + */ + function getFunctionDeclaration(name, fcn, isStatic) { + var jsDoc = ""; + if (fcn.experimental) { + jsDoc += "[EXPERIMENTAL] "; + } + if (fcn.description !== "") { + jsDoc += fcn.description + "\n"; + } + + var params = fcn.params; + params.map(function(param) { + var paramName = fixIdentifier(param.name); + jsDoc += "\n@param " + paramName + " "; + // Apparently param.description can be null, so check that it's not before looking at the contents! + if (param.description && param.description.trim() !== "") { + // Indent secondary lines of the description. + jsDoc += param.description.replace(/\n/g, '\n ') + "\n"; + } + }); + + var fcnDesc = fcn.return ? fcn.return.description.replace(/\n/g, '\n ') : ''; + + if (fcnDesc.trim() !== "") { + jsDoc += "\n@return " + fcnDesc; + } + + var paramPermutations = getParamPermutations(fcn.params); + return textToJSDoc(jsDoc) + "\n" + paramPermutations.map(function(params) { return getFunctionSignature(name, fcn, params, isStatic); }).join("\n"); + } + + /** + * Convert an enum into a TypeScript const enum declaration. + */ + function getEnumDeclaration(className, enumName, enumData) { + var exportName = className + enumName; + var enumFields = " " + Object.keys(enumData).sort().map(function(enumType) { + return enumType + " = " + enumData[enumType]; + }).join(",\n "); + return "declare enum " + exportName + " {\n" + enumFields + "\n}"; + } + + /** + * Indents + concatenates an array of lines. + */ + function indentLines(text, indentation) { + return text.join("\n").replace(/^/gm, indentation); + } + + // Array of TypeScript declarations. + var decls = []; + // Map from export name => class name + var nameMap = {}; + + Object.keys(apiData).sort().forEach(function(exportName) { + var className = getType(exportName); + var classData = apiData[exportName]; + var classDecl = ""; + + // Export specially only if we have to remap the name to avoid type collisions. + if (className !== exportName) { + nameMap[exportName] = className; + classDecl = "declare "; + } else { + classDecl = "export "; + } + // There's no description for actual classes. Jump right into a definition. + classDecl += "class " + className + " {\n" + + var staticMethods = Object.keys(classData.constructors).sort().map(function(name) { + return getFunctionDeclaration(name, classData.constructors[name], true); + }); + + var instanceMethods = Object.keys(classData.prototypes).sort().map(function(name) { + return getFunctionDeclaration(name, classData.prototypes[name], false); + }); + + // Fields + // HACK: Filter out fields that clash with declared methods. This is a bug in the doc script. + var fields = Object.keys(classData.fields).sort().filter(function(name) { + return !classData.prototypes[name]; + }).map(function(name) { + // Fields do not have descriptions. + return "public " + fixIdentifier(name) + ": " + getType(classData.fields[name]); + }); + + // Enums (static fields) + var enumFields = Object.keys(classData.enums).sort().map(function(name) { + // Add to decl list. They are self-exporting. + decls.push(getEnumDeclaration(className, name, classData.enums[name])); + + // Export on class, too. + return "public static " + name + ": typeof " + className + name + ";"; + }); + + var indent = " "; + classDecl += indentLines(enumFields, indent) + "\n" + indentLines(staticMethods, indent) + "\n" + indentLines(fields, indent) + "\n" + indentLines(instanceMethods, indent) + "\n" + + "}"; + decls.push(classDecl); + }); + + var tsDeclFile = "// Type definitions for nodegit\n// Project: http://www.nodegit.org/\n// Definitions by: John Vilk \n\n"; + + tsDeclFile += decls.join("\n\n"); + + tsDeclFile += "\n\nexport { " + Object.keys(nameMap).sort().map(function(exportName) { + return nameMap[exportName] + " as " + exportName; + }).join(", ") + " }\n"; + + fs.removeSync(Path.join(process.cwd(), path, 'ts')); + fs.outputFileSync('.' + path + 'ts/nodegit.d.ts', tsDeclFile); +}; + +module.exports = writeTsDecls; \ No newline at end of file