0 | module Libraries.Utils.Path
  1 |
  2 | import Idris.Env
  3 |
  4 | import Data.List
  5 | import Data.List1
  6 | import Data.Maybe
  7 | import Data.Nat
  8 | import Data.String
  9 |
 10 | import Libraries.Data.String.Extra
 11 | import Libraries.Text.Token
 12 | import Libraries.Text.Lexer
 13 | import Libraries.Text.Parser
 14 |
 15 | import System.Info
 16 | import System.File
 17 |
 18 | %default total
 19 |
 20 | export infixl 5 </>, />
 21 | export infixr 7 <.>
 22 | export infixr 7 <..>
 23 |
 24 |
 25 | ||| The character that separates directories in the path.
 26 | export
 27 | dirSeparator : Char
 28 | dirSeparator = if isWindows then '\\' else '/'
 29 |
 30 | ||| The character that separates multiple paths.
 31 | export
 32 | pathSeparator : Char
 33 | pathSeparator = if isWindows then ';' else ':'
 34 |
 35 | ||| Windows path prefix.
 36 | public export
 37 | data Volume
 38 |   =
 39 |   ||| Windows Uniform Naming Convention, consisting of server name and share
 40 |   ||| directory.
 41 |   |||
 42 |   ||| Example: `\\localhost\share`
 43 |   UNC String String |
 44 |   ||| The drive.
 45 |   |||
 46 |   ||| Example: `C:`
 47 |   Disk Char
 48 |
 49 | ||| A single body in path.
 50 | public export
 51 | data Body
 52 |   =
 53 |   ||| Represents ".".
 54 |   CurDir |
 55 |   ||| Represents "..".
 56 |   ParentDir |
 57 |   ||| Directory or file.
 58 |   Normal String
 59 |
 60 | ||| A parsed cross-platform file system path.
 61 | |||
 62 | ||| Use the function `parse` to constructs a Path from String, and use the
 63 | ||| function `show` to convert in reverse.
 64 | |||
 65 | ||| Trailing separator is only used for display and is ignored when comparing
 66 | ||| paths.
 67 | public export
 68 | record Path where
 69 |     constructor MkPath
 70 |     ||| Windows path prefix (only for Windows).
 71 |     volume : Maybe Volume
 72 |     ||| Whether the path contains root.
 73 |     hasRoot : Bool
 74 |     ||| Path bodies.
 75 |     body : List Body
 76 |     ||| Whether the path terminates with a separator.
 77 |     hasTrailSep : Bool
 78 |
 79 | export
 80 | Eq Volume where
 81 |   (==) (UNC l1 l2) (UNC r1 r2) = l1 == r1 && r1 == r2
 82 |   (==) (Disk l) (Disk r) = l == r
 83 |   (==) _ _ = False
 84 |
 85 | export
 86 | Eq Body where
 87 |   (==) CurDir CurDir = True
 88 |   (==) ParentDir ParentDir = True
 89 |   (==) (Normal l) (Normal r) = l == r
 90 |   (==) _ _ = False
 91 |
 92 | export
 93 | Eq Path where
 94 |   (==) (MkPath l1 l2 l3 _) (MkPath r1 r2 r3 _) =
 95 |     l1 == r1 && l2 == r2 && l3 == r3
 96 |
 97 | ||| An empty path, which represents "".
 98 | public export
 99 | emptyPath : Path
