diff --git a/build/BibExtras.hs b/build/BibExtras.hs index 77c3120..43235fc 100644 --- a/build/BibExtras.hs +++ b/build/BibExtras.hs @@ -30,6 +30,7 @@ import Data.Char (isAlphaNum, isSpace, toLower) import Data.List (dropWhileEnd) import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map +import System.IO (readFile') -- | Custom fields we extract per citekey. Fields absent from the @@ -63,7 +64,7 @@ firstAuthorSurname extra = case bibAuthor extra of -- | Parse a @.bib@ file; returns a map @citekey -> 'BibExtra'@. parseBibExtras :: FilePath -> IO (Map String BibExtra) -parseBibExtras path = Map.fromList . parseBib <$> readFile path +parseBibExtras path = Map.fromList . parseBib <$> readFile' path -- --------------------------------------------------------------------------- diff --git a/build/Catalog.hs b/build/Catalog.hs index 85039a8..b0225d1 100644 --- a/build/Catalog.hs +++ b/build/Catalog.hs @@ -229,10 +229,10 @@ catalogByCategoryField = field "catalog-by-category" $ \_ -> do grouped = groupBy (\a b -> ceCategory a == ceCategory b) sorted return $ concatMap renderGroup grouped where - -- groupBy on a non-empty list yields non-empty sublists, but pattern - -- matching is total whereas 'head' is not. - renderGroup [] = "" + -- groupBy on a non-empty list yields non-empty sublists, so the + -- (e:_) pattern is structurally guaranteed in this call site. renderGroup g@(e : _) = renderCategorySection (ceCategory e) g + renderGroup [] = "" -- unreachable; satisfies coverage checker musicCatalogCtx :: Context String musicCatalogCtx = diff --git a/build/Stability.hs b/build/Stability.hs index 0094a45..66ce58b 100644 --- a/build/Stability.hs +++ b/build/Stability.hs @@ -107,10 +107,8 @@ daySpan oldest newest = stabilityFromDates :: [String] -> String stabilityFromDates [] = "volatile" stabilityFromDates dates@(newest : _) = - let oldest = case reverse dates of - (x : _) -> x - [] -> newest -- unreachable; matched above - in classify (length dates) (daySpan oldest newest) + -- 'last' is safe: the (newest:_) pattern guarantees non-empty. + classify (length dates) (daySpan (last dates) newest) where classify n age | n <= 1 || age < volatileAge = "volatile" @@ -291,10 +289,10 @@ versionHistoryRangeField :: Context String versionHistoryRangeField = field "version-history-range" $ \item -> do entries <- loadVersionHistory item case entries of - [] -> fail "no version-history range" - [one] -> return (vhDate one) - (newest : more) -> - let oldest = last (newest : more) + [] -> fail "no version-history range" + [one] -> return (vhDate one) + es@(newest:_) -> + let oldest = last es -- safe: es is non-empty by pattern newD = vhDate newest oldD = vhDate oldest in if newD == oldD @@ -318,10 +316,10 @@ versionHistoryRangeEndField = field "version-history-range-end" $ \item -> do entries <- loadVersionHistory item case entries of - [] -> fail "no version-history end" - [_] -> fail "single-day history — no end" - (newest : more) -> - let oldest = last (newest : more) + [] -> fail "no version-history end" + [_] -> fail "single-day history — no end" + es@(newest:_) -> + let oldest = last es -- safe: es is non-empty by pattern in if vhDateIso newest == vhDateIso oldest then fail "single-day history — no end" else return (vhDateIso newest) diff --git a/build/Stats.hs b/build/Stats.hs index 3390428..df9ed28 100644 --- a/build/Stats.hs +++ b/build/Stats.hs @@ -22,6 +22,7 @@ import System.Directory (doesDirectoryExist, getFileSize, listDirector pathIsSymbolicLink) import System.Exit (ExitCode (..)) import System.FilePath (takeExtension, ()) +import System.IO (readFile') import System.Process (readProcessWithExitCode) import Text.Read (readMaybe) import qualified Data.Aeson as Aeson @@ -161,15 +162,12 @@ normUrl u pad2 :: (Show a, Integral a) => a -> String pad2 n = if n < 10 then "0" ++ show n else show n --- | Median of a non-empty list; returns 0 for empty. Uses 'drop' + --- pattern match instead of @(!!)@ so the function is total in its --- own implementation, not just by external invariant. +-- | Median of a non-empty list; returns 0 for empty. median :: [Int] -> Int median [] = 0 -median xs = - case drop (length xs `div` 2) (sort xs) of - (m : _) -> m - [] -> 0 -- unreachable: length xs >= 1 above +median xs = sort xs !! (length xs `div` 2) + -- Index is < length xs for non-empty xs, so '(!!)' is safe here + -- by construction. The empty case is caught by the first equation. -- --------------------------------------------------------------------------- @@ -854,7 +852,7 @@ statsRules tags = do buildTimestamp <- unsafeCompiler $ formatTime defaultTimeLocale "%Y-%m-%d %H:%M UTC" <$> getCurrentTime lastBuildDur <- unsafeCompiler $ - (readFile "data/last-build-seconds.txt" >>= \s -> + (readFile' "data/last-build-seconds.txt" >>= \s -> let secs = fromMaybe 0 (readMaybe (filter (/= '\n') s) :: Maybe Int) in return (show secs ++ "s")) `catch` (\(_ :: IOException) -> return "\x2014") diff --git a/build/Tags.hs b/build/Tags.hs index 159dbc5..951e740 100644 --- a/build/Tags.hs +++ b/build/Tags.hs @@ -175,11 +175,10 @@ portalTooltipField getSidecarId = field "portal-tooltip" $ \item -> do -- "nonfiction" → Nothing -- "a/b/c" → Just "a/b" parentOf :: String -> Maybe String -parentOf t = - let segs = wordsBy (== '/') t - in if length segs > 1 - then Just (intercalate "/" (init segs)) - else Nothing +parentOf t = case wordsBy (== '/') t of + [] -> Nothing + [_] -> Nothing + segs -> Just (intercalate "/" (init segs)) -- 'init' safe: 2+ segments -- | Number of @/@ characters in a tag path (i.e., depth - 1). slashCount :: String -> Int diff --git a/build/Utils.hs b/build/Utils.hs index a412f86..bcf7162 100644 --- a/build/Utils.hs +++ b/build/Utils.hs @@ -19,6 +19,7 @@ module Utils ) where import Data.Char (isAlphaNum, isSpace, toLower) +import Data.List (dropWhileEnd) import qualified Data.Text as T -- | Count the number of words in a string (split on whitespace). @@ -58,7 +59,7 @@ escapeHtmlText = T.concatMap escChar -- | Strip leading and trailing whitespace. trim :: String -> String -trim = dropWhile isSpace . reverse . dropWhile isSpace . reverse +trim = dropWhileEnd isSpace . dropWhile isSpace -- | Lowercase a string, drop everything that isn't alphanumeric or -- space, then replace runs of spaces with single hyphens.