diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,3 @@
+# 0.1.0.0
+
+  * Initial release
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,14 @@
+Copyright (C) 2021 Vanessa McHale
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
diff --git a/regex-rure.cabal b/regex-rure.cabal
new file mode 100644
--- /dev/null
+++ b/regex-rure.cabal
@@ -0,0 +1,74 @@
+cabal-version:      1.18
+name:               regex-rure
+version:            0.1.0.0
+license:            GPL-3
+license-file:       LICENSE
+maintainer:         vamchale@gmail.com
+author:             Vanessa McHale
+synopsis:           Bindings to Rust's regex library
+description:
+    Bindings to Rust's regex library, including a higher-level API.
+
+category:           Text, Regex
+build-type:         Simple
+extra-source-files: CHANGELOG.md
+
+source-repository head
+    type:     git
+    location: https://github.com/vmchale/rure
+
+library
+    exposed-modules:
+        Regex.Rure
+        Regex.Rure.FFI
+
+    build-tool-depends: c2hs:c2hs >=0.26.1
+    hs-source-dirs:     src
+    default-language:   Haskell2010
+    extra-libraries:    rure
+    ghc-options:        -Wall
+    build-depends:
+        base >=4.10.0.0 && <5,
+        bytestring >=0.11.0.0
+
+    if impl(ghc >=8.0)
+        ghc-options:
+            -Wincomplete-uni-patterns -Wincomplete-record-updates
+            -Wredundant-constraints -Widentities
+
+    if impl(ghc >=8.4)
+        ghc-options: -Wmissing-export-lists
+
+    if impl(ghc >=8.2)
+        ghc-options: -Wcpp-undef
+
+    if impl(ghc >=8.10)
+        ghc-options: -Wunused-packages
+
+test-suite regex-rure-test
+    type:             exitcode-stdio-1.0
+    main-is:          Spec.hs
+    hs-source-dirs:   test
+    default-language: Haskell2010
+    other-extensions: OverloadedStrings
+    ghc-options:      -Wall
+    build-depends:
+        base,
+        tasty,
+        tasty-hunit,
+        regex-rure,
+        bytestring
+
+    if impl(ghc >=8.0)
+        ghc-options:
+            -Wincomplete-uni-patterns -Wincomplete-record-updates
+            -Wredundant-constraints -Widentities
+
+    if impl(ghc >=8.4)
+        ghc-options: -Wmissing-export-lists
+
+    if impl(ghc >=8.2)
+        ghc-options: -Wcpp-undef
+
+    if impl(ghc >=8.10)
+        ghc-options: -Wunused-packages
diff --git a/src/Regex/Rure.chs b/src/Regex/Rure.chs
new file mode 100644
--- /dev/null
+++ b/src/Regex/Rure.chs
@@ -0,0 +1,206 @@
+module Regex.Rure ( -- * Higher-level functions
+                    hsMatches
+                  , hsIsMatch
+                  , hsSetIsMatch
+                  , hsFind
+                  , hsSetMatches
+                  -- * Stateful, functions in 'IO'
+                  , compile
+                  , compileSet
+                  , isMatch
+                  , setIsMatch
+                  , setMatches
+                  , find
+                  , matches
+                  , mkIter
+                  -- * Types
+                  , RureMatch (..)
+                  -- ** Pointer types (stateful, 'IO'-based API)
+                  , RurePtr
+                  , RureIterPtr
+                  , RureSetPtr
+                  -- * Options/flags
+                  , RureFlags
+                  , rureFlagCaseI
+                  , rureFlagMulti
+                  , rureFlagDotNL
+                  , rureFlagSwapGreed
+                  , rureFlagSpace
+                  , rureFlagUnicode
+                  , rureDefaultFlags
+                  ) where
+
+import Data.Coerce (coerce)
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Internal as BS
+import qualified Data.ByteString.Unsafe as BS
+import Data.Foldable (traverse_)
+import Foreign.C.Types (CSize)
+import Foreign.ForeignPtr (castForeignPtr, newForeignPtr, touchForeignPtr)
+import Foreign.ForeignPtr.Unsafe (unsafeForeignPtrToPtr)
+import Foreign.Ptr (castPtr, nullPtr, Ptr)
+import Foreign.Storable (sizeOf)
+import Foreign.Marshal.Alloc (allocaBytes)
+import Foreign.Marshal.Array (peekArray, pokeArray)
+import Regex.Rure.FFI
+import System.IO.Unsafe (unsafePerformIO)
+
+#include <rure.h>
+
+mkIter :: RurePtr -> IO RureIterPtr
+mkIter rePtr =
+    castForeignPtr <$> (newForeignPtr rureIterFree . castPtr =<< rureIterNew rePtr)
+
+compileSet :: RureFlags -> [BS.ByteString] -> IO (Either String RureSetPtr)
+compileSet flags bss = do
+    preErr <- rureErrorNew
+    err <- castForeignPtr <$> newForeignPtr rureErrorFree (castPtr preErr)
+    preOpt <- rureOptionsNew
+    opt <- castForeignPtr <$> newForeignPtr rureOptionsFree (castPtr preOpt)
+    allocaBytes lBytes $ \bPtrs ->
+        allocaBytes lBytes $ \szs -> do
+            pokeArray bPtrs (fmap unsafeForeignPtrToPtr ps)
+            pokeArray szs (fromIntegral <$> ss)
+            res <- rureCompileSet (castPtr bPtrs) szs (fromIntegral l) flags opt err
+            traverse_ touchForeignPtr ps
+            if res == nullPtr
+                then Left <$> rureErrorMessage err
+                else Right . castForeignPtr <$> newForeignPtr rureSetFree (castPtr res)
+    where l = length bss
+          lBytes = l * sizeOf (undefined :: Ptr a)
+          rip (BS.BS psϵ lϵ) = (psϵ, lϵ)
+          (ps, ss) = unzip (fmap rip bss)
+
+compile :: RureFlags -> BS.ByteString -> IO (Either String RurePtr)
+compile flags re = do
+    preErr <- rureErrorNew
+    err <- castForeignPtr <$> newForeignPtr rureErrorFree (castPtr preErr)
+    preOpt <- rureOptionsNew
+    opt <- castForeignPtr <$> newForeignPtr rureOptionsFree (castPtr preOpt)
+    BS.unsafeUseAsCStringLen re $ \(p, sz) -> do
+        res <- rureCompile (castPtr p) (fromIntegral sz) flags opt err
+        if res == nullPtr
+            then Left <$> rureErrorMessage err
+            else Right . castForeignPtr <$> newForeignPtr rureFree (castPtr res)
+
+{-# NOINLINE hsMatches #-}
+hsMatches :: RureFlags
+          -> BS.ByteString -- ^ Regex
+          -> BS.ByteString -- ^ Haystack (unicode)
+          -> Either String [RureMatch]
+hsMatches flags re haystack = unsafePerformIO $ do
+    rePtr <- compile flags re
+    case rePtr of
+        Left err -> pure (Left err)
+        Right rp -> Right <$> ((\riPtr -> matches riPtr haystack) =<< mkIter rp)
+
+matches :: RureIterPtr
+        -> BS.ByteString
+        -> IO [RureMatch]
+matches reIPtr haystack = do
+    res <- iterNext reIPtr haystack
+    case res of
+        Nothing -> pure []
+        Just m -> (m :) <$> matches reIPtr haystack
+
+iterNext :: RureIterPtr
+         -> BS.ByteString
+         -> IO (Maybe RureMatch)
+iterNext reIPtr haystack =
+    allocaBytes {# sizeof rure_match #} $ \matchPtr -> do
+        res <- BS.unsafeUseAsCStringLen haystack $ \(p, sz) ->
+            rureIterNext reIPtr (castPtr p) (fromIntegral sz) matchPtr
+        if res
+            then Just <$> rureMatchFromPtr matchPtr
+            else pure Nothing
+
+rureMatchFromPtr :: Ptr RureMatch -> IO RureMatch
+rureMatchFromPtr matchPtr =
+    RureMatch
+        <$> fmap coerce ({# get rure_match->start #} matchPtr)
+        <*> fmap coerce ({# get rure_match->end #} matchPtr)
+
+{-# NOINLINE hsFind #-}
+hsFind :: RureFlags
+       -> BS.ByteString -- ^ Regex
+       -> BS.ByteString -- ^ Haystack
+       -> Either String (Maybe (RureMatch))
+hsFind flags re haystack = unsafePerformIO $ do
+    rePtr <- compile flags re
+    case rePtr of
+        Left err -> pure (Left err)
+        Right rp -> Right <$> find rp haystack 0
+
+find :: RurePtr
+     -> BS.ByteString -- ^ Unicode
+     -> CSize -- ^ Start
+     -> IO (Maybe RureMatch)
+find rePtr haystack start' =
+    allocaBytes {# sizeof rure_match #} $ \matchPtr -> do
+        res <- BS.unsafeUseAsCStringLen haystack $ \(p, sz) ->
+            rureFind rePtr (castPtr p) (fromIntegral sz) start' matchPtr
+        if res
+            then Just <$> rureMatchFromPtr matchPtr
+            else pure Nothing
+
+{-# NOINLINE hsSetMatches #-}
+hsSetMatches :: RureFlags
+             -> [BS.ByteString]
+             -> BS.ByteString
+             -> Either String [Bool]
+hsSetMatches flags res haystack = unsafePerformIO $ do
+    resPtr <- compileSet flags res
+    case resPtr of
+        Left err -> pure (Left err)
+        Right rsp -> Right <$> setMatches rsp haystack 0
+
+{-# NOINLINE hsSetIsMatch #-}
+hsSetIsMatch :: RureFlags
+             -> [BS.ByteString] -- ^ Needles (regex)
+             -> BS.ByteString -- ^ Haystack
+             -> Either String Bool
+hsSetIsMatch flags res haystack = unsafePerformIO $ do
+    resPtr <- compileSet flags res
+    case resPtr of
+        Left err -> pure (Left err)
+        Right rsp -> Right <$> setIsMatch rsp haystack 0
+
+{-# NOINLINE hsIsMatch #-}
+hsIsMatch :: RureFlags
+          -> BS.ByteString -- ^ Regex
+          -> BS.ByteString -- ^ Haystack (unicode)
+          -> Either String Bool
+hsIsMatch flags re haystack = unsafePerformIO $ do
+    rePtr <- compile flags re
+    case rePtr of
+        Left err -> pure (Left err)
+        Right rp -> Right <$> isMatch rp haystack 0
+
+setIsMatch :: RureSetPtr
+           -> BS.ByteString -- ^ Unicode
+           -> CSize -- ^ Start
+           -> IO Bool
+setIsMatch rsPtr haystack startϵ =
+    BS.unsafeUseAsCStringLen haystack $ \(p, sz) ->
+        rureSetIsMatch rsPtr (castPtr p) (fromIntegral sz) startϵ
+
+setMatches :: RureSetPtr
+           -> BS.ByteString
+           -> CSize
+           -> IO [Bool]
+setMatches rsPtr haystack startϵ =
+    BS.unsafeUseAsCStringLen haystack $ \(p, sz) -> do
+        l <- fromIntegral <$> rureSetLen rsPtr
+        allocaBytes l $ \boolPtr -> do
+            rureSetMatches rsPtr (castPtr p) (fromIntegral sz) startϵ boolPtr
+            fmap cBoolToBool <$> peekArray l boolPtr
+    where cBoolToBool 0 = False
+          cBoolToBool _ = True
+
+isMatch :: RurePtr
+        -> BS.ByteString -- ^ Unicode
+        -> CSize -- ^ Start
+        -> IO Bool
+isMatch rePtr haystack start' =
+    BS.unsafeUseAsCStringLen haystack $ \(p, sz) ->
+        rureIsMatch rePtr (castPtr p) (fromIntegral sz) start'
diff --git a/src/Regex/Rure/FFI.chs b/src/Regex/Rure/FFI.chs
new file mode 100644
--- /dev/null
+++ b/src/Regex/Rure/FFI.chs
@@ -0,0 +1,213 @@
+-- | See @rure.h@ for documentation + how to use.
+module Regex.Rure.FFI ( -- * Types
+                      -- ** Abstract types
+                        Rure
+                      , RureOptions
+                      , RureError
+                      , RureCaptures
+                      , RureSet
+                      , RureIter
+                      -- ** Integer types
+                      , UInt8
+                      , UInt32
+                      -- ** Types
+                      , RureMatch (..)
+                      , RureFlags
+                      -- ** Pointer types (c2hs)
+                      , RurePtr
+                      , RureErrorPtr
+                      , RureOptionsPtr
+                      , RureIterPtr
+                      , RureCapturesPtr
+                      , RureSetPtr
+                      -- * Functions
+                      -- ** Allocation
+                      , rureOptionsNew
+                      , rureOptionsFree
+                      , rureErrorNew
+                      , rureErrorFree
+                      , rureIterNew
+                      , rureFree
+                      , rureIterFree
+                      , rureCapturesNew
+                      , rureCapturesFree
+                      , rureSetFree
+                      -- ** Options
+                      , rureOptionsSizeLimit
+                      , rureOptionsDfaSizeLimit
+                      , rureErrorMessage
+                      -- ** Compilation
+                      , rureCompile
+                      , rureCompileMust
+                      , rureCompileSet
+                      -- ** Matching
+                      , rureIsMatch
+                      , rureFind
+                      , rureIterNext
+                      , rureIterNextCaptures
+                      , rureCapturesAt
+                      , rureCapturesLen
+                      , rureFindCaptures
+                      , rureShortestMatch
+                      , rureCaptureNameIndex
+                      , rureSetIsMatch
+                      , rureSetMatches
+                      , rureSetLen
+                      -- ** Flags
+                      , rureFlagCaseI
+                      , rureFlagMulti
+                      , rureFlagDotNL
+                      , rureFlagSwapGreed
+                      , rureFlagSpace
+                      , rureFlagUnicode
+                      , rureDefaultFlags
+                      -- ** String utilities
+                      , rureEscapeMust
+                      , rureCstringFree
+                      ) where
+
+import Data.Bits (Bits, (.|.), shift)
+import Data.Coerce (coerce)
+import Data.Int (Int32)
+import Data.Semigroup (Semigroup (..))
+import Foreign.C.String (CString)
+import Foreign.C.Types (CBool, CSize)
+import Foreign.Ptr (Ptr, castPtr)
+
+#include <rure.h>
+
+type UInt8 = {# type uint8_t #}
+{#typedef uint8_t UInt8#}
+{#default in `Ptr UInt8' [uint8_t *] id#} -- TODO: bytestring?
+
+type UInt32 = {# type uint32_t #}
+
+newtype RureFlags = RureFlags UInt32
+
+instance Semigroup RureFlags where
+    (<>) (RureFlags x) (RureFlags y) = RureFlags (x .|. y)
+
+data Rure
+
+data RureOptions
+
+data RureMatch = RureMatch { start :: !CSize, end :: !CSize } deriving (Eq, Show)
+
+data RureError
+
+data RureIter
+
+data RureCaptures
+
+data RureSet
+
+(<<) :: Bits a => a -> Int -> a
+m << n = m `shift` n
+
+rureFlagCaseI :: RureFlags
+rureFlagCaseI = RureFlags ({# const RURE_FLAG_CASEI #})
+
+rureFlagMulti :: RureFlags
+rureFlagMulti = RureFlags ({# const RURE_FLAG_MULTI #})
+
+rureFlagDotNL :: RureFlags
+rureFlagDotNL = RureFlags ({# const RURE_FLAG_DOTNL #})
+
+rureFlagSwapGreed :: RureFlags
+rureFlagSwapGreed = RureFlags ({# const RURE_FLAG_SWAP_GREED #})
+
+rureFlagSpace :: RureFlags
+rureFlagSpace = RureFlags ({# const RURE_FLAG_SPACE #})
+
+rureFlagUnicode :: RureFlags
+rureFlagUnicode = RureFlags ({# const RURE_FLAG_UNICODE #})
+
+rureDefaultFlags :: RureFlags
+rureDefaultFlags = RureFlags ({# const RURE_FLAG_UNICODE #})
+
+{# pointer *rure as RurePtr foreign finalizer rure_free as ^ -> Rure #}
+{# pointer *rure_options as RureOptionsPtr foreign finalizer rure_options_free as ^ -> RureOptions #}
+{# pointer *rure_error as RureErrorPtr foreign finalizer rure_error_free as ^ -> RureError #}
+{# pointer *rure_iter as RureIterPtr foreign finalizer rure_iter_free as ^ -> RureIter #}
+{# pointer *rure_captures as RureCapturesPtr foreign finalizer rure_captures_free as ^ -> RureCaptures #}
+{# pointer *rure_set as RureSetPtr foreign finalizer rure_set_free as ^ -> RureSet #}
+
+{# fun rure_compile_must as ^ { `CString' } -> `Ptr Rure' id #}
+{# fun rure_compile as ^ { `Ptr UInt8'
+                         , coerce `CSize'
+                         , coerce `RureFlags'
+                         , `RureOptionsPtr'
+                         , `RureErrorPtr'
+                         } -> `Ptr Rure' id
+  #}
+{# fun rure_is_match as ^ { `RurePtr', `Ptr UInt8', coerce `CSize', coerce `CSize' } -> `Bool' #}
+{# fun rure_find as ^ { `RurePtr'
+                      , `Ptr UInt8'
+                      , coerce `CSize'
+                      , coerce `CSize'
+                      , castPtr `Ptr RureMatch'
+                      } -> `Bool'
+  #}
+{# fun rure_find_captures as ^ { `RurePtr'
+                               , `Ptr UInt8'
+                               , coerce `CSize'
+                               , coerce `CSize'
+                               , `RureCapturesPtr'
+                               } -> `Bool'
+  #}
+{# fun rure_shortest_match as ^ { `RurePtr'
+                                , `Ptr UInt8'
+                                , coerce `CSize'
+                                , coerce `CSize'
+                                , castPtr `Ptr CSize'
+                                } -> `Bool'
+  #}
+{# fun rure_capture_name_index as ^ { `RurePtr'
+                                    , `CString'
+                                    } -> `Int32'
+  #}
+{# fun rure_iter_new as ^ { `RurePtr' } -> `Ptr RureIter' id #}
+{# fun rure_iter_next as ^ { `RureIterPtr'
+                           , `Ptr UInt8'
+                           , coerce `CSize'
+                           , castPtr `Ptr RureMatch'
+                           } -> `Bool'
+  #}
+{# fun rure_iter_next_captures as ^ { `RureIterPtr'
+                                    , `Ptr UInt8'
+                                    , coerce `CSize'
+                                    , `RureCapturesPtr'
+                                    } -> `Bool'
+  #}
+{# fun rure_captures_new as ^ { `RurePtr' } -> `Ptr RureCaptures' id #}
+{# fun rure_captures_at as ^ { `RureCapturesPtr', coerce `CSize', castPtr `Ptr RureMatch' } -> `Bool' #}
+{# fun rure_captures_len as ^ { `RureCapturesPtr' } -> `CSize' coerce #}
+{# fun rure_options_new as ^ { } -> `Ptr RureOptions' id #}
+{# fun rure_options_size_limit as ^ { `RureOptionsPtr', coerce `CSize' } -> `()' #}
+{# fun rure_options_dfa_size_limit as ^ { `RureOptionsPtr', coerce `CSize' } -> `()' #}
+{# fun rure_compile_set as ^ { id `Ptr (Ptr UInt8)'
+                             , castPtr `Ptr CSize'
+                             , coerce `CSize'
+                             , coerce `RureFlags'
+                             , `RureOptionsPtr'
+                             , `RureErrorPtr'
+                             } -> `Ptr RureSet' id
+  #}
+{# fun rure_set_is_match as ^ { `RureSetPtr'
+                              , `Ptr UInt8'
+                              , coerce `CSize'
+                              , coerce `CSize'
+                              } -> `Bool'
+  #}
+{# fun rure_set_matches as ^ { `RureSetPtr'
+                             , `Ptr UInt8'
+                             , coerce `CSize'
+                             , coerce `CSize'
+                             , castPtr `Ptr CBool'
+                             } -> `Bool'
+  #}
+{# fun rure_set_len as ^ { `RureSetPtr' } -> `CSize' coerce #}
+{# fun rure_error_new as ^ { } -> `Ptr RureError' id #}
+{# fun rure_error_message as ^ { `RureErrorPtr' } -> `String' #}
+{# fun rure_escape_must as ^ { `CString' } -> `CString' #}
+{# fun rure_cstring_free as ^ { `CString' } -> `()' #}
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,67 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Main (main) where
+
+import qualified Data.ByteString  as BS
+import           Regex.Rure
+import           Test.Tasty
+import           Test.Tasty.HUnit
+
+main :: IO ()
+main = defaultMain $
+    testGroup "High-level interface"
+        [ testCase "Matches a file extension" (matchY "\\.awk$" "/usr/share/vim/vim82/tools/mve.awk")
+        , testCase "Matches file extensions" $
+            matchesY ["\\.y$", "\\.hs$", "\\.chs$", "\\.x$", "\\.lhs"] "Setup.hs"
+        , testCase "Matches pattern" $
+            matchesSet ["\\.y$", "\\.hs$", "\\.chs$", "\\.x$", "\\.lhs"] "Setup.hs"
+            [False, True, False, False, False]
+        , testCase "Matches at" (findAt "libj\\.dylib$" "/Applications/j903/bin/libj.dylib" (RureMatch 23 33))
+        , testCase "Matches at (plural)" $
+            matchesAt "\\d{4}-\\d{2}-\\d{2}" "2012-03-14 2013-01-01 2014-07-05"
+                [ RureMatch 0 10
+                , RureMatch 11 21
+                , RureMatch 22 32
+                ]
+        ]
+
+matchesAt :: BS.ByteString
+          -> BS.ByteString
+          -> [RureMatch]
+          -> Assertion
+matchesAt re haystack expected =
+    let (Right actual) = hsMatches rureDefaultFlags re haystack
+        in actual @?= expected
+
+findAt :: BS.ByteString
+       -> BS.ByteString
+       -> RureMatch
+       -> Assertion
+findAt re haystack expected =
+    let (Right actual) = hsFind rureDefaultFlags re haystack
+        in actual @?= Just expected
+
+matchesSet :: [BS.ByteString]
+           -> BS.ByteString
+           -> [Bool]
+           -> Assertion
+matchesSet res haystack expected =
+    case hsSetMatches rureDefaultFlags res haystack of
+        Left err     -> assertFailure err
+        Right actual -> actual @?= expected
+
+matchesY :: [BS.ByteString]
+         -> BS.ByteString
+         -> Assertion
+matchesY res haystack =
+    case hsSetIsMatch rureDefaultFlags res haystack of
+        Left err -> assertFailure err
+        Right b  -> assertBool "matches (set)" b
+
+matchY :: BS.ByteString
+       -> BS.ByteString
+       -> Assertion
+matchY re haystack =
+    case hsIsMatch rureDefaultFlags re haystack of
+        Left err -> assertFailure err
+        Right b  -> assertBool "matches" b
