packages feed

okf-core-0.2.0.0: src/Okf/Discovery.hs

-- | Discovery of OKF bundle roots in a directory tree.
--
-- A bundle root is a directory that looks like the top of an OKF bundle: it
-- either holds a reserved @index.md@, or it holds at least one concept
-- document (a non-reserved @.md@ file whose YAML frontmatter carries a
-- non-empty @type@ field). Discovery stops descending as soon as a directory
-- qualifies, so nested subdirectories of a bundle are never reported as
-- bundles of their own.
--
-- Discovery is a convenience for interactive callers, not a validation step:
-- directories that cannot be listed or files that cannot be read are skipped
-- rather than reported as errors.
module Okf.Discovery
  ( DiscoveryOptions (..),
    defaultDiscoveryOptions,
    discoverBundleRoots,
    directoryQualifiesAsBundleRoot,
  )
where

import Control.Exception (IOException, try)
import Control.Monad (filterM)
import Data.List qualified as List
import Data.Text qualified as Text
import Data.Text.IO qualified as Text.IO
import Okf.Bundle (isReservedMarkdownFile)
import Okf.Document (Frontmatter, OKFDocument (..), frontmatterLookup, parseDocument)
import Okf.Prelude
import System.Directory (doesDirectoryExist, listDirectory, pathIsSymbolicLink)
import System.FilePath ((</>))
import System.FilePath qualified as FilePath

-- | How far and where 'discoverBundleRoots' may look.
data DiscoveryOptions = DiscoveryOptions
  { -- | How many directory levels below the search root to inspect. The search
    -- root itself is depth 0, so @maxDepth = 4@ inspects four levels beneath it.
    maxDepth :: !Int,
    -- | Directory names never entered, regardless of depth. Directories whose
    -- name begins with @.@ are always skipped and need no entry here.
    skipDirectories :: ![FilePath]
  }
  deriving stock (Generic, Eq, Show)

-- | Depth four with the usual build-output directories skipped. Depth four is
-- enough to reach a bundle nested a few levels inside a source repository
-- without walking an entire home directory.
defaultDiscoveryOptions :: DiscoveryOptions
defaultDiscoveryOptions =
  DiscoveryOptions
    { maxDepth = 4,
      skipDirectories =
        [ "dist-newstyle",
          "dist",
          "node_modules",
          "target",
          "vendor",
          "_build"
        ]
    }

-- | Bundle roots under a search root, sorted and normalised. The search root
-- itself is a candidate: pointing this at a bundle returns that bundle.
discoverBundleRoots :: DiscoveryOptions -> FilePath -> IO [FilePath]
discoverBundleRoots DiscoveryOptions {maxDepth, skipDirectories} searchRoot =
  List.sort <$> walk 0 searchRoot
  where
    walk depth directory = do
      qualifies <- directoryQualifiesAsBundleRoot directory
      if qualifies
        then pure [FilePath.normalise directory]
        else
          if depth >= maxDepth
            then pure []
            else do
              entries <- listDirectorySafe directory
              subdirectories <-
                filterM
                  (isSearchableDirectory skipDirectories)
                  [directory </> entry | entry <- List.sort entries, not (isHidden entry)]
              concat <$> traverse (walk (depth + 1)) subdirectories

    isHidden entry = case entry of
      ('.' : _) -> True
      _ -> False

-- | Does this directory look like the top of an OKF bundle?
directoryQualifiesAsBundleRoot :: FilePath -> IO Bool
directoryQualifiesAsBundleRoot directory = do
  entries <- listDirectorySafe directory
  if "index.md" `List.elem` entries
    then pure True
    else anyM isConceptDocument [directory </> entry | entry <- entries, isConceptCandidate entry]
  where
    isConceptCandidate entry =
      FilePath.takeExtension entry == ".md" && not (isReservedMarkdownFile entry)

-- | A file is a concept document when it parses and declares a non-empty @type@.
isConceptDocument :: FilePath -> IO Bool
isConceptDocument path = do
  loaded <- try @IOException (Text.IO.readFile path)
  pure $ case loaded of
    Left _ -> False
    Right content -> case parseDocument content of
      Left _ -> False
      Right OKFDocument {frontmatter} -> hasNonEmptyType frontmatter

hasNonEmptyType :: Frontmatter -> Bool
hasNonEmptyType frontmatter =
  case frontmatterLookup "type" frontmatter of
    Just (String value) -> not (Text.null (Text.strip value))
    _ -> False

-- | A directory we may descend into: a real directory, not a symbolic link
-- (which could form a cycle), and not on the skip list.
isSearchableDirectory :: [FilePath] -> FilePath -> IO Bool
isSearchableDirectory skipDirectories path
  | FilePath.takeFileName path `List.elem` skipDirectories = pure False
  | otherwise = do
      isDirectory <- orFalse (doesDirectoryExist path)
      isSymlink <- orFalse (pathIsSymbolicLink path)
      pure (isDirectory && not isSymlink)

listDirectorySafe :: FilePath -> IO [FilePath]
listDirectorySafe directory = do
  listed <- try @IOException (listDirectory directory)
  pure (either (const []) Prelude.id listed)

orFalse :: IO Bool -> IO Bool
orFalse action = either (const False) Prelude.id <$> try @IOException action

anyM :: (a -> IO Bool) -> [a] -> IO Bool
anyM _ [] = pure False
anyM predicate (x : xs) = do
  matched <- predicate x
  if matched then pure True else anyM predicate xs