src/ez/target.bend
ezx@1.8.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.8.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