mdbook_exercises/
preprocessor.rs

1//! mdBook preprocessor integration.
2//!
3//! This module provides the mdBook preprocessor that transforms exercise
4//! directives in markdown files into interactive HTML.
5
6use crate::parser::parse_exercise;
7use crate::render::{render_exercise_with_config, RenderConfig};
8use mdbook::book::{Book, BookItem};
9use mdbook::errors::Error;
10use mdbook::preprocess::{Preprocessor, PreprocessorContext};
11use regex::Regex;
12use std::path::Path;
13
14/// The mdBook preprocessor for exercises.
15pub struct ExercisesPreprocessor;
16
17impl ExercisesPreprocessor {
18    /// Create a new preprocessor instance.
19    pub fn new() -> ExercisesPreprocessor {
20        ExercisesPreprocessor
21    }
22
23    /// Load configuration from the preprocessor context.
24    fn load_config(ctx: &PreprocessorContext) -> RenderConfig {
25        let mut config = RenderConfig::default();
26
27        if let Some(exercises_config) = ctx.config.get("preprocessor.exercises") {
28            if let Some(enabled) = exercises_config.get("enabled") {
29                config.enabled = enabled.as_bool().unwrap_or(true);
30            }
31            if let Some(reveal_hints) = exercises_config.get("reveal_hints") {
32                config.reveal_hints = reveal_hints.as_bool().unwrap_or(false);
33            }
34            if let Some(reveal_solution) = exercises_config.get("reveal_solution") {
35                config.reveal_solution = reveal_solution.as_bool().unwrap_or(false);
36            }
37            if let Some(playground) = exercises_config.get("playground") {
38                config.enable_playground = playground.as_bool().unwrap_or(true);
39            }
40            if let Some(playground_url) = exercises_config.get("playground_url") {
41                if let Some(url) = playground_url.as_str() {
42                    config.playground_url = url.to_string();
43                }
44            }
45            if let Some(progress) = exercises_config.get("progress_tracking") {
46                config.enable_progress = progress.as_bool().unwrap_or(true);
47            }
48            if let Some(manage_assets) = exercises_config.get("manage_assets") {
49                config.manage_assets = manage_assets.as_bool().unwrap_or(false);
50            }
51        }
52
53        config
54    }
55
56    /// Process a single chapter's content.
57    fn process_chapter(content: &str, config: &RenderConfig) -> Result<String, Error> {
58        // First, check if the content has any exercise directives
59        if !content.contains("::: exercise") && !content.contains("::: usecase") {
60            return Ok(content.to_string());
61        }
62
63        // Parse the exercise from the content
64        match parse_exercise(content) {
65            Ok(exercise) => {
66                // If we successfully parsed an exercise, render it
67                match render_exercise_with_config(&exercise, config) {
68                    Ok(html) => {
69                        // Wrap the HTML in a div and include the original non-directive content
70                        // Actually, we need a smarter approach: replace the directives with HTML
71                        // but keep the surrounding content
72
73                        // For now, if the whole file is an exercise, just return the rendered HTML
74                        // with some wrapper content
75                        let replaced = Self::replace_exercise_region(content, &html);
76                        Ok(replaced)
77                    }
78                    Err(e) => {
79                        // Return original content with an error message
80                        Ok(format!(
81                            "<!-- Exercise render error: {} -->\n\n{}",
82                            e, content
83                        ))
84                    }
85                }
86            }
87            Err(e) => {
88                // Return original content with an error message
89                Ok(format!(
90                    "<!-- Exercise parse error: {} -->\n\n{}",
91                    e, content
92                ))
93            }
94        }
95    }
96}
97
98impl Default for ExercisesPreprocessor {
99    fn default() -> Self {
100        Self::new()
101    }
102}
103
104impl Preprocessor for ExercisesPreprocessor {
105    fn name(&self) -> &str {
106        "exercises"
107    }
108
109    fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
110        // Info log in the same style as mdbook-quiz
111        eprintln!(
112            "[INFO] (mdbook-exercises): Running the mdbook-exercises preprocessor (v{})",
113            env!("CARGO_PKG_VERSION")
114        );
115
116        let config = Self::load_config(ctx);
117
118        if !config.enabled {
119            eprintln!("[INFO] (mdbook-exercises): Disabled by configuration; skipping.");
120            return Ok(book);
121        }
122
123        if config.manage_assets {
124            if let Err(e) = Self::install_assets(ctx) {
125                eprintln!("[WARN] (mdbook-exercises): Failed to install assets: {}", e);
126            } else {
127                eprintln!("[INFO] (mdbook-exercises): Assets installed to book theme directory.");
128            }
129        } else {
130            // Provide a helpful hint if assets aren't found in the theme directory
131            if let Some(hint) = Self::asset_setup_hint(ctx) {
132                eprintln!("[INFO] (mdbook-exercises): {}", hint);
133            }
134        }
135
136        // Process each chapter
137        book.for_each_mut(|item| {
138            if let BookItem::Chapter(chapter) = item {
139                if let Some(ref mut content) = Some(&mut chapter.content) {
140                    match Self::process_chapter(content, &config) {
141                        Ok(new_content) => {
142                            chapter.content = new_content;
143                        }
144                        Err(e) => {
145                            eprintln!(
146                                "Warning: Failed to process exercises in {}: {}",
147                                chapter.name, e
148                            );
149                        }
150                    }
151                }
152            }
153        });
154
155        Ok(book)
156    }
157
158    fn supports_renderer(&self, renderer: &str) -> bool {
159        // We support HTML renderers
160        renderer == "html"
161    }
162}
163
164/// A more sophisticated processor that handles inline exercise includes.
165///
166/// This allows syntax like:
167/// ```markdown
168/// Some introductory text...
169///
170/// {{#exercise path/to/exercise.md}}
171///
172/// Some concluding text...
173/// ```
174pub struct ExerciseIncludeProcessor {
175    config: RenderConfig,
176    book_root: std::path::PathBuf,
177}
178
179impl ExerciseIncludeProcessor {
180    /// Create a new include processor.
181    pub fn new(book_root: &Path, config: RenderConfig) -> Self {
182        Self {
183            config,
184            book_root: book_root.to_path_buf(),
185        }
186    }
187
188    /// Process a chapter, replacing {{#exercise ...}} includes.
189    pub fn process(&self, content: &str) -> Result<String, Error> {
190        let include_re = Regex::new(r"\{\{#exercise\s+([^}]+)\}\}")
191            .map_err(|e| Error::msg(format!("Regex error: {}", e)))?;
192
193        let mut result = content.to_string();
194
195        for cap in include_re.captures_iter(content) {
196            let full_match = cap.get(0).unwrap().as_str();
197            let exercise_path = cap.get(1).unwrap().as_str().trim();
198
199            let full_path = self.book_root.join(exercise_path);
200
201            match std::fs::read_to_string(&full_path) {
202                Ok(exercise_content) => match parse_exercise(&exercise_content) {
203                    Ok(exercise) => match render_exercise_with_config(&exercise, &self.config) {
204                        Ok(html) => {
205                            let wrapped = format!(
206                                r#"<div class="exercise-container">
207{}
208</div>"#,
209                                html
210                            );
211                            result = result.replace(full_match, &wrapped);
212                        }
213                        Err(e) => {
214                            let error_html = format!(
215                                r#"<div class="exercise-error">
216  <p><strong>Error rendering exercise:</strong> {}</p>
217  <p>File: {}</p>
218</div>"#,
219                                e, exercise_path
220                            );
221                            result = result.replace(full_match, &error_html);
222                        }
223                    },
224                    Err(e) => {
225                        let error_html = format!(
226                            r#"<div class="exercise-error">
227  <p><strong>Error parsing exercise:</strong> {}</p>
228  <p>File: {}</p>
229</div>"#,
230                            e, exercise_path
231                        );
232                        result = result.replace(full_match, &error_html);
233                    }
234                },
235                Err(e) => {
236                    let error_html = format!(
237                        r#"<div class="exercise-error">
238  <p><strong>Error loading exercise file:</strong> {}</p>
239  <p>File: {}</p>
240</div>"#,
241                        e, exercise_path
242                    );
243                    result = result.replace(full_match, &error_html);
244                }
245            }
246        }
247
248        Ok(result)
249    }
250}
251
252/// Preprocessor that supports both inline exercises and include syntax.
253pub struct FullExercisesPreprocessor;
254
255impl FullExercisesPreprocessor {
256    pub fn new() -> Self {
257        Self
258    }
259}
260
261impl Default for FullExercisesPreprocessor {
262    fn default() -> Self {
263        Self::new()
264    }
265}
266
267impl Preprocessor for FullExercisesPreprocessor {
268    fn name(&self) -> &str {
269        "exercises"
270    }
271
272    fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
273        // Info log in the same style as mdbook-quiz
274        eprintln!(
275            "[INFO] (mdbook-exercises): Running the mdbook-exercises preprocessor (v{})",
276            env!("CARGO_PKG_VERSION")
277        );
278
279        let config = ExercisesPreprocessor::load_config(ctx);
280        if !config.enabled {
281            eprintln!("[INFO] (mdbook-exercises): Disabled by configuration; skipping.");
282            return Ok(book);
283        }
284        if config.manage_assets {
285            if let Err(e) = ExercisesPreprocessor::install_assets(ctx) {
286                eprintln!("[WARN] (mdbook-exercises): Failed to install assets: {}", e);
287            } else {
288                eprintln!("[INFO] (mdbook-exercises): Assets installed to book theme directory.");
289            }
290        } else {
291            if let Some(hint) = ExercisesPreprocessor::asset_setup_hint(ctx) {
292                eprintln!("[INFO] (mdbook-exercises): {}", hint);
293            }
294        }
295        let book_root = ctx.root.join(&ctx.config.book.src);
296
297        book.for_each_mut(|item| {
298            if let BookItem::Chapter(chapter) = item {
299                let content = &chapter.content;
300
301                // First, process any {{#exercise ...}} includes
302                let include_processor = ExerciseIncludeProcessor::new(&book_root, config.clone());
303                let after_includes = match include_processor.process(content) {
304                    Ok(c) => c,
305                    Err(e) => {
306                        eprintln!(
307                            "Warning: Failed to process exercise includes in {}: {}",
308                            chapter.name, e
309                        );
310                        content.clone()
311                    }
312                };
313
314                // Then, process inline exercises
315                let final_content =
316                    match ExercisesPreprocessor::process_chapter(&after_includes, &config) {
317                        Ok(c) => c,
318                        Err(e) => {
319                            eprintln!(
320                                "Warning: Failed to process inline exercises in {}: {}",
321                                chapter.name, e
322                            );
323                            after_includes
324                        }
325                    };
326
327                chapter.content = final_content;
328            }
329        });
330
331        Ok(book)
332    }
333
334    fn supports_renderer(&self, renderer: &str) -> bool {
335        renderer == "html"
336    }
337}
338
339impl ExercisesPreprocessor {
340    /// Replace the contiguous exercise directive region with rendered HTML, preserving surrounding content.
341    fn replace_exercise_region(content: &str, rendered_html: &str) -> String {
342        // Find start of the exercise region (exercise or usecase)
343        let re_start = Regex::new(r"(?m)^\s*:::\s+(exercise|usecase)\b").unwrap();
344        let Some(m) = re_start.find(content) else { return content.to_string(); };
345
346        // Count directive starts/ends to find the end of the region
347        let re_open = Regex::new(r"^\s*:::\s+[a-zA-Z]").unwrap();
348        let re_close = Regex::new(r"^\s*:::\s*$").unwrap();
349        let mut open: i32 = 0;
350        let mut in_region = false;
351        let mut end_idx = content.len();
352        let mut offset = 0usize;
353        for line in content.split_inclusive('\n') {
354            let ls = offset;
355            let le = offset + line.len();
356            offset = le;
357            if ls < m.start() { continue; }
358            let t = line.trim_end_matches(['\n','\r']);
359            if re_open.is_match(t) {
360                if !in_region { in_region = true; }
361                open += 1;
362            } else if in_region && re_close.is_match(t) {
363                open -= 1;
364                if open <= 0 { end_idx = le; break; }
365            }
366        }
367
368        let mut out = String::new();
369        out.push_str(&content[..m.start()]);
370        out.push_str(&format!("<div class=\"exercise-container\">\n{}\n</div>\n", rendered_html));
371        out.push_str(&content[end_idx..]);
372        out
373    }
374
375    /// Install exercises.css and exercises.js into the book's theme directory when manage_assets is enabled.
376    fn install_assets(ctx: &PreprocessorContext) -> Result<(), Error> {
377        use std::fs;
378        use std::io::Write;
379        let theme_dir = ctx.root.join(&ctx.config.book.src).join("theme");
380        fs::create_dir_all(&theme_dir)
381            .map_err(|e| Error::msg(format!("Failed to create theme dir {}: {}", theme_dir.display(), e)))?;
382
383        // Embed asset contents at compile time and write them
384        const CSS: &str = include_str!("../assets/exercises.css");
385        const JS: &str = include_str!("../assets/exercises.js");
386
387        let css_path = theme_dir.join("exercises.css");
388        let js_path = theme_dir.join("exercises.js");
389
390        // Write CSS
391        {
392            let mut f = fs::File::create(&css_path)
393                .map_err(|e| Error::msg(format!("Failed to write {}: {}", css_path.display(), e)))?;
394            f.write_all(CSS.as_bytes())
395                .map_err(|e| Error::msg(format!("Failed to write {}: {}", css_path.display(), e)))?;
396        }
397        // Write JS
398        {
399            let mut f = fs::File::create(&js_path)
400                .map_err(|e| Error::msg(format!("Failed to write {}: {}", js_path.display(), e)))?;
401            f.write_all(JS.as_bytes())
402                .map_err(|e| Error::msg(format!("Failed to write {}: {}", js_path.display(), e)))?;
403        }
404
405        Ok(())
406    }
407
408    /// If assets are missing and not managed automatically, return a hint for setup.
409    fn asset_setup_hint(ctx: &PreprocessorContext) -> Option<String> {
410        use std::fs;
411        let theme_dir = ctx.root.join(&ctx.config.book.src).join("theme");
412        let css_path = theme_dir.join("exercises.css");
413        let js_path = theme_dir.join("exercises.js");
414
415        let css_exists = fs::metadata(&css_path).is_ok();
416        let js_exists = fs::metadata(&js_path).is_ok();
417        if css_exists && js_exists {
418            return None;
419        }
420
421        Some(format!(
422            "Assets not found under '{}'. Either enable manage_assets = true or copy assets manually and reference them in [output.html]: additional-css=['theme/exercises.css'], additional-js=['theme/exercises.js']",
423            theme_dir.display()
424        ))
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn test_process_chapter_no_exercises() {
434        let content = "# Just a normal chapter\n\nSome content here.";
435        let config = RenderConfig::default();
436
437        let result = ExercisesPreprocessor::process_chapter(content, &config).unwrap();
438
439        // Should return unchanged
440        assert_eq!(result, content);
441    }
442
443    #[test]
444    fn test_process_chapter_with_exercise() {
445        let content = r#"# My Exercise
446
447::: exercise
448id: test-ex
449difficulty: beginner
450:::
451
452Some description.
453
454::: starter
455```rust
456fn main() {}
457```
458:::
459"#;
460        let config = RenderConfig::default();
461
462        let result = ExercisesPreprocessor::process_chapter(content, &config).unwrap();
463
464        // Should contain rendered HTML
465        assert!(result.contains("exercise-container"));
466        assert!(result.contains("test-ex"));
467    }
468    
469    #[test]
470    fn test_process_chapter_with_usecase() {
471        let content = r#"# My UseCase
472
473::: usecase
474id: test-uc
475domain: general
476difficulty: beginner
477:::
478
479::: scenario
480Scen...
481:::
482
483::: prompt
484Prompt...
485:::
486"#;
487        let config = RenderConfig::default();
488
489        let result = ExercisesPreprocessor::process_chapter(content, &config).unwrap();
490
491        // Should contain rendered HTML
492        assert!(result.contains("exercise-container"));
493        assert!(result.contains("test-uc"));
494        assert!(result.contains("usecase-exercise"));
495    }
496}