To check out this repository please hg clone the following URL, or open the URL using EasyMercurial or your preferred Mercurial client.

Statistics Download as Zip
| Branch: | Tag: | Revision:

root / repoint.sml

History | View | Annotate | Download (106 KB)

1 105:fa09c8e155ec Chris
(*
2
    DO NOT EDIT THIS FILE.
3
    This file is automatically generated from the individual
4
    source files in the Repoint repository.
5
*)
6 100:dd1207a4dfc9 Chris
7
(*
8 105:fa09c8e155ec Chris
    Repoint
9 100:dd1207a4dfc9 Chris
10
    A simple manager for third-party source code dependencies
11
12 105:fa09c8e155ec Chris
    Copyright 2018 Chris Cannam, Particular Programs Ltd,
13
    and Queen Mary, University of London
14 100:dd1207a4dfc9 Chris
15
    Permission is hereby granted, free of charge, to any person
16
    obtaining a copy of this software and associated documentation
17
    files (the "Software"), to deal in the Software without
18
    restriction, including without limitation the rights to use, copy,
19
    modify, merge, publish, distribute, sublicense, and/or sell copies
20
    of the Software, and to permit persons to whom the Software is
21
    furnished to do so, subject to the following conditions:
22
23
    The above copyright notice and this permission notice shall be
24
    included in all copies or substantial portions of the Software.
25
26
    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
27
    EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
28
    MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
29
    NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR
30
    ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
31
    CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
32
    WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
33
34 105:fa09c8e155ec Chris
    Except as contained in this notice, the names of Chris Cannam,
35
    Particular Programs Ltd, and Queen Mary, University of London
36
    shall not be used in advertising or otherwise to promote the sale,
37
    use or other dealings in this Software without prior written
38
    authorization.
39 100:dd1207a4dfc9 Chris
*)
40
41 260:9b00c7784153 Chris
val repoint_version = "1.2"
42 100:dd1207a4dfc9 Chris
43
44
datatype vcs =
45
         HG |
46 105:fa09c8e155ec Chris
         GIT |
47
         SVN
48 100:dd1207a4dfc9 Chris
49
datatype source =
50
         URL_SOURCE of string |
51
         SERVICE_SOURCE of {
52
             service : string,
53
             owner : string option,
54
             repo : string option
55
         }
56
57
type id_or_tag = string
58
59
datatype pin =
60
         UNPINNED |
61
         PINNED of id_or_tag
62
63
datatype libstate =
64
         ABSENT |
65
         CORRECT |
66
         SUPERSEDED |
67
         WRONG
68
69
datatype localstate =
70
         MODIFIED |
71
         LOCK_MISMATCHED |
72
         CLEAN
73
74
datatype branch =
75
         BRANCH of string |
76
         DEFAULT_BRANCH
77
78
(* If we can recover from an error, for example by reporting failure
79
   for this one thing and going on to the next thing, then the error
80
   should usually be returned through a result type rather than an
81
   exception. *)
82
83
datatype 'a result =
84
         OK of 'a |
85
         ERROR of string
86
87
type libname = string
88
89
type libspec = {
90
    libname : libname,
91
    vcs : vcs,
92
    source : source,
93
    branch : branch,
94
    project_pin : pin,
95
    lock_pin : pin
96
}
97
98
type lock = {
99
    libname : libname,
100
    id_or_tag : id_or_tag
101
}
102
103
type remote_spec = {
104
    anon : string option,
105
    auth : string option
106
}
107
108
type provider = {
109
    service : string,
110
    supports : vcs list,
111
    remote_spec : remote_spec
112
}
113
114
type account = {
115
    service : string,
116
    login : string
117
}
118
119
type context = {
120
    rootpath : string,
121
    extdir : string,
122
    providers : provider list,
123
    accounts : account list
124
}
125
126
type userconfig = {
127
    providers : provider list,
128
    accounts : account list
129
}
130
131
type project = {
132
    context : context,
133
    libs : libspec list
134
}
135
136 105:fa09c8e155ec Chris
structure RepointFilenames = struct
137
    val project_file = "repoint-project.json"
138
    val project_lock_file = "repoint-lock.json"
139 110:125520c3dc05 Chris
    val project_completion_file = ".repoint.point"
140 105:fa09c8e155ec Chris
    val user_config_file = ".repoint.json"
141
    val archive_dir = ".repoint-archive"
142 100:dd1207a4dfc9 Chris
end
143
144
signature VCS_CONTROL = sig
145
146 105:fa09c8e155ec Chris
    (** Check whether the given VCS is installed and working *)
147
    val is_working : context -> bool result
148
149 100:dd1207a4dfc9 Chris
    (** Test whether the library is present locally at all *)
150
    val exists : context -> libname -> bool result
151
152
    (** Return the id (hash) of the current revision for the library *)
153
    val id_of : context -> libname -> id_or_tag result
154
155
    (** Test whether the library is at the given id *)
156
    val is_at : context -> libname * id_or_tag -> bool result
157
158
    (** Test whether the library is on the given branch, i.e. is at
159
        the branch tip or an ancestor of it *)
160
    val is_on_branch : context -> libname * branch -> bool result
161
162
    (** Test whether the library is at the newest revision for the
163
        given branch. False may indicate that the branch has advanced
164
        or that the library is not on the branch at all. This function
165
        may use the network to check for new revisions *)
166 105:fa09c8e155ec Chris
    val is_newest : context -> libname * source * branch -> bool result
167 100:dd1207a4dfc9 Chris
168
    (** Test whether the library is at the newest revision available
169
        locally for the given branch. False may indicate that the
170
        branch has advanced or that the library is not on the branch
171
        at all. This function must not use the network *)
172
    val is_newest_locally : context -> libname * branch -> bool result
173
174
    (** Test whether the library has been modified in the local
175
        working copy *)
176
    val is_modified_locally : context -> libname -> bool result
177
178
    (** Check out, i.e. clone a fresh copy of, the repo for the given
179
        library on the given branch *)
180
    val checkout : context -> libname * source * branch -> unit result
181
182 105:fa09c8e155ec Chris
    (** Update the library to the given branch tip. Assumes that a
183
        local copy of the library already exists *)
184
    val update : context -> libname * source * branch -> unit result
185 100:dd1207a4dfc9 Chris
186
    (** Update the library to the given specific id or tag *)
187 105:fa09c8e155ec Chris
    val update_to : context -> libname * source * id_or_tag -> unit result
188
189
    (** Return a URL from which the library can be cloned, given that
190
        the local copy already exists. For a DVCS this can be the
191
        local copy, but for a centralised VCS it will have to be the
192
        remote repository URL. Used for archiving *)
193
    val copy_url_for : context -> libname -> string result
194 100:dd1207a4dfc9 Chris
end
195
196
signature LIB_CONTROL = sig
197
    val review : context -> libspec -> (libstate * localstate) result
198
    val status : context -> libspec -> (libstate * localstate) result
199 105:fa09c8e155ec Chris
    val update : context -> libspec -> unit result
200 100:dd1207a4dfc9 Chris
    val id_of : context -> libspec -> id_or_tag result
201 105:fa09c8e155ec Chris
    val is_working : context -> vcs -> bool result
202 100:dd1207a4dfc9 Chris
end
203
204
structure FileBits :> sig
205
    val extpath : context -> string
206
    val libpath : context -> libname -> string
207
    val subpath : context -> libname -> string -> string
208
    val command_output : context -> libname -> string list -> string result
209
    val command : context -> libname -> string list -> unit result
210 105:fa09c8e155ec Chris
    val file_url : string -> string
211 100:dd1207a4dfc9 Chris
    val file_contents : string -> string
212
    val mydir : unit -> string
213
    val homedir : unit -> string
214
    val mkpath : string -> unit result
215 105:fa09c8e155ec Chris
    val rmpath : string -> unit result
216
    val nonempty_dir_exists : string -> bool
217 100:dd1207a4dfc9 Chris
    val project_spec_path : string -> string
218
    val project_lock_path : string -> string
219 110:125520c3dc05 Chris
    val project_completion_path : string -> string
220 100:dd1207a4dfc9 Chris
    val verbose : unit -> bool
221
end = struct
222
223
    fun verbose () =
224 105:fa09c8e155ec Chris
        case OS.Process.getEnv "REPOINT_VERBOSE" of
225 100:dd1207a4dfc9 Chris
            SOME "0" => false
226
          | SOME _ => true
227
          | NONE => false
228
229 105:fa09c8e155ec Chris
    fun split_relative path desc =
230
        case OS.Path.fromString path of
231
            { isAbs = true, ... } => raise Fail (desc ^ " may not be absolute")
232
          | { arcs, ... } => arcs
233
234 100:dd1207a4dfc9 Chris
    fun extpath ({ rootpath, extdir, ... } : context) =
235
        let val { isAbs, vol, arcs } = OS.Path.fromString rootpath
236
        in OS.Path.toString {
237
                isAbs = isAbs,
238
                vol = vol,
239 105:fa09c8e155ec Chris
                arcs = arcs @
240
                       split_relative extdir "extdir"
241 100:dd1207a4dfc9 Chris
            }
242
        end
243
244
    fun subpath ({ rootpath, extdir, ... } : context) libname remainder =
245
        (* NB libname is allowed to be a path fragment, e.g. foo/bar *)
246
        let val { isAbs, vol, arcs } = OS.Path.fromString rootpath
247
        in OS.Path.toString {
248
                isAbs = isAbs,
249
                vol = vol,
250 105:fa09c8e155ec Chris
                arcs = arcs @
251
                       split_relative extdir "extdir" @
252
                       split_relative libname "library path" @
253
                       split_relative remainder "subpath"
254 100:dd1207a4dfc9 Chris
            }
255
        end
256
257
    fun libpath context "" =
258
        extpath context
259
      | libpath context libname =
260
        subpath context libname ""
261
262
    fun project_file_path rootpath filename =
263
        let val { isAbs, vol, arcs } = OS.Path.fromString rootpath
264
        in OS.Path.toString {
265
                isAbs = isAbs,
266
                vol = vol,
267
                arcs = arcs @ [ filename ]
268
            }
269
        end
270
271
    fun project_spec_path rootpath =
272 105:fa09c8e155ec Chris
        project_file_path rootpath (RepointFilenames.project_file)
273 100:dd1207a4dfc9 Chris
274
    fun project_lock_path rootpath =
275 105:fa09c8e155ec Chris
        project_file_path rootpath (RepointFilenames.project_lock_file)
276 100:dd1207a4dfc9 Chris
277 110:125520c3dc05 Chris
    fun project_completion_path rootpath =
278
        project_file_path rootpath (RepointFilenames.project_completion_file)
279
280 100:dd1207a4dfc9 Chris
    fun trim str =
