packages feed

seihou-core-0.4.0.0: test/Seihou/Engine/TemplateSpec.hs

module Seihou.Engine.TemplateSpec (tests) where

import Data.Map.Strict qualified as Map
import Seihou.Core.Types
import Seihou.Engine.Template
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

tests :: IO TestTree
tests = testSpec "Seihou.Engine.Template" spec

spec :: Spec
spec = do
  describe "valueToText" $ do
    it "converts VText" $ do
      valueToText (VText "hello") `shouldBe` "hello"

    it "converts VBool True" $ do
      valueToText (VBool True) `shouldBe` "true"

    it "converts VBool False" $ do
      valueToText (VBool False) `shouldBe` "false"

    it "converts VInt" $ do
      valueToText (VInt 42) `shouldBe` "42"

    it "converts VList" $ do
      valueToText (VList [VText "a", VText "b", VText "c"]) `shouldBe` "a,b,c"

  describe "renderTemplate" $ do
    it "substitutes a simple placeholder" $ do
      let vars = Map.fromList [("name", VText "world")]
      renderTemplate "Hello, {{name}}!" vars `shouldBe` Right "Hello, world!"

    it "substitutes multiple placeholders on one line" $ do
      let vars = Map.fromList [("first", VText "Jane"), ("last", VText "Doe")]
      renderTemplate "Name: {{first}} {{last}}" vars
        `shouldBe` Right "Name: Jane Doe"

    it "substitutes placeholders across multiple lines" $ do
      let vars = Map.fromList [("project.name", VText "my-app"), ("project.version", VText "0.1.0.0")]
      renderTemplate "# {{project.name}}\nVersion: {{project.version}}" vars
        `shouldBe` Right "# my-app\nVersion: 0.1.0.0"

    it "handles the README fixture pattern" $ do
      let vars = Map.fromList [("project.name", VText "my-app"), ("project.version", VText "0.1.0.0")]
      renderTemplate "# {{project.name}}\n\nVersion: {{project.version}}\n" vars
        `shouldBe` Right "# my-app\n\nVersion: 0.1.0.0\n"

    it "passes through text with no placeholders" $ do
      let vars = Map.empty
      renderTemplate "No placeholders here." vars
        `shouldBe` Right "No placeholders here."

    it "handles empty template" $ do
      let vars = Map.empty
      renderTemplate "" vars `shouldBe` Right ""

    it "handles escape sequence" $ do
      let vars = Map.empty
      renderTemplate "Use \\{{var}} for placeholders" vars
        `shouldBe` Right "Use {{var}} for placeholders"

    it "handles escape sequence with real placeholder" $ do
      let vars = Map.fromList [("name", VText "world")]
      renderTemplate "Literal \\{{not.a.var}} and {{name}}" vars
        `shouldBe` Right "Literal {{not.a.var}} and world"

    it "coerces VBool to text in placeholder" $ do
      let vars = Map.fromList [("enabled", VBool True)]
      renderTemplate "Enabled: {{enabled}}" vars
        `shouldBe` Right "Enabled: true"

    it "coerces VInt to text in placeholder" $ do
      let vars = Map.fromList [("count", VInt 42)]
      renderTemplate "Count: {{count}}" vars
        `shouldBe` Right "Count: 42"

    it "coerces VList to text in placeholder" $ do
      let vars = Map.fromList [("items", VList [VText "a", VText "b"])]
      renderTemplate "Items: {{items}}" vars
        `shouldBe` Right "Items: a,b"

    it "reports unresolved placeholder with correct line number" $ do
      let vars = Map.empty
      case renderTemplate "line one\n{{missing}}\nline three" vars of
        Left errs -> do
          length errs `shouldBe` 1
          case errs of
            (UnresolvedPlaceholder name lineNum : _) -> do
              name `shouldBe` "missing"
              lineNum `shouldBe` 2
            other -> expectationFailure ("Unexpected error: " <> show other)
        Right _ -> expectationFailure "Expected Left"

    it "reports multiple unresolved placeholders" $ do
      let vars = Map.empty
      case renderTemplate "{{a}}\n{{b}}" vars of
        Left errs -> length errs `shouldBe` 2
        Right _ -> expectationFailure "Expected Left"

    it "strips whitespace around placeholder name" $ do
      let vars = Map.fromList [("name", VText "world")]
      renderTemplate "{{ name }}" vars `shouldBe` Right "world"

  describe "renderCommand" $ do
    it "substitutes variables in a command string" $ do
      let vars = Map.fromList [("project.name", VText "my-app")]
      renderCommand "echo {{project.name}}" vars
        `shouldBe` Right "echo my-app"

    it "substitutes multiple placeholders" $ do
      let vars = Map.fromList [("name", VText "app"), ("ver", VText "1.0")]
      renderCommand "echo {{name}}-{{ver}}" vars
        `shouldBe` Right "echo app-1.0"

    it "handles escape sequence" $ do
      let vars = Map.empty
      renderCommand "echo \\{{literal}}" vars
        `shouldBe` Right "echo {{literal}}"

    it "reports unresolved placeholder" $ do
      let vars = Map.empty
      case renderCommand "echo {{missing}}" vars of
        Left errs -> length errs `shouldBe` 1
        Right _ -> expectationFailure "Expected Left"

  describe "renderDestPath" $ do
    it "expands placeholder in destination path" $ do
      let vars = Map.fromList [("project.name", VText "my-app")]
      renderDestPath "{{project.name}}.cabal" vars
        `shouldBe` Right "my-app.cabal"

    it "handles path with subdirectory and placeholder" $ do
      let vars = Map.fromList [("project.name", VText "my-app")]
      renderDestPath "src/{{project.name}}/Main.hs" vars
        `shouldBe` Right "src/my-app/Main.hs"

    it "passes through path with no placeholders" $ do
      let vars = Map.empty
      renderDestPath "src/Lib.hs" vars `shouldBe` Right "src/Lib.hs"

  describe "renderTemplateText" $ do
    it "behaves like renderTemplate on templates with no blocks" $ do
      let vars = Map.fromList [("project.name", VText "my-app")]
          tpl = "# {{project.name}}\nVersion: 0.1.0.0\n"
      renderTemplateText tpl vars
        `shouldBe` renderTemplate tpl vars

    it "selects the then-branch when the if expression is true" $ do
      let vars = Map.fromList [("x", VBool True)]
      renderTemplateText "{{#if Eq x true}}A{{/if}}" vars
        `shouldBe` Right "A"

    it "excludes the then-branch when the if expression is false" $ do
      let vars = Map.fromList [("x", VBool False)]
      renderTemplateText "{{#if Eq x true}}A{{/if}}" vars
        `shouldBe` Right ""

    it "selects the then-branch of an if/else when the variable is set" $ do
      let vars = Map.fromList [("foo", VText "bar")]
      renderTemplateText "{{#if IsSet foo}}yes{{#else}}no{{/if}}" vars
        `shouldBe` Right "yes"

    it "selects the else-branch of an if/else when the variable is unset" $ do
      let vars = Map.empty
      renderTemplateText "{{#if IsSet foo}}yes{{#else}}no{{/if}}" vars
        `shouldBe` Right "no"

    it "handles two-level nesting with both branches taken" $ do
      let vars = Map.fromList [("a", VBool True), ("b", VBool True)]
      renderTemplateText
        "{{#if Eq a true}}outer{{#if Eq b true}}inner{{/if}}outer2{{/if}}"
        vars
        `shouldBe` Right "outerinnerouter2"

    it "handles three-level nesting" $ do
      let vars = Map.fromList [("a", VBool True), ("b", VBool True), ("c", VBool True)]
      renderTemplateText
        "{{#if Eq a true}}{{#if Eq b true}}{{#if Eq c true}}deep{{/if}}{{/if}}{{/if}}"
        vars
        `shouldBe` Right "deep"

    it "reports UnterminatedIf with the opener's source line" $ do
      let tpl = "line one\nline two\n{{#if Eq x true}}never closed\nline four\n"
          vars = Map.fromList [("x", VBool True)]
      case renderTemplateText tpl vars of
        Right _ -> expectationFailure "expected UnterminatedIf"
        Left [UnterminatedIf lineNum] -> lineNum `shouldBe` 3
        Left other -> expectationFailure ("expected [UnterminatedIf 3], got " <> show other)

    it "reports orphan {{/if}} at top level" $ do
      let tpl = "a\nb\n{{/if}}\n"
          vars = Map.empty
      case renderTemplateText tpl vars of
        Right _ -> expectationFailure "expected OrphanBlockToken"
        Left [OrphanBlockToken tok lineNum] -> do
          tok `shouldBe` "{{/if}}"
          lineNum `shouldBe` 3
        Left other ->
          expectationFailure ("expected [OrphanBlockToken \"{{/if}}\" 3], got " <> show other)

    it "reports orphan {{#else}} at top level" $ do
      let tpl = "a\n{{#else}}\n"
          vars = Map.empty
      case renderTemplateText tpl vars of
        Right _ -> expectationFailure "expected OrphanBlockToken"
        Left [OrphanBlockToken tok lineNum] -> do
          tok `shouldBe` "{{#else}}"
          lineNum `shouldBe` 2
        Left other ->
          expectationFailure ("expected [OrphanBlockToken \"{{#else}}\" 2], got " <> show other)

    it "reports MalformedIfExpression with the opener line and parser error" $ do
      let tpl = "ok\n{{#if &&garbage}}body{{/if}}"
          vars = Map.empty
      case renderTemplateText tpl vars of
        Right _ -> expectationFailure "expected MalformedIfExpression"
        Left [MalformedIfExpression rawExpr lineNum _parserErr] -> do
          rawExpr `shouldBe` "&&garbage"
          lineNum `shouldBe` 2
        Left other ->
          expectationFailure ("expected [MalformedIfExpression …], got " <> show other)

    it "substitutes {{var}} inside the taken branch and surfaces unresolved errors" $ do
      let vars = Map.fromList [("gate", VBool True)]
      case renderTemplateText "{{#if Eq gate true}}hello {{name}}{{/if}}" vars of
        Right _ -> expectationFailure "expected UnresolvedPlaceholder in taken branch"
        Left [UnresolvedPlaceholder (VarName nm) _] -> nm `shouldBe` "name"
        Left other -> expectationFailure ("unexpected errors: " <> show other)

    it "discards untaken branches so their {{var}} errors do not surface" $ do
      let vars = Map.fromList [("gate", VBool False)]
      renderTemplateText "before {{#if Eq gate true}}hello {{missing}}{{/if}}after" vars
        `shouldBe` Right "before after"

    it "evaluates && combinator: both true takes the branch" $ do
      let vars = Map.fromList [("a", VBool True), ("b", VBool True)]
      renderTemplateText "{{#if Eq a true && Eq b true}}Y{{#else}}N{{/if}}" vars
        `shouldBe` Right "Y"

    it "evaluates && combinator: one false drops the branch" $ do
      let vars = Map.fromList [("a", VBool True), ("b", VBool False)]
      renderTemplateText "{{#if Eq a true && Eq b true}}Y{{#else}}N{{/if}}" vars
        `shouldBe` Right "N"

    it "evaluates || combinator: either true takes the branch" $ do
      let vars = Map.fromList [("a", VBool False), ("b", VBool True)]
      renderTemplateText "{{#if Eq a true || Eq b true}}Y{{#else}}N{{/if}}" vars
        `shouldBe` Right "Y"

    it "evaluates ! combinator: negation flips the branch" $ do
      let vars = Map.fromList [("a", VBool False)]
      renderTemplateText "{{#if !Eq a true}}Y{{#else}}N{{/if}}" vars
        `shouldBe` Right "Y"

    it "evaluates a parenthesised expression honouring precedence" $ do
      -- (A || B) && !C — with A=false, B=true, C=false → (true) && true = true
      let vars =
            Map.fromList
              [ ("a", VBool False),
                ("b", VBool True),
                ("c", VBool False)
              ]
      renderTemplateText
        "{{#if (Eq a true || Eq b true) && !Eq c true}}Y{{#else}}N{{/if}}"
        vars
        `shouldBe` Right "Y"

    it "matches outer {{#else}} past a nested {{#else}} inside the then-branch" $ do
      -- The outer if's else is "e"; the inner if (B) has its own else "b2".
      -- splitBranches must skip the inner {{#else}} (it's at depth 1) and
      -- only mark the outer {{#else}} (at depth 0).
      let mkVars a b =
            Map.fromList [("a", VBool a), ("b", VBool b)]
      renderTemplateText
        "{{#if Eq a true}}a1{{#if Eq b true}}b1{{#else}}b2{{/if}}a2{{#else}}e{{/if}}"
        (mkVars True True)
        `shouldBe` Right "a1b1a2"
      renderTemplateText
        "{{#if Eq a true}}a1{{#if Eq b true}}b1{{#else}}b2{{/if}}a2{{#else}}e{{/if}}"
        (mkVars True False)
        `shouldBe` Right "a1b2a2"
      renderTemplateText
        "{{#if Eq a true}}a1{{#if Eq b true}}b1{{#else}}b2{{/if}}a2{{#else}}e{{/if}}"
        (mkVars False True)
        `shouldBe` Right "e"

    it "surfaces an UnterminatedIf from a nested block inside the taken branch" $ do
      -- Outer if takes the then-branch; the inner {{#if B}} has no closing
      -- token before the outer {{/if}}, so splitBranches consumes the outer
      -- {{/if}} as the inner's match and reports the outer opener as
      -- unterminated. The precise error is UnterminatedIf; line is the
      -- opener that actually ran out of closers.
      let vars = Map.fromList [("a", VBool True), ("b", VBool True)]
          tpl = "{{#if Eq a true}}outer{{#if Eq b true}}inner{{/if}}"
      case renderTemplateText tpl vars of
        Right r -> expectationFailure ("expected UnterminatedIf, got " <> show r)
        Left [UnterminatedIf _] -> pure ()
        Left other ->
          expectationFailure ("expected [UnterminatedIf _], got " <> show other)

    it "reports UnterminatedIf on the opener line for a long preamble" $ do
      -- Preamble of ten lines; opener on line 11; no {{/if}} at all.
      let preamble = mconcat (replicate 10 "line\n")
          tpl = preamble <> "{{#if Eq x true}}body\nmore body\n"
          vars = Map.fromList [("x", VBool True)]
      case renderTemplateText tpl vars of
        Right _ -> expectationFailure "expected UnterminatedIf"
        Left [UnterminatedIf lineNum] -> lineNum `shouldBe` 11
        Left other -> expectationFailure ("expected [UnterminatedIf 11], got " <> show other)

    it "handles an empty then-branch" $ do
      let vars = Map.fromList [("x", VBool True)]
      renderTemplateText "a{{#if Eq x true}}{{/if}}b" vars
        `shouldBe` Right "ab"

    it "handles an if/else with both branches empty" $ do
      let vars = Map.fromList [("x", VBool False)]
      renderTemplateText "a{{#if Eq x true}}{{#else}}{{/if}}b" vars
        `shouldBe` Right "ab"

    it "handles multiple top-level blocks back-to-back" $ do
      let vars = Map.fromList [("a", VBool True), ("b", VBool False)]
      renderTemplateText
        "{{#if Eq a true}}A{{/if}}-{{#if Eq b true}}B{{/if}}-{{#if Eq a true}}A2{{/if}}"
        vars
        `shouldBe` Right "A--A2"

    it "interleaves {{var}} and {{#if}} on the same line" $ do
      let vars =
            Map.fromList
              [ ("flag", VBool True),
                ("name", VText "world")
              ]
      renderTemplateText
        "pre {{name}} {{#if Eq flag true}}mid-{{name}}-mid{{/if}} post"
        vars
        `shouldBe` Right "pre world mid-world-mid post"

    describe "standalone-block whitespace trim" $ do
      it "absorbs surrounding indentation and the trailing newline when opener is on its own line" $ do
        let vars = Map.fromList [("flag", VBool True)]
            tpl = "prev\n  {{#if Eq flag true}}\n  body\n  {{/if}}\nnext\n"
        renderTemplateText tpl vars `shouldBe` Right "prev\n  body\nnext\n"

      it "absorbs the whole block's lines when the condition is false" $ do
        let vars = Map.fromList [("flag", VBool False)]
            tpl = "prev\n  {{#if Eq flag true}}\n  body\n  {{/if}}\nnext\n"
        renderTemplateText tpl vars `shouldBe` Right "prev\nnext\n"

      it "leaves a compact tag surrounded by content untouched" $ do
        -- Opener and closer are not standalone: both share a line
        -- with template content. No trim should apply.
        let vars = Map.fromList [("x", VBool True)]
        renderTemplateText "a{{#if Eq x true}}b{{/if}}c" vars
          `shouldBe` Right "abc"

      it "treats a tag with leading/trailing whitespace on its line as standalone" $ do
        let vars = Map.fromList [("flag", VBool True)]
            -- Tab + {{#if}} + trailing spaces + newline, all standalone.
            tpl = "prev\n\t{{#if Eq flag true}}   \nbody\n\t{{/if}}   \nnext"
        renderTemplateText tpl vars `shouldBe` Right "prev\nbody\nnext"

      it "handles standalone if/else with mixed branches" $ do
        let tpl =
              "outer\n\
              \{{#if Eq x true}}\n\
              \then-branch\n\
              \{{#else}}\n\
              \else-branch\n\
              \{{/if}}\n\
              \tail\n"
        renderTemplateText tpl (Map.fromList [("x", VBool True)])
          `shouldBe` Right "outer\nthen-branch\ntail\n"
        renderTemplateText tpl (Map.fromList [("x", VBool False)])
          `shouldBe` Right "outer\nelse-branch\ntail\n"

      it "preserves indentation of body lines when tags are standalone" $ do
        let vars = Map.fromList [("flag", VBool True)]
            tpl =
              "foo = [\n\
              \  a\n\
              \  {{#if Eq flag true}}\n\
              \  b\n\
              \  {{/if}}\n\
              \  c\n\
              \];\n"
        renderTemplateText tpl vars
          `shouldBe` Right "foo = [\n  a\n  b\n  c\n];\n"

      it "treats a standalone tag at end of template (no trailing newline) as standalone" $ do
        let vars = Map.fromList [("flag", VBool True)]
            tpl = "prev\n  {{#if Eq flag true}}\n  body\n  {{/if}}"
        renderTemplateText tpl vars `shouldBe` Right "prev\n  body\n"

      it "preserves an explicit blank line inside a standalone block" $ do
        -- A blank line immediately after {{#if}} is NOT consumed by
        -- the opener's trim (only ONE newline is absorbed). This lets
        -- template authors emit an intentional blank separator.
        let vars = Map.fromList [("x", VBool True)]
            tpl = "alpha\n{{#if Eq x true}}\n\nbeta\n{{/if}}\ngamma\n"
        renderTemplateText tpl vars `shouldBe` Right "alpha\n\nbeta\ngamma\n"

      it "handles nested standalone blocks" $ do
        let vars = Map.fromList [("a", VBool True), ("b", VBool True)]
            tpl =
              "head\n\
              \{{#if Eq a true}}\n\
              \outer\n\
              \{{#if Eq b true}}\n\
              \inner\n\
              \{{/if}}\n\
              \outer-tail\n\
              \{{/if}}\n\
              \tail\n"
        renderTemplateText tpl vars
          `shouldBe` Right "head\nouter\ninner\nouter-tail\ntail\n"