diff --git a/bazel/rules/rules_score/private/filter_execpath.bzl b/bazel/rules/rules_score/private/filter_execpath.bzl new file mode 100644 index 0000000..6ec2135 --- /dev/null +++ b/bazel/rules/rules_score/private/filter_execpath.bzl @@ -0,0 +1,121 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Rule for filtering execpaths files from a target's output and adapting the matching path. +Currently using this rule to resolve the input path for breathe's doxygen XML input. +""" + +load("//bazel/rules/rules_score:providers.bzl", "FilteredExecpathInfo") + +def _filter_execpath_impl(ctx): + """Implementation of the filter_execpath rule. + Iterates over the output files of the target, finds the one matching + filter_pattern, and computes the resolved path suffix after /bin/. + """ + target = ctx.attr.target + filter_pattern = ctx.attr.filter_pattern + flag = ctx.attr.flag + + # Get all output files from the target + files = target[DefaultInfo].files.to_list() + + # Filter for the path matching the filter pattern + matched_file = None + for f in files: + if filter_pattern in f.path: + matched_file = f + break + if not matched_file: + all_paths = [f.path for f in files] + fail("filter_execpath: no path matching '{}' found in outputs of {}. Available paths: {}".format( + filter_pattern, + target.label, + ", ".join(all_paths), + )) + + # Strip the Bazel bin directory prefix from the matched path to get the path + # relative to the output base. Since _relocate() in sphinx_module symlinks + # source files under /, the relocated file lives + # at / where source_dir is the Sphinx source directory. + # Breathe resolves breathe_projects paths relative to source_dir (app.srcdir), + # so we must return just the suffix_part. + matched_path = matched_file.path + bin_dir_prefix = ctx.bin_dir.path + "/" + if matched_path.startswith(bin_dir_prefix): + suffix_part = matched_path[len(bin_dir_prefix):] + else: + suffix_part = matched_path + resolved_arg = flag + "=" + suffix_part + return [ + DefaultInfo(files = depset([matched_file])), + FilteredExecpathInfo( + flag = flag, + resolved_path = suffix_part, + arg = resolved_arg, + matched_file = matched_file, + ), + ] + +_filter_execpath_rule = rule( + implementation = _filter_execpath_impl, + attrs = { + "flag": attr.string( + mandatory = True, + doc = "The Sphinx -D flag prefix (e.g. '-Dbreathe_projects.com').", + ), + "target": attr.label( + mandatory = True, + allow_files = True, + doc = "The Bazel target whose output files to search.", + ), + "filter_pattern": attr.string( + mandatory = True, + doc = "Substring to match when filtering the target's output file paths (e.g. 'doxygen_build/xml').", + ), + }, + doc = """Resolve and filter an execpath from a target's outputs at analysis time. + This rule finds the output file from `target` whose path contains + `filter_pattern`, strips the Bazel bin directory prefix, and provides + the result as a FilteredExecpathInfo for consumption by sphinx_module. + Example usage in BUILD: + load("@score_tooling//bazel/rules/rules_score:rules_score.bzl", "filter_execpath", "sphinx_module") + filter_execpath( + name = "breathe_doxygen_xml", + flag = "-Dbreathe_projects.doxygen_build", + target = "//docs/sphinx:doxygen_xml", + filter_pattern = "doxygen_build/xml", + ) + sphinx_module( + name = "sphinx_doc", + extra_opts_targets = [":breathe_doxygen_xml"], + extra_opts = ["-Dbreathe_default_project=doxygen_build"], + ... + ) + """, +) + +def filter_execpath(name, flag, target, filter_pattern, **kwargs): + """Resolve and filter an execpath from a target's outputs at analysis time. + Args: + name: Name for this target. + flag: The Sphinx -D flag prefix (e.g. "-Dbreathe_projects.doxygen_build"). + target: The Bazel label whose output files to search. + filter_pattern: Substring to match when filtering the target's output file paths. + **kwargs: Additional keyword arguments passed to the underlying rule (e.g. visibility). + """ + _filter_execpath_rule( + name = name, + flag = flag, + target = target, + filter_pattern = filter_pattern, + **kwargs + ) diff --git a/bazel/rules/rules_score/private/sphinx_module.bzl b/bazel/rules/rules_score/private/sphinx_module.bzl index 1f02360..9088575 100644 --- a/bazel/rules/rules_score/private/sphinx_module.bzl +++ b/bazel/rules/rules_score/private/sphinx_module.bzl @@ -10,18 +10,16 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* - # ====================================================================================== # Helpers # ====================================================================================== load("@bazel_skylib//lib:paths.bzl", "paths") load("@rules_python//sphinxdocs:sphinx_docs_library.bzl", "sphinx_docs_library") load("@rules_python//sphinxdocs/private:sphinx_docs_library_info.bzl", "SphinxDocsLibraryInfo") -load("//bazel/rules/rules_score:providers.bzl", "SphinxModuleInfo", "SphinxNeedsInfo") +load("//bazel/rules/rules_score:providers.bzl", "FilteredExecpathInfo", "SphinxModuleInfo", "SphinxNeedsInfo") def _create_config_py(ctx): """Get or generate the conf.py configuration file. - Args: ctx: Rule context """ @@ -70,7 +68,6 @@ def _score_needs_impl(ctx): # Phase 1: Build needs.json (without external needs) needs_inputs = ctx.files.srcs + [config_file] - needs_args = [ "--index_file", ctx.attr.index.files.to_list()[0].path, @@ -81,7 +78,6 @@ def _score_needs_impl(ctx): "--builder", "needs", ] - ctx.actions.run( inputs = needs_inputs, outputs = [needs_output], @@ -92,10 +88,8 @@ def _score_needs_impl(ctx): sphinx_toolchain.sphinx.files_to_run, ], ) - transitive_needs = [dep[SphinxNeedsInfo].needs_json_files for dep in ctx.attr.deps if SphinxNeedsInfo in dep] needs_json_files = depset([needs_output], transitive = transitive_needs) - return [ DefaultInfo( files = needs_json_files, @@ -108,10 +102,30 @@ def _score_needs_impl(ctx): def _score_html_impl(ctx): """Implementation for building a Sphinx module with two-phase build. - Phase 1: Generate needs.json for this module and collect from all deps Phase 2: Generate HTML with external needs and merge all dependency HTML """ + run_args = [] # Copy of the args to forward along to debug runner + args = ctx.actions.args() # Args passed to the action + + # Expand location references in extra_opts and collect as sphinx arguments. + # targets must include all labels referenced via $(location ...) / $(execpaths ...). + location_targets = ctx.attr.srcs + ctx.attr.docs_library_deps + source_prefix = ctx.label.name + + # Process extra_opts targets: these are rule targets (e.g. filter_execpath) + # providing FilteredExecpathInfo with resolved Sphinx arguments. + filtered_files = [] + for target in ctx.attr.extra_opts_targets: + info = target[FilteredExecpathInfo] + args.add(info.arg) + run_args.append(info.arg) + filtered_files.append(info.matched_file) + for opt in ctx.attr.extra_opts: + # Standard extra_opts: expand locations and pass through + expanded_opt = ctx.expand_location(opt, targets = location_targets) + args.add(expanded_opt) + run_args.append(expanded_opt) # Collect all transitive dependencies with deduplication modules = [] @@ -126,38 +140,20 @@ def _score_html_impl(ctx): "id_prefix": "", "css_class": "", } - for dep in ctx.attr.deps: if SphinxModuleInfo in dep: modules.extend([dep[SphinxModuleInfo].html_dir]) - needs_external_needs_json = ctx.actions.declare_file(ctx.label.name + "/needs_external_needs.json") - ctx.actions.write( output = needs_external_needs_json, content = json.encode_indent(needs_external_needs, indent = " "), ) - - # Read template and substitute PROJECT_NAME - config_file = ctx.actions.declare_file(ctx.label.name + "/conf.py") - template = sphinx_toolchain.conf_template.files.to_list()[0] - - ctx.actions.expand_template( - template = template, - output = config_file, - substitutions = { - "{PROJECT_NAME}": ctx.label.name.replace("_", " ").title(), - }, - ) - - source_prefix = ctx.label.name sphinx_source_files = [] # Materialize a file under the `_sources` dir def _relocate(source_file, dest_path = None): if not dest_path: dest_path = source_file.short_path.removeprefix(ctx.attr.strip_prefix) - dest_path = paths.join(source_prefix, dest_path) if source_file.is_directory: dest_file = ctx.actions.declare_directory(dest_path) @@ -174,21 +170,12 @@ def _score_html_impl(ctx): for dep in ctx.attr.deps: if SphinxModuleInfo in dep: modules.extend([dep[SphinxModuleInfo].html_dir]) - for t in ctx.attr.docs_library_deps: info = t[SphinxDocsLibraryInfo] for entry in info.transitive.to_list(): for original in entry.files: new_path = entry.prefix + original.short_path.removeprefix(entry.strip_prefix) _relocate(original, new_path) - - needs_external_needs_json = ctx.actions.declare_file(ctx.label.name + "/needs_external_needs.json") - - ctx.actions.write( - output = needs_external_needs_json, - content = json.encode_indent(needs_external_needs, indent = " "), - ) - config_file = _create_config_py(ctx) # Sphinx only accepts a single directory to read its doc sources from. @@ -196,14 +183,13 @@ def _score_html_impl(ctx): # we need to merge the two into a single directory. for orig_file in ctx.files.srcs: _relocate(orig_file) - relocated_index_file = "" for input_file in sphinx_source_files: if input_file.path.endswith("/index.rst"): relocated_index_file = input_file.path # Build HTML with external needs - html_inputs = sphinx_source_files + ctx.files.needs + [config_file, needs_external_needs_json] + html_inputs = sphinx_source_files + ctx.files.needs + filtered_files + [config_file, needs_external_needs_json] sphinx_html_output = ctx.actions.declare_directory(ctx.label.name + "/_html") html_args = [ "--index_file", @@ -215,11 +201,10 @@ def _score_html_impl(ctx): "--builder", "html", ] - ctx.actions.run( inputs = html_inputs, outputs = [sphinx_html_output], - arguments = html_args, + arguments = html_args + [args], progress_message = "Building HTML: %s" % ctx.label.name, executable = sphinx_toolchain.sphinx.files_to_run.executable, tools = [ @@ -237,7 +222,6 @@ def _score_html_impl(ctx): "--main", sphinx_html_output.path, ] - merge_inputs = [sphinx_html_output] # Add each dependency @@ -257,7 +241,6 @@ def _score_html_impl(ctx): executable = sphinx_toolchain.html_merge_tool.files_to_run.executable, tools = [sphinx_toolchain.html_merge_tool.files_to_run], ) - return [ DefaultInfo(files = depset(ctx.files.needs + [html_output])), SphinxModuleInfo( @@ -268,13 +251,11 @@ def _score_html_impl(ctx): # ====================================================================================== # Rule definitions # ====================================================================================== - _score_needs = rule( implementation = _score_needs_impl, attrs = sphinx_rule_attrs, toolchains = ["//bazel/rules/rules_score:toolchain_type"], ) - _score_html = rule( implementation = _score_html_impl, attrs = dict( @@ -287,6 +268,14 @@ _score_html = rule( allow_files = True, doc = "Submodule symbols.needs targets for this module.", ), + extra_opts_targets = attr.label_list( + providers = [FilteredExecpathInfo], + doc = "Label targets that resolve to extra Sphinx arguments at analysis time. " + + "Target must provide FilteredExecpathInfo.", + ), + extra_opts = attr.string_list( + doc = "Regular additional string options to pass onto Sphinx.", + ), ), toolchains = ["//bazel/rules/rules_score:toolchain_type"], ) @@ -294,7 +283,6 @@ _score_html = rule( # ====================================================================================== # Rule wrappers # ====================================================================================== - def sphinx_module( name, srcs, @@ -303,14 +291,14 @@ def sphinx_module( docs_library_deps = [], sphinx = Label("//bazel/rules/rules_score:score_build"), strip_prefix = "", + extra_opts = [], + extra_opts_targets = [], testonly = False, visibility = ["//visibility:public"]): """Build a Sphinx module with transitive HTML dependencies. - This rule builds documentation modules into complete HTML sites with transitive dependency collection. All dependencies are automatically included in a modules/ subdirectory for intersphinx cross-referencing. - Args: name: Name of the target srcs: List of source files (.rst, .md) with index file first @@ -323,6 +311,12 @@ def sphinx_module( source files. e.g., given `//sphinxdocs/docs:foo.md`, stripping `docs/` makes Sphinx see `foo.md` in its generated source directory. If not specified, then {any}`native.package_name` is used. + extra_opts: {type}`list[str]` Additional string options to pass onto Sphinx building. + On each provided option, a location expansion is performed. + See {any}`ctx.expand_location`. + extra_opts_targets: {type}`list[label]` Label targets that resolve to extra Sphinx + arguments at analysis time. Each target must provide FilteredExecpathInfo + (e.g. filter_execpath targets). visibility: Bazel visibility """ _score_needs( @@ -333,7 +327,6 @@ def sphinx_module( testonly = testonly, visibility = visibility, ) - _score_html( name = name, srcs = srcs, @@ -341,6 +334,8 @@ def sphinx_module( deps = deps, docs_library_deps = docs_library_deps, needs = [d + "_needs" for d in deps], + extra_opts = extra_opts, + extra_opts_targets = extra_opts_targets, testonly = testonly, visibility = visibility, ) diff --git a/bazel/rules/rules_score/providers.bzl b/bazel/rules/rules_score/providers.bzl index f0e63f0..b0614b2 100644 --- a/bazel/rules/rules_score/providers.bzl +++ b/bazel/rules/rules_score/providers.bzl @@ -205,3 +205,17 @@ SphinxNeedsInfo = provider( "needs_json_files": "Depset of needs.json files including transitive dependencies", }, ) +FilteredExecpathInfo = provider( + doc = """Provider for resolved filtered execpath targets. + Produced by the filter_execpath rule, this provider carries a resolved + Sphinx argument (flag=path) computed at analysis time from a target's output + files. Currently used to pass the location of Doxygen XML output to Breathe via a Sphinx + option. + """, + fields = { + "flag": "String – the Sphinx -D flag prefix (e.g. '-Dbreathe_projects.com').", + "resolved_path": "String – the resolved path suffix (after /bin/) to the matched output.", + "arg": "String – the fully formed argument: flag=resolved_path.", + "matched_file": "File – the matched output file from the target.", + }, +) diff --git a/bazel/rules/rules_score/rules_score.bzl b/bazel/rules/rules_score/rules_score.bzl index be2fe9c..2e873f3 100644 --- a/bazel/rules/rules_score/rules_score.bzl +++ b/bazel/rules/rules_score/rules_score.bzl @@ -10,10 +10,10 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* - load( "//bazel/rules/rules_score:providers.bzl", _ComponentInfo = "ComponentInfo", + _FilteredExecpathInfo = "FilteredExecpathInfo", _SphinxSourcesInfo = "SphinxSourcesInfo", _UnitInfo = "UnitInfo", ) @@ -37,6 +37,10 @@ load( "//bazel/rules/rules_score/private:dependable_element.bzl", _dependable_element = "dependable_element", ) +load( + "//bazel/rules/rules_score/private:filter_execpath.bzl", + _filter_execpath = "filter_execpath", +) load( "//bazel/rules/rules_score/private:fmea.bzl", _fmea = "fmea", @@ -64,12 +68,14 @@ assumptions_of_use = _assumptions_of_use component_requirements = _component_requirements dependability_analysis = _dependability_analysis feature_requirements = _feature_requirements +filter_execpath = _filter_execpath fmea = _fmea sphinx_module = _sphinx_module unit = _unit unit_design = _unit_design component = _component dependable_element = _dependable_element +FilteredExecpathInfo = _FilteredExecpathInfo SphinxSourcesInfo = _SphinxSourcesInfo UnitInfo = _UnitInfo ComponentInfo = _ComponentInfo diff --git a/bazel/rules/rules_score/src/sphinx_wrapper.py b/bazel/rules/rules_score/src/sphinx_wrapper.py index b308636..e3f679d 100644 --- a/bazel/rules/rules_score/src/sphinx_wrapper.py +++ b/bazel/rules/rules_score/src/sphinx_wrapper.py @@ -110,12 +110,15 @@ def validate_arguments(args: argparse.Namespace) -> None: raise ValueError(f"Index file does not exist: {args.index_file}") -def build_sphinx_arguments(args: argparse.Namespace) -> List[str]: +def build_sphinx_arguments( + args: argparse.Namespace, extra_args: List[str] = None +) -> List[str]: """ Build the argument list for Sphinx. Args: args: Parsed command-line arguments + extra_args: Additional arguments to forward to Sphinx (e.g., -D options from extra_opts) Returns: List of arguments to pass to Sphinx @@ -154,6 +157,10 @@ def build_sphinx_arguments(args: argparse.Namespace) -> List[str]: base_arguments.extend(["-b", args.builder]) + # Forward extra options (e.g., -D flags) to Sphinx + if extra_args: + base_arguments.extend(extra_args) + return base_arguments @@ -240,7 +247,7 @@ def parse_arguments() -> argparse.Namespace: help=f"Port to use for live preview (default: {DEFAULT_PORT}). Use 0 for auto-detection.", ) - return parser.parse_args() + return parser.parse_known_args() def main() -> int: @@ -251,14 +258,14 @@ def main() -> int: Exit code (0 for success, non-zero for failure) """ try: - args = parse_arguments() + args, extra_args = parse_arguments() validate_arguments(args) # Create processor instance stdout_processor = StdoutProcessor() stderr_processor = StderrProcessor() # Redirect stdout and stderr with redirect_stderr(stderr_processor), redirect_stdout(stdout_processor): - sphinx_args = build_sphinx_arguments(args) + sphinx_args = build_sphinx_arguments(args, extra_args) exit_code = run_sphinx_build(sphinx_args, args.builder) return exit_code except ValueError as e: