packages feed

scripths-0.5.0.0: src/ScriptHs/Version.hs

{-# LANGUAGE OverloadedStrings #-}

{- | The scripths version and the first-line /version tag/ that marks a script or
notebook with the scripths version it was written for.

The tag lets scripths warn when a file was authored by a newer release than the
binary running it (so a user knows parsing may be incomplete). It is spelled the
way each file type's comments are spelled:

@
-- scripths: 0.4.1.0          (.ghci \/ .hs)
\<!-- scripths: 0.4.1.0 --\>    (.md \/ .markdown)
@

The emitted version is always 'scripthsVersion', a PVP value (dot-separated
decimals), so the notebook tag's comment text can never contain @--@ or a
trailing @-@ — it is a well-formed HTML comment by construction.
-}
module ScriptHs.Version (
    scripthsVersion,
    scripthsVersionText,
    TagStyle (..),
    tagStyleFor,
    versionTag,
    parseTagLine,
    tagVersion,
    stampVersion,
    newerVersionWarning,
) where

import Data.Char (isAlphaNum, isDigit, isSpace)
import Data.Maybe (fromMaybe, isJust)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Version (Version, makeVersion, showVersion)
import System.FilePath (takeExtension)
import Text.Read (readMaybe)

import Paths_scripths (version)

-- | The running scripths version (single source of truth: the @.cabal@ @version@).
scripthsVersion :: Version
scripthsVersion = version

scripthsVersionText :: Text
scripthsVersionText = T.pack (showVersion scripthsVersion)

-- | How the version tag is spelled in a given file type.
data TagStyle
    = -- | @-- scripths: X@, for @.ghci@\/@.hs@ scripts.
      ScriptTag
    | -- | @\<!-- scripths: X --\>@, for @.md@\/@.markdown@ notebooks.
      NotebookTag
    deriving (Show, Eq)

-- | The tag style for a path, by extension (@.md@\/@.markdown@ are notebooks).
tagStyleFor :: FilePath -> TagStyle
tagStyleFor path
    | ext `elem` [".md", ".markdown"] = NotebookTag
    | otherwise = ScriptTag
  where
    ext = T.unpack (T.toLower (T.pack (takeExtension path)))

-- | The tag line for the current version, in the given style.
versionTag :: TagStyle -> Text
versionTag ScriptTag = "-- scripths: " <> scripthsVersionText
versionTag NotebookTag = "<!-- scripths: " <> scripthsVersionText <> " -->"

{- | The scripths version a single line declares, if it is a version tag of the
given style. Tolerant of a UTF-8 BOM, a trailing @\\r@ (CRLF), trailing
whitespace, and up to three leading spaces. The notebook output marker
@\<!-- scripths:mime … --\>@ is /not/ a version tag (its post-colon token is
@mime@, not a version), so it parses to 'Nothing'.
-}
parseTagLine :: TagStyle -> Text -> Maybe Version
parseTagLine style line = do
    let trimmed = normalizeLine line
    inner <- case style of
        ScriptTag -> T.stripPrefix "-- scripths:" trimmed
        NotebookTag ->
            T.stripPrefix "<!-- scripths:" trimmed
                >>= fmap T.stripEnd . T.stripSuffix "-->" . T.stripEnd
    -- Require whitespace after the colon, so @scripths:mime@ never matches.
    rest <- requireLeadingSpace inner
    parseVersionToken rest

-- | Strip a leading BOM, a trailing @\\r@\/whitespace, and up to three leading spaces.
normalizeLine :: Text -> Text
normalizeLine =
    dropLeadingSpaces (3 :: Int) . T.stripEnd . T.dropWhileEnd (== '\r') . dropBom
  where
    dropBom t = fromMaybe t (T.stripPrefix "\65279" t)
    dropLeadingSpaces 0 t = t
    dropLeadingSpaces n t = case T.uncons t of
        Just (' ', r) -> dropLeadingSpaces (n - 1) r
        _ -> t

requireLeadingSpace :: Text -> Maybe Text
requireLeadingSpace t = case T.uncons t of
    Just (c, _) | isSpace c -> Just (T.stripStart t)
    _ -> Nothing

{- | Parse a leading dotted-decimal version (e.g. @0.4.1.0@), ignoring trailing
text. Components are parsed as 'Integer' and clamped to 'maxBound' so an absurdly
long number cannot silently overflow 'Int' (which could otherwise wrap to a small
value and defeat the newer-than-binary check) — a too-big component stays "newer".
-}
parseVersionToken :: Text -> Maybe Version
parseVersionToken t =
    case T.splitOn "." token of
        parts@(_ : _)
            | not (any T.null parts) ->
                makeVersion . map clampToInt <$> traverse parseComponent parts
        _ -> Nothing
  where
    token = T.takeWhile (\c -> c == '.' || isDigit c) (T.stripStart t)
    parseComponent :: Text -> Maybe Integer
    parseComponent = readMaybe . T.unpack
    clampToInt :: Integer -> Int
    clampToInt = fromInteger . min (toInteger (maxBound :: Int))

{- | The scripths version a file declares, if any. Scans a small top-of-file
window: skip a leading @#!@ shebang (scripts) or a leading @---@ YAML
frontmatter block (notebooks), then read the first non-blank line.
-}
tagVersion :: TagStyle -> Text -> Maybe Version
tagVersion style contents =
    case dropWhile isBlank (snd (splitPreamble style (T.lines contents))) of
        (l : _) -> parseTagLine style l
        [] -> Nothing
  where
    isBlank = T.null . T.strip

{- | Split off a leading preamble that the tag sits /after/: a @#!@ shebang for
scripts, or a @---@…@---@ YAML frontmatter block for notebooks. Returns
@(preamble, rest)@.

A leading @---@ is treated as frontmatter only when the fenced block actually
looks like YAML (it closes with @---@ and contains at least one @key:@ line).
Otherwise the @---@ is an ordinary Markdown /thematic break/ and is left in the
body — so 'stampVersion' inserts the tag /above/ it rather than corrupting the
document by splicing into the middle (the @-i@ data-loss trap).
-}
splitPreamble :: TagStyle -> [Text] -> ([Text], [Text])
splitPreamble ScriptTag ls@(l : rest)
    | "#!" `T.isPrefixOf` T.stripStart l = ([l], rest)
    | otherwise = ([], ls)
splitPreamble NotebookTag ls@(l : rest)
    | T.strip l == "---"
    , (block, closing : afterClosing) <- break ((== "---") . T.strip) rest
    , any looksLikeYamlKey block =
        (l : block ++ [closing], afterClosing)
    | otherwise = ([], ls)
splitPreamble _ [] = ([], [])

{- | Does a line look like a YAML mapping entry (@key:@)? Used to tell a real
frontmatter block from a Markdown thematic break delimited by @---@.
-}
looksLikeYamlKey :: Text -> Bool
looksLikeYamlKey line =
    let (key, afterKey) = T.span (\c -> isAlphaNum c || c == '_' || c == '-') (T.stripStart line)
     in not (T.null key) && ":" `T.isPrefixOf` afterKey

{- | Ensure @contents@ carries the current version tag at the top of its body:
replace an existing tag line in place, or insert one after any shebang\/
frontmatter preamble. Exactly one blank line separates the tag from the body.
Idempotent.
-}
stampVersion :: TagStyle -> Text -> Text
stampVersion style contents =
    rejoin (preamble ++ tag : separator ++ body)
  where
    tag = versionTag style
    (preamble, rest) = splitPreamble style (T.lines contents)
    body = dropWhile isBlankLine (dropExistingTag rest)
    separator = ["" | not (null body)]
    dropExistingTag ls =
        let (_blanks, rest') = span isBlankLine ls
         in case rest' of
                (l : more) | isJustTag l -> more
                _ -> ls
    isJustTag l = isJust (parseTagLine style l)
    isBlankLine = T.null . T.strip

{- | Re-join lines, trimming leading\/trailing blank lines and re-adding a single
trailing newline iff there is content ('T.lines' drops the final newline).
-}
rejoin :: [Text] -> Text
rejoin ls =
    let trimmed = reverse (dropBlank (reverse (dropBlank ls)))
     in if null trimmed then "" else T.intercalate "\n" trimmed <> "\n"
  where
    dropBlank = dropWhile (T.null . T.strip)

-- | @Just@ a warning iff @declared@ is newer than the running scripths.
newerVersionWarning :: Version -> Maybe Text
newerVersionWarning declared
    | declared > scripthsVersion =
        Just $
            "file declares scripths "
                <> T.pack (showVersion declared)
                <> " but you are running "
                <> scripthsVersionText
                <> "; parsing may be incomplete \8212 consider upgrading scripths"
    | otherwise = Nothing