Radu Iosif

112 papers A* 6A 9B 22C 3Journal 55Unranked 16
YearRankTypeTitle / Venue / Authors
2026 J jnl
Log. Methods Comput. Sci.
Radu Iosif, Florian Zuleger
2025 conf
CAV (3)
Marius Bozga, Radu Iosif, Arnaud Sangnier, Neven Villani
2025 J jnl
CoRR
Marius Bozga, Radu Iosif, Arnaud Sangnier, Neven Villani
2025 conf
FSTTCS
Marius Bozga, Radu Iosif, Florian Zuleger
2025 J jnl
CoRR
Marius Bozga, Radu Iosif, Florian Zuleger
2025 ed.
FROM
Andrei Arusoaie, Horatiu Cheval, Radu Iosif
2025 A* conf
LICS
Marius Bozga, Radu Iosif, Florian Zuleger
2025 conf
NETYS
Radu Iosif, Arnaud Sangnier, Neven Villani
2025 J jnl
CoRR
Radu Iosif, Arnaud Sangnier, Neven Villani
2024 J jnl
CoRR
Lucas Bueri, Radu Iosif, Florian Zuleger
2024 J jnl
CoRR
Marius Bozga, Radu Iosif, Florian Zuleger
2024 B conf
LPAR
Mark Chimes, Radu Iosif, Florian Zuleger
2024 J jnl
CoRR
Mark Chimes, Radu Iosif, Florian Zuleger
2023 J jnl
CoRR
Radu Iosif, Florian Zuleger
2023 B conf
CONCUR
Radu Iosif, Florian Zuleger
2023 J jnl
CoRR
Radu Iosif, Florian Zuleger
2023 J jnl
CoRR
Marius Bozga, Lucas Bueri, Radu Iosif, Florian Zuleger
2023 J jnl
Theor. Comput. Sci.
Marius Bozga, Radu Iosif, Joseph Sifakis
2022 A conf
IJCAR
Marius Bozga, Lucas Bueri, Radu Iosif
2022 J jnl
CoRR
Marius Bozga, Lucas Bueri, Radu Iosif
2022 J jnl
Inf. Process. Lett.
Mnacho Echenim, Radu Iosif, Nicolas Peltier
2022 B conf
CONCUR
Marius Bozga, Lucas Bueri, Radu Iosif
2022 J jnl
CoRR
Marius Bozga, Lucas Bueri, Radu Iosif
2022 J jnl
CoRR
Radu Iosif, Florian Zuleger
2022 J jnl
Proc. ACM Program. Lang.
Emma Ahrens, Marius Bozga, Radu Iosif, Joost-Pieter Katoen
2021 J jnl
J. Log. Algebraic Methods Program.
Marius Bozga, Radu Iosif, Joseph Sifakis
2021 B conf
CSL
Mnacho Echenim, Radu Iosif, Nicolas Peltier
2021 J jnl
CoRR
Emma Ahrens, Marius Bozga, Radu Iosif, Joost-Pieter Katoen
2021 conf
FACS
Marius Bozga, Radu Iosif
2021 A conf
CADE
Mnacho Echenim, Radu Iosif, Nicolas Peltier
2021 J jnl
CoRR
Marius Bozga, Radu Iosif, Joseph Sifakis
2020 J jnl
Formal Methods Syst. Des.
Lukás Holík, Radu Iosif, Adam Rogalewicz, Tomás Vojnar
2020 J jnl
CoRR
Mnacho Echenim, Radu Iosif, Nicolas Peltier
2020 J jnl
CoRR
Mnacho Echenim, Radu Iosif, Nicolas Peltier
2020 B conf
LPAR
Mnacho Echenim, Radu Iosif, Nicolas Peltier
2020 J jnl
CoRR
Mnacho Echenim, Radu Iosif, Nicolas Peltier
2020 conf
TACAS (1)
Marius Bozga, Javier Esparza, Radu Iosif, Joseph Sifakis, Christoph Welzel
2020 J jnl
CoRR
Marius Bozga, Javier Esparza, Radu Iosif, Joseph Sifakis, Christoph Welzel
2020 J jnl
ACM Trans. Comput. Log.
Mnacho Echenim, Radu Iosif, Nicolas Peltier
2020 J jnl
CoRR
Marius Bozga, Radu Iosif
2019 conf
CAV (2)
Radu Iosif, Xiao Xu
2019 conf
TACAS (2)
Marius Bozga, Radu Iosif, Joseph Sifakis
2019 J jnl
CoRR
Marius Bozga, Radu Iosif, Joseph Sifakis
2019 B conf
TABLEAUX
Mnacho Echenim, Radu Iosif, Nicolas Peltier
2019 conf
TACAS (3)
Mihaela Sighireanu, Juan Antonio Navarro Pérez, Andrey Rybalchenko, Nikos Gorogiannis, Radu Iosif, Andrew Reynolds, Cristina Serban, Jens Katelaan, Christoph Matheja, Thomas Noll, Florian Zuleger, Wei-Ngan Chin, Quang Loc Le, Quang-Trung Ta, Ton-Chanh Le, Thanh-Toan Nguyen, Siau-Cheng Khoo, Michal Cyprian, Adam Rogalewicz, Tomás Vojnar, Constantin Enea, Ondrej Lengál, Chong Gao, Zhilin Wu
2019 J jnl
CoRR
Marius Bozga, Radu Iosif, Joseph Sifakis
2019 B conf
FoSSaCS
Mnacho Echenim, Radu Iosif, Nicolas Peltier
2018 B conf
LPAR
Radu Iosif, Cristina Serban
2018 conf
TACAS (2)
Radu Iosif, Xiao Xu
2018 J jnl
Electron. Commun. Eur. Assoc. Softw. Sci. Technol.
Cristina Serban, Radu Iosif
2018 J jnl
CoRR
Marius Bozga, Radu Iosif, Joseph Sifakis
2018 J jnl
CoRR
Radu Iosif, Xiao Xu
2018 J jnl
CoRR
Mnacho Echenim, Radu Iosif, Nicolas Peltier
2018 conf
SPIN
Radu Iosif
2018 J jnl
CoRR
Mnacho Echenim, Radu Iosif, Nicolas Peltier
2017 J jnl
CoRR
Radu Iosif, Cristina Serban
2017 B conf
VMCAI
Andrew Reynolds, Radu Iosif, Cristina Serban
2017 J jnl
CoRR
Radu Iosif, Xiao Xu
2017 J jnl
Int. J. Softw. Tools Technol. Transf.
Pierre Ganty, Radu Iosif, Filip Konecný
2016 B conf
ATVA
Andrew Reynolds, Radu Iosif, Cristina Serban, Tim King
2016 J jnl
CoRR
Andrew Reynolds, Radu Iosif, Tim King
2016 A conf
TACAS
Radu Iosif, Adam Rogalewicz, Tomás Vojnar
2016 B conf
ATVA
Radu Iosif, Arnaud Sangnier
2016 J jnl
CoRR
Radu Iosif, Arnaud Sangnier
2016 J jnl
CoRR
Andrew Reynolds, Radu Iosif, Cristina Serban
2015 B conf
FCT
Pierre Ganty, Radu Iosif
2014 J jnl
CoRR
Radu Iosif, Adam Rogalewicz, Tomás Vojnar
2014 J jnl
Log. Methods Comput. Sci.
Marius Bozga, Radu Iosif, Filip Konecný
2014 B conf
ATVA
Radu Iosif, Adam Rogalewicz, Tomás Vojnar
2014 J jnl
CoRR
Radu Iosif, Adam Rogalewicz, Tomás Vojnar
2014 J jnl
CoRR
Pierre Ganty, Radu Iosif
2014 B conf
VMCAI
Marius Bozga, Radu Iosif, Filip Konecný
2013 J jnl
Comput. Informatics
Radu Iosif, Adam Rogalewicz
2013 J jnl
CoRR
Marius Bozga, Radu Iosif, Filip Konecný
2013 J jnl
CoRR
Radu Iosif, Adam Rogalewicz, Jirí Simácek
2013 A conf
CADE
Radu Iosif, Adam Rogalewicz, Jirí Simácek
2013 A conf
TACAS
Pierre Ganty, Radu Iosif, Filip Konecný
2012 B conf
FM
Hossein Hojjat, Filip Konecný, Florent Garnier, Radu Iosif, Viktor Kuncak, Philipp Rümmer
2012 B conf
ATVA
Hossein Hojjat, Radu Iosif, Filip Konecný, Viktor Kuncak, Philipp Rümmer
2012 A conf
TACAS
Marius Bozga, Radu Iosif, Filip Konecný
2012 J jnl
CoRR
Pierre Ganty, Radu Iosif, Filip Konecný
2011 J jnl
Formal Methods Syst. Des.
Ahmed Bouajjani, Marius Bozga, Peter Habermehl, Radu Iosif, Pierre Moro, Tomás Vojnar
2010 J jnl
Acta Informatica
Peter Habermehl, Radu Iosif, Tomás Vojnar
2010 A* conf
CAV
Marius Bozga, Radu Iosif, Filip Konecný
2010 J jnl
J. Autom. Reason.
Marius Bozga, Radu Iosif, Swann Perarnau
2010 conf
WING@ETAPS/IJCAR
Marius Bozga, Radu Iosif, Filip Konecný, Tomás Vojnar
2009 C conf
CIAA
Radu Iosif, Adam Rogalewicz
2009 A* conf
CAV
Marius Bozga, Peter Habermehl, Radu Iosif, Filip Konecný, Tomás Vojnar
2009 J jnl
Fundam. Informaticae
Marius Bozga, Radu Iosif, Yassine Lakhnech
2009 A conf
TACAS
Marius Bozga, Codruta Gîrlea, Radu Iosif
2008 B conf
LPAR
Peter Habermehl, Radu Iosif, Tomás Vojnar
2008 A conf
IJCAR
Marius Bozga, Radu Iosif, Swann Perarnau
2008 B conf
FoSSaCS
Peter Habermehl, Radu Iosif, Tomás Vojnar
2007 B conf
VMCAI
Marius Bozga, Radu Iosif
2007 B conf
ATVA
Peter Habermehl, Radu Iosif, Adam Rogalewicz, Tomás Vojnar
2006 A conf
TACAS
Peter Habermehl, Radu Iosif, Tomás Vojnar
2006 conf
ICALP (2)
Marius Bozga, Radu Iosif, Yassine Lakhnech
2006 A* conf
CAV
Ahmed Bouajjani, Marius Bozga, Peter Habermehl, Radu Iosif, Pierre Moro, Tomás Vojnar
2005 B conf
FoSSaCS
Marius Bozga, Radu Iosif
2005 J jnl
Formal Methods Syst. Des.
Radu Iosif, Matthew B. Dwyer, John Hatcliff
2004 B conf
SAS
Marius Bozga, Radu Iosif, Yassine Lakhnech
2004 J jnl
Int. J. Softw. Tools Technol. Transf.
Radu Iosif
2003 conf
SoftMC@CAV
Robby, Matthew B. Dwyer, John Hatcliff, Radu Iosif
2003 C conf
PEPM
Marius Bozga, Radu Iosif, Yassine Lakhnech
2003 J jnl
J. Syst. Softw.
Radu Iosif, Riccardo Sisto
2002 conf
SPIN
Radu Iosif
2001 A* conf
ASE
Radu Iosif
2001 C conf
SEKE
Radu Iosif, Riccardo Sisto
2000 A* conf
ICSE
Radu Iosif
2000 conf
SPIN
Radu Iosif, Riccardo Sisto
1999 J jnl
Softw. Pract. Exp.
Claudio Demartini, Radu Iosif, Riccardo Sisto
1999 conf
SPIN
Claudio Demartini, Radu Iosif, Riccardo Sisto
src/main.rs
← Index src/main.rs rust
mod highlight;

