nova-nix-0.4.0.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 Data.Text.Encoding.Error (lenientDecode)
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.decodeUtf16LEWith lenientDecode (BS.drop 2 bytes)
| BS.length bytes >= 2,
BS.index bytes 0 == 0xFE,
BS.index bytes 1 == 0xFF =
TE.decodeUtf16BEWith lenientDecode (BS.drop 2 bytes)
| BS.length bytes >= 3,
BS.index bytes 0 == 0xEF,
BS.index bytes 1 == 0xBB,
BS.index bytes 2 == 0xBF =
TE.decodeUtf8With lenientDecode (BS.drop 3 bytes)
| otherwise =
TE.decodeUtf8With lenientDecode bytes