diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,13 @@
 
 # Revision history for nixfmt
 
+## 0.5.0 -- 2022-03-15
+* Add a nix flake to the nixfmt project.
+* Add a --verify flag to check idempotency.
+* Support nix path (`./${foo}.nix`) interpolations.
+* Fix escaping of interpolations after single quotes.
+* Fix handling of multiline strings with spaces in the last line.
+
 ## 0.4.0 -- 2020-02-10
 * Report non-conforming files on the same line to aid line-oriented processing
 * Fix help, summary, and version flag contents.
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -25,6 +25,10 @@
       cachix use nixfmt
       nix-env -f https://github.com/serokell/nixfmt/archive/master.tar.gz -i
 
+- Nix with flakes
+
+      nix profile install github:serokell/nixfmt
+
 ## Development
 
 ### With Nix
diff --git a/js/JSInterface.hs b/js/JSInterface.hs
--- a/js/JSInterface.hs
+++ b/js/JSInterface.hs
@@ -29,7 +29,7 @@
         out <- case format width filename text of
           Left err -> do
             setProp "err" (toJSBool True) obj
-            toJSVal $ S.pack $ errorBundlePretty err
+            toJSVal $ S.pack err
           Right out_ -> do
             setProp "err" (toJSBool False) obj
             toJSVal out_
diff --git a/js/js-interface-wrapper.js b/js/js-interface-wrapper.js
deleted file mode 100644
--- a/js/js-interface-wrapper.js
+++ /dev/null
@@ -1,10 +0,0 @@
-/* © 2019 Serokell <hi@serokell.io>
- *
- * SPDX-License-Identifier: MPL-2.0
- */
-
-function nixfmt(text, width=80, filename="<stdin>") {
-    const param = {width, filename}
-    nixfmt_(text, param)
-    return {text: param.ret, err: param.err}
-}
diff --git a/main/Main.hs b/main/Main.hs
--- a/main/Main.hs
+++ b/main/Main.hs
@@ -9,7 +9,6 @@
 module Main where
 
 import Control.Concurrent (Chan, forkIO, newChan, readChan, writeChan)
-import Data.Bifunctor (first)
 import Data.Either (lefts)
 import Data.Text (Text)
 import Data.Version (showVersion)
@@ -24,7 +23,7 @@
 
 import qualified Data.Text.IO as TextIO (getContents, hPutStr, putStr)
 
-import Nixfmt
+import qualified Nixfmt
 import System.IO.Atomic (withOutputFile)
 import System.IO.Utf8 (readFileUtf8, withUtf8StdHandles)
 
@@ -36,6 +35,7 @@
     , width :: Width
     , check :: Bool
     , quiet :: Bool
+    , verify :: Bool
     } deriving (Show, Data, Typeable)
 
 options :: Nixfmt
@@ -44,30 +44,28 @@
     , width = 80 &= help "Maximum width in characters"
     , check = False &= help "Check whether files are formatted"
     , quiet = False &= help "Do not report errors"
+    , verify = False &= help "Check that the output parses and formats the same as the input"
     } &= summary ("nixfmt v" ++ showVersion version)
     &= help "Format Nix source code"
 
-format' :: Width -> FilePath -> Text -> Either String Text
-format' w path = first errorBundlePretty . format w path
-
 data Target = Target
     { tDoRead :: IO Text
     , tPath :: FilePath
     , tDoWrite :: Text -> IO ()
     }
 
-formatTarget :: Width -> Target -> IO Result
-formatTarget w Target{tDoRead, tPath, tDoWrite} = do
+formatTarget :: Formatter -> Target -> IO Result
+formatTarget format Target{tDoRead, tPath, tDoWrite} = do
     contents <- tDoRead
-    let formatted = format' w tPath contents
+    let formatted = format tPath contents
     mapM tDoWrite formatted
 
 -- | Return an error if target could not be parsed or was not formatted
 -- correctly.