281
        hd (String.fields (fn x => x = #"\n" orelse x = #"\r") str)
282 190:dd9a670543df Chris
283
    fun make_canonical path =
284
        (* SML/NJ doesn't properly handle "/" when splitting paths -
285
           it should be a path separator even on Windows, but SML/NJ
286
           treats it as a normal filename character there. So we must
287
           convert these explicitly *)
288
        OS.Path.mkCanonical
289
            (if OS.Path.concat ("a", "b") = "a\\b"
290
             then String.translate (fn #"/" => "\\" |
291
                                    c => Char.toString c)
292
                                   path
293
             else path)
294 105:fa09c8e155ec Chris
295
    fun file_url path =
296
        let val forward_path =
297
                String.translate (fn #"\\" => "/" |
298
                                  c => Char.toString c)
299
                                 (OS.Path.mkCanonical path)
300
        in
301
            (* Path is expected to be absolute already, but if it
302
               starts with a drive letter, we'll need an extra slash *)
303
            case explode forward_path of
304
                #"/"::rest => "file:///" ^ implode rest
305
              | _ => "file:///" ^ forward_path
306
        end
307 100:dd1207a4dfc9 Chris
308
    fun file_contents filename =
309
        let val stream = TextIO.openIn filename
310
            fun read_all str acc =
311
                case TextIO.inputLine str of
312
                    SOME line => read_all str (trim line :: acc)
313
                  | NONE => rev acc
314
            val contents = read_all stream []
315
            val _ = TextIO.closeIn stream
316
        in
317
            String.concatWith "\n" contents
318
        end
319
320
    fun expand_commandline cmdlist =
321 105:fa09c8e155ec Chris
        (* We are quite strict about what we accept here, except
322 100:dd1207a4dfc9 Chris
           for the first element in cmdlist which is assumed to be a
323 105:fa09c8e155ec Chris
           known command location rather than arbitrary user input. *)
324 100:dd1207a4dfc9 Chris
        let open Char
325
            fun quote arg =
326
                if List.all
327
                       (fn c => isAlphaNum c orelse c = #"-" orelse c = #"_")
328
                       (explode arg)
329
                then arg
330
                else "\"" ^ arg ^ "\""
331
            fun check arg =
332 260:9b00c7784153 Chris
                let val valid = explode " /#:;?,._-{}@=+%"
333 100:dd1207a4dfc9 Chris
                in
334
                    app (fn c =>
335
                            if isAlphaNum c orelse
336 105:fa09c8e155ec Chris
                               List.exists (fn v => v = c) valid orelse
337
                               c > chr 127
338 100:dd1207a4dfc9 Chris
                            then ()
339
                            else raise Fail ("Invalid character '" ^
340
                                             (Char.toString c) ^
341
                                             "' in command list"))
342
                        (explode arg);
343
                    arg
344
                end
345
        in
346
            String.concatWith " "
347
                              (map quote
348
                                   (hd cmdlist :: map check (tl cmdlist)))
349
        end
350
351
    val tick_cycle = ref 0
352
    val tick_chars = Vector.fromList (map String.str (explode "|/-\\"))
353
354
    fun tick libname cmdlist =
355
        let val n = Vector.length tick_chars
356
            fun pad_to n str =
357
                if n <= String.size str then str
358
                else pad_to n (str ^ " ")
359
            val name = if libname <> "" then libname
360
                       else if cmdlist = nil then ""
361
                       else hd (rev cmdlist)
362
        in
363
            print ("  " ^
364
                   Vector.sub(tick_chars, !tick_cycle) ^ " " ^
365 105:fa09c8e155ec Chris
                   pad_to 70 name ^
366 100:dd1207a4dfc9 Chris
                   "\r");
367
            tick_cycle := (if !tick_cycle = n - 1 then 0 else 1 + !tick_cycle)
368
        end
369
370
    fun run_command context libname cmdlist redirect =
371
        let open OS
372
            val dir = libpath context libname
373
            val cmd = expand_commandline cmdlist
374
            val _ = if verbose ()
375 105:fa09c8e155ec Chris
                    then print ("\n=== " ^ dir ^ "\n<<< " ^ cmd ^ "\n")
376 100:dd1207a4dfc9 Chris
                    else tick libname cmdlist
377
            val _ = FileSys.chDir dir
378
            val status = case redirect of
379
                             NONE => Process.system cmd
380
                           | SOME file => Process.system (cmd ^ ">" ^ file)
381
        in
382
            if Process.isSuccess status
383
            then OK ()
384
            else ERROR ("Command failed: " ^ cmd ^ " (in dir " ^ dir ^ ")")
385
        end
386
        handle ex => ERROR ("Unable to run command: " ^ exnMessage ex)
387
388
    fun command context libname cmdlist =
389
        run_command context libname cmdlist NONE
390
391
    fun command_output context libname cmdlist =
392
        let open OS
393
            val tmpFile = FileSys.tmpName ()
394
            val result = run_command context libname cmdlist (SOME tmpFile)
395
            val contents = file_contents tmpFile
396 105:fa09c8e155ec Chris
            val _ = if verbose ()
397
                    then print (">>> \"" ^ contents ^ "\"\n")
398
                    else ()
399 100:dd1207a4dfc9 Chris
        in
400
            FileSys.remove tmpFile handle _ => ();
401
            case result of
402
                OK () => OK contents
403
              | ERROR e => ERROR e
404
        end
405
406
    fun mydir () =
407
        let open OS
408
            val { dir, file } = Path.splitDirFile (CommandLine.name ())
409
        in
410
            FileSys.realPath
411
                (if Path.isAbsolute dir
412
                 then dir
413
                 else Path.concat (FileSys.getDir (), dir))
414
        end
415
416
    fun homedir () =
417
        (* Failure is not routine, so we use an exception here *)
418
        case (OS.Process.getEnv "HOME",
419
              OS.Process.getEnv "HOMEPATH") of
420
            (SOME home, _) => home
421
          | (NONE, SOME home) => home
422
          | (NONE, NONE) =>
423
            raise Fail "Failed to look up home directory from environment"
424
425 105:fa09c8e155ec Chris
    fun mkpath' path =
426 100:dd1207a4dfc9 Chris
        if OS.FileSys.isDir path handle _ => false
427
        then OK ()
428
        else case OS.Path.fromString path of
429
                 { arcs = nil, ... } => OK ()
430
               | { isAbs = false, ... } => ERROR "mkpath requires absolute path"
431
               | { isAbs, vol, arcs } =>
432 105:fa09c8e155ec Chris
                 case mkpath' (OS.Path.toString {      (* parent *)
433
                                    isAbs = isAbs,
434
                                    vol = vol,
435
                                    arcs = rev (tl (rev arcs)) }) of
436 100:dd1207a4dfc9 Chris
                     ERROR e => ERROR e
437
                   | OK () => ((OS.FileSys.mkDir path; OK ())
438
                               handle OS.SysErr (e, _) =>
439
                                      ERROR ("Directory creation failed: " ^ e))
440 105:fa09c8e155ec Chris
441
    fun mkpath path =
442 190:dd9a670543df Chris
        mkpath' (make_canonical path)
443 105:fa09c8e155ec Chris
444
    fun dir_contents dir =
445
        let open OS
446
            fun files_from dirstream =
447
                case FileSys.readDir dirstream of
448
                    NONE => []
449
                  | SOME file =>
450
                    (* readDir is supposed to filter these,
451
                       but let's be extra cautious: *)
452
                    if file = Path.parentArc orelse file = Path.currentArc
453
                    then files_from dirstream
454
                    else file :: files_from dirstream
455
            val stream = FileSys.openDir dir
456
            val files = map (fn f => Path.joinDirFile
457
                                         { dir = dir, file = f })
458
                            (files_from stream)
459
            val _ = FileSys.closeDir stream
460
        in
461
            files
462
        end
463
464
    fun rmpath' path =
465
        let open OS
466
            fun remove path =
467
                if FileSys.isLink path (* dangling links bother isDir *)
468
                then FileSys.remove path
469
                else if FileSys.isDir path
470
                then (app remove (dir_contents path); FileSys.rmDir path)
471
                else FileSys.remove path
472
        in
473
            (remove path; OK ())
474
            handle SysErr (e, _) => ERROR ("Path removal failed: " ^ e)
475
        end
476
477
    fun rmpath path =
478 190:dd9a670543df Chris
        rmpath' (make_canonical path)
479 105:fa09c8e155ec Chris
480
    fun nonempty_dir_exists path =
481
        let open OS.FileSys
482
        in
483
            (not (isLink path) andalso
484
             isDir path andalso
485
             dir_contents path <> [])
486
            handle _ => false
487
        end
488
489 100:dd1207a4dfc9 Chris
end
490
491
functor LibControlFn (V: VCS_CONTROL) :> LIB_CONTROL = struct
492
493
    (* Valid states for unpinned libraries:
494
495
       - CORRECT: We are on the right branch and are up-to-date with
496
         it as far as we can tell. (If not using the network, this
497
         should be reported to user as "Present" rather than "Correct"
498
         as the remote repo may have advanced without us knowing.)
499
500
       - SUPERSEDED: We are on the right branch but we can see that
501
         there is a newer revision either locally or on the remote (in
502
         Git terms, we are at an ancestor of the desired branch tip).
503
504
       - WRONG: We are on the wrong branch (in Git terms, we are not
505
         at the desired branch tip or any ancestor of it).
506
507
       - ABSENT: Repo doesn't exist here at all.
508
509
       Valid states for pinned libraries:
510
511
       - CORRECT: We are at the pinned revision.
512
513
       - WRONG: We are at any revision other than the pinned one.
514
515
       - ABSENT: Repo doesn't exist here at all.
516
    *)
517
518
    fun check with_network context
519 105:fa09c8e155ec Chris
              ({ libname, source, branch,
520
                 project_pin, lock_pin, ... } : libspec) =
521 100:dd1207a4dfc9 Chris
        let fun check_unpinned () =
522 105:fa09c8e155ec Chris
                let val newest =
523
                        if with_network
524
                        then V.is_newest context (libname, source, branch)
525
                        else V.is_newest_locally context (libname, branch)
526 100:dd1207a4dfc9 Chris
                in
527 105:fa09c8e155ec Chris
                    case newest of
528 100:dd1207a4dfc9 Chris
                         ERROR e => ERROR e
529
                       | OK true => OK CORRECT
530
                       | OK false =>
531
                         case V.is_on_branch context (libname, branch) of
532
                             ERROR e => ERROR e
533
                           | OK true => OK SUPERSEDED
534
                           | OK false => OK WRONG
535
                end
536
            fun check_pinned target =
537
                case V.is_at context (libname, target) of
538
                    ERROR e => ERROR e
539
                  | OK true => OK CORRECT
540
                  | OK false => OK WRONG
541
            fun check_remote () =
542
                case project_pin of
543
                    UNPINNED => check_unpinned ()
544
                  | PINNED target => check_pinned target
545
            fun check_local () =
546
                case V.is_modified_locally context libname of
547
                    ERROR e => ERROR e
548
                  | OK true  => OK MODIFIED
549
                  | OK false =>
550
                    case lock_pin of
551
                        UNPINNED => OK CLEAN
552
                      | PINNED target =>
553
                        case V.is_at context (libname, target) of
554
                            ERROR e => ERROR e
555
                          | OK true => OK CLEAN
556
                          | OK false => OK LOCK_MISMATCHED
557
        in
558
            case V.exists context libname of
559
                ERROR e => ERROR e
560
              | OK false => OK (ABSENT, CLEAN)
561
              | OK true =>
562
                case (check_remote (), check_local ()) of
563
                    (ERROR e, _) => ERROR e
564
                  | (_, ERROR e) => ERROR e
565
                  | (OK r, OK l) => OK (r, l)
566
        end
567
568
    val review = check true
569
    val status = check false
570
571
    fun update context
572
               ({ libname, source, branch,
573
                  project_pin, lock_pin, ... } : libspec) =
574
        let fun update_unpinned () =
575 105:fa09c8e155ec Chris
                case V.is_newest context (libname, source, branch) of
576 100:dd1207a4dfc9 Chris
                    ERROR e => ERROR e
577 105:fa09c8e155ec Chris
                  | OK true => OK ()
578
                  | OK false => V.update context (libname, source, branch)
579 100:dd1207a4dfc9 Chris
            fun update_pinned target =
580
                case V.is_at context (libname, target) of
581
                    ERROR e => ERROR e
582 105:fa09c8e155ec Chris
                  | OK true => OK ()
583
                  | OK false => V.update_to context (libname, source, target)
584 100:dd1207a4dfc9 Chris
            fun update' () =
585
                case lock_pin of
586
                    PINNED target => update_pinned target
587
                  | UNPINNED =>
588
                    case project_pin of
589
                        PINNED target => update_pinned target
590
                      | UNPINNED => update_unpinned ()
591
        in
592
            case V.exists context libname of
593
                ERROR e => ERROR e
594
              | OK true => update' ()
595
              | OK false =>
596
                case V.checkout context (libname, source, branch) of
597
                    ERROR e => ERROR e
598
                  | OK () => update' ()
599
        end
600
601
    fun id_of context ({ libname, ... } : libspec) =
602
        V.id_of context libname
603 105:fa09c8e155ec Chris
604
    fun is_working context vcs =
605
        V.is_working context
606 100:dd1207a4dfc9 Chris
607
end
608
609
(* Simple Standard ML JSON parser
610
   https://bitbucket.org/cannam/sml-simplejson
611 105:fa09c8e155ec Chris
   Copyright 2017 Chris Cannam. BSD licence.
612 100:dd1207a4dfc9 Chris
   Parts based on the JSON parser in the Ponyo library by Phil Eaton.
613
*)
614
615
signature JSON = sig
616
617
    datatype json = OBJECT of (string * json) list
618
                  | ARRAY of json list
619
                  | NUMBER of real
620
                  | STRING of string
621
                  | BOOL of bool
622
                  | NULL
623
624
    datatype 'a result = OK of 'a
625
                       | ERROR of string
626
627
    val parse : string -> json result
628
    val serialise : json -> string
629
    val serialiseIndented : json -> string
630
631
end
632
633
structure Json :> JSON = struct
634
635
    datatype json = OBJECT of (string * json) list
636
                  | ARRAY of json list
637
                  | NUMBER of real
638
                  | STRING of string
639
                  | BOOL of bool
640
                  | NULL
641
642
    datatype 'a result = OK of 'a
643
                       | ERROR of string
644
645
    structure T = struct
646
        datatype token = NUMBER of char list
647
                       | STRING of string
648
                       | BOOL of bool
649
                       | NULL
650
                       | CURLY_L
651
                       | CURLY_R
652
                       | SQUARE_L
653
                       | SQUARE_R
654
                       | COLON
655
                       | COMMA
656
657
        fun toString t =
658
            case t of NUMBER digits => implode digits
659
                    | STRING s => s
660
                    | BOOL b => Bool.toString b
661
                    | NULL => "null"
662
                    | CURLY_L => "{"
663
                    | CURLY_R => "}"
664
                    | SQUARE_L => "["
665
                    | SQUARE_R => "]"
666
                    | COLON => ":"
667
                    | COMMA => ","
668
    end
669
670
    fun bmpToUtf8 cp =  (* convert a codepoint in Unicode BMP to utf8 bytes *)
671
        let open Word
672
	    infix 6 orb andb >>
673
        in
674
            map (Char.chr o toInt)
675
                (if cp < 0wx80 then
676
                     [cp]
677
                 else if cp < 0wx800 then
678
                     [0wxc0 orb (cp >> 0w6), 0wx80 orb (cp andb 0wx3f)]
679
                 else if cp < 0wx10000 then
680
                     [0wxe0 orb (cp >> 0w12),
681
                      0wx80 orb ((cp >> 0w6) andb 0wx3f),
682
		      0wx80 orb (cp andb 0wx3f)]
683
                 else raise Fail ("Invalid BMP point " ^ (Word.toString cp)))
684
        end
685
686
    fun error pos text = ERROR (text ^ " at character position " ^
687
                                Int.toString (pos - 1))
688
    fun token_error pos = error pos ("Unexpected token")
689
690
    fun lexNull pos acc (#"u" :: #"l" :: #"l" :: xs) =
691
        lex (pos + 3) (T.NULL :: acc) xs
692
      | lexNull pos acc _ = token_error pos
693
694
    and lexTrue pos acc (#"r" :: #"u" :: #"e" :: xs) =
695
        lex (pos + 3) (T.BOOL true :: acc) xs
696
      | lexTrue pos acc _ = token_error pos
697
698
    and lexFalse pos acc (#"a" :: #"l" :: #"s" :: #"e" :: xs) =
699
        lex (pos + 4) (T.BOOL false :: acc) xs
700
      | lexFalse pos acc _ = token_error pos
701
702
    and lexChar tok pos acc xs =
703
        lex pos (tok :: acc) xs
704
705
    and lexString pos acc cc =
706
        let datatype escaped = ESCAPED | NORMAL
707
            fun lexString' pos text ESCAPED [] =
708
                error pos "End of input during escape sequence"
709
              | lexString' pos text NORMAL [] =
710
                error pos "End of input during string"
711
              | lexString' pos text ESCAPED (x :: xs) =
712
                let fun esc c = lexString' (pos + 1) (c :: text) NORMAL xs
713
                in case x of
714
                       #"\"" => esc x
715
                     | #"\\" => esc x
716
                     | #"/"  => esc x
717
                     | #"b"  => esc #"\b"
718
                     | #"f"  => esc #"\f"
719
                     | #"n"  => esc #"\n"
720
                     | #"r"  => esc #"\r"
721
                     | #"t"  => esc #"\t"
722
                     | _     => error pos ("Invalid escape \\" ^
723
                                           Char.toString x)
724
                end
725
              | lexString' pos text NORMAL (#"\\" :: #"u" ::a::b::c::d:: xs) =
726
                if List.all Char.isHexDigit [a,b,c,d]
727
                then case Word.fromString ("0wx" ^ (implode [a,b,c,d])) of
728
                         SOME w => (let val utf = rev (bmpToUtf8 w) in
729
                                        lexString' (pos + 6) (utf @ text)
730
                                                   NORMAL xs
731
                                    end
732
                                    handle Fail err => error pos err)
733
                       | NONE => error pos "Invalid Unicode BMP escape sequence"
734
                else error pos "Invalid Unicode BMP escape sequence"
735
              | lexString' pos text NORMAL (x :: xs) =
736
                if Char.ord x < 0x20
737
                then error pos "Invalid unescaped control character"
738
                else
739
                    case x of
740
                        #"\"" => OK (rev text, xs, pos + 1)
741
                      | #"\\" => lexString' (pos + 1) text ESCAPED xs
742
                      | _     => lexString' (pos + 1) (x :: text) NORMAL xs
743
        in
744
            case lexString' pos [] NORMAL cc of
745
                OK (text, rest, newpos) =>
746
                lex newpos (T.STRING (implode text) :: acc) rest
747
              | ERROR e => ERROR e
748
        end
749
750
    and lexNumber firstChar pos acc cc =
751
        let val valid = explode ".+-e"
752
            fun lexNumber' pos digits [] = (rev digits, [], pos)
753
              | lexNumber' pos digits (x :: xs) =
754
                if x = #"E" then lexNumber' (pos + 1) (#"e" :: digits) xs
755
                else if Char.isDigit x orelse List.exists (fn c => x = c) valid
756
                then lexNumber' (pos + 1) (x :: digits) xs
757
                else (rev digits, x :: xs, pos)
758
            val (digits, rest, newpos) =
759
                lexNumber' (pos - 1) [] (firstChar :: cc)
760
        in
761
            case digits of
762
                [] => token_error pos
763
              | _ => lex newpos (T.NUMBER digits :: acc) rest
764
        end
765
766
    and lex pos acc [] = OK (rev acc)
767
      | lex pos acc (x::xs) =
768
        (case x of
769
             #" "  => lex
770
           | #"\t" => lex
771
           | #"\n" => lex
772
           | #"\r" => lex
773
           | #"{"  => lexChar T.CURLY_L
774
           | #"}"  => lexChar T.CURLY_R
775
           | #"["  => lexChar T.SQUARE_L
776
           | #"]"  => lexChar T.SQUARE_R
777
           | #":"  => lexChar T.COLON
778
           | #","  => lexChar T.COMMA
779
           | #"\"" => lexString
780
           | #"t"  => lexTrue
781
           | #"f"  => lexFalse
782
           | #"n"  => lexNull
783
           | x     => lexNumber x) (pos + 1) acc xs
784
785
    fun show [] = "end of input"
786
      | show (tok :: _) = T.toString tok
787
788
    fun parseNumber digits =
789
        (* Note lexNumber already case-insensitised the E for us *)
790
        let open Char
791
792
            fun okExpDigits [] = false
793
              | okExpDigits (c :: []) = isDigit c
794
              | okExpDigits (c :: cs) = isDigit c andalso okExpDigits cs
795
796
            fun okExponent [] = false
797
              | okExponent (#"+" :: cs) = okExpDigits cs
798
              | okExponent (#"-" :: cs) = okExpDigits cs
799
              | okExponent cc = okExpDigits cc
800
801
            fun okFracTrailing [] = true
802
              | okFracTrailing (c :: cs) =
803
                (isDigit c andalso okFracTrailing cs) orelse
804
                (c = #"e" andalso okExponent cs)
805
806
            fun okFraction [] = false
807
              | okFraction (c :: cs) =
808
                isDigit c andalso okFracTrailing cs
809
810
            fun okPosTrailing [] = true
811
              | okPosTrailing (#"." :: cs) = okFraction cs
812
              | okPosTrailing (#"e" :: cs) = okExponent cs
813
              | okPosTrailing (c :: cs) =
814
                isDigit c andalso okPosTrailing cs
815
816
            fun okPositive [] = false
817
              | okPositive (#"0" :: []) = true
818
              | okPositive (#"0" :: #"." :: cs) = okFraction cs
819
              | okPositive (#"0" :: #"e" :: cs) = okExponent cs
820
              | okPositive (#"0" :: cs) = false
821
              | okPositive (c :: cs) = isDigit c andalso okPosTrailing cs
822
823
            fun okNumber (#"-" :: cs) = okPositive cs
824
              | okNumber cc = okPositive cc
825
        in
826
            if okNumber digits
827
            then case Real.fromString (implode digits) of
828
                     NONE => ERROR "Number out of range"
829
                   | SOME r => OK r
830
            else ERROR ("Invalid number \"" ^ (implode digits) ^ "\"")
831
        end
832
833
    fun parseObject (T.CURLY_R :: xs) = OK (OBJECT [], xs)
834
      | parseObject tokens =
835
        let fun parsePair (T.STRING key :: T.COLON :: xs) =
836
                (case parseTokens xs of
837
                     ERROR e => ERROR e
838
                   | OK (j, xs) => OK ((key, j), xs))
839
              | parsePair other =
840
                ERROR ("Object key/value pair expected around \"" ^
841
                       show other ^ "\"")
842
            fun parseObject' acc [] = ERROR "End of input during object"
843
              | parseObject' acc tokens =
844
                case parsePair tokens of
845
                    ERROR e => ERROR e
846
                  | OK (pair, T.COMMA :: xs) =>
847
                    parseObject' (pair :: acc) xs
848
                  | OK (pair, T.CURLY_R :: xs) =>
849
                    OK (OBJECT (rev (pair :: acc)), xs)
850
                  | OK (_, _) => ERROR "Expected , or } after object element"
851
        in
852
            parseObject' [] tokens
853
        end
854
855
    and parseArray (T.SQUARE_R :: xs) = OK (ARRAY [], xs)
856
      | parseArray tokens =
857
        let fun parseArray' acc [] = ERROR "End of input during array"
858
              | parseArray' acc tokens =
859
                case parseTokens tokens of
860
                    ERROR e => ERROR e
861
                  | OK (j, T.COMMA :: xs) => parseArray' (j :: acc) xs
862
                  | OK (j, T.SQUARE_R :: xs) => OK (ARRAY (rev (j :: acc)), xs)
863
                  | OK (_, _) => ERROR "Expected , or ] after array element"
864
        in
865
            parseArray' [] tokens
866
        end
867
868
    and parseTokens [] = ERROR "Value expected"
869
      | parseTokens (tok :: xs) =
870
        (case tok of
871
             T.NUMBER d => (case parseNumber d of
872
                                OK r => OK (NUMBER r, xs)
873
                              | ERROR e => ERROR e)
874
           | T.STRING s => OK (STRING s, xs)
875
           | T.BOOL b   => OK (BOOL b, xs)
876
           | T.NULL     => OK (NULL, xs)
877
           | T.CURLY_L  => parseObject xs
878
           | T.SQUARE_L => parseArray xs
879
           | _ => ERROR ("Unexpected token " ^ T.toString tok ^
880
                         " before " ^ show xs))
881
882
    fun parse str =
883
        case lex 1 [] (explode str) of
884
           ERROR e => ERROR e
885
         | OK tokens => case parseTokens tokens of
886
                            OK (value, []) => OK value
887
                          | OK (_, _) => ERROR "Extra data after input"
888
                          | ERROR e => ERROR e
889
890
    fun stringEscape s =
891
        let fun esc x = [x, #"\\"]
892
            fun escape' acc [] = rev acc
893
              | escape' acc (x :: xs) =
894
                escape' (case x of
895
                             #"\"" => esc x @ acc
896
                           | #"\\" => esc x @ acc
897
                           | #"\b" => esc #"b" @ acc
898
                           | #"\f" => esc #"f" @ acc
899
                           | #"\n" => esc #"n" @ acc
900
                           | #"\r" => esc #"r" @ acc
901
                           | #"\t" => esc #"t" @ acc
902
                           | _ =>
903
                             let val c = Char.ord x
904
                             in
905
                                 if c < 0x20
906
                                 then let val hex = Word.toString (Word.fromInt c)
907
                                      in (rev o explode) (if c < 0x10
908
                                                          then ("\\u000" ^ hex)
909
                                                          else ("\\u00" ^ hex))
910
                                      end @ acc
911
                                 else
912
                                     x :: acc
913
                             end)
914
                        xs
915
        in
916
            implode (escape' [] (explode s))
917
        end
918
919
    fun serialise json =
920
        case json of
921
            OBJECT pp => "{" ^ String.concatWith
922
                                   "," (map (fn (key, value) =>
923
                                                serialise (STRING key) ^ ":" ^
924
                                                serialise value) pp) ^
925
                         "}"
926
          | ARRAY arr => "[" ^ String.concatWith "," (map serialise arr) ^ "]"
927
          | NUMBER n => implode (map (fn #"~" => #"-" | c => c)
928
                                     (explode (Real.toString n)))
929
          | STRING s => "\"" ^ stringEscape s ^ "\""
930
          | BOOL b => Bool.toString b
931
          | NULL => "null"
932
933
    fun serialiseIndented json =
934
        let fun indent 0 = ""
935
              | indent i = "  " ^ indent (i - 1)
936
            fun serialiseIndented' i json =
937
                let val ser = serialiseIndented' (i + 1)
938
                in
939
                    case json of
940
                        OBJECT [] => "{}"
941
                      | ARRAY [] => "[]"
942
                      | OBJECT pp => "{\n" ^ indent (i + 1) ^
943
                                     String.concatWith
944
                                         (",\n" ^ indent (i + 1))
945
                                         (map (fn (key, value) =>
946
                                                  ser (STRING key) ^ ": " ^
947
                                                  ser value) pp) ^
948
                                     "\n" ^ indent i ^ "}"
949
                      | ARRAY arr => "[\n" ^ indent (i + 1) ^
950
                                     String.concatWith
951
                                         (",\n" ^ indent (i + 1))
952
                                         (map ser arr) ^
953
                                     "\n" ^ indent i ^ "]"
954
                      | other => serialise other
955
                end
956
        in
957
            serialiseIndented' 0 json ^ "\n"
958
        end
959
960
end
961
962
963
structure JsonBits :> sig
964 105:fa09c8e155ec Chris
    exception Config of string
965 100:dd1207a4dfc9 Chris
    val load_json_from : string -> Json.json (* filename -> json *)
966
    val save_json_to : string -> Json.json -> unit
967
    val lookup_optional : Json.json -> string list -> Json.json option
968
    val lookup_optional_string : Json.json -> string list -> string option
969
    val lookup_mandatory : Json.json -> string list -> Json.json
970
    val lookup_mandatory_string : Json.json -> string list -> string
971
end = struct
972
973 105:fa09c8e155ec Chris
    exception Config of string
974
975 100:dd1207a4dfc9 Chris
    fun load_json_from filename =
976
        case Json.parse (FileBits.file_contents filename) of
977
            Json.OK json => json
978 105:fa09c8e155ec Chris
          | Json.ERROR e => raise Config ("Failed to parse file: " ^ e)
979 100:dd1207a4dfc9 Chris
980
    fun save_json_to filename json =
981
        (* using binary I/O to avoid ever writing CR/LF line endings *)
982
        let val jstr = Json.serialiseIndented json
983
            val stream = BinIO.openOut filename
984
        in
985
            BinIO.output (stream, Byte.stringToBytes jstr);
986
            BinIO.closeOut stream
987
        end
988
989
    fun lookup_optional json kk =
990
        let fun lookup key =
991
                case json of
992
                    Json.OBJECT kvs =>
993 105:fa09c8e155ec Chris
                    (case List.filter (fn (k, v) => k = key) kvs of
994
                         [] => NONE
995
                       | [(_,v)] => SOME v
996
                       | _ => raise Config ("Duplicate key: " ^
997
                                            (String.concatWith " -> " kk)))
998
                  | _ => raise Config "Object expected"
999 100:dd1207a4dfc9 Chris
        in
1000
            case kk of
1001
                [] => NONE
1002
              | key::[] => lookup key
1003
              | key::kk => case lookup key of
1004
                               NONE => NONE
1005
                             | SOME j => lookup_optional j kk
1006
        end
1007
1008
    fun lookup_optional_string json kk =
1009
        case lookup_optional json kk of
1010
            SOME (Json.STRING s) => SOME s
1011 105:fa09c8e155ec Chris
          | SOME _ => raise Config ("Value (if present) must be string: " ^
1012
                                    (String.concatWith " -> " kk))
1013 100:dd1207a4dfc9 Chris
          | NONE => NONE
1014
1015
    fun lookup_mandatory json kk =
1016
        case lookup_optional json kk of
1017
            SOME v => v
1018 105:fa09c8e155ec Chris
          | NONE => raise Config ("Value is mandatory: " ^
1019
                                  (String.concatWith " -> " kk))
1020 100:dd1207a4dfc9 Chris
1021
    fun lookup_mandatory_string json kk =
1022
        case lookup_optional json kk of
1023
            SOME (Json.STRING s) => s
1024 105:fa09c8e155ec Chris
          | _ => raise Config ("Value must be string: " ^
1025
                               (String.concatWith " -> " kk))
1026 100:dd1207a4dfc9 Chris
end
1027
1028
structure Provider :> sig
1029
    val load_providers : Json.json -> provider list
1030
    val load_more_providers : provider list -> Json.json -> provider list
1031
    val remote_url : context -> vcs -> source -> libname -> string
1032
end = struct
1033
1034
    val known_providers : provider list =
1035
        [ {
1036
            service = "bitbucket",
1037
            supports = [HG, GIT],
1038
            remote_spec = {
1039
                anon = SOME "https://bitbucket.org/{owner}/{repository}",
1040
                auth = SOME "ssh://{vcs}@bitbucket.org/{owner}/{repository}"
1041
            }
1042
          },
1043
          {
1044
            service = "github",
1045
            supports = [GIT],
1046
            remote_spec = {
1047
                anon = SOME "https://github.com/{owner}/{repository}",
1048
                auth = SOME "ssh://{vcs}@github.com/{owner}/{repository}"
1049
            }
1050 260:9b00c7784153 Chris
          },
1051
          { service = "sourcehut",
1052
            supports = [HG, GIT],
1053
            remote_spec = {
1054
                anon = SOME "https://{vcs}.sr.ht/%7E{owner}/{repository}",
1055
                auth = SOME "ssh://{vcs}@{vcs}.sr.ht/%7E{owner}/{repository}"
1056
            }
1057 100:dd1207a4dfc9 Chris
          }
1058
        ]
1059
1060
    fun vcs_name vcs =
1061 105:fa09c8e155ec Chris
        case vcs of HG => "hg"
1062
                  | GIT => "git"
1063
                  | SVN => "svn"
1064 100:dd1207a4dfc9 Chris
1065
    fun vcs_from_name name =
1066 105:fa09c8e155ec Chris
        case name of "hg" => HG
1067
                   | "git" => GIT
1068
                   | "svn" => SVN
1069 100:dd1207a4dfc9 Chris
                   | other => raise Fail ("Unknown vcs name \"" ^ name ^ "\"")
1070
1071
    fun load_more_providers previously_loaded json =
1072
        let open JsonBits
1073
            fun load pjson pname : provider =
1074
                {
1075
                  service = pname,
1076
                  supports =
1077
                  case lookup_mandatory pjson ["vcs"] of
1078
                      Json.ARRAY vv =>
1079
                      map (fn (Json.STRING v) => vcs_from_name v
1080
                          | _ => raise Fail "Strings expected in vcs array")
1081
                          vv
1082
                    | _ => raise Fail "Array expected for vcs",
1083
                  remote_spec = {
1084
                      anon = lookup_optional_string pjson ["anonymous"],
1085
                      auth = lookup_optional_string pjson ["authenticated"]
1086
                  }
1087
                }
1088
            val loaded =
1089
                case lookup_optional json ["services"] of
1090
                    NONE => []
1091
                  | SOME (Json.OBJECT pl) => map (fn (k, v) => load v k) pl
1092
                  | _ => raise Fail "Object expected for services in config"
1093
            val newly_loaded =
1094
                List.filter (fn p => not (List.exists (fn pp => #service p =
1095
                                                                #service pp)
1096
                                                      previously_loaded))
1097
                            loaded
1098
        in
1099
            previously_loaded @ newly_loaded
1100
        end
1101
1102
    fun load_providers json =
1103
        load_more_providers known_providers json
1104
1105
    fun expand_spec spec { vcs, service, owner, repo } login =
1106
        (* ugly *)
1107
        let fun replace str =
1108
                case str of
1109
                    "vcs" => vcs_name vcs
1110
                  | "service" => service
1111
                  | "owner" =>
1112
                    (case owner of
1113
                         SOME ostr => ostr
1114
                       | NONE => raise Fail ("Owner not specified for service " ^
1115
                                             service))
1116
                  | "repository" => repo
1117
                  | "account" =>
1118
                    (case login of
1119
                         SOME acc => acc
1120
                       | NONE => raise Fail ("Account not given for service " ^
1121
                                             service))
1122
                  | other => raise Fail ("Unknown variable \"" ^ other ^
1123
                                         "\" in spec for service " ^ service)
1124
            fun expand' acc sstr =
1125
                case Substring.splitl (fn c => c <> #"{") sstr of
1126
                    (pfx, sfx) =>
1127
                    if Substring.isEmpty sfx
1128
                    then rev (pfx :: acc)
1129
                    else
1130
                        case Substring.splitl (fn c => c <> #"}") sfx of
1131
                            (tok, remainder) =>
1132
                            if Substring.isEmpty remainder
1133
                            then rev (tok :: pfx :: acc)
1134
                            else let val replacement =
1135
                                         replace
1136
                                             (* tok begins with "{": *)
1137
                                             (Substring.string
1138
                                                  (Substring.triml 1 tok))
1139
                                 in
1140
                                     expand' (Substring.full replacement ::
1141
                                              pfx :: acc)
1142
                                             (* remainder begins with "}": *)
1143
                                             (Substring.triml 1 remainder)
1144
                                 end
1145
        in
1146
            Substring.concat (expand' [] (Substring.full spec))
1147
        end
1148
1149
    fun provider_url req login providers =
1150
        case providers of
1151
            [] => raise Fail ("Unknown service \"" ^ (#service req) ^
1152
                              "\" for vcs \"" ^ (vcs_name (#vcs req)) ^ "\"")
1153
          | ({ service, supports, remote_spec : remote_spec } :: rest) =>
1154
            if service <> (#service req) orelse
1155
               not (List.exists (fn v => v = (#vcs req)) supports)
1156
            then provider_url req login rest
1157
            else
1158
                case (login, #auth remote_spec, #anon remote_spec) of
1159
                    (SOME _, SOME auth, _) => expand_spec auth req login
1160
                  | (SOME _, _, SOME anon) => expand_spec anon req NONE
1161
                  | (NONE,   _, SOME anon) => expand_spec anon req NONE
1162
                  | _ => raise Fail ("No suitable anonymous or authenticated " ^
1163
                                     "URL spec provided for service \"" ^
1164
                                     service ^ "\"")
1165
1166
    fun login_for ({ accounts, ... } : context) service =
1167
        case List.find (fn a => service = #service a) accounts of
1168
            SOME { login, ... } => SOME login
1169
          | NONE => NONE
1170 105:fa09c8e155ec Chris
1171
    fun reponame_for path =
1172
        case String.tokens (fn c => c = #"/") path of
1173
            [] => raise Fail "Non-empty library path required"
1174
          | toks => hd (rev toks)
1175
1176 100:dd1207a4dfc9 Chris
    fun remote_url (context : context) vcs source libname =
1177
        case source of
1178
            URL_SOURCE u => u
1179
          | SERVICE_SOURCE { service, owner, repo } =>
1180
            provider_url { vcs = vcs,
1181
                           service = service,
1182
                           owner = owner,
1183
                           repo = case repo of
1184
                                      SOME r => r
1185 105:fa09c8e155ec Chris
                                    | NONE => reponame_for libname }
1186 100:dd1207a4dfc9 Chris
                         (login_for context service)
1187
                         (#providers context)
1188
end
1189
1190
structure HgControl :> VCS_CONTROL = struct
1191 105:fa09c8e155ec Chris
1192
    (* Pulls always use an explicit URL, never just the default
1193
       remote, in order to ensure we update properly if the location
1194
       given in the project file changes. *)
1195
1196 100:dd1207a4dfc9 Chris
    type vcsstate = { id: string, modified: bool,
1197
                      branch: string, tags: string list }
1198
1199 105:fa09c8e155ec Chris
    val hg_program = "hg"
1200
1201
    val hg_args = [ "--config", "ui.interactive=true",
1202
                    "--config", "ui.merge=:merge" ]
1203 100:dd1207a4dfc9 Chris
1204
    fun hg_command context libname args =
1205 105:fa09c8e155ec Chris
        FileBits.command context libname (hg_program :: hg_args @ args)
1206 100:dd1207a4dfc9 Chris
1207
    fun hg_command_output context libname args =
1208 105:fa09c8e155ec Chris
        FileBits.command_output context libname (hg_program :: hg_args @ args)
1209
1210
    fun is_working context =
1211
        case hg_command_output context "" ["--version"] of
1212
            OK "" => OK false
1213
          | OK _ => OK true
1214
          | ERROR e => ERROR e
1215
1216 100:dd1207a4dfc9 Chris
    fun exists context libname =
1217
        OK (OS.FileSys.isDir (FileBits.subpath context libname ".hg"))
1218
        handle _ => OK false
1219
1220
    fun remote_for context (libname, source) =
1221
        Provider.remote_url context HG source libname
1222
1223
    fun current_state context libname : vcsstate result =
1224
        let fun is_branch text = text <> "" andalso #"(" = hd (explode text)
1225
            and extract_branch b =
1226
                if is_branch b     (* need to remove enclosing parens *)
1227
                then (implode o rev o tl o rev o tl o explode) b
1228
                else "default"
1229
            and is_modified id = id <> "" andalso #"+" = hd (rev (explode id))
1230
            and extract_id id =
1231
                if is_modified id  (* need to remove trailing "+" *)
1232
                then (implode o rev o tl o rev o explode) id
1233
                else id
1234
            and split_tags tags = String.tokens (fn c => c = #"/") tags
1235
            and state_for (id, branch, tags) =
1236
                OK { id = extract_id id,
1237
                     modified = is_modified id,
1238
                     branch = extract_branch branch,
1239
                     tags = split_tags tags }
1240
        in
1241
            case hg_command_output context libname ["id"] of
1242
                ERROR e => ERROR e
1243
              | OK out =>
1244
                case String.tokens (fn x => x = #" ") out of
1245
                    [id, branch, tags] => state_for (id, branch, tags)
1246
                  | [id, other] => if is_branch other
1247
                                   then state_for (id, other, "")
1248
                                   else state_for (id, "", other)
1249
                  | [id] => state_for (id, "", "")
1250
                  | _ => ERROR ("Unexpected output from hg id: " ^ out)
1251
        end
1252
1253
    fun branch_name branch = case branch of
1254
                                 DEFAULT_BRANCH => "default"
1255
                               | BRANCH "" => "default"
1256
                               | BRANCH b => b
1257
1258
    fun id_of context libname =
1259
        case current_state context libname of
1260
            ERROR e => ERROR e
1261
          | OK { id, ... } => OK id
1262
1263
    fun is_at context (libname, id_or_tag) =
1264
        case current_state context libname of
1265
            ERROR e => ERROR e
1266
          | OK { id, tags, ... } =>
1267
            OK (String.isPrefix id_or_tag id orelse
1268
                String.isPrefix id id_or_tag orelse
1269
                List.exists (fn t => t = id_or_tag) tags)
1270
1271
    fun is_on_branch context (libname, b) =
1272
        case current_state context libname of
1273
            ERROR e => ERROR e
1274
          | OK { branch, ... } => OK (branch = branch_name b)
1275
1276
    fun is_newest_locally context (libname, branch) =
1277
        case hg_command_output context libname
1278
                               ["log", "-l1",
1279
                                "-b", branch_name branch,
1280
                                "--template", "{node}"] of
1281 105:fa09c8e155ec Chris
            ERROR e => OK false (* desired branch does not exist *)
1282 100:dd1207a4dfc9 Chris
          | OK newest_in_repo => is_at context (libname, newest_in_repo)
1283
1284 105:fa09c8e155ec Chris
    fun pull context (libname, source) =
1285
        let val url = remote_for context (libname, source)
1286
        in
1287
            hg_command context libname
1288
                       (if FileBits.verbose ()
1289
                        then ["pull", url]
1290
                        else ["pull", "-q", url])
1291
        end
1292 100:dd1207a4dfc9 Chris
1293 105:fa09c8e155ec Chris
    fun is_newest context (libname, source, branch) =
1294 100:dd1207a4dfc9 Chris
        case is_newest_locally context (libname, branch) of
1295
            ERROR e => ERROR e
1296
          | OK false => OK false
1297
          | OK true =>
1298 105:fa09c8e155ec Chris
            case pull context (libname, source) of
1299 100:dd1207a4dfc9 Chris
                ERROR e => ERROR e
1300
              | _ => is_newest_locally context (libname, branch)
1301
1302
    fun is_modified_locally context libname =
1303
        case current_state context libname of
1304
            ERROR e => ERROR e
1305
          | OK { modified, ... } => OK modified
1306
1307
    fun checkout context (libname, source, branch) =
1308
        let val url = remote_for context (libname, source)
1309
        in
1310 105:fa09c8e155ec Chris
            (* make the lib dir rather than just the ext dir, since
1311
               the lib dir might be nested and hg will happily check
1312
               out into an existing empty dir anyway *)
1313
            case FileBits.mkpath (FileBits.libpath context libname) of
1314 100:dd1207a4dfc9 Chris
                ERROR e => ERROR e
1315
              | _ => hg_command context ""
1316
                                ["clone", "-u", branch_name branch,
1317
                                 url, libname]
1318
        end
1319
1320 105:fa09c8e155ec Chris
    fun update context (libname, source, branch) =
1321
        let val pull_result = pull context (libname, source)
1322 100:dd1207a4dfc9 Chris
        in
1323
            case hg_command context libname ["update", branch_name branch] of
1324
                ERROR e => ERROR e
1325
              | _ =>
1326
                case pull_result of
1327
                    ERROR e => ERROR e
1328 105:fa09c8e155ec Chris
                  | _ => OK ()
1329 100:dd1207a4dfc9 Chris
        end
1330
1331 105:fa09c8e155ec Chris
    fun update_to context (libname, _, "") =
1332 100:dd1207a4dfc9 Chris
        ERROR "Non-empty id (tag or revision id) required for update_to"
1333 105:fa09c8e155ec Chris
      | update_to context (libname, source, id) =
1334
        let val pull_result = pull context (libname, source)
1335 100:dd1207a4dfc9 Chris
        in
1336
            case hg_command context libname ["update", "-r", id] of
1337 105:fa09c8e155ec Chris
                OK _ => OK ()
1338 100:dd1207a4dfc9 Chris
              | ERROR e =>
1339
                case pull_result of
1340
                    ERROR e' => ERROR e' (* this was the ur-error *)
1341
                  | _ => ERROR e
1342
        end
1343 105:fa09c8e155ec Chris
1344
    fun copy_url_for context libname =
1345
        OK (FileBits.file_url (FileBits.libpath context libname))
1346
1347 100:dd1207a4dfc9 Chris
end
1348
1349
structure GitControl :> VCS_CONTROL = struct
1350
1351
    (* With Git repos we always operate in detached HEAD state. Even
1352 105:fa09c8e155ec Chris
       the master branch is checked out using a remote reference
1353
       (repoint/master). The remote we use is always named repoint, and we
1354
       update it to the expected URL each time we fetch, in order to
1355
       ensure we update properly if the location given in the project
1356
       file changes. The origin remote is unused. *)
1357 100:dd1207a4dfc9 Chris
1358 105:fa09c8e155ec Chris
    val git_program = "git"
1359
1360 100:dd1207a4dfc9 Chris
    fun git_command context libname args =
1361 105:fa09c8e155ec Chris
        FileBits.command context libname (git_program :: args)
1362 100:dd1207a4dfc9 Chris
1363
    fun git_command_output context libname args =
1364 105:fa09c8e155ec Chris
        FileBits.command_output context libname (git_program :: args)
1365
1366
    fun is_working context =
1367
        case git_command_output context "" ["--version"] of
1368
            OK "" => OK false
1369
          | OK _ => OK true
1370
          | ERROR e => ERROR e
1371 100:dd1207a4dfc9 Chris
1372
    fun exists context libname =
1373
        OK (OS.FileSys.isDir (FileBits.subpath context libname ".git"))
1374
        handle _ => OK false
1375
1376
    fun remote_for context (libname, source) =
1377
        Provider.remote_url context GIT source libname
1378
1379
    fun branch_name branch = case branch of
1380
                                 DEFAULT_BRANCH => "master"
1381
                               | BRANCH "" => "master"
1382
                               | BRANCH b => b
1383
1384 105:fa09c8e155ec Chris
    val our_remote = "repoint"
1385
1386
    fun remote_branch_name branch = our_remote ^ "/" ^ branch_name branch
1387 100:dd1207a4dfc9 Chris
1388
    fun checkout context (libname, source, branch) =
1389
        let val url = remote_for context (libname, source)
1390
        in
1391 105:fa09c8e155ec Chris
            (* make the lib dir rather than just the ext dir, since
1392
               the lib dir might be nested and git will happily check
1393
               out into an existing empty dir anyway *)
1394
            case FileBits.mkpath (FileBits.libpath context libname) of
1395 100:dd1207a4dfc9 Chris
                OK () => git_command context ""
1396 105:fa09c8e155ec Chris
                                     ["clone", "--origin", our_remote,
1397
                                      "--branch", branch_name branch,
1398 100:dd1207a4dfc9 Chris
                                      url, libname]
1399
              | ERROR e => ERROR e
1400
        end
1401
1402 105:fa09c8e155ec Chris
    fun add_our_remote context (libname, source) =
1403
        (* When we do the checkout ourselves (above), we add the
1404
           remote at the same time. But if the repo was cloned by
1405
           someone else, we'll need to do it after the fact. Git
1406
           doesn't seem to have a means to add a remote or change its
1407
           url if it already exists; seems we have to do this: *)
1408
        let val url = remote_for context (libname, source)
1409
        in
1410
            case git_command context libname
1411
                             ["remote", "set-url", our_remote, url] of
1412
                OK () => OK ()
1413
              | ERROR e => git_command context libname
1414
                                       ["remote", "add", "-f", our_remote, url]
1415
        end
1416
1417 100:dd1207a4dfc9 Chris
    (* NB git rev-parse HEAD shows revision id of current checkout;
1418
       git rev-list -1 <tag> shows revision id of revision with that tag *)
1419
1420
    fun id_of context libname =
1421
        git_command_output context libname ["rev-parse", "HEAD"]
1422
1423
    fun is_at context (libname, id_or_tag) =
1424
        case id_of context libname of
1425 105:fa09c8e155ec Chris
            ERROR e => OK false (* HEAD nonexistent, expected in empty repo *)
1426 100:dd1207a4dfc9 Chris
          | OK id =>
1427
            if String.isPrefix id_or_tag id orelse
1428
               String.isPrefix id id_or_tag
1429
            then OK true
1430 105:fa09c8e155ec Chris
            else is_at_tag context (libname, id, id_or_tag)
1431
1432
    and is_at_tag context (libname, id, tag) =
1433
        (* For annotated tags (with message) show-ref returns the tag
1434
           object ref rather than that of the revision being tagged;
1435
           we need the subsequent rev-list to chase that up. In fact
1436
           the rev-list on its own is enough to get us the id direct
1437
           from the tag name, but it fails with an error if the tag
1438
           doesn't exist, whereas we want to handle that quietly in
1439
           case the tag simply hasn't been pulled yet *)
1440
        case git_command_output context libname
1441
                                ["show-ref", "refs/tags/" ^ tag, "--"] of
1442
            OK "" => OK false (* Not a tag *)
1443
          | ERROR _ => OK false
1444
          | OK s =>
1445
            let val tag_ref = hd (String.tokens (fn c => c = #" ") s)
1446
            in
1447 100:dd1207a4dfc9 Chris
                case git_command_output context libname
1448 105:fa09c8e155ec Chris
                                        ["rev-list", "-1", tag_ref] of
1449
                    OK tagged => OK (id = tagged)
1450 100:dd1207a4dfc9 Chris
                  | ERROR _ => OK false
1451 105:fa09c8e155ec Chris
            end
1452
1453 100:dd1207a4dfc9 Chris
    fun branch_tip context (libname, branch) =
1454 105:fa09c8e155ec Chris
        (* We don't have access to the source info or the network
1455
           here, as this is used by status (e.g. via is_on_branch) as
1456
           well as review. It's possible the remote branch won't exist,
1457
           e.g. if the repo was checked out by something other than
1458
           Repoint, and if that's the case, we can't add it here; we'll
1459
           just have to fail, since checking against local branches
1460
           instead could produce the wrong result. *)
1461 100:dd1207a4dfc9 Chris
        git_command_output context libname
1462
                           ["rev-list", "-1",
1463 105:fa09c8e155ec Chris
                            remote_branch_name branch, "--"]
1464 100:dd1207a4dfc9 Chris
1465
    fun is_newest_locally context (libname, branch) =
1466
        case branch_tip context (libname, branch) of
1467 105:fa09c8e155ec Chris
            ERROR e => OK false
1468 100:dd1207a4dfc9 Chris
          | OK rev => is_at context (libname, rev)
1469
1470
    fun is_on_branch context (libname, branch) =
1471
        case branch_tip context (libname, branch) of
1472 105:fa09c8e155ec Chris
            ERROR e => OK false
1473 100:dd1207a4dfc9 Chris
          | OK rev =>
1474
            case is_at context (libname, rev) of
1475
                ERROR e => ERROR e
1476
              | OK true => OK true
1477
              | OK false =>
1478
                case git_command context libname
1479
                                 ["merge-base", "--is-ancestor",
1480
                                  "HEAD", remote_branch_name branch] of
1481
                    ERROR e => OK false  (* cmd returns non-zero for no *)
1482
                  | _ => OK true
1483
1484 105:fa09c8e155ec Chris
    fun fetch context (libname, source) =
1485
        case add_our_remote context (libname, source) of
1486 100:dd1207a4dfc9 Chris
            ERROR e => ERROR e
1487 105:fa09c8e155ec Chris
          | _ => git_command context libname ["fetch", our_remote]
1488
1489
    fun is_newest context (libname, source, branch) =
1490
        case add_our_remote context (libname, source) of
1491
            ERROR e => ERROR e
1492
          | OK () =>
1493
            case is_newest_locally context (libname, branch) of
1494 100:dd1207a4dfc9 Chris
                ERROR e => ERROR e
1495 105:fa09c8e155ec Chris
              | OK false => OK false
1496
              | OK true =>
1497
                case fetch context (libname, source) of
1498
                    ERROR e => ERROR e
1499
                  | _ => is_newest_locally context (libname, branch)
1500 100:dd1207a4dfc9 Chris
1501
    fun is_modified_locally context libname =
1502
        case git_command_output context libname ["status", "--porcelain"] of
1503
            ERROR e => ERROR e
1504
          | OK "" => OK false
1505
          | OK _ => OK true
1506
1507
    (* This function updates to the latest revision on a branch rather
1508
       than to a specific id or tag. We can't just checkout the given
1509
       branch, as that will succeed even if the branch isn't up to
1510
       date. We could checkout the branch and then fetch and merge,
1511
       but it's perhaps cleaner not to maintain a local branch at all,
1512
       but instead checkout the remote branch as a detached head. *)
1513
1514 105:fa09c8e155ec Chris
    fun update context (libname, source, branch) =
1515
        case fetch context (libname, source) of
1516 100:dd1207a4dfc9 Chris
            ERROR e => ERROR e
1517
          | _ =>
1518
            case git_command context libname ["checkout", "--detach",
1519
                                              remote_branch_name branch] of
1520
                ERROR e => ERROR e
1521 105:fa09c8e155ec Chris
              | _ => OK ()
1522 100:dd1207a4dfc9 Chris
1523
    (* This function is dealing with a specific id or tag, so if we
1524
       can successfully check it out (detached) then that's all we
1525
       need to do, regardless of whether fetch succeeded or not. We do
1526
       attempt the fetch first, though, purely in order to avoid ugly
1527
       error messages in the common case where we're being asked to
1528
       update to a new pin (from the lock file) that hasn't been
1529
       fetched yet. *)
1530
1531 105:fa09c8e155ec Chris
    fun update_to context (libname, _, "") =
1532 100:dd1207a4dfc9 Chris
        ERROR "Non-empty id (tag or revision id) required for update_to"
1533 105:fa09c8e155ec Chris
      | update_to context (libname, source, id) =
1534
        let val fetch_result = fetch context (libname, source)
1535 100:dd1207a4dfc9 Chris
        in
1536
            case git_command context libname ["checkout", "--detach", id] of
1537 105:fa09c8e155ec Chris
                OK _ => OK ()
1538 100:dd1207a4dfc9 Chris
              | ERROR e =>
1539
                case fetch_result of
1540
                    ERROR e' => ERROR e' (* this was the ur-error *)
1541
                  | _ => ERROR e
1542
        end
1543 105:fa09c8e155ec Chris
1544
    fun copy_url_for context libname =
1545
        OK (FileBits.file_url (FileBits.libpath context libname))
1546 100:dd1207a4dfc9 Chris
1547
end
1548
1549 105:fa09c8e155ec Chris
(* SubXml - A parser for a subset of XML
1550
   https://bitbucket.org/cannam/sml-subxml
1551
   Copyright 2018 Chris Cannam. BSD licence.
1552
*)
1553
1554
signature SUBXML = sig
1555
1556
    datatype node = ELEMENT of { name : string, children : node list }
1557
                  | ATTRIBUTE of { name : string, value : string }
1558
                  | TEXT of string
1559
                  | CDATA of string
1560
                  | COMMENT of string
1561
1562
    datatype document = DOCUMENT of { name : string, children : node list }
1563
1564
    datatype 'a result = OK of 'a
1565
                       | ERROR of string
1566
1567
    val parse : string -> document result
1568
    val serialise : document -> string
1569
1570
end
1571
1572
structure SubXml :> SUBXML = struct
1573
1574
    datatype node = ELEMENT of { name : string, children : node list }
1575
                  | ATTRIBUTE of { name : string, value : string }
1576
                  | TEXT of string
1577
                  | CDATA of string
1578
                  | COMMENT of string
1579
1580
    datatype document = DOCUMENT of { name : string, children : node list }
1581
1582
    datatype 'a result = OK of 'a
1583
                       | ERROR of string
1584
1585
    structure T = struct
1586
        datatype token = ANGLE_L
1587
                       | ANGLE_R
1588
                       | ANGLE_SLASH_L
1589
                       | SLASH_ANGLE_R
1590
                       | EQUAL
1591
                       | NAME of string
1592
                       | TEXT of string
1593
                       | CDATA of string
1594
                       | COMMENT of string
1595
1596
        fun name t =
1597
            case t of ANGLE_L => "<"
1598
                    | ANGLE_R => ">"
1599
                    | ANGLE_SLASH_L => "</"
1600
                    | SLASH_ANGLE_R => "/>"
1601
                    | EQUAL => "="
1602
                    | NAME s => "name \"" ^ s ^ "\""
1603
                    | TEXT s => "text"
1604
                    | CDATA _ => "CDATA section"
1605
                    | COMMENT _ => "comment"
1606
    end
1607
1608
    structure Lex :> sig
1609
                  val lex : string -> T.token list result
1610
              end = struct
1611
1612
        fun error pos text =
1613
            ERROR (text ^ " at character position " ^ Int.toString (pos-1))
1614
        fun tokenError pos token =
1615
            error pos ("Unexpected token '" ^ Char.toString token ^ "'")
1616
1617
        val nameEnd = explode " \t\n\r\"'</>!=?"
1618
1619
        fun quoted quote pos acc cc =
1620
            let fun quoted' pos text [] =
1621
                    error pos "Document ends during quoted string"
1622
                  | quoted' pos text (x::xs) =
1623
                    if x = quote
1624
                    then OK (rev text, xs, pos+1)
1625
                    else quoted' (pos+1) (x::text) xs
1626
            in
1627
                case quoted' pos [] cc of
1628
                    ERROR e => ERROR e
1629
                  | OK (text, rest, newpos) =>
1630
                    inside newpos (T.TEXT (implode text) :: acc) rest
1631
            end
1632
1633
        and name first pos acc cc =
1634
            let fun name' pos text [] =
1635
                    error pos "Document ends during name"
1636
                  | name' pos text (x::xs) =
1637
                    if List.find (fn c => c = x) nameEnd <> NONE
1638
                    then OK (rev text, (x::xs), pos)
1639
                    else name' (pos+1) (x::text) xs
1640
            in
1641
                case name' (pos-1) [] (first::cc) of
1642
                    ERROR e => ERROR e
1643
                  | OK ([], [], pos) => error pos "Document ends before name"
1644
                  | OK ([], (x::xs), pos) => tokenError pos x
1645
                  | OK (text, rest, pos) =>
1646
                    inside pos (T.NAME (implode text) :: acc) rest
1647
            end
1648
1649
        and comment pos acc cc =
1650
            let fun comment' pos text cc =
1651
                    case cc of
1652
                        #"-" :: #"-" :: #">" :: xs => OK (rev text, xs, pos+3)
1653
                      | x :: xs => comment' (pos+1) (x::text) xs
1654
                      | [] => error pos "Document ends during comment"
1655
            in
1656
                case comment' pos [] cc of
1657
                    ERROR e => ERROR e
1658
                  | OK (text, rest, pos) =>
1659
                    outside pos (T.COMMENT (implode text) :: acc) rest
1660
            end
1661
1662
        and instruction pos acc cc =
1663
            case cc of
1664
                #"?" :: #">" :: xs => outside (pos+2) acc xs
1665
              | #">" :: _ => tokenError pos #">"
1666
              | x :: xs => instruction (pos+1) acc xs
1667
              | [] => error pos "Document ends during processing instruction"
1668
1669
        and cdata pos acc cc =
1670
            let fun cdata' pos text cc =
1671
                    case cc of
1672
                        #"]" :: #"]" :: #">" :: xs => OK (rev text, xs, pos+3)
1673
                      | x :: xs => cdata' (pos+1) (x::text) xs
1674
                      | [] => error pos "Document ends during CDATA section"
1675
            in
1676
                case cdata' pos [] cc of
1677
                    ERROR e => ERROR e
1678
                  | OK (text, rest, pos) =>
1679
                    outside pos (T.CDATA (implode text) :: acc) rest
1680
            end
1681
1682
        and doctype pos acc cc =
1683
            case cc of
1684
                #">" :: xs => outside (pos+1) acc xs
1685
              | x :: xs => doctype (pos+1) acc xs
1686
              | [] => error pos "Document ends during DOCTYPE"
1687
1688
        and declaration pos acc cc =
1689
            case cc of
1690
                #"-" :: #"-" :: xs =>
1691
                comment (pos+2) acc xs
1692
              | #"[" :: #"C" :: #"D" :: #"A" :: #"T" :: #"A" :: #"[" :: xs =>
1693
                cdata (pos+7) acc xs
1694
              | #"D" :: #"O" :: #"C" :: #"T" :: #"Y" :: #"P" :: #"E" :: xs =>
1695
                doctype (pos+7) acc xs
1696
              | [] => error pos "Document ends during declaration"
1697
              | _ => error pos "Unsupported declaration type"
1698
1699
        and left pos acc cc =
1700
            case cc of
1701
                #"/" :: xs => inside (pos+1) (T.ANGLE_SLASH_L :: acc) xs
1702
              | #"!" :: xs => declaration (pos+1) acc xs
1703
              | #"?" :: xs => instruction (pos+1) acc xs
1704
              | xs => inside pos (T.ANGLE_L :: acc) xs
1705
1706
        and slash pos acc cc =
1707
            case cc of
1708
                #">" :: xs => outside (pos+1) (T.SLASH_ANGLE_R :: acc) xs
1709
              | x :: _ => tokenError pos x
1710
              | [] => error pos "Document ends before element closed"
1711
1712
        and close pos acc xs = outside pos (T.ANGLE_R :: acc) xs
1713
1714
        and equal pos acc xs = inside pos (T.EQUAL :: acc) xs
1715
1716
        and outside pos acc [] = OK acc
1717
          | outside pos acc cc =
1718
            let fun textOf text = T.TEXT (implode (rev text))
1719
                fun outside' pos [] acc [] = OK acc
1720
                  | outside' pos text acc [] = OK (textOf text :: acc)
1721
                  | outside' pos text acc (x::xs) =
1722
                    case x of
1723
                        #"<" => if text = []
1724
                                then left (pos+1) acc xs
1725
                                else left (pos+1) (textOf text :: acc) xs
1726
                      | x => outside' (pos+1) (x::text) acc xs
1727
            in
1728
                outside' pos [] acc cc
1729
            end
1730
1731
        and inside pos acc [] = error pos "Document ends within tag"
1732
          | inside pos acc (#"<"::_) = tokenError pos #"<"
1733
          | inside pos acc (x::xs) =
1734
            (case x of
1735
                 #" " => inside | #"\t" => inside
1736
               | #"\n" => inside | #"\r" => inside
1737
               | #"\"" => quoted x | #"'" => quoted x
1738
               | #"/" => slash | #">" => close | #"=" => equal
1739
               | x => name x) (pos+1) acc xs
1740
1741
        fun lex str =
1742
            case outside 1 [] (explode str) of
1743
                ERROR e => ERROR e
1744
              | OK tokens => OK (rev tokens)
1745
    end
1746
1747
    structure Parse :> sig
1748
                  val parse : string -> document result
1749
              end = struct
1750
1751
        fun show [] = "end of input"
1752
          | show (tok :: _) = T.name tok
1753
1754
        fun error toks text = ERROR (text ^ " before " ^ show toks)
1755
1756
        fun attribute elt name toks =
1757
            case toks of
1758
                T.EQUAL :: T.TEXT value :: xs =>
1759
                namedElement {
1760
                    name = #name elt,
1761
                    children = ATTRIBUTE { name = name, value = value } ::
1762
                               #children elt
1763
                } xs
1764
              | T.EQUAL :: xs => error xs "Expected attribute value"
1765
              | toks => error toks "Expected attribute assignment"
1766
1767
        and content elt toks =
1768
            case toks of
1769
                T.ANGLE_SLASH_L :: T.NAME n :: T.ANGLE_R :: xs =>
1770
                if n = #name elt
1771
                then OK (elt, xs)
1772
                else ERROR ("Closing tag </" ^ n ^ "> " ^
1773
                            "does not match opening <" ^ #name elt ^ ">")
1774
              | T.TEXT text :: xs =>
1775
                content {
1776
                    name = #name elt,
1777
                    children = TEXT text :: #children elt
1778
                } xs
1779
              | T.CDATA text :: xs =>
1780
                content {
1781
                    name = #name elt,
1782
                    children = CDATA text :: #children elt
1783
                } xs
1784
              | T.COMMENT text :: xs =>
1785
                content {
1786
                    name = #name elt,
1787
                    children = COMMENT text :: #children elt
1788
                } xs
1789
              | T.ANGLE_L :: xs =>
1790
                (case element xs of
1791
                     ERROR e => ERROR e
1792
                   | OK (child, xs) =>
1793
                     content {
1794
                         name = #name elt,
1795
                         children = ELEMENT child :: #children elt
1796
                     } xs)
1797
              | tok :: xs =>
1798
                error xs ("Unexpected token " ^ T.name tok)
1799
              | [] =>
1800
                ERROR ("Document ends within element \"" ^ #name elt ^ "\"")
1801
1802
        and namedElement elt toks =
1803
            case toks of
1804
                T.SLASH_ANGLE_R :: xs => OK (elt, xs)
1805
              | T.NAME name :: xs => attribute elt name xs
1806
              | T.ANGLE_R :: xs => content elt xs
1807
              | x :: xs => error xs ("Unexpected token " ^ T.name x)
1808
              | [] => ERROR "Document ends within opening tag"
1809
1810
        and element toks =
1811
            case toks of
1812
                T.NAME name :: xs =>
1813
                (case namedElement { name = name, children = [] } xs of
1814
                     ERROR e => ERROR e
1815
                   | OK ({ name, children }, xs) =>
1816
                     OK ({ name = name, children = rev children }, xs))
1817
              | toks => error toks "Expected element name"
1818
1819
        and document [] = ERROR "Empty document"
1820
          | document (tok :: xs) =
1821
            case tok of
1822
                T.TEXT _ => document xs
1823
              | T.COMMENT _ => document xs
1824
              | T.ANGLE_L =>
1825
                (case element xs of
1826
                     ERROR e => ERROR e
1827
                   | OK (elt, []) => OK (DOCUMENT elt)
1828
                   | OK (elt, (T.TEXT _ :: xs)) => OK (DOCUMENT elt)
1829
                   | OK (elt, xs) => error xs "Extra data after document")
1830
              | _ => error xs ("Unexpected token " ^ T.name tok)
1831
1832
        fun parse str =
1833
            case Lex.lex str of
1834
                ERROR e => ERROR e
1835
              | OK tokens => document tokens
1836
    end
1837
1838
    structure Serialise :> sig
1839
                  val serialise : document -> string
1840
              end = struct
1841
1842
        fun attributes nodes =
1843
            String.concatWith
1844
                " "
1845
                (map node (List.filter
1846
                               (fn ATTRIBUTE _ => true | _ => false)
1847
                               nodes))
1848
1849
        and nonAttributes nodes =
1850
            String.concat
1851
                (map node (List.filter
1852
                               (fn ATTRIBUTE _ => false | _ => true)
1853
                               nodes))
1854
1855
        and node n =
1856
            case n of
1857
                TEXT string =>
1858
                string
1859
              | CDATA string =>
1860
                "<![CDATA[" ^ string ^ "]]>"
1861
              | COMMENT string =>
1862
                "<!-- " ^ string ^ "-->"
1863
              | ATTRIBUTE { name, value } =>
1864
                name ^ "=" ^ "\"" ^ value ^ "\"" (*!!!*)
1865
              | ELEMENT { name, children } =>
1866
                "<" ^ name ^
1867
                (case (attributes children) of
1868
                     "" => ""
1869
                   | s => " " ^ s) ^
1870
                (case (nonAttributes children) of
1871
                     "" => "/>"
1872
                   | s => ">" ^ s ^ "</" ^ name ^ ">")
1873
1874
        fun serialise (DOCUMENT { name, children }) =
1875
            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" ^
1876
            node (ELEMENT { name = name, children = children })
1877
    end
1878
1879
    val parse = Parse.parse
1880
    val serialise = Serialise.serialise
1881
1882
end
1883
1884
1885
structure SvnControl :> VCS_CONTROL = struct
1886
1887
    val svn_program = "svn"
1888
1889
    fun svn_command context libname args =
1890
        FileBits.command context libname (svn_program :: args)
1891
1892
    fun svn_command_output context libname args =
1893
        FileBits.command_output context libname (svn_program :: args)
1894
1895
    fun svn_command_lines context libname args =
1896
        case svn_command_output context libname args of
1897
            ERROR e => ERROR e
1898
          | OK s => OK (String.tokens (fn c => c = #"\n" orelse c = #"\r") s)
1899
1900
    fun split_line_pair line =
1901
        let fun strip_leading_ws str = case explode str of
1902
                                           #" "::rest => implode rest
1903
                                         | _ => str
1904
        in
1905
            case String.tokens (fn c => c = #":") line of
1906
                [] => ("", "")
1907
              | first::rest =>
1908
                (first, strip_leading_ws (String.concatWith ":" rest))
1909
        end
1910
1911
    fun is_working context =
1912
        case svn_command_output context "" ["--version"] of
1913
            OK "" => OK false
1914
          | OK _ => OK true
1915
          | ERROR e => ERROR e
1916
1917
    structure X = SubXml
1918
1919
    fun svn_info context libname route =
1920
        (* SVN 1.9 has info --show-item which is just what we need,
1921
           but at this point we still have 1.8 on the CI boxes so we
1922
           might as well aim to support it. For that we really have to
1923
           use the XML output format, since the default info output is
1924
           localised. This is the only thing our mini-XML parser is
1925
           used for though, so it would be good to trim it at some
1926
           point *)
1927
        let fun find elt [] = OK elt
1928
              | find { children, ... } (first :: rest) =
1929
                case List.find (fn (X.ELEMENT { name, ... }) => name = first
1930
                               | _ => false)
1931
                               children of
1932
                    NONE => ERROR ("No element \"" ^ first ^ "\" in SVN XML")
1933
                  | SOME (X.ELEMENT e) => find e rest
1934
                  | SOME _ => ERROR "Internal error"
1935
        in
1936
            case svn_command_output context libname ["info", "--xml"] of
1937
                ERROR e => ERROR e
1938
              | OK xml =>
1939
                case X.parse xml of
1940
                    X.ERROR e => ERROR e
1941
                  | X.OK (X.DOCUMENT doc) => find doc route
1942
        end
1943
1944
    fun exists context libname =
1945
        OK (OS.FileSys.isDir (FileBits.subpath context libname ".svn"))
1946
        handle _ => OK false
1947
1948
    fun remote_for context (libname, source) =
1949
        Provider.remote_url context SVN source libname
1950
1951
    (* Remote the checkout came from, not necessarily the one we want *)
1952
    fun actual_remote_for context libname =
1953
        case svn_info context libname ["entry", "url"] of
1954
            ERROR e => ERROR e
1955
          | OK { children, ... } =>
1956
            case List.find (fn (X.TEXT _) => true | _ => false) children of
1957
                NONE => ERROR "No content for URL in SVN info XML"
1958
              | SOME (X.TEXT url) => OK url
1959
              | SOME _ => ERROR "Internal error"
1960
1961
    fun id_of context libname =
1962
        case svn_info context libname ["entry"] of
1963
            ERROR e => ERROR e
1964
          | OK { children, ... } =>
1965
            case List.find
1966
                     (fn (X.ATTRIBUTE { name = "revision", ... }) => true
1967
                     | _ => false)
1968
                     children of
1969
                NONE => ERROR "No revision for entry in SVN info XML"
1970
              | SOME (X.ATTRIBUTE { value, ... }) => OK value
1971
              | SOME _ => ERROR "Internal error"
1972
1973
    fun is_at context (libname, id_or_tag) =
1974
        case id_of context libname of
1975
            ERROR e => ERROR e
1976
          | OK id => OK (id = id_or_tag)
1977
1978
    fun is_on_branch context (libname, b) =
1979
        OK (b = DEFAULT_BRANCH)
1980
1981
    fun check_remote context (libname, source) =
1982
      case (remote_for context (libname, source),
1983
            actual_remote_for context libname) of
1984
          (_, ERROR e) => ERROR e
1985
        | (url, OK actual) =>
1986
          if actual = url
1987
          then OK ()
1988
          else svn_command context libname ["relocate", url]
1989
1990
    fun is_newest context (libname, source, branch) =
1991
        case check_remote context (libname, source) of
1992
            ERROR e => ERROR e
1993
          | OK () =>
1994
            case svn_command_lines context libname
1995
                                   ["status", "--show-updates"] of
1996
                ERROR e => ERROR e
1997
              | OK lines =>
1998
                case rev lines of
1999
                    [] => ERROR "No result returned for server status"
2000
                  | last_line::_ =>
2001
                    case rev (String.tokens (fn c => c = #" ") last_line) of
2002
                        [] => ERROR "No revision field found in server status"
2003
                      | server_id::_ => is_at context (libname, server_id)
2004
2005
    fun is_newest_locally context (libname, branch) =
2006
        OK true (* no local history *)
2007
2008
    fun is_modified_locally context libname =
2009
        case svn_command_output context libname ["status"] of
2010
            ERROR e => ERROR e
2011
          | OK "" => OK false
2012
          | OK _ => OK true
2013
2014
    fun checkout context (libname, source, branch) =
2015
        let val url = remote_for context (libname, source)
2016
            val path = FileBits.libpath context libname
2017
        in
2018
            if FileBits.nonempty_dir_exists path
2019
            then (* Surprisingly, SVN itself has no problem with
2020
                    this. But for consistency with other VCSes we
2021
                    don't allow it *)
2022
                ERROR ("Refusing checkout to nonempty dir \"" ^ path ^ "\"")
2023
            else
2024
                (* make the lib dir rather than just the ext dir, since
2025
                   the lib dir might be nested and svn will happily check
2026
                   out into an existing empty dir anyway *)
2027
                case FileBits.mkpath (FileBits.libpath context libname) of
2028
                    ERROR e => ERROR e
2029
                  | _ => svn_command context "" ["checkout", url, libname]
2030
        end
2031
2032
    fun update context (libname, source, branch) =
2033
        case check_remote context (libname, source) of
2034
            ERROR e => ERROR e
2035
          | OK () =>
2036
            case svn_command context libname
2037
                             ["update", "--accept", "postpone"] of
2038
                ERROR e => ERROR e
2039
              | _ => OK ()
2040
2041
    fun update_to context (libname, _, "") =
2042
        ERROR "Non-empty id (tag or revision id) required for update_to"
2043
      | update_to context (libname, source, id) =
2044
        case check_remote context (libname, source) of
2045
            ERROR e => ERROR e
2046
          | OK () =>
2047
            case svn_command context libname
2048
                             ["update", "-r", id, "--accept", "postpone"] of
2049
                ERROR e => ERROR e
2050
              | OK _ => OK ()
2051
2052
    fun copy_url_for context libname =
2053
        actual_remote_for context libname
2054
2055
end
2056
2057 100:dd1207a4dfc9 Chris
structure AnyLibControl :> LIB_CONTROL = struct
2058
2059
    structure H = LibControlFn(HgControl)
2060
    structure G = LibControlFn(GitControl)
2061 105:fa09c8e155ec Chris
    structure S = LibControlFn(SvnControl)
2062 100:dd1207a4dfc9 Chris
2063
    fun review context (spec as { vcs, ... } : libspec) =
2064 105:fa09c8e155ec Chris
        (fn HG => H.review | GIT => G.review | SVN => S.review) vcs context spec
2065 100:dd1207a4dfc9 Chris
2066
    fun status context (spec as { vcs, ... } : libspec) =
2067 105:fa09c8e155ec Chris
        (fn HG => H.status | GIT => G.status | SVN => S.status) vcs context spec
2068 100:dd1207a4dfc9 Chris
2069
    fun update context (spec as { vcs, ... } : libspec) =
2070 105:fa09c8e155ec Chris
        (fn HG => H.update | GIT => G.update | SVN => S.update) vcs context spec
2071 100:dd1207a4dfc9 Chris
2072
    fun id_of context (spec as { vcs, ... } : libspec) =
2073 105:fa09c8e155ec Chris
        (fn HG => H.id_of | GIT => G.id_of | SVN => S.id_of) vcs context spec
2074
2075
    fun is_working context vcs =
2076
        (fn HG => H.is_working | GIT => G.is_working | SVN => S.is_working)
2077
            vcs context vcs
2078
2079
end
2080
2081
2082
type exclusions = string list
2083
2084
structure Archive :> sig
2085
2086
    val archive : string * exclusions -> project -> OS.Process.status
2087
2088
end = struct
2089
2090
    (* The idea of "archive" is to replace hg/git archive, which won't
2091
       include files, like the Repoint-introduced external libraries,
2092
       that are not under version control with the main repo.
2093
2094
       The process goes like this:
2095
2096
       - Make sure we have a target filename from the user, and take
2097
         its basename as our archive directory name
2098
2099
       - Make an "archive root" subdir of the project repo, named
2100
         typically .repoint-archive
2101
2102
       - Identify the VCS used for the project repo. Note that any
2103
         explicit references to VCS type in this structure are to
2104
         the VCS used for the project (something Repoint doesn't
2105
         otherwise care about), not for an individual library
2106
2107
       - Synthesise a Repoint project with the archive root as its
2108
         root path, "." as its extdir, with one library whose
2109
         name is the user-supplied basename and whose explicit
2110
         source URL is the original project root; update that
2111
         project -- thus cloning the original project to a subdir
2112
         of the archive root
2113
2114
       - Synthesise a Repoint project identical to the original one for
2115
         this project, but with the newly-cloned copy as its root
2116
         path; update that project -- thus checking out clean copies
2117
         of the external library dirs
2118
2119
       - Call out to an archive program to archive up the new copy,
2120
         running e.g.
2121
         tar cvzf project-release.tar.gz \
2122
             --exclude=.hg --exclude=.git project-release
2123
         in the archive root dir
2124
2125
       - (We also omit the repoint-project.json file and any trace of
2126
         Repoint. It can't properly be run in a directory where the
2127
         external project folders already exist but their repo history
2128
         does not. End users shouldn't get to see Repoint)
2129
2130
       - Clean up by deleting the new copy
2131
    *)
2132
2133
    fun project_vcs_id_and_url dir =
2134
        let val context = {
2135
                rootpath = dir,
2136
                extdir = ".",
2137
                providers = [],
2138
                accounts = []
2139
            }
2140
            val vcs_maybe =
2141
                case [HgControl.exists context ".",
2142
                      GitControl.exists context ".",
2143
                      SvnControl.exists context "."] of
2144
                    [OK true, OK false, OK false] => OK HG
2145
                  | [OK false, OK true, OK false] => OK GIT
2146
                  | [OK false, OK false, OK true] => OK SVN
2147
                  | _ => ERROR ("Unable to identify VCS for directory " ^ dir)
2148
        in
2149
            case vcs_maybe of
2150
                ERROR e => ERROR e
2151
              | OK vcs =>
2152
                case (fn HG => HgControl.id_of
2153
                       | GIT => GitControl.id_of
2154
                       | SVN => SvnControl.id_of)
2155
                         vcs context "." of
2156
                    ERROR e => ERROR ("Unable to find id of project repo: " ^ e)
2157
                  | OK id =>
2158
                    case (fn HG => HgControl.copy_url_for
2159
                           | GIT => GitControl.copy_url_for
2160
                           | SVN => SvnControl.copy_url_for)
2161
                             vcs context "." of
2162
                        ERROR e => ERROR ("Unable to find URL of project repo: "
2163
                                          ^ e)
2164
                      | OK url => OK (vcs, id, url)
2165
        end
2166
2167
    fun make_archive_root (context : context) =
2168
        let val path = OS.Path.joinDirFile {
2169
                    dir = #rootpath context,
2170
                    file = RepointFilenames.archive_dir
2171
                }
2172
        in
2173
            case FileBits.mkpath path of
2174
                ERROR e => raise Fail ("Failed to create archive directory \""
2175
                                       ^ path ^ "\": " ^ e)
2176
              | OK () => path
2177
        end
2178
2179
    fun archive_path archive_dir target_name =
2180
        OS.Path.joinDirFile {
2181
            dir = archive_dir,
2182
            file = target_name
2183
        }
2184
2185
    fun check_nonexistent path =
2186
        case SOME (OS.FileSys.fileSize path) handle OS.SysErr _ => NONE of
2187
            NONE => ()
2188
          | _ => raise Fail ("Path " ^ path ^ " exists, not overwriting")
2189
2190
    fun make_archive_copy target_name (vcs, project_id, source_url)
2191
                          ({ context, ... } : project) =
2192
        let val archive_root = make_archive_root context
2193
            val synthetic_context = {
2194
                rootpath = archive_root,
2195
                extdir = ".",
2196
                providers = [],
2197
                accounts = []
2198
            }
2199
            val synthetic_library = {
2200
                libname = target_name,
2201
                vcs = vcs,
2202
                source = URL_SOURCE source_url,
2203
                branch = DEFAULT_BRANCH, (* overridden by pinned id below *)
2204
                project_pin = PINNED project_id,
2205
                lock_pin = PINNED project_id
2206
            }
2207
            val path = archive_path archive_root target_name
2208
            val _ = print ("Cloning original project to " ^ path
2209
                           ^ " at revision " ^ project_id ^ "...\n");
2210
            val _ = check_nonexistent path
2211
        in
2212
            case AnyLibControl.update synthetic_context synthetic_library of
2213
                ERROR e => ERROR ("Failed to clone original project to "
2214
                                  ^ path ^ ": " ^ e)
2215
              | OK _ => OK archive_root
2216
        end
2217
2218
    fun update_archive archive_root target_name
2219
                       (project as { context, ... } : project) =
2220
        let val synthetic_context = {
2221
                rootpath = archive_path archive_root target_name,
2222
                extdir = #extdir context,
2223
                providers = #providers context,
2224
                accounts = #accounts context
2225
            }
2226
        in
2227
            foldl (fn (lib, acc) =>
2228
                      case acc of
2229
                          ERROR e => ERROR e
2230
                        | OK () => AnyLibControl.update synthetic_context lib)
2231
                  (OK ())
2232
                  (#libs project)
2233
        end
2234
2235
    datatype packer = TAR
2236
                    | TAR_GZ
2237
                    | TAR_BZ2
2238
                    | TAR_XZ
2239
    (* could add other packers, e.g. zip, if we knew how to
2240
       handle the file omissions etc properly in pack_archive *)
2241
2242
    fun packer_and_basename path =
2243
        let val extensions = [ (".tar", TAR),
2244
                               (".tar.gz", TAR_GZ),
2245
                               (".tar.bz2", TAR_BZ2),
2246
                               (".tar.xz", TAR_XZ)]
2247
            val filename = OS.Path.file path
2248
        in
2249
            foldl (fn ((ext, packer), acc) =>
2250
                      if String.isSuffix ext filename
2251
                      then SOME (packer,
2252
                                 String.substring (filename, 0,
2253
                                                   String.size filename -
2254
                                                   String.size ext))
2255
                      else acc)
2256
                  NONE
2257
                  extensions
2258
        end
2259
2260
    fun pack_archive archive_root target_name target_path packer exclusions =
2261
        case FileBits.command {
2262
                rootpath = archive_root,
2263
                extdir = ".",
2264
                providers = [],
2265
                accounts = []
2266
            } "" ([
2267
                     "tar",
2268
                     case packer of
2269
                         TAR => "cf"
2270
                       | TAR_GZ => "czf"
2271
                       | TAR_BZ2 => "cjf"
2272
                       | TAR_XZ => "cJf",
2273
                     target_path,
2274
                     "--exclude=.hg",
2275
                     "--exclude=.git",
2276
                     "--exclude=.svn",
2277
                     "--exclude=repoint",
2278
                     "--exclude=repoint.sml",
2279
                     "--exclude=repoint.ps1",
2280
                     "--exclude=repoint.bat",
2281
                     "--exclude=repoint-project.json",
2282
                     "--exclude=repoint-lock.json"
2283
                 ] @ (map (fn e => "--exclude=" ^ e) exclusions) @
2284
                  [ target_name ])
2285
         of
2286
            ERROR e => ERROR e
2287
          | OK _ => FileBits.rmpath (archive_path archive_root target_name)
2288
2289
    fun archive (target_path, exclusions) (project : project) =
2290
        let val _ = check_nonexistent target_path
2291
            val (packer, name) =
2292
                case packer_and_basename target_path of
2293
                    NONE => raise Fail ("Unsupported archive file extension in "
2294
                                        ^ target_path)
2295
                  | SOME pn => pn
2296
            val details =
2297
                case project_vcs_id_and_url (#rootpath (#context project)) of
2298
                    ERROR e => raise Fail e
2299
                  | OK details => details
2300
            val archive_root =
2301
                case make_archive_copy name details project of
2302
                    ERROR e => raise Fail e
2303
                  | OK archive_root => archive_root
2304
            val outcome =
2305
                case update_archive archive_root name project of
2306
                    ERROR e => ERROR e
2307
                  | OK _ =>
2308
                    case pack_archive archive_root name
2309
                                      target_path packer exclusions of
2310
                        ERROR e => ERROR e
2311
                      | OK _ => OK ()
2312
        in
2313
            case outcome of
2314
                ERROR e => raise Fail e
2315
              | OK () => OS.Process.success
2316
        end
2317
2318 100:dd1207a4dfc9 Chris
end
2319
2320
val libobjname = "libraries"
2321
2322
fun load_libspec spec_json lock_json libname : libspec =
2323
    let open JsonBits
2324
        val libobj   = lookup_mandatory spec_json [libobjname, libname]
2325
        val vcs      = lookup_mandatory_string libobj ["vcs"]
2326
        val retrieve = lookup_optional_string libobj
2327
        val service  = retrieve ["service"]
2328
        val owner    = retrieve ["owner"]
2329
        val repo     = retrieve ["repository"]
2330
        val url      = retrieve ["url"]
2331
        val branch   = retrieve ["branch"]
2332
        val project_pin = case retrieve ["pin"] of
2333
                              NONE => UNPINNED
2334
                            | SOME p => PINNED p
2335
        val lock_pin = case lookup_optional lock_json [libobjname, libname] of
2336
                           NONE => UNPINNED
2337
                         | SOME ll => case lookup_optional_string ll ["pin"] of
2338
                                          SOME p => PINNED p
2339
                                        | NONE => UNPINNED
2340
    in
2341
        {
2342
          libname = libname,
2343
          vcs = case vcs of
2344
                    "hg" => HG
2345
                  | "git" => GIT
2346 105:fa09c8e155ec Chris
                  | "svn" => SVN
2347 100:dd1207a4dfc9 Chris
                  | other => raise Fail ("Unknown version-control system \"" ^
2348
                                         other ^ "\""),
2349
          source = case (url, service, owner, repo) of
2350
                       (SOME u, NONE, _, _) => URL_SOURCE u
2351
                     | (NONE, SOME ss, owner, repo) =>
2352
                       SERVICE_SOURCE { service = ss, owner = owner, repo = repo }
2353
                     | _ => raise Fail ("Must have exactly one of service " ^
2354
                                        "or url string"),
2355
          project_pin = project_pin,
2356
          lock_pin = lock_pin,
2357
          branch = case branch of
2358 105:fa09c8e155ec Chris
                       NONE => DEFAULT_BRANCH
2359
                     | SOME b =>
2360
                       case vcs of
2361
                           "svn" => raise Fail ("Branches not supported for " ^
2362
                                                "svn repositories; change " ^
2363
                                                "URL instead")
2364
                         | _ => BRANCH b
2365 100:dd1207a4dfc9 Chris
        }
2366
    end
2367
2368
fun load_userconfig () : userconfig =
2369
    let val home = FileBits.homedir ()
2370
        val conf_json =
2371
            JsonBits.load_json_from
2372
                (OS.Path.joinDirFile {
2373
                      dir = home,
2374 105:fa09c8e155ec Chris
                      file = RepointFilenames.user_config_file })
2375 100:dd1207a4dfc9 Chris
            handle IO.Io _ => Json.OBJECT []
2376
    in
2377
        {
2378
          accounts = case JsonBits.lookup_optional conf_json ["accounts"] of
2379
                         NONE => []
2380
                       | SOME (Json.OBJECT aa) =>
2381
                         map (fn (k, (Json.STRING v)) =>
2382
                                 { service = k, login = v }
2383
                             | _ => raise Fail
2384
                                          "String expected for account name")
2385
                             aa
2386
                       | _ => raise Fail "Array expected for accounts",
2387
          providers = Provider.load_providers conf_json
2388
        }
2389
    end
2390
2391
datatype pintype =
2392
         NO_LOCKFILE |
2393
         USE_LOCKFILE
2394
2395
fun load_project (userconfig : userconfig) rootpath pintype : project =
2396
    let val spec_file = FileBits.project_spec_path rootpath
2397
        val lock_file = FileBits.project_lock_path rootpath
2398
        val _ = if OS.FileSys.access (spec_file, [OS.FileSys.A_READ])
2399
                   handle OS.SysErr _ => false
2400
                then ()
2401
                else raise Fail ("Failed to open project spec file " ^
2402 105:fa09c8e155ec Chris
                                 (RepointFilenames.project_file) ^ " in " ^
2403 100:dd1207a4dfc9 Chris
                                 rootpath ^
2404
                                 ".\nPlease ensure the spec file is in the " ^
2405
                                 "project root and run this from there.")
2406
        val spec_json = JsonBits.load_json_from spec_file
2407
        val lock_json = if pintype = USE_LOCKFILE
2408
                        then JsonBits.load_json_from lock_file
2409
                             handle IO.Io _ => Json.OBJECT []
2410
                        else Json.OBJECT []
2411
        val extdir = JsonBits.lookup_mandatory_string spec_json
2412
                                                      ["config", "extdir"]
2413
        val spec_libs = JsonBits.lookup_optional spec_json [libobjname]
2414
        val lock_libs = JsonBits.lookup_optional lock_json [libobjname]
2415
        val providers = Provider.load_more_providers
2416
                            (#providers userconfig) spec_json
2417
        val libnames = case spec_libs of
2418
                           NONE => []
2419
                         | SOME (Json.OBJECT ll) => map (fn (k, v) => k) ll
2420
                         | _ => raise Fail "Object expected for libs"
2421
    in
2422
        {
2423
          context = {
2424
            rootpath = rootpath,
2425
            extdir = extdir,
2426
            providers = providers,
2427
            accounts = #accounts userconfig
2428
          },
2429
          libs = map (load_libspec spec_json lock_json) libnames
2430
        }
2431
    end
2432
2433
fun save_lock_file rootpath locks =
2434
    let val lock_file = FileBits.project_lock_path rootpath
2435
        open Json
2436
        val lock_json =
2437
            OBJECT [
2438
                (libobjname,
2439
                 OBJECT (map (fn { libname, id_or_tag } =>
2440
                                 (libname,
2441
                                  OBJECT [ ("pin", STRING id_or_tag) ]))
2442
                             locks))
2443
            ]
2444
    in
2445
        JsonBits.save_json_to lock_file lock_json
2446
    end
2447 110:125520c3dc05 Chris
2448
fun checkpoint_completion_file rootpath =
2449
    let val completion_file = FileBits.project_completion_path rootpath
2450
        val stream = TextIO.openOut completion_file
2451
    in
2452
        TextIO.closeOut stream
2453
    end
2454
2455 100:dd1207a4dfc9 Chris
fun pad_to n str =
2456
    if n <= String.size str then str
2457
    else pad_to n (str ^ " ")
2458
2459
fun hline_to 0 = ""
2460
  | hline_to n = "-" ^ hline_to (n-1)
2461
2462 105:fa09c8e155ec Chris
val libname_width = 28
2463 100:dd1207a4dfc9 Chris
val libstate_width = 11
2464
val localstate_width = 17
2465
val notes_width = 5
2466
val divider = " | "
2467
val clear_line = "\r" ^ pad_to 80 "";
2468
2469
fun print_status_header () =
2470
    print (clear_line ^ "\n " ^
2471
           pad_to libname_width "Library" ^ divider ^
2472
           pad_to libstate_width "State" ^ divider ^
2473
           pad_to localstate_width "Local" ^ divider ^
2474
           "Notes" ^ "\n " ^
2475
           hline_to libname_width ^ "-+-" ^
2476
           hline_to libstate_width ^ "-+-" ^
2477
           hline_to localstate_width ^ "-+-" ^
2478
           hline_to notes_width ^ "\n")
2479
2480
fun print_outcome_header () =
2481
    print (clear_line ^ "\n " ^
2482
           pad_to libname_width "Library" ^ divider ^
2483
           pad_to libstate_width "Outcome" ^ divider ^
2484
           "Notes" ^ "\n " ^
2485
           hline_to libname_width ^ "-+-" ^
2486
           hline_to libstate_width ^ "-+-" ^
2487
           hline_to notes_width ^ "\n")
2488
2489 105:fa09c8e155ec Chris
fun print_status with_network (lib : libspec, status) =
2490 100:dd1207a4dfc9 Chris
    let val libstate_str =
2491
            case status of
2492
                OK (ABSENT, _) => "Absent"
2493
              | OK (CORRECT, _) => if with_network then "Correct" else "Present"
2494
              | OK (SUPERSEDED, _) => "Superseded"
2495
              | OK (WRONG, _) => "Wrong"
2496
              | ERROR _ => "Error"
2497
        val localstate_str =
2498
            case status of
2499
                OK (_, MODIFIED) => "Modified"
2500
              | OK (_, LOCK_MISMATCHED) => "Differs from Lock"
2501
              | OK (_, CLEAN) => "Clean"
2502
              | ERROR _ => ""
2503
        val error_str =
2504
            case status of
2505
                ERROR e => e
2506
              | _ => ""
2507
    in
2508
        print (" " ^
2509 105:fa09c8e155ec Chris
               pad_to libname_width (#libname lib) ^ divider ^
2510 100:dd1207a4dfc9 Chris
               pad_to libstate_width libstate_str ^ divider ^
2511
               pad_to localstate_width localstate_str ^ divider ^
2512
               error_str ^ "\n")
2513
    end
2514
2515 105:fa09c8e155ec Chris
fun print_update_outcome (lib : libspec, outcome) =
2516 100:dd1207a4dfc9 Chris
    let val outcome_str =
2517
            case outcome of
2518
                OK id => "Ok"
2519
              | ERROR e => "Failed"
2520
        val error_str =
2521
            case outcome of
2522
                ERROR e => e
2523
              | _ => ""
2524
    in
2525
        print (" " ^
2526 105:fa09c8e155ec Chris
               pad_to libname_width (#libname lib) ^ divider ^
2527 100:dd1207a4dfc9 Chris
               pad_to libstate_width outcome_str ^ divider ^
2528
               error_str ^ "\n")
2529
    end
2530
2531 105:fa09c8e155ec Chris
fun vcs_name HG = ("Mercurial", "hg")
2532
  | vcs_name GIT = ("Git", "git")
2533
  | vcs_name SVN = ("Subversion", "svn")
2534
2535
fun print_problem_summary context lines =
2536
    let val failed_vcs =
2537
            foldl (fn (({ vcs, ... } : libspec, ERROR _), acc) => vcs::acc
2538
                  | (_, acc) => acc) [] lines
2539
        fun report_nonworking vcs error =
2540
            print ((if error = "" then "" else error ^ "\n\n") ^
2541
                   "Error: The project uses the " ^ (#1 (vcs_name vcs)) ^
2542
                   " version control system, but its\n" ^
2543
                   "executable program (" ^ (#2 (vcs_name vcs)) ^
2544
                   ") does not appear to be installed in the program path\n\n")
2545
        fun check_working [] checked = ()
2546
          | check_working (vcs::rest) checked =
2547
            if List.exists (fn v => vcs = v) checked
2548
            then check_working rest checked
2549
            else
2550
                case AnyLibControl.is_working context vcs of
2551
                    OK true => check_working rest checked
2552
                  | OK false => (report_nonworking vcs "";
2553
                                 check_working rest (vcs::checked))
2554
                  | ERROR e => (report_nonworking vcs e;
2555
                                check_working rest (vcs::checked))
2556
    in
2557
        print "\nError: Some operations failed\n\n";
2558
        check_working failed_vcs []
2559
    end
2560
2561
fun act_and_print action print_header print_line context (libs : libspec list) =
2562
    let val lines = map (fn lib => (lib, action lib)) libs
2563
        val imperfect = List.exists (fn (_, ERROR _) => true | _ => false) lines
2564 100:dd1207a4dfc9 Chris
        val _ = print_header ()
2565
    in
2566
        app print_line lines;
2567 105:fa09c8e155ec Chris
        if imperfect then print_problem_summary context lines else ();
2568 100:dd1207a4dfc9 Chris
        lines
2569
    end
2570
2571
fun return_code_for outcomes =
2572
    foldl (fn ((_, result), acc) =>
2573
              case result of
2574
                  ERROR _ => OS.Process.failure
2575
                | _ => acc)
2576
          OS.Process.success
2577
          outcomes
2578
2579
fun status_of_project ({ context, libs } : project) =
2580
    return_code_for (act_and_print (AnyLibControl.status context)
2581
                                   print_status_header (print_status false)
2582 105:fa09c8e155ec Chris
                                   context libs)
2583 100:dd1207a4dfc9 Chris
2584
fun review_project ({ context, libs } : project) =
2585
    return_code_for (act_and_print (AnyLibControl.review context)
2586
                                   print_status_header (print_status true)
2587 105:fa09c8e155ec Chris
                                   context libs)
2588 100:dd1207a4dfc9 Chris
2589
fun lock_project ({ context, libs } : project) =
2590 105:fa09c8e155ec Chris
    let val _ = if FileBits.verbose ()
2591
                then print ("Scanning IDs for lock file...\n")
2592
                else ()
2593
        val outcomes = map (fn lib => (lib, AnyLibControl.id_of context lib))
2594 100:dd1207a4dfc9 Chris
                           libs
2595
        val locks =
2596
            List.concat
2597 105:fa09c8e155ec Chris
                (map (fn (lib : libspec, result) =>
2598 100:dd1207a4dfc9 Chris
                         case result of
2599
                             ERROR _ => []
2600 105:fa09c8e155ec Chris
                           | OK id => [{ libname = #libname lib,
2601
                                         id_or_tag = id }])
2602 100:dd1207a4dfc9 Chris
                     outcomes)
2603
        val return_code = return_code_for outcomes
2604
        val _ = print clear_line
2605
    in
2606
        if OS.Process.isSuccess return_code
2607
        then save_lock_file (#rootpath context) locks
2608
        else ();
2609
        return_code
2610
    end
2611 105:fa09c8e155ec Chris
2612
fun update_project (project as { context, libs }) =
2613
    let val outcomes = act_and_print
2614
                           (AnyLibControl.update context)
2615
                           print_outcome_header print_update_outcome
2616
                           context libs
2617
        val _ = if List.exists (fn (_, OK _) => true | _ => false) outcomes
2618
                then lock_project project
2619
                else OS.Process.success
2620 110:125520c3dc05 Chris
        val return_code = return_code_for outcomes
2621 105:fa09c8e155ec Chris
    in
2622 110:125520c3dc05 Chris
        if OS.Process.isSuccess return_code
2623
        then checkpoint_completion_file (#rootpath context)
2624
        else ();
2625
        return_code
2626 105:fa09c8e155ec Chris
    end
2627
2628 100:dd1207a4dfc9 Chris
fun load_local_project pintype =
2629
    let val userconfig = load_userconfig ()
2630
        val rootpath = OS.FileSys.getDir ()
2631
    in
2632
        load_project userconfig rootpath pintype
2633
    end
2634
2635
fun with_local_project pintype f =
2636 105:fa09c8e155ec Chris
  let open OS.Process
2637
      val return_code =
2638
          f (load_local_project pintype)
2639
          handle Fail msg =>
2640
                 failure before print ("Error: " ^ msg)
2641
               | JsonBits.Config msg =>
2642
                 failure before print ("Error in configuration: " ^ msg)
2643
               | e =>
2644
                 failure before print ("Error: " ^ exnMessage e)
2645 100:dd1207a4dfc9 Chris
        val _ = print "\n";
2646
    in
2647
        return_code
2648
    end
2649
2650
fun review () = with_local_project USE_LOCKFILE review_project
2651
fun status () = with_local_project USE_LOCKFILE status_of_project
2652
fun update () = with_local_project NO_LOCKFILE update_project
2653
fun lock () = with_local_project NO_LOCKFILE lock_project
2654
fun install () = with_local_project USE_LOCKFILE update_project
2655
2656
fun version () =
2657 105:fa09c8e155ec Chris
    (print ("v" ^ repoint_version ^ "\n");
2658 100:dd1207a4dfc9 Chris
     OS.Process.success)
2659
2660
fun usage () =
2661 105:fa09c8e155ec Chris
    (print "\nRepoint ";
2662 100:dd1207a4dfc9 Chris
     version ();
2663 110:125520c3dc05 Chris
     print ("\n  A simple manager for third-party source code dependencies.\n"
2664
            ^ "  http://all-day-breakfast.com/repoint/\n\n"
2665 100:dd1207a4dfc9 Chris
            ^ "Usage:\n\n"
2666 110:125520c3dc05 Chris
            ^ "  repoint <command> [<options>]\n\n"
2667 100:dd1207a4dfc9 Chris
            ^ "where <command> is one of:\n\n"
2668
            ^ "  status   print quick report on local status only, without using network\n"
2669
            ^ "  review   check configured libraries against their providers, and report\n"
2670
            ^ "  install  update configured libraries according to project specs and lock file\n"
2671
            ^ "  update   update configured libraries and lock file according to project specs\n"
2672 110:125520c3dc05 Chris
            ^ "  lock     rewrite lock file to match local library status\n"
2673
            ^ "  archive  pack up project and all libraries into an archive file:\n"
2674
            ^ "           invoke as 'repoint archive targetfile.tar.gz --exclude unwanted.txt'\n"
2675
            ^ "  version  print the Repoint version number and exit\n\n"
2676
            ^ "and <options> may include:\n\n"
2677
            ^ "  --directory <dir>\n"
2678
            ^ "           change to directory <dir> before doing anything; in particular,\n"
2679
            ^ "           expect to find project spec file in that directory\n\n");
2680 100:dd1207a4dfc9 Chris
    OS.Process.failure)
2681
2682 105:fa09c8e155ec Chris
fun archive target args =
2683
    case args of
2684
        [] =>
2685
        with_local_project USE_LOCKFILE (Archive.archive (target, []))
2686
      | "--exclude"::xs =>
2687
        with_local_project USE_LOCKFILE (Archive.archive (target, xs))
2688
      | _ => usage ()
2689
2690 110:125520c3dc05 Chris
fun handleSystemArgs args =
2691
    let fun handleSystemArgs' leftover args =
2692
            case args of
2693
                "--directory"::dir::rest =>
2694
                (OS.FileSys.chDir dir;
2695
                 handleSystemArgs' leftover rest)
2696
              | arg::rest =>
2697
                handleSystemArgs' (leftover @ [arg]) rest
2698
              | [] => leftover
2699
    in
2700
        OK (handleSystemArgs' [] args)
2701
        handle e => ERROR (exnMessage e)
2702
    end
2703
2704 105:fa09c8e155ec Chris
fun repoint args =
2705 110:125520c3dc05 Chris
    case handleSystemArgs args of
2706
        ERROR e => (print ("Error: " ^ e ^ "\n");
2707
                    OS.Process.exit OS.Process.failure)
2708
      | OK args =>
2709
        let val return_code =
2710 100:dd1207a4dfc9 Chris
            case args of
2711
                ["review"] => review ()
2712
              | ["status"] => status ()
2713
              | ["install"] => install ()
2714
              | ["update"] => update ()
2715
              | ["lock"] => lock ()
2716
              | ["version"] => version ()
2717 105:fa09c8e155ec Chris
              | "archive"::target::args => archive target args
2718
              | arg::_ => (print ("Error: unknown argument \"" ^ arg ^ "\"\n");
2719
                           usage ())
2720 100:dd1207a4dfc9 Chris
              | _ => usage ()
2721 110:125520c3dc05 Chris
        in
2722
            OS.Process.exit return_code
2723
        end
2724 100:dd1207a4dfc9 Chris
2725
fun main () =
2726 105:fa09c8e155ec Chris
    repoint (CommandLine.arguments ())