1use std::path::Path;
4
5use crate::plugin_lang::{self, LanguageName};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8pub enum Lang {
9 Plain,
10 Rust,
11 Python,
12 JavaScript,
13 Bash,
14 C,
15 Cpp,
16 Css,
17 Go,
18 Html,
19 Java,
20 Json,
21 Lua,
22 Ruby,
23 Sql,
26 Toml,
27 TypeScript,
28 Wit,
36 Tsx,
41 Yaml,
42 Markdown,
49 Plugin(LanguageName),
62}
63
64impl Lang {
65 pub fn builtin_by_name(name: &str) -> Option<Self> {
71 const BUILTINS: &[Lang] = &[
72 Lang::Plain,
73 Lang::Rust,
74 Lang::Python,
75 Lang::JavaScript,
76 Lang::Bash,
77 Lang::C,
78 Lang::Cpp,
79 Lang::Css,
80 Lang::Go,
81 Lang::Html,
82 Lang::Java,
83 Lang::Json,
84 Lang::Lua,
85 Lang::Ruby,
86 Lang::Sql,
87 Lang::Toml,
88 Lang::TypeScript,
89 Lang::Wit,
90 Lang::Tsx,
91 Lang::Yaml,
92 Lang::Markdown,
93 ];
94 BUILTINS.iter().copied().find(|l| l.name() == name).or_else(
95 || (name == "markdown_inline").then_some(Lang::Markdown),
99 )
100 }
101
102 pub fn detect_from_path(path: Option<&Path>) -> Self {
104 if let Some(p) = path
106 && let Some(name) = p.file_name().and_then(|n| n.to_str())
107 {
108 let lower = name.to_ascii_lowercase();
109 match lower.as_str() {
110 ".bashrc" | ".bash_profile" | ".bash_login" | ".bash_logout" | ".zshrc"
111 | ".zshenv" | ".zprofile" | ".zlogin" | ".zlogout" | ".profile" | ".shrc"
112 | ".kshrc" => return Lang::Bash,
113 _ => {}
114 }
115 }
116 match path
117 .and_then(|p| p.extension())
118 .and_then(|e| e.to_str())
119 .map(str::to_ascii_lowercase)
120 .as_deref()
121 {
122 Some("rs") => Lang::Rust,
123 Some("py") | Some("pyw") => Lang::Python,
124 Some("js") | Some("mjs") | Some("cjs") => Lang::JavaScript,
125 Some("sh") | Some("bash") | Some("zsh") | Some("fish") => Lang::Bash,
126 Some("c") | Some("h") => Lang::C,
127 Some("cpp") | Some("cc") | Some("cxx") | Some("hpp") | Some("hh") | Some("hxx") => {
128 Lang::Cpp
129 }
130 Some("css") => Lang::Css,
131 Some("go") => Lang::Go,
132 Some("html") | Some("htm") | Some("xhtml") => Lang::Html,
133 Some("java") => Lang::Java,
134 Some("json") => Lang::Json,
135 Some("lua") => Lang::Lua,
136 Some("rb") | Some("ruby") => Lang::Ruby,
137 Some("sql") => Lang::Sql,
138 Some("toml") => Lang::Toml,
139 Some("wit") => Lang::Wit,
140 Some("ts") | Some("mts") | Some("cts") => Lang::TypeScript,
141 Some("tsx") => Lang::Tsx,
142 Some("yaml") | Some("yml") => Lang::Yaml,
143 Some("md") | Some("markdown") | Some("mdown") | Some("mkd") => Lang::Markdown,
144 Some(ext) => plugin_lang::resolve_extension(ext).map_or(Lang::Plain, Lang::Plugin),
151 None => Lang::Plain,
152 }
153 }
154
155 pub fn label(self) -> &'static str {
156 match self {
157 Lang::Plain => "plain",
158 Lang::Rust => "rust",
159 Lang::Python => "python",
160 Lang::JavaScript => "javascript",
161 Lang::Bash => "bash",
162 Lang::C => "c",
163 Lang::Cpp => "cpp",
164 Lang::Css => "css",
165 Lang::Go => "go",
166 Lang::Html => "html",
167 Lang::Java => "java",
168 Lang::Json => "json",
169 Lang::Lua => "lua",
170 Lang::Ruby => "ruby",
171 Lang::Sql => "sql",
172 Lang::Toml => "toml",
173 Lang::TypeScript => "typescript",
174 Lang::Tsx => "tsx",
175 Lang::Yaml => "yaml",
176 Lang::Wit => "wit",
177 Lang::Markdown => "markdown",
178 Lang::Plugin(n) => n.as_str(),
181 }
182 }
183
184 pub fn name(self) -> &'static str {
189 match self {
190 Lang::Plain => "plain",
191 Lang::Rust => "rust",
192 Lang::Python => "python",
193 Lang::JavaScript => "javascript",
194 Lang::Bash => "bash",
195 Lang::C => "c",
196 Lang::Cpp => "cpp",
197 Lang::Css => "css",
198 Lang::Go => "go",
199 Lang::Html => "html",
200 Lang::Java => "java",
201 Lang::Json => "json",
202 Lang::Lua => "lua",
203 Lang::Ruby => "ruby",
204 Lang::Sql => "sql",
205 Lang::Toml => "toml",
206 Lang::TypeScript => "typescript",
207 Lang::Tsx => "tsx",
208 Lang::Yaml => "yaml",
209 Lang::Wit => "wit",
210 Lang::Markdown => "markdown",
211 Lang::Plugin(n) => n.as_str(),
214 }
215 }
216
217 pub fn comment_syntax(self) -> lattice_grammar::CommentSyntax {
223 let (line, block): (Option<&str>, Option<(&str, &str)>) = match self {
224 Lang::Rust | Lang::JavaScript | Lang::TypeScript | Lang::Tsx => {
225 (Some("//"), Some(("/*", "*/")))
226 }
227 Lang::Python | Lang::Ruby | Lang::Bash | Lang::Yaml | Lang::Toml => (Some("#"), None),
228 Lang::Go | Lang::C | Lang::Cpp | Lang::Java | Lang::Wit => {
231 (Some("//"), Some(("/*", "*/")))
232 }
233 Lang::Sql => (Some("--"), Some(("/*", "*/"))),
235 Lang::Css => (None, Some(("/*", "*/"))),
236 Lang::Html => (None, Some(("<!--", "-->"))),
237 Lang::Lua => (Some("--"), Some(("--[[", "]]"))),
238 Lang::Json | Lang::Plain | Lang::Markdown => (None, None),
239 Lang::Plugin(_) => (None, None),
243 };
244 lattice_grammar::CommentSyntax {
245 line: line.map(str::to_string),
246 block: block.map(|(s, e)| (s.to_string(), e.to_string())),
247 }
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 #![allow(clippy::unwrap_used, clippy::panic)]
254 use super::*;
255 use std::path::PathBuf;
256
257 #[test]
261 fn line_comment_leaders_match_each_language() {
262 let expected: &[(Lang, Option<&str>)] = &[
263 (Lang::Rust, Some("//")),
264 (Lang::JavaScript, Some("//")),
265 (Lang::TypeScript, Some("//")),
266 (Lang::Tsx, Some("//")),
267 (Lang::Go, Some("//")),
268 (Lang::C, Some("//")),
269 (Lang::Cpp, Some("//")),
270 (Lang::Java, Some("//")),
271 (Lang::Wit, Some("//")),
272 (Lang::Python, Some("#")),
273 (Lang::Ruby, Some("#")),
274 (Lang::Bash, Some("#")),
275 (Lang::Yaml, Some("#")),
276 (Lang::Toml, Some("#")),
277 (Lang::Sql, Some("--")),
278 (Lang::Lua, Some("--")),
279 (Lang::Css, None),
280 (Lang::Html, None),
281 (Lang::Json, None),
282 (Lang::Markdown, None),
283 (Lang::Plain, None),
284 ];
285 for (lang, leader) in expected {
286 assert_eq!(
287 lang.comment_syntax().line.as_deref(),
288 *leader,
289 "line comment leader for {lang:?}"
290 );
291 }
292 }
293
294 #[test]
295 fn sql_keeps_its_block_comment_form() {
296 assert_eq!(
297 Lang::Sql.comment_syntax().block,
298 Some(("/*".to_string(), "*/".to_string()))
299 );
300 }
301
302 #[test]
303 fn detects_rust() {
304 assert_eq!(
305 Lang::detect_from_path(Some(&PathBuf::from("foo.rs"))),
306 Lang::Rust
307 );
308 }
309
310 #[test]
311 fn detects_python() {
312 assert_eq!(
313 Lang::detect_from_path(Some(&PathBuf::from("foo.py"))),
314 Lang::Python
315 );
316 assert_eq!(
317 Lang::detect_from_path(Some(&PathBuf::from("foo.pyw"))),
318 Lang::Python
319 );
320 }
321
322 #[test]
323 fn detects_javascript() {
324 assert_eq!(
325 Lang::detect_from_path(Some(&PathBuf::from("foo.js"))),
326 Lang::JavaScript
327 );
328 assert_eq!(
329 Lang::detect_from_path(Some(&PathBuf::from("foo.mjs"))),
330 Lang::JavaScript
331 );
332 assert_eq!(
333 Lang::detect_from_path(Some(&PathBuf::from("foo.cjs"))),
334 Lang::JavaScript
335 );
336 }
337
338 #[test]
339 fn falls_back_to_plain() {
340 assert_eq!(Lang::detect_from_path(None), Lang::Plain);
341 assert_eq!(
342 Lang::detect_from_path(Some(&PathBuf::from("README"))),
343 Lang::Plain
344 );
345 assert_eq!(
346 Lang::detect_from_path(Some(&PathBuf::from("foo.unknown"))),
347 Lang::Plain
348 );
349 }
350
351 #[test]
352 fn detection_is_case_insensitive() {
353 assert_eq!(
354 Lang::detect_from_path(Some(&PathBuf::from("FOO.RS"))),
355 Lang::Rust
356 );
357 assert_eq!(
358 Lang::detect_from_path(Some(&PathBuf::from("Foo.PY"))),
359 Lang::Python
360 );
361 }
362
363 #[test]
364 fn label_is_distinct_per_lang() {
365 assert_eq!(Lang::Plain.label(), "plain");
366 assert_eq!(Lang::Rust.label(), "rust");
367 assert_eq!(Lang::Python.label(), "python");
368 assert_eq!(Lang::JavaScript.label(), "javascript");
369 assert_eq!(Lang::Markdown.label(), "markdown");
370 }
371
372 #[test]
375 fn detects_wit_files() {
376 assert_eq!(
377 Lang::detect_from_path(Some(&PathBuf::from("wit/grammar.wit"))),
378 Lang::Wit
379 );
380 }
381
382 #[test]
389 fn a_wit_declaration_and_its_name_are_both_styled() {
390 let src = "interface ui {\n register-segment: func(id: string);\n}\n";
391 let mut syntax = crate::Syntax::for_language(Lang::Wit)
392 .expect("registry builds")
393 .expect("wit has a grammar");
394 syntax.parse(src);
395 let lines = syntax.highlight_lines_native(0, 3).expect("spans");
396 let first = &lines[0];
397 assert!(
398 !first.is_empty(),
399 "the `interface ui {{` line produced no styled spans at all"
400 );
401 let styles: std::collections::BTreeSet<String> =
404 first.iter().map(|s| format!("{:?}", s.style)).collect();
405 assert!(
406 styles.len() >= 2,
407 "keyword and declaration name must style differently, got {styles:?}"
408 );
409 }
410
411 #[test]
422 fn a_doc_comment_styles_the_same_in_wit_as_in_rust() {
423 fn styles(lang: Lang, src: &str) -> Vec<crate::Style> {
424 let mut s = crate::Syntax::for_language(lang)
425 .expect("registry builds")
426 .expect("language has a grammar");
427 s.parse(src);
428 s.highlight_lines_native(0, 2)
429 .expect("spans")
430 .iter()
431 .flat_map(|line| line.iter().map(|sp| sp.style))
432 .collect()
433 }
434 let wit = styles(Lang::Wit, "/// Doc.\n// Plain.\ninterface ui {\n}\n");
435 let rust = styles(Lang::Rust, "/// Doc.\n// Plain.\nfn f() {}\n");
436 assert_eq!(
437 wit, rust,
438 "a doc comment and a line comment must resolve to the same styles in \
439 both languages; WIT rendered them as Attribute and Type"
440 );
441 }
442
443 #[test]
444 fn detects_markdown_extensions() {
445 for ext in ["md", "markdown", "mdown", "mkd"] {
446 assert_eq!(
447 Lang::detect_from_path(Some(&PathBuf::from(format!("README.{ext}")))),
448 Lang::Markdown,
449 "{ext}"
450 );
451 }
452 assert_eq!(
454 Lang::detect_from_path(Some(&PathBuf::from("README.MD"))),
455 Lang::Markdown
456 );
457 }
458}