-checkTarget :: Width -> Target -> IO Result
-checkTarget w Target{tDoRead, tPath} = do
+checkTarget :: Formatter -> Target -> IO Result
+checkTarget format Target{tDoRead, tPath} = do
     contents <- tDoRead
-    return $ case format' w tPath contents of
+    return $ case format tPath contents of
         Left err -> Left err
         Right formatted
             | formatted == contents -> Right ()
@@ -87,16 +85,24 @@
 toTargets Nixfmt{ files = [] }    = [stdioTarget]
 toTargets Nixfmt{ files = paths } = map fileTarget paths
 
-toOperation :: Nixfmt -> Target -> IO Result
-toOperation Nixfmt{ width = w, check = True } = checkTarget w
-toOperation Nixfmt{ width = w } = formatTarget w
+type Formatter = FilePath -> Text -> Either String Text
 
+toFormatter :: Nixfmt -> Formatter
+toFormatter Nixfmt{ width, verify = True  } = Nixfmt.formatVerify width
+toFormatter Nixfmt{ width, verify = False } = Nixfmt.format width
+
+type Operation = Formatter -> Target -> IO Result
+
+toOperation :: Nixfmt -> Operation
+toOperation Nixfmt{ check = True } = checkTarget
+toOperation Nixfmt{ } = formatTarget
+
 toWriteError :: Nixfmt -> String -> IO ()
 toWriteError Nixfmt{ quiet = False } = hPutStrLn stderr
 toWriteError Nixfmt{ quiet = True } = const $ return ()
 
 toJobs :: Nixfmt -> [IO Result]
-toJobs opts = map (toOperation opts) $ toTargets opts
+toJobs opts = map (toOperation opts $ toFormatter opts) $ toTargets opts
 
 -- TODO: Efficient parallel implementation. This is just a sequential stub.
 -- This was originally implemented using parallel-io, but it gave a factor two
diff --git a/main/System/IO/Utf8.hs b/main/System/IO/Utf8.hs
--- a/main/System/IO/Utf8.hs
+++ b/main/System/IO/Utf8.hs
@@ -10,7 +10,7 @@
 --
 -- Standard IO functions assume that the character encoding of the data
 -- they read or write is the same as the one used by current locale. In many
--- situtations this assumption is wrong, as tools work with files, and
+-- situations this assumption is wrong, as tools work with files, and
 -- the files nowadays are mostly UTF-8 encoded, regardless of the locale.
 -- Therefore, it is almost always a good idea to switch the encoding of
 -- file handles to UTF-8.
diff --git a/nixfmt.cabal b/nixfmt.cabal
--- a/nixfmt.cabal
+++ b/nixfmt.cabal
@@ -1,12 +1,12 @@
 cabal-version:       2.0
 
--- © 2019 Serokell <hi@serokell.io>
--- © 2019 Lars Jellema <lars.jellema@gmail.com>
+-- © 2022 Serokell <hi@serokell.io>
+-- © 2022 Lars Jellema <lars.jellema@gmail.com>
 --
 -- SPDX-License-Identifier: MPL-2.0
 
 name:                nixfmt
-version:             0.4.0
+version:             0.5.0
 synopsis:            An opinionated formatter for Nix
 description:
   A formatter for Nix that ensures consistent and clear formatting by forgetting
@@ -17,7 +17,7 @@
 license-file:        LICENSE
 author:              Lars Jellema
 maintainer:          lars.jellema@gmail.com
-copyright:           2019 Serokell, 2019 Lars Jellema
+copyright:           2022 Serokell, 2022 Lars Jellema
 category:            Development
 build-type:          Simple
 extra-source-files:  README.md, CHANGELOG.md
@@ -41,7 +41,7 @@
   else
     buildable: True
   build-depends:
-      base             >= 4.12.0 && < 4.13
+      base             >= 4.12.0 && < 4.17
     , cmdargs          >= 0.10.20 && < 0.11
     , nixfmt
     , unix             >= 2.7.2 && < 2.8
@@ -69,12 +69,21 @@
     Nixfmt.Pretty
     Nixfmt.Types
     Nixfmt.Util
