packages feed

okf-core-0.13.0.0: src/Okf/Log.hs

-- | Parsing and rendering for OKF @log.md@ reserved files.
module Okf.Log
  ( Log (..),
    LogDay (..),
    LogEntry (..),
    LogParseError (..),
    LogValidationError (..),
    appendLogEntry,
    insertLogEntry,
    logErrorIsStructural,
    parseLog,
    renderLogDiff,
    serializeLog,
    validateLog,
  )
where

import CMarkGFM qualified
import Data.Char qualified as Char
import Data.List qualified as List
import Data.Text qualified as Text
import Data.Time (Day, defaultTimeLocale, parseTimeM)
import Numeric (showOct)
import Okf.Markdown (markdownOptions)
import Okf.Prelude

-- | One parsed @log.md@ file.
data Log = Log
  { logTitle :: !Text,
    logDays :: ![LogDay]
  }
  deriving stock (Generic, Eq, Show)

-- | One @## YYYY-MM-DD@ date group and its entries.
data LogDay = LogDay
  { logDate :: !Text,
    logEntries :: ![LogEntry]
  }
  deriving stock (Generic, Eq, Show)

-- | One bullet entry under a date group.
data LogEntry = LogEntry
  { logKind :: !(Maybe Text),
    logText :: !Text
  }
  deriving stock (Generic, Eq, Show)

-- | Reserved for future parser failures. CommonMark parsing is total today.
data LogParseError
  = LogNotMarkdown Text
  deriving stock (Generic, Eq, Show)

-- | A structural or advisory problem in one parsed log.
data LogValidationError
  = LogDateNotIso Text
  | LogDaysOutOfOrder Text Text
  | LogEmptyDay Text
  deriving stock (Generic, Eq, Show)

