refactor move syntax.ml from lib/views/ to lib/

syntax.ml is a pure language-detection utility with no view or Dream_html dependency. It belongs alongside highlight.ml and highlight_grammars.ml, forming the detection → registry → render pipeline. No code changes; include_subdirs unqualified makes the module name stable.

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