-  other-extensions:    OverloadedStrings, LambdaCase, FlexibleInstances, DeriveFoldable, DeriveFunctor, StandaloneDeriving
+
+  other-extensions:
+    DeriveFoldable
+    DeriveFunctor
+    FlexibleInstances
+    LambdaCase
+    OverloadedStrings
+    StandaloneDeriving
+    TupleSections
+
   hs-source-dirs:      src
   build-depends:
-      base             >= 4.12.0 && < 4.13
-    , megaparsec       >= 7.0.5 && < 7.1
-    , parser-combinators >= 1.0.3 && < 1.3
+      base             >= 4.12.0 && < 4.17
+    , megaparsec       >= 9.0.1 && < 9.3
+    , parser-combinators >= 1.0.3 && < 1.4
     , text             >= 1.2.3 && < 1.3
   default-language:    Haskell2010
   ghc-options:
@@ -98,10 +107,9 @@
       -Wredundant-constraints
       -Wno-orphans
     build-depends:
-        base           >= 4.12.0 && < 4.13
+      base             >= 4.12.0 && < 4.17
       , ghcjs-base     >= 0.2.0 && < 0.3
       , nixfmt
-    js-sources:        js/js-interface-wrapper.js
     hs-source-dirs:    js/
   else
     buildable: False
diff --git a/src/Nixfmt.hs b/src/Nixfmt.hs
--- a/src/Nixfmt.hs
+++ b/src/Nixfmt.hs
@@ -8,10 +8,12 @@
     ( errorBundlePretty
     , ParseErrorBundle
     , format
+    , formatVerify
     ) where
 
+import Data.Bifunctor (bimap, first)
 import Data.Text (Text)
-import Text.Megaparsec (parse)
+import qualified Text.Megaparsec as Megaparsec (parse)
 import Text.Megaparsec.Error (errorBundlePretty)
 
 import Nixfmt.Parser (file)
@@ -19,8 +21,27 @@
 import Nixfmt.Pretty ()
 import Nixfmt.Types (ParseErrorBundle)
 
+type Width = Int
+
 -- | @format w filename source@ returns either a parsing error specifying a
 -- failure in @filename@ or a formatted version of @source@ with a maximum width
 -- of @w@ columns where possible.
-format :: Int -> FilePath -> Text -> Either ParseErrorBundle Text
-format width filename = fmap (layout width) . parse file filename
+format :: Width -> FilePath -> Text -> Either String Text
+format width filename
+    = bimap errorBundlePretty (layout width)
+    . Megaparsec.parse file filename
+
+formatVerify :: Width -> FilePath -> Text -> Either String Text
+formatVerify width path unformatted = do
+    unformattedParsed <- parse unformatted
+    let formattedOnce = layout width unformattedParsed
+    formattedOnceParsed <- parse formattedOnce
+    let formattedTwice = layout width formattedOnceParsed
+    if formattedOnceParsed /= unformattedParsed
+    then pleaseReport "Parses differently after formatting."
+    else if formattedOnce /= formattedTwice
+    then pleaseReport "Nixfmt is not idempotent."
+    else Right formattedOnce
+    where
+        parse = first errorBundlePretty . Megaparsec.parse file path
+        pleaseReport x = Left $ path <> ": " <> x <> " This is a bug in nixfmt. Please report it at https://github.com/serokell/nixfmt"
diff --git a/src/Nixfmt/Parser.hs b/src/Nixfmt/Parser.hs
--- a/src/Nixfmt/Parser.hs
+++ b/src/Nixfmt/Parser.hs
@@ -16,23 +16,22 @@
   (Operator(..), makeExprParser)
 import Data.Char (isAlpha)
 import Data.Foldable (toList)
