src/ez/target.bend

ezx@1.9.0.0 · 456 lines · 0 definitions

1# ez/target: what `ez add`, `ez tool run`, `install` and `upgrade` were asked
2# for, decided from the word alone. `owner/repo` is GitHub. A URL is that URL.
3# Anything else is a path, and a path written `./…`, `../…`, `/…` or `~/…` is
4# a path even when it would otherwise read as a repository. For `ez add`, a
5# word bend reads as a hub package's `<name>@<version>` names that package,
6# where it would otherwise be a path (`aim`).
7import Base
8import ezx@1.9.0.0/src/pkg/pkg.bend as K
9
10# where a target names, or why it names nothing
11type Place is Data:
12  Bad{why: String}
13  Local{path: String}
14  Remote{url: String, slug: String}
15
16# a char a GitHub owner or repository may hold besides a dot: a letter, a
17# digit, `-` or `_`
18def plain.at(+ch: Char) -> Bool:
19  Bool.or(Char.is_digit(ch), Bool.or(Char.is_alpha(ch),
20    Bool.or(Char.is_eq(ch, '-'), Char.is_eq(ch, '_'))))
21
22# what one char of a target is, as far as telling a shorthand from a path
23# goes. A colon is asked about first, so a char that is anything but `Other`
24# is known not to be one: that is what lets a proof say a shorthand never
25# contains `://`.
26type Mark is Data:
27  Slash{}
28  Dot{}
29  Tilde{}
30  Word{}
31  Other{}
32
33def kind.word(word: Bool) -> Mark:
34  match word:
35    case True{}:
36      Word{}
37    case False{}:
38      Other{}
39
40def kind.tilde(tilde: Bool, word: Bool) -> Mark:
41  match tilde:
42    case True{}:
43      Tilde{}
44    case False{}:
45      kind.word(word)
46
47def kind.dot(dot: Bool, tilde: Bool, word: Bool) -> Mark:
48  match dot:
49    case True{}:
50      Dot{}
51    case False{}:
52      kind.tilde(tilde, word)
53
54def kind.slash(slash: Bool, dot: Bool, tilde: Bool, word: Bool) -> Mark:
55  match slash:
56    case True{}:
57      Slash{}
58    case False{}:
59      kind.dot(dot, tilde, word)
60
61def kind.colon(colon: Bool, slash: Bool, dot: Bool, tilde: Bool, word: Bool) -> Mark:
62  match colon:
63    case True{}:
64      Other{}
65    case False{}:
66      kind.slash(slash, dot, tilde, word)
67
68# a char's kind
69def kind(+ch: Char) -> Mark:
70  kind.colon(Char.is_eq(ch, ':'), Char.is_eq(ch, '/'), Char.is_eq(ch, '.'),
71    Char.is_eq(ch, '~'), plain.at(ch))
72
73# one char of a name, with the rest of the name still to be asked about.
74# The rest arrives as a thunk, so a char that ends the name ends the walk.
75def name.keep(mark: Mark, rest: Unit -> Bool) -> Bool:
76  match mark:
77    case Word{}:
78      rest(Unit{})
79    case Dot{}:
80      rest(Unit{})
81    case Slash{}:
82      False{}
83    case Tilde{}:
84      False{}
85    case Other{}:
86      False{}
87
88# every char a letter, a digit, `-`, `_` or `.`
89def name.all(text: String) -> Bool:
90  match text:
91    case SNil{}:
92      True{}
93    case SCon{+h, t}:
94      name.keep(kind(h), _u => name.all(t))
95
96# after `..`: one more name char, so the segment is not `..`
97def seg.dots(text: String) -> Bool:
98  match text:
99    case SNil{}:
100      False{}
101    case SCon{+h, t}:
102      name.keep(kind(h), _u => name.all(t))
103
104# the second char after a leading dot
105def seg.dot2(mark: Mark, rest: String) -> Bool:
106  match mark:
107    case Word{}:
108      name.all(rest)
109    case Dot{}:
110      seg.dots(rest)
111    case Slash{}:
112      False{}
113    case Tilde{}:
114      False{}
115    case Other{}:
116      False{}
117
118# after a leading `.`: more, so the segment is not `.`
119def seg.dot(text: String) -> Bool:
120  match text:
121    case SNil{}:
122      False{}
123    case SCon{+h, t}:
124      seg.dot2(kind(h), t)
125
126# the first char of a segment
127def seg.first(mark: Mark, rest: String) -> Bool:
128  match mark:
129    case Word{}:
130      name.all(rest)
131    case Dot{}:
132      seg.dot(rest)
133    case Slash{}:
134      False{}
135    case Tilde{}:
136      False{}
137    case Other{}:
138      False{}
139
140# one segment of a GitHub shorthand: letters, digits, `-`, `_` and `.`, at
141# least one of them, and not `.` or `..`, which name a directory
142def seg(text: String) -> Bool:
143  match text:
144    case SNil{}:
145      False{}
146    case SCon{+h, t}:
147      seg.first(kind(h), t)
148
149# a word cut at its first slash, or not cut because a char before any slash
150# could not be in a shorthand
151type Halves is Data:
152  Uncut{}
153  Two{left: String, right: String}
154
155# a char put back in front of the left half
156def halve.put(ch: Char, halves: Halves) -> Halves:
157  match halves:
158    case Uncut{}:
159      Uncut{}
160    case Two{left, right}:
161      Two{SCon{ch, left}, right}
162
163# one char of the first segment. A slash ends it; the rest of the word is
164# asked about only past a char a segment may hold.
165def halve.at(mark: Mark, ch: Char, +tail: String, rest: Unit -> Halves) -> Halves:
166  match mark:
167    case Slash{}:
168      Two{"", tail}
169    case Word{}:
170      halve.put(ch, rest(Unit{}))
171    case Dot{}:
172      halve.put(ch, rest(Unit{}))
173    case Tilde{}:
174      Uncut{}
175    case Other{}:
176      Uncut{}
177
178# the word before its first slash and the word after it
179def halve(text: String) -> Halves:
180  match text:
181    case SNil{}:
182      Uncut{}
183    case SCon{+h, +t}:
184      halve.at(kind(h), h, t, _u => halve(t))
185
186# a word naming a Bend file. As the second of two segments it is the last
187# component of a path, `src/main.bend`, and not a repository.
188def bend.file(+text: String) -> Bool:
189  String.ends_with(text, ".bend")
190
191# the second segment of a shorthand: a segment that is not a Bend file
192def repo.seg(+text: String) -> Bool:
193  Bool.and(seg(text), Bool.not(bend.file(text)))
194
195# both segments of a shorthand, where there are two
196def github.halves(halves: Halves) -> Bool:
197  match halves:
198    case Uncut{}:
199      False{}
200    case Two{left, right}:
201      Bool.and(seg(left), repo.seg(right))
202
203# `owner/repo`: exactly two segments, each a `seg`, the second not ending in
204# `.bend`. A second slash leaves the right half with a slash in it, which no
205# segment holds.
206def github.ok(text: String) -> Bool:
207  github.halves(halve(text))
208
209# one step of a scan looking for `..`. A slug with one would leave the cache.
210def climb.step(here: Bool, rest: Unit -> Bool) -> Bool:
211  match here:
212    case True{}:
213      True{}
214    case False{}:
215      rest(Unit{})
216
217# whether any component is a climb
218def climb(ps: List<&2, String>) -> Bool:
219  match ps:
220    case []:
221      False{}
222    case +h <> t:
223      climb.step(String.eq(h, ".."), _u => climb(t))
224
225# a slug that stays under the cache: non-empty, relative, and with no `..`
226def slug.ok(+text: String) -> Bool:
227  Bool.and(Bool.not(String.is_empty(text)),
228    Bool.and(Bool.not(String.starts_with(text, "/")),
229      Bool.not(climb(String.split(text, '/')))))
230
231# a remote whose slug can be a directory, or a refusal when it cannot
232def remote(+url: String, +slug: String) -> Place:
233  Bool.pick(Place, slug.ok(slug), Remote{url, slug}, Bad{"not a repository"})
234
235# `s` with a suffix of `n` chars taken off when it ends in that suffix.
236# `Bool.pick` builds both arms, which is the same trade `pkg/path.unbend` makes.
237def cut.end(+text: String, +suf: String, len: Nat) -> String:
238  Bool.pick(String, String.ends_with(text, suf),
239    String.take(text, Nat.sub(String.length(text), len)), text)
240
241# a trailing slash is not part of the cache directory
242def unslash(+text: String) -> String:
243  cut.end(text, "/", 1n)
244
245# a trailing `.git` is not part of the cache directory
246def ungit(+text: String) -> String:
247  cut.end(text, ".git", 4n)
248
249# everything after the first colon. `https://host/path` splits into the
250# scheme and `//host/path`.
251def body(ps: List<&2, String>) -> String:
252  match ps:
253    case Nil{}:
254      ""
255    case _h <> t:
256      String.join(t, ":")
257
258# the `//` a scheme leaves in front of the host
259def drop.slashes(+text: String) -> String:
260  Bool.pick(String, String.starts_with(text, "//"), String.drop(text, 2n), text)
261
262# a URL with no host, `file:///srv/repo`, is on this host, and is cached
263# where `file://localhost/srv/repo` is, rather than at an absolute slug
264def hostless(+text: String) -> String:
265  Bool.pick(String, String.starts_with(text, "/"), "localhost" ++ text, text)
266
267# `scheme://host/path` as a cache directory
268def slug.url(+text: String) -> String:
269  ungit(unslash(hostless(drop.slashes(body(String.split(text, ':'))))))
270
271# `git@host:path` as a cache directory. The colon is the only separator.
272def slug.scp(+text: String) -> String:
273  ungit(unslash(String.join(String.split(String.drop(text, 4n), ':'), "/")))
274
275# the char after `~` or `..`: a slash
276def looks.slash.at(mark: Mark) -> Bool:
277  match mark:
278    case Slash{}:
279      True{}
280    case Dot{}:
281      False{}
282    case Tilde{}:
283      False{}
284    case Word{}:
285      False{}
286    case Other{}:
287      False{}
288
289def looks.slash(text: String) -> Bool:
290  match text:
291    case SNil{}:
292      False{}
293    case SCon{+h, _t}:
294      looks.slash.at(kind(h))
295
296# the char after a leading dot: `./` or `../`
297def looks.dot.at(mark: Mark, rest: String) -> Bool:
298  match mark:
299    case Slash{}:
300      True{}
301    case Dot{}:
302      looks.slash(rest)
303    case Tilde{}:
304      False{}
305    case Word{}:
306      False{}
307    case Other{}:
308      False{}
309
310def looks.dot(text: String) -> Bool:
311  match text:
312    case SNil{}:
313      False{}
314    case SCon{+h, t}:
315      looks.dot.at(kind(h), t)
316
317# the first char: `/`, or the start of `./`, `../` or `~/`
318def looks.first(mark: Mark, rest: String) -> Bool:
319  match mark:
320    case Slash{}:
321      True{}
322    case Dot{}:
323      looks.dot(rest)
324    case Tilde{}:
325      looks.slash(rest)
326    case Word{}:
327      False{}
328    case Other{}:
329      False{}
330
331# a word that already says it is a path: it starts `/`, `./`, `../` or `~/`
332def looks.path(text: String) -> Bool:
333  match text:
334    case SNil{}:
335      False{}
336    case SCon{+h, t}:
337      looks.first(kind(h), t)
338
339# `git@host:path`, or a path
340def classify.scp(scp: Bool, +text: String) -> Place:
341  match scp:
342    case True{}:
343      remote(text, slug.scp(text))
344    case False{}:
345      Local{text}
346
347# `owner/repo`. Its slug is safe without the check a URL's gets: neither
348# segment is empty, `.`, `..` or holds a slash, so it cannot climb out of the
349# cache. A trailing `.git` is not part of the cache directory, as for a URL.
350def classify.hub(ok: Bool, +text: String) -> Place:
351  match ok:
352    case True{}:
353      Remote{"https://github.com/" ++ text, "github.com/" ++ ungit(text)}
354    case False{}:
355      classify.scp(String.starts_with(text, "git@"), text)
356
357# a path prefix, or a possible shorthand
358def classify.path(local: Bool, +text: String) -> Place:
359  match local:
360    case True{}:
361      Local{text}
362    case False{}:
363      classify.hub(github.ok(text), text)
364
365# a URL, or not
366def classify.url(url: Bool, +text: String) -> Place:
367  match url:
368    case True{}:
369      remote(text, slug.url(text))
370    case False{}:
371      classify.path(looks.path(text), text)
372
373# nothing was asked for
374def classify.empty(empty: Bool, +text: String) -> Place:
375  match empty:
376    case True{}:
377      Bad{"a target is needed"}
378    case False{}:
379      classify.url(String.contains(text, "://"), text)
380
381# what a target word names. A word with `://` in it is a URL; one starting
382# `/`, `./`, `../` or `~/` is a path; `owner/repo` is GitHub; one starting
383# `git@` is a URL; anything else is a path. The shorthand is asked about
384# before `git@` because no shorthand holds an `@`, and in that order the
385# shorthand's law needs nothing said about `git@`.
386def classify(+text: String) -> Place:
387  classify.empty(String.is_empty(text), text)
388
389# what `ez add` was asked for: a hub package by its `<name>@<version>`, or a
390# place as `classify` reads it
391type Aim is Data:
392  Named{nv: String}
393  Placed{place: Place}
394
395# a word `classify` reads as a path names a hub package when bend's NAMED rule
396# rules it in (`K.name.ok`); otherwise it is that path
397def aim.local(named: Bool, +text: String, +path: String) -> Aim:
398  match named:
399    case True{}:
400      Named{text}
401    case False{}:
402      Placed{Local{path}}
403
404# the place read, with only a path asked whether it is a name. A URL, a
405# `git@host:path` and an `owner/repo` are never read again, so nothing that
406# was a remote becomes a name.
407def aim.place(place: Place, +text: String) -> Aim:
408  match place:
409    case Bad{why}:
410      Placed{Bad{why}}
411    case Local{path}:
412      aim.local(K.name.ok(text), text, path)
413    case Remote{url, slug}:
414      Placed{Remote{url, slug}}
415
416# what an `ez add` target names. A name holds no `/` and no `:`, and starts
417# with a letter, so a word bend reads as a name is one `classify` reads as a
418# path, and only such a word is asked.
419def aim(+text: String) -> Aim:
420  aim.place(classify(text), text)
421
422# whether a target names a hub package
423def aim.named(aim: Aim) -> Bool:
424  match aim:
425    case Named{_nv}:
426      True{}
427    case Placed{_place}:
428      False{}
429
430# `~` and `~/…` expanded. Anything else is already a path.
431def expand(+home: String, +path: String) -> String:
432  Bool.pick(String, String.eq(path, "~"), home,
433    Bool.pick(String, String.starts_with(path, "~/"),
434      home ++ String.drop(path, 1n), path))
435
436# the entry, or `main.bend` when a ledger names none
437def entry.or(+path: String) -> String:
438  Bool.pick(String, String.is_empty(path), "main.bend", path)
439
440# the file that is built: `bin` when the ledger names one, otherwise the entry
441def file.of(+bin: String, +entry: String) -> String:
442  Bool.pick(String, String.is_empty(bin), entry.or(entry), bin)
443
444# the binary's name: the package's, or `app` when the ledger names none
445def out.name(+name: String) -> String:
446  Bool.pick(String, String.is_empty(name), "app", name)
447
448# a place on one line, for a proof to read
449def show(place: Place) -> String:
450  match place:
451    case Bad{why}:
452      "bad " ++ why
453    case Local{path}:
454      "local " ++ path
455    case Remote{url, slug}:
456      "remote " ++ url ++ " " ++ slug