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"))