-import Data.Maybe (fromMaybe)
-import Data.Text as Text
-  (Text, cons, empty, null, singleton, split, strip, stripPrefix)
+import Data.Maybe (fromMaybe, mapMaybe, maybeToList)
+import Data.Text as Text (Text, cons, empty, singleton, split, stripPrefix)
 import Text.Megaparsec
   (anySingle, chunk, eof, label, lookAhead, many, notFollowedBy, oneOf,
-  optional, satisfy, try, (<|>))
+  optional, satisfy, some, try, (<|>))
 import Text.Megaparsec.Char (char)
 import qualified Text.Megaparsec.Char.Lexer as L (decimal, float)
 
 import Nixfmt.Lexer (lexeme)
 import Nixfmt.Types
   (Ann, Binder(..), Expression(..), File(..), Fixity(..), Leaf, Operator(..),
-  ParamAttr(..), Parameter(..), Parser, Selector(..), SimpleSelector(..),
+  ParamAttr(..), Parameter(..), Parser, Path, Selector(..), SimpleSelector(..),
   String, StringPart(..), Term(..), Token(..), operators, tokenText)
 import Nixfmt.Util
-  (commonIndentation, identChar, manyP, manyText, pathChar, schemeChar, someP,
-  someText, uriChar)
+  (commonIndentation, identChar, isSpaces, manyP, manyText, pathChar,
+  schemeChar, someP, someText, uriChar)
 
 -- HELPER FUNCTIONS
 
@@ -83,9 +82,16 @@
     someP pathChar <> manyText (slash <> someP pathChar)
     <* char '>'
 
-path :: Parser (Ann Token)
-path = ann Path $ manyP pathChar <> someText (slash <> someP pathChar)
+pathText :: Parser StringPart
+pathText = TextPart <$> someP pathChar
 
+pathTraversal :: Parser [StringPart]
+pathTraversal = liftM2 (:) (TextPart <$> slash) (some (pathText <|> interpolation))
+
+path :: Parser Path
+path = try $ lexeme $ fmap normalizeLine $
+    (maybeToList <$> optional pathText) <> (concat <$> some pathTraversal)
+
 uri :: Parser [[StringPart]]
 uri = fmap (pure . pure . TextPart) $ try $
     someP schemeChar <> chunk ":" <> someP uriChar
@@ -124,23 +130,28 @@
 
 isEmptyLine :: [StringPart] -> Bool
 isEmptyLine []           = True
-isEmptyLine [TextPart t] = Text.null (Text.strip t)
+isEmptyLine [TextPart t] = isSpaces t
 isEmptyLine _            = False
 
--- | Strip the first line of a string if it is empty.
-stripFirstLine :: [[StringPart]] -> [[StringPart]]
-stripFirstLine [] = []
-stripFirstLine (x : xs)
-    | isEmptyLine x = xs
-    | otherwise     = x : xs
+-- | Drop the first line of a string if it is empty.
+fixFirstLine :: [[StringPart]] -> [[StringPart]]
+fixFirstLine []       = []
+fixFirstLine (x : xs) = if isEmptyLine x' then xs else x' : xs
+    where x' = normalizeLine x
 
-textHeads :: [StringPart] -> [Text]
-textHeads line@(TextPart t : _)
-    | isEmptyLine line              = []
-    | otherwise                     = [t]
-textHeads (Interpolation _ _ _ : _) = [""]
-textHeads []                        = []
+-- | Empty the last line if it contains only spaces.
+fixLastLine :: [[StringPart]] -> [[StringPart]]
+fixLastLine []       = []
+fixLastLine [line]   = if isEmptyLine line' then [[]] else [line']
+    where line' = normalizeLine line
+fixLastLine (x : xs) = x : fixLastLine xs
 
+lineHead :: [StringPart] -> Maybe Text
+lineHead []                        = Nothing
+lineHead line | isEmptyLine line   = Nothing
+lineHead (TextPart t : _)          = Just t
+lineHead (Interpolation _ _ _ : _) = Just ""
+
 stripParts :: Text -> [StringPart] -> [StringPart]
 stripParts indentation (TextPart t : xs) =
     TextPart (fromMaybe Text.empty $ Text.stripPrefix indentation t) : xs
