(** Guessing a file's language for syntax highlighting. Detection is best-effort and purely advisory: the blob renders identically whether or not a language is found, so a wrong guess degrades to plain text rather than breaking the page. Sources are tried in descending order of reliability: the filename extension, then a shebang, then an Emacs file variable, then a Vim modeline. *) let of_filename name = (* First check the bare filename for well-known extensionless files *) let basename = Filename.basename name |> String.lowercase_ascii in match basename with | "makefile" | "gnumakefile" -> Some "makefile" | "dockerfile" -> Some "dockerfile" | "dune" | "dune-project" | "dune-workspace" -> Some "dune" | "jenkinsfile" -> Some "groovy" | _ -> ( match Filename.extension name |> String.lowercase_ascii with | ".ml" | ".mli" -> Some "ocaml" | ".mld" -> Some "ocamldoc" | ".c" | ".h" -> Some "c" | ".clj" | ".cljs" | ".cljc" | ".edn" -> Some "clojure" | ".cpp" | ".cc" | ".cxx" | ".hpp" -> Some "cpp" | ".cs" -> Some "csharp" | ".css" -> Some "css" | ".dart" -> Some "dart" | ".diff" | ".patch" -> Some "diff" | ".dune" -> Some "dune" | ".el" | ".lisp" | ".cl" | ".scm" | ".ss" -> Some "lisp" | ".erl" | ".hrl" -> Some "erlang" | ".ex" | ".exs" -> Some "elixir" | ".f90" | ".f95" | ".f03" | ".f08" | ".f" | ".for" -> Some "fortran" | ".go" -> Some "go" | ".gradle" | ".groovy" -> Some "groovy" | ".hs" -> Some "haskell" | ".html" | ".htm" -> Some "html" | ".java" -> Some "java" | ".jl" -> Some "julia" | ".js" | ".mjs" | ".cjs" -> Some "javascript" | ".json" -> Some "json" | ".kt" | ".kts" -> Some "kotlin" | ".lua" -> Some "lua" | ".m" -> Some "objective-c" | ".md" -> Some "markdown" | ".nix" -> Some "nix" | ".opam" -> Some "opam" | ".org" -> Some "org" | ".php" -> Some "php" | ".pl" | ".pm" | ".t" -> Some "perl" | ".proto" -> Some "protobuf" | ".ps1" | ".psm1" | ".psd1" -> Some "powershell" | ".py" -> Some "python" | ".r" -> Some "r" | ".rb" -> Some "ruby" | ".rs" -> Some "rust" | ".scala" | ".sc" -> Some "scala" | ".sh" | ".bash" | ".zsh" -> Some "bash" | ".sql" -> Some "sql" | ".swift" -> Some "swift" | ".tf" | ".hcl" -> Some "terraform" | ".toml" -> Some "toml" | ".ts" | ".tsx" -> Some "typescript" | ".vue" -> Some "vue" | ".xml" | ".svg" | ".xsl" -> Some "xml" | ".yaml" | ".yml" -> Some "yaml" | ".zig" -> Some "zig" | _ -> None) let of_shebang line = if not (String.starts_with ~prefix:"#!" line) then None else (* Extract the last path component, ignoring env and arguments *) let rest = String.sub line 2 (String.length line - 2) in let parts = String.split_on_char ' ' (String.trim rest) in let interpreter = match parts with | [] -> "" | cmd :: args -> let base = Filename.basename cmd in if base = "env" then (* /usr/bin/env python3 — take next non-flag argument *) List.find_opt (fun s -> s <> "" && s.[0] <> '-') args |> Option.value ~default:"" |> Filename.basename else base in (* Strip version suffixes: python3.11 -> python, ruby3.2 -> ruby *) let strip_trailing_digits s = let len = String.length s in let rec find_end i = if i < 0 then s else if s.[i] >= '0' && s.[i] <= '9' then find_end (i - 1) else String.sub s 0 (i + 1) in find_end (len - 1) in let interpreter = match String.split_on_char '.' interpreter with | [] -> "" | base :: _ -> strip_trailing_digits base in match String.lowercase_ascii interpreter with | "sh" | "bash" | "dash" | "ash" | "zsh" -> Some "bash" | "python" -> Some "python" | "ruby" -> Some "ruby" | "perl" -> Some "perl" | "node" | "deno" | "bun" -> Some "javascript" | "lua" -> Some "lua" | "php" -> Some "php" | "elixir" -> Some "elixir" | "awk" | "gawk" | "mawk" -> Some "awk" | "ocaml" -> Some "ocaml" | _ -> None let of_emacs_variables line = let find_between s prefix suffix = let plen = String.length prefix in let slen = String.length suffix in let total = String.length s in let rec find_start i = if i > total - plen then None else if String.sub s i plen = prefix then let after = i + plen in let rec find_end j = if j > total - slen then None else if String.sub s j slen = suffix then Some (String.sub s after (j - after) |> String.trim) else find_end (j + 1) in find_end after else find_start (i + 1) in find_start 0 in let extract_mode between = let props = String.split_on_char ';' between in let mode_prop = List.find_map (fun prop -> match String.split_on_char ':' (String.trim prop) with | [ key; value ] when String.trim (String.lowercase_ascii key) = "mode" -> Some (String.trim value) | _ -> None) props in match mode_prop with | Some _ -> mode_prop | None -> if (not (String.contains between ':')) && not (String.contains between ';') then Some (String.trim between) else None in let normalize_mode mode = match String.lowercase_ascii mode with | "tuareg" | "caml" | "ocaml" -> Some "ocaml" | "emacs-lisp" | "lisp" | "elisp" -> Some "lisp" | "shell-script" | "sh" | "bash" -> Some "bash" | "python" -> Some "python" | "ruby" -> Some "ruby" | "perl" | "cperl" -> Some "perl" | "c" -> Some "c" | "c++" -> Some "cpp" | "javascript" | "js" -> Some "javascript" | "typescript" -> Some "typescript" | "rust" -> Some "rust" | "go" -> Some "go" | "haskell" -> Some "haskell" | "lua" -> Some "lua" | "sql" -> Some "sql" | "yaml" -> Some "yaml" | "nix" -> Some "nix" | "makefile" -> Some "makefile" | m -> Some m in let ( >>= ) = Option.bind in find_between line "-*-" "-*-" >>= extract_mode >>= normalize_mode let of_vim_modeline line = let contains_substring s sub = let slen = String.length s in let sublen = String.length sub in let rec check i = if i > slen - sublen then false else if String.sub s i sublen = sub then true else check (i + 1) in sublen <= slen && check 0 in let l = String.lowercase_ascii line in let has_vim_prefix = contains_substring l "vim:" || contains_substring l "vi:" || contains_substring l "ex:" in if not has_vim_prefix then None else let find_value prefix s = let plen = String.length prefix in let slen = String.length s in let rec find_at i = if i > slen - plen then None else if String.sub s i plen = prefix then let vstart = i + plen in let rec scan_end j = if j >= slen || s.[j] = ' ' || s.[j] = ':' || s.[j] = '\t' then j else scan_end (j + 1) in let vend = scan_end vstart in Some (String.sub s vstart (vend - vstart)) else find_at (i + 1) in find_at 0 in let ft = match find_value "ft=" l with | Some _ as r -> r | None -> find_value "filetype=" l in match ft with | None -> None | Some ft -> ( match ft with | "sh" | "bash" | "zsh" -> Some "bash" | "python" -> Some "python" | "ruby" -> Some "ruby" | "perl" -> Some "perl" | "javascript" | "js" -> Some "javascript" | "typescript" -> Some "typescript" | "ocaml" -> Some "ocaml" | "c" -> Some "c" | "cpp" -> Some "cpp" | "rust" -> Some "rust" | "go" -> Some "go" | "haskell" -> Some "haskell" | "lua" -> Some "lua" | "make" | "makefile" -> Some "makefile" | "yaml" -> Some "yaml" | "sql" -> Some "sql" | "nix" -> Some "nix" | other -> Some other) (** Inspect the first and last five lines, where editors conventionally place mode declarations. *) let of_content content = let lines = String.split_on_char '\n' content in let len = List.length lines in let rec take n = function | _ when n <= 0 -> [] | [] -> [] | x :: rest -> x :: take (n - 1) rest in let rec drop n = function | l when n <= 0 -> l | [] -> [] | _ :: rest -> drop (n - 1) rest in let first_lines = take (min 5 len) lines in let last_lines = drop (max 0 (len - 5)) lines in let try_lines detector lines = List.find_map detector lines in let ( <|> ) a b = match a with Some _ -> a | None -> b () in match first_lines with | [] -> None | first :: _ -> ( (of_shebang first <|> fun () -> try_lines of_emacs_variables first_lines) <|> fun () -> try_lines of_vim_modeline first_lines ) <|> fun () -> try_lines of_vim_modeline last_lines (** Prefer the filename, falling back to markers inside the content. *) let detect ~filename content = match Option.bind filename of_filename with | Some _ as found -> found | None -> of_content content