use std::collections::BTreeMap;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::Instant;

use walkdir::WalkDir;

use highlight::highlight_code;

/// Above this threshold the file is read in full and classified
/// (text or binary) based on its complete content.
const FULL_READ_LIMIT: u64 = 4_000_000; // 4 MB
/// For files larger than the threshold above, we only read a preview
/// of this size (so we never load huge files fully into memory).
const PREVIEW_BYTES: usize = 16_384;
/// How many bytes of binary content to show in the hex dump.
const HEXDUMP_BYTES: usize = 4096;

struct FileResult {
    rel_path: PathBuf, // relative path of the original file
    out_rel: PathBuf,  // relative path of the generated .html file (inside out/)
    label: String,     // label shown in the badge (language, or "binary")
}

enum Unreadable {
    IoError(PathBuf),
}

enum Content {
    /// Successfully decoded text. `truncated` indicates whether the file
    /// is larger than what was actually read (very large files: preview only).
    Text { content: String, truncated: bool, total_size: u64 },
    /// Content that isn't valid UTF-8 text: we show a hex dump instead
    /// of trying to decode it.
    Binary { preview: Vec<u8>, total_size: u64, truncated: bool },
}

/// Reads a file safely: for small files, reads everything and classifies
/// text/binary based on the full content; for large files, reads only a
/// prefix, to avoid loading huge files into memory.
fn read_content(path: &Path, total_size: u64) -> std::io::Result<Content> {
    if total_size <= FULL_READ_LIMIT {
        let bytes = fs::read(path)?;
        match String::from_utf8(bytes) {
            Ok(s) => Ok(Content::Text {
                content: s,
                truncated: false,
                total_size,
            }),
            Err(e) => Ok(Content::Binary {
                preview: e.into_bytes(),
                total_size,
                truncated: false,
            }),
        }
    } else {
        let mut f = fs::File::open(path)?;
        let mut buf = vec![0u8; PREVIEW_BYTES];
        let n = f.read(&mut buf)?;
        buf.truncate(n);

        match std::str::from_utf8(&buf) {
            Ok(s) => Ok(Content::Text {
                content: s.to_string(),
                truncated: true,
                total_size,
            }),
            Err(e) => {
                let valid_up_to = e.valid_up_to();
                // If the error is only due to cutting a multi-byte
                // character in half (common when reading an arbitrary
                // prefix of a text file), still treat it as text.
                if valid_up_to > 0 && buf.len() - valid_up_to <= 4 {
                    let s = std::str::from_utf8(&buf[..valid_up_to]).unwrap().to_string();
                    Ok(Content::Text {
                        content: s,
                        truncated: true,
                        total_size,
                    })
                } else {
                    Ok(Content::Binary {
                        preview: buf,
                        total_size,
                        truncated: true,
                    })
                }
            }
        }
    }
}