100 | emptyPath = MkPath Nothing False [] False
101 |
102 | --------------------------------------------------------------------------------
103 | -- Show
104 | --------------------------------------------------------------------------------
105 |
106 | export
107 | Show Body where
108 |   show CurDir = "."
109 |   show ParentDir = ".."
110 |   show (Normal normal) = normal
111 |
112 | export
113 | Show Volume where
114 |   show (UNC server share) = "\\\\" ++ server ++ "\\" ++ share
115 |   show (Disk disk) = String.singleton disk ++ ":"
116 |
117 | ||| Displays the path in the format of this platform.
118 | export
119 | Show Path where
120 |   show path =
121 |     let
122 |       sep = String.singleton dirSeparator
123 |       showVol = maybe "" show path.volume
124 |       showRoot = if path.hasRoot then sep else ""
125 |       showBody = join sep $ map show path.body
126 |       showTrail = if path.hasTrailSep then sep else ""
127 |     in
128 |       showVol ++ showRoot ++ showBody ++ showTrail
129 |
130 | --------------------------------------------------------------------------------
131 | -- Parser
132 | --------------------------------------------------------------------------------
133 |
134 | data PathTokenKind = PTText | PTPunct Char
135 |
136 | Eq PathTokenKind where
137 |   (==) PTText PTText = True
138 |   (==) (PTPunct c1) (PTPunct c2) = c1 == c2
139 |   (==) _ _ = False
140 |
141 | PathToken : Type
142 | PathToken = Token PathTokenKind
143 |
144 | PathGrammar : Bool -> Type -> Type
145 | PathGrammar = Grammar () PathToken
146 |
147 | TokenKind PathTokenKind where
148 |   TokType PTText = String
149 |   TokType (PTPunct _) = ()
150 |
151 |   tokValue PTText x = x
152 |   tokValue (PTPunct _) _ = ()
153 |
154 | pathTokenMap : TokenMap PathToken
155 | pathTokenMap = toTokenMap $
156 |   [ (is '/', PTPunct '/')
157 |   , (is '\\', PTPunct '\\')
158 |   , (is ':', PTPunct ':')
159 |   , (is '?', PTPunct '?')
160 |   , (some $ non $ oneOf "/\\:?", PTText)
161 |   ]
162 |
163 | lexPath : String -> List (WithBounds PathToken)
164 | lexPath str = let (tokens, _, _, _) = lex pathTokenMap str in tokens
165 |
166 | -- match both '/' and '\\' regardless of the platform.
167 | bodySeparator : PathGrammar True ()
168 | bodySeparator = (match $ PTPunct '\\') <|> (match $ PTPunct '/')
169 |
170 | -- Windows will automatically translate '/' to '\\'. And the verbatim prefix,
171 | -- i.e., `\\?\`, can be used to disable the translation.
172 | -- However, we just parse it and ignore it.
173 | --
174 | -- Example: \\?\
175 | verbatim : PathGrammar True ()
176 | verbatim =
177 |   do
178 |     ignore $ count (exactly 2) $ match $ PTPunct '\\'
179 |     match $ PTPunct '?'
180 |     match $ PTPunct '\\'
181 |     pure ()
182 |
183 | -- Example: \\server\share
184 | unc : PathGrammar True Volume
185 | unc =
186 |   do
187 |     ignore $ count (exactly 2) $ match $ PTPunct '\\'
188 |     server <- match PTText
189 |     bodySeparator
190 |     share <- match PTText
191 |     pure $ UNC server share
192 |
193 | -- Example: \\?\server\share
194 | verbatimUnc : PathGrammar True Volume
195 | verbatimUnc =
196 |   do
197 |     verbatim
198 |     server <- match PTText
199 |     bodySeparator
200 |     share <- match PTText
201 |     pure $ UNC server share
202 |
203 | -- Example: C:
204 | disk : PathGrammar True Volume
205 | disk =
206 |   do
207 |     text <- match PTText
208 |     disk <- case unpack text of
209 |               (disk :: xs) => pure disk
210 |               [] => fail "Expects disk"
211 |     match $ PTPunct ':'
212 |     pure $ Disk (toUpper disk)
213 |
214 | -- Example: \\?\C:
215 | verbatimDisk : PathGrammar True Volume
216 | verbatimDisk =
217 |   do
218 |     verbatim
219 |     disk <- disk
220 |     pure disk
221 |
222 | parseVolume : PathGrammar True Volume
223 | parseVolume =
224 |       verbatimUnc
225 |   <|> verbatimDisk
226 |   <|> unc
227 |   <|> disk
228 |
229 | parseBody : PathGrammar True Body
230 | parseBody =
231 |   do
232 |     text <- match PTText
233 |     the (PathGrammar False _) $
234 |       case text of
235 |         ".." => pure ParentDir
236 |         "." => pure CurDir
237 |         normal => pure (Normal normal)
238 |
239 | parsePath : PathGrammar False Path
240 | parsePath =
241 |   do
242 |     vol <- optional parseVolume
243 |     root <- optional (some bodySeparator)
244 |     body <- sepBy (some bodySeparator) parseBody
245 |     trailSep <- optional (some bodySeparator)
246 |     let body = filter (\case Normal s => ltrim s /= ""_ => True) body
247 |     let body = case body of
248 |                 [] => []
249 |                 (x::xs) => x :: delete CurDir xs
250 |     pure $ MkPath vol (isJust root) body (isJust trailSep)
251 |
252 | ||| Parses a String into Path.
253 | |||
254 | ||| The string is parsed as much as possible from left to right, and the invalid
255 | ||| parts on the right is ignored.
256 | |||
257 | ||| Some kind of invalid paths are accepted. The relax rules:
258 | |||
259 | ||| - Both slash('/') and backslash('\\') are parsed as valid directory separator,
260 | |||   regardless of the platform;
261 | ||| - Any characters in the body in allowed, e.g., glob like "/root/*";
262 | ||| - Verbatim prefix(`\\?\`) that disables the forward slash is ignored (for
263 | |||   Windows only).
264 | ||| - Repeated separators are ignored, therefore, "a/b" and "a//b" both have "a"
265 | |||   and "b" as bodies.
266 | ||| - "." in the body are removed, unless they are at the beginning of the path.
267 | |||   For example, "a/./b", "a/b/", "a/b/." and "a/b" will have "a" and "b" as
268 | |||   bodies, and "./a/b" will starts with `CurDir`.
269 | |||
270 | ||| ```idris example
271 | ||| parse "C:\\Windows/System32"
272 | ||| parse "/usr/local/etc/*"
273 | ||| ```
274 | export
275 | parse : String -> Path
276 | parse str =
277 |   case parse parsePath (lexPath str) of
278 |     Right (_, path, _) => path
279 |     _ => emptyPath
280 |
281 | --------------------------------------------------------------------------------
282 | -- Utils
283 | --------------------------------------------------------------------------------
284 |
285 | isAbsolute' : Path -> Bool
286 | isAbsolute' path =
287 |   if isWindows then
288 |     case path.volume of
289 |       Just (UNC {})  => True
290 |       Just (Disk {}) => path.hasRoot
291 |       Nothing => False
292 |   else
293 |     path.hasRoot
294 |
295 | append' : (left : Path) -> (right : Path) -> Path
296 | append' left right =
297 |   if isAbsolute' right || isJust right.volume then
298 |     right
299 |   else if hasRoot right then
300 |     { volume := left.volume } right
301 |   else
302 |     { body := left.body ++ right.body, hasTrailSep := right.hasTrailSep } left
303 |
304 | splitPath' : Path -> List Path
305 | splitPath' path =
306 |   case splitRoot path of
307 |     (Just root, other) => root :: iterateBody (path.body) (path.hasTrailSep)
308 |     (Nothing, other) => iterateBody (path.body) (path.hasTrailSep)
309 |   where
310 |     splitRoot : Path -> (Maybe Path, Path)
311 |     splitRoot path@(MkPath Nothing False _ _) = (Nothing, path)
312 |     splitRoot (MkPath vol root xs trailSep) =
313 |       (Just $ MkPath vol root [] False, MkPath Nothing False xs trailSep)
314 |
315 |     iterateBody : List Body -> (trailSep : Bool) -> List Path
316 |     iterateBody [] _ = []
317 |     iterateBody [x] trailSep = [MkPath Nothing False [x] trailSep]
318 |     iterateBody (x::y::xs) trailSep =
319 |       (MkPath Nothing False [x] False) :: iterateBody (y::xs) trailSep
320 |
321 | splitParent' : Path -> Maybe (Path, Path)
322 | splitParent' path =
323 |   case path.body of
324 |     [] => Nothing
325 |     (x::xs) =>
326 |       let
327 |         parent = { body := init (x::xs), hasTrailSep := False } path
328 |         child = MkPath Nothing False [last (x::xs)] path.hasTrailSep
329 |       in
330 |         Just (parent, child)
331 |
332 | parent' : Path -> Maybe Path
333 | parent' = map fst . splitParent'
334 |
335 | fileName' : Path -> Maybe String
336 | fileName' path =
337 |   findNormal $ reverse path.body
338 | where
339 |   findNormal : List Body -> Maybe String
340 |   findNormal ((Normal normal)::xs) = Just normal
341 |   findNormal (CurDir::xs) = findNormal xs
342 |   findNormal _ = Nothing
343 |
344 | setFileName' : (name : String) -> Path -> Path
345 | setFileName' name path =
346 |   if isJust (fileName' path) then
347 |     append' (fromMaybe emptyPath $ parent' path) (parse name)
348 |   else
349 |     append' path (parse name)
350 |
351 | export
352 | splitFileName : String -> (String, String)
353 | splitFileName name =
354 |   case break (== '.') $ reverse $ unpack name of
355 |     (_, []) => (name, "")
356 |     (_, ['.']) => (name, "")
357 |     (revExt, (dot :: revStem)) =>
358 |       ((pack $ reverse revStem), (pack $ reverse revExt))
359 |
360 | ||| Split a file name into a basename and a list of extensions.
361 | ||| A leading dot is considered to be part of the basename.
362 | ||| ```
363 | ||| splitExtensions "Path.idr"           = ("Path", ["idr"])
364 | ||| splitExtensions "file.latex.lidr"    = ("file", ["latex", "lidr"])
365 | ||| splitExtensions ".hidden.latex.lidr" = (".hidden", ["latex", "lidr"])
366 | ||| ```
367 | export
368 | splitExtensions : String -> (String, List String)
369 | splitExtensions name = case map pack $ split (== '.') (unpack name) of
370 |   ("" ::: base :: exts) => ("." ++ base, exts)
371 |   (base ::: exts) => (base, exts)
372 |
373 | --------------------------------------------------------------------------------
374 | -- Methods
375 | --------------------------------------------------------------------------------
376 |
377 | ||| Returns true if the path is absolute.
378 | |||
379 | ||| - On Unix, a path is absolute if it starts with the root, so `isAbsolute` and
380 | |||   `hasRoot` are equivalent.
381 | |||
382 | ||| - On Windows, a path is absolute if it starts with a disk and has root or
383 | |||   starts with UNC. For example, `c:\\windows` is absolute, while `c:temp`
384 | |||   and `\temp` are not.
385 | export
386 | isAbsolute : String -> Bool
387 | isAbsolute = isAbsolute' . parse
388 |
389 | ||| Returns true if the path is relative.
390 | export
391 | isRelative : String -> Bool
392 | isRelative = not . isAbsolute
393 |
394 | ||| Appends the right path to the left path.
395 | |||
396 | ||| If the path on the right is absolute, it replaces the left path.
397 | |||
398 | ||| On Windows:
399 | |||
400 | ||| - If the right path has a root but no volume (e.g., `\windows`), it replaces
401 | |||   everything except for the volume (if any) of left.
402 | ||| - If the right path has a volume but no root, it replaces left.
403 | |||
404 | ||| ```idris example
405 | ||| parse "/usr" /> "local/etc" == "/usr/local/etc"
406 | ||| ```
407 | export
408 | (/>) : (left : Path) -> (right : String) -> Path
409 | (/>) left right = append' left (parse right)
410 |
411 | ||| Appends the right path to the left path.
412 | |||
413 | ||| If the path on the right is absolute, it replaces the left path.
414 | |||
415 | ||| On Windows:
416 | |||
417 | ||| - If the right path has a root but no volume (e.g., `\windows`), it replaces
418 | |||   everything except for the volume (if any) of left.
419 | ||| - If the right path has a volume but no root, it replaces left.
420 | |||
421 | ||| ```idris example
422 | ||| "/usr" </> "local/etc" == "/usr/local/etc"
423 | ||| ```
424 | export
425 | (</>) : (left : String) -> (right : String) -> String
426 | (</>) left right = show $ parse left /> right
427 |
428 |
429 | ||| Joins path components into one.
430 | |||
431 | ||| ```idris example
432 | ||| joinPath ["/usr", "local/etc"] == "/usr/local/etc"
433 | ||| ```
434 | export
435 | joinPath : List String -> String
436 | joinPath xs = show $ foldl (/>) (parse "") xs
437 |
438 | ||| Splits path into components.
439 | |||
440 | ||| ```idris example
441 | ||| splitPath "/usr/local/etc" == ["/", "usr", "local", "etc"]
442 | ||| splitPath "tmp/Foo.idr" == ["tmp", "Foo.idr"]
443 | ||| ```
444 | export
445 | splitPath : String -> List String
446 | splitPath = map show . splitPath' . parse
447 |
448 | ||| Splits the path into parent and child.
449 | |||
450 | ||| ```idris example
451 | ||| splitParent "/usr/local/etc" == Just ("/usr/local", "etc")
452 | ||| ```
453 | export
454 | splitParent : String -> Maybe (String, String)
455 | splitParent path =
456 |   do
457 |     (parent, child) <- splitParent' (parse path)
458 |     pure (show parent, show child)
459 |
460 | ||| Returns the path without its final component, if there is one.
461 | |||
462 | ||| Returns Nothing if the path terminates by a root or volume.
463 | export
464 | parent : String -> Maybe String
465 | parent = map show . parent' . parse
466 |
467 | ||| Returns the list of all parents of the path, longest first, self included.
468 | |||
469 | ||| ```idris example
470 | ||| parents "/etc/kernel" == ["/etc/kernel", "/etc", "/"]
471 | ||| ```
472 | export
473 | covering
474 | parents : String -> List String
475 | parents = map show . List.iterate parent' . parse
476 |
477 | ||| Determines whether the base is one of the parents of target.
478 | |||
479 | ||| Trailing separator is ignored.
480 | |||
481 | ||| ```idris example
482 | ||| "/etc" `isBaseOf` "/etc/kernel"
483 | ||| ```
484 | export
485 | isBaseOf : (base : String) -> (target : String) -> Bool
486 | isBaseOf base target =
487 |   let
488 |     MkPath baseVol baseRoot baseBody _ = parse base
489 |     MkPath targetVol targetRoot targetBody _ = parse target
490 |   in
491 |        baseVol == targetVol
492 |     && baseRoot == targetRoot
493 |     && (baseBody `isPrefixOf` targetBody)
494 |
495 | ||| Returns a path that, when appended to base, yields target.
496 | |||
497 | ||| Returns Nothing if the base is not a prefix of the target.
498 | export
499 | dropBase : (base : String) -> (target : String) -> Maybe String
500 | dropBase base target =
501 |   do
502 |     let MkPath baseVol baseRoot baseBody _ = parse base
503 |     let MkPath targetVol targetRoot targetBody targetTrialSep = parse target
504 |     if baseVol == targetVol && baseRoot == targetRoot then Just () else Nothing
505 |     body <- dropBody baseBody targetBody
506 |     pure $ show $ MkPath Nothing False body targetTrialSep
507 |   where
508 |     dropBody : (base : List Body) -> (target : List Body) -> Maybe (List Body)
509 |     dropBody [] ys = Just ys
510 |     dropBody xs [] = Nothing
511 |     dropBody (x::xs) (y::ys) = if x == y then dropBody xs ys else Nothing
512 |
513 | ||| Returns the last body of the path.
514 | |||
515 | ||| If the last body is a file, this is the file name;
516 | ||| if it's a directory, this is the directory name;
517 | ||| if it's ".", it recurses on the previous body;
518 | ||| if it's "..", returns Nothing.
519 | export
520 | fileName : String -> Maybe String
521 | fileName  = fileName' . parse
522 |
523 | ||| Extracts the file name in the path without extension.
524 | |||
525 | ||| The stem is:
526 | |||
527 | ||| - Nothing, if there is no file name;
528 | ||| - The entire file name if there is no embedded ".";
529 | ||| - The entire file name if the file name begins with a "." and has no other ".";
530 | ||| - Otherwise, the portion of the file name before the last ".".
531 | export
532 | fileStem : String -> Maybe String
533 | fileStem path = pure $ fst $ splitFileName !(fileName path)
534 |
535 | ||| Extracts the extension of the file name in the path.
536 | |||
537 | ||| The extension is:
538 | |||
539 | ||| - Nothing, if there is no file name;
540 | ||| - Nothing, if there is no embedded ".";
541 | ||| - Nothing, if the file name begins with a "." and has no other ".";
542 | ||| - Otherwise, the portion of the file name after the last ".".
543 | export
544 | extension : String -> Maybe String
545 | extension path = fileName path >>=
546 |   filter (/= "") . Just . snd . splitFileName
547 |
548 | ||| Extracts the list of extensions of the file name in the path.
549 | ||| The returned value is:
550 | |||
551 | ||| - Nothing, if there is no file name;
552 | ||| - Just [], if there is no embedded ".";
553 | ||| - Just [], if the filename begins with a "." and has no other ".";
554 | ||| - Just es, the portions between the "."s (excluding a potential leading one).
555 | export
556 | extensions : String -> Maybe (List String)
557 | extensions path = snd . splitExtensions <$> fileName path
558 |
559 | ||| Updates the file name in the path.
560 | |||
561 | ||| If there is no file name, it appends the name to the path;
562 | ||| otherwise, it appends the name to the parent of the path.
563 | export
564 | setFileName : (name : String) -> String -> String
565 | setFileName name path = show $ setFileName' name (parse path)
566 |
567 | ||| Appends a extension to the path.
568 | |||
569 | ||| If there is no file name, the path will not change;
570 | ||| if the path has no extension, the extension will be appended;
571 | ||| if the given extension is empty, the extension of the path will be dropped;
572 | ||| otherwise, the extension will be replaced.
573 | |||
574 | ||| ```idris example
575 | ||| "/tmp/Foo" <.> "idr" == "/tmp/Foo.idr"
576 | ||| ```
577 | export
578 | (<.>) : String -> (extension : String) -> String
579 | (<.>) path ext =
580 |   let
581 |     path' = parse path
582 |     ext = pack $ dropWhile (\char => char == '.' || isSpace char) (unpack ext)
583 |     ext = if ext == "" then "" else "." ++ ext
584 |   in
585 |     case fileName' path' of
586 |       Just name =>
587 |         let (stem, _) = splitFileName name in
588 |           show $ setFileName' (stem ++ ext) path'
589 |       Nothing => path
590 |
591 | ||| Appends a extension to the path, replacing multiple extensions.
592 | |||
593 | ||| If there is no file name, the path will not change;
594 | ||| if the path has no extension, the extension will be appended;
595 | ||| if the given extension is empty, all of the extensions of the path will be dropped;
596 | ||| otherwise, the extensions will be replaced.
597 | |||
598 | ||| ```idris example
599 | ||| "/tmp/Foo.lidr.md" <.> "idr" == "/tmp/Foo.idr"
600 | ||| ```
601 | export
602 | (<..>) : String -> (extension : String) -> String
603 | (<..>) path ext =
604 |   let
605 |     path' = parse path
606 |     ext = pack $ dropWhile (\char => char == '.' || isSpace char) (unpack ext)
607 |     ext = if ext == "" then "" else "." ++ ext
608 |   in
609 |     case fileName' path' of
610 |       Just name =>
611 |         let (stem, _) = splitExtensions name in
612 |           show $ setFileName' (stem ++ ext) path'
613 |       Nothing => path
614 |
615 |
616 | ||| Drops the extension of the path.
617 | export
618 | dropExtension : String -> String
619 | dropExtension path = path <.> ""
620 |
621 | ||| Drops all of the extensions of a path.
622 | ||| ```idris example
623 | ||| "/tmp/Foo.lidr.md" == "/tmp/Foo"
624 | export
625 | dropExtensions : String -> String
626 | dropExtensions path = path <..> ""
627 |
628 | ||| Looks up an executable from a list of candidate names in the PATH
629 | export
630 | pathLookup : List String -> IO (Maybe String)
631 | pathLookup candidates
632 |     = do path <- idrisGetEnv "PATH"
633 |          let extensions = if isWindows then [".exe", ".cmd", ".bat", ""] else [""]
634 |          let pathList = forget $ String.split (== pathSeparator) $ fromMaybe "/usr/bin:/usr/local/bin" path
635 |          let candidates = [p ++ "/" ++ x ++ y | p <- pathList,
636 |                                                 x <- candidates,
637 |                                                 y <- extensions ]
638 |          firstExists candidates
639 |