packages feed

nova-nix-0.9.0.0: src/Nix/Eval/StringInterp.hs

-- | String coercion and indented-string whitespace stripping.
--
-- Provides 'coerceToString' (string interpolation and @builtins.toString@),
-- 'concatChunks' (a double-quoted string's parts joined) and
-- 'stripIndentedChunks' (the indented-string indentation algorithm, applied
-- by the bytecode evaluator).  Force/apply are passed in as parameters to
-- break the import cycle with @Nix.Eval@.
module Nix.Eval.StringInterp
  ( StringChunk (..),
    concatChunks,
    stripIndentedChunks,
    CoercePath,
    coerceToString,
    joinCoercedList,
    formatNixFloat,
  )
where

import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BC
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Nix.Eval.CList (clistLen, clistThunks)
import Nix.Eval.Print (PrintOptions (..), printValue)
import Nix.Eval.Types (MonadEval (..), NixValue (..), StringContext, Thunk (..), attrSetLookup, emptyContext, typeName)
import Numeric (showFFloat)

-- | Force a thunk to a value.
type Force m = Thunk -> m NixValue

-- | Apply a function value to an argument value.
type Apply m = NixValue -> NixValue -> m NixValue

-- | What the caller does with a coerced path: the verbatim text for
-- non-copying coercions (@builtins.toString@), the source store path
-- with context for copy-to-store coercions (interpolation, derivation
-- fields, @builtins.toJSON@).  A parameter for the same reason
-- 'Force' and 'Apply' are: the store copy lives above this module,
-- and the attrset case's recursion must carry the caller's choice -
-- an @outPath@ reached through an attrset coerces exactly as the same
-- path written directly would.
type CoercePath m = Text -> m (ByteString, StringContext)

-- | One evaluated part of a string, with the three-way distinction
-- upstream's @stripIndentation@ sees: a literal is measured by the
-- indentation scan and stripped; an escape (@'''@, @''\n@, ...) is
-- stripped but never measured, since upstream's lexer emits it without
-- the hasIndentation mark; an interpolated value is opaque to both and
-- carries the context it was coerced with.
data StringChunk
  = ChunkLit !ByteString
  | ChunkEsc !ByteString
  | ChunkInterp !ByteString !StringContext
  deriving (Eq, Show)

-- | The bytes a chunk contributes, whatever its kind.
chunkBytes :: StringChunk -> ByteString
chunkBytes (ChunkLit t) = t
chunkBytes (ChunkEsc t) = t
chunkBytes (ChunkInterp t _) = t

-- | The combined context of the interpolated chunks.
chunksContext :: [StringChunk] -> StringContext
chunksContext chunks = mconcat [c | ChunkInterp _ c <- chunks]

-- | Join evaluated chunks as a double-quoted string does: bytes in order,
-- nothing stripped.
concatChunks :: [StringChunk] -> (ByteString, StringContext)
concatChunks chunks = (BS.concat (map chunkBytes chunks), chunksContext chunks)

-- | Strip the common indentation from the evaluated parts of an indented
-- string, as upstream's @ParserState::stripIndentation@ (parser-state.hh
-- at 2.24.9, the same through 2.33.2) does at parse time.  Three of its
-- rules are not the obvious ones:
--
-- * The minimum indent starts at 'indentSentinel', and only a character
--   that is neither a space nor a newline lowers it, an escape or an
--   interpolation at line start counting as one.  A string with no such
--   line loses every leading space on every line.
--
-- * Only a space is indentation.  A tab fixes the minimum at its column
--   and stays in the output.
--
-- * Once the last part is stripped, a final line holding only spaces is
--   removed from it, so the closing @''@'s own indentation never reaches
--   the value.  The rule runs on that part's text alone: a string ending
--   in an interpolation keeps whatever follows it.
--
-- Escapes are stripped like literals and skipped by the scan, so an
-- escaped newline starts a line whose spaces are stripped but were never
-- measured; interpolated values are opaque to both passes and leave the
-- line mid-way.  The scan is byte-level via the Char8 view: it only ever
-- compares against space and newline, single bytes in UTF-8 that never
-- occur inside a multi-byte sequence, so multi-byte content passes
-- through untouched.
stripIndentedChunks :: [StringChunk] -> (ByteString, StringContext)
stripIndentedChunks chunks =
  (BS.concat (chunksStrip (chunksMinIndent chunks) chunks), chunksContext chunks)

-- | Upstream's starting minimum (@size_t minIndent = 1000000@): until a
-- line is measured, every leading space is indentation.
indentSentinel :: Int
indentSentinel = 1000000

-- | Scan state: whether the current line has held only spaces so far,
-- how many, and the minimum measured.
data ScanState = ScanState !Bool !Int !Int

-- | The common indentation of the literal chunks.
chunksMinIndent :: [StringChunk] -> Int
chunksMinIndent chunks =
  let ScanState _ _ measured = foldl' scanChunk (ScanState True 0 indentSentinel) chunks
   in measured
  where
    scanChunk st (ChunkLit t) = BC.foldl' scanChar st t
    scanChunk st _ = endLeadingSpace st
    scanChar (ScanState True cur minIndent) ' ' = ScanState True (cur + 1) minIndent
    scanChar (ScanState _ _ minIndent) '\n' = ScanState True 0 minIndent
    scanChar st@(ScanState True _ _) _ = endLeadingSpace st
    scanChar st _ = st
    endLeadingSpace (ScanState True cur minIndent) = ScanState False cur (min minIndent cur)
    endLeadingSpace st = st

-- | Strip state carried across parts: whether the current line has held
-- only spaces so far, and how many of them have been seen.
data StripState = StripState !Bool !Int

-- | Strip @minIndent@ leading spaces from every line of the literal and
-- escape chunks; an interpolated chunk is emitted verbatim.
chunksStrip :: Int -> [StringChunk] -> [ByteString]
chunksStrip minIndent = go (StripState True 0)
  where
    go _ [] = []
    go _ (ChunkInterp t _ : rest) = t : go (StripState False 0) rest
    go st (ChunkLit t : rest) = stripText st t rest
    go st (ChunkEsc t : rest) = stripText st t rest
    stripText st t rest =
      let (stripped, next) = stripLines minIndent st t
       in finishPart rest stripped : go next rest
    -- Upstream's @n == 1@: only the last part sheds a final line of spaces.
    finishPart [] = dropSpaceOnlyLastLine
    finishPart _ = id

-- | Strip one part line by line.  At line start a space is dropped while
-- fewer than @minIndent@ of the line's spaces have been seen (upstream's
-- @curDropped++ >= minIndent@ keeps the rest); a line that is still only
-- spaces at the end of the part hands its count to the next part.
stripLines :: Int -> StripState -> ByteString -> (ByteString, StripState)
stripLines minIndent = go []
  where
    go acc st t
      | BS.null t = (BS.concat (reverse acc), st)
      | otherwise =
          let (line, afterLine) = BC.break (== '\n') t
              (kept, lineEnd) = stripLine st line
           in case BC.uncons afterLine of
                Just (_, remaining) -> go ("\n" : kept : acc) (StripState True 0) remaining
                Nothing -> (BS.concat (reverse (kept : acc)), lineEnd)
    stripLine st@(StripState False _) line = (line, st)
    stripLine (StripState True seen) line
      | BS.null content = (BS.drop dropped spaces, StripState True (seen + spaceCount))
      | otherwise = (BS.drop dropped line, StripState False 0)
      where
        (spaces, content) = BC.span (== ' ') line
        spaceCount = BS.length spaces
        dropped = max 0 (min spaceCount (minIndent - seen))

-- | Remove a final line that holds only spaces, keeping the newline before
-- it (upstream: @find_last_of('\n')@, then nothing but spaces past it).
-- A part with no newline is left alone.
dropSpaceOnlyLastLine :: ByteString -> ByteString
dropSpaceOnlyLastLine s = case BC.elemIndexEnd '\n' s of
  Just p | BC.all (== ' ') (BS.drop (p + 1) s) -> BS.take (p + 1) s
  _ -> s

-- | Coerce a Nix value to a (byte) string.
--
-- The @coerceMore@ flag mirrors C++ Nix's @coerceToString@ argument: when
-- 'True' (e.g. @builtins.toString@, derivation-env values) ints, floats, bools
-- and null coerce permissively; when 'False' (string interpolation,
-- @builtins.concatStringsSep@) those are type errors, matching C++ Nix.
-- Strings, paths, and attribute sets with @__toString@/@outPath@ coerce in
-- both modes, a list in the permissive one ('joinCoercedList'), and a
-- function never.
coerceToString :: (MonadEval m) => Bool -> Force m -> Apply m -> CoercePath m -> NixValue -> m (ByteString, StringContext)
coerceToString _ _ _ _ (VStr s ctx) = pure (s, ctx)
coerceToString _ _ _ coercePathFn (VPath p) = coercePathFn p
coerceToString True _ _ _ (VInt n) = pure (BC.pack (show n), emptyContext)
coerceToString True _ _ _ (VFloat n) = pure (TE.encodeUtf8 (formatNixFloatFixed n), emptyContext)
coerceToString True _ _ _ VNull = pure ("", emptyContext)
coerceToString True _ _ _ (VBool True) = pure ("1", emptyContext)
coerceToString True _ _ _ (VBool False) = pure ("", emptyContext)
coerceToString True forceFn applyFn coercePathFn (VList cl) = do
  parts <- mapM element (clistThunks cl)
  pure (joinCoercedList [(bytes, isEmptyList) | (bytes, _, isEmptyList) <- parts], mconcat [ctx | (_, ctx, _) <- parts])
  where
    element thunk = do
      val <- forceFn (Thunk thunk)
      (bytes, ctx) <- coerceToString True forceFn applyFn coercePathFn val
      pure (bytes, ctx, emptyList val)
    emptyList (VList inner) = clistLen inner == 0
    emptyList _ = False
-- Attribute sets: try __toString first, then outPath (both modes).
-- The recursion carries the caller's path coercion, so a path-valued
-- @outPath@ lands on the caller's path case, not a fixed one.
coerceToString coerceMore forceFn applyFn coercePathFn (VAttrs attrs) =
  case attrSetLookup "__toString" attrs of
    Just toStrThunk -> do
      toStrFn <- forceFn toStrThunk
      result <- applyFn toStrFn (VAttrs attrs)
      coerceToString coerceMore forceFn applyFn coercePathFn result
    Nothing -> case attrSetLookup "outPath" attrs of
      Just outPathThunk -> do
        outPathVal <- forceFn outPathThunk
        coerceToString coerceMore forceFn applyFn coercePathFn outPathVal
      Nothing -> uncoercible (VAttrs attrs)
coerceToString _ _ _ _ other = uncoercible other

-- | The coerced elements of a list joined as upstream's @coerceToString@
-- joins them (eval.cc at 2.24.9): a space after each but the last, except
-- after one that is itself an empty list, which upstream's own comment
-- calls "not quite correct".  Each element comes with whether it was one.
joinCoercedList :: [(ByteString, Bool)] -> ByteString
joinCoercedList = BS.concat . pieces
  where
    pieces ((bytes, isEmptyList) : rest@(_ : _))
      | isEmptyList = bytes : pieces rest
      | otherwise = bytes : " " : pieces rest
    pieces [(bytes, _)] = [bytes]
    pieces [] = []

-- | Upstream's refusal of a value no coercion takes (@coerceToString@,
-- eval.cc at 2.24.9): its type, then the value as an error prints it.
-- The message is built before it is thrown, so a thunk it shows reads as
-- it is at the failure, not as it is once the force around the failure
-- has been unwound.
uncoercible :: (MonadEval m) => NixValue -> m a
uncoercible val =
  throwEvalError $! "cannot coerce " <> typeName val <> " to a string: " <> printValue PrintForError val

-- | Format a float the way C++ Nix's coerceToString does -
-- @std::to_string@, i.e. FIXED 6 decimal places with no trimming:
-- @toString 1.5@ is @"1.500000"@.  Derivation env values and
-- @builtins.toString@ both see this form, so it is hash-relevant.
formatNixFloatFixed :: Double -> Text
formatNixFloatFixed n
  | isNaN n = "nan"
  | isInfinite n = if n > 0 then "inf" else "-inf"
  | otherwise = T.pack (showFFloat (Just 6) n "")

-- | Format a float for value display and JSON: 6 fixed decimal places,
-- then strip trailing zeros and an unnecessary decimal point.  E.g.
-- @1.0@ becomes @"1"@, @3.14@ becomes @"3.14"@.
formatNixFloat :: Double -> Text
formatNixFloat n
  | isNaN n = "nan"
  | isInfinite n = if n > 0 then "inf" else "-inf"
  | otherwise =
      let fixed = showFFloat (Just 6) n ""
       in T.pack (stripZeros fixed)
  where
    stripZeros s
      | '.' `elem` s = reverse (dropDot (dropWhile (== '0') (reverse s)))
      | otherwise = s
    dropDot ('.' : rest) = rest
    dropDot xs = xs