/// Tree node used to build the index (index.html)
#[derive(Default)]
struct Node {
    dirs: BTreeMap<String, Node>,
    files: Vec<(String, String, PathBuf)>, // (display_name, label, href relative to the index)
}

impl Node {
    fn insert_file(&mut self, components: &[String], display_name: String, label: String, href: PathBuf) {
        if components.is_empty() {
            self.files.push((display_name, label, href));
        } else {
            let head = &components[0];
            let child = self.dirs.entry(head.clone()).or_default();
            child.insert_file(&components[1..], display_name, label, href);
        }
    }

    fn render(&self, out: &mut String) {
        out.push_str("<ul class=\"tree\">\n");
        for (name, node) in &self.dirs {
            out.push_str(&format!(
                "<li class=\"dir\"><span class=\"dir-name\">\u{1F4C1} {}</span>\n",
                escape_html(name)
            ));
            node.render(out);
            out.push_str("</li>\n");
        }
        for (name, label, href) in &self.files {
            out.push_str(&format!(
                "<li class=\"file\"><a href=\"{}\">\u{1F4C4} {}</a> <span class=\"badge\">{}</span></li>\n",
                href.to_string_lossy().replace('\\', "/"),
                escape_html(name),
                escape_html(label)
            ));
        }
        out.push_str("</ul>\n");
    }
}