@@ -162,28 +173,31 @@
         _           -> error "unreachable"
 
 stripIndentation :: [[StringPart]] -> [[StringPart]]
-stripIndentation parts = case commonIndentation (concatMap textHeads parts) of
+stripIndentation parts = case commonIndentation $ mapMaybe lineHead parts of
     Nothing -> map (const []) parts
     Just indentation -> map (stripParts indentation) parts
 
-dropEmptyParts :: [[StringPart]] -> [[StringPart]]
-dropEmptyParts = map $ filter (\case
-    TextPart t | Text.null t -> False
-    _                        -> True)
+normalizeLine :: [StringPart] -> [StringPart]
+normalizeLine [] = []
+normalizeLine (TextPart "" : xs) = normalizeLine xs
+normalizeLine (TextPart x : TextPart y : xs) = normalizeLine (TextPart (x <> y) : xs)
+normalizeLine (x : xs) = x : normalizeLine xs
 
 fixSimpleString :: [StringPart] -> [[StringPart]]
-fixSimpleString parts = case splitLines parts of
-    [] -> []
-    [line] -> [line]
-    parts' -> dropEmptyParts (stripIndentation parts')
+fixSimpleString = map normalizeLine . splitLines
 
 simpleString :: Parser [[StringPart]]
 simpleString = rawSymbol TDoubleQuote *>
-    fmap splitLines (many (simpleStringPart <|> interpolation)) <*
+    fmap fixSimpleString (many (simpleStringPart <|> interpolation)) <*
     rawSymbol TDoubleQuote
 
 fixIndentedString :: [[StringPart]] -> [[StringPart]]
-fixIndentedString = dropEmptyParts . concatMap splitLines . stripIndentation . stripFirstLine
+fixIndentedString
+    = map normalizeLine
+    . concatMap splitLines
+    . stripIndentation
+    . fixLastLine
+    . fixFirstLine
 
 indentedString :: Parser [[StringPart]]
 indentedString = rawSymbol TDoubleSingleQuote *>
@@ -212,8 +226,8 @@
     many (selector $ Just $ symbol TDot)
 
 simpleTerm :: Parser Term
-simpleTerm = (String <$> string) <|>
-    (Token <$> (path <|> envPath <|> float <|> integer <|> identifier)) <|>
+simpleTerm = (String <$> string) <|> (Path <$> path) <|>
+    (Token <$> (envPath <|> float <|> integer <|> identifier)) <|>
     parens <|> set <|> list
 
 term :: Parser Term
diff --git a/src/Nixfmt/Pretty.hs b/src/Nixfmt/Pretty.hs
--- a/src/Nixfmt/Pretty.hs
+++ b/src/Nixfmt/Pretty.hs
@@ -23,7 +23,7 @@
   (Ann(..), Binder(..), Expression(..), File(..), Leaf, ParamAttr(..),
   Parameter(..), Selector(..), SimpleSelector(..), StringPart(..), Term(..),
   Token(..), TrailingComment(..), Trivia, Trivium(..), tokenText)
-import Nixfmt.Util (commonIndentation)
+import Nixfmt.Util (commonIndentation, isSpaces, replaceMultiple)
 
 prettyCommentLine :: Text -> Doc
 prettyCommentLine l
@@ -88,6 +88,7 @@
 prettyTerm :: Term -> Doc
 prettyTerm (Token t) = pretty t
 prettyTerm (String s) = pretty s
+prettyTerm (Path p) = pretty p
 prettyTerm (Selection term selectors) = pretty term <> hcat selectors
 
 prettyTerm (List (Ann paropen Nothing []) [] parclose)
@@ -301,23 +302,32 @@
           nonEmpty _                  = True
           inits = map textInit $ filter nonEmpty parts
 
-isEmptyLine :: [StringPart] -> Bool
-isEmptyLine []           = True
-isEmptyLine [TextPart t] = Text.null $ Text.strip t
-isEmptyLine _            = False
+-- | If the last line has at least one space but nothing else, it cannot be
+-- cleanly represented in an indented string.
+lastLineIsSpaces :: [[StringPart]] -> Bool
+lastLineIsSpaces [] = False
+lastLineIsSpaces xs = case last xs of
+    [TextPart t] -> isSpaces t
+    _            -> False
 
