src/ws/workspace.bend

ezx@1.10.0.0 · 565 lines · 0 definitions

1# ws/workspace: which directories a workspace's root ledger makes members
2# (EZ-WS-1). A root ledger's `[workspace]` table lists `members`, each a
3# directory relative to the root or `<dir>/*`, every directory directly under
4# `<dir>`, and `exclude`, directories taken back out. The design is
5# docs/rfc/ez-workspaces.md.
6#
7# Everything here is pure. A path is read as its segments, the pieces
8# between its `/`s, so a member, an entry and an exclusion are compared
9# segment by segment and nothing is cut off the end of a string. What the
10# member set is decided from is the root ledger's text and, for every ez.toml
11# git lists under the root, the directory it is in, its `[package] name` and
12# whether it has a `[workspace]` table of its own. The interpreter of each
13# command that reads a workspace gathers those; it decides nothing.
14import Base
15import emerging-eztoml@0.9.0.0/main.bend as E
16import ezx@1.10.0.0/src/toml/toml.bend as T
17
18# a path, as the segments between its `/`s
19def segs.of(+path: String) -> List<&2, String>:
20  String.split(path, '/')
21
22# a path's segments, written back with `/` between them
23def segs.text(ss: List<&2, String>) -> String:
24  String.join(ss, "/")
25
26# a list of segments with one more in front, when there was a list
27def cut.cons(+head: String, rest: Maybe<&2, List<&2, String>>) -> Maybe<&2, List<&2, String>>:
28  match rest:
29    case None{}:
30      None{}
31    case Some{ds}:
32      Some{head <> ds}
33
34# the last segment, kept when it is the word and nothing otherwise
35def cut.last(hit: Bool) -> Maybe<&2, List<&2, String>>:
36  match hit:
37    case True{}:
38      Some{[]}
39    case False{}:
40      None{}
41
42# the segments before the last, when the last is the word: `libs/*` cut at
43# `*` is `libs`, and `cli/ez.toml` cut at `ez.toml` is `cli`. A path whose last
44# segment is another word cuts to nothing.
45def cut(ss: List<&2, String>, +word: String) -> Maybe<&2, List<&2, String>>:
46  match ss:
47    case []:
48      None{}
49    case +hd <> []:
50      cut.last(String.eq(hd, word))
51    case +hd <> tl:
52      cut.cons(hd, cut(tl, word))
53
54# ---------------------------------------------------------------------------
55# entries
56
57# one entry of `members`: a directory named as it is, or every directory
58# directly under one, which `<dir>/*` names
59type Entry is Data:
60  Plain{dir: List<&2, String>}
61  Under{dir: List<&2, String>}
62
63# an entry, once it is known whether its last segment is `*`
64def entry.go(under: Maybe<&2, List<&2, String>>, +ss: List<&2, String>) -> Entry:
65  match under:
66    case Some{ds}:
67      Under{ds}
68    case None{}:
69      Plain{ss}
70
71# an entry's segments read: `<dir>/*` is every directory under `<dir>`, and
72# anything else is the directory it names
73def entry.of(+ss: List<&2, String>) -> Entry:
74  entry.go(cut(ss, "*"), ss)
75
76# the directory an entry names, or the one it names the directories under
77def entry.dir(entry: Entry) -> List<&2, String>:
78  match entry:
79    case Plain{ds}:
80      ds
81    case Under{ds}:
82      ds
83
84# an entry as it was written
85def entry.text(entry: Entry) -> String:
86  match entry:
87    case Plain{ds}:
88      segs.text(ds)
89    case Under{ds}:
90      segs.text(ds) ++ "/*"
91
92# whether a segment can be part of a member's path: it is not empty, which a
93# leading `/` or a `//` would make it, it is neither `.` nor `..`, which
94# would name a directory by where it is from here, and it is not `*`, which
95# only ends an entry
96def seg.ok(+seg: String) -> Bool:
97  Bool.and(Bool.not(String.is_empty(seg)),
98    Bool.and(Bool.not(String.eq(seg, ".")),
99      Bool.and(Bool.not(String.eq(seg, "..")), Bool.not(String.eq(seg, "*")))))
100
101# whether every segment can be part of a member's path
102def segs.ok(ss: List<&2, String>) -> Bool:
103  match ss:
104    case []:
105      True{}
106    case +hd <> tl:
107      rest = segs.ok(tl)
108      Bool.and(seg.ok(hd), rest)
109
110# whether a directory is one a workspace can name: it has a segment, and
111# every segment is one a member's path can have
112def dir.ok(+ss: List<&2, String>) -> Bool:
113  Bool.and(Bool.not(List.is_empty(&2, String, ss)), segs.ok(ss))
114
115# whether every entry names a directory a workspace can name
116def entries.ok(es: List<&2, Entry>) -> Bool:
117  match es:
118    case []:
119      True{}
120    case +hd <> tl:
121      rest = entries.ok(tl)
122      Bool.and(dir.ok(entry.dir(hd)), rest)
123
124# the first entry that names no directory a workspace can name, "" for none
125def entries.bad(es: List<&2, Entry>) -> String:
126  match es:
127    case []:
128      ""
129    case +hd <> tl:
130      rest = entries.bad(tl)
131      Bool.pick(String, dir.ok(entry.dir(hd)), rest, entry.text(hd))
132
133# ---------------------------------------------------------------------------
134# matching a directory
135
136# whether two paths are one, segment by segment
137def segs.eq(as: List<&2, String>, bs: List<&2, String>) -> Bool:
138  match as:
139    case []:
140      List.is_empty(&2, String, bs)
141    case +ah <> at:
142      match bs:
143        case []:
144          False{}
145        case +bh <> bt:
146          rest = segs.eq(at, bt)
147          Bool.and(String.eq(ah, bh), rest)
148
149# whether a path is exactly one segment
150def one(ds: List<&2, String>) -> Bool:
151  match ds:
152    case []:
153      False{}
154    case _hd <> tl:
155      List.is_empty(&2, String, tl)
156
157# whether a directory is directly under another: the other's segments, then
158# exactly one more
159def under(ps: List<&2, String>, ds: List<&2, String>) -> Bool:
160  match ps:
161    case []:
162      one(ds)
163    case +ph <> pt:
164      match ds:
165        case []:
166          False{}
167        case +dh <> dt:
168          rest = under(pt, dt)
169          Bool.and(String.eq(ph, dh), rest)
170
171# whether an entry names a directory
172def hit(entry: Entry, +ds: List<&2, String>) -> Bool:
173  match entry:
174    case Plain{ps}:
175      segs.eq(ps, ds)
176    case Under{ps}:
177      under(ps, ds)
178
179# whether any entry names a directory
180def listed(es: List<&2, Entry>, +ds: List<&2, String>) -> Bool:
181  match es:
182    case []:
183      False{}
184    case +hd <> tl:
185      rest = listed(tl, ds)
186      Bool.or(hit(hd, ds), rest)
187
188# whether a list of paths holds one
189def has(xs: List<&2, List<&2, String>>, +ds: List<&2, String>) -> Bool:
190  match xs:
191    case []:
192      False{}
193    case +hd <> tl:
194      rest = has(tl, ds)
195      Bool.or(segs.eq(hd, ds), rest)
196
197# ---------------------------------------------------------------------------
198# the member set
199
200# what `[workspace]` says: its entries and the directories it excludes
201type Spec is Data:
202  Spec{members: List<&2, Entry>, exclude: List<&2, List<&2, String>>}
203
204# the root's ledger: one with a `[package]` table is a member, under its
205# name, and one with none is a virtual root, which holds the workspace and
206# nothing to build
207type Root is Data:
208  Virtual{}
209  Package{name: String}
210
211# an ez.toml git lists under the root, other than the root's own: the
212# directory it is in, its `[package] name`, and whether it has a
213# `[workspace]` table of its own
214type Ledger is Data:
215  Ledger{dir: List<&2, String>, name: String, nested: Bool}
216
217# the member set, or why there is none: whether the root is a member, and
218# every other member's directory
219type Members is Data:
220  Refused{why: String}
221  Members{root: Bool, dirs: List<&2, List<&2, String>>}
222
223# a ledger's directory
224def ledger.dir(ledger: Ledger) -> List<&2, String>:
225  Ledger{ds, _n, _w} = ledger
226  ds
227
228# a ledger's package name
229def ledger.name(ledger: Ledger) -> String:
230  Ledger{_ds, nn, _w} = ledger
231  nn
232
233# whether a ledger has a `[workspace]` table of its own
234def ledger.nested(ledger: Ledger) -> Bool:
235  Ledger{_ds, _n, ww} = ledger
236  ww
237
238# the directory of every ledger, in order
239def dirs.of(ls: List<&2, Ledger>) -> List<&2, List<&2, String>>:
240  match ls:
241    case []:
242      []
243    case +hd <> tl:
244      ledger.dir(hd) <> dirs.of(tl)
245
246# whether a directory is a member: an entry names it and no exclusion does
247def chosen(+spec: Spec, +ds: List<&2, String>) -> Bool:
248  Spec{es, ex} = spec
249  Bool.and(listed(es, ds), Bool.not(has(ex, ds)))
250
251# this ledger in front of the rest, when its directory is a member
252def picks.put(keep: Bool, +ledger: Ledger, rest: List<&2, Ledger>) -> List<&2, Ledger>:
253  match keep:
254    case True{}:
255      ledger <> rest
256    case False{}:
257      rest
258
259# every ledger whose directory is a member, in the order git listed them
260def picks(+spec: Spec, ls: List<&2, Ledger>) -> List<&2, Ledger>:
261  match ls:
262    case []:
263      []
264    case +hd <> tl:
265      picks.put(chosen(spec, ledger.dir(hd)), hd, picks(spec, tl))
266
267# whether an entry that names a directory as it is names one that holds a
268# committed ez.toml. `<dir>/*` names whatever is there, none included.
269def present.one(entry: Entry, +found: List<&2, List<&2, String>>) -> Bool:
270  match entry:
271    case Plain{ps}:
272      has(found, ps)
273    case Under{_ps}:
274      True{}
275
276# whether every entry naming a directory as it is names one that is there
277def present(es: List<&2, Entry>, +found: List<&2, List<&2, String>>) -> Bool:
278  match es:
279    case []:
280      True{}
281    case +hd <> tl:
282      rest = present(tl, found)
283      Bool.and(present.one(hd, found), rest)
284
285# the first entry naming a directory as it is that names none there, "" for
286# none
287def absent(es: List<&2, Entry>, +found: List<&2, List<&2, String>>) -> String:
288  match es:
289    case []:
290      ""
291    case +hd <> tl:
292      rest = absent(tl, found)
293      Bool.pick(String, present.one(hd, found), rest, entry.text(hd))
294
295# whether no member's ledger has a `[workspace]` table of its own
296def unnested(ls: List<&2, Ledger>) -> Bool:
297  match ls:
298    case []:
299      True{}
300    case +hd <> tl:
301      rest = unnested(tl)
302      Bool.and(Bool.not(ledger.nested(hd)), rest)
303
304# the first member whose ledger has a `[workspace]` table, "" for none
305def nested(ls: List<&2, Ledger>) -> String:
306  match ls:
307    case []:
308      ""
309    case +hd <> tl:
310      rest = nested(tl)
311      Bool.pick(String, ledger.nested(hd), segs.text(ledger.dir(hd)), rest)
312
313# the package name of every ledger, in order
314def names.of(ls: List<&2, Ledger>) -> List<&2, String>:
315  match ls:
316    case []:
317      []
318    case +hd <> tl:
319      ledger.name(hd) <> names.of(tl)
320
321# the package name of every member, the root's first when it is one
322def names(root: Root, ls: List<&2, Ledger>) -> List<&2, String>:
323  match root:
324    case Virtual{}:
325      names.of(ls)
326    case Package{nn}:
327      nn <> names.of(ls)
328
329# whether a list of names holds one
330def named(ns: List<&2, String>, +nn: String) -> Bool:
331  match ns:
332    case []:
333      False{}
334    case +hd <> tl:
335      rest = named(tl, nn)
336      Bool.or(String.eq(hd, nn), rest)
337
338# whether no name is in the list twice
339def distinct(ns: List<&2, String>) -> Bool:
340  match ns:
341    case []:
342      True{}
343    case +hd <> +tl:
344      rest = distinct(tl)
345      Bool.and(Bool.not(named(tl, hd)), rest)
346
347# the first name in the list twice, "" for none
348def twice(ns: List<&2, String>) -> String:
349  match ns:
350    case []:
351      ""
352    case +hd <> +tl:
353      rest = twice(tl)
354      Bool.pick(String, named(tl, hd), hd, rest)
355
356# whether the root is a member
357def root.member(root: Root) -> Bool:
358  match root:
359    case Virtual{}:
360      False{}
361    case Package{_nn}:
362      True{}
363
364# whether the workspace has a member set: every entry names a directory a
365# workspace can name, every one naming a directory as it is names one with a
366# committed ez.toml, no member is a workspace root of its own, and no two
367# members have one package name
368def ok(+spec: Spec, +root: Root, +ls: List<&2, Ledger>) -> Bool:
369  Spec{es, _ex} = spec
370  Bool.and(entries.ok(es),
371    Bool.and(present(es, dirs.of(ls)),
372      Bool.and(unnested(picks(spec, ls)), distinct(names(root, picks(spec, ls))))))
373
374# why a workspace has no member set, said for the first thing found wrong
375def why(+spec: Spec, +root: Root, +ls: List<&2, Ledger>) -> String:
376  Spec{es, _ex} = spec
377  +bad = entries.bad(es)
378  +gone = absent(es, dirs.of(ls))
379  +deep = nested(picks(spec, ls))
380  +dup = twice(names(root, picks(spec, ls)))
381  Bool.pick(String, String.is_empty(bad),
382    Bool.pick(String, String.is_empty(gone),
383      Bool.pick(String, String.is_empty(deep),
384        "ez: two workspace members are both named " ++ dup,
385        "ez: workspace member " ++ deep ++ " has a [workspace] table of its own"),
386      "ez: workspace member " ++ gone ++ " has no committed ez.toml"),
387    "ez: workspace member " ++ bad ++ " is not a directory under the root")
388
389# the member set, once it is known whether there is one
390def members.go(fine: Bool, +spec: Spec, +root: Root, +ls: List<&2, Ledger>) -> Members:
391  match fine:
392    case True{}:
393      Members{root.member(root), dirs.of(picks(spec, ls))}
394    case False{}:
395      Refused{why(spec, root, ls)}
396
397# the members of a workspace: the root when it has a package, and the
398# directory of every ledger an entry names and no exclusion does, or why
399# there are none
400def members(+spec: Spec, +root: Root, +ls: List<&2, Ledger>) -> Members:
401  members.go(ok(spec, root, ls), spec, root, ls)
402
403# ---------------------------------------------------------------------------
404# reading the ledgers
405
406# what a ledger's text says of its workspace: nothing, a `[workspace]` table,
407# or why the table could not be read
408type Read is Data:
409  Lone{}
410  Bad{why: String}
411  Root{spec: Spec}
412
413# every item of an array as text, or nothing when one is not a string
414def strs(items: List<&2, E.Val>) -> Maybe<&2, List<&2, String>>:
415  match items:
416    case []:
417      Some{[]}
418    case E.VStr{text} <> tl:
419      cut.cons(text, strs(tl))
420    case E.VSpan{src, nn} <> tl:
421      cut.cons(E.string(E.VSpan{src, nn}), strs(tl))
422    case _hd <> tl:
423      None{}
424
425# a value read as an array of strings
426def array.val(val: E.Val) -> Maybe<&2, List<&2, String>>:
427  match val:
428    case E.VArr{items}:
429      strs(items)
430    case _:
431      None{}
432
433# this pair's array when its key is the key, otherwise the rest's
434def array.at(hit: Bool, val: E.Val, rest: Maybe<&2, List<&2, String>>) -> Maybe<&2, List<&2, String>>:
435  match hit:
436    case True{}:
437      array.val(val)
438    case False{}:
439      rest
440
441# the array a table's key holds, an absent key holding none, or nothing when
442# the key holds something else
443def array(rows: List<&2, E.Val>, +key: String) -> Maybe<&2, List<&2, String>>:
444  match rows:
445    case []:
446      Some{[]}
447    case E.VPair{+name, val} <> tl:
448      array.at(String.eq(name, key), val, array(tl, key))
449    case _hd <> tl:
450      array(tl, key)
451
452# every text read as an entry
453def entries.of(ts: List<&2, String>) -> List<&2, Entry>:
454  match ts:
455    case []:
456      []
457    case +hd <> tl:
458      entry.of(segs.of(hd)) <> entries.of(tl)
459
460# every text read as a path's segments
461def paths.of(ts: List<&2, String>) -> List<&2, List<&2, String>>:
462  match ts:
463    case []:
464      []
465    case +hd <> tl:
466      segs.of(hd) <> paths.of(tl)
467
468# the table, once both arrays were read
469def table.go(ms: Maybe<&2, List<&2, String>>, ex: Maybe<&2, List<&2, String>>) -> Read:
470  match ms:
471    case None{}:
472      Bad{"ez: [workspace] members is not a list of strings"}
473    case Some{mm}:
474      match ex:
475        case None{}:
476          Bad{"ez: [workspace] exclude is not a list of strings"}
477        case Some{xx}:
478          Root{Spec{entries.of(mm), paths.of(xx)}}
479
480# a `[workspace]` table's rows read
481def table(+rows: List<&2, E.Val>) -> Read:
482  table.go(array(rows, "members"), array(rows, "exclude"))
483
484# this table when it is `[workspace]`, otherwise the rest's
485def spec.at(hit: Bool, +sub: List<&2, E.Val>, rest: Read) -> Read:
486  match hit:
487    case True{}:
488      table(sub)
489    case False{}:
490      rest
491
492# the `[workspace]` table among a document's rows, or none
493def spec.rows(rows: List<&2, E.Val>) -> Read:
494  match rows:
495    case []:
496      Lone{}
497    case E.VPair{+name, E.VHead{_path, +sub}} <> tl:
498      spec.at(String.eq(name, "workspace"), sub, spec.rows(tl))
499    case _hd <> tl:
500      spec.rows(tl)
501
502# what a ledger's text says of its workspace. A ledger that does not parse
503# says nothing here; the command that reads it refuses it as it always has.
504def spec.of(text: String) -> Read:
505  spec.rows(E.root(E.parse(text)))
506
507# a section's name
508def sect.name(sect: T.Sect) -> String:
509  T.Sect{nn, _ps} = sect
510  nn
511
512# whether a document has a section of this name
513def sect.has(ss: List<&2, T.Sect>, +name: String) -> Bool:
514  match ss:
515    case []:
516      False{}
517    case +hd <> tl:
518      rest = sect.has(tl, name)
519      Bool.or(String.eq(sect.name(hd), name), rest)
520
521# this section's pairs when its name matches, otherwise the rest's
522def pairs.at(sect: T.Sect, rest: List<&2, T.Kv>, +name: String) -> List<&2, T.Kv>:
523  T.Sect{nn, ps} = sect
524  Bool.pick(List<&2, T.Kv>, String.eq(nn, name), ps, rest)
525
526# the pairs of the section with this name, or none
527def pairs.of(ss: List<&2, T.Sect>, +name: String) -> List<&2, T.Kv>:
528  match ss:
529    case []:
530      []
531    case hd <> tl:
532      pairs.at(hd, pairs.of(tl, name), name)
533
534# the root, read from its ledger's text: a member under its `[package] name`
535# when it has a `[package]` table, a virtual root when it has none
536def root.of(+text: String) -> Root:
537  +ss = T.sects(T.parse(text))
538  +nn = T.value(pairs.of(ss, "package"), "name")
539  Bool.pick(Root, sect.has(ss, "package"), Package{nn}, Virtual{})
540
541# a ledger under a directory of at least one segment
542def ledger.some(ds: List<&2, String>, nn: String, ww: Bool) -> Maybe<&2, Ledger>:
543  match ds:
544    case []:
545      None{}
546    case +hd <> tl:
547      Some{Ledger{hd <> tl, nn, ww}}
548
549# the ledger, once its directory is known: the root's own ez.toml cuts to no
550# segments, and is not one of these
551def ledger.go(dir: Maybe<&2, List<&2, String>>, nn: String, ww: Bool) -> Maybe<&2, Ledger>:
552  match dir:
553    case None{}:
554      None{}
555    case Some{ds}:
556      ledger.some(ds, nn, ww)
557
558# a committed ez.toml under the root, read: the directory it is in, which is
559# its path with the last segment cut, its `[package] name`, and whether it has
560# a `[workspace]` table. A path that is not an ez.toml under a directory is
561# none.
562def ledger.of(+path: String, +text: String) -> Maybe<&2, Ledger>:
563  +ss = T.sects(T.parse(text))
564  ledger.go(cut(segs.of(path), "ez.toml"), T.value(pairs.of(ss, "package"), "name"),
565    sect.has(ss, "workspace"))