fn main() {
    let args: Vec<String> = std::env::args().collect();
    let input_dir = args.get(1).cloned().unwrap_or_else(|| ".".to_string());
    let output_dir = args.get(2).cloned().unwrap_or_else(|| "out".to_string());
    // Extra directory names to exclude, optional, comma-separated
    // (e.g. "node_modules,.git"). Nothing is excluded by default:
    // the generator includes everything it finds, including
    // __pycache__, compiled files, binaries, etc.
    let extra_excludes: Vec<String> = args
        .get(3)
        .map(|s| s.split(',').map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect())
        .unwrap_or_default();

    let input = PathBuf::from(&input_dir);
    let output = PathBuf::from(&output_dir);

    if !input.is_dir() {
        eprintln!("Error: '{}' is not a valid directory.", input.display());
        std::process::exit(1);
    }

    if let Err(e) = fs::create_dir_all(&output) {
        eprintln!("Error creating the output directory: {e}");
        std::process::exit(1);
    }

    // Absolute path of the output directory, so we can exclude it from the
    // scan in case it's nested inside the input directory (e.g. input=".",
    // output="out"). This is the only automatic exclusion and it can't be
    // turned off: it only prevents the program from processing the files
    // it is currently writing.
    let output_abs = fs::canonicalize(&output).unwrap_or_else(|_| output.clone());

    println!("Scanning '{}' -> '{}'", input.display(), output.display());
    if !extra_excludes.is_empty() {
        println!("Directories excluded on request: {}", extra_excludes.join(", "));
    }
    let start = Instant::now();

    let mut results: Vec<FileResult> = Vec::new();
    let mut unreadable: Vec<Unreadable> = Vec::new();
    let mut n_binary = 0usize;
    let mut n_truncated = 0usize;

    for entry in WalkDir::new(&input)
        .into_iter()
        .filter_entry(|e| {
            if e.file_type().is_dir() {
                if let Some(name) = e.file_name().to_str() {
                    if extra_excludes.iter().any(|x| x == name) {
                        return false;
                    }
                }
                if let Ok(abs) = fs::canonicalize(e.path()) {
                    if abs == output_abs {
                        return false;
                    }
                }
            }
            true
        })
    {
        let entry = match entry {
            Ok(e) => e,
            Err(_) => continue,
        };

        if !entry.file_type().is_file() {
            continue;
        }

        let path = entry.path();
        let rel_path = match path.strip_prefix(&input) {
            Ok(p) => p.to_path_buf(),
            Err(_) => path.to_path_buf(),
        };

        let meta = match fs::metadata(path) {
            Ok(m) => m,
            Err(_) => {
                unreadable.push(Unreadable::IoError(rel_path));
                continue;
            }
        };

        let content = match read_content(path, meta.len()) {
            Ok(c) => c,
            Err(_) => {
                unreadable.push(Unreadable::IoError(rel_path));
                continue;
            }
        };

        let syntax_lang = detect_language(path);

        let (body_html, label, note): (String, String, Option<String>) = match &content {
            Content::Text { content, truncated, total_size } => {
                let body = highlight_code(content, syntax_lang);
                let note = if *truncated {
                    n_truncated += 1;
                    Some(format!(
                        "Truncated preview: showing the first {} bytes out of {} total.",
                        content.len(),
                        total_size
                    ))
                } else {
                    None
                };
                (body, syntax_lang.to_string(), note)
            }
            Content::Binary { preview, total_size, truncated } => {
                n_binary += 1;
                let dump = hex_dump(preview, HEXDUMP_BYTES);
                let shown = preview.len().min(HEXDUMP_BYTES);
                let note = Some(format!(
                    "Binary file: {} bytes total. Showing the first {} bytes as hex.{}",
                    total_size,
                    shown,
                    if *truncated { " (only the first bytes of the file were read)" } else { "" }
                ));
                (escape_html(&dump), "binary".to_string(), note)
            }
        };

        let mut out_rel = rel_path.clone();
        let new_name = format!(
            "{}.html",
            out_rel.file_name().unwrap_or_default().to_string_lossy()
        );
        out_rel.set_file_name(new_name);

        let out_path = output.join(&out_rel);
        if let Some(parent) = out_path.parent() {
            let _ = fs::create_dir_all(parent);
        }

        let depth = out_rel.components().count().saturating_sub(1);
        let back_to_index = "../".repeat(depth);

        let html = render_file_html(
            &rel_path.to_string_lossy(),
            &label,
            &body_html,
            note.as_deref(),
            &back_to_index,
        );

        if fs::write(&out_path, html).is_ok() {
            results.push(FileResult {
                rel_path,
                out_rel,
                label,
            });
        }
    }

    // Build the tree for the index
    let mut root = Node::default();
    for r in &results {
        let components: Vec<String> = r
            .rel_path
            .parent()
            .map(|p| {
                p.components()
                    .map(|c| c.as_os_str().to_string_lossy().to_string())
                    .filter(|s| !s.is_empty())
                    .collect()
            })
            .unwrap_or_default();
        let display_name = r
            .rel_path
            .file_name()
            .unwrap_or_default()
            .to_string_lossy()
            .to_string();
        root.insert_file(
            &components,
            display_name,
            r.label.clone(),
            r.out_rel.clone(),
        );
    }

    let mut tree_html = String::new();
    root.render(&mut tree_html);

    let index_html = render_index_html(&tree_html, results.len(), n_binary, n_truncated);
    let _ = fs::write(output.join("index.html"), index_html);

    let elapsed = start.elapsed();
    println!(
        "Done in {:.2?}: {} files included ({} binary, {} with truncated preview), {} unreadable.",
        elapsed,
        results.len(),
        n_binary,
        n_truncated,
        unreadable.len()
    );
    for u in &unreadable {
        match u {
            Unreadable::IoError(p) => println!("  unreadable (I/O error): {}", p.display()),
        }
    }
    println!("Open {}/index.html in your browser.", output.display());
}