+isInvisibleLine :: [StringPart] -> Bool
+isInvisibleLine []           = True
+isInvisibleLine [TextPart t] = Text.null $ Text.strip t
+isInvisibleLine _            = False
+
 isSimpleString :: [[StringPart]] -> Bool
 isSimpleString [parts]
-    | hasDualQuotes parts     = True
-    | endsInSingleQuote parts = True
-    | isIndented [parts]      = True
-    | hasQuotes parts         = False
-    | otherwise               = True
+    | hasDualQuotes parts       = True
+    | endsInSingleQuote parts   = True
+    | isIndented [parts]        = True
+    | hasQuotes parts           = False
+    | otherwise                 = True
 
 isSimpleString parts
-    | all isEmptyLine parts = True
-    | isIndented parts      = True
-    | otherwise             = False
+    | all isInvisibleLine parts = True
+    | isIndented parts          = True
+    | lastLineIsSpaces parts    = True
+    | otherwise                 = False
 
 instance Pretty StringPart where
     pretty (TextPart t) = text t
@@ -385,10 +395,11 @@
     text "''" <> line'
     <> nest 2 (sepBy newline (map (prettyLine escape unescapeInterpol) parts))
     <> text "''"
-    where escape
-              = Text.replace "$''${" "$${"
-              . Text.replace "${" "''${"
-              . Text.replace "''" "'''"
+    where escape = replaceMultiple
+              [ ("'${", "''\\'''${")
+              , ("${", "''${")
+              , ("''", "'''")
+              ]
 
           unescapeInterpol t
               | Text.null t        = t
diff --git a/src/Nixfmt/Types.hs b/src/Nixfmt/Types.hs
--- a/src/Nixfmt/Types.hs
+++ b/src/Nixfmt/Types.hs
@@ -24,59 +24,67 @@
     = EmptyLine
     | LineComment     Text
     | BlockComment    [Text]
-    deriving (Show)
+    deriving (Eq, Show)
 
 type Trivia = [Trivium]
 
-newtype TrailingComment = TrailingComment Text deriving (Show)
+newtype TrailingComment = TrailingComment Text deriving (Eq, Show)
 
 data Ann a
     = Ann a (Maybe TrailingComment) Trivia
     deriving (Show)
 
+-- | Equality of annotated syntax is defines as equality of their corresponding
+-- semantics, thus ignoring the annotations.
+instance Eq a => Eq (Ann a) where
+    Ann x _ _ == Ann y _ _ = x == y
+
 type Leaf = Ann Token
 
 data StringPart
     = TextPart Text
     | Interpolation Leaf Expression Token
-    deriving (Show)
+    deriving (Eq, Show)
 
+type Path = Ann [StringPart]
+
 type String = Ann [[StringPart]]
 
 data SimpleSelector
     = IDSelector Leaf
     | InterpolSelector (Ann StringPart)
     | StringSelector String
-    deriving (Show)
+    deriving (Eq, Show)
 
 data Selector
     = Selector (Maybe Leaf) SimpleSelector (Maybe (Leaf, Term))
-    deriving (Show)
+    deriving (Eq, Show)
 
 data Binder
     = Inherit Leaf (Maybe Term) [Leaf] Leaf
     | Assignment [Selector] Leaf Expression Leaf
-    deriving (Show)
+    deriving (Eq, Show)
 
 data Term
     = Token Leaf
     | String String
+    | Path Path
     | List Leaf [Term] Leaf
     | Set (Maybe Leaf) Leaf [Binder] Leaf
     | Selection Term [Selector]
     | Parenthesized Leaf Expression Leaf
-    deriving (Show)
+    deriving (Eq, Show)
 
 data ParamAttr
     = ParamAttr Leaf (Maybe (Leaf, Expression)) (Maybe Leaf)
     | ParamEllipsis Leaf
