blob: ebcfabaafe2accfef23b319fd195dc1261c6d341 [file] [log] [blame]
# 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.
import argparse
import os
import pdb
from pprint import pprint
import re
import subprocess
import sys
import traceback
from pathlib import Path
# Replace this:
IMPORT_ASSERT_JS = re.compile(
r"import {(.*)} from 'chrome://resources/ash/common/assert.js'")
# With this:
IMPORT_ASSERT_TS = '''import {%s} from 'chrome://resources/js/assert.js';\n'''
TS_IGNORE = re.compile(r'@ts-ignore')
TS_NOCHECK = re.compile(r'@ts-nocheck')
COMMENT_JSDOC = re.compile(
r'@params?|@returns?|@implements?|@type|@extends?|'
r'@private|@protected|@override'
)
TESTCASE_NAMESPACE = re.compile(r"testcase\.(.*) =( async)? \(\) => {")
IMPORT_TESTCASE = re.compile(r"import {testcase} from '../testcase.js';")
ARROW_FUNCTION_CLOSE_BRACE = re.compile(r"^};$")
# Matching:` this.bla;`` or `private this.bla;`
NON_INITIALIZED_PROP = re.compile(r'\s+this\.[\w_]+;')
# Matching: this.bla = 'anything';
INITIALIZED_PROP = re.compile(r'\s+this\.[\w_]+ = .*;')
DELETE_MARK = '-*- DELETE -*-'
# The location of the script.
_HERE = Path(__file__).resolve().parent
# Traverse back to the root of the repo.
_REPO_ROOT = _HERE.parent.parent.parent.parent
# //ui/file_manager/.
_FILE_MANAGER_ROOT = _REPO_ROOT.joinpath('ui', 'file_manager')
# //ui/file_manager/file_manager/.
_FILES_APP_ROOT = _FILE_MANAGER_ROOT.joinpath('file_manager')
# //ui/file_manager/integration_tests/.
_INTEGRATION_TESTS_ROOT = _FILE_MANAGER_ROOT.joinpath('integration_tests')
# //ui/file_manager/image_loader/.
_IMAGE_LOADER_ROOT = _FILE_MANAGER_ROOT.joinpath('image_loader')
#### Start of JS file conversions.
def is_comment(line):
"""Ignores inline comments."""
l = line.strip()
return (l.startswith('//') or ('/*' in l and '*/' not in l))
def is_jsdoc_star(line):
return line.lstrip().startswith('*')
def process_js_file(js_fname):
"""Processes the JS file to convert the content to TS."""
lines = []
# True while processing a @ts-ignore comment, because it may span through
# multiple lines.
ts_ignoring = False
with js_fname.open() as f:
for line in f.readlines():
# First pass we just remove lines.
assert_import = IMPORT_ASSERT_JS.findall(line)
if assert_import:
names = assert_import[0]
line = IMPORT_ASSERT_TS % names
lines.append(line)
continue
# Don't include testcase import.
if IMPORT_TESTCASE.match(line):
continue
# Fully commented out line, ignores inline comment.
if is_comment(line):
if ts_ignoring:
continue # continue to delete a @ts-ignore.
if TS_IGNORE.search(line):
ts_ignoring = True
continue # delete the line.
if TS_NOCHECK.search(line):
continue # delete the line.
elif ts_ignoring:
# End of the @ts-ignore.
ts_ignoring = False
lines.append(line)
processing_comment = False
processing_arrow_function = False
idx = -1
while (idx < len(lines) - 1):
idx += 1
line = lines[idx]
if is_comment(line) or (processing_comment and is_jsdoc_star(line)):
processing_comment = True
match = COMMENT_JSDOC.search(line)
original_line = line
# Loop because we might need to process multiple JSDoc tags in the
# same line.
while (match):
previous_line = line
if not process_jsdoc(lines, line, idx, match):
print(f'*** Failed to process:\n{line}'
f'\nFrom:\n{original_line}')
# Refresh the line:
line = lines[idx]
if (line == previous_line):
break
match = COMMENT_JSDOC.search(line)
continue
if not is_jsdoc_star(line):
processing_comment = False
# Arrow functions end with a ';' after their closing brace, remove it.
if processing_arrow_function and ARROW_FUNCTION_CLOSE_BRACE.match(
line):
lines[idx] = "}\n"
processing_arrow_function = False
continue
# Rewrite testcase imports to exports instead.
match = TESTCASE_NAMESPACE.search(line)
if match:
processing_arrow_function = True
lines[idx] = f"export async function {match.group(1)}() " "{\n"
# Remove the lines marked for deletion.
remove_lines(lines)
return lines
def process_jsdoc(lines, cur_line, idx, regex_match):
"""Processes the `line` to convert the JSDoc to TS."""
# Normalize the name without the `s`.
jsdoc = cur_line[regex_match.start():regex_match.end()]
if jsdoc.endswith('s'):
jsdoc = jsdoc[:-1]
if jsdoc == '@implement':
process_extends_implements('implements', lines, idx)
return True
elif jsdoc == '@extend':
process_extends_implements('extends', lines, idx)
return True
elif jsdoc == '@type':
process_type(lines, idx)
return True
elif jsdoc == '@return':
process_return(lines, idx)
return True
elif jsdoc == '@param':
process_param(lines, idx)
return True
elif jsdoc == '@private':
process_private('private', lines, idx)
return True
elif jsdoc == '@protected':
process_private('protected', lines, idx)
return True
elif jsdoc == '@override':
process_private('override', lines, idx)
return True
print(f'***Unknown JSDoc tag {jsdoc}')
return False
def remove_lines(lines):
"""Removes the lines marked for deletion. Changes the list in-place."""
idx = len(lines) - 1
while idx >= 0:
line = lines[idx]
if DELETE_MARK in line:
del lines[idx]
idx -= 1
def maybe_remove_tag(tag, line):
"""Only removes the tag if the tag has no additional description."""
tmp_line = remove_tag(tag, line)
if DELETE_MARK in tmp_line:
# Remove the tag and the line.
return tmp_line
# Return original line instead.
return line
def remove_tag(tag, line):
"""Removes the JSDoc tag and if it doesn't have any additional comment mark
the line for deletion."""
ret = re.sub(tag, '', line)
# Remove space `*` and spaces after `*`.
maybe_empty_line = re.sub(r'^\s+\*\s*', '', ret)
if not maybe_empty_line.strip():
# Mark for deletion.
return ret + DELETE_MARK
# Return just removing the @tag.
return ret
def extract_type(tag, line):
"""Extracts the type definition from the JSDoc tag.
It removes the type definitions from the line."""
try:
t = re.findall(rf'{tag}\s+{{(.*)}}', line)[0]
new_line = line.replace('{' + t + '}', '')
return t, new_line
except IndexError:
return None, None
def get_param_name(line):
"""Gets the name of the param from the @param tag."""
try:
n = re.findall(r'@params?\s+(\w+)', line)[0]
return n
except IndexError:
print(f'Failed to find the @param name in JSDoc on line:\n{line}')
return ''
def sanitize_type(type_spec):
"""Converts the JSDoc definition to the TS definition."""
t = type_spec.replace('!', '')
t = t.replace('?', 'null|')
t = t.replace('=', '')
t = t.replace('*', 'unknown')
t = t.replace('function():void', 'VoidCallback')
t = re.sub(r'import\(.*\)\.', '', t)
return t
def find_end_of_comment(new_file, idx):
"""Finds the first line from `idx` that isn't a comment."""
while (idx < len(new_file)):
line = new_file[idx]
if is_comment(line) or is_jsdoc_star(line):
idx += 1
continue
return idx
def process_type(lines, idx):
"""Converts the @type tag to TS."""
line = lines[idx]
t, new_line = extract_type(r'@type', line)
if not t:
return
t = sanitize_type(t)
new_line = remove_tag(r'@type', new_line)
lines[idx] = new_line
idx = find_end_of_comment(lines, idx)
if not idx:
return
# Try to insert the type as TS syntax.
new_line = lines[idx]
if NON_INITIALIZED_PROP.search(new_line):
# Case: this.bla;
new_line = new_line.replace(';', f': {t};')
elif INITIALIZED_PROP.search(new_line):
# Case: this.bla = 'anything';
new_line = new_line.replace(' =', f': {t} =')
lines[idx] = new_line
def process_return(new_file, idx):
"""Converts the @return tag to TS."""
line = new_file[idx]
t, new_line = extract_type(r'@returns?', line)
if not t:
return
t = sanitize_type(t)
new_line = maybe_remove_tag(r'@returns?', new_line)
if not DELETE_MARK in new_line:
# Normalize @returns to @return (without S) and single space after it.
new_line = re.sub(r'@returns?\s+', '@return ', new_line)
new_file[idx] = new_line
idx = find_end_of_comment(new_file, idx)
if not idx:
return
# Try to insert the return type as TS syntax.
# Find the beginning of the function scope.
line = new_file[idx]
while (' {\n' not in line):
idx += 1
if idx >= len(new_file):
return
line = new_file[idx]
new_line = line.replace(' {\n', f': {t} {{\n', 1)
new_file[idx] = new_line
def process_param(new_file, idx):
"""Converts the @param tag to TS."""
line = new_file[idx]
t, new_line = extract_type(r'@params?', line)
if not t:
return
is_optional = t.endswith('=')
param_name = get_param_name(new_line)
new_line = maybe_remove_tag(fr'@params?\s+{param_name}', new_line)
if not DELETE_MARK in new_line:
# Normalize @params to @param (without S) and single space after it.
new_line = re.sub(r'@params?\s+', '@param ', new_line)
new_file[idx] = new_line
t = sanitize_type(t)
idx = find_end_of_comment(new_file, idx)
if not idx:
return
# Try to insert the param type as TS syntax.
# Find the param in the function signature.
line = new_file[idx]
search_param_name = re.compile(fr'(\W){param_name}(\W)')
while (not search_param_name.search(line)):
idx += 1
if idx >= len(new_file):
return
line = new_file[idx]
line = new_file[idx]
new_param_name = param_name
if is_optional:
new_param_name += '?'
new_line = search_param_name.sub(fr'\1{new_param_name}: {t}\2', line)
new_file[idx] = new_line
def process_extends_implements(jsdoc_tag, new_file, idx):
"""Converts the @extends or @implements tag to TS."""
tag_regex = r'@extends?'
ts_keyword = 'extends'
if jsdoc_tag == 'implements':
tag_regex = r'@implements?'
ts_keyword = 'implements'
line = new_file[idx]
# Extends doesn't have additional syntax like !?= for the type, so it
# doesn't need the sanitize.
t, new_line = extract_type(tag_regex, line)
if not t:
return
new_line = remove_tag(tag_regex, new_line)
new_file[idx] = new_line
idx = find_end_of_comment(new_file, idx)
if not idx:
return
line = new_file[idx]
# Try to insert the implements/extends as TS syntax.
if not ' class ' in line:
return
if f' {ts_keyword} ' in line:
t += ', '
else:
t = f' {ts_keyword} {t}'
new_line = re.sub(r'class (\w+)', rf'class \1{t}', line)
new_file[idx] = new_line
def process_private(jsdoc_tag, new_file, idx):
"""Converts the @private or @protected tag to TS."""
tag_regex = r'@private'
ts_keyword = 'private'
if jsdoc_tag == 'protected':
tag_regex = r'@protected'
ts_keyword = 'protected'
elif jsdoc_tag == 'override':
tag_regex = r'@override'
ts_keyword = 'override'
line = new_file[idx]
new_line = remove_tag(tag_regex, line)
new_file[idx] = new_line
idx = find_end_of_comment(new_file, idx)
if not idx:
return
line = new_file[idx]
# Try to insert the private/protected as TS syntax.
l = line.strip()
if l.startswith('this.'):
new_line = line.replace('this.', f'{ts_keyword} this.')
new_file[idx] = new_line
return
new_line = re.sub(r'^(\s*)(\w+)', fr'\1{ts_keyword} \2', line)
new_file[idx] = new_line
#### End of JS file conversions.
#### Start of Build file conversions:
def process_build_file(build_file_name, js_file_name):
"""Processes the BUILD file."""
build_target = os.path.splitext(js_file_name.name)[0]
new_file = []
from_root = js_file_name.absolute().parent.relative_to(_REPO_ROOT)
if build_file_name.parent == js_file_name.parent:
# Within the same build file refers as ":bla".
ref_target = f':{build_target}'
else:
# From other build files refer as "//path/to:bla".
ref_target = f'//{from_root}:{build_target}'
scope = 0
with build_file_name.open() as f:
for line in f.readlines():
# A line that is referring to the removed build target, like a
# `deps`.
if ref_target in line:
continue # delete line
# The start of the build target definitions.
if (f'js_library("{build_target}") {{' in line
or f'js_unittest("{build_target}") {{' in line):
scope += 1
continue # delete line
# Detecting inner scope, like a `if` inside the `js_library()`.
if scope > 0 and '{' in line and '}' not in line:
scope += 1
continue # continue to delete in the scope.
# End of a scope.
if scope > 0 and '}' in line:
scope -= 1
continue # delete the last line of the scope
# End of all the scopes within the `js_library()`.
if scope > 0:
continue # continue to delete in the scope.
# Replace .js with .ts inplace for Image Loader.
if f'"{js_file_name.name}",' in line.strip():
l = line.replace('.js', '.ts', 1)
new_file.append(l)
continue
new_file.append(line)
if 'integration_tests/' in str(from_root):
js_str_name = str(js_file_name.absolute().relative_to(build_file_name.parent))
js_str_name = js_str_name.replace('.js', '.ts')
anchor = 'ts_files = [\n'
find_and_uncomment(new_file, anchor, js_str_name)
return new_file
def process_file_names_gni(js_fname, gni_file):
"""Removes the reference to the JS file and add to the TS file in the
file_names.gni."""
new_file = []
# All files in file_names.gni a relative to //ui/file_manager.
fname = js_fname.relative_to(gni_file.parent)
js_str_name = str(fname)
ts_str_name = js_str_name.replace('.js', '.ts')
ts_line = f' "{ts_str_name}",\n'
# anchor is where the TS file will be added.
anchor = 'ts_files = [\n'
if js_str_name.endswith('unittest.js'):
anchor = 'ts_test_files = [\n'
if 'integration_tests/' in str(gni_file):
anchor = None
elif 'image_loader/' in js_str_name:
anchor = None
with gni_file.open() as f:
for line in f.readlines():
if js_str_name in line:
continue # delete the line referring to the JS file.
new_file.append(line)
# The anchor was added above, just append the TS line.
if line == anchor:
new_file.append(ts_line)
if 'image_loader/' in js_str_name:
anchor = 'image_loader_ts = [\n'
ts_str_name = js_str_name.replace('.js', '.ts')
find_and_uncomment(new_file, anchor, ts_str_name)
return new_file
def find(lines, pattern):
for idx, line in enumerate(lines):
if pattern in line:
return idx
return -1
def find_and_uncomment(lines, anchor, ts_fname):
"""Finds the line with `ts_fname` and uncomment.
It updates the `lines` in-place.
"""
idx_start = find(lines, anchor)
idx_end = idx_start + find(lines[idx_start:], ']')
idx_fname = idx_start + find(lines[idx_start:idx_end], f'# "{ts_fname}"')
line = lines[idx_fname]
# Uncomment the line.
line = line.replace('# ', '', 1)
lines[idx_fname] = line
def to_build_file(js_fname):
"""Converts the `js_fname` path to its sibling BUILD.gn."""
return Path(js_fname).with_name('BUILD.gn')
def find_build_files(js_fname):
"""Finds the BUILD.gn files to process."""
# Always process the BUILD.gn sibling of the JS file being converted.
sibling_build = to_build_file(js_fname)
ret = set()
if sibling_build.exists():
ret.add(Path(str(sibling_build)).absolute())
str_js_path = str(js_fname.absolute())
# Avoid mixing the BUILD.gn of the Files app, Integration Tests and Image
# Loader. If the JS file belongs to one of these, only process BUILD files
# in that section.
root = _FILES_APP_ROOT
if str_js_path.startswith(str(_INTEGRATION_TESTS_ROOT)):
root = _INTEGRATION_TESTS_ROOT
elif str_js_path.startswith(str(_IMAGE_LOADER_ROOT)):
root = _IMAGE_LOADER_ROOT
for base, dirs, files in os.walk(root):
for f in files:
if f == 'BUILD.gn':
ret.add(Path(base, f))
return ret
#### End of Build file conversions.
def replace_file(fname, content):
"""Replaces the `fname` content in the file system."""
with open(fname, 'w') as f:
f.writelines(content)
def run_git_mv(js_path_str):
"""Runs the git mv to rename the JS file to TS."""
ts_path = js_path_str.replace('.js', '.ts')
cmd = [
'git',
'mv',
js_path_str,
ts_path,
]
cmd = ' '.join(cmd)
try:
out = subprocess.check_output(cmd,
stderr=subprocess.STDOUT,
shell=True)
return out.strip()
except subprocess.CalledProcessError as e:
print('***')
print(e.stdout)
print(e.stderr)
return ''
def register_testcase(js_file_name):
# Lines:
# 1. Import line
# 2. Spread assigning to the `testcase` namespace.
namespace_re = re.compile(r"import \* as (\w+) from '[\w\/.]+';")
js_import_path = js_file_name.absolute().relative_to(_INTEGRATION_TESTS_ROOT)
import_line_pattern = f"from './{js_import_path}';"
testcase_path = _INTEGRATION_TESTS_ROOT.joinpath('testcase.ts')
lines = list(open(testcase_path).readlines())
import_idx = find(lines, import_line_pattern)
if import_idx == -1:
return
import_line = lines[import_idx]
lines[import_idx] = import_line.replace('// ', '');
try:
namespace_name = namespace_re.findall(import_line)[0]
except IndexError:
namespace_name = ''
if namespace_name:
spread_line_pattern = f" ...{namespace_name},"
spread_line_idx = find(lines, spread_line_pattern)
if spread_line_idx > -1:
spread_line = lines[spread_line_idx]
lines[spread_line_idx] = spread_line.replace('// ', '');
replace_file(testcase_path, lines)
#### Start of controlling the execution of the script.
def process_js_files(files):
for fname in files:
print('Processing:', fname)
js_path = Path(fname)
js_path_abs_str = str(js_path.absolute())
# Processing all BUILD files
bs = find_build_files(js_path)
for b in bs:
new_build_file = process_build_file(b, js_path)
replace_file(b, new_build_file)
# Process as `file_names.gni` to remove the .js file and add it to
# the `ts_files = ` section.
if b == _INTEGRATION_TESTS_ROOT.joinpath('BUILD.gn'):
new_build_file = process_file_names_gni(js_path.absolute(), b)
replace_file(b, new_build_file)
# Only process file_names.gni for Files app and Image Loader.
if js_path_abs_str.startswith((str(_FILES_APP_ROOT),
str(_IMAGE_LOADER_ROOT))):
file_names = _FILE_MANAGER_ROOT.joinpath('file_names.gni')
new_file_names_gni = process_file_names_gni(
js_path.absolute(), file_names)
replace_file(file_names, new_file_names_gni)
# For Integration Tests, uncomment relevant lines in the testcase.ts
if 'integration_tests/' in fname:
register_testcase(js_path)
# Process the JS file content and save as JS file.
ts_content = process_js_file(js_path)
replace_file(js_path, ts_content)
# Rename to TS.
run_git_mv(str(js_path))
def main():
parser = argparse.ArgumentParser(
description='Convert the given JS files to TS')
parser.add_argument('js_files', metavar='FILE', type=str, nargs='+')
args = parser.parse_args()
process_js_files(args.js_files)
if __name__ == "__main__":
main()