packages feed

tilia-0.0.1.0: src/Tilia/Project.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}

-- | Finding the project a file belongs to.
--
-- A formatter is usually handed a file, not a project. An editor may run it
-- with the working directory set to the file's own directory, or to the
-- editor's, or to wherever the user happened to start it. None of those is
-- reliably the root, and the root is what the build plan and the package
-- cache are found relative to.
module Tilia.Project
  ( ProjectRoot (..),
    Marker (..),
    markerFile,
    findProjectRoot,
  )
where

import Data.List (isSuffixOf)
import Data.Maybe (listToMaybe)
import System.Directory
  ( canonicalizePath,
    doesDirectoryExist,
    listDirectory,
  )
import System.FilePath (takeDirectory)
import Tilia.Utils (quietly)

-- | A project, and what marked it out.
data ProjectRoot = ProjectRoot
  { -- | The directory.
    prPath :: FilePath,
    -- | What identified it.
    prMarker :: Marker
  }
  deriving (Eq, Show)

-- | What a project was recognised by.
--
-- The two are read differently—one names the packages of a build, the
-- other is a package—so which it was has to survive being found.
data Marker
  = -- | A @cabal.project@, which names the packages.
    ProjectFile
  | -- | A @.cabal@ file, by its name: a package with no project around it.
    PackageFile FilePath
  deriving (Eq, Show)

-- | The file a marker stands for, for reporting.
markerFile :: Marker -> FilePath
markerFile = \case
  ProjectFile -> "cabal.project"
  PackageFile named -> named

-- | Walk upwards from a file or directory looking for a project.
--
-- A @cabal.project@ anywhere above wins over a @.cabal@ file nearer to
-- hand, so that a package inside a multi-package repository resolves to the
-- repository rather than to itself. That is where @cabal@ solves the build
-- and writes the plan, and a run rooted at the package would go looking for
-- a plan that is one directory up.
--
-- Only what @cabal@ reads counts. A @stack.yaml@ is not a project here: it
-- would stop the climb at a directory @cabal@ cannot solve in, whereas
-- passing over it settles on a @.cabal@ that @cabal@ can, which is the
-- difference between formatting a stack project and refusing it.
--
-- Stops at the filesystem root and returns 'Nothing' rather than guessing.
-- Formatting a file that belongs to no project is a perfectly ordinary
-- thing to do; it simply cannot have its fixities resolved.
findProjectRoot :: FilePath -> IO (Maybe ProjectRoot)
findProjectRoot start = quietly Nothing $ do
  from <- startingDirectory
  found <- traverse markersIn (from : ancestorsOf from)
  pure (listToMaybe (concatMap fst found <> concatMap snd found))
  where
    startingDirectory = do
      absolute <- canonicalizePath start
      isDirectory <- doesDirectoryExist absolute
      pure (if isDirectory then absolute else takeDirectory absolute)

    ancestorsOf directory =
      let parent = takeDirectory directory
       in if parent == directory then [] else parent : ancestorsOf parent

    markersIn directory = quietly ([], []) $ do
      entries <- listDirectory directory
      pure
        ( [ProjectRoot directory ProjectFile | "cabal.project" `elem` entries],
          [ ProjectRoot directory (PackageFile named)
          | named <- take 1 (filter (".cabal" `isSuffixOf`) entries)
          ]
        )