Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,4 @@ jobs:
uses: DeterminateSystems/magic-nix-cache-action@v8

- name: Run integration tests
run: nix build -L .#checks.x86_64-linux.runE2ETests
run: nix build -L .#checks.x86_64-linux.mdbook-check-code-test
18 changes: 17 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,19 @@ version = "0.1.0"
edition = "2021"
license = "AGPL-3.0-only"

[lib]
name = "mdbook_check_code"
path = "src/lib.rs"

[[bin]]
name = "mdbook-check-code"
path = "src/main.rs"

[[test]]
name = "integration"
path = "tests/integration.rs"
required-features = ["integration-tests"]

[dependencies]
mdbook = "0.4"
pulldown-cmark = "0.11"
Expand All @@ -22,6 +31,13 @@ chrono = "0.4"
clap = { version = "4.5", features = ["derive", "cargo"] }
sha2 = "0.10.9"
directories = "6.0.0"
tokio = { version = "1.42", features = ["process", "fs", "io-util"] }
tokio = { version = "1.42", features = ["process", "fs", "io-util", "rt"] }
futures = "0.3"
num_cpus = "1.16"

[dev-dependencies]
tokio = { version = "1.42", features = ["macros", "rt-multi-thread"] }

[features]
default = []
integration-tests = []
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,20 @@ Optional:

## Testing

Test with included fixtures:
Run the test suite (requires compilers: gcc, clang with parasol target, tsc, solc):

```bash
# With all compilers (Nix)
nix develop --command bash -c "cd tests/fixtures && mdbook build"
# Run all tests (unit + integration)
cargo test --features test-util

# Or with Cargo
cargo build --release
cd tests/fixtures && mdbook build
# Run only unit tests (no compilers required)
cargo test --lib

# Run integration tests
cargo test --test integration --features test-util

# With Nix (provides all compilers)
nix develop --command cargo test --features test-util
```

## License
Expand Down
43 changes: 9 additions & 34 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,6 @@
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;

fixture-src = gitignoreSource ./tests/fixtures;

