diff --git a/src/highlight.rs b/src/highlight.rsnew file mode 100644index 0000000..5a79051--- /dev/null+++ b/src/highlight.rs@@ -0,0 +1,403 @@1+//! Build-time syntax highlighting (f0rge #73).2+//!3+//! No JS, no dependencies: a small line scanner that carries only block4+//! comment state between lines. Unknown languages return `None` and the5+//! file page stays plain. Output is escaped HTML with `tok-*` spans;6+//! every byte of input goes through [`esc`], never raw.7+//!8+//! Deliberately conservative: a mis-highlighted token must never change9+//! the text, only its color, and unterminated strings/comments degrade10+//! to "rest of line" rather than swallowing the file.11+12+use crate::layout::esc;13+14+/// Files larger than this stay plain: token spans would triple the page.15+pub const MAX_HIGHLIGHT_BYTES: usize = 512 * 1024;16+17+struct Lang {18+ keywords: &'static [&'static str],19+ line_comment: &'static [&'static str],20+ block: Option<(&'static str, &'static str)>,21+ strings: &'static [char],22+}23+24+const RUST: Lang = Lang {25+ keywords: &[26+ "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum",27+ "extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move",28+ "mut", "pub", "ref", "return", "self", "Self", "static", "struct", "super", "trait",29+ "true", "type", "unsafe", "use", "where", "while", "yield",30+ ],31+ line_comment: &["//"],32+ block: Some(("/*", "*/")),33+ strings: &['"'],34+};35+36+const JS: Lang = Lang {37+ keywords: &[38+ "async",39+ "await",40+ "break",41+ "case",42+ "catch",43+ "class",44+ "const",45+ "continue",46+ "default",47+ "delete",48+ "do",49+ "else",50+ "export",51+ "extends",52+ "false",53+ "finally",54+ "for",55+ "from",56+ "function",57+ "if",58+ "import",59+ "in",60+ "instanceof",61+ "let",62+ "new",63+ "null",64+ "of",65+ "return",66+ "static",67+ "super",68+ "switch",69+ "this",70+ "throw",71+ "true",72+ "try",73+ "typeof",74+ "undefined",75+ "var",76+ "void",77+ "while",78+ "yield",79+ ],80+ line_comment: &["//"],81+ block: Some(("/*", "*/")),82+ strings: &['"', '\'', '`'],83+};84+85+const JSON: Lang = Lang {86+ keywords: &["true", "false", "null"],87+ line_comment: &[],88+ block: None,89+ strings: &['"'],90+};91+92+const YAML: Lang = Lang {93+ keywords: &["true", "false", "null", "yes", "no", "on", "off"],94+ line_comment: &["#"],95+ block: None,96+ strings: &['"', '\''],97+};98+99+const TOML: Lang = Lang {100+ keywords: &["true", "false"],101+ line_comment: &["#"],102+ block: None,103+ strings: &['"', '\''],104+};105+106+const SHELL: Lang = Lang {107+ keywords: &[108+ "case", "cd", "do", "done", "echo", "elif", "else", "esac", "exec", "exit", "export", "fi",109+ "for", "function", "if", "in", "local", "read", "return", "set", "shift", "then", "until",110+ "unset", "while",111+ ],112+ line_comment: &["#"],113+ block: None,114+ strings: &['"', '\''],115+};116+117+const PYTHON: Lang = Lang {118+ keywords: &[119+ "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del",120+ "elif", "else", "except", "False", "finally", "for", "from", "global", "if", "import",121+ "in", "is", "lambda", "None", "nonlocal", "not", "or", "pass", "raise", "return", "True",122+ "try", "while", "with", "yield",123+ ],124+ line_comment: &["#"],125+ block: None,126+ strings: &['"', '\''],127+};128+129+const GO: Lang = Lang {130+ keywords: &[131+ "break",132+ "case",133+ "chan",134+ "const",135+ "continue",136+ "default",137+ "defer",138+ "else",139+ "fallthrough",140+ "for",141+ "func",142+ "go",143+ "goto",144+ "if",145+ "import",146+ "interface",147+ "map",148+ "package",149+ "range",150+ "return",151+ "select",152+ "struct",153+ "switch",154+ "type",155+ "var",156+ ],157+ line_comment: &["//"],158+ block: Some(("/*", "*/")),159+ strings: &['"', '`'],160+};161+162+const CSS: Lang = Lang {163+ keywords: &[164+ "color",165+ "background",166+ "border",167+ "display",168+ "flex",169+ "grid",170+ "margin",171+ "padding",172+ "position",173+ "width",174+ "height",175+ "font",176+ "content",177+ "media",178+ "import",179+ ],180+ line_comment: &[],181+ block: Some(("/*", "*/")),182+ strings: &['"', '\''],183+};184+185+fn lang_for(path: &str) -> Option<&'static Lang> {186+ let ext = path.rsplit('.').next()?.to_ascii_lowercase();187+ match ext.as_str() {188+ "rs" => Some(&RUST),189+ "js" | "mjs" | "cjs" | "jsx" | "ts" | "tsx" => Some(&JS),190+ "json" | "jsonl" => Some(&JSON),191+ "yaml" | "yml" => Some(&YAML),192+ "toml" | "ini" | "cfg" | "conf" => Some(&TOML),193+ "sh" | "bash" | "zsh" | "fish" => Some(&SHELL),194+ "py" => Some(&PYTHON),195+ "go" => Some(&GO),196+ "css" | "scss" | "less" => Some(&CSS),197+ _ => None,198+ }199+}200+201+fn is_ident_start(c: char) -> bool {202+ c.is_alphabetic() || c == '_' || c == '$'203+}204+205+fn is_ident(c: char) -> bool {206+ c.is_alphanumeric() || c == '_' || c == '$'207+}208+209+fn push_span(out: &mut String, class: &str, text: &str) {210+ if text.is_empty() {211+ return;212+ }213+ out.push_str("<span class=\"tok tok-");214+ out.push_str(class);215+ out.push_str("\">");216+ out.push_str(&esc(text));217+ out.push_str("</span>");218+}219+220+/// Highlight one file into one HTML fragment per input line.221+///222+/// `None` for unknown languages, oversized input, or a path with no223+/// useful lexer (markdown, plain text) — callers then escape as-is.224+#[must_use]225+pub fn highlight_lines(path: &str, text: &str) -> Option<Vec<String>> {226+ if text.len() > MAX_HIGHLIGHT_BYTES {227+ return None;228+ }229+ let lang = lang_for(path)?;230+ let mut in_block = false;231+ let mut out = Vec::new();232+ for line in text.split('\n') {233+ out.push(scan_line(lang, line, &mut in_block));234+ }235+ Some(out)236+}237+238+fn scan_line(lang: &Lang, line: &str, in_block: &mut bool) -> String {239+ let chars: Vec<char> = line.chars().collect();240+ let mut out = String::with_capacity(line.len() + 16);241+ let mut i = 0;242+ while i < chars.len() {243+ let rest: String = chars[i..].iter().collect();244+245+ if *in_block {246+ if let Some((_, end)) = lang.block {247+ if let Some(pos) = rest.find(end) {248+ let stop = i + rest[..pos].chars().count() + end.chars().count();249+ let piece: String = chars[i..stop].iter().collect();250+ push_span(&mut out, "com", &piece);251+ *in_block = false;252+ i = stop;253+ continue;254+ }255+ }256+ let piece: String = chars[i..].iter().collect();257+ push_span(&mut out, "com", &piece);258+ return out;259+ }260+261+ if let Some((start, end)) = lang.block {262+ if let Some(after_start) = rest.strip_prefix(start) {263+ if let Some(pos) = after_start.find(end) {264+ let stop = i + rest[..pos + start.len()].chars().count() + end.chars().count();265+ let piece: String = chars[i..stop].iter().collect();266+ push_span(&mut out, "com", &piece);267+ i = stop;268+ continue;269+ }270+ *in_block = true;271+ let piece: String = chars[i..].iter().collect();272+ push_span(&mut out, "com", &piece);273+ return out;274+ }275+ }276+277+ if lang.line_comment.iter().any(|m| rest.starts_with(m)) {278+ let piece: String = chars[i..].iter().collect();279+ push_span(&mut out, "com", &piece);280+ return out;281+ }282+283+ let c = chars[i];284+ if lang.strings.contains(&c) {285+ let mut j = i + 1;286+ let mut escaped = false;287+ while j < chars.len() {288+ let d = chars[j];289+ if escaped {290+ escaped = false;291+ } else if d == '\\' && c != '\'' {292+ escaped = true;293+ } else if d == c {294+ j += 1;295+ break;296+ }297+ j += 1;298+ }299+ let piece: String = chars[i..j].iter().collect();300+ push_span(&mut out, "str", &piece);301+ i = j;302+ continue;303+ }304+305+ if c.is_ascii_digit() {306+ let mut j = i + 1;307+ while j < chars.len()308+ && (chars[j].is_ascii_alphanumeric() || matches!(chars[j], '.' | '_' | 'x' | 'X'))309+ {310+ j += 1;311+ }312+ let piece: String = chars[i..j].iter().collect();313+ push_span(&mut out, "num", &piece);314+ i = j;315+ continue;316+ }317+318+ if is_ident_start(c) {319+ let mut j = i + 1;320+ while j < chars.len() && is_ident(chars[j]) {321+ j += 1;322+ }323+ let word: String = chars[i..j].iter().collect();324+ let next_non_space = chars[j..].iter().find(|c| !c.is_whitespace());325+ let call = next_non_space.is_some_and(|c| *c == '(');326+ let class = if lang.keywords.contains(&word.as_str()) {327+ Some("kw")328+ } else if call {329+ Some("fn")330+ } else if word.chars().next().is_some_and(char::is_uppercase) {331+ Some("type")332+ } else {333+ None334+ };335+ match class {336+ Some(class) => push_span(&mut out, class, &word),337+ None => out.push_str(&esc(&word)),338+ }339+ i = j;340+ continue;341+ }342+343+ out.push_str(&esc(&c.to_string()));344+ i += 1;345+ }346+ out347+}348+349+#[cfg(test)]350+mod tests {351+ use super::*;352+353+ #[test]354+ fn rust_tokens_are_classified() {355+ let src = "// hi\nfn main() { let x = 42; println!(\"ok\"); }";356+ let lines = highlight_lines("a.rs", src).expect("rust");357+ assert!(lines[0].contains("tok-com"));358+ assert!(lines[1].contains("tok-kw\">fn<"));359+ assert!(lines[1].contains("tok-fn\">main<"));360+ assert!(lines[1].contains("tok-num\">42<"));361+ assert!(lines[1].contains("tok-str\">"));362+ // Text is preserved exactly once tags are stripped.363+ let mut stripped = String::new();364+ let mut in_tag = false;365+ for c in lines.join("\n").chars() {366+ match c {367+ '<' => in_tag = true,368+ '>' => in_tag = false,369+ _ if !in_tag => stripped.push(c),370+ _ => {}371+ }372+ }373+ assert!(stripped.contains("fn main() { let x = 42;"));374+ }375+376+ #[test]377+ fn unknown_languages_stay_plain() {378+ assert!(highlight_lines("notes.txt", "fn main() {}").is_none());379+ assert!(highlight_lines("README.md", "# hi").is_none());380+ }381+382+ #[test]383+ fn block_comment_carries_across_lines() {384+ let lines = highlight_lines("a.rs", "/* one\ntwo */\nfn x() {}").expect("rust");385+ assert!(lines[0].contains("tok-com"));386+ assert!(lines[1].contains("tok-com"));387+ assert!(lines[2].contains("tok-kw"));388+ }389+390+ #[test]391+ fn hostile_text_is_escaped() {392+ let lines =393+ highlight_lines("a.js", "const x = \"<script>alert(1)</script>\";").expect("js");394+ assert!(!lines[0].contains("<script>"));395+ assert!(lines[0].contains("<script>"));396+ }397+398+ #[test]399+ fn oversized_inputs_stay_plain() {400+ let big = "x".repeat(MAX_HIGHLIGHT_BYTES + 1);401+ assert!(highlight_lines("a.rs", &big).is_none());402+ }403+}