1use crate::syntax::registry::REGISTRY;
6
7pub type Rgb = (u8, u8, u8);
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub struct Language {
11 pub name: &'static str,
12 pub color: Rgb,
14 line_comments: &'static [&'static str],
15 block_comment: Option<(&'static str, &'static str)>,
16}
17
18impl Language {
19 fn classify(self, line: &str, in_block: bool) -> (LineKind, bool) {
22 if in_block {
23 let closed = self
24 .block_comment
25 .is_some_and(|(_, end)| line.contains(end));
26 return (LineKind::Comment, !closed);
27 }
28 if let Some((start, end)) = self.block_comment
30 && line.starts_with(start)
31 {
32 let closed = line[start.len()..].contains(end);
33 return (LineKind::Comment, !closed);
34 }
35 if self
36 .line_comments
37 .iter()
38 .any(|token| line.starts_with(token))
39 {
40 return (LineKind::Comment, false);
41 }
42 (LineKind::Code, false)
43 }
44}
45
46enum LineKind {
47 Code,
48 Comment,
49}
50
51#[must_use]
54pub fn count_lines(text: &str, language: Option<Language>) -> (usize, usize, usize) {
55 let (mut code, mut comments, mut blanks) = (0, 0, 0);
56 let mut in_block = false;
57 for (index, raw) in text.lines().enumerate() {
58 let line = raw.trim();
59 if line.is_empty() {
60 blanks += 1;
61 continue;
62 }
63 if index == 0 && line.starts_with("#!") {
65 code += 1;
66 continue;
67 }
68 let Some(language) = language else {
69 code += 1;
70 continue;
71 };
72 let (kind, still_open) = language.classify(line, in_block);
73 in_block = still_open;
74 match kind {
75 LineKind::Code => code += 1,
76 LineKind::Comment => comments += 1,
77 }
78 }
79 (code, comments, blanks)
80}
81
82#[must_use]
83pub fn of_path(path: &str) -> Option<Language> {
84 if let Some(entry) = REGISTRY.for_path(path)
85 && let Some(language) = by_key(entry.name)
86 {
87 return Some(language);
88 }
89 let name = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase();
90 if let Some(key) = EXTRA_FILENAMES
91 .iter()
92 .find_map(|(filename, key)| (*filename == name).then_some(key))
93 {
94 return by_key(key);
95 }
96 if let Some(extension) = name.rsplit_once('.').map(|(_, ext)| ext)
97 && let Some(key) = EXTRA_EXTENSIONS
98 .iter()
99 .find_map(|(ext, key)| (*ext == extension).then_some(key))
100 {
101 return by_key(key);
102 }
103 if crate::classify::DOC_NAMES
105 .iter()
106 .any(|stem| name.starts_with(stem))
107 {
108 return by_key("text");
109 }
110 None
111}
112
113fn by_key(key: &str) -> Option<Language> {
114 TABLE
115 .iter()
116 .find_map(|(name, language)| (*name == key).then_some(*language))
117}
118
119#[must_use]
122pub fn readable_on(color: Rgb, bg: Rgb) -> Rgb {
123 const TARGET: f32 = 3.0;
124 let toward = if luminance(bg) > 0.5 { 0 } else { 255 };
125 let mut out = color;
126 for _ in 0..40 {
127 if contrast(out, bg) >= TARGET {
128 break;
129 }
130 out = step_toward(out, toward);
131 }
132 out
133}
134
135fn step_toward(color: Rgb, toward: u8) -> Rgb {
136 let blend = |channel: u8| {
137 let (from, to) = (i32::from(channel), i32::from(toward));
138 let by = (to - from) / 10;
139 let stepped = from + if by == 0 { (to - from).signum() } else { by };
140 u8::try_from(stepped.clamp(0, 255)).unwrap_or(toward)
141 };
142 (blend(color.0), blend(color.1), blend(color.2))
143}
144
145fn contrast(a: Rgb, b: Rgb) -> f32 {
146 let (high, low) = {
147 let (la, lb) = (luminance(a), luminance(b));
148 if la > lb { (la, lb) } else { (lb, la) }
149 };
150 (high + 0.05) / (low + 0.05)
151}
152
153fn luminance(color: Rgb) -> f32 {
155 let channel = |value: u8| {
156 let value = f32::from(value) / 255.0;
157 if value <= 0.03928 {
158 value / 12.92
159 } else {
160 ((value + 0.055) / 1.055).powf(2.4)
161 }
162 };
163 0.2126 * channel(color.0) + 0.7152 * channel(color.1) + 0.0722 * channel(color.2)
164}
165
166const TABLE: &[(&str, Language)] = &[
168 lang(
169 "rust",
170 "Rust",
171 (0xde, 0xa5, 0x84),
172 &["//"],
173 Some(("/*", "*/")),
174 ),
175 lang(
176 "python",
177 "Python",
178 (0x35, 0x72, 0xa5),
179 &["#"],
180 Some(("\"\"\"", "\"\"\"")),
181 ),
182 lang(
183 "javascript",
184 "JavaScript",
185 (0xf1, 0xe0, 0x5a),
186 &["//"],
187 Some(("/*", "*/")),
188 ),
189 lang(
190 "typescript",
191 "TypeScript",
192 (0x31, 0x78, 0xc6),
193 &["//"],
194 Some(("/*", "*/")),
195 ),
196 lang(
197 "tsx",
198 "TSX",
199 (0x31, 0x78, 0xc6),
200 &["//"],
201 Some(("/*", "*/")),
202 ),
203 lang("go", "Go", (0x00, 0xad, 0xd8), &["//"], Some(("/*", "*/"))),
204 lang("c", "C", (0x55, 0x55, 0x55), &["//"], Some(("/*", "*/"))),
205 lang(
206 "cpp",
207 "C++",
208 (0xf3, 0x4b, 0x7d),
209 &["//"],
210 Some(("/*", "*/")),
211 ),
212 lang(
213 "java",
214 "Java",
215 (0xb0, 0x72, 0x19),
216 &["//"],
217 Some(("/*", "*/")),
218 ),
219 lang(
220 "c-sharp",
221 "C#",
222 (0x17, 0x86, 0x00),
223 &["//"],
224 Some(("/*", "*/")),
225 ),
226 lang(
227 "ruby",
228 "Ruby",
229 (0x70, 0x15, 0x16),
230 &["#"],
231 Some(("=begin", "=end")),
232 ),
233 lang(
234 "php",
235 "PHP",
236 (0x4f, 0x5d, 0x95),
237 &["//", "#"],
238 Some(("/*", "*/")),
239 ),
240 lang("bash", "Shell", (0x89, 0xe0, 0x51), &["#"], None),
241 lang("json", "JSON", (0x29, 0x29, 0x29), &[], None),
242 lang(
243 "html",
244 "HTML",
245 (0xe3, 0x4c, 0x26),
246 &[],
247 Some(("<!--", "-->")),
248 ),
249 lang("css", "CSS", (0x66, 0x33, 0x99), &[], Some(("/*", "*/"))),
250 lang("yaml", "YAML", (0xcb, 0x17, 0x1e), &["#"], None),
251 lang(
252 "sql",
253 "SQL",
254 (0xe3, 0x8c, 0x00),
255 &["--"],
256 Some(("/*", "*/")),
257 ),
258 lang(
259 "markdown",
260 "Markdown",
261 (0x08, 0x3f, 0xa1),
262 &[],
263 Some(("<!--", "-->")),
264 ),
265 lang("toml", "TOML", (0x9c, 0x42, 0x21), &["#"], None),
266 lang(
267 "hcl",
268 "HCL",
269 (0x84, 0x4f, 0xba),
270 &["#", "//"],
271 Some(("/*", "*/")),
272 ),
273 lang("dockerfile", "Dockerfile", (0x38, 0x4d, 0x54), &["#"], None),
274 lang("make", "Makefile", (0x42, 0x78, 0x19), &["#"], None),
275 lang(
276 "lua",
277 "Lua",
278 (0x00, 0x00, 0x80),
279 &["--"],
280 Some(("--[[", "]]")),
281 ),
282 lang("nix", "Nix", (0x7e, 0x7e, 0xff), &["#"], Some(("/*", "*/"))),
283 lang("xml", "XML", (0x00, 0x60, 0xac), &[], Some(("<!--", "-->"))),
284 lang(
285 "swift",
286 "Swift",
287 (0xf0, 0x51, 0x38),
288 &["//"],
289 Some(("/*", "*/")),
290 ),
291 lang(
292 "scala",
293 "Scala",
294 (0xc2, 0x2d, 0x40),
295 &["//"],
296 Some(("/*", "*/")),
297 ),
298 lang(
299 "elixir",
300 "Elixir",
301 (0x6e, 0x4a, 0x7e),
302 &["#"],
303 Some(("@doc \"\"\"", "\"\"\"")),
304 ),
305 lang("zig", "Zig", (0xec, 0x91, 0x5c), &["//"], None),
306 lang(
307 "haskell",
308 "Haskell",
309 (0x5e, 0x50, 0x86),
310 &["--"],
311 Some(("{-", "-}")),
312 ),
313 lang(
314 "dart",
315 "Dart",
316 (0x00, 0xb4, 0xab),
317 &["//"],
318 Some(("/*", "*/")),
319 ),
320 lang(
321 "powershell",
322 "PowerShell",
323 (0x01, 0x24, 0x56),
324 &["#"],
325 Some(("<#", "#>")),
326 ),
327 lang(
328 "svelte",
329 "Svelte",
330 (0xff, 0x3e, 0x00),
331 &[],
332 Some(("<!--", "-->")),
333 ),
334 lang(
336 "kotlin",
337 "Kotlin",
338 (0xa9, 0x7b, 0xff),
339 &["//"],
340 Some(("/*", "*/")),
341 ),
342 lang("vue", "Vue", (0x41, 0xb8, 0x83), &[], Some(("<!--", "-->"))),
343 lang("r", "R", (0x19, 0x8c, 0xe7), &["#"], None),
344 lang("perl", "Perl", (0x02, 0x98, 0xc3), &["#"], None),
345 lang(
346 "protobuf",
347 "Protocol Buffer",
348 (0xe3, 0xc5, 0x8e),
349 &["//"],
350 Some(("/*", "*/")),
351 ),
352 lang("ini", "INI", (0xd1, 0xdb, 0xe0), &[";", "#"], None),
353 lang("csv", "CSV", (0x23, 0x73, 0x46), &[], None),
354 lang(
355 "scheme",
356 "Scheme",
357 (0x1e, 0x4a, 0xec),
358 &[";"],
359 Some(("#|", "|#")),
360 ),
361 lang("text", "Text", (0x8b, 0x94, 0x9e), &[], None),
362];
363
364const EXTRA_EXTENSIONS: &[(&str, &str)] = &[
365 ("kt", "kotlin"),
366 ("kts", "kotlin"),
367 ("vue", "vue"),
368 ("r", "r"),
369 ("pl", "perl"),
370 ("pm", "perl"),
371 ("proto", "protobuf"),
372 ("ini", "ini"),
373 ("cfg", "ini"),
374 ("conf", "ini"),
375 ("properties", "ini"),
376 ("csv", "csv"),
377 ("tsv", "csv"),
378 ("txt", "text"),
379 ("scm", "scheme"),
380 ("ss", "scheme"),
381];
382
383const EXTRA_FILENAMES: &[(&str, &str)] = &[
384 (".gitignore", "ini"),
385 (".gitattributes", "ini"),
386 (".editorconfig", "ini"),
387];
388
389const fn lang(
390 key: &'static str,
391 name: &'static str,
392 color: Rgb,
393 line_comments: &'static [&'static str],
394 block_comment: Option<(&'static str, &'static str)>,
395) -> (&'static str, Language) {
396 (
397 key,
398 Language {
399 name,
400 color,
401 line_comments,
402 block_comment,
403 },
404 )
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 #[test]
412 fn extensions_resolve_through_the_highlighters_own_table() {
413 assert_eq!(of_path("src/main.rs").map(|l| l.name), Some("Rust"));
414 assert_eq!(of_path("a/b/setup.py").map(|l| l.name), Some("Python"));
415 assert_eq!(of_path("scripts/release.sh").map(|l| l.name), Some("Shell"));
416 assert_eq!(of_path("infra/main.tf").map(|l| l.name), Some("HCL"));
417 assert_eq!(of_path("Makefile").map(|l| l.name), Some("Makefile"));
418 }
419
420 #[test]
421 fn a_license_is_prose_whatever_it_is_suffixed_with() {
422 assert_eq!(of_path("LICENSE-APACHE").map(|l| l.name), Some("Text"));
423 assert_eq!(of_path("LICENSE-MIT").map(|l| l.name), Some("Text"));
424 assert_eq!(of_path("LICENSE.md").map(|l| l.name), Some("Markdown"));
425 }
426
427 #[test]
428 fn a_python_docstring_counts_as_a_comment() {
429 let python = of_path("x.py").expect("python");
430 let source = "\"\"\"What this does.\n\nAnd why.\n\"\"\"\nimport os\n";
431 assert_eq!(count_lines(source, Some(python)), (1, 3, 1));
432 let inline = "def f():\n \"\"\"Note.\"\"\"\n return 1\n";
433 assert_eq!(count_lines(inline, Some(python)), (2, 1, 0));
434 }
435
436 #[test]
437 fn languages_without_a_grammar_still_resolve() {
438 assert_eq!(of_path("app/Main.kt").map(|l| l.name), Some("Kotlin"));
439 assert_eq!(
440 of_path("api/user.proto").map(|l| l.name),
441 Some("Protocol Buffer")
442 );
443 assert_eq!(of_path("LICENSE").map(|l| l.name), Some("Text"));
444 assert_eq!(of_path("deploy/app.ini").map(|l| l.name), Some("INI"));
445 }
446
447 #[test]
448 fn an_unknown_path_has_no_language() {
449 assert_eq!(of_path("data.bin"), None);
450 assert_eq!(of_path("no-extension-here"), None);
451 }
452
453 #[test]
454 fn counting_splits_code_comments_and_blanks() {
455 let rust = of_path("x.rs").expect("rust");
456 let source = "fn main() {\n\n // why\n let x = 1; // trailing\n}\n";
457 assert_eq!(count_lines(source, Some(rust)), (3, 1, 1));
458 }
459
460 #[test]
461 fn a_block_comment_runs_until_its_end_token() {
462 let rust = of_path("x.rs").expect("rust");
463 let source = "/* one\n two\n three */\nfn main() {}\n";
464 assert_eq!(count_lines(source, Some(rust)), (1, 3, 0));
465 }
466
467 #[test]
468 fn a_block_that_opens_and_closes_on_one_line_leaves_nothing_open() {
469 let rust = of_path("x.rs").expect("rust");
470 let source = "/* note */\nfn main() {}\nlet y = 2;\n";
471 assert_eq!(count_lines(source, Some(rust)), (2, 1, 0));
472 }
473
474 #[test]
475 fn a_shebang_counts_as_code_even_where_its_token_opens_comments() {
476 let shell = of_path("run.sh").expect("shell");
477 assert_eq!(
478 count_lines("#!/bin/sh\n# note\necho hi\n", Some(shell)),
479 (2, 1, 0)
480 );
481 }
482
483 #[test]
484 fn a_block_opener_that_starts_with_the_line_token_still_opens_a_block() {
485 let lua = of_path("init.lua").expect("lua");
486 let source = "--[[\n long note\n more\n]]\nprint(1)\n";
487 assert_eq!(count_lines(source, Some(lua)), (1, 4, 0));
488 assert_eq!(count_lines("-- note\nprint(1)\n", Some(lua)), (1, 1, 0));
489 }
490
491 #[test]
492 fn an_unknown_language_counts_every_filled_line_as_code() {
493 assert_eq!(count_lines("a\n\nb\n", None), (2, 0, 1));
494 }
495
496 #[test]
497 fn a_dark_ground_lifts_the_colours_that_would_vanish_on_it() {
498 let terminal = (0x0d, 0x11, 0x17);
499 let json = of_path("a.json").expect("json").color;
500 assert_eq!(json, (0x29, 0x29, 0x29), "the table keeps Linguist's hex");
501 let lifted = readable_on(json, terminal);
502 assert!(
503 luminance(lifted) > luminance(json),
504 "JSON's near-black lifts off the terminal ground: {lifted:?}"
505 );
506 assert!(contrast(lifted, terminal) >= 3.0);
507 }
508
509 #[test]
510 fn a_colour_that_already_reads_is_left_alone() {
511 let terminal = (0x0d, 0x11, 0x17);
512 let rust = of_path("a.rs").expect("rust").color;
513 assert_eq!(readable_on(rust, terminal), rust);
514 }
515
516 #[test]
517 fn a_light_ground_darkens_instead_of_lifting() {
518 let page = (0xff, 0xff, 0xff);
519 let shell = of_path("a.sh").expect("shell").color;
520 let fitted = readable_on(shell, page);
521 assert!(
522 luminance(fitted) < luminance(shell),
523 "Linguist's bright green has to darken for a light theme: {fitted:?}"
524 );
525 assert!(contrast(fitted, page) >= 3.0);
526 }
527}