diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,18 @@
-## Changelog for Weeder
+# Changelog
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and is generated by [Changie](https://github.com/miniscruff/changie).
+
+
+## 2.6.0 - 2023-07-07
+### Added
+* Weeder now supports GHC 9.6.
+### Changed
+* Weeder now uses [TOML](https://toml.io) as a configuration format. Please see Weeder's `README` for more information on the configuration format.
+* Weeder now sorts weeds in a single file by ascending line number
+### Fixed
+* Weeder now internally adds declarations once. This shouldn't result in any user visible changes, but may improve performance.
 
 ### [`2.5.0`][v2.5.0] - *2023-01-23*
  
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -18,9 +18,9 @@
 
 ## Preparing Your Code for Weeder
 
-To use Weeder, you will need to generate `.hie` files from your source code. 
+To use Weeder, you will need to generate `.hie` files from your source code.
 
-### Cabal 
+### Cabal
 
 If you use Cabal, this is easily done by adding one line to your
 `cabal.project.local` file:
@@ -55,13 +55,8 @@
 
 ## Calling Weeder
 
-To call Weeder, you first need to provide a configuration file, `weeder.dhall`. Weeder uses
-[Dhall](https://dhall-lang.org) as its configuration format, and configuration
-files have the type:
-
-``` dhall
-{ roots : List Text, type-class-roots : Bool }
-```
+To call Weeder, you first need to provide a configuration file, `weeder.toml`. Weeder uses
+[TOML](https://toml.io/en/) as its configuration format.
 
 `roots` is a list of regular expressions of symbols that are considered as
 alive. If you're building an executable, the pattern `^Main.main$` is a
@@ -74,8 +69,9 @@
 edges into type class instances, and without this flag may produce false
 positives. It's recommended to initially set this to `True`:
 
-``` dhall
-{ roots = [ "^Main.main$" ], type-class-roots = True }
+``` toml
+roots = [ "^Main.main$" ]
+type-class-roots = true
 ```
 
 Now invoke the `weeder` executable, and - if your project has weeds - you will
@@ -93,7 +89,7 @@
 
 # Tips
 
-- You may want to add `^Paths_.*` to the roots in `weeder.dhall` to ignore the
+- You may want to add `^Paths_.*` to the roots in `weeder.toml` to ignore the
   `Paths_packageName` module automatically generated by Cabal.
 
 # Limitations
diff --git a/src/Weeder.hs b/src/Weeder.hs
--- a/src/Weeder.hs
+++ b/src/Weeder.hs
@@ -164,7 +164,7 @@
 -- | Determine the set of all declaration reachable from a set of roots.
 reachable :: Analysis -> Set Root -> Set Declaration
 reachable Analysis{ dependencyGraph, exports } roots =
-  Set.fromList ( dfs ( foldMap rootDeclarations roots ) dependencyGraph )
+  Set.fromList ( dfs dependencyGraph ( foldMap rootDeclarations roots ) )
 
   where
 
@@ -241,10 +241,8 @@
 -- | Try and add vertices for all declarations in an AST - both
 -- those declared here, and those referred to from here.
 addAllDeclarations :: ( MonadState Analysis m ) => HieAST a -> m ()
-addAllDeclarations n@Node{ nodeChildren } = do
+addAllDeclarations n = do
   for_ ( findIdentifiers ( const True ) n ) addDeclaration
-
-  for_ nodeChildren addAllDeclarations
 
 
 topLevelAnalysis :: MonadState Analysis m => HieAST a -> m ()
diff --git a/src/Weeder/Config.hs b/src/Weeder/Config.hs
--- a/src/Weeder/Config.hs
+++ b/src/Weeder/Config.hs
@@ -3,14 +3,13 @@
 {-# language OverloadedStrings #-}
 {-# language RecordWildCards #-}
 
-module Weeder.Config ( Config(..), config ) where
+module Weeder.Config ( Config(..) ) where
 
 -- containers
 import Data.Set ( Set )
-import qualified Data.Set as Set
 
--- dhall
-import qualified Dhall
+-- toml-reader
+import qualified TOML
 
 
 -- | Configuration for Weeder analysis.
@@ -24,14 +23,9 @@
     -- instance is used - enabling this option can prevent false positives.
   }
 
-
--- | A Dhall expression decoder for 'Config'.
---
--- This parses Dhall expressions of the type @{ roots : List Text, type-class-roots : Bool }@.
-config :: Dhall.Decoder Config
-config =
-  Dhall.record do
-    rootPatterns <- Set.fromList <$> Dhall.field "roots" ( Dhall.list Dhall.string )
-    typeClassRoots <- Dhall.field "type-class-roots" Dhall.bool
+instance TOML.DecodeTOML Config where
+  tomlDecoder = do
+    rootPatterns <- TOML.getField "roots"
+    typeClassRoots <- TOML.getField "type-class-roots"
 
     return Config{..}
diff --git a/src/Weeder/Main.hs b/src/Weeder/Main.hs
--- a/src/Weeder/Main.hs
+++ b/src/Weeder/Main.hs
@@ -9,13 +9,14 @@
 module Weeder.Main ( main, mainWithConfig ) where
 
 -- base
-import Control.Monad ( guard, unless, when )
+import Control.Exception ( throwIO )
+import Control.Monad ( guard, when )
 import Control.Monad.IO.Class ( liftIO )
 import Data.Bool
 import Data.Foldable
-import Data.List ( isSuffixOf )
+import Data.List ( isSuffixOf, sortOn )
 import Data.Version ( showVersion )
-import System.Exit ( exitFailure )
+import System.Exit ( exitFailure, ExitCode(..), exitWith )
 
 -- containers
 import qualified Data.Map.Strict as Map
@@ -24,8 +25,8 @@
 -- text
 import qualified Data.Text as T
 
--- dhall
-import qualified Dhall
+-- toml-reader
+import qualified TOML
 
 -- directory
 import System.Directory ( canonicalizePath, doesDirectoryExist, doesFileExist, doesPathExist, listDirectory, withCurrentDirectory )
@@ -63,15 +64,19 @@
     execParser $
       info (optsP <**> helper <**> versionP) mempty
 
-  Dhall.input config configExpr
-    >>= mainWithConfig hieExt hieDirectories requireHsFiles
+  (exitCode, _) <- 
+    TOML.decodeFile (T.unpack configExpr)
+      >>= either throwIO pure
+      >>= mainWithConfig hieExt hieDirectories requireHsFiles
+  
+  exitWith exitCode
   where
     optsP = (,,,)
         <$> strOption
             ( long "config"
-                <> help "A Dhall expression for Weeder's configuration. Can either be a file path (a Dhall import) or a literal Dhall expression."
-                <> value "./weeder.dhall"
-                <> metavar "<weeder.dhall>"
+                <> help "A file path for Weeder's configuration."
+                <> value "./weeder.toml"
+                <> metavar "<weeder.toml>"
                 <> showDefaultWith T.unpack
             )
         <*> strOption
@@ -102,7 +107,7 @@
 --
 -- This will recursively find all files with the given extension in the given directories, perform
 -- analysis, and report all unused definitions according to the 'Config'.
-mainWithConfig :: String -> [FilePath] -> Bool -> Config -> IO ()
+mainWithConfig :: String -> [FilePath] -> Bool -> Config -> IO (ExitCode, Analysis)
 mainWithConfig hieExt hieDirectories requireHsFiles Config{ rootPatterns, typeClassRoots } = do
   hieFilePaths <-
     concat <$>
@@ -160,10 +165,12 @@
         dead
 
   for_ ( Map.toList warnings ) \( path, declarations ) ->
-    for_ declarations \( start, d ) ->
+    for_ (sortOn (srcLocLine . fst) declarations) \( start, d ) ->
       putStrLn $ showWeed path start d
 
-  unless ( null warnings ) exitFailure
+  let exitCode = if null warnings then ExitSuccess else ExitFailure 1
+
+  pure (exitCode, analysis)
 
 showWeed :: FilePath -> RealSrcLoc -> Declaration -> String
 showWeed path start d =
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,64 @@
+import qualified Weeder.Main
+import qualified Weeder
+import qualified TOML
+
+import Algebra.Graph.Export.Dot
+import GHC.Types.Name.Occurrence (occNameString)
+import System.Directory
+import System.Environment (getArgs, withArgs)
+import System.FilePath
+import System.Process
+import System.IO.Silently (hCapture_)
+import System.IO (stdout, stderr, hPrint)
+import Test.Hspec
+import Control.Monad (zipWithM_, when)
+import Control.Exception ( throwIO, IOException, handle )
+
+main :: IO ()
+main = do
+  args <- getArgs
+  stdoutFiles <- discoverIntegrationTests
+  let hieDirectories = map dropExtension stdoutFiles
+      drawDots = mapM_ (drawDot . (<.> ".dot")) hieDirectories
+      graphviz = "--graphviz" `elem` args
+  withArgs (filter (/="--graphviz") args) $
+    hspec $ afterAll_ (when graphviz drawDots) $ do
+      describe "Weeder.Main" $
+        describe "mainWithConfig" $
+          zipWithM_ integrationTestSpec stdoutFiles hieDirectories
+  where
+    -- Draw a dotfile via graphviz
+    drawDot f = callCommand $ "dot -Tpng " ++ f ++ " -o " ++ (f -<.> ".png")
+
+-- | Run weeder on hieDirectory, comparing the output to stdoutFile
+-- The directory containing hieDirectory must also have a .toml file
+-- with the same name as hieDirectory
+integrationTestSpec :: FilePath -> FilePath -> Spec
+integrationTestSpec stdoutFile hieDirectory = do
+  it ("produces the expected output for " ++ hieDirectory) $ do
+    expectedOutput <- readFile stdoutFile
+    actualOutput <- integrationTestOutput hieDirectory
+    actualOutput `shouldBe` expectedOutput
+
+-- | Returns detected .stdout files in ./test/Spec
+discoverIntegrationTests :: IO [FilePath]
+discoverIntegrationTests = do
+  contents <- listDirectory "./test/Spec"
+  pure . map ("./test/Spec" </>) $ filter (".stdout" `isExtensionOf`) contents
+
+-- | Run weeder on the given directory for .hie files, returning stdout
+-- Also creates a dotfile containing the dependency graph as seen by Weeder
+integrationTestOutput :: FilePath -> IO String
+integrationTestOutput hieDirectory = hCapture_ [stdout] $ do
+  isEmpty <- not . any (".hie" `isExtensionOf`) <$> listDirectory hieDirectory
+  when isEmpty $ fail "No .hie files found in directory, this is probably unintended"
+  (_, analysis) <-
+    TOML.decodeFile configExpr
+      >>= either throwIO pure
+      >>= Weeder.Main.mainWithConfig ".hie" [hieDirectory] True
+  let graph = Weeder.dependencyGraph analysis
+      graph' = export (defaultStyle (occNameString . Weeder.declOccName)) graph
+  handle (\e -> hPrint stderr (e :: IOException)) $
+    writeFile (hieDirectory <.> ".dot") graph'
+  where
+    configExpr = hieDirectory <.> ".toml"
diff --git a/test/Spec/BasicExample.stdout b/test/Spec/BasicExample.stdout
new file mode 100644
--- /dev/null
+++ b/test/Spec/BasicExample.stdout
@@ -0,0 +1,1 @@
+test/Spec/BasicExample/BasicExample.hs:4: unrelated
diff --git a/test/Spec/BasicExample.toml b/test/Spec/BasicExample.toml
new file mode 100644
--- /dev/null
+++ b/test/Spec/BasicExample.toml
@@ -0,0 +1,3 @@
+roots = [ "Spec.BasicExample.BasicExample.root" ]
+
+type-class-roots = true
diff --git a/test/Spec/BasicExample/BasicExample.hs b/test/Spec/BasicExample/BasicExample.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec/BasicExample/BasicExample.hs
@@ -0,0 +1,10 @@
+module Spec.BasicExample.BasicExample where
+
+unrelated :: Int
+unrelated = 3
+
+dependency :: Int
+dependency = 1
+
+root :: Int
+root = dependency + 1
diff --git a/weeder.cabal b/weeder.cabal
--- a/weeder.cabal
+++ b/weeder.cabal
@@ -5,7 +5,7 @@
 author:        Ollie Charles <ollie@ocharles.org.uk>
 maintainer:    Ollie Charles <ollie@ocharles.org.uk>
 build-type:    Simple
-version:       2.5.0
+version:       2.6.0
 copyright:     Neil Mitchell 2017-2020, Oliver Charles 2020-2023
 synopsis:      Detect dead code
 description:   Find declarations.
@@ -15,23 +15,26 @@
 extra-doc-files:
     README.md
     CHANGELOG.md
+extra-source-files:
+    test/Spec/*.toml
+    test/Spec/*.stdout
 
 library
   build-depends:
-    , algebraic-graphs     ^>= 0.4 || ^>= 0.5  || ^>= 0.6
-    , base                 ^>= 4.17.0.0
+    , algebraic-graphs     ^>= 0.7
+    , base                 ^>= 4.17.0.0 || ^>= 4.18.0.0
     , bytestring           ^>= 0.10.9.0 || ^>= 0.11.0.0
     , containers           ^>= 0.6.2.1
-    , dhall                ^>= 1.30.0 || ^>= 1.31.0 || ^>= 1.32.0 || ^>= 1.33.0 || ^>= 1.34.0 || ^>= 1.35.0 || ^>= 1.36.0 || ^>= 1.37.0 || ^>= 1.40.0 || ^>= 1.41
     , directory            ^>= 1.3.3.2
     , filepath             ^>= 1.4.2.1
     , generic-lens         ^>= 2.2.0.0
-    , ghc                  ^>= 9.4
+    , ghc                  ^>= 9.4 || ^>= 9.6
     , lens                 ^>= 5.1 || ^>= 5.2
-    , mtl                  ^>= 2.2.2
+    , mtl                  ^>= 2.2.2 || ^>= 2.3
     , optparse-applicative ^>= 0.14.3.0 || ^>= 0.15.1.0 || ^>= 0.16.0.0 || ^>=  0.17
     , regex-tdfa           ^>= 1.2.0.0 || ^>= 1.3.1.0
     , text                 ^>= 2.0.1
+    , toml-reader          ^>= 0.2.0.0
     , transformers         ^>= 0.5.6.2 || ^>= 0.6
   hs-source-dirs: src
   exposed-modules:
@@ -60,4 +63,30 @@
   main-is: Main.hs
   hs-source-dirs: exe-weeder
   ghc-options: -Wall -fwarn-incomplete-uni-patterns
+  default-language: Haskell2010
+
+test-suite weeder-test
+  build-depends:
+    , aeson
+    , algebraic-graphs
+    , base
+    , directory
+    , filepath
+    , ghc
+    , hspec
+    , process
+    , silently
+    , text
+    , toml-reader
+    , weeder
+  type: exitcode-stdio-1.0
+  main-is: Spec.hs
+  hs-source-dirs: test
+  autogen-modules:
+    Paths_weeder
+  other-modules:
+    Paths_weeder
+    -- Tests
+    Spec.BasicExample.BasicExample
+  ghc-options: -Wall -fwarn-incomplete-uni-patterns -fwrite-ide-info -hiedir ./test
   default-language: Haskell2010
