Columns NOT numbered from zero (unless -z given).
[ocaml-csv.git] / csvtool.ml
1 (* Handy tool for managing CSV files.
2  * $Id: csvtool.ml,v 1.6 2006-11-24 10:09:56 rich Exp $
3  *)
4
5 open Printf
6 open Csv
7
8 (*------------------------------ start of code from extlib *)
9 exception Invalid_string
10
11 let find str sub =
12   let sublen = String.length sub in
13   if sublen = 0 then
14     0
15   else
16     let found = ref 0 in
17     let len = String.length str in
18     try
19       for i = 0 to len - sublen do
20         let j = ref 0 in
21         while String.unsafe_get str (i + !j) = String.unsafe_get sub !j do
22           incr j;
23           if !j = sublen then begin found := i; raise Exit; end;
24         done;
25       done;
26       raise Invalid_string
27     with
28       Exit -> !found
29
30 let split str sep =
31   let p = find str sep in
32   let len = String.length sep in
33   let slen = String.length str in
34   String.sub str 0 p, String.sub str (p + len) (slen - p - len)
35
36 let nsplit str sep =
37   if str = "" then []
38   else (
39     let rec nsplit str sep =
40       try
41         let s1 , s2 = split str sep in
42         s1 :: nsplit s2 sep
43       with
44         Invalid_string -> [str]
45     in
46     nsplit str sep
47   )
48
49 type 'a mut_list =  {
50         hd: 'a;
51         mutable tl: 'a list
52 }
53 external inj : 'a mut_list -> 'a list = "%identity"
54
55 let dummy_node () = { hd = Obj.magic (); tl = [] }
56
57 let rec drop n = function
58   | _ :: l when n > 0 -> drop (n-1) l
59   | l -> l
60
61 let take n l =
62   let rec loop n dst = function
63     | h :: t when n > 0 ->
64         let r = { hd = h; tl = [] } in
65         dst.tl <- inj r;
66         loop (n-1) r t
67     | _ ->
68         ()
69   in
70   let dummy = dummy_node() in
71   loop n dummy l;
72   dummy.tl
73 (*------------------------------ end of extlib code *)
74
75 (* Parse column specs. *)
76 type colspec = range list
77 and range =
78   | Col of int (* 0 *)
79   | Range of int * int (* 2-5 *)
80   | ToEnd of int (* 7- *)
81
82 let parse_colspec ~count_zero colspec =
83   let cols = nsplit colspec "," in
84   let cols = List.map (
85     fun col ->
86       try
87         (try
88            let first, second = split col "-" in
89            if second <> "" then
90              Range (int_of_string first, int_of_string second)
91            else
92              ToEnd (int_of_string first)
93          with
94            Invalid_string ->
95              Col (int_of_string col)
96         )
97       with
98         Failure "int_of_string" ->
99           failwith (colspec ^ ":" ^ col ^ ": invalid column-spec")
100   ) cols in
101
102   (* Adjust so columns always count from zero. *)
103   if not count_zero then
104     List.map (
105       function
106       | Col c -> Col (c-1)
107       | Range (s, e) -> Range (s-1, e-1)
108       | ToEnd e -> ToEnd (e-1)
109     ) cols
110   else
111     cols
112
113 let rec width_of_colspec = function
114   | [] -> 0
115   | Col c :: rest -> 1 + width_of_colspec rest
116   | Range (s, e) :: rest -> (e-s+1) + width_of_colspec rest
117   | ToEnd _ :: _ ->
118       failwith "width_of_colspec: cannot calculate width of an open column spec (one which contains 'N-')"
119
120 (* For closed column specs, this preserves the correct width in the
121  * result.
122  *)
123 let cols_of_colspec colspec row =
124   let rec loop = function
125     | [] -> []
126     | Col c :: rest ->
127         (try List.nth row c
128          with Failure "nth" -> "") :: loop rest
129     | Range (s, e) :: rest ->
130         let width = e-s+1 in
131         let range = take width (drop s row) in
132         let range = List.hd (set_columns width [range]) in
133         List.append range (loop rest)
134     | ToEnd e :: rest ->
135         List.append (drop e row) (loop rest)
136   in
137   loop colspec
138
139 (* The actual commands. *)
140 let cmd_cols ~input_sep ~output_sep ~chan colspec files =
141   List.iter (
142     fun filename ->
143       let csv = load ~separator:input_sep filename in
144       let csv = List.map (cols_of_colspec colspec) csv in
145       save_out ~separator:output_sep chan csv
146   ) files
147
148 let cmd_namedcols ~input_sep ~output_sep ~chan names files =
149   List.iter (
150     fun filename ->
151       let csv = load ~separator:input_sep filename in
152       let header, data =
153         match csv with
154         | [] -> failwith "no rows in this CSV file"
155         | h :: t -> h, t in
156       let data = associate header data in
157       let data = List.map (
158         fun row -> List.map (fun name -> List.assoc name row) names
159       ) data in
160       save_out ~separator:output_sep chan data
161   ) files
162
163 let cmd_width ~input_sep ~chan files =
164   let width = List.fold_left (
165     fun width filename ->
166       let csv = load ~separator:input_sep filename in
167       let width = max width (columns csv) in
168       width
169   ) 0 files in
170   fprintf chan "%d\n" width
171
172 let cmd_height ~input_sep ~chan files =
173   let height = List.fold_left (
174     fun height filename ->
175       let csv = load ~separator:input_sep filename in
176       let height = height + lines csv in
177       height
178   ) 0 files in
179   fprintf chan "%d\n" height
180
181 let cmd_readable ~input_sep ~chan files =
182   let csv = List.concat (List.map (load ~separator:input_sep) files) in
183   save_out_readable chan csv
184
185 let cmd_cat ~input_sep ~output_sep ~chan files =
186   (* Avoid loading the whole file into memory. *)
187   let f row =
188     save_out ~separator:output_sep chan [row]
189   in
190   List.iter (
191     fun filename ->
192       let in_chan = open_in filename in
193       load_rows ~separator:input_sep f in_chan;
194       close_in in_chan
195   ) files
196
197 let cmd_square ~input_sep ~output_sep ~chan files =
198   let csv = List.concat (List.map (load ~separator:input_sep) files) in
199   let csv = square csv in
200   save_out ~separator:output_sep chan csv
201
202 let cmd_sub ~input_sep ~output_sep ~chan r c rows cols files =
203   let csv = List.concat (List.map (load ~separator:input_sep) files) in
204   let csv = sub r c rows cols csv in
205   save_out ~separator:output_sep chan csv
206
207 let cmd_replace ~input_sep ~output_sep ~chan colspec update files =
208   let csv = List.concat (List.map (load ~separator:input_sep) files) in
209
210   (* Load the update CSV file in. *)
211   let update = Csv.load ~separator:input_sep update in
212
213   (* Compare two rows for equality by considering only the columns
214    * in colspec.
215    *)
216   let equal row1 row2 =
217     let row1 = cols_of_colspec colspec row1 in
218     let row2 = cols_of_colspec colspec row2 in
219     0 = Csv.compare [row1] [row2]
220   in
221
222   (* Look for rows in the original to be replaced by rows from the
223    * update file.  This is an ugly O(n^2) hack (XXX).
224    *)
225   let csv = List.filter (
226     fun row -> not (List.exists (equal row) update)
227   ) csv in
228   let csv = csv @ update in
229   save_out ~separator:output_sep chan csv
230
231 let rec uniq = function
232   | [] -> []
233   | [x] -> [x]
234   | x :: y :: xs when Pervasives.compare x y = 0 ->
235       uniq (x :: xs)
236   | x :: y :: xs ->
237       x :: uniq (y :: xs)
238
239 let cmd_join ~input_sep ~output_sep ~chan colspec1 colspec2 files =
240   (* Load in the files separately. *)
241   let csvs = List.map (load ~separator:input_sep) files in
242
243   (* For each CSV file, construct a hash table from row class (key) to
244    * the (possibly empty) output columns (values).
245    * Also construct a hash which has the unique list of row classes.
246    *)
247   let keys = Hashtbl.create 1023 in
248   let hashes = List.map (
249     fun csv ->
250       let hash = Hashtbl.create 1023 in
251       List.iter (
252         fun row ->
253           let key = cols_of_colspec colspec1 row in
254           let value = cols_of_colspec colspec2 row in
255           if not (Hashtbl.mem keys key) then Hashtbl.add keys key true;
256           Hashtbl.add hash key value
257       ) csv;
258       hash
259   ) csvs in
260
261   (* Get the keys. *)
262   let keys = Hashtbl.fold (fun key _ xs -> key :: xs) keys [] in
263
264   let value_width = width_of_colspec colspec2 in
265   let empty_value =
266     List.hd (set_columns value_width [[""]]) in
267   let multiple_values =
268     List.hd (set_columns value_width [["!MULTIPLE VALUES"]]) in
269
270   (* Generate output CSV. *)
271   let keys = List.sort Pervasives.compare keys in
272   let keys = List.map (fun key -> key, []) keys in
273   let csv = List.fold_left (
274     fun keys hash ->
275       List.map (
276         fun (key, values) ->
277           let value = try Hashtbl.find_all hash key with Not_found -> [] in
278           let value =
279             match value with
280             | [] -> empty_value
281             | [value] -> value
282             | _::_ -> multiple_values in
283           key, (value :: values)
284       ) keys
285   ) keys hashes in
286   let csv = List.map (
287     fun (key, values) ->
288       key @ List.flatten (List.rev values)
289   ) csv in
290   save_out ~separator:output_sep chan csv
291
292 (* Process the arguments. *)
293 let usage =
294   "csvtool - Copyright (C) 2005-2006 Richard W.M. Jones, Merjis Ltd.
295
296 csvtool is a tool for performing manipulations on CSV files from shell scripts.
297
298 Summary:
299   csvtool [-options] command [command-args] input.csv [input2.csv [...]]
300
301 Commands:
302   col <column-spec>
303     Return one or more columns from the CSV file.
304
305     For <column-spec>, see below.
306
307       Example: csvtool col 1-3,6 input.csv > output.csv
308
309   namedcol <names>
310     Assuming the first row of the CSV file is a list of column headings,
311     this returned the column(s) with the named headings.
312
313     <names> is a comma-separated list of names.
314
315       Example: csvtool namedcol Account,Cost input.csv > output.csv
316
317   width
318     Print the maximum width of the CSV file (number of columns in the
319     widest row).
320
321   height
322     Print the number of rows in the CSV file.
323
324   readable
325     Print the input CSV in a readable format.
326
327   cat
328     This concatenates the input files together and writes them to
329     the output.  You can use this to change the separator character.
330
331       Example: csvtool -t TAB -u , cat input.tsv > output.csv
332
333   join <column-spec1> <column-spec2>
334     Join (collate) multiple CSV files together.
335
336     <column-spec1> controls which columns are compared.
337
338     <column-spec2> controls which columns are copied into the new file.
339
340       Example:
341         csvtool join 1 2 coll1.csv coll2.csv
342         If coll1.csv contains:
343           Computers,$40
344           Software,$100
345         and coll2.csv contains:
346           Computers,$50
347         then the output will be:
348           Computers,$40,$50
349           Software,$100,
350
351   square
352     Make the CSV square, so all rows have the same length.
353
354   sub r c rows cols
355     Take a square subset of the CSV, top left at row r, column c (counting
356     from 0), which is rows deep and cols wide.
357
358   replace <column-spec> update.csv original.csv
359     Replace rows in original.csv with rows from update.csv.  The columns
360     in <column-spec> only are used to compare rows in input.csv and
361     update.csv to see if they are candidates for replacement.
362
363       Example:
364         csvtool replace 3 updates.csv original.csv > new.csv
365         mv new.csv original.csv
366
367 Column specs:
368   A <column-spec> is a comma-separated list of column numbers
369   or column ranges.
370
371     Examples:
372       1                       Column 1 (the first, leftmost column)
373       2,5,7                   Columns 2, 5 and 7
374       1-3,5                   Columns 1, 2, 3 and 5
375       1,5-                    Columns 1, 5 and up.
376
377   Columns are numbered starting from 1 unless the -z option is given.
378
379 Input files:
380   csvtool takes a list of input file(s) from the command line.
381
382 Output file:
383   Normally the output is written to stdout.  Use the -o option
384   to override this.
385
386 Separators:
387   The default separator character is , (comma).  To change this
388   on input or output see the -t and -u options respectively.
389
390   Use -t TAB or -u TAB (literally T-A-B!) to specify tab-separated
391   files.
392
393 Options:"
394
395 let () =
396   let input_sep = ref ',' in
397   let set_input_sep = function
398     | "TAB" -> input_sep := '\t'
399     | "COMMA" -> input_sep := ','
400     | "SPACE" -> input_sep := ' '
401     | s -> input_sep := s.[0]
402   in
403
404   let output_sep = ref ',' in
405   let set_output_sep = function
406     | "TAB" -> output_sep := '\t'
407     | "COMMA" -> output_sep := ','
408     | "SPACE" -> output_sep := ' '
409     | s -> output_sep := s.[0]
410   in
411
412   let count_zero = ref false in
413
414   let output_file = ref "" in
415
416   let argspec = [
417     "-t", Arg.String set_input_sep,
418     "Input separator char.  Use -t TAB for tab separated input.";
419     "-u", Arg.String set_output_sep,
420     "Output separator char.  Use -u TAB for tab separated output.";
421     "-o", Arg.Set_string output_file,
422     "Write output to file (instead of stdout)";
423     "-z", Arg.Set count_zero,
424     "Number columns from 0 instead of 1";
425   ] in
426
427   let rest = ref [] in
428   let set_rest str =
429     rest := str :: !rest
430   in
431
432   Arg.parse argspec set_rest usage;
433
434   let input_sep = !input_sep in
435   let output_sep = !output_sep in
436   let count_zero = !count_zero in
437   let output_file = !output_file in
438   let rest = List.rev !rest in
439
440   (* Set up the output file. *)
441   let chan =
442     if output_file <> "" then open_out output_file
443     else stdout in
444
445   (match rest with
446      | ("col"|"cols") :: colspec :: files ->
447          let colspec = parse_colspec ~count_zero colspec in
448          cmd_cols ~input_sep ~output_sep ~chan colspec files
449      | ("namedcol"|"namedcols") :: names :: files ->
450          let names = nsplit names "," in
451          cmd_namedcols ~input_sep ~output_sep ~chan names files
452      | "width" :: files ->
453          cmd_width ~input_sep ~chan files
454      | "height" :: files ->
455          cmd_height ~input_sep ~chan files
456      | "readable" :: files ->
457          cmd_readable ~input_sep ~chan files
458      | ("cat"|"concat") :: files ->
459          cmd_cat ~input_sep ~output_sep ~chan files
460      | ("join"|"collate") :: colspec1 :: colspec2 :: ((_::_::_) as files) ->
461          let colspec1 = parse_colspec ~count_zero colspec1 in
462          let colspec2 = parse_colspec ~count_zero colspec2 in
463          cmd_join ~input_sep ~output_sep ~chan colspec1 colspec2 files
464      | "square" :: files ->
465          cmd_square ~input_sep ~output_sep ~chan files
466      | "sub" :: r :: c :: rows :: cols :: files ->
467          let r = int_of_string r in
468          let c = int_of_string c in
469          let rows = int_of_string rows in
470          let cols = int_of_string cols in
471          cmd_sub ~input_sep ~output_sep ~chan r c rows cols files
472      | "replace" :: colspec :: update :: files ->
473          let colspec = parse_colspec ~count_zero colspec in
474          cmd_replace ~input_sep ~output_sep ~chan colspec update files
475      | _ ->
476          prerr_endline (Sys.executable_name ^ " --help for usage");
477          exit 2
478   );
479
480   if output_file <> "" then close_out chan