[OCaml] Mobile-friendly clone of cgit.
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.
Changed files
.gitignore
@@ -3,3 +3,6 @@
3
3
_build/
4
4
_opam/
5
5
dist/
6
Added:
tmp/
7
Added:
\#*#
8
Added:
.#*
lib/line_diff.ml
@@ -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
@@ -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
@@ -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