chromium/tools/json_schema_compiler/ts_definition_generator.py

# Copyright 2023 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Generator that produces a definition file for typescript.

Note: This is a work in progress, and generated definitions may need tweaking.
      See bug: crbug.com/1203307
This script is currently run manually.
"""

import datetime
import os
import subprocess
import tempfile

from code_util import Code
from js_util import JsUtil
from model import *
from schema_util import *

CHROMIUM_SRC = os.path.abspath(
    os.path.join(os.path.dirname(__file__), "..", ".."))


class TsDefinitionGenerator(object):

  def Generate(self, namespace):
    return _Generator(namespace).Generate()


class _Generator(object):

  def __init__(self, namespace: Namespace):
    self._namespace = namespace
    self._events_required = False
    self._js_util = JsUtil()

  def Generate(self):
    main_code = Code()
    body_code = Code()
    # Generate the definition first to determine if an import is required.
    self._AppendDefinitionBody(body_code)
    # Create copyright header.
    self._AppendChromiumHeader(main_code)
    # Create file overview.
    self._AppendFileOverview(main_code)
    # Create import area.
    self._AppendImportArea(main_code)
    # Create namespaces.
    namespaces_to_close = self._OpenNamespaces(main_code)
    # Append definitions.
    main_code.Concat(body_code)
    # Close namespaces.
    self._CloseNamespaces(main_code, namespaces_to_close)
    # Cleanup a little.
    main_code.TrimTrailingNewlines()
    # If events are needed, add the import.
    if self._events_required:
      main_code.Substitute(
          {"imports": "import {ChromeEvent} from './chrome_event.js';"})
    else:
      main_code.Substitute({"imports": ""})
    main_code = self._ClangFormat(main_code)
    # Final new line.
    main_code.Append()
    return main_code

  def _AppendChromiumHeader(self, c: Code):
    c.Append(f"""// Copyright {datetime.date.today().year} The Chromium Authors
    // Use of this source code is governed by a BSD-style license that can be
    // found in the LICENSE file.""")
    c.Append()

  def _AppendFileOverview(self, c: Code):
    c.Append("""/**
     * @fileoverview Definitions for chrome.{name} API
     * Generated from: {file}
     * run `tools/json_schema_compiler/compiler.py {file} -g ts_definitions` to
     * regenerate.
     */""".format(name=self._namespace.name, file=self._namespace.source_file))
    c.Append()

  def _AppendImportArea(self, c: Code):
    # Assume these declarations will be placed in tools/typescript/definitions.
    c.Append("%(imports)s")
    c.Append()

  def _OpenNamespaces(self, c: Code):
    namespaces_opened = 2
    declare_or_export = "declare"
    # If adding an import the definition file becomes a module.
    # If that happens we must declare something global specifically.
    # Otherwise the definition file is considered global by default.
    if self._events_required:
      c.Sblock("declare global {")
      namespaces_opened += 1
      c.Append()
      declare_or_export = "export"
    c.Sblock(f"{declare_or_export} namespace chrome {{")
    c.Append()
    c.Sblock(f"export namespace {self._namespace.name} {{")
    c.Append()
    return namespaces_opened

  def _AppendDefinitionBody(self, c: Code):
    # Add namespace level properties.
    for prop in self._namespace.properties.values():
      type_name = self._ExtractType(prop.type_)
      # If the ref type has additional properties, do a namespace merge.
      prop_type: Type = prop.type_
      if (len(prop_type.properties) > 0
          and prop_type.property_type == PropertyType.REF):
        type_name = self._AppendInterfaceForProperty(c, prop, type_name)
      c.Append(f"export const {prop.name}: {type_name};")
      c.Append()
    # Add types.
    for type in self._namespace.types.values():
      self._AppendType(c, type)
    # Add namespace level functions.
    for func in self._namespace.functions.values():
      self._AppendFunction(c, func)
    # Add Events.
    for event in self._namespace.events.values():
      event_type = self._ExtractFunctionType(event)
      c.Append(f"export const {event.name}: ChromeEvent<{event_type}>;")
      c.Append()
      self._events_required = True

  def _CloseNamespaces(self, c: Code, to_close: int):
    for i in range(to_close):
      c.Eblock("}")

  def _AppendFunction(self, c: Code, func):
    params = self._ExtractFunctionParams(func)
    ret_type = self._ExtractFunctionReturnType(func)
    c.Append(f"export function {func.name}({params}): {ret_type};")
    c.Append()

  # This appends an local only interface to allow for additional
  # properties on an already defined type.
  def _AppendInterfaceForProperty(self, c: Code, prop: Property,
                                  prop_type_name):
    if prop.deprecated:
      return
    prop_type = prop.type_
    interface_name = f"{prop.name}_{prop_type_name}"
    # The names of these interfaces are not in pascal case.
    # They are unexported though which results in the correct behavior.
    c.Append("// eslint-disable-next-line @typescript-eslint/naming-convention")
    c.Sblock(f"interface {interface_name} extends {prop_type_name}{{")
    for prop in prop_type.properties.values():
      type_name = self._ExtractType(prop.type_)
      c.Append(f"readonly {prop.name}: {type_name};")
    # Add interface functions.
    for func in prop_type.functions.values():
      self._AppendFunction(c, func)
    # Add Events.
    for event in prop_type.events.values():
      event_type = self._ExtractFunctionType(event)
      c.Append(f"readonly {event.name}: ChromeEvent<{event_type}>;")
      self._events_required = True
    c.Eblock("}")
    return interface_name

  def _AppendType(self, c: Code, type: Type):
    if type.property_type is PropertyType.ENUM:
      self._AppendEnum(c, type)
    elif type.property_type is PropertyType.OBJECT:
      self._AppendInterface(c, type)
    elif type.property_type.is_fundamental:
      # Type alias
      c.Append(f"export type {type.name} = {type.property_type.name};")
      c.Append()
    elif (type.property_type is PropertyType.ARRAY
          or type.property_type is PropertyType.CHOICES):
      ts_type = self._ExtractType(type)
      c.Append(f"export type {type.name} = {ts_type};")
      c.Append()
    else:
      # Adding this for things we may not have accounted for here.
      c.Append(
          f"// TODO({os.getlogin()}) -- {type.name}: {type.property_type.name}")

  def _AppendInterface(self, c: Code, interface: Type):
    c.Sblock(f"export interface {interface.name} {{")
    # Add interface properties.
    for property in interface.properties.values():
      c.Append(self._ExtractPropertyDefinition(property))
    # Add interface functions.
    func: Function
    for func in interface.functions.values():
      c.Append(f"{func.name}{self._ExtractFunctionType(func, ':')};")
    # Add interface events.
    for evnt in interface.events.values():
      event_type = self._ExtractFunctionType(evnt)
      c.Append(f"{evnt.name}: ChromeEvent<{event_type}>;")
      self._events_required = True
    c.Eblock("}")
    c.Append()

  def _AppendEnum(self, c: Code, enum):
    c.Sblock(f"export enum {enum.name} {{")
    for v in enum.enum_values:
      c.Append(f"{self._js_util.GetPropertyName(v.name)} = '{v.name}',")
    c.Eblock("}")
    c.Append()

  def _AppendClass(self, c: Code, class_type: Type):
    c.Sblock(f"export class {class_type.name} {{")
    for property in class_type.properties.values():
      c.Append(self._ExtractPropertyDefinition(property))
    # Add class functions.
    func: Function
    for func in class_type.functions.values():
      c.Append(f"{func.name}{self._ExtractFunctionType(func, ':')};")
    # Add class events.
    for evnt in class_type.events.values():
      event_type = self._ExtractFunctionType(evnt)
      c.Append(f"{evnt.name}: ChromeEvent<{event_type}>;")
      self._events_required = True
    c.Eblock("}")

  def _ExtractFunctionReturnType(self, func: Function):
    ret_type = "void"
    if func.returns is not None:
      ret_type = self._ExtractType(func.returns)
    elif (func.returns_async is not None
          and func.returns_async.can_return_promise):
      ret_type = f"Promise<{self._ExtractPromiseType(func.returns_async)}>"
    return ret_type

  # Extracts the code required to define a type.
  # Uses recursion to get types within types.
  def _ExtractType(self, type: Type):
    if type is None:
      return "void"
    if type.property_type in (PropertyType.INTEGER, PropertyType.DOUBLE):
      return "number"
    elif type.property_type is PropertyType.OBJECT:
      return self._ExtractObjectDefinition(type)
    elif type.property_type is PropertyType.REF:
      return type.ref_type
    elif type.property_type is PropertyType.CHOICES:
      type_list = ""
      for i, choice in enumerate(type.choices):
        if i != 0:
          type_list += "|"
        type_list += self._ExtractType(choice)
      return type_list
    elif type.property_type is PropertyType.ARRAY:
      if type.item_type.property_type is PropertyType.OBJECT:
        element_type = self._ExtractType(type.item_type)
        # Trying to idenfity non-simple elements to use the syntax:
        # Array<string | number>
        # Array<{prop: string}>
        # Array<() => void>
        if '|' in element_type or '(' in element_type or '{' in element_type:
          return f"Array<{element_type}>"

        # For simple type use like the syntax: string[]
        return f"{element_type}[]"
      elif type.item_type.property_type is PropertyType.CHOICES:
        return f"({self._ExtractType(type.item_type)})[]"
      else:
        return f"{self._ExtractType(type.item_type)}[]"
    elif type.property_type.is_fundamental:
      return type.property_type.name
    elif type.property_type is PropertyType.FUNCTION:
      return self._ExtractFunctionType(type.function)
    elif type.property_type is PropertyType.ANY:
      return "any"
    elif type.property_type is PropertyType.BINARY:
      return "ArrayBuffer"
    else:
      # Added for accounting for unknown objects.
      return f"unknown /*TODO({os.getlogin()})*/"

  def _ExtractPropertyDefinition(self, prop: Property, terminator=";"):
    q_mark = "?" if prop.optional else ""
    type_name = self._ExtractType(prop.type_)
    return f"{prop.name}{q_mark}: {type_name}{terminator}"

  # Extracts the function type as an arrow function.
  # The delimiter can be changed so this can be used for interface / object
  # members.
  def _ExtractFunctionType(self, func: Function, return_delim=" =>"):
    params = self._ExtractFunctionParams(func)
    ret_type = self._ExtractFunctionReturnType(func)
    return f"({params}){return_delim} {ret_type}"

  # Extracts an object definition.
  def _ExtractObjectDefinition(self, obj: Type):
    if obj.instance_of:
      return obj.instance_of

    # If there are no specific properties on the object then we should expect
    # and object of random keys with specific values.
    if len(obj.properties) == 0:
      value_type = self._ExtractType(obj.additional_properties)
      return "{[key:string]: %s,}" % value_type

    ## Otherwise we will build a definition similar to an interface
    obj_code = Code()
    obj_code.Append("{")
    for property in obj.properties.values():
      obj_code.Append(self._ExtractPropertyDefinition(property, ","))
    func: Function
    for func in obj.functions.values():
      obj_code.Append(f"{func.name}{self._ExtractFunctionType(func, ':')};")
    obj_code.Append("}")
    for evnt in obj.events.values():
      event_type = self._ExtractFunctionType(evnt)
      obj_code.Append(f"{evnt.name}: ChromeEvent<{event_type}>;")
      self._events_required = True
    return obj_code.Render()

  # Extracts parameters from a function as a string representation.
  # Example = "p1: string, p2: number, p3: any".
  def _ExtractFunctionParams(self, func: Function):
    param_str = self._ExtractParams(func.params)

    # When the return async isn't a promise, we append it as a return callback
    # at the end of the parameters.
    use_callback = (func.returns_async
                    and not func.returns_async.can_return_promise)
    if use_callback:
      callback_params = self._ExtractParams(func.returns_async.params)
      if param_str:
        param_str += ", "

      param_str += f"{func.returns_async.name} "
      if func.returns_async.optional:
        param_str += "?"
      param_str += f": ({callback_params}) => void"

    return param_str

  def _ExtractParams(self, params: list):
    param_str = ""
    required_index = -1
    for i, param in reversed(list(enumerate(params))):
      if not param.optional:
        required_index = i
        break
    for i, param in enumerate(params):
      q_mark = "?" if param.optional and not i < required_index else ""
      type_name = self._ExtractType(param.type_)
      # Typescript doesn't allow an optional before a required param.
      # In this case append | undefined to the parameter.
      if i < required_index and param.optional:
        type_name += "|undefined"
      param_str += f"{param.name}{q_mark}: {type_name}"
      if i < len(params) - 1:
        param_str += ", "
    return param_str

  # Extracts the type from a promise.
  def _ExtractPromiseType(self, async_return: ReturnsAsync):
    retval = "void"
    # Assume that there is at most only one param since functions can only
    # return one thing. This includes those that are async and use a promise to
    # return a value. It could also be 0 for void return type.
    assert len(async_return.params) <= 1
    for ret in async_return.params:
      retval = self._ExtractType(ret.type_)
      if ret.optional:
        retval += "|undefined"
    return retval

  def _ClangFormat(self, c: Code, level=0):
    # temp = tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js")
    # f_name = temp.name
    with tempfile.NamedTemporaryFile("w",
                                     encoding="utf-8",
                                     suffix=".js",
                                     delete=False) as f:
      f.write(c.Render())
      f_name = f.name
    script_path = self._GetChromiumClangFormatScriptPath()
    style_path = self._GetChromiumClangFormatStylePath()
    cmd = (f'python3 {script_path} --fallback-style=none '
           f'--style=file:{style_path} "{f_name}"')
    p = subprocess.Popen(cmd,
                         cwd=CHROMIUM_SRC,
                         encoding="utf-8",
                         shell=True,
                         stdout=subprocess.PIPE)
    out = p.communicate()[0]
    out_code = Code()
    out_code.Append(out)
    os.remove(f_name)
    return out_code

  def _GetChromiumClangFormatScriptPath(self):
    return os.path.join(CHROMIUM_SRC, "third_party", "depot_tools",
                        "clang_format.py")

  def _GetChromiumClangFormatStylePath(self):
    return os.path.join(CHROMIUM_SRC, ".clang-format")