View raw

1 (** Guessing a file's language for syntax highlighting. 2 3 Detection is best-effort and purely advisory: the blob renders identically 4 whether or not a language is found, so a wrong guess degrades to plain text 5 rather than breaking the page. 6 7 Sources are tried in descending order of reliability: the filename 8 extension, then a shebang, then an Emacs file variable, then a Vim modeline. 9 *) 10 11 let of_filename name = 12 (* First check the bare filename for well-known extensionless files *) 13 let basename = Filename.basename name |> String.lowercase_ascii in 14 match basename with 15 | "makefile" | "gnumakefile" -> Some "makefile" 16 | "dockerfile" -> Some "dockerfile" 17 | "dune" | "dune-project" | "dune-workspace" -> Some "dune" 18 | "jenkinsfile" -> Some "groovy" 19 | _ -> ( 20 match Filename.extension name |> String.lowercase_ascii with 21 | ".ml" | ".mli" -> Some "ocaml" 22 | ".mld" -> Some "ocamldoc" 23 | ".c" | ".h" -> Some "c" 24 | ".clj" | ".cljs" | ".cljc" | ".edn" -> Some "clojure" 25 | ".cpp" | ".cc" | ".cxx" | ".hpp" -> Some "cpp" 26 | ".cs" -> Some "csharp" 27 | ".css" -> Some "css" 28 | ".dart" -> Some "dart" 29 | ".diff" | ".patch" -> Some "diff" 30 | ".dune" -> Some "dune" 31 | ".el" | ".lisp" | ".cl" | ".scm" | ".ss" -> Some "lisp" 32 | ".erl" | ".hrl" -> Some "erlang" 33 | ".ex" | ".exs" -> Some "elixir" 34 | ".f90" | ".f95" | ".f03" | ".f08" | ".f" | ".for" -> Some "fortran" 35 | ".go" -> Some "go" 36 | ".gradle" | ".groovy" -> Some "groovy" 37 | ".hs" -> Some "haskell" 38 | ".html" | ".htm" -> Some "html" 39 | ".java" -> Some "java" 40 | ".jl" -> Some "julia" 41 | ".js" | ".mjs" | ".cjs" -> Some "javascript" 42 | ".json" -> Some "json" 43 | ".kt" | ".kts" -> Some "kotlin" 44 | ".lua" -> Some "lua" 45 | ".m" -> Some "objective-c" 46 | ".md" -> Some "markdown" 47 | ".nix" -> Some "nix" 48 | ".opam" -> Some "opam" 49 | ".org" -> Some "org" 50 | ".php" -> Some "php" 51 | ".pl" | ".pm" | ".t" -> Some "perl" 52 | ".proto" -> Some "protobuf" 53 | ".ps1" | ".psm1" | ".psd1" -> Some "powershell" 54 | ".py" -> Some "python" 55 | ".r" -> Some "r" 56 | ".rb" -> Some "ruby" 57 | ".rs" -> Some "rust" 58 | ".scala" | ".sc" -> Some "scala" 59 | ".sh" | ".bash" | ".zsh" -> Some "bash" 60 | ".sql" -> Some "sql" 61 | ".swift" -> Some "swift" 62 | ".tf" | ".hcl" -> Some "terraform" 63 | ".toml" -> Some "toml" 64 | ".ts" | ".tsx" -> Some "typescript" 65 | ".vue" -> Some "vue" 66 | ".xml" | ".svg" | ".xsl" -> Some "xml" 67 | ".yaml" | ".yml" -> Some "yaml" 68 | ".zig" -> Some "zig" 69 | _ -> None) 70 71 let of_shebang line = 72 if not (String.starts_with ~prefix:"#!" line) then None 73 else 74 (* Extract the last path component, ignoring env and arguments *) 75 let rest = String.sub line 2 (String.length line - 2) in 76 let parts = String.split_on_char ' ' (String.trim rest) in 77 let interpreter = 78 match parts with 79 | [] -> "" 80 | cmd :: args -> 81 let base = Filename.basename cmd in 82 if base = "env" then 83 (* /usr/bin/env python3 — take next non-flag argument *) 84 List.find_opt (fun s -> s <> "" && s.[0] <> '-') args 85 |> Option.value ~default:"" |> Filename.basename 86 else base 87 in 88 (* Strip version suffixes: python3.11 -> python, ruby3.2 -> ruby *) 89 let strip_trailing_digits s = 90 let len = String.length s in 91 let rec find_end i = 92 if i < 0 then s 93 else if s.[i] >= '0' && s.[i] <= '9' then find_end (i - 1) 94 else String.sub s 0 (i + 1) 95 in 96 find_end (len - 1) 97 in 98 let interpreter = 99 match String.split_on_char '.' interpreter with 100 | [] -> "" 101 | base :: _ -> strip_trailing_digits base 102 in 103 match String.lowercase_ascii interpreter with 104 | "sh" | "bash" | "dash" | "ash" | "zsh" -> Some "bash" 105 | "python" -> Some "python" 106 | "ruby" -> Some "ruby" 107 | "perl" -> Some "perl" 108 | "node" | "deno" | "bun" -> Some "javascript" 109 | "lua" -> Some "lua" 110 | "php" -> Some "php" 111 | "elixir" -> Some "elixir" 112 | "awk" | "gawk" | "mawk" -> Some "awk" 113 | "ocaml" -> Some "ocaml" 114 | _ -> None 115 116 let of_emacs_variables line = 117 let find_between s prefix suffix = 118 let plen = String.length prefix in 119 let slen = String.length suffix in 120 let total = String.length s in 121 let rec find_start i = 122 if i > total - plen then None 123 else if String.sub s i plen = prefix then 124 let after = i + plen in 125 let rec find_end j = 126 if j > total - slen then None 127 else if String.sub s j slen = suffix then 128 Some (String.sub s after (j - after) |> String.trim) 129 else find_end (j + 1) 130 in 131 find_end after 132 else find_start (i + 1) 133 in 134 find_start 0 135 in 136 let extract_mode between = 137 let props = String.split_on_char ';' between in 138 let mode_prop = 139 List.find_map 140 (fun prop -> 141 match String.split_on_char ':' (String.trim prop) with 142 | [ key; value ] 143 when String.trim (String.lowercase_ascii key) = "mode" -> 144 Some (String.trim value) 145 | _ -> None) 146 props 147 in 148 match mode_prop with 149 | Some _ -> mode_prop 150 | None -> 151 if 152 (not (String.contains between ':')) 153 && not (String.contains between ';') 154 then Some (String.trim between) 155 else None 156 in 157 let normalize_mode mode = 158 match String.lowercase_ascii mode with 159 | "tuareg" | "caml" | "ocaml" -> Some "ocaml" 160 | "emacs-lisp" | "lisp" | "elisp" -> Some "lisp" 161 | "shell-script" | "sh" | "bash" -> Some "bash" 162 | "python" -> Some "python" 163 | "ruby" -> Some "ruby" 164 | "perl" | "cperl" -> Some "perl" 165 | "c" -> Some "c" 166 | "c++" -> Some "cpp" 167 | "javascript" | "js" -> Some "javascript" 168 | "typescript" -> Some "typescript" 169 | "rust" -> Some "rust" 170 | "go" -> Some "go" 171 | "haskell" -> Some "haskell" 172 | "lua" -> Some "lua" 173 | "sql" -> Some "sql" 174 | "yaml" -> Some "yaml" 175 | "nix" -> Some "nix" 176 | "makefile" -> Some "makefile" 177 | m -> Some m 178 in 179 let ( >>= ) = Option.bind in 180 find_between line "-*-" "-*-" >>= extract_mode >>= normalize_mode 181 182 let of_vim_modeline line = 183 let contains_substring s sub = 184 let slen = String.length s in 185 let sublen = String.length sub in 186 let rec check i = 187 if i > slen - sublen then false 188 else if String.sub s i sublen = sub then true 189 else check (i + 1) 190 in 191 sublen <= slen && check 0 192 in 193 let l = String.lowercase_ascii line in 194 let has_vim_prefix = 195 contains_substring l "vim:" 196 || contains_substring l "vi:" || contains_substring l "ex:" 197 in 198 if not has_vim_prefix then None 199 else 200 let find_value prefix s = 201 let plen = String.length prefix in 202 let slen = String.length s in 203 let rec find_at i = 204 if i > slen - plen then None 205 else if String.sub s i plen = prefix then 206 let vstart = i + plen in 207 let rec scan_end j = 208 if j >= slen || s.[j] = ' ' || s.[j] = ':' || s.[j] = '\t' then j 209 else scan_end (j + 1) 210 in 211 let vend = scan_end vstart in 212 Some (String.sub s vstart (vend - vstart)) 213 else find_at (i + 1) 214 in 215 find_at 0 216 in 217 let ft = 218 match find_value "ft=" l with 219 | Some _ as r -> r 220 | None -> find_value "filetype=" l 221 in 222 match ft with 223 | None -> None 224 | Some ft -> ( 225 match ft with 226 | "sh" | "bash" | "zsh" -> Some "bash" 227 | "python" -> Some "python" 228 | "ruby" -> Some "ruby" 229 | "perl" -> Some "perl" 230 | "javascript" | "js" -> Some "javascript" 231 | "typescript" -> Some "typescript" 232 | "ocaml" -> Some "ocaml" 233 | "c" -> Some "c" 234 | "cpp" -> Some "cpp" 235 | "rust" -> Some "rust" 236 | "go" -> Some "go" 237 | "haskell" -> Some "haskell" 238 | "lua" -> Some "lua" 239 | "make" | "makefile" -> Some "makefile" 240 | "yaml" -> Some "yaml" 241 | "sql" -> Some "sql" 242 | "nix" -> Some "nix" 243 | other -> Some other) 244 245 (** Inspect the first and last five lines, where editors conventionally place 246 mode declarations. *) 247 let of_content content = 248 let lines = String.split_on_char '\n' content in 249 let len = List.length lines in 250 let rec take n = function 251 | _ when n <= 0 -> [] 252 | [] -> [] 253 | x :: rest -> x :: take (n - 1) rest 254 in 255 let rec drop n = function 256 | l when n <= 0 -> l 257 | [] -> [] 258 | _ :: rest -> drop (n - 1) rest 259 in 260 let first_lines = take (min 5 len) lines in 261 let last_lines = drop (max 0 (len - 5)) lines in 262 let try_lines detector lines = List.find_map detector lines in 263 let ( <|> ) a b = match a with Some _ -> a | None -> b () in 264 match first_lines with 265 | [] -> None 266 | first :: _ -> 267 ( (of_shebang first <|> fun () -> try_lines of_emacs_variables first_lines) 268 <|> fun () -> try_lines of_vim_modeline first_lines ) 269 <|> fun () -> try_lines of_vim_modeline last_lines 270 271 (** Prefer the filename, falling back to markers inside the content. *) 272 let detect ~filename content = 273 match Option.bind filename of_filename with 274 | Some _ as found -> found 275 | None -> of_content content 276