Skip to content

Commit

Permalink
feat(typescript): generate tsconfig.json for ts_project (#2130)
Browse files Browse the repository at this point in the history
This is an experimental, opt-in feature where ts_project will generate a tsconfig.json file in place of
the user specifying one in the source directory.

I expect a long tail of compatibility bugs since any path appearing in this generated file needs to point from
bazel-out back to the source directory.

Fixes #2058
  • Loading branch information
alexeagle authored Aug 28, 2020
1 parent 1ed754e commit 09ec233
Show file tree
Hide file tree
Showing 27 changed files with 441 additions and 54 deletions.
37 changes: 11 additions & 26 deletions packages/typescript/checked_in_ts_project.bzl
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"checked_in_ts_project rule"

load("@build_bazel_rules_nodejs//:index.bzl", "generated_file_test")
load("@build_bazel_rules_nodejs//third_party/github.com/bazelbuild/bazel-skylib:rules/write_file.bzl", "write_file")
load("//packages/typescript:index.bzl", "ts_project")

def checked_in_ts_project(name, src, checked_in_js = None, **kwargs):
Expand All @@ -15,34 +14,20 @@ def checked_in_ts_project(name, src, checked_in_js = None, **kwargs):
if not checked_in_js:
checked_in_js = src[:-3] + ".js"

tsconfig = "tsconfig_%s.json" % name

# workspace is up three dirs (bazel-out/arch/bin) plus number of segments in the package
workspace_root = "/".join([".."] * (3 + len(native.package_name().split("/"))))

# Generate a tsconfig, this is partly an example of how it can be done, per jbedard and toxicable request
write_file(
name = "_gen_tsconfig_%s" % name,
content = [struct(
compilerOptions = struct(
lib = ["es2017", "dom"],
strict = True,
target = "es2015",
module = "commonjs",
removeComments = True,
declaration = True,
skipLibCheck = True,
),
files = ["/".join([workspace_root, native.package_name(), src])],
).to_json()],
out = tsconfig,
)

ts_project(
name = name,
srcs = [src],
declaration = True,
tsconfig = tsconfig,
tsconfig = {
"compilerOptions": {
"declaration": True,
"lib": ["es2017", "dom"],
"module": "commonjs",
"removeComments": True,
"skipLibCheck": True,
"strict": True,
"target": "es2015",
},
},
**kwargs
)

Expand Down
76 changes: 76 additions & 0 deletions packages/typescript/internal/ts_config.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,79 @@ feature from TypeScript, then the Bazel implementation needs to know about that
extended configuration file as well, to pass them both to the TypeScript compiler.
""",
)

def _join(*elements):
return "/".join([f for f in elements if f])

def _relative_path(tsconfig, dest):
relative_to = tsconfig.dirname
if dest.is_source:
# Calculate a relative path from the directory where we're writing the tsconfig
# back to the sources root
workspace_root = "/".join([".."] * len(relative_to.split("/")))
return _join(workspace_root, dest.path)

# Bazel guarantees that srcs are beneath the package directory, and we disallow
# tsconfig.json being generated with a "/" in the name.
# So we can calculate a relative path from e.g.
# bazel-out/darwin-fastbuild/bin/packages/typescript/test/ts_project/generated_tsconfig/gen_src
# to <generated file packages/typescript/test/ts_project/generated_tsconfig/gen_src/subdir/a.ts>
return dest.path[len(relative_to) + 1:]

def _write_tsconfig_rule(ctx):
# TODO: is it useful to expand Make variables in the content?
content = "\n".join(ctx.attr.content)
if ctx.attr.extends:
content = content.replace(
"__extends__",
_relative_path(ctx.outputs.out, ctx.file.extends),
)
if ctx.attr.files:
content = content.replace(
"\"__files__\"",
str([_relative_path(ctx.outputs.out, f) for f in ctx.files.files]),
)
ctx.actions.write(
output = ctx.outputs.out,
content = content,
)
return [DefaultInfo(files = depset([ctx.outputs.out]))]

write_tsconfig_rule = rule(
implementation = _write_tsconfig_rule,
attrs = {
"content": attr.string_list(),
"extends": attr.label(allow_single_file = True),
"files": attr.label_list(allow_files = True),
"out": attr.output(),
},
)

# Syntax sugar around skylib's write_file
def write_tsconfig(name, config, files, out, extends = None):
"""Wrapper around bazel_skylib's write_file which understands tsconfig paths
Args:
name: name of the resulting write_file rule
config: tsconfig dictionary
files: list of input .ts files to put in the files[] array
out: the file to write
extends: a label for a tsconfig.json file to extend from, if any
"""
if out.find("/") >= 0:
fail("tsconfig should be generated in the package directory, to make relative pathing simple")

if extends:
config["extends"] = "__extends__"

amended_config = struct(
files = "__files__",
**config
)
write_tsconfig_rule(
name = name,
files = files,
extends = extends,
content = [amended_config.to_json()],
out = out,
)
152 changes: 124 additions & 28 deletions packages/typescript/internal/ts_project.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

load("@build_bazel_rules_nodejs//:providers.bzl", "DeclarationInfo", "NpmPackageInfo", "declaration_info", "js_module_info", "run_node")
load("@build_bazel_rules_nodejs//internal/linker:link_node_modules.bzl", "module_mappings_aspect")
load(":ts_config.bzl", "TsConfigInfo")
load(":ts_config.bzl", "TsConfigInfo", "write_tsconfig")

_DEFAULT_TSC = (
# BEGIN-INTERNAL
Expand Down Expand Up @@ -39,11 +39,22 @@ _OUTPUTS = {
}

def _join(*elements):
return "/".join([f for f in elements if f])
segments = [f for f in elements if f]
if len(segments):
return "/".join(segments)
return "."

def _ts_project_impl(ctx):
arguments = ctx.actions.args()

generated_srcs = False
for src in ctx.files.srcs:
if src.is_source:
if generated_srcs:
fail("srcs cannot be a mix of generated files and source files")
else:
generated_srcs = True

# Add user specified arguments *before* rule supplied arguments
arguments.add_all(ctx.attr.args)

Expand All @@ -53,7 +64,11 @@ def _ts_project_impl(ctx):
"--outDir",
_join(ctx.bin_dir.path, ctx.label.package, ctx.attr.out_dir),
"--rootDir",
_join(ctx.label.package, ctx.attr.root_dir) if ctx.label.package else ".",
_join(
ctx.bin_dir.path if generated_srcs else None,
ctx.label.package,
ctx.attr.root_dir,
),
])
if len(ctx.outputs.typings_outs) > 0:
declaration_dir = ctx.attr.declaration_dir if ctx.attr.declaration_dir else ctx.attr.out_dir
Expand Down Expand Up @@ -90,12 +105,17 @@ def _ts_project_impl(ctx):
deps_depsets.append(dep[DeclarationInfo].transitive_declarations)

inputs = ctx.files.srcs + depset(transitive = deps_depsets).to_list()

# Gather TsConfig info from both the direct (tsconfig) and indirect (extends) attribute
if TsConfigInfo in ctx.attr.tsconfig:
inputs.extend(ctx.attr.tsconfig[TsConfigInfo].deps)
else:
inputs.append(ctx.file.tsconfig)
if ctx.attr.extends:
inputs.extend(ctx.files.extends)
for extend in ctx.attr.extends:
if TsConfigInfo in extend:
inputs.extend(extend[TsConfigInfo].deps)
else:
inputs.extend(extend.files.to_list())

# We do not try to predeclare json_outs, because their output locations generally conflict with their path in the source tree.
# (The exception is when out_dir is used, then the .json output is a different path than the input.)
Expand Down Expand Up @@ -359,14 +379,51 @@ def ts_project_macro(
deps: List of labels of other rules that produce TypeScript typings (.d.ts files)
tsconfig: Label of the tsconfig.json file to use for the compilation, or a target that provides TsConfigInfo.
tsconfig: Label of the tsconfig.json file to use for the compilation
By default, we assume the tsconfig file is named by adding `.json` to the `name` attribute.
To support "chaining" of more than one extended config, this label could be a target that
provdes `TsConfigInfo` such as `ts_config`.
extends: List of labels of tsconfig file(s) referenced in `extends` section of tsconfig.
By default, we assume the tsconfig file is named by adding `.json` to the `name` attribute.
Any tsconfig files "chained" by extends clauses must either be transitive deps of the TsConfigInfo
provided to the `tsconfig` attribute, or must be explicitly listed here.
EXPERIMENTAL: generated tsconfig
Instead of a label, you can pass a dictionary of tsconfig keys.
In this case, a tsconfig.json file will be generated for this compilation, in the following way:
- all top-level keys will be copied by converting the dict to json.
So `tsconfig = {"compilerOptions": {"declaration": True}}`
will result in a generated `tsconfig.json` with `{"compilerOptions": {"declaration": true}}`
- each file in srcs will be converted to a relative path in the `files` section.
- the `extends` attribute will be converted to a relative path
Note that you can mix and match attributes and compilerOptions properties, so these are equivalent:
```
ts_project(
tsconfig = {
"compilerOptions": {
"declaration": True,
},
},
)
```
and
```
ts_project(
declaration = True,
)
```
extends: Label of the tsconfig file referenced in the `extends` section of tsconfig
To support "chaining" of more than one extended config, this label could be a target that
provdes `TsConfigInfo` such as `ts_config`.
_DEPRECATED, to be removed in 3.0_:
For backwards compatibility, this accepts a list of Labels of the "chained"
tsconfig files. You should instead use a single Label of a `ts_config` target.
Follow this deprecation: https://github.com/bazelbuild/rules_nodejs/issues/2140
args: List of strings of additional command-line arguments to pass to tsc.
Expand Down Expand Up @@ -413,31 +470,70 @@ def ts_project_macro(

if srcs == None:
srcs = native.glob(["**/*.ts", "**/*.tsx"])

if tsconfig == None:
tsconfig = name + ".json"

extra_deps = []

if validate:
validate_options(
name = "_validate_%s_options" % name,
target = "//%s:%s" % (native.package_name(), name),
declaration = declaration,
source_map = source_map,
declaration_map = declaration_map,
composite = composite,
incremental = incremental,
emit_declaration_only = emit_declaration_only,
ts_build_info_file = ts_build_info_file,
tsconfig = tsconfig,
extends = extends,
if type(tsconfig) == type(dict()):
# Opt-in to #2140 breaking change at the same time you opt-in to experimental tsconfig dict
if type(extends) == type([]):
fail("when tsconfig is a dict, extends should have a single value")

# Copy attributes <-> tsconfig properties
# TODO: fail if compilerOptions includes a conflict with an attribute?
compiler_options = tsconfig.setdefault("compilerOptions", {})
source_map = compiler_options.setdefault("sourceMap", source_map)
declaration = compiler_options.setdefault("declaration", declaration)
declaration_map = compiler_options.setdefault("declarationMap", declaration_map)
emit_declaration_only = compiler_options.setdefault("emitDeclarationOnly", emit_declaration_only)

# These options are always passed on the tsc command line so don't include them
# in the tsconfig. At best they're redundant, but at worst we'll have a conflict
if "outDir" in compiler_options.keys():
out_dir = compiler_options.pop("outDir")
if "declarationDir" in compiler_options.keys():
declaration_dir = compiler_options.pop("declarationDir")
if "rootDir" in compiler_options.keys():
root_dir = compiler_options.pop("rootDir")

# FIXME: need to remove keys that have a None value?
write_tsconfig(
name = "_gen_tsconfig_%s" % name,
config = tsconfig,
files = srcs,
extends = Label("//%s:%s" % (native.package_name(), name)).relative(extends) if extends else None,
out = "tsconfig_%s.json" % name,
)
extra_deps.append("_validate_%s_options" % name)

# From here, tsconfig becomes a file, the same as if the
# user supplied a tsconfig.json InputArtifact
tsconfig = "tsconfig_%s.json" % name

else:
if tsconfig == None:
tsconfig = name + ".json"

if validate:
validate_options(
name = "_validate_%s_options" % name,
target = "//%s:%s" % (native.package_name(), name),
declaration = declaration,
source_map = source_map,
declaration_map = declaration_map,
composite = composite,
incremental = incremental,
ts_build_info_file = ts_build_info_file,
emit_declaration_only = emit_declaration_only,
tsconfig = tsconfig,
extends = extends,
)
extra_deps.append("_validate_%s_options" % name)

typings_out_dir = declaration_dir if declaration_dir else out_dir
tsbuildinfo_path = ts_build_info_file if ts_build_info_file else name + ".tsbuildinfo"

# Backcompat for extends as a list, to cleanup in #2140
if (type(extends) == type("")):
extends = [extends]

ts_project(
name = name,
srcs = srcs,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"Test that properties in the tsconfig dict are honored"

load("@build_bazel_rules_nodejs//:index.bzl", "generated_file_test")
load("//packages/typescript:index.bzl", "ts_project")

ts_project(
tsconfig = {
"compilerOptions": {
"declaration": True,
"declarationDir": "types",
"declarationMap": True,
"module": "esnext",
"outDir": "out",
"rootDir": "src",
"sourceMap": True,
"types": [],
},
},
)

generated_file_test(
name = "test",
src = "expected.js_",
generated = ":out/a.js",
)

generated_file_test(
name = "test_map",
src = "expected.js.map_",
generated = ":out/a.js.map",
)

generated_file_test(
name = "test_dts",
src = "expected.d.ts_",
generated = ":types/a.d.ts",
)

generated_file_test(
name = "test_dtsmap",
src = "expected.d.ts.map_",
generated = ":types/a.d.ts.map",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"version":3,"file":"a.d.ts","sourceRoot":"","sources":["../../../../../../../../../../packages/typescript/test/ts_project/generated_tsconfig/config/src/a.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,CAAC,EAAE,MAAsB,CAAC"}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export declare const a: string;
//# sourceMappingURL=a.d.ts.map
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"version":3,"file":"a.js","sourceRoot":"","sources":["../../../../../../../../../../packages/typescript/test/ts_project/generated_tsconfig/config/src/a.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,IAAM,CAAC,GAAW,aAAa,CAAC"}
Loading

0 comments on commit 09ec233

Please sign in to comment.