Skip to content

[Extension] Extract and test Compliant and Non-compliant code blocks #91

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
Draft
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
13 changes: 12 additions & 1 deletion builder/build_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def build_docs(
debug: bool,
offline: bool,
spec_lock_consistency_check: bool,
test_rust_blocks: bool,
) -> Path:
"""
Builds the Sphinx documentation with the specified options.
Expand Down Expand Up @@ -72,6 +73,8 @@ def build_docs(
conf_opt_values.append("offline=1")
if debug:
conf_opt_values.append("debug=1")
if test_rust_blocks:
conf_opt_values.append("test_rust_blocks=1")

# Only add the --define argument if there are options to define
if conf_opt_values:
Expand Down Expand Up @@ -151,6 +154,14 @@ def main(root):
help="build in offline mode",
action="store_true",
)

parser.add_argument(
"--test-rust-blocks",
help="Test extracted rust code blocks using rustc",
default=False,
action="store_true"
)

group = parser.add_mutually_exclusive_group()
parser.add_argument(
"--ignore-spec-lock-diff",
Expand Down Expand Up @@ -192,6 +203,6 @@ def main(root):
update_spec_lockfile(SPEC_CHECKSUM_URL, root / "src" / SPEC_LOCKFILE)

rendered = build_docs(
root, "xml" if args.xml else "html", args.clear, args.serve, args.debug, args.offline, not args.ignore_spec_lock_diff,
root, "xml" if args.xml else "html", args.clear, args.serve, args.debug, args.offline, not args.ignore_spec_lock_diff, args.test_rust_blocks
)

27 changes: 21 additions & 6 deletions exts/coding_guidelines/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@
from .common import logger, get_tqdm, bar_format, logging
from sphinx.domains import Domain

import logging

# Get the Sphinx logger
logger = logging.getLogger('sphinx')
logger.setLevel(logging.ERROR)

class CodingGuidelinesDomain(Domain):
name = "coding-guidelines"
label = "Rust Standard Library"
Expand Down Expand Up @@ -42,6 +48,13 @@ def on_build_finished(app, exception):
def setup(app):

app.add_domain(CodingGuidelinesDomain)

app.add_config_value(
name='test_rust_blocks',
default=False,
rebuild='env'
)

app.add_config_value(
name = "offline",
default=False,
Expand Down Expand Up @@ -73,12 +86,14 @@ def setup(app):
logger.setLevel(logging.INFO)
common.disable_tqdm = True

app.connect('env-check-consistency', guidelines_checks.validate_required_fields)
app.connect('env-check-consistency', fls_checks.check_fls)
app.connect('build-finished', write_guidelines_ids.build_finished)
app.connect('build-finished', fls_linking.build_finished)
app.connect('build-finished', on_build_finished)

# Ignore builds while testing code blocks
if not app.config.test_rust_blocks:
app.connect('env-check-consistency', guidelines_checks.validate_required_fields)
app.connect('env-check-consistency', fls_checks.check_fls)
app.connect('build-finished', write_guidelines_ids.build_finished)
app.connect('build-finished', fls_linking.build_finished)
app.connect('build-finished', on_build_finished)

return {
'version': '0.1',
'parallel_read_safe': True,
Expand Down
49 changes: 49 additions & 0 deletions exts/rust-code-runner/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from . import rust_examples_aggregate
from . import rustc
import os
from pathlib import Path

def setup(app):


# Define output directory
app.output_rust = "build/rust-code-blocks/"

# Ensure the src directory exists
base_dir = Path(app.output_rust)
src_dir = base_dir / "src"
src_dir.mkdir(parents=True, exist_ok=True)


# Write Cargo.toml with required dependencies
cargo_toml = base_dir / "Cargo.toml"
cargo_toml.write_text(
"""[package]
name = "sc_generated_tests"
version = "0.1.0"
edition = "2024"

