From fd6a3a75aaad6f9a6da023adea4e7ae72df289f2 Mon Sep 17 00:00:00 2001 From: John Vilk Date: Thu, 25 Aug 2016 18:39:26 -0400 Subject: [PATCH] TypeScript Definition Generator Adds a generator that produces TypeScript definition files for a JavaScript client. As with JavaScript, there are two generators: `tsd_types` and `tsd_client`. The produced definition files require TypeScript 2.0 at the minimum, as they rely on TypeScript tagged unions. Below, I will summarize how we map Stone types to TypeScript. TypeScript's basic types match JSDoc, so there is no difference from the `js_types` generator. Aliases are emitted as `type`s: ``` typescript type AliasName = ReferencedType; ``` Structs are emitted as `interface`s, which support inheritance. Thus, if a struct `A` extends struct `B`, it will be emitted as: ``` typescript interface A extends B { // fields go here } ``` Nullable fields and fields with default values are emitted as _optional_ fields. In addition, the generator adds a field description with the default field value, if the field has one: ``` typescript interface A { // Defaults to False recur?: boolean; } ``` Unions are emitted as a `type` that is the disjunction of all possible union variants (including those from parent types!). Each variant is emitted as an individual `interface`. ``` union Shape point square Float64 "The value is the length of a side." circle Float64 "The value is the radius." ``` ``` typescript interface ShapeCircle { .tag: 'circle'; circle: number; } interface ShapeSquare { .tag: 'square'; square: number; } interface ShapePoint { .tag: 'point'; } type Shape = ShapePoint | ShapeSquare | ShapeCircle; ``` TypeScript 2.0 supports [tagged union types](https://github.com/Microsoft/TypeScript/wiki/What%27s-new-in-TypeScript#tagged-union-types) like these ones, so the compiler should automatically infer the type of a shape when the developer writes code like so (and statically check that all cases are covered!): ``` typescript var shape: Shape = getShape(); switch (shape['.tag']) { case 'point': console.log('point'); break; case 'square': // Compiler knows this is a ShapeSquare, so .square field is visible. console.log('square ' + shape.square); break; // No 'circle' case! If developer enables the relevant compiler option, compilation will fail. } ``` Unfortunately, [there is a bug that prevents this from happening](https://github.com/Microsoft/TypeScript/issues/10530) when you use bracket notation to access a field. It will be fixed in TypeScript 2.1. Until then, developers will need to cast: ``` typescript var shape: Shape = getShape(); switch (shape['.tag']) { case 'point': console.log('point'); break; case 'square': console.log('square ' + ( shape).square); break; } ``` When a struct explicitly enumerates its subtypes, direct references to the struct will have a `.tag` field to indicate which subtype it is. Direct references to the struct's subtypes will omit this field. To capture this subtlety, the generator emits an interface that represents a direct reference to a struct with enumerated subtypes: ``` struct Resource union file File folder Folder path String struct File extends Resource ... struct Folder extends Resource ... ``` ``` typescript interface Resource { path: string; } interface File extends Resource { } interface Folder extends Resource { } interface ResourceReference extends Resource { '.tag': 'file' | 'folder'; } interface FileReference extends File { '.tag': 'file'; } interface FolderReference extends Folder { '.tag': 'folder'; } ``` Direct references to `Resource` will be typed as `FileReference | FolderReference | ResourceReference` if the union is open, or `FileReference | FolderReference` if the union is closed. A direct reference to `File` will be typed as `File`, since the `.tag` field will not be present. TypeScript 2.0's tagged union support should work on these types once the previously-discussed bug is fixed. Nullable types are emitted as optional fields when referenced from structs. Routes are emitted in the same manner as the JavaScript generators, **except** that TypeScript's type system is unable to type `Promise`-based errors. The generator adds text to the route's documentation that explicitly mentions the data type the developer should expect when an error occurs. Example: ``` typescript type DropboxError = DropboxTypes.Error; db.filesListFolder({path: ''}).then((response) => { // TypeScript knows the type of response, so no type annotation is needed. }).catch( // Add explicit annotation on err. (err: DropboxError) => { }); ``` Stone namespaces are mapped directly to TypeScript namespaces: ``` namespace files; import common; struct Metadata parent_shared_folder_id common.SharedFolderId? ``` ``` typescript namespace files { interface Metadata { parent_shared_folder_id?: common.SharedFolderId; } } ``` Both `tsd_types` and `tsd_client` consume a template file, which contains a skeleton around the types they omit. This skeleton is unavoidable, as SDKs may augment SDK classes (like `Dropbox` or `DropboxTeam`) with additional methods not described in stone. The "templates" simply have a comment string that marks where the generator should insert code. For example, the following template has markers for route definitions and type definitions: ``` typescript class Dropbox { // This is an SDK-specific method which isn't described in stone. getClientId(): string; // All of the routes go here: /*ROUTES*/ } // All of the stone data types are defined here: /*TYPES*/ ``` In the above template, the developer would need to run the `tsd_types` generator to produce an output file, and then run the `tsd_client` generator on that output to insert the routes (or vice-versa). The developer may also choose to have separate template files for types and routes: ``` typescript // in types.d.ts namespace DropboxTypes { /*TYPES*/ } ``` ``` typescript /// // ^ this will "import" the types from the other file. // in dropbox.d.ts namespace DropboxTypes { class Dropbox { /*ROUTES*/ } } ``` Developers can customize the template string used for `tsd_client` with a command line parameter, in case they have multiple independent sets of routes: ``` typescript namespace DropboxTypes { class Dropbox { /*ROUTES*/ } class DropboxTeam { /*TEAM_ROUTES*/ } } ``` For Dropbox's JavaScript SDK, I've defined the following templates. **dropbox.d.tstemplate**: Contains a template for the `Dropbox` class. ``` typescript /// declare module DropboxTypes { class Dropbox extends DropboxBase { /** * The Dropbox SDK class. */ constructor(options: DropboxOptions); /*ROUTES*/ } } ``` **dropbox_team.d.tstemplate**: Contains a template for the `DropboxTeam` class. ``` typescript /// /// declare module DropboxTypes { class DropboxTeam extends DropboxBase { /** * The DropboxTeam SDK class. */ constructor(options: DropboxOptions); /** * Returns an instance of Dropbox that can make calls to user api endpoints on * behalf of the passed user id, using the team access token. Only relevant for * team endpoints. */ actAsUser(userId: string): Dropbox; /*ROUTES*/ } } ``` **dropbox_types.d.ts**: Contains a template for the Stone data types, as well as the `DropboxBase` class (which is shared by both `Dropbox` and `DropboxTeam`). ``` typescript declare module DropboxTypes { interface DropboxOptions { // An access token for making authenticated requests. accessToken?: string; // The client id for your app. Used to create authentication URL. clientId?: string; // Select user is only used by team endpoints. It specifies which user the team access token should be acting as. selectUser?: string; } class DropboxBase { /** * Get the access token. */ getAccessToken(): string; /** * Get a URL that can be used to authenticate users for the Dropbox API. * @param redirectUri A URL to redirect the user to after authenticating. * This must be added to your app through the admin interface. * @param state State that will be returned in the redirect URL to help * prevent cross site scripting attacks. */ getAuthenticationUrl(redirectUri: string, state?: string): string; /** * Get the client id */ getClientId(): string; /** * Set the access token used to authenticate requests to the API. * @param accessToken An access token. */ setAccessToken(accessToken: string): void; /** * Set the client id, which is used to help gain an access token. * @param clientId Your app's client ID. */ setClientId(clientId: string): void; } /*TYPES*/ } ``` Then, I defined simple definition files for each of the ways you package up the SDK, which references these types. These can be readily distributed alongside your libraries. `DropboxTeam-sdk.min.d.ts` (`DropboxTeam` class in a UMD module): ``` typescript /// export = DropboxTypes.DropboxTeam; export as namespace DropboxTeam; ``` `Dropbox-sdk.min.d.ts` (`Dropbox` class in a UMD module): ``` typescript /// export = DropboxTypes.Dropbox; export as namespace Dropbox; ``` `dropbox-sdk.js` (`Dropbox` class in a CommonJS module -- not sure why you distribute this when you have a UMD version!): ``` typescript /// export = DropboxTypes.Dropbox; ``` Finally, for your Node module, there's `src/index.d.ts` which goes alongside `src/index.js` and defines all of your Node modules together. After adding a `typings` field to `package.json` that points to `src/index`, the TypeScript compiler _automatically_ picks up the definitions from the NPM module: ``` typescript /// /// declare module "dropbox/team" { export = DropboxTypes.DropboxTeam; } declare module "dropbox" { export = DropboxTypes.Dropbox; } ``` To properly bundle things, I added a `typescript-copy.js` script that NPM calls when you run `npm run build`. The script simply copies the TypeScript typings to the `dist` folder. These are the files that must be maintained to provide complete TypeScript typings for all of your distribution methods. The files that are likely to change in the future are the templates, as you add/modify/remove SDK-specific interfaces. --- doc/builtin_generators.rst | 3 +- stone/cli.py | 2 + stone/target/tsd_client.py | 123 ++++++++++++ stone/target/tsd_helpers.py | 134 +++++++++++++ stone/target/tsd_types.py | 377 ++++++++++++++++++++++++++++++++++++ 5 files changed, 638 insertions(+), 1 deletion(-) create mode 100644 stone/target/tsd_client.py create mode 100644 stone/target/tsd_helpers.py create mode 100644 stone/target/tsd_types.py diff --git a/doc/builtin_generators.rst b/doc/builtin_generators.rst index 0319618e..dfc3ca01 100644 --- a/doc/builtin_generators.rst +++ b/doc/builtin_generators.rst @@ -37,7 +37,8 @@ command-line interface (CLI):: a generator module. Paths to generator modules must end with a .stoneg.py extension. The following generators are built-in: js_client, js_types, - python_types, python_client, swift_client + tsd_client, tsd_types, python_types, python_client, + swift_client output The folder to save generated files to. spec Path to API specifications. Each must have a .stone extension. If omitted or set to "-", the spec is read diff --git a/stone/cli.py b/stone/cli.py index 39cf757f..cb541cd2 100644 --- a/stone/cli.py +++ b/stone/cli.py @@ -23,6 +23,8 @@ _builtin_generators = ( 'js_client', 'js_types', + 'tsd_client', + 'tsd_types', 'python_types', 'python_client', 'swift_types', diff --git a/stone/target/tsd_client.py b/stone/target/tsd_client.py new file mode 100644 index 00000000..a6217fa7 --- /dev/null +++ b/stone/target/tsd_client.py @@ -0,0 +1,123 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +import argparse +import json +import os +import re +import six +import sys + +from stone.generator import CodeGenerator +from stone.target.tsd_helpers import ( + fmt_error_type, + fmt_func, + fmt_tag, + fmt_type, + fmt_union, +) + + +_cmdline_parser = argparse.ArgumentParser(prog='tsd-client-generator') +_cmdline_parser.add_argument( + 'template', + help=('A template to use when generating the TypeScript definition file.') +) +_cmdline_parser.add_argument( + 'filename', + help=('The name to give the single TypeScript definition file to contain ' + 'all of the emitted types.'), +) +_cmdline_parser.add_argument( + '-t', + '--template-string', + type=str, + default='ROUTES', + help=('The name of the template string to replace with route definitions. ' + 'Defaults to ROUTES, which replaces the string /*ROUTES*/ with route ' + 'definitions.') +) +_cmdline_parser.add_argument( + '-i', + '--indent-level', + type=int, + default=1, + help=('Indentation level to emit types at. Routes are automatically ' + 'indented one level further than this.') +) +_cmdline_parser.add_argument( + '-s', + '--spaces-per-indent', + type=int, + default=2, + help=('Number of spaces to use per indentation level.') +) + +_header = """\ +// Auto-generated by Stone, do not modify. +""" + +class TSDClientGenerator(CodeGenerator): + """Generates a TypeScript definition file with routes defined.""" + + cmdline_parser = _cmdline_parser + + preserve_aliases = True + + def generate(self, api): + spaces_per_indent = self.args.spaces_per_indent + indent_level = self.args.indent_level + template_path = os.path.join(self.target_folder_path, self.args.template) + template_string = self.args.template_string + + with self.output_to_relative_path(self.args.filename): + if os.path.isfile(template_path): + with open(template_path, 'r') as template_file: + template = template_file.read() + else: + exit('TypeScript template file does not exist.') + + # /*ROUTES*/ + r_match = re.search("/\*%s\*/" % (template_string), template) + if not r_match: + exit('Missing /*%s*/ in TypeScript template file.' % template_string) + + r_start = r_match.start() + r_end = r_match.end() + r_ends_with_newline = template[r_end - 1] == '\n' + t_end = len(template) + t_ends_with_newline = template[t_end - 1] == '\n' + + self.emit_raw(template[0:r_start] + ('\n' if not r_ends_with_newline else '')) + self._generate_routes(api, spaces_per_indent, indent_level) + self.emit_raw(template[r_end + 1 : t_end] + ('\n' if not t_ends_with_newline else '')) + + def _generate_routes(self, api, spaces_per_indent, indent_level): + with self.indent(dent=spaces_per_indent * (indent_level + 1)): + for namespace in api.namespaces.values(): + for route in namespace.routes: + self._generate_route( + api.route_schema, namespace, route) + + def _generate_route(self, route_schema, namespace, route): + function_name = fmt_func(namespace.name + '_' + route.name) + self.emit() + self.emit('/**') + if route.doc: + self.emit_wrapped_text(self.process_doc(route.doc, self._docf), prefix=' * ') + self.emit(' * ') + self.emit_wrapped_text('When an error occurs, the route rejects the promise with type %s.' % fmt_error_type(route.error_data_type), prefix = ' * ') + if route.deprecated: + self.emit(' * @deprecated') + + self.emit(' * @param arg The request parameters.') + self.emit(' */') + + self.emit('public %s(arg: %s): Promise<%s>;' % + (function_name, fmt_type(route.arg_data_type), + fmt_type(route.result_data_type))) + + def _docf(self, tag, val): + """ + Callback to process documentation references. + """ + return fmt_tag(None, tag, val) diff --git a/stone/target/tsd_helpers.py b/stone/target/tsd_helpers.py new file mode 100644 index 00000000..3f9ae6cf --- /dev/null +++ b/stone/target/tsd_helpers.py @@ -0,0 +1,134 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +import json +import six + +from stone.data_type import ( + Boolean, + Bytes, + Float32, + Float64, + Int32, + Int64, + List, + String, + Timestamp, + UInt32, + UInt64, + Void, + is_alias, + is_list_type, + is_struct_type, + is_user_defined_type, +) +from stone.target.helpers import ( + fmt_camel, +) + +_base_type_table = { + Boolean: 'boolean', + Bytes: 'string', + Float32: 'number', + Float64: 'number', + Int32: 'number', + Int64: 'number', + List: 'Array', + String: 'string', + UInt32: 'number', + UInt64: 'number', + Timestamp: 'Timestamp', + Void: 'void', +} + + + +def fmt_error_type(data_type, inside_namespace = None): + """ + Converts the error type into a TypeScript type. + inside_namespace should be set to the namespace that the reference + occurs in, or None if this parameter is not relevant. + """ + return 'Error<%s>' % fmt_type(data_type, inside_namespace) + +def fmt_type_name(data_type, inside_namespace = None): + """ + Produces a TypeScript type name for the given data type. + inside_namespace should be set to the namespace that the reference + occurs in, or None if this parameter is not relevant. + """ + if is_user_defined_type(data_type) or is_alias(data_type): + if data_type.namespace == inside_namespace: + return data_type.name + else: + return '%s.%s' % (data_type.namespace.name, data_type.name) + else: + fmted_type = _base_type_table.get(data_type.__class__, 'Object') + if is_list_type(data_type): + fmted_type += '<' + fmt_type(data_type.data_type, inside_namespace) + '>' + return fmted_type + +def fmt_polymorphic_type_reference(data_type, inside_namespace = None): + """ + Produces a TypeScript type name for the meta-type that refers to the given + struct, which belongs to an enumerated subtypes tree. This meta-type contains the + .tag field that lets developers discriminate between subtypes. + """ + # NOTE: These types are not properly namespaced, so there could be a conflict + # with other user-defined types. If this ever surfaces as a problem, we + # can defer emitting these types until the end, and emit them in a + # nested namespace (e.g., files.references.MetadataReference). + return fmt_type_name(data_type, inside_namespace) + "Reference" + +def fmt_type(data_type, inside_namespace = None): + """ + Returns a TypeScript type annotation for a data type. + May contain a union of enumerated subtypes. + inside_namespace should be set to the namespace that the type reference + occurs in, or None if this parameter is not relevant. + """ + if is_struct_type(data_type) and data_type.has_enumerated_subtypes(): + possible_types = [] + possible_subtypes = data_type.get_all_subtypes_with_tags() + for _, subtype in possible_subtypes: + possible_types.append(fmt_polymorphic_type_reference(subtype, inside_namespace)) + if data_type.is_catch_all(): + possible_types.append(fmt_polymorphic_type_reference(data_type, inside_namespace)) + return fmt_union(possible_types) + else: + return fmt_type_name(data_type, inside_namespace) + +def fmt_union(type_strings): + """ + Returns a union type of the given types. + """ + return '|'.join(type_strings) if len(type_strings) > 1 else type_strings[0]; + +def fmt_func(name): + return fmt_camel(name) + +def fmt_var(name): + return fmt_camel(name) + +def fmt_tag(cur_namespace, tag, val): + """ + Processes a documentation reference. + """ + if tag == 'type': + fq_val = val + if '.' not in val and cur_namespace != None: + fq_val = cur_namespace.name + '.' + fq_val + return fq_val + elif tag == 'route': + return fmt_func(val) + "()" + elif tag == 'link': + anchor, link = val.rsplit(' ', 1) + # There's no way to have links in TSDoc, so simply use JSDoc's formatting. + # It's entirely possible some editors support this. + return '[%s]{@link %s}' % (anchor, link) + elif tag == 'val': + # Value types seem to match JavaScript (true, false, null) + return val + elif tag == 'field': + return val + else: + raise RuntimeError('Unknown doc ref tag %r' % tag) diff --git a/stone/target/tsd_types.py b/stone/target/tsd_types.py new file mode 100644 index 00000000..2bbba9dc --- /dev/null +++ b/stone/target/tsd_types.py @@ -0,0 +1,377 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +import argparse +import json +import os +import re +import six +import sys + +from stone.data_type import ( + is_alias, + is_struct_type, + is_union_type, + is_user_defined_type, + is_void_type, + unwrap_nullable, +) +from stone.generator import CodeGenerator +from stone.target.helpers import ( + fmt_pascal, +) +from stone.target.tsd_helpers import ( + fmt_func, + fmt_polymorphic_type_reference, + fmt_tag, + fmt_type, + fmt_type_name, + fmt_union, +) + + +_cmdline_parser = argparse.ArgumentParser(prog='tsd-types-generator') +_cmdline_parser.add_argument( + 'template', + help=('A template to use when generating the TypeScript definition file. ' + 'Replaces the string /*TYPES*/ with stone type definitions.') +) +_cmdline_parser.add_argument( + 'filename', + help=('The name to give the single TypeScript definition file that contains ' + 'all of the emitted types.'), +) +_cmdline_parser.add_argument( + '-e', + '--extra-arg', + action='append', + type=str, + default=[], + help=("Additional argument to add to a route's argument based " + "on if the route has a certain attribute set. Format (JSON): " + '{"match": ["ROUTE_ATTR", ROUTE_VALUE_TO_MATCH], ' + '"arg_name": "ARG_NAME", "arg_type": "ARG_TYPE", ' + '"arg_docstring": "ARG_DOCSTRING"}'), +) +_cmdline_parser.add_argument( + '-i', + '--indent-level', + type=int, + default=1, + help=('Indentation level to emit types at. Routes are automatically ' + 'indented one level further than this.') +) +_cmdline_parser.add_argument( + '-s', + '--spaces-per-indent', + type=int, + default=2, + help=('Number of spaces to use per indentation level.') +) + +_header = """\ +// Auto-generated by Stone, do not modify. +""" + +_types_header = """\ +/** + * An Error object returned from a route. + */ +interface Error { +\t// Text summary of the error. +\terror_summary: string; +\t// The error object. +\terror: T; +\t// User-friendly error message. +\tuser_message: UserMessage; +} + +/** + * User-friendly error message. + */ +interface UserMessage { +\t// The message. +\ttext: string; +\t// The locale of the message. +\tlocale: string; +} + +type Timestamp = string; +""" + +class TSDTypesGenerator(CodeGenerator): + """Generates a single TypeScript definition file with all of the types defined.""" + + cmdline_parser = _cmdline_parser + + preserve_aliases = True + + cur_namespace = None + + def generate(self, api): + spaces_per_indent = self.args.spaces_per_indent + indent_level = self.args.indent_level + template_path = os.path.join(self.target_folder_path, self.args.template) + + with self.output_to_relative_path(self.args.filename): + extra_args = self._parse_extra_args(api, self.args.extra_arg) + if os.path.isfile(template_path): + with open(template_path, 'r') as template_file: + template = template_file.read() + else: + exit('TypeScript template file does not exist.') + + # /*TYPES*/ + t_match = re.search("/\*TYPES\*/", template) + if not t_match: + exit('Missing /*TYPES*/ in TypeScript template file.') + + t_start = t_match.start() + t_end = t_match.end() + t_ends_with_newline = template[t_end - 1] == '\n' + temp_end = len(template) + temp_ends_with_newline = template[temp_end - 1] == '\n' + + self.emit_raw(template[0:t_start] + ("\n" if not t_ends_with_newline else '')) + self._generate_types(api, spaces_per_indent, indent_level, extra_args) + self.emit_raw(template[t_end + 1 : temp_end] + ("\n" if not temp_ends_with_newline else '')) + + def _generate_types(self, api, spaces_per_indent, indent_level, extra_args): + indent = spaces_per_indent * indent_level + indent_spaces = (' '*indent) + with self.indent(dent = indent): + indented_types_header = indent_spaces + ('\n' + indent_spaces).join(_types_header.split('\n')).replace('\t', ' ' * spaces_per_indent) + self.emit_raw(indented_types_header + '\n') + self.emit() + + for namespace in api.namespaces.values(): + self.cur_namespace = namespace + # Count aliases as data types too! + data_types = namespace.data_types + namespace.aliases + # Skip namespaces that do not contain types. + if len(data_types) == 0: + continue; + + if namespace.doc: + self._emit_tsdoc_header(namespace.doc) + self.emit_wrapped_text('namespace %s {' % namespace.name) + + with self.indent(dent = spaces_per_indent): + for data_type in data_types: + self._generate_type(data_type, spaces_per_indent, extra_args.get(data_type, [])) + + self.emit('}') + self.emit() + + def _parse_extra_args(self, api, extra_args_raw): + """ + Parses extra arguments into a map keyed on particular data types. + """ + extra_args = {} + + for extra_arg_raw in extra_args_raw: + def exit(m): + print('Invalid --extra-arg:%s: %s' % (m, extra_arg_raw), + file=sys.stderr) + sys.exit(1) + + try: + extra_arg = json.loads(extra_arg_raw) + except ValueError as e: + exit(str(e)) + + # Validate extra_arg JSON blob + if 'match' not in extra_arg: + exit('No match key') + elif (not isinstance(extra_arg['match'], list) or + len(extra_arg['match']) != 2): + exit('match key is not a list of two strings') + elif (not isinstance(extra_arg['match'][0], six.text_type) or + not isinstance(extra_arg['match'][1], six.text_type)): + print(type(extra_arg['match'][0])) + exit('match values are not strings') + elif 'arg_name' not in extra_arg: + exit('No arg_name key') + elif not isinstance(extra_arg['arg_name'], six.text_type): + exit('arg_name is not a string') + elif 'arg_type' not in extra_arg: + exit('No arg_type key') + elif not isinstance(extra_arg['arg_type'], six.text_type): + exit('arg_type is not a string') + elif ('arg_docstring' in extra_arg and + not isinstance(extra_arg['arg_docstring'], six.text_type)): + exit('arg_docstring is not a string') + + attr_key, attr_val = extra_arg['match'][0], extra_arg['match'][1] + extra_args.setdefault(attr_key, {})[attr_val] = \ + (extra_arg['arg_name'], extra_arg['arg_type'], + extra_arg.get('arg_docstring')) + + # Extra arguments, keyed on data type objects. + extra_args_for_types = {} + # Locate data types that contain extra arguments + for namespace in api.namespaces.values(): + for route in namespace.routes: + extra_parameters = [] + if is_user_defined_type(route.arg_data_type): + for attr_key in route.attrs: + if attr_key not in extra_args: + continue + attr_val = route.attrs[attr_key] + if attr_val in extra_args[attr_key]: + extra_parameters.append(extra_args[attr_key][attr_val]) + if len(extra_parameters) > 0: + extra_args_for_types[route.arg_data_type] = extra_parameters + + return extra_args_for_types + + def _emit_tsdoc_header(self, docstring): + self.emit('/**') + self.emit_wrapped_text(self.process_doc(docstring, self._docf), prefix = ' * ') + self.emit(' */') + + def _generate_type(self, data_type, indent_spaces, extra_args): + """ + Generates a TypeScript type for the given type. + """ + if is_alias(data_type): + self._generate_alias_type(data_type) + elif is_struct_type(data_type): + self._generate_struct_type(data_type, indent_spaces, extra_args) + elif is_union_type(data_type): + self._generate_union_type(data_type, indent_spaces) + + def _generate_alias_type(self, alias_type): + """ + Generates a TypeScript type for a stone alias. + """ + namespace = alias_type.namespace + self.emit('type %s = %s;' % (fmt_type_name(alias_type, namespace), + fmt_type_name(alias_type.data_type, namespace))); + self.emit() + + def _generate_struct_type(self, struct_type, indent_spaces, extra_parameters): + """ + Generates a TypeScript interface for a stone struct. + """ + namespace = struct_type.namespace + if struct_type.doc: + self._emit_tsdoc_header(struct_type.doc) + parent_type = struct_type.parent_type + extends_line = ' extends %s' % fmt_type_name(parent_type, namespace) if parent_type else '' + self.emit('interface %s%s {' % (fmt_type_name(struct_type, namespace), extends_line)) + with self.indent(dent = indent_spaces): + + for param_name, param_type, param_docstring in extra_parameters: + if param_docstring: + self._emit_tsdoc_header(param_docstring) + self.emit('%s: %s;' % (param_name, param_type)) + + for field in struct_type.fields: + doc = field.doc + field_type, nullable = unwrap_nullable(field.data_type) + field_ts_type = fmt_type(field_type, namespace) + optional = nullable or field.has_default + if field.has_default: + # doc may be None. If it is not empty, add newlines + # before appending to it. + doc = doc + '\n\n' if doc else '' + doc = "Defaults to %s." % field.default + + if doc: + self._emit_tsdoc_header(doc) + # Translate nullable types into optional properties. + field_name = '%s?' % field.name if optional else field.name + self.emit('%s: %s;' % (field_name, field_ts_type)) + + self.emit('}') + self.emit() + + # Some structs can explicitly list their subtypes. These structs have a .tag field that indicate + # which subtype they are, which is only present when a type reference is ambiguous. + # Emit a special interface that contains this extra field, and refer to it whenever we encounter + # a reference to a type with enumerated subtypes. + if struct_type.is_member_of_enumerated_subtypes_tree(): + if struct_type.has_enumerated_subtypes(): + # This struct is the parent to multiple subtypes. Determine all of the possible + # values of the .tag property. + tag_values = [] + for tags, _ in struct_type.get_all_subtypes_with_tags(): + for tag in tags: + tag_values.append('"%s"' % tag) + + tag_union = fmt_union(tag_values) + self._emit_tsdoc_header('Reference to the %s polymorphic type. Contains a .tag property to ' + 'let you discriminate between possible subtypes.' % fmt_type_name(struct_type, namespace)); + self.emit('interface %s extends %s {' % (fmt_polymorphic_type_reference(struct_type, namespace), + fmt_type_name(struct_type, namespace))) + + with self.indent(dent = indent_spaces): + self._emit_tsdoc_header('Tag identifying the subtype variant.') + self.emit('\'.tag\': %s;' % tag_union) + + self.emit('}') + self.emit() + else: + # This struct is a particular subtype. Find the applicable .tag value from the parent + # type, which may be an arbitrary number of steps up the inheritance hierarchy. + previous = struct_type + parent = struct_type.parent_type + while not parent.has_enumerated_subtypes(): + previous = parent + parent = parent.parent_type + # parent now contains the closest parent type in the inheritance hierarchy that has + # enumerated subtypes. Determine which subtype this is. + for subtype in parent.get_enumerated_subtypes(): + if subtype.data_type == struct_type: + self._emit_tsdoc_header('Reference to the %s type, identified by the value of the .tag property.' + % fmt_type_name(struct_type, namespace)) + self.emit('interface %s extends %s {' % (fmt_polymorphic_type_reference(struct_type, namespace), + fmt_type_name(struct_type, namespace))) + + with self.indent(dent = indent_spaces): + self._emit_tsdoc_header('Tag identifying this subtype variant. This field ' + 'is only present when needed to discriminate between multiple possible ' + 'subtypes.') + self.emit_wrapped_text('\'.tag\': \'%s\';' % subtype.name) + + self.emit('}') + self.emit() + break + + def _generate_union_type(self, union_type, indent_spaces): + """ + Generates a TypeScript interface for a stone union. + """ + # Emit an interface for each variant. TypeScript 2.0 supports these tagged unions. + # https://github.com/Microsoft/TypeScript/wiki/What%27s-new-in-TypeScript#tagged-union-types + parent_type = union_type.parent_type + namespace = union_type.namespace + union_type_name = fmt_type_name(union_type, namespace) + variant_type_names = [] + if parent_type: + variant_type_names.append(fmt_type_name(parent_type, namespace)) + for variant in union_type.fields: + if variant.doc: + self._emit_tsdoc_header(variant.doc) + variant_name = '%s%s' % (union_type_name, fmt_pascal(variant.name)) + variant_type_names.append(variant_name) + self.emit('interface %s {' % variant_name) + with self.indent(dent = indent_spaces): + # Since field contains non-alphanumeric character, we need to enclose + # it in quotation marks. + self.emit("'.tag': '%s';" % variant.name) + if is_void_type(variant.data_type) == False: + self.emit("%s: %s;" % (variant.name, fmt_type(variant.data_type, namespace))) + self.emit('}') + self.emit() + + if union_type.doc: + self._emit_tsdoc_header(union_type.doc) + self.emit('type %s = %s;' % (union_type_name, ' | '.join(variant_type_names))) + self.emit() + + def _docf(self, tag, val): + """ + Callback to process documentation references. + """ + return fmt_tag(self.cur_namespace, tag, val)