# Map script names to their specific dependencies
scriptDeps = {
format-markdown = with pkgs; [ git nodePackages.prettier ];
Expand Down Expand Up @@ -104,12 +102,13 @@
echo "Markdown formatting check passed" > $out/result
'';

# The script inlined for brevity, consider extracting it
# so that it becomes independent of nix
runE2ETests = pkgs.runCommand "e2e-tests" {
# Run all tests including integration tests
# Use gitignoreSource to include test fixtures (cleanCargoSource filters them out)
mdbook-check-code-test = craneLib.cargoTest (commonArgs // {
src = gitignoreSource ./.;
inherit cargoArtifacts;
cargoTestExtraArgs = "--features integration-tests";
nativeBuildInputs = with pkgs; [
mdbook

# C compilers
sunscreen-llvm-pkg
gcc
Expand All @@ -119,33 +118,9 @@
nodePackages.typescript
solc
];
} ''
cp -r ${fixture-src}/* $TMPDIR/

# Make everything in this directory writable, otherwise all the
# commands below will fail.
chmod -R u+w .

export CLANG="${sunscreen-llvm-pkg}/bin/clang"
export RUST_LOG=info

# Set XDG_DATA_HOME to a temporary location for approval storage
# This allows the approval mechanism to work in the nix sandbox
export XDG_DATA_HOME=$TMPDIR/xdg-data

# Replace the mdbook-check-code path in book.toml
# to point to the built binary in this derivation.
sed -i "s|../../target/release/mdbook-check-code|${mdbook-check-code}/bin/mdbook-check-code|g" book.toml

# Approve the book.toml for security
${mdbook-check-code}/bin/mdbook-check-code allow

mdbook build

# After the build is successful, copy the final output to the expected $out path.
mkdir $out
cp -r $TMPDIR/book/* $out
'';
CLANG = "${sunscreen-llvm-pkg}/bin/clang";
RUST_LOG = "info";
});
};

devShells.default = with pkgs;
Expand Down
3 changes: 3 additions & 0 deletions src/approval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ pub fn is_approved(book_toml_path: &Path) -> Result<bool> {
}

/// Approve a book.toml
#[allow(dead_code)] // Used by CLI binary
pub fn approve(book_toml_path: &Path) -> Result<()> {
let content = fs::read_to_string(book_toml_path)
.with_context(|| format!("Failed to read {}", book_toml_path.display()))?;
Expand All @@ -65,6 +66,7 @@ pub fn approve(book_toml_path: &Path) -> Result<()> {
}

/// Deny (remove approval) for a book.toml
#[allow(dead_code)] // Used by CLI binary
pub fn deny(book_toml_path: &Path) -> Result<()> {
let content = fs::read_to_string(book_toml_path)
.with_context(|| format!("Failed to read {}", book_toml_path.display()))?;
Expand All @@ -85,6 +87,7 @@ pub fn deny(book_toml_path: &Path) -> Result<()> {
}

/// List all approved books
#[allow(dead_code)] // Used by CLI binary
pub fn list_approved() -> Result<Vec<String>> {
let approval_dir = get_approval_dir()?;

Expand Down
7 changes: 3 additions & 4 deletions src/extractor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,8 @@ pub struct CodeBlock {
///
/// # Example
///
/// ```ignore
/// let markdown = r#"
/// # My Code
/// ````ignore
/// let markdown = r#"# My Code
///
/// ```c
/// int main() { return 0; }
Expand All @@ -72,7 +71,7 @@ pub struct CodeBlock {
/// let blocks = extract_code_blocks(markdown);
/// assert_eq!(blocks.len(), 1);
/// assert_eq!(blocks[0].language, "c");
/// ```
/// ````
pub fn extract_code_blocks(content: &str) -> Vec<CodeBlock> {
let parser = Parser::new(content);
let mut code_blocks = Vec::new();
Expand Down
8 changes: 6 additions & 2 deletions src/language.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ impl LanguageMetadata {
///
/// # Examples
///
/// ```ignore
/// ```
/// use mdbook_check_code::get_language_metadata;
///
/// let makefile_meta = get_language_metadata("makefile");
/// assert!(makefile_meta.is_complete_filename());
/// assert_eq!(makefile_meta.file_extension, "Makefile");
Expand Down Expand Up @@ -61,7 +63,9 @@ impl LanguageMetadata {
///
/// # Examples
///
/// ```ignore
/// ```
/// use mdbook_check_code::get_language_metadata;
///
/// let metadata = get_language_metadata("c");
/// assert_eq!(metadata.fence_markers, vec!["c", "h"]);
/// assert_eq!(metadata.file_extension, ".c");
Expand Down
26 changes: 26 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
//! mdbook-check-code library
//!
//! This library provides the preprocessor implementation for validating code blocks
//! in mdBook projects. The primary interface is the mdbook-check-code binary, but
//! the library can be used programmatically for testing or custom integrations.
//!
//! ## Public API
//!
//! The main public interface is [`CheckCodePreprocessor`], which implements the
//! mdBook `Preprocessor` trait.
//!
//! Additional utilities:
//! - [`get_language_metadata`] - Get metadata for a language (fence markers and file extension)
//! - [`LanguageMetadata`] - Metadata structure for a language

mod approval;
mod compilation;
mod config;
mod extractor;
mod language;
mod preprocessor;
mod reporting;
mod task_collector;

pub use language::{get_language_metadata, LanguageMetadata};
pub use preprocessor::CheckCodePreprocessor;
60 changes: 47 additions & 13 deletions src/preprocessor.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
use crate::approval::is_approved;
use crate::config::CheckCodeConfig;
use crate::language::LanguageRegistry;
use crate::reporting::print_info;
use crate::{compilation, reporting, task_collector};
use anyhow::{Context, Result};
use chrono::Local;
use mdbook::book::Book;
use mdbook::preprocess::{Preprocessor, PreprocessorContext};
use tempfile::TempDir;
Expand Down Expand Up @@ -35,11 +35,39 @@ use tempfile::TempDir;
/// The preprocessor validates compiler paths to prevent command injection attacks.
/// Compiler paths cannot contain shell metacharacters (`;`, `|`, `&`, `` ` ``) or
/// use parent directory traversal (`..`).
pub struct CheckCodePreprocessor;
pub struct CheckCodePreprocessor {
#[cfg(feature = "integration-tests")]
skip_approval: bool,
}

impl CheckCodePreprocessor {
pub fn new() -> Self {
Self
Self {
#[cfg(feature = "integration-tests")]
skip_approval: false,
}
}

/// Create preprocessor with approval checking disabled.
///
/// # Safety
///
/// **WARNING**: This bypasses SHA256-based security checks and should ONLY
/// be used in tests. This function is only available when the `integration-tests`
/// feature is enabled.
///
/// # Example
///
/// ```toml
/// [dev-dependencies]
/// mdbook-check-code = { version = "0.1", features = ["integration-tests"] }
/// ```
#[cfg(feature = "integration-tests")]
#[allow(dead_code)] // Used by integration tests with integration-tests feature
pub fn new_for_testing() -> Self {
Self {
skip_approval: true,
}
}
}

Expand All @@ -51,15 +79,19 @@ impl Default for CheckCodePreprocessor {

impl CheckCodePreprocessor {
pub async fn run_async(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book> {
eprintln!(
"{} [INFO] (mdbook_check_code): Preprocessor started",
Local::now().format("%Y-%m-%d %H:%M:%S")
);

let book_toml_path = ctx.root.join("book.toml");
if !is_approved(&book_toml_path)? {
reporting::report_approval_error(&book_toml_path)?;
anyhow::bail!("book.toml not approved");
print_info("Preprocessor started");

#[cfg(feature = "integration-tests")]
let skip_approval = self.skip_approval;
#[cfg(not(feature = "integration-tests"))]
let skip_approval = false;

if !skip_approval {
let book_toml_path = ctx.root.join("book.toml");
if !is_approved(&book_toml_path)? {
reporting::report_approval_error(&book_toml_path)?;
anyhow::bail!("book.toml not approved");
}
}

let config = CheckCodeConfig::from_preprocessor_context(ctx)?;
Expand Down Expand Up @@ -109,7 +141,9 @@ impl Preprocessor for CheckCodePreprocessor {
}

fn run(&self, ctx: &PreprocessorContext, book: Book) -> Result<Book> {
tokio::runtime::Handle::current().block_on(self.run_async(ctx, book))
let runtime = tokio::runtime::Runtime::new()
.context("Failed to create Tokio runtime for async preprocessing")?;
runtime.block_on(self.run_async(ctx, book))
}

fn supports_renderer(&self, _renderer: &str) -> bool {
Expand Down
37 changes: 22 additions & 15 deletions src/reporting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,26 @@ use std::fmt::Display;
use std::path::Path;
use std::time::Duration;

/// Prints an error message to stderr with mdBook-style timestamp and prefix.
pub fn print_error<S: Display>(message: S) {
/// Internal helper for printing messages with consistent formatting.
fn print_message<S: Display>(level: &str, message: S) {
eprintln!(
"{} [ERROR] (mdbook_check_code): {}",
"{} [{}] (mdbook_check_code): {}",
Local::now().format("%Y-%m-%d %H:%M:%S"),
level,
message
);
}

/// Prints an error message to stderr with mdBook-style timestamp and prefix.
pub fn print_error<S: Display>(message: S) {
print_message("ERROR", message);
}

/// Prints an info message to stderr with mdBook-style timestamp and prefix.
pub fn print_info<S: Display>(message: S) {
print_message("INFO", message);
}

/// Reports the approval error to stderr with mdBook-style formatting.
pub fn report_approval_error(book_toml_path: &Path) -> Result<()> {
print_error("book.toml not approved for code execution");
Expand Down Expand Up @@ -110,18 +121,14 @@ pub fn print_compilation_statistics(results: &[CompilationResult], parallel_dura
};
let parallel_ms = parallel_duration.as_millis();

eprintln!(
"{} [INFO] (mdbook_check_code): Successfully validated {} code block(s) ({})",
Local::now().format("%Y-%m-%d %H:%M:%S"),
total_blocks,
stats_str
);
eprintln!(
"{} [INFO] (mdbook_check_code): Preprocessor finished in {}ms (avg {}ms per block)",
Local::now().format("%Y-%m-%d %H:%M:%S"),
parallel_ms,
avg_ms
);
print_info(format!(
"Successfully validated {} code block(s) ({})",
total_blocks, stats_str
));
print_info(format!(
"Preprocessor finished in {}ms (avg {}ms per block)",
parallel_ms, avg_ms
));

log::debug!("Timing breakdown by language:");
for (lang, count) in sorted_stats {
Expand Down
Loading
Loading