/// Produces a hex dump (in the style of `xxd -C`) of the first `limit` bytes.
fn hex_dump(bytes: &[u8], limit: usize) -> String {
    let show = &bytes[..bytes.len().min(limit)];
    let mut out = String::with_capacity(show.len() * 4);
    for (i, chunk) in show.chunks(16).enumerate() {
        let offset = i * 16;
        out.push_str(&format!("{:08x}  ", offset));
        for (j, b) in chunk.iter().enumerate() {
            out.push_str(&format!("{:02x} ", b));
            if j == 7 {
                out.push(' ');
            }
        }
        let missing = 16usize.saturating_sub(chunk.len());
        for _ in 0..missing {
            out.push_str("   ");
        }
        out.push_str(" |");
        for b in chunk {
            let c = *b;
            if (0x20..0x7f).contains(&c) {
                out.push(c as char);
            } else {
                out.push('.');
            }
        }
        out.push_str("|\n");
    }
    out
}

/// Determines the language (used to pick syntax-highlighting rules)
/// from the file's name/extension. This is only used when the content
/// turns out to actually be text; binary files are classified as such
/// regardless by read_content.
fn detect_language(path: &Path) -> &'static str {
    let file_name = path
        .file_name()
        .map(|s| s.to_string_lossy().to_lowercase())
        .unwrap_or_default();

    match file_name.as_str() {
        "dockerfile" => return "dockerfile",
        "makefile" | "gnumakefile" => return "makefile",
        "cmakelists.txt" => return "cmake",
        ".gitignore" | ".dockerignore" | ".npmignore" => return "plaintext",
        "cargo.toml" | "cargo.lock" => return "ini",
        _ => {}
    }

    let ext = path
        .extension()
        .map(|s| s.to_string_lossy().to_lowercase())
        .unwrap_or_default();

    match ext.as_str() {
        "py" | "pyw" | "pyi" => "python",
        "rs" => "rust",
        "js" | "mjs" | "cjs" => "javascript",
        "jsx" => "javascript",
        "ts" => "typescript",
        "tsx" => "typescript",
        "java" => "java",
        "c" => "c",
        "h" => "c",
        "hpp" | "hh" | "hxx" => "cpp",
        "cpp" | "cc" | "cxx" => "cpp",
        "cs" => "csharp",
        "go" => "go",
        "rb" => "ruby",
        "php" => "php",
        "html" | "htm" => "xml",
        "css" => "css",
        "scss" | "sass" => "scss",
        "less" => "less",
        "json" => "json",
        "xml" => "xml",
        "yaml" | "yml" => "yaml",
        "toml" | "ini" | "cfg" | "conf" => "ini",
        "sh" | "bash" | "zsh" => "bash",
        "sql" => "sql",
        "md" | "markdown" => "markdown",
        "kt" | "kts" => "kotlin",
        "swift" => "swift",
        "lua" => "lua",
        "r" => "r",
        "pl" | "pm" => "perl",
        "groovy" | "gradle" => "groovy",
        "vb" => "vbnet",
        "graphql" | "gql" => "graphql",
        "diff" | "patch" => "diff",
        "bat" | "cmd" => "dos",
        "ps1" => "powershell",
        "vue" => "xml",
        "txt" => "plaintext",
        _ => "plaintext",
    }
}