-    deriving (Show)
+    deriving (Eq, Show)
 
 data Parameter
     = IDParameter Leaf
     | SetParameter Leaf [ParamAttr] Leaf
     | ContextParameter Parameter Leaf Parameter
-    deriving (Show)
+    deriving (Eq, Show)
 
 data Expression
     = Term Term
@@ -91,17 +99,16 @@
     | MemberCheck Expression Leaf [Selector]
     | Negation Leaf Expression
     | Inversion Leaf Expression
-    deriving (Show)
+    deriving (Eq, Show)
 
 data File
     = File Leaf Expression
-    deriving (Show)
+    deriving (Eq, Show)
 
 data Token
     = Integer    Int
     | Float      Double
     | Identifier Text
-    | Path       Text
     | EnvPath    Text
 
     | KAssert
@@ -165,12 +172,12 @@
     | InfixN
     | InfixR
     | Postfix
-    deriving (Show)
+    deriving (Eq, Show)
 
 data Operator
     = Op Fixity Token
     | Apply
-    deriving (Show)
+    deriving (Eq, Show)
 
 -- | A list of lists of operators where lists that come first contain operators
 -- that bind more strongly.
@@ -201,7 +208,6 @@
 tokenText (Identifier i)     = i
 tokenText (Integer i)        = pack (show i)
 tokenText (Float f)          = pack (show f)
-tokenText (Path p)           = p
 tokenText (EnvPath p)        = "<" <> p <> ">"
 
 tokenText KAssert            = "assert"
diff --git a/src/Nixfmt/Util.hs b/src/Nixfmt/Util.hs
--- a/src/Nixfmt/Util.hs
+++ b/src/Nixfmt/Util.hs
@@ -4,6 +4,8 @@
  - SPDX-License-Identifier: MPL-2.0
  -}
 
+{-# LANGUAGE TupleSections #-}
+
 module Nixfmt.Util
     ( manyP
     , someP
@@ -13,15 +15,20 @@
     , commonIndentation
     , dropCommonIndentation
     , identChar
+    , isSpaces
     , pathChar
+    , replaceMultiple
     , schemeChar
     , uriChar
     ) where
 
+import Control.Applicative ((<|>))
 import Data.Char (isAlpha, isDigit, isSpace)
+import Data.Foldable (asum)
+import Data.List (unfoldr)
 import Data.Maybe (fromMaybe)
 import Data.Text as Text
-  (Text, commonPrefixes, concat, empty, stripEnd, stripPrefix, takeWhile)
+  (Text, all, commonPrefixes, concat, empty, null, splitAt, stripEnd, stripPrefix, takeWhile)
 import Text.Megaparsec
   (ParsecT, Stream, Token, Tokens, many, some, takeWhile1P, takeWhileP)
 
@@ -78,3 +85,23 @@
     in case commonIndentation (filter (/=empty) strippedLines) of
             Nothing          -> map (const empty) strippedLines
             Just indentation -> map (fromMaybe empty . stripPrefix indentation) strippedLines
+
+isSpaces :: Text -> Bool
+isSpaces = Text.all (==' ')
+
+-- | Apply multiple independent replacements. This function passes over the text
+-- once and applies the first replacement it can find at each position. After a
+-- replacement is matched, the function continues after the replacement, not
+-- inside it.
+replaceMultiple :: [(Text, Text)] -> Text -> Text
+replaceMultiple replacements = mconcat . unfoldr replaceAny
+  where
+    -- | replaceAny assumes input is nonempty
+    replaceAny :: Text -> Maybe (Text, Text)
+    replaceAny t
+      | Text.null t = Nothing
+      | otherwise   = asum (map (replaceStart t) replacements)
+                      <|> Just (Text.splitAt 1 t)
+
+    replaceStart :: Text -> (Text, Text) -> Maybe (Text, Text)
+    replaceStart t (pat, rep) = (rep,) <$> Text.stripPrefix pat t