[dependencies]
# tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
""",
encoding="utf-8",
)


print(f"Setup complete in '{base_dir.resolve()}'")

# we hook into 'source-read' because data is mutable at this point and easier to parse
# and it also makes this extension indepandant from `needs`.
if not app.config.test_rust_blocks:
# empty lib.rs on every run (incremental build is not supported)
with open(app.output_rust + "src/lib.rs", "w", encoding="utf-8"):
pass
app.connect('source-read', rust_examples_aggregate.preprocess_rst_for_rust_code)
else:
app.connect('build-finished', rustc.check_rust_test_errors)

return {
'version': '0.1',
'parallel_read_safe': False,
'parallel_write_safe': False,
}
114 changes: 114 additions & 0 deletions exts/rust-code-runner/rust_examples_aggregate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
from sphinx.errors import SphinxError
import logging
import re

class ExecuteRustExamples(SphinxError):
category = "ExecuteRustExamples Error"


def extract_code_blocks(text):
pattern = re.compile(
r"\.\. code-block:: rust\s*\n(?:(?:\s*\n)+)?((?: {2,}.*(?:\n|$))+)",
re.MULTILINE
)

matches = pattern.findall(text)
blocks = []
for i, block in enumerate(matches):
lines = block.splitlines()
non_empty_lines = [line for line in lines if line.strip()]
processed_block = "\n".join(non_empty_lines)
blocks.append(processed_block)

# print(f"====== code block {i + 1} ========")
# print(processed_block)
# print("====== end code block ========")

return blocks

def strip_hidden(code_block):
lines = code_block.splitlines()
result = []
hidden = []
is_hidden = False

for line in lines:
stripped_for_marker_check = line[2:] if line.startswith(" ") else line
if "// HIDDEN START" in stripped_for_marker_check:
is_hidden = True
continue
if "// HIDDEN END" in stripped_for_marker_check:
is_hidden = False
continue
if not is_hidden:
result.append(line)
else:
hidden.append(line)
return "\n".join(result), "\n".join(hidden)

def remove_hidden_blocks_from_document(source_text):
code_block_re = re.compile(
r"(\.\. code-block:: rust\s*\n\n)((?: {2}.*\n)+)",
re.DOTALL
)
# callback for replacing
def replacer(match):
prefix = match.group(1)
code_content = match.group(2)
cleaned_code, hidden_code = strip_hidden(code_content)
# print("============")
# print(hidden_code)
# print("============")
return prefix + cleaned_code

modified_text = code_block_re.sub(replacer, source_text)
return modified_text

import re

def sanitize_code_blocks(code_blocks):
"""
Removes unwanted attributes from each Rust code block:
- `#[macro_export]` (to avoid exported-macro conflicts)
- `#[tokio::main]` (to keep compilation as a library/test)
"""
patterns = [
r'\s*#\s*\[macro_export\]',
r'\s*#\s*\[tokio::main\]'
]
sanitized = []
for block in code_blocks:
lines = block.splitlines()
cleaned = [
line for line in lines
if not any(re.match(pat, line) for pat in patterns)
]
sanitized.append("\n".join(cleaned))
return sanitized

def preprocess_rst_for_rust_code(app, docname, source):

original_content = source[0]
code_blocks = extract_code_blocks(original_content)
code_blocks = sanitize_code_blocks(code_blocks)
modified_content = remove_hidden_blocks_from_document(original_content)
source[0] = modified_content

# print(f"Original content length: {len(original_content)}")
# print(f"Extracted {len(code_blocks)} code blocks")

safe_docname = docname.replace("/", "_").replace("-", "_")
try:
with open(app.output_rust + "src/lib.rs", "a", encoding="utf-8") as f:
for i, block in enumerate(code_blocks, start=1):
f.write(f"// ==== Code Block {i} ====\n")
f.write(f"mod code_block_{i}_{safe_docname} {{\n")
f.write(" #[test]\n")
f.write(f" fn test_block_{safe_docname}_{i}() {{\n")
for line in block.splitlines():
f.write(f" {line}\n") # extra indent for the module
f.write(" }\n") # close fn
f.write("}\n\n") # close mod
except Exception as e:
print("Error writing file:", e)

117 changes: 117 additions & 0 deletions exts/rust-code-runner/rustc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import json
import sys
import os
import subprocess

def print_code_snippet(file_path, line_num, context=3):
"""
Prints a code snippet from a file with context around a specific line.