fn escape_html(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&#39;"),
            _ => out.push(c),
        }
    }
    out
}

const STYLE: &str = r#"
:root {
  --bg: #0d1117;
  --panel: #161b22;
  --text: #c9d1d9;
  --muted: #8b949e;
  --accent: #58a6ff;
  --border: #30363d;
}
* { box-sizing: border-box; }
body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
}
header {
  padding: 14px 20px;
  background: var(--panel);
  border-bottom: 1px solid var(--border);
  display: flex;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
}
header a { color: var(--accent); text-decoration: none; font-weight: 600; }
header a:hover { text-decoration: underline; }
.path { color: var(--muted); font-size: 0.9em; word-break: break-all; }
.note {
  padding: 10px 20px;
  background: #21262d;
  border-bottom: 1px solid var(--border);
  color: var(--muted);
  font-size: 0.85em;
}
main { padding: 0; }
pre {
  margin: 0;
  padding: 16px 20px 40px 20px;
  overflow-x: auto;
  font-size: 13px;
  line-height: 1.5;
}
code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; }
.container { max-width: 1000px; margin: 30px auto; padding: 0 20px 60px; }
h1 { font-size: 1.6em; }
.meta { color: var(--muted); font-size: 0.9em; margin-bottom: 20px; }
ul.tree { list-style: none; padding-left: 18px; margin: 6px 0; }
ul.tree > li { margin: 4px 0; }
.dir-name { font-weight: 600; color: var(--text); }
.file a { color: var(--accent); text-decoration: none; }
.file a:hover { text-decoration: underline; }
.badge {
  display: inline-block;
  font-size: 0.72em;
  color: var(--muted);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 1px 8px;
  margin-left: 6px;
}
.skipped { color: var(--muted); font-size: 0.85em; margin-top: 30px; }

