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