-
Notifications
You must be signed in to change notification settings - Fork 20
[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
base: main
Are you sure you want to change the base?
Changes from all commits
02adb31
2e3667f
1e752c7
04487d4
557f25e
71344ee
0cd3be4
00be2c4
14aaf64
b882c1d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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" | ||
|
@@ -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, | ||
|
@@ -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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should run the build 2 x then, once with and without Could you update this? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, do you think this should be on the build workflow, or in a seperate workflow? |
||
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, | ||
|
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, | ||
} |
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) | ||
|
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) |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is this empty? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wanted to write unit tests, but i don't think it is necessary anymore. |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -371,7 +371,6 @@ Macros | |
|
||
#[tokio::main] // non-compliant | ||
async fn main() { | ||
|
||
} | ||
|
||
.. compliant_example:: | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I see the reasoning for this, but it should be documented along with the ability to extract the code blocks and run tests in
README.md
imho.Could you update the docs where appropriate under the build instructions?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense 👍