{-# LANGUAGE NoImplicitPrelude, GeneralizedNewtypeDeriving #-} {-| Module : Yarn.Lock Description : Parser & Types for yarn.lock files Maintainer : Profpatsch Stability : experimental The improves on npm in that it writes @yarn.lock@ files that contain a complete version resolution of all dependencies. This way a deterministic deployment can be guaranteed. This module provides a parser for @yarn.lock@ files. -} module Yarn.Lock ( Lockfile, PackageKey(..), Package(..), RemoteFile(..) , PackageEntry, PackageList , Yarn.Lock.parse , decycle -- | = Parsers , lockfile , packageListToLockfile, packageList , packageEntry, packageKeys, packageKey, package ) where import Protolude hiding (try) import qualified Data.List as L import Data.String (String) import Text.Megaparsec as MP import Text.Megaparsec.Text import qualified Data.Text as T import qualified Data.MultiKeyedMap as MKM import Data.Proxy (Proxy(..)) -- | Yarn lockfile. -- -- It is a multi-keyed map (each value can be referenced by multiple keys). -- This is achieved by using an intermediate key @ik@. type Lockfile = MKM.MKMap PackageKey Package -- | Proxy type for our MKMap intermediate key lockfileIkProxy :: Proxy Int lockfileIkProxy = Proxy -- instance Monoid Lockfile where -- mempty = Lockfile $ MKM.mkMap (Proxy :: Proxy Int) -- -- TODO associativity? -- mappend = undefined -- | Key that indexes package for a specific version. data PackageKey = PackageKey { name :: Text -- ^ package name , npmSemver :: Text -- ^ semver string } deriving (Show, Eq, Ord) -- | The actual package with dependencies and download link. data Package = Package { version :: Text -- ^ resolved, specific version , resolved :: RemoteFile -- ^ download link w/ hash , dependencies :: [PackageKey] -- ^ list of dependencies , optionalDependencies :: [PackageKey] -- ^ list of optional dependencies } deriving (Eq, Show) -- | A package download link. data RemoteFile = RemoteFile { url :: Text , sha1sum :: Text } deriving (Eq, Show) -- | A entry as it appears in the yarn.lock representation. type PackageEntry = ([PackageKey], Package) -- | Convenience alias. type PackageList = [PackageEntry] -- | Convenience function that converts errors to Text. -- -- The actual parsers are below. parse :: Text -- ^ name of source file -> Text -- ^ input for parser -> Either Text Lockfile parse src inp = first (T.pack . parseErrorPretty) $ MP.parse lockfile (T.unpack src) inp -- TODO: actually use somehow (apart from manual testing) -- The yarn.lock file should resolve each packageKey exactly once. -- -- No pkgname/semver combination should appear twice. That means -- the lengths of the converted map and the list lists need to match. -- prop_LockfileSameAmountOfKeys :: PackageList -> Bool -- prop_LockfileSameAmountOfKeys pl = length (packageListToLockfile pl) -- == length (concatMap fst pl) -- | Takes a 'Lockfile' and removes dependency cycles. -- -- Node packages often contain those and the yarn lockfile -- does not yet eliminate them, which may lead to infinite -- recursions. decycle :: Lockfile -> Lockfile decycle lf = goFold [] lf (MKM.keys lf) -- TODO: probably rewrite with State where -- | fold over all package keys, passing the lockfile goFold seen lf' pkeys = foldl' (\lf'' pkey -> go (pkey:seen) lf'') lf' pkeys -- | We get a stack of already seen packages -- and filter out any dependencies we already saw. go :: [PackageKey] -> Lockfile -> Lockfile go seen@(we:_) lf' = let ourPkg = lf' MKM.! we -- old deps minus the already seen ones -- TODO make handling of opt pkgs less of a duplication newDeps = dependencies ourPkg L.\\ seen newOptDeps = optionalDependencies ourPkg L.\\ seen -- we update the pkg with the cleaned dependencies lf'' = MKM.insert we (ourPkg { dependencies = newDeps , optionalDependencies = newOptDeps }) lf' -- finally we do the same for all remaining deps in goFold seen lf'' $ newDeps ++ newOptDeps go [] _ = panic $ toS "should not happen!" -- HALP, I don’t know how to parser. -- It appears to be a more general format which somewhat resembles yaml. -- The code below conflates the format & the semantics of yarn.lock files. -- It should be separated sometime, to make parsing easier. -- | Convenience function that applies @packageListToLockfile@. lockfile :: Parser Lockfile lockfile = packageListToLockfile <$> packageList -- | The yarn.lock file is basically a hashmap with multi-keyed entries. -- -- This should press it into our Lockfile Map. packageListToLockfile :: PackageList -> Lockfile packageListToLockfile = MKM.fromList lockfileIkProxy -- foldl' go mempty -- where go lf (keys, pkg) = foldl' (\lf' key' -> M.insert key' pkg lf') lf keys -- | Parse a complete yarn.lock into exaclty the same representation. -- -- You can apply @packageListToLockfile@ to make it usable. packageList :: Parser PackageList packageList = many $ (skipMany (comment <|> eol)) *> packageEntry where comment = char '#' *> manyTill anyChar eol -- | A single PackageEntry. -- -- @ -- handlebars@^4.0.4: -- version "4.0.6" -- resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.0.6.tgz#2ce4484850537f9c97a8026d5399b935c4ed4ed7" -- dependencies: -- async "^1.4.0" -- optimist "^0.6.1" -- source-map "^0.4.4" -- optionalDependencies: -- uglify-js "^2.6" -- @ packageEntry :: Parser PackageEntry packageEntry = (,) <$> packageKeys <*> package "package entry" -- | The list of PackageKeys that index the same Package -- -- @ -- align-text@^0.1.1, align-text@^0.1.3:\\n -- @ packageKeys :: Parser [PackageKey] packageKeys = sepBy1 packageKey (string ", ") <* (char ':') <* eol "package keys" -- | A packageKey is @\\@\@; -- -- If the semver contains spaces, it is also quoted with @"@. packageKey :: Parser PackageKey packageKey = label "package key" $ inString pkgKey <|> pkgKey where pkgKey = PackageKey -- everything until the version, sep is @ <$> (someTextUntilSep '@' "package name part of package key") -- a version is anything but the , (used for seperating package keys) -- or : (used to close the packageKeys line) -- could be more specific (version is semver), but I’m lazy <*> (someText (noneOf "\",:") "semver part of package key") -- | Parses the content fields of a package. package :: Parser Package package = Package -- TODO: order shouldn’t matter, horrible indentation scheme <$> (indent 2 $ key "version" stringText) <*> (indent 2 $ key "resolved" remoteFile) <*> (maybe [] identity <$> optional (indent 2 $ dependencyEntries "dependencies")) <*> (maybe [] identity <$> optional (indent 2 $ dependencyEntries "optionalDependencies")) -- internal parsers -- | the “resolved”-field contains the link and the hash remoteFile :: Parser RemoteFile remoteFile = label "file link with hash" $ RemoteFile <$> someTextUntilSep '#' <*> stringText -- | dependency field of a package dependencyEntries :: String -> Parser [PackageKey] dependencyEntries key' = label (key' <>" field") $ do _ <- string (key' <>":") <* eol -- TODO: cool indentation handling some (indent 4 dep) where -- It’s a bit like a key below, but the value of the key is not known. -- Here’s where the format should get its own AST, but I’m too lazy right now. dep = PackageKey <$> someTextUntilSep ' ' <*> inString stringText <* eol "a dependency entry" -- | A key-value pair, separated by space. The value is enclosed in "". -- -- The given parser is used to parse the value and should not parse ". key :: String -> Parser a -> Parser a key name' val = label ("key " <> name') $ string name' *> char ' ' *> inString val <* eol -- text versions of parsers & helpers someText :: Parser Char -> Parser Text someText c = T.pack <$> some c -- | parse everything as inside a string -- TODO: this breaks the 'between' abstraction, can it be avoided somehow? inString :: Parser a -> Parser a inString = between (char '"') (char '"') -- | function to annotate text inside strings (which should never parse ") -- symptom of the broken 'between' abstraction stringText :: Parser Text stringText = someText (noneOf "\"") "non-empty text without \"" -- | parse some text until seperator is reached someTextUntilSep :: Char -> Parser Text someTextUntilSep sep = T.pack <$> someTill anyChar (char sep) -- | intend by @i@ spaces indent :: Int -> Parser a -> Parser a indent i p = try $ count i (char ' ') *> p