This function is typically used to display source code around an error line
for better debugging and error reporting.

Args:
file_path (str): Path to the source file.
line_num (int): The line number where the error occurred (1-based index).
context (int, optional): The number of lines to display before and after
the error line. Defaults to 3.

Returns:
None
"""
try:
stripped_lines = []
with open(file_path, "r") as f:
lines = f.readlines()
start = max(line_num - context - 1, 0)
end = min(line_num + context, len(lines))
for i in range(start, end):
prefix = ">" if i == line_num - 1 else " "
stripped_lines.append(f"{prefix} {i+1:4}: {lines[i].rstrip()}")
return "\n".join(stripped_lines)
except Exception as e:
print(f"Could not read file {file_path}: {e}")


import json

def parse_cargo_errors(output: str, output_rust):
"""
Parses Cargo’s JSON output and prints only the first compiler error it finds.
Ignores warnings and notes entirely.
"""
for line in output.splitlines():
line = line.strip()
if not line:
continue

try:
rec = json.loads(line)
except json.JSONDecodeError:
continue

# Only look at compiler messages
if rec.get("reason") != "compiler-message":
continue

msg = rec["message"]
# Skip anything that isn't an error
if msg.get("level") != "error":
continue

text = msg.get("message", "")
spans = msg.get("spans", [])

# Print the high-level error first
print(f"\nerror: {text}")

# Then try to show its primary location
for span in spans:
if span.get("is_primary"):
file = span.get("file_name")
line_start = span.get("line_start")
label = span.get("label", "")
print(f" --> {file}:{line_start} {label}".rstrip(), file= sys.stderr)
# and a snippet
snippet = print_code_snippet(output_rust + file, line_start, context=5)
print("\n" + snippet, file = sys.stderr)
break

# Stop after the first error
return

def check_rust_test_errors(app, exception):
"""
Sphinx 'build-finished' event handler that compiles the generated Rust file in test mode.

This function is connected to the Sphinx build lifecycle and is executed after the build finishes.
It invokes `rustc` in test mode on the generated Rust file and reports any compilation or test-related
errors.
"""
rs_path = app.output_rust
cargo_toml_path = os.path.join(rs_path, "Cargo.toml")
# Run the Rust compiler in test mode with JSON error output format.
# capturing stdout and stderr as text.
result = subprocess.run(
[
"cargo",
"test",
"--message-format=json",
"--manifest-path",
cargo_toml_path
],
capture_output=True,
text=True,
)

if result.returncode != 0:
print("\033[31m--- Cargo test errors ---\033[0m")
parse_cargo_errors(result.stdout, app.output_rust) # parse stdout JSON lines
# print("--- rustc Output ---")
# print(result.stdout)
else:
print("\033[1;32mAll tests succeeded\033[0m") # ANSI magic
# print(result.stdout)
# if result.stderr:
# print("\n\n--- rustc Warnings ---")
# print(result.stderr)
Empty file.
1 change: 0 additions & 1 deletion src/coding-guidelines/macros.rst
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,6 @@ Macros

#[tokio::main] // non-compliant
async fn main() {

}

.. compliant_example::
Expand Down
1 change: 1 addition & 0 deletions src/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
'sphinx.ext.autosectionlabel',
'sphinx_needs',
'coding_guidelines',
'rust-code-runner',
]

# Basic needs configuration
Expand Down
Loading