diff --git a/README.md b/README.md index 855db5c..f534144 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,15 @@ Since `formatjs_extract` is a custom rule, you can attach Bazel aspects to it fo - Aggregating messages across multiple targets - Custom reporting and analysis +### Automatic extraction from runtime dependencies + +Use [`formatjs_message_collector_aspect`](docs/automatic-extraction.md) to extract sources +when their configured dependencies enable FormatJS, then collect catalogs through +transitive depsets. Supply runtime labels or a runtime predicate; no +language-rule dependency is required. Custom rules/aspects can reuse +`formatjs_extract_action` instead of duplicating CLI wiring. Select extraction roots +with `collect_at` for rule adapters or `formatjs_root` for CLI use. + ### Message Aggregation Aspect The `formatjs_aggregate_aspect` collects and merges extracted messages from a target and all its dependencies into a single JSON file using `jq`. diff --git a/docs/automatic-extraction.md b/docs/automatic-extraction.md new file mode 100644 index 0000000..a5ed3ef --- /dev/null +++ b/docs/automatic-extraction.md @@ -0,0 +1,142 @@ +# Dependency-triggered extraction + +`formatjs_message_collector_aspect` follows configured `deps` and `srcs`, detects +Intl usage, and carries source plans and explicit catalogs in depsets. Extraction +runs only at selected roots. Libraries do not flatten transitive source sets. + +## CLI use + +Define `build/i18n.bzl`: + +```starlark +load("@rules_formatjs//formatjs:defs.bzl", "formatjs_message_collector_aspect") + +rust_messages = formatjs_message_collector_aspect( + name = "rust_messages", + runtime_labels = [Label("@my_crates//:formatjs_intl")], + source_extensions = ["rs"], + id_interpolation_pattern = "[sha512:contenthash:base64:10]", + extract_source_location = True, + flatten = True, +) +``` + +Select the extraction root explicitly: + +```sh +bazel build //app:main \ + --aspects=//build:i18n.bzl%rust_messages \ + --aspects_parameters=formatjs_root=//app:main \ + --output_groups=all_messages +``` + +`formatjs_root` must be the actual target's absolute canonical label, not an alias +or relative label. It scopes materialization to that consumer. Other nodes only +propagate source plans and existing catalogs. + +Runtime labels must be caller-created `Label` objects. This preserves the caller's +repository mappings. Aliases, including configurable aliases, are resolved in the +target configuration through private resolver dependencies. They are not traversed +for Intl detection, so configuring a resolver does not opt unrelated graphs into +extraction. Their files are not extraction inputs and are not built for detection. +Resolution can configure runtime dependencies in otherwise unrelated graphs; those +configured targets are shared within each configuration. + +Alternatively, supply `is_runtime(target, ctx)` to inspect native language providers +or a `FormatjsRuntimeInfo` marker. Predicate-only adapters add no resolver dependency +on the runtime. No language build rules are imported by this ruleset. + +## Rule adapters + +Rule-propagated aspects use a `collect_at(target, ctx)` predicate instead of the CLI +parameter. Choose application or catalog-consumer roots, not every library: + +```starlark +def _at_application(_target, ctx): + return ctx.label == Label("//app:main") + +rust_messages = formatjs_message_collector_aspect( + name = "rust_messages", + runtime_labels = [Label("@my_crates//:formatjs_intl")], + source_extensions = ["rs"], + collect_at = _at_application, + extract_source_location = True, +) +``` + +This configuration also works from the CLI without `--aspects_parameters`. +Consumers access `FormatjsMessagesInfo.catalogs` on a selected root: + +```starlark +load("@rules_formatjs//formatjs:defs.bzl", "FormatjsMessagesInfo") + +def _catalogs_impl(ctx): + return [DefaultInfo(files = ctx.attr.target[FormatjsMessagesInfo].catalogs)] + +source_catalogs = rule( + implementation = _catalogs_impl, + attrs = {"target": attr.label(aspects = [rust_messages])}, +) +``` + +The public provider has two fields: `catalogs` and `uses_runtime`. At intermediate +nodes, `catalogs` contains only explicit catalogs. A private provider carries source +plans and explicit coverage; consumers do not depend on that internal state. + +## Catalogs and execution + +Roots flatten candidate and covered sources once. Ownership, extension and explicit +coverage filters run before extraction actions are registered. Excluded generated +artifacts are not action inputs, so their producers do not run for catalog builds. +Partial explicit metadata excludes only its declared sources. + +Without source locations, uncovered sources use one batch extraction per root. +With `extract_source_location = True`, each file gets its own catalog: the native +CLI keeps only one location for duplicate IDs within a batch. Per-file catalogs +preserve declarations across files and allow unchanged files to reuse cached +extraction actions. Duplicate occurrences within one file retain the native CLI's +behavior. Catalog order is deterministic; no intermediate merge actions are added. +Consumers choose their duplicate-ID and source-location merge policies. Output-name +hash collisions receive deterministic suffixes instead of failing analysis. + +Existing `FormatjsExtractInfo` catalogs are collected unchanged. Automatic outputs +are exposed through `FormatjsMessagesInfo` and `all_messages`; they do not replace +or add a singular `FormatjsExtractInfo` on the underlying target. + +Automatic mode requires only the native FormatJS CLI toolchain. No host shell, +Python or filtering toolchain is used. Without runtime labels or a predicate, the +factory only collects existing catalogs and requires no CLI toolchain. The existing +`message_collector_aspect` entrypoint keeps that collection-only behavior. + +## Scope and options + +By default, only targets in the main repository contribute new source candidates. +External dependencies can enable extraction and provide explicit catalogs without +having their implementation scanned. Override `should_extract(target, ctx)` to +change that target boundary. Rejected sources stay excluded when ancestors forward +them through source wrappers. Independent allowed declarations of the same source +remain eligible. Source plans retain configured identity, so allowing a generator +in one configuration cannot enable its rejected output in another. Plans stay in +depsets until the extraction root; each configured node is visited once there. +Only files owned by a contributing target's repository are eligible, including +when source labels point into another repository. + +`source_extensions` defaults to `ts`, `tsx`, `js`, `jsx`, `mjs`, `cjs`, `rs`, and `py`. +`dependency_attributes` controls detection/collection edges; `source_attributes` +controls source inputs and is also traversed for extraction metadata. Both label +and label-list attributes work. Aliases and `select()` are handled after resolution. + +Other keyword arguments are options accepted by `formatjs_extract_action`. +Use one configured aspect per collection pass. Mixed-language adapters can combine +runtime recognition and source extensions in that aspect. Give separate instances +unique `name` values to distinguish output files. + +Archive ownership, translation projection, catalog merging and runtime installation +remain consumer decisions. + +## Custom extraction actions + +`formatjs_extract_action(ctx, srcs, out, **options)` registers the native action and +returns `FormatjsExtractInfo`. `srcs` accepts a list or depset of source Files; the +caller declares the output and CLI toolchain. The existing `formatjs_extract` rule +uses the same helper, including its options and response-file handling. diff --git a/examples/simple/MODULE.bazel b/examples/simple/MODULE.bazel index c2c44c1..68ab195 100644 --- a/examples/simple/MODULE.bazel +++ b/examples/simple/MODULE.bazel @@ -13,3 +13,4 @@ local_path_override( # Test utilities (not required by rules_formatjs users) bazel_dep(name = "aspect_bazel_lib", version = "2.22.5") +bazel_dep(name = "jq.bzl", version = "0.7.1") diff --git a/examples/simple/auto_extract/BUILD.bazel b/examples/simple/auto_extract/BUILD.bazel new file mode 100644 index 0000000..44e0146 --- /dev/null +++ b/examples/simple/auto_extract/BUILD.bazel @@ -0,0 +1,37 @@ +load("@jq.bzl//jq:jq.bzl", "jq_test") +load("@rules_formatjs//tests/auto_extract:fixtures.bzl", "catalogs", "graph") +load(":extract.bzl", "extract") + +graph( + name = "consumer", + srcs = [ + "messages.py", + "@rules_formatjs//tests/auto_extract:foreign.py", + ], + deps = ["@rules_formatjs//tests/auto_extract:runtime_with_sources"], +) + +catalogs( + name = "messages", + target = ":consumer", +) + +jq_test( + name = "main_repository_only_test", + file1 = ":messages", + file2 = "expected.json", + filter1 = "[.[] | (.defaultMessage // .message)]", +) + +extract( + name = "custom_rule", + srcs = ["messages.py"], + out = "custom_rule.json", +) + +jq_test( + name = "consumer_action_test", + file1 = ":custom_rule", + file2 = "expected.json", + filter1 = "[.[] | (.defaultMessage // .message)]", +) diff --git a/examples/simple/auto_extract/expected.json b/examples/simple/auto_extract/expected.json new file mode 100644 index 0000000..bdab156 --- /dev/null +++ b/examples/simple/auto_extract/expected.json @@ -0,0 +1 @@ +["Consumer message"] diff --git a/examples/simple/auto_extract/extract.bzl b/examples/simple/auto_extract/extract.bzl new file mode 100644 index 0000000..8f23acc --- /dev/null +++ b/examples/simple/auto_extract/extract.bzl @@ -0,0 +1,16 @@ +"""Consumer-defined rule using the public action helper.""" + +load("@rules_formatjs//formatjs:defs.bzl", "formatjs_extract_action") + +def _extract_impl(ctx): + info = formatjs_extract_action(ctx, ctx.files.srcs, ctx.outputs.out) + return [DefaultInfo(files = depset([info.messages])), info] + +extract = rule( + implementation = _extract_impl, + attrs = { + "srcs": attr.label_list(allow_files = [".py"]), + "out": attr.output(mandatory = True), + }, + toolchains = ["@rules_formatjs//formatjs_cli:toolchain_type"], +) diff --git a/examples/simple/auto_extract/messages.py b/examples/simple/auto_extract/messages.py new file mode 100644 index 0000000..11ec61c --- /dev/null +++ b/examples/simple/auto_extract/messages.py @@ -0,0 +1,3 @@ +from intl import define_message + +GREETING = define_message(id="consumer.message", default_message="Consumer message") diff --git a/formatjs/BUILD.bazel b/formatjs/BUILD.bazel index bb78422..2b88caa 100644 --- a/formatjs/BUILD.bazel +++ b/formatjs/BUILD.bazel @@ -2,6 +2,7 @@ exports_files([ "defs.bzl", + "collector.bzl", "extract.bzl", "compile.bzl", "aggregate.bzl", diff --git a/formatjs/aspects.bzl b/formatjs/aspects.bzl index d7f9313..a802a8b 100644 --- a/formatjs/aspects.bzl +++ b/formatjs/aspects.bzl @@ -3,6 +3,7 @@ This module demonstrates how to create aspects that work with formatjs_extract. """ +load(":collector.bzl", "formatjs_message_collector_aspect") load(":extract.bzl", "FormatjsExtractInfo") def _message_stats_aspect_impl(target, ctx): @@ -113,32 +114,5 @@ message_validator_aspect = aspect( """, ) -def _message_collector_aspect_impl(target, ctx): - direct = [target[FormatjsExtractInfo].messages] if FormatjsExtractInfo in target else [] - transitive = [] - for attr_name in ["deps", "srcs"]: - for dep in getattr(ctx.rule.attr, attr_name, []): - if OutputGroupInfo in dep and hasattr(dep[OutputGroupInfo], "all_messages"): - transitive.append(dep[OutputGroupInfo].all_messages) - - return [OutputGroupInfo(all_messages = depset(direct, transitive = transitive))] - -message_collector_aspect = aspect( - implementation = _message_collector_aspect_impl, - attr_aspects = ["deps", "srcs"], - doc = """Collect original message catalogs without registering merge actions. - - Follows both deps and srcs, including libraries that carry extraction - metadata through a source target. Transitive depsets retain shared catalogs - only once without flattening the dependency graph at each target. No jq - toolchain is required. Empty graphs expose an empty all_messages group. - - Consumers can merge the original catalogs with their own duplicate-ID and - source-location policies. This aspect does not extract messages from raw - sources: extraction targets must provide FormatjsExtractInfo. - - Usage: - bazel build //path/to:target --aspects=//formatjs:aspects.bzl%message_collector_aspect \\ - --output_groups=all_messages - """, -) +# Preserve the collection-only entrypoint without requiring a CLI toolchain. +message_collector_aspect = formatjs_message_collector_aspect() diff --git a/formatjs/collector.bzl b/formatjs/collector.bzl new file mode 100644 index 0000000..eb8a632 --- /dev/null +++ b/formatjs/collector.bzl @@ -0,0 +1,208 @@ +"""Catalog collection with optional dependency-triggered source extraction.""" + +load(":extract.bzl", "FormatjsExtractInfo", "formatjs_extract_action") + +FormatjsRuntimeInfo = provider( + doc = "Marks a runtime dependency that enables FormatJS extraction.", + fields = [], +) + +FormatjsMessagesInfo = provider( + doc = "Original catalogs and runtime detection propagated by automatic extraction.", + fields = { + "catalogs": "Depset of original catalogs; no intermediate merges.", + "uses_runtime": "Whether a configured dependency enables extraction.", + }, +) + +_SourcePlansInfo = provider(fields = ["catalogs", "covered", "plan", "plans"]) + +def _main_repository(_target, ctx): + return ctx.label.repo_name == "" + +def _targets(ctx, attributes): + result = [] + for attribute in attributes: + value = getattr(ctx.rule.attr, attribute, []) + result.extend(value if type(value) == "list" else [value] if value != None else []) + return result + +def _source_path(source): + return source.path + +def _source_files(plans, covered, extensions): + requested = {} + candidates = {} + direct = [] + owners = {} + + # Parents request source views before their children are visited. Each + # configured node is visited once, even through shared source wrappers. + for plan in plans.to_list()[::-1]: + if not plan.allowed or not (plan.enabled or plan.key in requested): + continue + if plan.key in requested: + owners[plan.key] = True + for source in plan.sources: + if plan.enabled: + candidates.setdefault(plan.key[0].repo_name, []).append(source.files) + if source.key == None: + direct.append(source.files) + else: + requested[source.key] = True + eligible = {source: True for source in depset(transitive = direct).to_list()} + excluded = {source: True for source in covered.to_list()} + return [ + source + for repository, inputs in candidates.items() + for source in depset(transitive = inputs).to_list() + if source.extension in extensions and source.owner.repo_name == repository and source not in excluded and ( + source in eligible or (source.owner, source.root.path) in owners + ) + ] + +def _extract_catalogs(ctx, name, sources, options): + if not sources: + return [] + if not options.get("extract_source_location", False): + return [formatjs_extract_action( + ctx, + sources, + ctx.actions.declare_file(ctx.label.name + "_" + name + ".json"), + **options + ).messages] + + # The CLI keeps only one location for duplicate IDs within a batch. + catalogs = [] + names = {} + for source in sources: + filename = source.basename[:80] + "." + str(hash(source.path)) + names.setdefault(filename, []).append(source) + for filename, files in names.items(): + # Disambiguate hash collisions without lengthening every source path. + for index, source in enumerate(sorted(files, key = _source_path) if len(files) > 1 else files): + suffix = "_" + str(index) if len(files) > 1 else "" + catalogs.append(formatjs_extract_action( + ctx, + [source], + ctx.actions.declare_file(ctx.label.name + "_" + name + "/" + filename + suffix + ".json"), + **options + ).messages) + return catalogs + +def formatjs_message_collector_aspect( + name = "messages", + runtime_labels = [], + is_runtime = None, + should_extract = _main_repository, + collect_at = None, + source_extensions = ["ts", "tsx", "js", "jsx", "mjs", "cjs", "rs", "py"], + dependency_attributes = ["deps", "srcs"], + source_attributes = ["srcs"], + **extraction_options): + """Creates a collector that can extract sources based on configured dependencies. + + Args: + name: Unique suffix for this aspect's output files. + runtime_labels: Label objects for runtime targets that enable extraction. + is_runtime: Optional predicate (target, ctx) for native providers or + FormatjsRuntimeInfo. Extraction is disabled when neither is supplied. + should_extract: Predicate (target, ctx) limiting source owners; defaults + to targets in the main repository. Collection still crosses others. + collect_at: Optional predicate (target, ctx) selecting extraction roots for + rule adapters. Without it, CLI users set --aspects_parameters=formatjs_root=//app:main. + source_extensions: File extensions without leading dots. + dependency_attributes: Dependency edges followed for runtime detection + and catalog collection. Label and label-list attributes work. + source_attributes: Source attributes; also traversed for extraction metadata. + **extraction_options: Keyword options accepted by formatjs_extract_action. + + Returns: + An aspect exposing FormatjsMessagesInfo and all_messages output group. + Explicit extraction providers remain unchanged on the underlying target. + """ + if not name: + fail("formatjs_message_collector_aspect requires a nonempty name") + for label in runtime_labels: + if type(label) != "Label": + fail("runtime_labels must contain Label objects; use Label(...) in the calling .bzl module") + extraction_enabled = bool(runtime_labels) or is_runtime != None + attributes = depset(dependency_attributes + source_attributes).to_list() + + def _impl(target, ctx): + dependencies = _targets(ctx, attributes) + inherited = [dependency[_SourcePlansInfo] for dependency in dependencies if _SourcePlansInfo in dependency] + runtime_targets = {runtime.label: True for runtime in ctx.attr._formatjs_runtime_targets} + uses_runtime = ( + target.label in runtime_targets or + ctx.label in runtime_targets or + (is_runtime != None and is_runtime(target, ctx)) or + any([dependency[FormatjsMessagesInfo].uses_runtime for dependency in dependencies if FormatjsMessagesInfo in dependency]) + ) + explicit_catalogs = depset( + [target[FormatjsExtractInfo].messages] if FormatjsExtractInfo in target else [], + transitive = [dependency.catalogs for dependency in inherited], + ) + covered = depset( + transitive = [dependency.covered for dependency in inherited] + ( + [getattr(target[FormatjsExtractInfo], "srcs", depset())] if FormatjsExtractInfo in target else [] + ), + ) + plan = None + if extraction_enabled: + allowed = should_extract(target, ctx) + + # Labels alone conflate generators reached through transitions. + plan = struct( + key = (target.label, ctx.bin_dir.path), + allowed = allowed, + enabled = allowed and uses_runtime, + sources = tuple([ + struct( + key = dependency[_SourcePlansInfo].plan.key if _SourcePlansInfo in dependency else None, + files = dependency[DefaultInfo].files, + ) + for dependency in _targets(ctx, source_attributes) + if DefaultInfo in dependency + ]), + ) + plans = depset( + [plan] if plan != None else [], + transitive = [dependency.plans for dependency in inherited], + order = "postorder", + ) + catalogs = explicit_catalogs + if extraction_enabled: + if collect_at != None: + is_root = collect_at(target, ctx) + else: + if not ctx.attr.formatjs_root: + fail("Set --aspects_parameters=formatjs_root=//app:main or configure collect_at") + is_root = str(ctx.label) == ctx.attr.formatjs_root + if is_root: + catalogs = depset(_extract_catalogs( + ctx, + name, + _source_files(plans, covered, source_extensions), + extraction_options, + ), transitive = [explicit_catalogs]) + return [ + FormatjsMessagesInfo( + catalogs = catalogs, + uses_runtime = uses_runtime, + ), + _SourcePlansInfo(catalogs = explicit_catalogs, covered = covered, plan = plan, plans = plans), + OutputGroupInfo(all_messages = catalogs), + ] + + attrs = { + "_formatjs_runtime_targets": attr.label_list(default = runtime_labels, cfg = "target"), + } + if extraction_enabled and collect_at == None: + attrs["formatjs_root"] = attr.string(default = "") + return aspect( + implementation = _impl, + attr_aspects = attributes, + attrs = attrs, + toolchains = [Label("//formatjs_cli:toolchain_type")] if extraction_enabled else [], + ) diff --git a/formatjs/defs.bzl b/formatjs/defs.bzl index 59570e6..13fd9f5 100644 --- a/formatjs/defs.bzl +++ b/formatjs/defs.bzl @@ -9,8 +9,14 @@ load( _formatjs_aggregate = "formatjs_aggregate", _formatjs_aggregate_aspect = "formatjs_aggregate_aspect", ) +load( + ":collector.bzl", + _FormatjsMessagesInfo = "FormatjsMessagesInfo", + _FormatjsRuntimeInfo = "FormatjsRuntimeInfo", + _formatjs_message_collector_aspect = "formatjs_message_collector_aspect", +) load(":compile.bzl", _formatjs_compile = "formatjs_compile") -load(":extract.bzl", _FormatjsExtractInfo = "FormatjsExtractInfo", _formatjs_extract = "formatjs_extract") +load(":extract.bzl", _FormatjsExtractInfo = "FormatjsExtractInfo", _formatjs_extract = "formatjs_extract", _formatjs_extract_action = "formatjs_extract_action") load(":verify.bzl", _formatjs_verify_test = "formatjs_verify_test") # Public API - Rules @@ -25,3 +31,9 @@ formatjs_aggregate_aspect = _formatjs_aggregate_aspect # Public API - Providers FormatjsExtractInfo = _FormatjsExtractInfo FormatjsAggregateInfo = _FormatjsAggregateInfo + +# Public API - Rule/aspect integration +formatjs_extract_action = _formatjs_extract_action +formatjs_message_collector_aspect = _formatjs_message_collector_aspect +FormatjsMessagesInfo = _FormatjsMessagesInfo +FormatjsRuntimeInfo = _FormatjsRuntimeInfo diff --git a/formatjs/extract.bzl b/formatjs/extract.bzl index 766a612..ad0000e 100644 --- a/formatjs/extract.bzl +++ b/formatjs/extract.bzl @@ -17,6 +17,8 @@ For more information about the FormatJS CLI and its extraction features, see: https://formatjs.github.io/docs/tooling/cli """ +_FORMATJS_TOOLCHAIN_TYPE = Label("//formatjs_cli:toolchain_type") + FormatjsExtractInfo = provider( doc = """Provider containing information about extracted FormatJS messages. @@ -30,87 +32,109 @@ FormatjsExtractInfo = provider( }, ) -def _formatjs_extract_impl(ctx): - """Implementation of the formatjs_extract rule.""" - - # Get FormatJS CLI from toolchain - toolchain = ctx.toolchains["@rules_formatjs//formatjs_cli:toolchain_type"] - formatjs_cli_info = toolchain.formatjs_cli_info - - # Determine output file - if ctx.outputs.out: - out_file = ctx.outputs.out - else: - out_file = ctx.actions.declare_file(ctx.label.name + ".json") - - # Build arguments for formatjs CLI +def formatjs_extract_action( + ctx, + srcs, + out, + format = "", + id_interpolation_pattern = "", + extract_source_location = False, + additional_component_names = [], + additional_function_names = [], + ignore = [], + throws = True, + pragma = "", + preserve_whitespace = False, + flatten = False): + """Registers extraction for a rule or aspect and returns its metadata. + + Args: + ctx: Rule or aspect context with the FormatJS CLI toolchain. + srcs: List or depset of source Files. + out: Caller-declared output File. + format: CLI output formatter. + id_interpolation_pattern: Generated message ID pattern. + extract_source_location: Include source locations. + additional_component_names: Additional component names. + additional_function_names: Additional function names. + ignore: Source exclusion patterns. + throws: Fail on extraction errors. + pragma: Metadata pragma. + preserve_whitespace: Preserve message whitespace. + flatten: Hoist selectors. + + Returns: + FormatjsExtractInfo for the registered action. + """ args = ctx.actions.args() args.add("extract") - args.add("--out-file", out_file) - - if ctx.attr.format: - args.add("--format", ctx.attr.format) - - if ctx.attr.id_interpolation_pattern: - args.add("--id-interpolation-pattern", ctx.attr.id_interpolation_pattern) - - if ctx.attr.extract_source_location: + args.add("--out-file", out) + for flag, value in [ + ("--format", format), + ("--id-interpolation-pattern", id_interpolation_pattern), + ]: + if value: + args.add(flag, value) + if extract_source_location: args.add("--extract-source-location") - - for component in ctx.attr.additional_component_names: - args.add("--additional-component-names", component) - - for function in ctx.attr.additional_function_names: - args.add("--additional-function-names", function) - - for pattern in ctx.attr.ignore: - args.add("--ignore", pattern) - - if ctx.attr.throws: + for flag, values in [ + ("--additional-component-names", additional_component_names), + ("--additional-function-names", additional_function_names), + ("--ignore", ignore), + ]: + for value in values: + args.add(flag, value) + + if throws: args.add("--throws") - - if ctx.attr.pragma: - args.add("--pragma", ctx.attr.pragma) - - if ctx.attr.preserve_whitespace: + if pragma: + args.add("--pragma", pragma) + if preserve_whitespace: args.add("--preserve-whitespace") - - if ctx.attr.flatten: + if flatten: args.add("--flatten") - # Keep source lists below Windows command-line limits. + source_files = srcs if type(srcs) == "depset" else depset(srcs) sources = ctx.actions.args() - sources.add_all(ctx.files.srcs) + sources.add_all(source_files) sources.set_param_file_format("multiline") sources.use_param_file("--in-file=%s", use_always = True) - - # Run formatjs extract (v0.1.1+ sorts keys by default) ctx.actions.run( - executable = formatjs_cli_info.cli, + executable = ctx.toolchains[_FORMATJS_TOOLCHAIN_TYPE].formatjs_cli_info.cli, arguments = [args, sources], - inputs = depset(ctx.files.srcs), - outputs = [out_file], + inputs = source_files, + outputs = [out], mnemonic = "FormatjsExtract", progress_message = "Extracting messages from %{label}", - env = { - "BAZEL_BINDIR": ".", - }, + env = {"BAZEL_BINDIR": "."}, + ) + return FormatjsExtractInfo( + messages = out, + srcs = source_files, + id_interpolation_pattern = id_interpolation_pattern, ) +def _formatjs_extract_impl(ctx): + out = ctx.outputs.out or ctx.actions.declare_file(ctx.label.name + ".json") + info = formatjs_extract_action( + ctx, + ctx.files.srcs, + out, + format = ctx.attr.format, + id_interpolation_pattern = ctx.attr.id_interpolation_pattern, + extract_source_location = ctx.attr.extract_source_location, + additional_component_names = ctx.attr.additional_component_names, + additional_function_names = ctx.attr.additional_function_names, + ignore = ctx.attr.ignore, + throws = ctx.attr.throws, + pragma = ctx.attr.pragma, + preserve_whitespace = ctx.attr.preserve_whitespace, + flatten = ctx.attr.flatten, + ) return [ - DefaultInfo( - files = depset([out_file]), - # Make the output file available for direct reference - runfiles = ctx.runfiles(files = [out_file]), - ), - FormatjsExtractInfo( - messages = out_file, - srcs = depset(ctx.files.srcs), - id_interpolation_pattern = ctx.attr.id_interpolation_pattern, - ), - OutputGroupInfo( - json = depset([out_file]), - ), + DefaultInfo(files = depset([out]), runfiles = ctx.runfiles(files = [out])), + info, + OutputGroupInfo(json = depset([out])), ] formatjs_extract = rule( diff --git a/tests/auto_extract/Aa/messages.py b/tests/auto_extract/Aa/messages.py new file mode 100644 index 0000000..6394a8c --- /dev/null +++ b/tests/auto_extract/Aa/messages.py @@ -0,0 +1,2 @@ +from py_intl import define_message +message = define_message(default_message="First collision message") diff --git a/tests/auto_extract/BB/messages.py b/tests/auto_extract/BB/messages.py new file mode 100644 index 0000000..5104ccb --- /dev/null +++ b/tests/auto_extract/BB/messages.py @@ -0,0 +1,2 @@ +from py_intl import define_message +message = define_message(default_message="Second collision message") diff --git a/tests/auto_extract/BUILD.bazel b/tests/auto_extract/BUILD.bazel new file mode 100644 index 0000000..355dca7 --- /dev/null +++ b/tests/auto_extract/BUILD.bazel @@ -0,0 +1,534 @@ +load("@jq.bzl", "jq") +load("@jq.bzl//jq:jq.bzl", "jq_test") +load("//formatjs:defs.bzl", "formatjs_extract") +load(":fixtures.bzl", "adapter_catalogs", "alias_catalogs", "batch_catalogs", "batch_report", "catalogs", "collection_only_catalogs", "custom_action", "custom_catalogs", "graph", "ignored", "metadata", "python_source", "report", "runtime", "scoped_catalogs", "selected_alias_catalogs", "source_chain", "unmatched_report") + +exports_files( + ["foreign.py"], + visibility = ["//visibility:public"], +) + +runtime(name = "runtime") + +runtime( + name = "runtime_with_sources", + srcs = ["foreign.py"], + visibility = ["//visibility:public"], +) + +alias( + name = "runtime_alias", + actual = ":runtime", +) + +graph( + name = "leaf", + srcs = select({"//conditions:default": ["leaf.py"]}), + deps = select({"//conditions:default": [":runtime_alias"]}), +) + +alias( + name = "leaf_alias", + actual = ":leaf", +) + +graph( + name = "bridge", + deps = [":leaf_alias"], +) + +formatjs_extract( + name = "legacy", + srcs = ["legacy.tsx"], + extract_source_location = True, +) + +metadata( + name = "legacy_sources", + srcs = ["legacy.tsx"], + extraction = ":legacy", +) + +graph( + name = "root", + srcs = [ + "root.rs", + ":legacy_sources", + ], + deps = [ + ":bridge", + ":leaf", + ], +) + +catalogs( + name = "root_catalogs", + target = ":root", +) + +jq( + name = "merged", + srcs = [":root_catalogs"], + args = ["--slurp"], + filter = "add | [.[] | (.defaultMessage // .message)] | sort", +) + +jq_test( + name = "messages_test", + file1 = ":merged", + file2 = "messages.json", +) + +report( + name = "root_report", + target = ":root", +) + +jq_test( + name = "collection_test", + file1 = ":root_report", + file2 = "report.json", +) + +graph( + name = "plain", + srcs = ["leaf.py"], +) + +report( + name = "plain_report", + target = ":plain", +) + +jq_test( + name = "no_runtime_test", + file1 = ":plain_report", + file2 = "empty.json", +) + +graph( + name = "unsupported", + srcs = ["ignored.txt"], + deps = [":runtime"], +) + +report( + name = "unsupported_report", + target = ":unsupported", +) + +jq_test( + name = "unsupported_extension_test", + file1 = ":unsupported_report", + file2 = "unsupported.json", +) + +graph( + name = "adapter_runtime", + runtime = True, +) + +graph( + name = "adapter_source", + srcs = ["leaf.py"], + deps = [":adapter_runtime"], +) + +adapter_catalogs( + name = "adapter_output", + target = ":adapter_source", +) + +jq_test( + name = "runtime_adapter_test", + file1 = ":adapter_output", + file2 = "python.json", + filter1 = "[.[] | (.defaultMessage // .message)]", +) + +graph( + name = "custom_attributes", + runtime_dep = ":runtime_alias", + source = "leaf.py", +) + +custom_catalogs( + name = "custom_output", + target = ":custom_attributes", +) + +jq_test( + name = "custom_attributes_test", + file1 = ":custom_output", + file2 = "python.json", + filter1 = "[.[] | (.defaultMessage // .message)]", +) + +custom_action( + name = "action_output", + srcs = ["root.rs"], +) + +formatjs_extract( + name = "rule_output", + srcs = ["root.rs"], + extract_source_location = True, + id_interpolation_pattern = "[sha512:contenthash:base64:10]", +) + +jq_test( + name = "action_rule_parity_test", + file1 = ":action_output", + file2 = ":rule_output", +) + +graph( + name = "skipped", + srcs = ["root.rs"], + deps = [":leaf"], +) + +scoped_catalogs( + name = "scoped_output", + target = ":skipped", +) + +jq_test( + name = "custom_scope_test", + file1 = ":scoped_output", + file2 = "python.json", + filter1 = "[.[] | (.defaultMessage // .message)]", +) + +collection_only_catalogs( + name = "collection_only_output", + target = ":root", +) + +jq_test( + name = "collection_only_test", + file1 = ":collection_only_output", + file2 = ":legacy", +) + +unmatched_report( + name = "unmatched_output", + target = ":leaf", +) + +jq_test( + name = "unmatched_runtime_test", + file1 = ":unmatched_output", + file2 = "empty.json", +) + +metadata( + name = "partial_metadata", + srcs = [ + "leaf.py", + "legacy.tsx", + ], + extraction = ":legacy", + deps = [":runtime"], +) + +catalogs( + name = "partial_catalogs", + target = ":partial_metadata", +) + +jq( + name = "partial_merged", + srcs = [":partial_catalogs"], + args = ["--slurp"], + filter = "add | [.[] | (.defaultMessage // .message)] | sort", +) + +jq_test( + name = "partial_metadata_test", + file1 = ":partial_merged", + file2 = "partial.json", +) + +source_chain( + name = "chain", + count = 100, +) + +jq_test( + name = "source_chain_messages_test", + file1 = ":chain_merged", + file2 = "chain_count.json", + filter1 = "length", +) + +jq_test( + name = "source_chain_catalog_test", + file1 = ":chain_report", + file2 = "chain_catalog_count.json", + filter1 = ".catalog_count", +) + +catalogs( + name = "unsupported_catalogs", + target = ":unsupported", +) + +jq_test( + name = "empty_filtered_catalog_test", + file1 = ":unsupported_merged", + file2 = "empty_catalog.json", +) + +jq( + name = "unsupported_merged", + srcs = [":unsupported_catalogs"], + args = ["--slurp"], + filter = "(. // []) | add // {}", +) + +alias_catalogs( + name = "alias_output", + target = ":leaf", +) + +jq_test( + name = "aliased_runtime_label_test", + file1 = ":alias_output", + file2 = "python.json", + filter1 = "[.[] | (.defaultMessage // .message)]", +) + +ignored( + name = "ignored_generated", + tags = ["manual"], +) + +graph( + name = "with_ignored", + srcs = [ + "leaf.py", + ":ignored_generated", + ], + tags = ["manual"], + deps = [":runtime"], +) + +catalogs( + name = "ignored_output", + target = ":with_ignored", +) + +jq_test( + name = "ignored_producer_test", + file1 = ":ignored_output", + file2 = "python.json", + filter1 = "[.[] | (.defaultMessage // .message)]", +) + +graph( + name = "shared_one", + srcs = ["shared_one.py"], + deps = [":runtime"], +) + +graph( + name = "shared_two", + srcs = ["shared_two.py"], + deps = [":runtime"], +) + +graph( + name = "shared_root", + deps = [ + ":shared_one", + ":shared_two", + ], +) + +catalogs( + name = "shared_catalogs", + target = ":shared_root", +) + +jq( + name = "shared_locations", + srcs = [":shared_catalogs"], + args = ["--slurp"], + filter = "[.[] | .[\"shared.message\"].file] | sort", +) + +jq_test( + name = "shared_locations_test", + file1 = ":shared_locations", + file2 = "shared_locations.json", +) + +jq( + name = "chain_merged", + srcs = [":chain_catalogs"], + args = ["--slurp"], + filter = "add", +) + +config_setting( + name = "windows", + constraint_values = ["@platforms//os:windows"], +) + +graph(name = "runtime_windows") + +alias( + name = "runtime_selected_alias", + actual = select({ + ":windows": ":runtime_windows", + "//conditions:default": ":runtime", + }), +) + +graph( + name = "leaf_select", + srcs = ["leaf.py"], + deps = [":runtime_selected_alias"], +) + +selected_alias_catalogs( + name = "selected_alias_output", + target = ":leaf_select", +) + +jq_test( + name = "selected_runtime_alias_test", + file1 = ":selected_alias_output", + file2 = "python.json", + filter1 = "[.[] | (.defaultMessage // .message)]", +) + +platform( + name = "windows_platform", + constraint_values = [ + "@platforms//os:windows", + "@platforms//cpu:x86_64", + ], +) + +batch_catalogs( + name = "batch_chain_catalogs", + target = ":chain_99", +) + +batch_report( + name = "batch_chain_report", + target = ":chain_99", +) + +jq_test( + name = "batch_messages_test", + file1 = ":batch_chain_catalogs", + file2 = "chain_count.json", + filter1 = "length", +) + +jq_test( + name = "batch_catalog_test", + file1 = ":batch_chain_report", + file2 = "batch_catalog_count.json", + filter1 = ".catalog_count", +) + +python_source(name = "skipped_generated") + +python_source(name = "allowed_generated") + +graph( + name = "skipped_generated_wrapper", + srcs = [":allowed_generated"], +) + +graph( + name = "scope_wrapper", + srcs = [ + ":skipped", + ":skipped_generated_wrapper", + ], +) + +graph( + name = "scope_wrapper_root", + srcs = [ + ":scope_wrapper", + ":skipped_generated", + ], +) + +scoped_catalogs( + name = "scope_wrapper_output", + target = ":scope_wrapper_root", +) + +jq( + name = "scope_wrapper_merged", + srcs = [":scope_wrapper_output"], + args = ["--slurp"], + filter = "add | [.[] | (.defaultMessage // .message)] | sort", +) + +jq_test( + name = "scope_wrapper_test", + file1 = ":scope_wrapper_merged", + file2 = "python.json", +) + +graph( + name = "scope_direct_root", + srcs = [ + "root.rs", + ":allowed_generated", + ":scope_wrapper", + ], +) + +scoped_catalogs( + name = "scope_direct_output", + target = ":scope_direct_root", +) + +jq( + name = "scope_direct_merged", + srcs = [":scope_direct_output"], + args = ["--slurp"], + filter = "add | [.[] | (.defaultMessage // .message)] | sort", +) + +jq_test( + name = "scope_direct_test", + file1 = ":scope_direct_merged", + file2 = "scope_direct.json", +) + +graph( + name = "collision_root", + srcs = [ + "Aa/messages.py", + "BB/messages.py", + ], + deps = [":runtime"], +) + +catalogs( + name = "collision_output", + target = ":collision_root", +) + +jq( + name = "collision_merged", + srcs = [":collision_output"], + args = ["--slurp"], + filter = "add | [.[] | (.defaultMessage // .message)] | sort", +) + +jq_test( + name = "output_collision_test", + file1 = ":collision_merged", + file2 = "collisions.json", +) diff --git a/tests/auto_extract/batch_catalog_count.json b/tests/auto_extract/batch_catalog_count.json new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/auto_extract/batch_catalog_count.json @@ -0,0 +1 @@ +1 diff --git a/tests/auto_extract/chain_catalog_count.json b/tests/auto_extract/chain_catalog_count.json new file mode 100644 index 0000000..29d6383 --- /dev/null +++ b/tests/auto_extract/chain_catalog_count.json @@ -0,0 +1 @@ +100 diff --git a/tests/auto_extract/chain_count.json b/tests/auto_extract/chain_count.json new file mode 100644 index 0000000..29d6383 --- /dev/null +++ b/tests/auto_extract/chain_count.json @@ -0,0 +1 @@ +100 diff --git a/tests/auto_extract/collisions.json b/tests/auto_extract/collisions.json new file mode 100644 index 0000000..31d186d --- /dev/null +++ b/tests/auto_extract/collisions.json @@ -0,0 +1 @@ +["First collision message", "Second collision message"] diff --git a/tests/auto_extract/configured/BUILD.bazel b/tests/auto_extract/configured/BUILD.bazel new file mode 100644 index 0000000..86b955c --- /dev/null +++ b/tests/auto_extract/configured/BUILD.bazel @@ -0,0 +1,41 @@ +load("@jq.bzl//jq:jq.bzl", "jq_test") +load(":fixtures.bzl", "catalogs", "generate", "graph", "wrapper") + +config_setting( + name = "off", + values = {"compilation_mode": "dbg"}, +) + +generate( + name = "source", + srcs = ["input.py"], + scope_allow = select({ + ":off": False, + "//conditions:default": True, + }), +) + +wrapper( + name = "off_wrapper", + srcs = [":source"], +) + +graph( + name = "root", + srcs = [ + ":off_wrapper", + ":source", + ], +) + +catalogs( + name = "output", + target = ":root", +) + +jq_test( + name = "configured_scope_test", + file1 = ":output", + file2 = "expected.json", + filter1 = "[.[] | (.defaultMessage // .message)]", +) diff --git a/tests/auto_extract/configured/expected.json b/tests/auto_extract/configured/expected.json new file mode 100644 index 0000000..8c6b88b --- /dev/null +++ b/tests/auto_extract/configured/expected.json @@ -0,0 +1 @@ +["Allowed"] diff --git a/tests/auto_extract/configured/fixtures.bzl b/tests/auto_extract/configured/fixtures.bzl new file mode 100644 index 0000000..362a346 --- /dev/null +++ b/tests/auto_extract/configured/fixtures.bzl @@ -0,0 +1,39 @@ +load("//formatjs:defs.bzl", "FormatjsMessagesInfo", "formatjs_message_collector_aspect") + +def _off_impl(_settings, _attr): + return {"//command_line_option:compilation_mode": "dbg"} + +_off = transition(implementation = _off_impl, inputs = [], outputs = ["//command_line_option:compilation_mode"]) + +def _gen(ctx): + out = ctx.actions.declare_file(ctx.label.name + ".py") + text = "Allowed" if ctx.attr.scope_allow else "Rejected" + ctx.actions.write(out, 'from py_intl import define_message\nmessage = define_message(default_message="' + text + '")\n') + return [DefaultInfo(files = depset([out]))] + +generate = rule(implementation = _gen, attrs = {"scope_allow": attr.bool(), "srcs": attr.label_list(allow_files = True)}) + +def _graph(ctx): + return [DefaultInfo(files = depset(transitive = [s[DefaultInfo].files for s in ctx.attr.srcs]))] + +graph = rule(implementation = _graph, attrs = {"srcs": attr.label_list()}) +wrapper = rule(implementation = _graph, attrs = { + "srcs": attr.label_list(cfg = _off), + "_allowlist_function_transition": attr.label(default = "@bazel_tools//tools/allowlists/function_transition_allowlist"), +}) + +def _runtime(_target, ctx): + return ctx.label.name == "root" + +def _scope(_target, ctx): + return getattr(ctx.rule.attr, "scope_allow", True) + +def _root(_target, ctx): + return ctx.label.name == "root" + +messages = formatjs_message_collector_aspect(is_runtime = _runtime, should_extract = _scope, collect_at = _root, extract_source_location = True) + +def _catalogs(ctx): + return [DefaultInfo(files = ctx.attr.target[FormatjsMessagesInfo].catalogs)] + +catalogs = rule(implementation = _catalogs, attrs = {"target": attr.label(aspects = [messages])}) diff --git a/tests/auto_extract/configured/input.py b/tests/auto_extract/configured/input.py new file mode 100644 index 0000000..bd45338 --- /dev/null +++ b/tests/auto_extract/configured/input.py @@ -0,0 +1,2 @@ +from py_intl import define_message +message = define_message(default_message="Generator input stays unscanned") diff --git a/tests/auto_extract/empty.json b/tests/auto_extract/empty.json new file mode 100644 index 0000000..dd0c467 --- /dev/null +++ b/tests/auto_extract/empty.json @@ -0,0 +1,4 @@ +{ + "catalog_count": 0, + "uses_runtime": false +} diff --git a/tests/auto_extract/empty_catalog.json b/tests/auto_extract/empty_catalog.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/auto_extract/empty_catalog.json @@ -0,0 +1 @@ +{} diff --git a/tests/auto_extract/fixtures.bzl b/tests/auto_extract/fixtures.bzl new file mode 100644 index 0000000..d3f801f --- /dev/null +++ b/tests/auto_extract/fixtures.bzl @@ -0,0 +1,230 @@ +"""Real source graphs for the public automatic extraction API.""" + +load("//formatjs:defs.bzl", "FormatjsExtractInfo", "FormatjsMessagesInfo", "FormatjsRuntimeInfo", "formatjs_extract_action", "formatjs_message_collector_aspect") + +def _graph_impl(ctx): + return [DefaultInfo(files = depset(transitive = [source[DefaultInfo].files for source in ctx.attr.srcs]))] + +graph = rule( + implementation = _graph_impl, + attrs = { + "deps": attr.label_list(), + "srcs": attr.label_list(allow_files = True), + "runtime": attr.bool(), + "runtime_dep": attr.label(), + "source": attr.label(allow_single_file = True), + }, +) + +def _runtime_impl(ctx): + return [DefaultInfo(files = depset(ctx.files.srcs)), FormatjsRuntimeInfo()] + +runtime = rule( + implementation = _runtime_impl, + attrs = {"srcs": attr.label_list(allow_files = True)}, +) + +def _metadata_impl(ctx): + return [ + DefaultInfo(files = depset(ctx.files.srcs)), + ctx.attr.extraction[FormatjsExtractInfo], + ] + +metadata = rule( + implementation = _metadata_impl, + attrs = { + "srcs": attr.label_list(allow_files = True), + "extraction": attr.label(providers = [FormatjsExtractInfo]), + "deps": attr.label_list(), + }, +) + +def _collect_at(_target, ctx): + return ctx.label.name in [ + "root", + "leaf", + "plain", + "unsupported", + "adapter_source", + "custom_attributes", + "skipped", + "partial_metadata", + "chain_99", + "consumer", + "shared_root", + "with_ignored", + "leaf_select", + "scope_wrapper_root", + "scope_direct_root", + "collision_root", + ] + +messages_aspect = formatjs_message_collector_aspect( + name = "messages", + collect_at = _collect_at, + runtime_labels = [ + Label("//tests/auto_extract:runtime"), + Label("//tests/auto_extract:runtime_with_sources"), + ], + extract_source_location = True, + id_interpolation_pattern = "[sha512:contenthash:base64:10]", +) + +def _catalogs_impl(ctx): + return [DefaultInfo(files = ctx.attr.target[FormatjsMessagesInfo].catalogs)] + +def _report_impl(ctx): + info = ctx.attr.target[FormatjsMessagesInfo] + output = ctx.actions.declare_file(ctx.label.name + ".json") + ctx.actions.write(output, json.encode({ + "catalog_count": len(info.catalogs.to_list()), + "uses_runtime": info.uses_runtime, + })) + return [DefaultInfo(files = depset([output]))] + +catalogs = rule( + implementation = _catalogs_impl, + attrs = {"target": attr.label(aspects = [messages_aspect])}, +) + +report = rule( + implementation = _report_impl, + attrs = {"target": attr.label(aspects = [messages_aspect])}, +) + +def _adapter_runtime(_target, ctx): + return getattr(ctx.rule.attr, "runtime", False) + +adapter_aspect = formatjs_message_collector_aspect( + name = "adapter", + collect_at = _collect_at, + is_runtime = _adapter_runtime, + source_extensions = ["py"], +) + +adapter_catalogs = rule( + implementation = _catalogs_impl, + attrs = {"target": attr.label(aspects = [adapter_aspect])}, +) + +custom_attributes_aspect = formatjs_message_collector_aspect( + name = "custom", + collect_at = _collect_at, + runtime_labels = [Label("//tests/auto_extract:runtime")], + dependency_attributes = ["runtime_dep"], + source_attributes = ["source"], + source_extensions = ["py"], +) + +custom_catalogs = rule( + implementation = _catalogs_impl, + attrs = {"target": attr.label(aspects = [custom_attributes_aspect])}, +) + +def _action_impl(ctx): + output = ctx.actions.declare_file(ctx.label.name + ".json") + info = formatjs_extract_action( + ctx, + ctx.files.srcs, + output, + extract_source_location = True, + id_interpolation_pattern = "[sha512:contenthash:base64:10]", + ) + return [DefaultInfo(files = depset([output])), info] + +custom_action = rule( + implementation = _action_impl, + attrs = {"srcs": attr.label_list(allow_files = True)}, + toolchains = ["//formatjs_cli:toolchain_type"], +) + +def _scope(_target, ctx): + return ctx.label.name not in ["skipped", "skipped_generated", "skipped_generated_wrapper"] + +scoped_aspect = formatjs_message_collector_aspect( + name = "scoped", + collect_at = _collect_at, + runtime_labels = [Label("//tests/auto_extract:runtime")], + should_extract = _scope, +) + +scoped_catalogs = rule( + implementation = _catalogs_impl, + attrs = {"target": attr.label(aspects = [scoped_aspect])}, +) + +collection_only_aspect = formatjs_message_collector_aspect() + +collection_only_catalogs = rule( + implementation = _catalogs_impl, + attrs = {"target": attr.label(aspects = [collection_only_aspect])}, +) + +unmatched_aspect = formatjs_message_collector_aspect( + collect_at = _collect_at, + runtime_labels = [Label("//tests/auto_extract:ignored_generated")], +) + +unmatched_report = rule( + implementation = _report_impl, + attrs = {"target": attr.label(aspects = [unmatched_aspect])}, +) + +def _python_source_impl(ctx): + output = ctx.actions.declare_file(ctx.label.name + ".py") + ctx.actions.write(output, "from py_intl import define_message\nmessage = define_message(default_message=%s)\n" % json.encode(ctx.label.name)) + return [DefaultInfo(files = depset([output]))] + +python_source = rule(implementation = _python_source_impl) + +def source_chain(name, count): + for index in range(count): + python_source(name = name + "_source_" + str(index)) + graph( + name = name + "_" + str(index), + srcs = [":" + name + "_source_" + str(index)] + ([":" + name + "_" + str(index - 1)] if index else []), + deps = [] if index else [":runtime"], + ) + catalogs(name = name + "_catalogs", target = ":" + name + "_" + str(count - 1)) + report(name = name + "_report", target = ":" + name + "_" + str(count - 1)) + +alias_aspect = formatjs_message_collector_aspect( + runtime_labels = [Label("//tests/auto_extract:runtime_alias")], + collect_at = _collect_at, +) + +alias_catalogs = rule( + implementation = _catalogs_impl, + attrs = {"target": attr.label(aspects = [alias_aspect])}, +) + +def _ignored_impl(ctx): + output = ctx.actions.declare_file(ctx.label.name + ".txt") + ctx.actions.run_shell(outputs = [output], command = "exit 1", mnemonic = "IgnoredProducer") + return [DefaultInfo(files = depset([output]))] + +ignored = rule(implementation = _ignored_impl) + +cli_aspect = formatjs_message_collector_aspect( + runtime_labels = [Label("//tests/auto_extract:runtime_alias"), Label("//tests/auto_extract:runtime_with_sources")], + extract_source_location = True, +) + +selected_alias_aspect = formatjs_message_collector_aspect( + runtime_labels = [Label("//tests/auto_extract:runtime_selected_alias")], + collect_at = _collect_at, +) + +selected_alias_catalogs = rule( + implementation = _catalogs_impl, + attrs = {"target": attr.label(aspects = [selected_alias_aspect])}, +) + +batch_aspect = formatjs_message_collector_aspect( + name = "batch", + runtime_labels = [Label("//tests/auto_extract:runtime_alias")], + collect_at = _collect_at, +) + +batch_catalogs = rule(implementation = _catalogs_impl, attrs = {"target": attr.label(aspects = [batch_aspect])}) +batch_report = rule(implementation = _report_impl, attrs = {"target": attr.label(aspects = [batch_aspect])}) diff --git a/tests/auto_extract/foreign.py b/tests/auto_extract/foreign.py new file mode 100644 index 0000000..8fc61fd --- /dev/null +++ b/tests/auto_extract/foreign.py @@ -0,0 +1,3 @@ +from intl import define_message + +FOREIGN = define_message(id="foreign.runtime", default_message="External runtime internals") diff --git a/tests/auto_extract/ignored.txt b/tests/auto_extract/ignored.txt new file mode 100644 index 0000000..dbfdbec --- /dev/null +++ b/tests/auto_extract/ignored.txt @@ -0,0 +1 @@ +Not a supported source extension. diff --git a/tests/auto_extract/leaf.py b/tests/auto_extract/leaf.py new file mode 100644 index 0000000..98a95fc --- /dev/null +++ b/tests/auto_extract/leaf.py @@ -0,0 +1,3 @@ +from intl import define_message + +GREETING = define_message(id="auto.python", default_message="Automatic Python") diff --git a/tests/auto_extract/legacy.tsx b/tests/auto_extract/legacy.tsx new file mode 100644 index 0000000..888c37d --- /dev/null +++ b/tests/auto_extract/legacy.tsx @@ -0,0 +1,5 @@ +import { FormattedMessage } from "react-intl"; + +export const Message = () => ( + +); diff --git a/tests/auto_extract/messages.json b/tests/auto_extract/messages.json new file mode 100644 index 0000000..07e3f2e --- /dev/null +++ b/tests/auto_extract/messages.json @@ -0,0 +1 @@ +["Automatic Python", "Automatic Rust", "Explicit TypeScript"] diff --git a/tests/auto_extract/partial.json b/tests/auto_extract/partial.json new file mode 100644 index 0000000..dbed1a3 --- /dev/null +++ b/tests/auto_extract/partial.json @@ -0,0 +1 @@ +["Automatic Python", "Explicit TypeScript"] diff --git a/tests/auto_extract/python.json b/tests/auto_extract/python.json new file mode 100644 index 0000000..57e3977 --- /dev/null +++ b/tests/auto_extract/python.json @@ -0,0 +1 @@ +["Automatic Python"] diff --git a/tests/auto_extract/report.json b/tests/auto_extract/report.json new file mode 100644 index 0000000..052aeca --- /dev/null +++ b/tests/auto_extract/report.json @@ -0,0 +1,4 @@ +{ + "catalog_count": 3, + "uses_runtime": true +} diff --git a/tests/auto_extract/root.rs b/tests/auto_extract/root.rs new file mode 100644 index 0000000..10983b3 --- /dev/null +++ b/tests/auto_extract/root.rs @@ -0,0 +1,4 @@ +const MESSAGE: formatjs_intl::MessageDescriptor = formatjs_intl::message_descriptor!( + id: "auto.rust", + default_message: "Automatic Rust" +); diff --git a/tests/auto_extract/scope_direct.json b/tests/auto_extract/scope_direct.json new file mode 100644 index 0000000..8ead4f4 --- /dev/null +++ b/tests/auto_extract/scope_direct.json @@ -0,0 +1 @@ +["Automatic Python", "Automatic Rust", "allowed_generated"] diff --git a/tests/auto_extract/shared_locations.json b/tests/auto_extract/shared_locations.json new file mode 100644 index 0000000..ebdbd23 --- /dev/null +++ b/tests/auto_extract/shared_locations.json @@ -0,0 +1 @@ +["tests/auto_extract/shared_one.py", "tests/auto_extract/shared_two.py"] diff --git a/tests/auto_extract/shared_one.py b/tests/auto_extract/shared_one.py new file mode 100644 index 0000000..5b3634d --- /dev/null +++ b/tests/auto_extract/shared_one.py @@ -0,0 +1,3 @@ +from intl import define_message + +MESSAGE = define_message(id="shared.message", default_message="Shared message") diff --git a/tests/auto_extract/shared_two.py b/tests/auto_extract/shared_two.py new file mode 100644 index 0000000..5b3634d --- /dev/null +++ b/tests/auto_extract/shared_two.py @@ -0,0 +1,3 @@ +from intl import define_message + +MESSAGE = define_message(id="shared.message", default_message="Shared message") diff --git a/tests/auto_extract/unsupported.json b/tests/auto_extract/unsupported.json new file mode 100644 index 0000000..bb20424 --- /dev/null +++ b/tests/auto_extract/unsupported.json @@ -0,0 +1,4 @@ +{ + "catalog_count": 0, + "uses_runtime": true +}