-- | Parse Markdown into the log model. Structural validation is separate.
-- This model omits other Markdown blocks and source formatting; use
-- 'insertLogEntry' to modify an existing file without losing that content.
parseLog :: Text -> Log
parseLog markdown =
  finish (foldl' step emptyBuild topLevelNodes)
  where
    topLevelNodes =
      case CMarkGFM.commonmarkToNode markdownOptions [] markdown of
        CMarkGFM.Node _ _ documentChildren -> documentChildren

-- | Render a log deterministically with a trailing newline.
serializeLog :: Log -> Text
serializeLog Log {logTitle, logDays} =
  ensureTrailingNewline
    ( Text.intercalate
        "\n\n"
        ("# " <> logTitle : fmap renderDay logDays)
    )
  where
    renderDay LogDay {logDate, logEntries} =
      Text.intercalate "\n" ("## " <> logDate : fmap renderEntry logEntries)

    renderEntry LogEntry {logKind = Just kind, logText} =
      "* **" <> kind <> "**: " <> logText
    renderEntry LogEntry {logKind = Nothing, logText} =
      "* " <> logText

-- | Validate the OKF @log.md@ structure and ordering.
validateLog :: Log -> [LogValidationError]
validateLog Log {logDays} =
  foldMap validateDay logDays <> validateOrdering logDays
  where
    validateDay LogDay {logDate, logEntries} =
      [LogDateNotIso logDate | not (isIsoDate logDate)]
        <> [LogEmptyDay logDate | null logEntries]

    validateOrdering days =
      [ LogDaysOutOfOrder earlier later
      | (LogDay earlier _, LogDay later _) <- zip days (drop 1 days),
        isIsoDate earlier,
        isIsoDate later,
        earlier < later
      ]

-- | Whether a log validation error is a hard reserved-file structure error.
logErrorIsStructural :: LogValidationError -> Bool
logErrorIsStructural = \case
  LogDateNotIso _ -> True
  LogEmptyDay _ -> True
  LogDaysOutOfOrder _ _ -> False

data BuildLog = BuildLog
  { buildTitle :: !(Maybe Text),
    buildDaysRev :: ![LogDay],
    buildCurrentDay :: !(Maybe LogDay)
  }

emptyBuild :: BuildLog
emptyBuild =
  BuildLog
    { buildTitle = Nothing,
      buildDaysRev = [],
      buildCurrentDay = Nothing
    }

step :: BuildLog -> CMarkGFM.Node -> BuildLog
step build node@(CMarkGFM.Node _ nodeType childNodes) =
  case nodeType of
    CMarkGFM.HEADING 1 ->
      case buildTitle build of
        Nothing -> build {buildTitle = Just (plainText node)}
        Just _ -> build
    CMarkGFM.HEADING 2 ->
      (pushCurrent build) {buildCurrentDay = Just (LogDay (plainText node) [])}
    CMarkGFM.LIST _ ->
      case buildCurrentDay build of
        Nothing -> build
        Just day ->
          build
            { buildCurrentDay =
                Just day {logEntries = logEntries day <> foldMap logEntriesFromItem childNodes}
            }
    _ -> build

pushCurrent :: BuildLog -> BuildLog
pushCurrent build =
  case buildCurrentDay build of
    Nothing -> build
    Just day -> build {buildDaysRev = day : buildDaysRev build, buildCurrentDay = Nothing}

finish :: BuildLog -> Log
finish build =
  Log
    { logTitle = fromMaybe "" (buildTitle build),
      logDays = reverse (maybe (buildDaysRev build) (: buildDaysRev build) (buildCurrentDay build))
    }

logEntriesFromItem :: CMarkGFM.Node -> [LogEntry]
logEntriesFromItem (CMarkGFM.Node _ CMarkGFM.ITEM childNodes) =
  case inlineNodes childNodes of
    [] -> [LogEntry Nothing ""]
    firstInline : restInline ->
      case firstInline of
        CMarkGFM.Node _ CMarkGFM.STRONG _ ->
          [ LogEntry
              { logKind = Just (plainText firstInline),
                logText = dropKindSeparator (renderInlineNodes restInline)
              }
          | not (Text.null (plainText firstInline))
          ]
        _ ->
          [LogEntry {logKind = Nothing, logText = renderInlineNodes (firstInline : restInline)}]
logEntriesFromItem _ = []

inlineNodes :: [CMarkGFM.Node] -> [CMarkGFM.Node]
inlineNodes [] = []
inlineNodes (CMarkGFM.Node _ CMarkGFM.PARAGRAPH paragraphChildren : rest) =
  paragraphChildren <> inlineNodes rest
inlineNodes (node : rest) =
  node : inlineNodes rest

plainText :: CMarkGFM.Node -> Text
plainText (CMarkGFM.Node _ nodeType childNodes) =
  case nodeType of
    CMarkGFM.TEXT text -> text
    CMarkGFM.CODE text -> text
    CMarkGFM.SOFTBREAK -> "\n"
    CMarkGFM.LINEBREAK -> "\n"
    _ -> foldMap plainText childNodes

renderInlineNodes :: [CMarkGFM.Node] -> Text
renderInlineNodes nodes =
  Text.strip (CMarkGFM.nodeToCommonmark [] Nothing (CMarkGFM.Node Nothing CMarkGFM.PARAGRAPH nodes))

-- | Insert an entry under a date, keeping date groups newest first.
appendLogEntry :: Text -> LogEntry -> Log -> Log
appendLogEntry dateText entry logFile@Log {logDays} =
  logFile {logDays = insertDay logDays}
  where
    newDay = LogDay dateText [entry]

    insertDay [] = [newDay]
    insertDay (day : rest)
      | logDate day == dateText = day {logEntries = entry : logEntries day} : rest
      | dateText > logDate day = newDay : day : rest
      | otherwise = day : insertDay rest

-- | Insert into the original Markdown without rendering historical content.
-- Source positions come from top-level headings, so heading-like text inside
-- code, HTML, lists and block quotes cannot become an insertion boundary.
-- Unsupported or ambiguous structure is rejected before a caller writes it.
insertLogEntry :: Text -> LogEntry -> Text -> Either Text Text
insertLogEntry dateText entry markdown = do
  unless (isIsoDate dateText) (Left "use a real calendar date in YYYY-MM-DD form")
  when (Text.any (== '\r') (Text.replace "\r\n" "" markdown)) $
    Left "bare CR line endings are unsupported; use LF or CRLF"
  title <- case titles of
    [node] -> Right node
    _ -> Left "expected exactly one top-level log title (# heading)"
  titlePosition <- headingPosition title
  days <- for dateHeadings $ \node -> do
    pos <- headingPosition node
    let day = plainText node
    unless (isIsoDate day) (Left ("non-date level-two heading: " <> day))
    unless (CMarkGFM.startLine pos > CMarkGFM.endLine titlePosition) $
      Left "date headings must follow the log title"
    pure (day, pos)
  unless (and (zipWith (>) (fmap fst days) (drop 1 (fmap fst days)))) $
    Left "date headings must be unique and ordered newest first"
  -- Parsing the generated fragment also rejects messages that introduce extra
  -- headings or entries. Compare the complete parsed result below to catch an
  -- insertion swallowed by an unclosed code fence or HTML block at EOF.
  case CMarkGFM.commonmarkToNode markdownOptions [] fragment of
    CMarkGFM.Node _ _ [CMarkGFM.Node _ (CMarkGFM.HEADING 1) _, CMarkGFM.Node _ (CMarkGFM.HEADING 2) _, CMarkGFM.Node _ (CMarkGFM.LIST _) [_]] -> pure ()
    _ -> Left "kind and message must form a single log entry"
  let (offset, insertion) = case List.find ((== dateText) . fst) days of
        Just (_, pos) -> (afterLine (CMarkGFM.endLine pos), renderedEntry)
        Nothing ->
          ( maybe
              (Text.length markdown)
              (beforeLine . CMarkGFM.startLine . snd)
              (List.find ((< dateText) . fst) days),
            "## " <> dateText <> newline <> renderedEntry
          )
      (before, after) = Text.splitAt offset markdown
      separator
        | Text.null before || (newline <> newline) `Text.isSuffixOf` before = ""
        | newline `Text.isSuffixOf` before = newline
        | otherwise = newline <> newline
      result = before <> separator <> insertion <> newline <> (if Text.null after then "" else newline) <> after
      parsedResult = parseLog result
  -- Read the new entry in context: reference-style links may resolve against
  -- definitions in the original Markdown rather than in the isolated fragment.
  insertedEntry <- case [newEntry | LogDay day (newEntry : _) <- logDays parsedResult, day == dateText] of
    [newEntry] -> Right newEntry
    _ -> Left "inserted entry is not visible under its date heading"
  unless (parsedResult == appendLogEntry dateText insertedEntry (parseLog markdown)) $
    Left "cannot safely insert an entry into this Markdown structure"
  pure result
  where
    CMarkGFM.Node _ _ nodes = CMarkGFM.commonmarkToNode markdownOptions [] markdown
    titles = [node | node@(CMarkGFM.Node _ (CMarkGFM.HEADING 1) _) <- nodes]
    dateHeadings = [node | node@(CMarkGFM.Node _ (CMarkGFM.HEADING 2) _) <- nodes]
    headingPosition (CMarkGFM.Node (Just pos) _ _) = Right pos
    headingPosition _ = Left "log heading has no source position"
    newline = if "\r\n" `Text.isInfixOf` markdown then "\r\n" else "\n"
    renderedEntry = case entry of
      LogEntry (Just kind) body -> "* **" <> kind <> "**: " <> body
      LogEntry Nothing body -> "* " <> body
    fragment = "# Log\n\n## " <> dateText <> "\n" <> renderedEntry <> "\n"
    -- Keep CRs and all original line terminators in the slices. Only line
    -- numbers are used; cmark's byte-based columns need no Text conversion.
    afterLine line = min (Text.length markdown) (sum (fmap ((+ 1) . Text.length) (take line (Text.splitOn "\n" markdown))))
    beforeLine line = afterLine (line - 1)

dropKindSeparator :: Text -> Text
dropKindSeparator text =
  Text.stripStart
    ( fromMaybe
        stripped
        (Text.stripPrefix ":" stripped)
    )
  where
    stripped = Text.stripStart text

ensureTrailingNewline :: Text -> Text
ensureTrailingNewline text
  | Text.null text = "\n"
  | "\n" `Text.isSuffixOf` text = text
  | otherwise = text <> "\n"

-- | Show an exact unified diff against the original source, or @/dev/null@
-- for a new file. A single hunk spans every change with up to three context
-- lines at each end. Line terminators (including CRLF and absent final LF)
-- participate in comparison, so formatting changes cannot be hidden.
renderLogDiff :: FilePath -> Maybe Text -> Text -> Text
renderLogDiff path original updated =
  "--- "
    <> maybe "/dev/null" (const displayPath) original
    <> "\n"
    <> "+++ "
    <> displayPath
    <> "\n"
    <> if old == new
      then ""
      else
        "@@ -"
          <> range start (length before + length removed + length after)
          <> " +"
          <> range start (length before + length added + length after)
          <> " @@\n"
          <> foldMap (renderLine " ") before
          <> foldMap (renderLine "-") removed
          <> foldMap (renderLine "+") added
          <> foldMap (renderLine " ") after
  where
    -- Quote unusual path characters using Git's quoted-path convention.
    displayPath
      | any (\c -> c == '"' || c == '\\' || Char.isControl c) path = "\"" <> Text.concatMap quoteChar (Text.pack path) <> "\""
      | otherwise = Text.pack path
    quoteChar '"' = "\\\""
    quoteChar '\\' = "\\\\"
    quoteChar c
      | Char.ord c < 32 || Char.ord c == 127 =
          let octal = showOct (Char.ord c) ""
           in "\\" <> Text.pack (replicate (3 - length octal) '0' <> octal)
      | otherwise = Text.singleton c
    old = sourceLines (fromMaybe "" original)
    new = sourceLines updated
    prefixLength = length (takeWhile (uncurry (==)) (zip old new))
    oldRest = drop prefixLength old
    newRest = drop prefixLength new
    suffixLength = length (takeWhile (uncurry (==)) (zip (reverse oldRest) (reverse newRest)))
    before = drop (max 0 (prefixLength - 3)) (take prefixLength old)
    after = take 3 (drop (length oldRest - suffixLength) oldRest)
    removed = take (length oldRest - suffixLength) oldRest
    added = take (length newRest - suffixLength) newRest
    start = prefixLength - length before
    range offset count = Text.pack (show (if count == 0 then offset else offset + 1)) <> "," <> Text.pack (show count)
    renderLine marker line = marker <> line <> if "\n" `Text.isSuffixOf` line then "" else "\n\\ No newline at end of file\n"
    sourceLines text
      | Text.null text = []
      | otherwise =
          let (line, rest) = Text.breakOn "\n" text
           in if Text.null rest then [line] else (line <> "\n") : sourceLines (Text.drop 1 rest)

isIsoDate :: Text -> Bool
isIsoDate value =
  Text.length value == 10
    && Text.index value 4 == '-'
    && Text.index value 7 == '-'
    && Text.all Char.isDigit (Text.take 4 value)
    && Text.all Char.isDigit (Text.take 2 (Text.drop 5 value))
    && Text.all Char.isDigit (Text.drop 8 value)
    && isJust parsed
  where
    parsed :: Maybe Day
    parsed = parseTimeM True defaultTimeLocale "%Y-%m-%d" (Text.unpack value)