packages feed

nova-nix-0.1.7.0: src/Nix/Parser.hs

-- | Hand-rolled Nix language parser.
--
-- == How Nix parsing works
--
-- A @.nix@ file is a single expression. There is no top-level module
-- declaration, no import list, no @main@.  The file IS the expression.
--
-- @
-- -- default.nix
-- { pkgs ? import \<nixpkgs\> {} }:
-- pkgs.mkShell { buildInputs = [ pkgs.ghc ]; }
-- @
--
-- This is a lambda that takes an attribute set with a default, and
-- returns a call to @mkShell@. The whole file is one 'Expr'.
--
-- == Why hand-rolled
--
-- hnix used Megaparsec and it was 10x slower than C++ Nix (38% of runtime
-- in GC from parser allocations). We use a hand-rolled recursive descent
-- parser with 'Data.Text' slicing to minimize allocations.
--
-- == Operator precedence (lowest to highest)
--
-- @
-- ->            (right)    implication
-- ||            (left)     logical or
-- &&            (left)     logical and
-- == !=         (none)     equality
-- \< \> \<= \>=      (none)     comparison
-- //            (right)    attribute set merge
-- !             (prefix)   logical not
-- ++ (list)     (right)    list concatenation
-- + -           (left)     addition, subtraction
-- * /           (left)     multiplication, division
-- -             (prefix)   arithmetic negation
-- f x           (left)     function application
-- e.a           (left)     attribute selection
-- @
module Nix.Parser
  ( -- * Parsing
    parseNix,
    parseNixFile,

    -- * Encoding
    readFileAutoEncoding,

    -- * Errors
    ParseError (..),
  )
where

import qualified Data.ByteString as BS
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Nix.Expr.ClosureTrim (trimClosures)
import Nix.Expr.Resolve (resolveVars)
import Nix.Expr.Types (Expr)
import Nix.Parser.Expr (parseTopLevel)
import Nix.Parser.Internal (ParseState (..), runParser)
import Nix.Parser.Lexer (tokenize)
import Nix.Parser.ParseError (ParseError (..))

-- | Parse a Nix expression from source text.
--
-- The input is the full file contents. The file name is used only for
-- error messages.  Strips a leading UTF-8 BOM if present — Windows
-- editors (Notepad, PowerShell) commonly add one.
parseNix :: Text -> Text -> Either ParseError Expr
parseNix fileName source = do
  tokens <- tokenize fileName (stripBOM source)
  let st = ParseState {psTokens = tokens, psFile = fileName}
  (expr, _remaining) <- runParser parseTopLevel st
  pure (trimClosures (resolveVars expr))

-- | Strip a leading UTF-8 byte order mark (U+FEFF) if present.
stripBOM :: Text -> Text
stripBOM t = case T.uncons t of
  Just ('\xFEFF', rest) -> rest
  _ -> t

-- | Parse a @.nix@ file from disk.
-- Uses 'readFileAutoEncoding' to handle UTF-8, UTF-16 LE, and UTF-16 BE.
parseNixFile :: FilePath -> IO (Either ParseError Expr)
parseNixFile path = do
  source <- readFileAutoEncoding path
  pure $ parseNix (T.pack path) source

-- ---------------------------------------------------------------------------
-- Encoding detection
-- ---------------------------------------------------------------------------

-- | Read a file, detecting encoding from its byte order mark.
--
-- Windows tools (PowerShell's @>@ operator, Notepad) commonly write
-- UTF-16 LE.  Nix files are normally UTF-8, but on a Windows-first
-- implementation we handle all three BOM variants:
--
-- * @FF FE@       → UTF-16 LE (PowerShell default)
-- * @FE FF@       → UTF-16 BE
-- * @EF BB BF@    → UTF-8 (BOM stripped)
-- * No BOM        → UTF-8
readFileAutoEncoding :: FilePath -> IO Text
readFileAutoEncoding path = decodeAutoEncoding <$> BS.readFile path

-- | Decode bytes to 'Text' based on byte order mark.
decodeAutoEncoding :: BS.ByteString -> Text
decodeAutoEncoding bytes
  | BS.length bytes >= 2,
    BS.index bytes 0 == 0xFF,
    BS.index bytes 1 == 0xFE =
      TE.decodeUtf16LE (BS.drop 2 bytes)
  | BS.length bytes >= 2,
    BS.index bytes 0 == 0xFE,
    BS.index bytes 1 == 0xFF =
      TE.decodeUtf16BE (BS.drop 2 bytes)
  | BS.length bytes >= 3,
    BS.index bytes 0 == 0xEF,
    BS.index bytes 1 == 0xBB,
    BS.index bytes 2 == 0xBF =
      TE.decodeUtf8 (BS.drop 3 bytes)
  | otherwise =
      TE.decodeUtf8 bytes