Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions bazel/rules/rules_score/private/filter_execpath.bzl
Original file line number Diff line number Diff line change
@@ -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 <source_prefix>/<original_path>, the relocated file lives
# at <source_dir>/<suffix_part> 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
)
89 changes: 42 additions & 47 deletions bazel/rules/rules_score/private/sphinx_module.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Expand Down Expand Up @@ -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,
Expand All @@ -81,7 +78,6 @@ def _score_needs_impl(ctx):
"--builder",
"needs",
]

ctx.actions.run(
inputs = needs_inputs,
outputs = [needs_output],
Expand All @@ -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,
Expand All @@ -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 = []
Expand All @@ -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)
Expand All @@ -174,36 +170,26 @@ 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.
# Because plain files and generated files are in different directories,
# 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",
Expand All @@ -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 = [
Expand All @@ -237,7 +222,6 @@ def _score_html_impl(ctx):
"--main",
sphinx_html_output.path,
]

merge_inputs = [sphinx_html_output]

# Add each dependency
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -287,14 +268,21 @@ _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"],
)

# ======================================================================================
# Rule wrappers
# ======================================================================================

def sphinx_module(
name,
srcs,
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -333,14 +327,15 @@ def sphinx_module(
testonly = testonly,
visibility = visibility,
)

_score_html(
name = name,
srcs = srcs,
index = index,
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,
)
Loading
Loading