/* Syntax highlighting: generated in Rust, no JavaScript involved. */
.c { color: #8b949e; font-style: italic; }
.s { color: #a5d6ff; }
.n { color: #79c0ff; }
.k { color: #ff7b72; font-weight: 600; }
"#;

fn render_file_html(rel_path: &str, label: &str, body_html: &str, note: Option<&str>, back_to_index: &str) -> String {
    let title = escape_html(rel_path);
    let note_html = match note {
        Some(n) => format!("<div class=\"note\">{}</div>\n", escape_html(n)),
        None => String::new(),
    };
    format!(
        r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{title}</title>
<style>{STYLE}</style>
</head>
<body>
<header>
  <a href="{back_to_index}index.html">&larr; Index</a>
  <span class="path">{title}</span>
  <span class="badge">{label}</span>
</header>
{note_html}<main>
<pre><code>{body_html}</code></pre>
</main>
</body>
</html>
"#
    )
}

fn render_index_html(tree_html: &str, n_ok: usize, n_binary: usize, n_truncated: usize) -> String {
    let mut extra = String::new();
    if n_binary > 0 {
        extra.push_str(&format!(
            "<p class=\"skipped\">{} binary files included with a hex-dump preview.</p>",
            n_binary
        ));
    }
    if n_truncated > 0 {
        extra.push_str(&format!(
            "<p class=\"skipped\">{} very large files shown as a truncated preview only.</p>",
            n_truncated
        ));
    }

    let folder_emoji = '\u{1F4C2}';
    format!(
        r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Source index</title>
<style>{STYLE}</style>
</head>
<body>
<div class="container">
<h1>{folder_emoji} Source index</h1>
<p class="meta">{n_ok} files included in total.</p>
{tree_html}
{extra}
</div>
</body>
</html>
"#
    )
}