diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,163 @@
+Apache License
+
+Version 2.0, January 2004
+
+http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+"License" shall mean the terms and conditions for use, reproduction, and
+distribution as defined by Sections 1 through 9 of this document.
+
+"Licensor" shall mean the copyright owner or entity authorized by the
+copyright owner that is granting the License.
+
+"Legal Entity" shall mean the union of the acting entity and all other
+entities that control, are controlled by, or are under common control with
+that entity. For the purposes of this definition, "control" means (i) the
+power, direct or indirect, to cause the direction or management of such
+entity, whether by contract or otherwise, or (ii) ownership of fifty percent
+(50%) or more of the outstanding shares, or (iii) beneficial ownership of such
+entity.
+
+"You" (or "Your") shall mean an individual or Legal Entity exercising
+permissions granted by this License.
+
+"Source" form shall mean the preferred form for making modifications,
+including but not limited to software source code, documentation source, and
+configuration files.
+
+"Object" form shall mean any form resulting from mechanical transformation or
+translation of a Source form, including but not limited to compiled object
+code, generated documentation, and conversions to other media types.
+
+"Work" shall mean the work of authorship, whether in Source or Object form,
+made available under the License, as indicated by a copyright notice that is
+included in or attached to the work (an example is provided in the Appendix
+below).
+
+"Derivative Works" shall mean any work, whether in Source or Object form, that
+is based on (or derived from) the Work and for which the editorial revisions,
+annotations, elaborations, or other modifications represent, as a whole, an
+original work of authorship. For the purposes of this License, Derivative
+Works shall not include works that remain separable from, or merely link (or
+bind by name) to the interfaces of, the Work and Derivative Works thereof.
+
+"Contribution" shall mean any work of authorship, including the original
+version of the Work and any modifications or additions to that Work or
+Derivative Works thereof, that is intentionally submitted to Licensor for
+inclusion in the Work by the copyright owner or by an individual or Legal
+Entity authorized to submit on behalf of the copyright owner. For the purposes
+of this definition, "submitted" means any form of electronic, verbal, or
+written communication sent to the Licensor or its representatives, including
+but not limited to communication on electronic mailing lists, source code
+control systems, and issue tracking systems that are managed by, or on behalf
+of, the Licensor for the purpose of discussing and improving the Work, but
+excluding communication that is conspicuously marked or otherwise designated
+in writing by the copyright owner as "Not a Contribution."
+
+"Contributor" shall mean Licensor and any individual or Legal Entity on behalf
+of whom a Contribution has been received by Licensor and subsequently
+incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of this
+License, each Contributor hereby grants to You a perpetual, worldwide,
+non-exclusive, no-charge, royalty-free, irrevocable copyright license to
+reproduce, prepare Derivative Works of, publicly display, publicly perform,
+sublicense, and distribute the Work and such Derivative Works in Source or
+Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of this
+License, each Contributor hereby grants to You a perpetual, worldwide,
+non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this
+section) patent license to make, have made, use, offer to sell, sell, import,
+and otherwise transfer the Work, where such license applies only to those
+patent claims licensable by such Contributor that are necessarily infringed by
+their Contribution(s) alone or by combination of their Contribution(s) with
+the Work to which such Contribution(s) was submitted. If You institute patent
+litigation against any entity (including a cross-claim or counterclaim in a
+lawsuit) alleging that the Work or a Contribution incorporated within the Work
+constitutes direct or contributory patent infringement, then any patent
+licenses granted to You under this License for that Work shall terminate as of
+the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the Work or
+Derivative Works thereof in any medium, with or without modifications, and in
+Source or Object form, provided that You meet the following conditions:
+
+You must give any other recipients of the Work or Derivative Works a copy of
+this License; and
+
+You must cause any modified files to carry prominent notices stating that You
+changed the files; and
+
+You must retain, in the Source form of any Derivative Works that You
+distribute, all copyright, patent, trademark, and attribution notices from the
+Source form of the Work, excluding those notices that do not pertain to any
+part of the Derivative Works; and
+
+If the Work includes a "NOTICE" text file as part of its distribution, then
+any Derivative Works that You distribute must include a readable copy of the
+attribution notices contained within such NOTICE file, excluding those notices
+that do not pertain to any part of the Derivative Works, in at least one of
+the following places: within a NOTICE text file distributed as part of the
+Derivative Works; within the Source form or documentation, if provided along
+with the Derivative Works; or, within a display generated by the Derivative
+Works, if and wherever such third-party notices normally appear. The contents
+of the NOTICE file are for informational purposes only and do not modify the
+License. You may add Your own attribution notices within Derivative Works that
+You distribute, alongside or as an addendum to the NOTICE text from the Work,
+provided that such additional attribution notices cannot be construed as
+modifying the License.
+
+You may add Your own copyright statement to Your modifications and may provide
+additional or different license terms and conditions for use, reproduction, or
+distribution of Your modifications, or for any such Derivative Works as a
+whole, provided Your use, reproduction, and distribution of the Work otherwise
+complies with the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise, any
+Contribution intentionally submitted for inclusion in the Work by You to the
+Licensor shall be under the terms and conditions of this License, without any
+additional terms or conditions. Notwithstanding the above, nothing herein
+shall supersede or modify the terms of any separate license agreement you may
+have executed with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade names,
+trademarks, service marks, or product names of the Licensor, except as
+required for reasonable and customary use in describing the origin of the Work
+and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or agreed to in
+writing, Licensor provides the Work (and each Contributor provides its
+Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied, including, without limitation, any warranties
+or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+PARTICULAR PURPOSE. You are solely responsible for determining the
+appropriateness of using or redistributing the Work and assume any risks
+associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory, whether in
+tort (including negligence), contract, or otherwise, unless required by
+applicable law (such as deliberate and grossly negligent acts) or agreed to in
+writing, shall any Contributor be liable to You for damages, including any
+direct, indirect, special, incidental, or consequential damages of any
+character arising as a result of this License or out of the use or inability
+to use the Work (including but not limited to damages for loss of goodwill,
+work stoppage, computer failure or malfunction, or any and all other
+commercial damages or losses), even if such Contributor has been advised of
+the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing the Work
+or Derivative Works thereof, You may choose to offer, and charge a fee for,
+acceptance of support, warranty, indemnity, or other liability obligations
+and/or rights consistent with this License. However, in accepting such
+obligations, You may act only on Your own behalf and on Your sole
+responsibility, not on behalf of any other Contributor, and only if You agree
+to indemnify, defend, and hold each Contributor harmless for any liability
+incurred by, or claims asserted against, such Contributor by reason of your
+accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/all-todos/Main.hs b/all-todos/Main.hs
new file mode 100644
--- /dev/null
+++ b/all-todos/Main.hs
@@ -0,0 +1,59 @@
+-- | Get todos from source code
+--
+-- e.g.
+--   all-todos foo.py
+
+module Main (main) where
+
+import Protolude
+
+import Data.Text.IO (readFile)
+import GHC.IO (FilePath)
+import Options.Applicative
+  ( ParserInfo
+  , argument
+  , execParser
+  , fullDesc
+  , header
+  , helper
+  , info
+  , metavar
+  , progDesc
+  , str
+  )
+
+import qualified Fixme
+import Fixme.Comment (Comment, languageForFile)
+
+
+options :: ParserInfo [FilePath]
+options =
+  info (helper <*> parser) description
+  where
+    parser = many (argument str (metavar "FILES..."))
+
+    description = mconcat
+      [ fullDesc
+      , progDesc "Find all todos from source code"
+      , header "all-todos - Get all todos in source code"
+      ]
+
+
+readComments :: FilePath -> IO (Maybe [Comment])
+readComments filename =
+  case languageForFile (toS filename) of
+    Nothing -> pure Nothing
+    Just language -> do
+      contents <- readFile filename
+      pure (Just $ Fixme.parseComments language contents)
+
+
+main :: IO ()
+main = do
+  files <- execParser options
+  forM_ files $ \filename -> do
+    comments' <- readComments filename
+    case comments' of
+      Nothing -> pure ()
+      Just comments -> do
+        mapM_ (putStrLn . Fixme.formatTodo) (concatMap Fixme.getTodos comments)
diff --git a/difftodo.cabal b/difftodo.cabal
new file mode 100644
--- /dev/null
+++ b/difftodo.cabal
@@ -0,0 +1,72 @@
+-- This file has been generated from package.yaml by hpack version 0.13.0.
+--
+-- see: https://github.com/sol/hpack
+
+name:           difftodo
+version:        0.1.0
+synopsis:       Generate todo lists from source code
+category:       Development
+homepage:       https://github.com/jml/difftodo#readme
+bug-reports:    https://github.com/jml/difftodo/issues
+maintainer:     Jonathan M. Lange <jml@mumak.net>
+license:        Apache
+license-file:   LICENSE
+build-type:     Simple
+cabal-version:  >= 1.10
+
+source-repository head
+  type: git
+  location: https://github.com/jml/difftodo
+
+library
+  hs-source-dirs:
+      src
+  default-extensions: NoImplicitPrelude OverloadedStrings NamedFieldPuns RecordWildCards
+  ghc-options: -Wall
+  build-depends:
+      base >= 4.9 && < 5
+    , protolude >= 0.1.5
+    , text
+    , diff-parse
+    , highlighting-kate
+  exposed-modules:
+      Fixme
+      Fixme.Comment
+      Fixme.Diff
+      Fixme.Todo
+  default-language: Haskell2010
+
+executable all-todos
+  main-is: Main.hs
+  hs-source-dirs:
+      all-todos
+  default-extensions: NoImplicitPrelude OverloadedStrings NamedFieldPuns RecordWildCards
+  ghc-options: -Wall
+  build-depends:
+      base >= 4.9 && < 5
+    , protolude >= 0.1.5
+    , text
+    , difftodo
+    , optparse-applicative
+  default-language: Haskell2010
+
+test-suite fixme-tests
+  type: exitcode-stdio-1.0
+  main-is: Main.hs
+  hs-source-dirs:
+      tests
+  default-extensions: NoImplicitPrelude OverloadedStrings NamedFieldPuns RecordWildCards
+  ghc-options: -Wall
+  build-depends:
+      base >= 4.9 && < 5
+    , protolude >= 0.1.5
+    , text
+    , bytestring
+    , highlighting-kate
+    , difftodo
+    , tasty
+    , tasty-hunit
+  other-modules:
+      Comment
+      Diff
+  default-language: Haskell2010
diff --git a/src/Fixme.hs b/src/Fixme.hs
new file mode 100644
--- /dev/null
+++ b/src/Fixme.hs
@@ -0,0 +1,17 @@
+-- | Routines for getting todos from source code
+--
+-- No backwards compatibility is promised for any code in this library.
+
+module Fixme
+  ( -- | Get comments from code or from diffs
+    Comment.parseComments
+  , Diff.newCommentsFromDiff
+    -- | Turn comments into todos
+  , Todo.getTodos
+    -- | Format todos
+  , Todo.formatTodo
+  ) where
+
+import qualified Fixme.Comment as Comment
+import qualified Fixme.Diff as Diff
+import qualified Fixme.Todo as Todo
diff --git a/src/Fixme/Comment.hs b/src/Fixme/Comment.hs
new file mode 100644
--- /dev/null
+++ b/src/Fixme/Comment.hs
@@ -0,0 +1,111 @@
+module Fixme.Comment
+  ( -- * Understand comments
+    Comment
+  , Located
+  , parseComments
+  , commentText
+  , startLine
+  , endLine
+    -- ** Exposed for testing
+  , newComment
+  , parseComments'
+
+    -- * Understand programming languages
+  , Language
+  , languageForFile
+    -- ** Exposed for testing
+  , highlightCode
+  ) where
+
+import Protolude
+
+import qualified Data.Text as Text
+import Text.Highlighting.Kate
+  ( SourceLine
+  , Token
+  , TokenType(..)
+  , highlightAs
+  , languagesByFilename
+  )
+
+
+-- | Given some source code, return a list of comments.
+parseComments :: Language -> Text -> [Comment]
+parseComments language = parseComments' . highlightCode language
+
+-- | Given a consecutive sequence of lexed lines of source, return a list of
+-- all the comments found, along with the line number on which the comment
+-- starts.
+parseComments' :: [SourceLine] -> [Comment]
+parseComments' =
+  coalesce appendComment . mapMaybe getComment . locateTokens
+
+  where
+    coalesce :: (a -> a -> Maybe a) -> [a] -> [a]
+    coalesce maybeAppend (x:y:ys) =
+      case x `maybeAppend` y of
+        Nothing -> x:(coalesce maybeAppend (y:ys))
+        Just z -> (coalesce maybeAppend (z:ys))
+    coalesce _ xs = xs
+
+
+-- | A thing that is located somewhere in a text file.
+data Located a = Located { startLine :: Int
+                         , value :: a
+                         } deriving (Eq, Show)
+
+type LocatedToken = Located Token
+
+locateTokens :: [SourceLine] -> [LocatedToken]
+locateTokens lines = [Located i x | (i, xs) <- zip [0..] lines, x <- xs]
+
+
+type Comment = Located Text
+
+commentText :: Comment -> Text
+commentText = value
+
+newComment :: Int -> Text -> Comment
+newComment i text = Located i text
+
+endLine :: Comment -> Int
+endLine (Located startLine c) = startLine + (Text.count "\n" c)
+
+appendComment :: Comment -> Comment -> Maybe Comment
+appendComment x y
+  | startLine y - endLine x > 1 = Nothing
+  | startLine y - endLine x == 1 = Just (newComment (startLine x) (commentText x <> "\n" <> commentText y))
+  | otherwise = Just (newComment (startLine x) (commentText x <> commentText y))
+
+getComment :: LocatedToken -> Maybe Comment
+getComment (Located lineNum token) =
+  case getComment' token of
+    Nothing -> Nothing
+    Just comment -> Just $ Located lineNum comment
+
+getComment' :: Token -> Maybe Text
+getComment' (AlertTok, value) = Just (toS value)
+getComment' (CommentTok, value) = Just (toS value)
+getComment' _ = Nothing
+
+
+-- | Wrappers around syntax highlighting code.
+
+-- TODO: Move these to a separate module, maybe.
+
+type Language = Text
+
+languageForFile :: Text -> Maybe Language
+languageForFile = fmap toS . head . languagesByFilename . toS
+
+highlightCode :: Language -> Text -> [SourceLine]
+highlightCode language code =
+  let highlighted = highlightAs (toS language) (toS code)
+  in case Text.toLower language of
+       "haskell" -> map fixupHaskellComments highlighted
+       _ -> highlighted
+
+  where
+    -- | For some reason, Kate highlights a Haskell comment with no text (i.e.
+    -- "--") as a function. Fix this up.
+    fixupHaskellComments = map (\t -> if t == (FunctionTok, "--") then (CommentTok, "--") else t)
diff --git a/src/Fixme/Diff.hs b/src/Fixme/Diff.hs
new file mode 100644
--- /dev/null
+++ b/src/Fixme/Diff.hs
@@ -0,0 +1,106 @@
+-- | Extract comments from diffs
+
+module Fixme.Diff
+  ( newCommentsFromDiff
+  ) where
+
+import Protolude
+
+import qualified Data.Text as Text
+import Text.Diff.Parse (parseDiff)
+import Text.Diff.Parse.Types
+  ( Annotation(..)
+  , Content(..)
+  , FileDelta(..)
+  , FileStatus(..)
+  , Hunk(..)
+  , Line(..)
+  )
+import Fixme.Comment
+  ( Comment
+  , Language
+  , languageForFile
+  , parseComments
+  , startLine
+  , endLine
+  )
+
+-- TODO: Try to use lenses for this.
+
+-- | Get the comments that were added or modified in a diff.
+--
+-- Silently ignores files if we can't figure out what their programming
+-- language is.
+newCommentsFromDiff :: ByteString -> Either Text [Comment]
+newCommentsFromDiff =
+  bimap toS (join . catMaybes . map getNewCommentsForFile) . parseDiff .toS
+  where
+    getNewCommentsForFile (FileDelta Deleted _ _ _) = Just []
+    getNewCommentsForFile (FileDelta _ _ _ Binary) = Just []
+    getNewCommentsForFile (FileDelta _ _ filename (Hunks hunks)) =
+      case languageForFile filename of
+        Nothing -> Nothing
+        Just language -> Just $ concatMap (getNewCommentsForHunk language) hunks
+
+
+-- | Retrieve all the comments that were either added or modified in 'hunk'.
+--
+-- What we actually want to do is to find all of the TODOs that were added in
+-- a branch by means of examining the diff between that branch and 'master',
+-- so that we have a way of figuring out what's left to do while working on a
+-- branch.
+--
+-- This means we can use a simplified approach to analyze the code:
+--
+--  * if a hunk has no insertions, then it can't be a pending todo
+--  * if a hunk has no comments, then it can't be a pending todo
+--  * if a comment appears in a hunk, but nothing was inserted, then it can't be a
+--    pending todo
+--  * if a comment appears in a hunk, and there is a line of insertion in that
+--    comment, then it *might* be a pending todo
+--
+-- Here, "comment" means a contiguous sequence of comment tokens.
+getNewCommentsForHunk :: Language -> Hunk -> [Comment]
+getNewCommentsForHunk language hunk =
+  let comments = parseComments language afterText
+  in filterInsertions addedLineNumbers comments
+  where
+    -- | Reconstitute the "after" text of the diff hunk.
+    -- That is, the text that you would get after applying the hunk as a patch.
+    afterText :: Text
+    afterText = Text.unlines $ map lineContent rightSide
+
+    -- | Return only the comments that are touched by the given line numbers.
+    --
+    -- Assumes that:
+    --   * comments are in order
+    --   * comments are not overlapping
+    --   * line numbers are sorted
+    filterInsertions :: [Int] -> [Comment] -> [Comment]
+    filterInsertions [] _ = []
+    filterInsertions _ [] = []
+    filterInsertions lineNums@(i:lineNums') comments@(c:comments') =
+      case lineInComment i c of
+        LT -> filterInsertions lineNums' comments
+        EQ -> c:(filterInsertions lineNums comments')
+        GT -> filterInsertions lineNums comments'
+
+    -- | Is this line number before (LT), within (EQ), or after (GT) this
+    -- comment?
+    lineInComment :: Int -> Comment -> Ordering
+    lineInComment lineNum comment
+      | lineNum > endLine comment = GT
+      | lineNum < startLine comment = LT
+      | otherwise = EQ
+
+    -- | Return the line numbers (of the right side of the diff) that are
+    -- insertions.
+    addedLineNumbers :: [Int]
+    addedLineNumbers =
+      [ i | (i, line) <- (zip [0..] rightSide)
+          , lineAnnotation line == Added ]
+
+    -- | Get the right-hand side of the diff. That is, only the insertions and
+    -- context lines. We don't care about the "before", only the "after".
+    rightSide :: [Line]
+    rightSide = [ line | line <- hunkLines hunk, lineAnnotation line /= Removed ]
diff --git a/src/Fixme/Todo.hs b/src/Fixme/Todo.hs
new file mode 100644
--- /dev/null
+++ b/src/Fixme/Todo.hs
@@ -0,0 +1,28 @@
+-- | Get todos from comments
+
+module Fixme.Todo
+  ( getTodos
+  , formatTodo
+  ) where
+
+import Protolude
+
+import qualified Data.Text as Text
+import Fixme.Comment (Comment, Located, commentText)
+
+
+type Todo = Located Text
+
+getTodos :: Comment -> [Todo]
+getTodos comment =
+  if any (`Text.isInfixOf` commentText comment) defaultTags then
+    [comment] else []
+
+formatTodo :: Todo -> Text
+formatTodo = ((<>) "\n") . commentText
+
+defaultTags :: [Text]
+defaultTags = [ "XXX"
+              , "TODO"
+              , "FIXME"
+              ]
diff --git a/tests/Comment.hs b/tests/Comment.hs
new file mode 100644
--- /dev/null
+++ b/tests/Comment.hs
@@ -0,0 +1,170 @@
+module Comment (tests) where
+
+import Protolude
+
+import Data.Text (stripEnd, unlines)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit
+
+import Text.Highlighting.Kate (TokenType(..))
+
+import Fixme.Comment
+  ( parseComments'
+  , parseComments
+  , newComment
+  , highlightCode
+  )
+
+
+tests :: TestTree
+tests =
+  testGroup "Fixme.Comment"
+  [ testGroup "Text.Highlighting.Kate"
+    [ testCase "parse python" $ do
+        let lexed = highlightCode "python" (toS pythonExample)
+        let expected = [ [(CommentTok,"# first comment")]
+                       , []
+                       , [(CommentTok,"# second comment")]
+                       , [(CommentTok,"# is multi-line")]
+                       , []
+                       , [(DecValTok,"1"),(NormalTok," "),(OperatorTok,"+"),(NormalTok," "),(DecValTok,"2"),(NormalTok,"  "),(CommentTok,"# trailing comment")]
+                       ]
+        expected @=? lexed
+    , testCase "parse haskell" $ do
+        let lexed = highlightCode "haskell" (toS haskellExample)
+        let expected = [ [(CommentTok,"-- first comment")]
+                       , []
+                       , [(CommentTok,"-- second comment")]
+                       , [(CommentTok,"-- is multi-line")]
+                       , []
+                       , [(CommentTok,"{- comment block start")]
+                       , [(CommentTok,"continues")]
+                       , [(CommentTok,"ends -}")]
+                       , []
+                       , [ (NormalTok,"f "),(FunctionTok,"="),(NormalTok," "),(CommentTok,"{- foo -}"),(NormalTok," ")
+                         , (StringTok,"\"bar\""),(NormalTok," "),(CommentTok,"{- baz -}"),(NormalTok," ")
+                         , (CommentTok,"-- qux")
+                         ]
+                       ]
+        expected @=? lexed
+    , testCase "parse indented haskell" $ do
+        let lexed = highlightCode "haskell" (toS indentedHaskellExample)
+        let expected = [ [(CommentTok,"-- first comment")]
+                       , []
+                       , [(NormalTok," "),(NormalTok," "),(CommentTok,"-- second comment")]
+                       , [(NormalTok," "),(NormalTok," "),(CommentTok,"-- is multi-line")]
+                       , []
+                       , [(CommentTok,"{- comment block start")]
+                       , [(CommentTok,"   continues")]
+                       , [(CommentTok,"   ends -}")]
+                       ]
+        expected @=? lexed
+    , testCase "parse comment with newline" $ do
+        let example = unlines $ [ "-- first line"
+                                , "--"
+                                , "-- second line"
+                                ]
+        let expected = [ [(CommentTok,"-- first line")]
+                       , [(CommentTok,"--")]
+                       , [(CommentTok,"-- second line")]
+                       ]
+        expected @=? highlightCode "haskell" (toS example)
+    , testCase "parse comment with newline (indented)" $ do
+        let example = unlines $ [ "  -- first line"
+                                , "  --"
+                                , "  -- second line"
+                                ]
+        let expected = [ [(NormalTok," "),(NormalTok," "),(CommentTok,"-- first line")]
+                       , [(NormalTok," "),(NormalTok," "),(CommentTok,"--")]
+                       , [(NormalTok," "),(NormalTok," "),(CommentTok,"-- second line")]
+                       ]
+        expected @=? highlightCode "haskell" (toS example)
+    , testCase "parse comment with newline (python)" $ do
+        let example = unlines $ [ "# first line"
+                                , "#"
+                                , "# second line"
+                                ]
+        let expected = [ [(CommentTok,"# first line")]
+                       , [(CommentTok,"#")]
+                       , [(CommentTok,"# second line")]
+                       ]
+        expected @=? highlightCode "python" (toS example)
+    ]
+  , testGroup "Parsing comments from tokens"
+    [ testCase "Parse Python example" $ do
+        let input = [ [(CommentTok,"# first comment")]
+                    , []
+                    , [(CommentTok,"# second comment")]
+                    , [(CommentTok,"# is multi-line")]
+                    , []
+                    , [(DecValTok,"1"),(NormalTok," "),(OperatorTok,"+"),(NormalTok," "),(DecValTok,"2"),(NormalTok,"  "),(CommentTok,"# trailing comment")]
+                    ]
+        let expected = [ newComment 0 "# first comment"
+                       , newComment 2 "# second comment\n# is multi-line"
+                       , newComment 5 "# trailing comment"
+                       ]
+        expected @=? parseComments' input
+    , testCase "Parse indented example" $ do
+        let input = [ [(NormalTok," "),(NormalTok," "),(CommentTok,"-- second comment")]
+                    , [(NormalTok," "),(NormalTok," "),(CommentTok,"-- is multi-line")]
+                    ]
+        let expected = [ newComment 0 "-- second comment\n-- is multi-line" ]
+        expected @=? parseComments' input
+    , testCase "Commented blank line continues comment" $ do
+        let input = [ [(CommentTok,"# comment")]
+                    , [(CommentTok,"#")]
+                    , [(CommentTok,"# is multi-line")]
+                    ]
+        let expected = [ newComment 0 "# comment\n#\n# is multi-line" ]
+        expected @=? parseComments' input
+    ]
+  , testGroup "Parsing comments from source"
+    [ testCase "Multi-line Haskell comment" $ do
+        let example = unlines $
+              [ "-- | First line."
+              , "--"
+              , "-- Explanatory"
+              , "-- paragraph."
+              , "--"
+              , "-- Conclusion"
+              ]
+        let expected = [ newComment 0 (stripEnd example) ]
+        expected @=? parseComments "haskell" example
+    ]
+  ]
+
+pythonExample :: Text
+pythonExample = unlines $
+  [ "# first comment"
+  , ""
+  , "# second comment"
+  , "# is multi-line"
+  , ""
+  , "1 + 2  # trailing comment"
+  ]
+
+haskellExample :: Text
+haskellExample = unlines $
+  [ "-- first comment"
+  , ""
+  , "-- second comment"
+  , "-- is multi-line"
+  , ""
+  , "{- comment block start"
+  , "continues"
+  , "ends -}"
+  , ""
+  , "f = {- foo -} \"bar\" {- baz -} -- qux"
+  ]
+
+indentedHaskellExample :: Text
+indentedHaskellExample = unlines $
+  [ "-- first comment"
+  , ""
+  , "  -- second comment"
+  , "  -- is multi-line"
+  , ""
+  , "{- comment block start"
+  , "   continues"
+  , "   ends -}"
+  ]
diff --git a/tests/Diff.hs b/tests/Diff.hs
new file mode 100644
--- /dev/null
+++ b/tests/Diff.hs
@@ -0,0 +1,56 @@
+module Diff (tests) where
+
+import Protolude
+
+import qualified Data.ByteString.Char8
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit
+
+import Fixme.Comment (newComment)
+import Fixme.Diff (newCommentsFromDiff)
+
+
+tests :: TestTree
+tests =
+  testGroup "Fixme.Diff"
+  [ testCase "New file" $ do
+      let input = Data.ByteString.Char8.unlines $
+            [ "diff --git a/src/Fixme/Diff.hs b/src/Fixme/Diff.hs"
+            , "new file mode 100644"
+            , "index 0000000..c926970"
+            , "--- /dev/null"
+            , "+++ b/src/Fixme/Diff.hs"
+            , "@@ -0,0 +1,8 @@"
+            , "+-- | Extract comments from diffs"
+            , "+"
+            , "+module Fixme.Diff"
+            , "+  ( parseComments"
+            , "+  , newCommentsFromDiff"
+            , "+  , newComment"
+            , "+  , comment"
+            , "+  ) where"
+            ]
+      let expected = [ newComment 0 "-- | Extract comments from diffs"]
+      Right expected @=? newCommentsFromDiff input
+  , testCase "Multi-line comment" $ do
+      let input = Data.ByteString.Char8.unlines $
+            [ "diff --git a/src/Fixme/Diff.hs b/src/Fixme/Diff.hs"
+            , "new file mode 100644"
+            , "index 0000000..c926970"
+            , "--- /dev/null"
+            , "+++ b/src/Fixme/Diff.hs"
+            , "@@ -0,0 +1,10 @@"
+            , "+-- | Extract comments from diffs"
+            , "+--"
+            , "+-- Yep."
+            , "+"
+            , "+module Fixme.Diff"
+            , "+  ( parseComments"
+            , "+  , newCommentsFromDiff"
+            , "+  , newComment"
+            , "+  , comment"
+            , "+  ) where"
+            ]
+      let expected = [ newComment 0 "-- | Extract comments from diffs\n--\n-- Yep."]
+      Right expected @=? newCommentsFromDiff input
+  ]
diff --git a/tests/Main.hs b/tests/Main.hs
new file mode 100644
--- /dev/null
+++ b/tests/Main.hs
@@ -0,0 +1,19 @@
+module Main (main) where
+
+import Protolude
+
+import Test.Tasty (defaultMain, TestTree, testGroup)
+
+import qualified Comment
+import qualified Diff
+
+
+main :: IO ()
+main = defaultMain tests
+
+tests :: TestTree
+tests =
+  testGroup "Fixme"
+  [ Diff.tests
+  , Comment.tests
+  ]
