packages feed

seihou-core-0.4.0.0: src/Seihou/Engine/Template.hs

module Seihou.Engine.Template
  ( renderTemplate,
    renderTemplateText,
    expandConditionals,
    extractIfExprs,
    valueToText,
    renderDestPath,
    renderCommand,
  )
where

import Data.Map.Strict qualified as Map
import Data.Text qualified as T
import Seihou.Core.Expr (evalExpr, parseExpr)
import Seihou.Core.Types
import Seihou.Prelude

-- | Convert a variable value to its text representation for template output.
valueToText :: VarValue -> Text
valueToText (VText t) = t
valueToText (VBool True) = "true"
valueToText (VBool False) = "false"
valueToText (VInt n) = T.pack (show n)
valueToText (VList vs) = T.intercalate "," (map valueToText vs)

-- | Render a template by substituting @{{placeholder}}@ occurrences.
-- The escape sequence @\\{{@ produces a literal @{{@ in the output.
-- Returns either a list of errors or the rendered text.
renderTemplate :: Text -> Map VarName VarValue -> Either [PlaceholderError] Text
renderTemplate template vars =
  let lns = T.splitOn "\n" template
      results = zipWith (renderLine vars) [1 ..] lns
      (allErrors, renderedLines) = partitionResults results
   in if null allErrors
        then Right (T.intercalate "\n" renderedLines)
        else Left (concat allErrors)

-- | Render a template body supporting both @{{placeholder}}@ substitution
-- and @{{#if}}\/{{#else}}\/{{\/if}}@ conditional blocks with unbounded
-- nesting. Expression syntax inside @{{#if …}}@ is the same grammar used
-- by a step's @when@ clause ('Seihou.Core.Expr.parseExpr').
--
-- Runs in two passes: 'expandConditionals' consumes block tokens and
-- emits plain template text, then 'renderTemplate' performs @{{var}}@
-- substitution on the expanded text.
--
-- Intended for template bodies only. Destination paths and shell
-- commands stay on 'renderDestPath' \/ 'renderCommand'.
renderTemplateText :: Text -> Map VarName VarValue -> Either [PlaceholderError] Text
renderTemplateText template vars =
  case expandConditionals vars template of
    Left errs -> Left errs
    Right expanded -> renderTemplate expanded vars

-- | Extract the raw expression text from every @{{#if …}}@ opener in a
-- template body, in document order. Used by authoring-time lint to scan
-- template conditionals without expanding them; each returned string is
-- intended to be fed to 'parseExpr'. Nesting is irrelevant here — every
-- opener is reported, regardless of depth. A malformed (unterminated)
-- opener stops the scan, mirroring 'splitNextBlock'.
extractIfExprs :: Text -> [Text]
extractIfExprs = go
  where
    go t = case T.breakOn "{{#if " t of
      (_, "") -> []
      (_, match) ->
        let afterOpen = T.drop 6 match -- skip "{{#if "
         in case T.breakOn "}}" afterOpen of
              (_, "") -> [] -- unterminated opener; stop
              (exprRaw, rest) -> T.strip exprRaw : go (T.drop 2 rest)

-- | Render destination path placeholders (same substitution logic).
renderDestPath :: Text -> Map VarName VarValue -> Either [PlaceholderError] Text
renderDestPath = renderTemplate

-- | Render placeholders in a shell command string.
-- Same substitution as 'renderTemplate' but named for clarity at call sites.
renderCommand :: Text -> Map VarName VarValue -> Either [PlaceholderError] Text
renderCommand = renderTemplate

-- | Render a single line, returning errors or the rendered text.
renderLine :: Map VarName VarValue -> Int -> Text -> Either [PlaceholderError] Text
renderLine vars lineNum line =
  case scanLine vars lineNum line of
    ([], rendered) -> Right rendered
    (errs, _) -> Left errs

-- | Scan a line for placeholders and substitute them.
-- Returns accumulated errors and the rendered text.
scanLine :: Map VarName VarValue -> Int -> Text -> ([PlaceholderError], Text)
scanLine vars lineNum = go
  where
    go :: Text -> ([PlaceholderError], Text)
    go txt
      | T.null txt = ([], "")
      | "\\{{" `T.isPrefixOf` txt =
          -- Escape sequence: produce literal {{
          let (errs, rest) = go (T.drop 3 txt)
           in (errs, "{{" <> rest)
      | "{{" `T.isPrefixOf` txt =
          -- Placeholder: find the closing }}
          let after = T.drop 2 txt
           in case T.breakOn "}}" after of
                (_, "") ->
                  -- No closing }}, treat as malformed
                  let raw = T.take 20 txt
                      (errs, rest) = go (T.drop 2 txt)
                   in (MalformedPlaceholder raw lineNum : errs, rest)
                (varNameText, remaining) ->
                  let trimmed = T.strip varNameText
                      name = VarName trimmed
                      restAfter = T.drop 2 remaining -- skip the }}
                   in case Map.lookup name vars of
                        Just val ->
                          let (errs, rest) = go restAfter
                           in (errs, valueToText val <> rest)
                        Nothing ->
                          let (errs, rest) = go restAfter
                           in (UnresolvedPlaceholder name lineNum : errs, rest)
      | otherwise =
          -- Find the next {{ or \{{ occurrence
          case findNextPlaceholder txt of
            Nothing -> ([], txt)
            Just idx ->
              let (before, after) = T.splitAt idx txt
                  (errs, rest) = go after
               in (errs, before <> rest)

-- | Find the index of the next placeholder start (@{{@ or @\\{{@).
findNextPlaceholder :: Text -> Maybe Int
findNextPlaceholder txt = go 0 txt
  where
    go :: Int -> Text -> Maybe Int
    go idx t
      | T.null t = Nothing
      | "\\{{" `T.isPrefixOf` t = Just idx
      | "{{" `T.isPrefixOf` t = Just idx
      | otherwise = go (idx + 1) (T.drop 1 t)

-- | Partition a list of Either into errors and successes.
partitionResults :: [Either e a] -> ([e], [a])
partitionResults = foldr step ([], [])
  where
    step (Left e) (errs, oks) = (e : errs, oks)
    step (Right a) (errs, oks) = (errs, a : oks)

-- | First-pass expander: consume @{{#if}}\/{{#else}}\/{{\/if}}@ block
-- tokens and emit plain template text with only the selected branches
-- retained. Supports arbitrary nesting depth.
--
-- Exported for test access; in production code the recommended entry
-- point is 'renderTemplateText'.
expandConditionals :: Map VarName VarValue -> Text -> Either [PlaceholderError] Text
expandConditionals vars = expandAtTopLevel vars 1

-- | Expand at the top level: produce text until we hit @{{\/if}}@ or
-- @{{#else}}@ (which at top level are orphans). Only the selected
-- branch is expanded; the untaken branch is discarded, so errors
-- inside it do not surface.
--
-- When a block tag is the only non-whitespace on its line (a
-- \"standalone block\" in the Mustache/Handlebars sense), the
-- surrounding indentation and the line\'s terminating newline are
-- consumed as part of the tag so the expanded text does not leave a
-- blank line behind. This is what lets module authors format a
-- template with tags on their own lines and still get clean output.
expandAtTopLevel :: Map VarName VarValue -> Int -> Text -> Either [PlaceholderError] Text
expandAtTopLevel vars startLine input =
  case splitNextBlock input of
    NoBlockLeft -> Right input
    FoundIf before0 afterBlockOpen0 exprText ->
      let beforeLines = T.count "\n" before0
          openAt = startLine + beforeLines
          (before, afterBlockOpen, openerSkipped) =
            trimStandaloneAround before0 afterBlockOpen0
          bodyStart = openAt + openerSkipped
       in case parseExpr exprText of
            Left parseErr ->
              Left [MalformedIfExpression exprText openAt parseErr]
            Right expr -> do
              (thenText, elseText, afterBlock, consumedLines, closerSkipped) <-
                splitBranches openAt afterBlockOpen
              let selectedRaw =
                    if evalExpr vars expr then thenText else elseText
              selectedExpanded <- expandAtTopLevel vars bodyStart selectedRaw
              let afterLine = openAt + openerSkipped + consumedLines + closerSkipped
              rest <- expandAtTopLevel vars afterLine afterBlock
              pure (before <> selectedExpanded <> rest)
    FoundOrphan tok beforeLines ->
      Left [OrphanBlockToken tok (startLine + beforeLines)]

-- | Raw tokenisation result for the top-level scan.
data NextBlock
  = -- | No block tokens in the remaining input.
    NoBlockLeft
  | -- | An @{{#if …}}@ block. @before@ is text preceding the opener;
    -- @after@ is everything after the closing @}}@ of the opener
    -- (i.e. the body plus whatever follows the matching @{{/if}}@);
    -- @expr@ is the raw expression text.
    FoundIf
      { foundBefore :: Text,
        foundAfter :: Text,
        foundExpr :: Text
      }
  | -- | A @{{#else}}@ or @{{/if}}@ encountered before any matching
    -- @{{#if}}@ at the current depth. The 'Int' is the line offset
    -- (0-based) from the start of the scanned region.
    FoundOrphan Text Int

-- | Scan @input@ for the next block token at the outer level (i.e. for
-- the purpose of locating the next @{{#if}}@ opener, or an orphan if
-- one occurs first).
splitNextBlock :: Text -> NextBlock
splitNextBlock input = scan 0 input
  where
    scan :: Int -> Text -> NextBlock
    scan pos t
      | T.null t = NoBlockLeft
      | "{{#if " `T.isPrefixOf` t =
          let before = T.take pos input
              afterOpen = T.drop 6 t -- skip "{{#if "
           in case T.breakOn "}}" afterOpen of
                (_, "") -> NoBlockLeft -- malformed; let higher-level handle
                (exprRaw, rest) ->
                  let expr = T.strip exprRaw
                      afterCloseTag = T.drop 2 rest -- skip "}}"
                   in FoundIf
                        { foundBefore = before,
                          foundAfter = afterCloseTag,
                          foundExpr = expr
                        }
      | "{{/if}}" `T.isPrefixOf` t =
          FoundOrphan "{{/if}}" (lineOffset (T.take pos input))
      | "{{#else}}" `T.isPrefixOf` t =
          FoundOrphan "{{#else}}" (lineOffset (T.take pos input))
      | otherwise =
          let headChar = T.take 1 t
              rest = T.drop 1 t
           in scan (pos + T.length headChar) rest

-- | Given the text immediately after an @{{#if …}}@ opener, find the
-- matching @{{\/if}}@ (honouring nested @{{#if}}@\/@{{\/if}}@ pairs) and
-- split the body at a top-level @{{#else}}@ if one is present.
--
-- Returns @(thenBranch, elseBranch, afterCloseBlock, linesConsumedUpToAndIncludingClose, closerSkipped)@.
-- The @openLine@ parameter is the absolute source-line number of the
-- opener, used only when reporting 'UnterminatedIf'.
--
-- Standalone-block trim is applied to the top-level @{{#else}}@ (if
-- present) and to the matching @{{\/if}}@. @closerSkipped@ is 1 when
-- the closer\'s trailing newline was absorbed by standalone trim and
-- 0 otherwise; callers add it to their own post-block line counter
-- so chained blocks at the same level report accurate line numbers.
splitBranches ::
  Int ->
  Text ->
  Either [PlaceholderError] (Text, Text, Text, Int, Int)
splitBranches openLine bodyPlusTail = go 0 0 Nothing bodyPlusTail
  where
    -- depth: how many extra {{#if}} openers we've seen since the outer opener.
    -- accLen: how much of @bodyPlusTail@ we've consumed (character count).
    -- mElseAt: @Just accLen@ at the first top-level {{#else}}, if any.
    go :: Int -> Int -> Maybe Int -> Text -> Either [PlaceholderError] (Text, Text, Text, Int, Int)
    go _ _ _ t
      | T.null t = Left [UnterminatedIf openLine]
    go depth accLen mElseAt t
      | "{{/if}}" `T.isPrefixOf` t && depth == 0 =
          let (thenText0, elseText0) = case mElseAt of
                Nothing -> (T.take accLen bodyPlusTail, "")
                Just elseAt ->
                  ( T.take elseAt bodyPlusTail,
                    T.take (accLen - elseAt - elseTokenLen) (T.drop (elseAt + elseTokenLen) bodyPlusTail)
                  )
              afterClose0 = T.drop (accLen + ifCloseLen) bodyPlusTail
              consumedLines = lineOffset (T.take (accLen + ifCloseLen) bodyPlusTail)
              -- Trim standalone closer: the line's whitespace-before
              -- lives at the tail of whichever branch ended at it
              -- (elseText0 if present, otherwise thenText0), and the
              -- whitespace+newline-after lives at the head of afterClose0.
              (thenText, elseText, afterClose, closerSkipped) =
                case mElseAt of
                  Nothing ->
                    let (tt, ac, n) = trimStandaloneAround thenText0 afterClose0
                     in (tt, "", ac, n)
                  Just _ ->
                    -- First try to trim standalone {{#else}} between
                    -- thenText and elseText, then trim the closer.
                    let (tt, et0, _elseSkipped) = trimStandaloneAround thenText0 elseText0
                        (et, ac, n) = trimStandaloneAround et0 afterClose0
                     in (tt, et, ac, n)
           in Right (thenText, elseText, afterClose, consumedLines, closerSkipped)
      | "{{/if}}" `T.isPrefixOf` t =
          advance (depth - 1) accLen mElseAt t ifCloseLen
      | "{{#if " `T.isPrefixOf` t =
          advance (depth + 1) accLen mElseAt t ifOpenPrefixLen
      | "{{#else}}" `T.isPrefixOf` t && depth == 0 =
          let newElseAt = case mElseAt of
                Just existing -> Just existing
                Nothing -> Just accLen
           in advance depth accLen newElseAt t elseTokenLen
      | "{{#else}}" `T.isPrefixOf` t =
          advance depth accLen mElseAt t elseTokenLen
      | otherwise =
          advance depth accLen mElseAt t 1

    advance ::
      Int ->
      Int ->
      Maybe Int ->
      Text ->
      Int ->
      Either [PlaceholderError] (Text, Text, Text, Int, Int)
    advance depth accLen mElseAt t n =
      let step = T.take n t
          rest = T.drop n t
       in go depth (accLen + T.length step) mElseAt rest

    ifOpenPrefixLen = T.length ("{{#if " :: Text)
    ifCloseLen = T.length ("{{/if}}" :: Text)
    elseTokenLen = T.length ("{{#else}}" :: Text)

-- | Number of newlines in @t@; used to compute line offsets from
-- byte positions.
lineOffset :: Text -> Int
lineOffset = T.count "\n"

-- | Standalone-block trim: if the tag sitting between @before@ and
-- @after@ is the only non-whitespace content on its line, strip the
-- surrounding indentation and the trailing newline. Returns the
-- trimmed pair and the number of newlines the trim absorbed (0 or 1)
-- so the caller can keep source-line tracking accurate.
--
-- A tag qualifies as standalone iff:
--
-- * the tail of @before@ since the last newline (or since the start
--   of @before@ when there is no prior newline) consists only of
--   spaces and tabs, AND
-- * the head of @after@ up to and including the next newline consists
--   only of spaces and tabs followed by a newline — OR @after@ ends
--   before any newline appears and contains only spaces and tabs
--   (i.e. EOF with trailing whitespace).
trimStandaloneAround :: Text -> Text -> (Text, Text, Int)
trimStandaloneAround before after =
  case (stripTailWhitespace before, stripHeadWhitespaceNewline after) of
    (Just b', Just (a', consumed)) -> (b', a', consumed)
    _ -> (before, after, 0)

-- | Strip trailing spaces and tabs at the end of @t@ if the suffix
-- since the last newline (or from the start of @t@) contains only
-- whitespace. Returns 'Nothing' otherwise, so the caller can fall
-- back to the untrimmed text.
stripTailWhitespace :: Text -> Maybe Text
stripTailWhitespace t =
  let (beforeLastLine, lastLine) = T.breakOnEnd "\n" t
   in if T.all isSpaceOrTab lastLine
        then Just beforeLastLine
        else Nothing

-- | Strip leading spaces and tabs at the start of @t@ followed by a
-- single newline. Returns the remainder paired with the number of
-- newlines consumed (always 0 or 1). EOF after trailing whitespace
-- counts as a standalone match with 0 newlines consumed.
stripHeadWhitespaceNewline :: Text -> Maybe (Text, Int)
stripHeadWhitespaceNewline t =
  let (_ws, rest) = T.span isSpaceOrTab t
   in if T.null rest
        then Just ("", 0)
        else case T.uncons rest of
          Just ('\n', after) -> Just (after, 1)
          _ -> Nothing

isSpaceOrTab :: Char -> Bool
isSpaceOrTab c = c == ' ' || c == '\t'