Wanli Xing

119 papers A* 1A 16B 8C 6Misc 1Journal 62Unranked 25
YearRankTypeTitle / Venue / Authors
2026 A* conf
AAAI
Zifeng Liu, Hai Li, Jie Chao, Wanli Xing
2026 A conf
LAK
Zifeng Liu, Linlin Li, Jie Chao, Anupom Mondol, Wanli Xing, Yuanlin Zhang
2026 conf
SIGCSE (2)
Zifeng Liu, Yihan Jiang, Wanli Xing
2026 J jnl
Internet High. Educ.
Fan Zhang, Wanli Xing, Chenglu Li, Yihan Jiang
2026 J jnl
IEEE Trans. Educ.
Richard Owoputi, Aidan Kwok, Zifeng Liu, Wangda Zhu, Wanli Xing, Sandip Ray
2026 A conf
LAK
Zifeng Liu, Wanli Xing, Zhihui Fang
2026 J jnl
Comput. Educ. Artif. Intell.
Zifeng Liu, Serene Cheon, Austin Stanbury, Xinyue Jiao, Wanli Xing, Hyo Kang
2025 J jnl
Br. J. Educ. Technol.
Hai Li, Wanli Xing, Chenglu Li, Wangda Zhu, Hyunju Oh
2025 A conf
LAK
Wangda Zhu, Wanli Xing, Bailing Lyu, Chenglu Li, Fan Zhang, Hai Li
2025 conf
SIGCSE (1)
Zifeng Liu, Xinyue Jiao, Wanli Xing, Wangda Zhu
2025 J jnl
Br. J. Educ. Technol.
Wanli Xing, Yukyeong Song, Chenglu Li, Zifeng Liu, Wangda Zhu, Hyunju Oh
2025 A conf
LAK
Hyunju Oh, Zifeng Liu, Wanli Xing
2025 conf
SIGCSE (1)
Zifeng Liu, Shan Zhang, Maya Israel, Robert Smith, Wanli Xing, Victor Minces
2025 conf
AIED (6)
Hai Li, Wanli Xing
2025 conf
ICER (2)
Zifeng Liu, Bach Ngo, Wanli Xing
2025 J jnl
J. Learn. Anal.
Zifeng Liu, Wanli Xing, Chenglu Li, Fan Zhang, Hai Li, Victor Minces
2025 J jnl
J. Learn. Anal.
Zifeng Liu, Wanli Xing, Xinyue Jiao, Chenglu Li
2025 A conf
LAK
Bailing Lyu, Chenglu Li, Hai Li, Hyunju Oh, Yukyeong Song, Wangda Zhu, Wanli Xing
2025 J jnl
J. Learn. Anal.
Hai Li, Wanli Xing, Chenglu Li, Wangda Zhu, Simon Woodhead
2025 J jnl
Internet High. Educ.
Yukyeong Song, Chenglu Li, Wanli Xing, Bailing Lyu, Wangda Zhu
2025 J jnl
Educ. Inf. Technol.
Wanli Xing, Hai Li, Taehyun Kim, Wangda Zhu, Yukyeong Song
2025 conf
AIED Companion (2)
Hai Li, Wanli Xing, Wangda Zhu, Chenglu Li, Bailing Lyu, Zifeng Liu, Neil T. Heffernan
2025 J jnl
J. Comput. High. Educ.
Zifeng Liu, Wanli Xing, Yihan Jiang, Chenglu Li, Taehyun Kim, Hai Li
2025 J jnl
Int. J. Artif. Intell. Educ.
Fan Zhang, Chenglu Li, Owen Henkel, Wanli Xing, Sami Baral, Neil T. Heffernan, Hai Li
2025 J jnl
J. Comput. Assist. Learn.
Hanxiang Du, Gaoxia Zhu, Wanli Xing
2025 J jnl
Comput. Educ. Artif. Intell.
Chenglu Li, Wanli Xing, Yukyeong Song, Bailing Lyu
2025 J jnl
J. Comput. High. Educ.
Hai Li, Wanli Xing, Wangda Zhu, Shan Zhang, Zifeng Liu
2025 B conf
IEEE Big Data
Hai Li, Wanli Xing, Chenglu Li, Bailing Lyu
2025 conf
AIED (4)
Bailing Lyu, Chenglu Li, Hai Li, Wanli Xing, Gökhan Gülfidan, Linlin Wu
2025 J jnl
Int. J. Artif. Intell. Educ.
Wanli Xing, Nia Nixon, Scott A. Crossley, Paul Denny, Andrew S. Lan, John C. Stamper, Zhou Yu
2025 J jnl
Comput. Educ.
Bailing Lyu, Chenglu Li, Hai Li, Hyunju Oh, Yukyeong Song, Wangda Zhu, Wanli Xing
2025 J jnl
J. Learn. Anal.
Raymond A. Opoku, Bo Pei, Wanli Xing
2025 J jnl
Educ. Inf. Technol.
Zifeng Liu, Wanli Xing, Xinyue Jiao, Chenglu Li, Wangda Zhu
2025 A conf
LAK
Hai Li, Wanli Xing, Chenglu Li, Wangda Zhu, Bailing Lyu, Fan Zhang, Zifeng Liu
2024 A conf
LAK
Yukyeong Song, Chenglu Li, Wanli Xing, Shan Li, Hakeoung Hannah Lee
2024 C conf
FIE
Hyo Kang, Serene Cheon, Alice Abia-Okon, Aida Damanpak Rizi, Wanli Xing, Navid Asadizanjani
2024 A conf
LAK
Chenglu Li, Wangda Zhu, Wanli Xing, Rui Guo
2024 A conf
LAK
Hai Li, Chenglu Li, Wanli Xing, Sami Baral, Neil T. Heffernan
2024 B conf
L@S
Hai Li, Rui Guo, Chenglu Li, Wanli Xing
2024 J jnl
CoRR
Yukyeong Song, Jinhee Kim, Wanli Xing, Zifeng Liu, Chenglu Li, Hyunju Oh
2024 conf
HEXED/L3MNGET@EDM
Zifeng Liu, Wanli Xing, Chenglu Li
2024 B conf
EDM
Zifeng Liu, Xinyue Jiao, Chenglu Li, Wanli Xing
2024 B conf
L@S
Bailing Lyu, Chenglu Li, Hai Li, Wanli Xing
2024 J jnl
Br. J. Educ. Technol.
Yukyeong Song, Wanli Xing, Chenglu Li, Xiaoyi Tian, Yingbo Ma
2024 J jnl
Smart Learn. Environ.
Bo Pei, Ying Cheng, G. Alex Ambrose, Eva Dziadula, Wanli Xing, Jie Lu
2024 B conf
L@S
Hai Li, Wanli Xing, Chenglu Li, Wangda Zhu, Neil T. Heffernan
2024 B conf
L@S
Bailing Lyu, Chenglu Li, Hai Li, Wanli Xing
2024 J jnl
CoRR
Yukyeong Song, Jinhee Kim, Zifeng Liu, Chenglu Li, Wanli Xing
2024 J jnl
Interact. Learn. Environ.
Chenglu Li, Wanli Xing, Walter L. Leite
2024 C conf
FIE
Hai Li, Rui Guo, Wangda Zhu, Chenglu Li, Wanli Xing
2024 C conf
FIE
Fan Zhang, Rui Guo, Wanli Xing, Wangda Zhu, Taehyun Kim, Zifeng Liu
2024 C conf
FIE
Zifeng Liu, Rui Guo, Yukyeong Song, Wanli Xing
2023 J jnl
Interact. Learn. Environ.
Bo Pei, Wanli Xing, Minjuan Wang
2023 conf
SIGCSE (2)
Yuanlin Zhang, Hanxiang Du, Wendy Staffen, Wanli Xing, Joshua Archer
2023 A conf
LAK
Yukyeong Song, Wanli Xing, Xiaoyi Tian, Chenglu Li
2023 J jnl
Interact. Learn. Environ.
Hanxiang Du, Wanli Xing, Bo Pei
2023 J jnl
J. Comput. High. Educ.
Hengtao Tang, Okan Arslan, Wanli Xing, Tugba Kamali Arslantas
2023 J jnl
Educ. Inf. Technol.
Bo Pei, Wanli Xing, Gaoxia Zhu, Kristine Antonyan, Charles Xie
2023 Misc conf
IDC
Yukyeong Song, Wanli Xing, Alec Barron, Hyunju Oh, Chenglu Li, Victor Minces
2023 A conf
LAK
Fan Zhang, Wanli Xing, Chenglu Li
2023 J jnl
CoRR
Zachary Levonian, Chenglu Li, Wangda Zhu, Anoushka Gade, Owen Henkel, Millie-Ellen Postle, Wanli Xing
2023 J jnl
Comput. Educ.
Wanli Xing, Xudong Huang, Chenglu Li, Charles Xie
2023 J jnl
Interact. Learn. Environ.
Bowen Liu, Yonghe Wu, Wanli Xing, Shou-Chao Guo, Lijuan Zhu
2023 J jnl
Interact. Learn. Environ.
Juan Zheng, Wanli Xing, Xudong Huang, Shan Li, Guanhua Chen, Charles Xie
2023 J jnl
Comput. Educ. X Real.
Matthew Schmidt, Noah Glaser, Heath Palmer, Carla Schmidt, Wanli Xing
2023 conf
AIED (Posters/Late Breaking Results/...)
Vincent Aleven, Richard G. Baraniuk, Emma Brunskill, Scott A. Crossley, Dora Demszky, Stephen Fancsali, Shivang Gupta, Kenneth R. Koedinger, Chris Piech, Steven Ritter, Danielle R. Thomas, Simon Woodhead, Wanli Xing
2023 J jnl
J. Comput. High. Educ.
Wanli Xing, Gaoxia Zhu, Okan Arslan, Jaesub Shim, Vitaliy Popov
2023 J jnl
Interact. Learn. Environ.
Wanli Xing, Bo Pei, Shan Li, Guanhua Chen, Charles Xie
2022 conf
ICLP Technical Communications / Doctoral Consortium
Zach Hansen, Hanxiang Du, Wanli Xing, Rory Eckel, Justin Lugo, Yuanlin Zhang
2022 J jnl
Br. J. Educ. Technol.
Chenglu Li, Wanli Xing, Walter L. Leite
2022 A conf
LAK
Chenglu Li, Wanli Xing, Walter L. Leite
2022 B conf
L@S
Walter L. Leite, Huan Kuang, Zuchao Shen, Nilanjana Chakraborty, George Michailidis, Sidney D'Mello, Wanli Xing
2022 J jnl
Br. J. Educ. Technol.
Bowen Liu, Wanli Xing, Yifang Zeng, Yonghe Wu
2022 conf
ICER (2)
Hanxiang Du, Wanli Xing, Yuanlin Zhang
2022 B conf
L@S
Chenglu Li, Wanli Xing
2022 J jnl
Comput. Educ.
Walter L. Leite, Huan Kuang, Zeyuan Jing, Wanli Xing, Catherine Cavanaugh, Anne Corinne Huggins-Manley
2022 conf
CSEDU (1)
Hanxiang Du, Wanli Xing, Bo Pei, Yifang Zeng, Jie Lu, Yuanlin Zhang
2022 J jnl
Behav. Inf. Technol.
Wanli Xing, Xianhui Wang
2022 J jnl
J. Comput. Assist. Learn.
Okan Arslan, Wanli Xing, Fethi A. Inan, Hanxiang Du
2022 C conf
iLRN
Noah Glaser, Matthew Schmidt, Heath Palmer, Wanli Xing, Carla Schmidt
2021 conf
ITiCSE (2)
Hanxiang Du, Wanli Xing, Yuanlin Zhang
2021 J jnl
IEEE Trans. Learn. Technol.
Wanli Xing, Dongping Du, Ali Bakhshi, Kuo-Chun Chiu, Hanxiang Du
2021 J jnl
Int. J. Artif. Intell. Educ.
Chenglu Li, Wanli Xing
2021 conf
AIED (2)
Chenglu Li, Wanli Xing, Walter L. Leite
2021 A conf
LAK
Chenglu Li, Wanli Xing, Walter L. Leite
2020 J jnl
Comput. Educ.
Shan Li, Hanxiang Du, Wanli Xing, Juan Zheng, Guanhua Chen, Charles Xie
2020 J jnl
Comput. Educ.
Shan Li, Guanhua Chen, Wanli Xing, Juan Zheng, Charles Xie
2020 J jnl
Comput. Educ.
Juan Zheng, Wanli Xing, Gaoxia Zhu, Guanhua Chen, Henglv Zhao, Charles Xie
2020 conf
ICLS
Xudong Huang, Wanli Xing, Henglv Zhao, Jie Chao, Corey Schimpf, Guanhua Chen, Charles Xie
2020 C conf
PADL
Vinh T. Nguyen, Yuanlin Zhang, Kwanghee Jung, Wanli Xing, Tommy Dang
2019 J jnl
Internet High. Educ.
Wanli Xing, Hengtao Tang, Bo Pei
2019 J jnl
Comput. Educ.
Juan Zheng, Wanli Xing, Gaoxia Zhu
2019 J jnl
User Model. User Adapt. Interact.
Gaoxia Zhu, Wanli Xing, Stacy Costa, Marlene Scardamalia, Bo Pei
2019 J jnl
Comput. Hum. Behav.
Wanli Xing, Vitaliy Popov, Gaoxia Zhu, Paul Horwitz, Cynthia McIntyre
2019 conf
COMPSAC (2)
Hanxiang Du, Long Hoang Nguyen, Zhou Yang, Hashim Abu-gellban, Xingyu Zhou, Wanli Xing, Guofeng Cao, Fang Jin
2019 J jnl
Internet High. Educ.
Gaoxia Zhu, Wanli Xing, Vitaliy Popov
2018 J jnl
Br. J. Educ. Technol.
Xianhui Wang, Wanli Xing, James M. Laffey
2018 J jnl
J. Educ. Technol. Soc.
Xianhui Wang, Wanli Xing
2018 J jnl
Comput. Educ.
Wanli Xing, Fei Gao
2018 J jnl
Comput. Hum. Behav.
Wanli Xing, Sean P. Goggins, Josh Introne
2018 conf
ICLS
Vitaliy Popov, Wanli Xing, Gaoxia Zhu, Paul Horwitz, Cynthia McIntyre
2018 J jnl
Comput. Hum. Behav.
Andrew A. Tawfik, Victor Law, Xun Ge, Wanli Xing, Kyung Kim
2017 J jnl
Comput. Hum. Behav.
Wanli Xing, Xin Chen, Jared Stein, Michael Marcinkowski
2016 J jnl
Comput. Educ.
Sean P. Goggins, Wanli Xing
2016 J jnl
Comput. Hum. Behav.
Xianhui Wang, James M. Laffey, Wanli Xing, Yanfei Ma, Janine Stichter
2016 J jnl
Comput. Hum. Behav.
Wanli Xing, Xin Chen, Jared Stein, Michael Marcinkowski
2015 A conf
LAK
Bodong Chen, Xin Chen, Wanli Xing
2015 J jnl
J. Educ. Technol. Soc.
Wanli Xing, Robert Wadholm, Eva Petakovic, Sean P. Goggins
2015 J jnl
J. Univers. Comput. Sci.
Sean P. Goggins, Wanli Xing, Xin Chen, Bodong Chen, Bob Wadholm
2015 A conf
LAK
Wanli Xing, Sean P. Goggins
2015 conf
CSCL
Wanli Xing, So Mi Kim, Sean P. Goggins
2015 J jnl
Comput. Hum. Behav.
Wanli Xing, Rui Guo, Eva Petakovic, Sean P. Goggins
2014 conf
ICLS
Wanli Xing, Robert Wadholm, Sean P. Goggins
2014 conf
LAK Workshops
Wanli Xing, Sean P. Goggins
2014 conf
HCI (13)
Wanli Xing, Rui Guo, Nathan Lowrance, Thomas R. Kochtanek
2014 conf
HCI (12)
Wanli Xing, Rui Guo, Ben Richardson, Thomas R. Kochtanek
2014 conf
HCI (27)
Yanfei Ma, Cathryn Friel, Wanli Xing
2014 A conf
LAK
Wanli Xing, Bob Wadholm, Sean P. Goggins
2013 conf
HCI (28)
Yanfei Ma, Wanli Xing, Cathryn Friel
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>
"#
    )
}