diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,11 @@
+# Changelog
+
+`hslua-module-paths` uses [PVP Versioning][1].
+The changelog is available [on GitHub][2].
+
+## 0.0.1
+
+* Initially created.
+
+[1]: https://pvp.haskell.org
+[2]: https://github.com/hslua/hslua-module-path/releases
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2020 Albert Krewinkel
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,183 @@
+# hslua-module-path
+
+[![GitHub CI][CI badge]](https://github.com/hslua/hslua-module-paths/actions)
+[![Hackage][Hackage badge]](https://hackage.haskell.org/package/hslua-module-path)
+[![Stackage Lts][Stackage Lts badge]](http://stackage.org/lts/package/hslua-module-path)
+[![Stackage Nightly][Stackage Nightly badge]](http://stackage.org/nightly/package/hslua-module-path)
+[![MIT license][License badge]](LICENSE)
+
+[CI badge]: https://github.com/hslua/hslua-module-path/workflows/CI/badge.svg
+[Hackage badge]: https://img.shields.io/hackage/v/hslua-module-path.svg?logo=haskell
+[Stackage Lts badge]: http://stackage.org/package/hslua-module-path/badge/lts
+[Stackage Nightly badge]: http://stackage.org/package/hslua-module-path/badge/nightly
+[License badge]: https://img.shields.io/badge/license-MIT-blue.svg
+
+Lua module to work with file paths.
+
+## path
+
+Module for file path manipulations.
+
+#### separator
+
+The character that separates directories.
+
+#### search\_path\_separator
+
+The character that is used to separate the entries in the `PATH`
+environment variable.
+
+### Functions
+
+#### directory (filepath)
+
+Get the directory name; move up one level.
+
+Parameters:
+
+filepath  
+path (string)
+
+Returns:
+
+-   The filepath up to the last directory separator. (string)
+
+#### filename (filepath)
+
+Get the file name.
+
+Parameters:
+
+filepath  
+path (string)
+
+Returns:
+
+-   File name part of the input path. (string)
+
+#### is\_absolute (filepath)
+
+Checks whether a path is absolute, i.e. not fixed to a root.
+
+Parameters:
+
+filepath  
+path (string)
+
+Returns:
+
+-   `true` iff `filepath` is an absolute path, `false` otherwise.
+    (boolean)
+
+#### is\_relative (filepath)
+
+Checks whether a path is relative or fixed to a root.
+
+Parameters:
+
+filepath  
+path (string)
+
+Returns:
+
+-   `true` iff `filepath` is a relative path, `false` otherwise.
+    (boolean)
+
+#### join (filepaths)
+
+Join path elements back together by the directory separator.
+
+Parameters:
+
+filepaths  
+path components (list of strings)
+
+Returns:
+
+-   The joined path. (string)
+
+#### make\_relative (path, root, unsafe)
+
+Contract a filename, based on a relative path. Note that the resulting
+path will never introduce `..` paths, as the presence of symlinks means
+`../b` may not reach `a/b` if it starts from `a/c`. For a worked example
+see [this blog
+post](http://neilmitchell.blogspot.co.uk/2015/10/filepaths-are-subtle-symlinks-are-hard.html).
+
+Parameters:
+
+path  
+path to be made relative (string)
+
+root  
+root path (string)
+
+unsafe  
+whether to allow `..` in the result. (boolean)
+
+Returns:
+
+-   contracted filename (string)
+
+#### normalize (filepath)
+
+Normalizes a path.
+
+-   `//` outside of the drive can be made blank
+-   `/` becomes the `path.separator`
+-   `./` -&gt; ’’
+-   an empty path becomes `.`
+
+Parameters:
+
+filepath  
+path (string)
+
+Returns:
+
+-   The normalized path. (string)
+
+#### split (filepath)
+
+Splits a path by the directory separator.
+
+Parameters:
+
+filepath  
+path (string)
+
+Returns:
+
+-   List of all path components. (list of strings)
+
+#### split\_extension (filepath)
+
+Splits the last extension from a file path and returns the parts. The
+extension, if present, includes the leading separator; if the path has
+no extension, then the empty string is returned as the extension.
+
+Parameters:
+
+filepath  
+path (string)
+
+Returns:
+
+-   filepath without extension (string)
+
+-   extension or empty string (string)
+
+#### split\_search\_path (search\_path)
+
+Takes a string and splits it on the `search_path_separator` character.
+Blank items are ignored on Windows, and converted to `.` on Posix. On
+Windows path elements are stripped of quotes.
+
+Parameters:
+
+search\_path  
+platform-specific search path (string)
+
+Returns:
+
+-   list of directories in search path (list of strings)
diff --git a/hslua-module-path.cabal b/hslua-module-path.cabal
new file mode 100644
--- /dev/null
+++ b/hslua-module-path.cabal
@@ -0,0 +1,71 @@
+cabal-version:       2.4
+name:                hslua-module-path
+version:             0.0.1
+synopsis:            Lua module to work with file paths.
+description:         Lua module to work with file paths in a platform
+                     independent way.
+homepage:            https://github.com/hslua/hslua-module-path
+bug-reports:         https://github.com/hslua/hslua-module-path/issues
+license:             MIT
+license-file:        LICENSE
+author:              Albert Krewinkel
+maintainer:          Albert Krewinkel <albert@zeitkraut.de>
+copyright:           © 2020-2021 Albert Krewinkel
+category:            Foreign
+build-type:          Simple
+extra-doc-files:     README.md
+                     CHANGELOG.md
+extra-source-files:  test/test-path.lua
+tested-with:         GHC == 8.0.2
+                   , GHC == 8.2.2
+                   , GHC == 8.4.4
+                   , GHC == 8.6.5
+                   , GHC == 8.8.4
+                   , GHC == 8.10.2
+
+source-repository head
+  type:                git
+  location:            https://github.com/hslua/hslua-module-path.git
+
+common common-options
+  build-depends:       base       >= 4.9.1  && < 5
+                     , filepath   >= 1.4    && < 1.5
+                     , hslua      >= 1.2    && < 1.4
+                     , text       >= 1.0    && < 1.3
+  
+  ghc-options:         -Wall
+                       -Wcompat
+                       -Widentities
+                       -Wincomplete-uni-patterns
+                       -Wincomplete-record-updates
+  if impl(ghc >= 8.0)
+    ghc-options:       -Wredundant-constraints
+  if impl(ghc >= 8.2)
+    ghc-options:       -fhide-source-paths
+  if impl(ghc >= 8.4)
+    ghc-options:       -Wmissing-export-lists
+                       -Wpartial-fields
+  if impl(ghc >= 8.8)
+    ghc-options:       -Wmissing-deriving-strategies
+
+  default-language:    Haskell2010
+
+library
+  import:              common-options
+  hs-source-dirs:      src
+  exposed-modules:     Foreign.Lua.Module.Path
+
+test-suite hslua-module-path-test
+  import:              common-options
+  type:                exitcode-stdio-1.0
+  hs-source-dirs:      test
+  main-is:             test-hslua-module-path.hs
+  build-depends:       base
+                     , hslua-module-path
+                     , tasty
+                     , tasty-hunit
+                     , tasty-lua  >= 0.2    && < 0.3
+                     , text
+  ghc-options:         -threaded
+                       -rtsopts
+                       -with-rtsopts=-N
diff --git a/src/Foreign/Lua/Module/Path.hs b/src/Foreign/Lua/Module/Path.hs
new file mode 100644
--- /dev/null
+++ b/src/Foreign/Lua/Module/Path.hs
@@ -0,0 +1,429 @@
+{-# LANGUAGE CPP #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-|
+Module      : Foreign.Lua.Module.Path
+Copyright   : © 2020 Albert Krewinkel
+License     : MIT
+Maintainer  : Albert Krewinkel <albert+hslua@zeitkraut.de>
+Stability   : alpha
+Portability : Requires GHC 8 or later.
+
+Lua module to work with file paths.
+-}
+module Foreign.Lua.Module.Path (
+  -- * Module
+    pushModule
+  , preloadModule
+  , documentedModule
+
+  -- * Path manipulations
+  , add_extension
+  , combine
+  , directory
+  , filename
+  , is_absolute
+  , is_relative
+  , join
+  , make_relative
+  , normalize
+  , split
+  , split_extension
+  , split_search_path
+  , treat_strings_as_paths
+  )
+where
+
+import Control.Monad (forM_)
+import Data.Char (toLower)
+#if !MIN_VERSION_base(4,11,0)
+import Data.Semigroup (Semigroup(..))  -- includes (<>)
+#endif
+import Data.Text (Text)
+import Foreign.Lua
+  ( Lua, NumResults (..), getglobal, getmetatable, nth, pop, rawset
+  , remove, top )
+import Foreign.Lua.Call
+import Foreign.Lua.Module hiding (preloadModule, pushModule)
+import Foreign.Lua.Peek (Peeker, peekBool, peekList, peekString)
+import Foreign.Lua.Push (pushBool, pushList, pushString, pushText)
+
+import qualified Data.Text as T
+import qualified Foreign.Lua.Module as Module
+import qualified System.FilePath as Path
+
+--
+-- Module
+--
+
+description :: Text
+description = "Module for file path manipulations."
+
+documentedModule :: Module
+documentedModule = Module
+  { moduleName = "path"
+  , moduleFields = fields
+  , moduleDescription = description
+  , moduleFunctions = functions
+  }
+
+-- | Pushes the @path@ module to the Lua stack.
+pushModule :: Lua NumResults
+pushModule = 1 <$ pushModule' documentedModule
+
+-- | Add the @path@ module under the given name to the table of
+-- preloaded packages.
+preloadModule :: String -> Lua ()
+preloadModule name = Module.preloadModule $
+  documentedModule { moduleName = T.pack name }
+
+-- | Helper function which pushes the module with its fields. This
+-- function should be removed once the respective hslua bug has been
+-- fixed.
+pushModule' :: Module -> Lua ()
+pushModule' mdl = do
+  Module.pushModule mdl
+  forM_ (moduleFields mdl) $ \field -> do
+    pushText (fieldName field)
+    fieldPushValue field
+    rawset (nth 3)
+
+--
+-- Fields
+--
+
+-- | Exported fields.
+fields :: [Field]
+fields =
+  [ separator
+  , search_path_separator
+  ]
+
+-- | Wrapper for @'Path.pathSeparator'@.
+separator :: Field
+separator = Field
+  { fieldName = "separator"
+  , fieldDescription = "The character that separates directories."
+  , fieldPushValue = pushString [Path.pathSeparator]
+  }
+
+-- | Wrapper for @'Path.searchPathSeparator'@.
+search_path_separator :: Field
+search_path_separator = Field
+  { fieldName = "search_path_separator"
+  , fieldDescription = "The character that is used to separate the entries in "
+                    <> "the `PATH` environment variable."
+  , fieldPushValue = pushString [Path.searchPathSeparator]
+  }
+
+--
+-- Functions
+--
+
+functions :: [(Text, HaskellFunction)]
+functions =
+  [ ("directory", directory)
+  , ("filename", filename)
+  , ("is_absolute", is_absolute)
+  , ("is_relative", is_relative)
+  , ("join", join)
+  , ("make_relative", make_relative)
+  , ("normalize", normalize)
+  , ("split", split)
+  , ("split_extension", split_extension)
+  , ("split_search_path", split_search_path)
+  , ("treat_strings_as_paths", treat_strings_as_paths)
+  ]
+
+-- | See @Path.takeDirectory@
+directory :: HaskellFunction
+directory = toHsFnPrecursor Path.normalise
+  <#> filepathParam
+  =#> [filepathResult "The filepath up to the last directory separator."]
+  #? "Get the directory name; move up one level."
+
+-- | See @Path.takeFilename@
+filename :: HaskellFunction
+filename = toHsFnPrecursor Path.takeFileName
+  <#> filepathParam
+  =#> [filepathResult "File name part of the input path."]
+  #? "Get the file name."
+
+-- | See @Path.isAbsolute@
+is_absolute :: HaskellFunction
+is_absolute = toHsFnPrecursor Path.isAbsolute
+  <#> filepathParam
+  =#> [booleanResult ("`true` iff `filepath` is an absolute path, " <>
+                      "`false` otherwise.")]
+  #? "Checks whether a path is absolute, i.e. not fixed to a root."
+
+-- | See @Path.isRelative@
+is_relative :: HaskellFunction
+is_relative = toHsFnPrecursor Path.isRelative
+  <#> filepathParam
+  =#> [booleanResult ("`true` iff `filepath` is a relative path, " <>
+                      "`false` otherwise.")]
+  #? "Checks whether a path is relative or fixed to a root."
+
+-- | See @Path.joinPath@
+join :: HaskellFunction
+join = toHsFnPrecursor Path.joinPath
+  <#> Parameter
+      { parameterPeeker = peekList peekFilePath
+      , parameterDoc = ParameterDoc
+        { parameterName = "filepaths"
+        , parameterType = "list of strings"
+        , parameterDescription = "path components"
+        , parameterIsOptional = False
+        }
+      }
+  =#> [filepathResult "The joined path."]
+  #? "Join path elements back together by the directory separator."
+
+make_relative :: HaskellFunction
+make_relative = toHsFnPrecursor makeRelative
+  <#> parameter
+        peekFilePath
+        "string"
+        "path"
+        "path to be made relative"
+  <#> parameter
+        peekFilePath
+        "string"
+        "root"
+        "root path"
+  <#> optionalParameter
+        peekBool
+        "boolean"
+        "unsafe"
+        "whether to allow `..` in the result."
+  =#> [filepathResult "contracted filename"]
+  #? mconcat
+     [ "Contract a filename, based on a relative path. Note that the "
+     , "resulting path will never introduce `..` paths, as the "
+     , "presence of symlinks means `../b` may not reach `a/b` if it "
+     , "starts from `a/c`. For a worked example see "
+     , "[this blog post](http://neilmitchell.blogspot.co.uk"
+     , "/2015/10/filepaths-are-subtle-symlinks-are-hard.html)."
+     ]
+
+-- | See @Path.normalise@
+normalize :: HaskellFunction
+normalize = toHsFnPrecursor Path.normalise
+  <#> filepathParam
+  =#> [filepathResult "The normalized path."]
+  #? T.unlines
+     [ "Normalizes a path."
+     , ""
+     , "- `//` outside of the drive can be made blank"
+     , "- `/` becomes the `path.separator`"
+     , "- `./` -> \'\'"
+     , "- an empty path becomes `.`"
+     ]
+
+-- | See @Path.splitDirectories@.
+--
+-- Note that this does /not/ wrap @'Path.splitPath'@, as that function
+-- adds trailing slashes to each directory, which is often inconvenient.
+split :: HaskellFunction
+split = toHsFnPrecursor Path.splitDirectories
+  <#> filepathParam
+  =#> [filepathListResult "List of all path components."]
+  #? "Splits a path by the directory separator."
+
+-- | See @Path.splitExtension@
+split_extension :: HaskellFunction
+split_extension = toHsFnPrecursor Path.splitExtension
+  <#> filepathParam
+  =#> [ FunctionResult
+        { fnResultPusher = pushString . fst
+        , fnResultDoc = FunctionResultDoc
+          { functionResultType = "string"
+          , functionResultDescription = "filepath without extension"
+          }
+        },
+        FunctionResult
+        { fnResultPusher = pushString . snd
+        , fnResultDoc = FunctionResultDoc
+          { functionResultType = "string"
+          , functionResultDescription = "extension or empty string"
+          }
+        }
+      ]
+  #? ("Splits the last extension from a file path and returns the parts. "
+      <> "The extension, if present, includes the leading separator; "
+      <> "if the path has no extension, then the empty string is returned "
+      <> "as the extension.")
+
+-- | Wraps function @'Path.splitSearchPath'@.
+split_search_path :: HaskellFunction
+split_search_path = toHsFnPrecursor Path.splitSearchPath
+  <#> Parameter
+      { parameterPeeker = peekString
+      , parameterDoc = ParameterDoc
+        { parameterName = "search_path"
+        , parameterType = "string"
+        , parameterDescription = "platform-specific search path"
+        , parameterIsOptional = False
+        }
+      }
+  =#> [filepathListResult "list of directories in search path"]
+  #? ("Takes a string and splits it on the `search_path_separator` "
+      <> "character. Blank items are ignored on Windows, "
+      <> "and converted to `.` on Posix. "
+      <> "On Windows path elements are stripped of quotes.")
+
+-- | Join two paths with a directory separator. Wraps @'Path.combine'@.
+combine :: HaskellFunction
+combine = toHsFnPrecursor Path.combine
+  <#> filepathParam
+  <#> filepathParam
+  =#> [filepathResult "combined paths"]
+  #? "Combine two paths with a path separator."
+
+-- | Adds an extension to a file path. Wraps @'Path.addExtension'@.
+add_extension :: HaskellFunction
+add_extension = toHsFnPrecursor Path.addExtension
+  <#> filepathParam
+  <#> Parameter
+      { parameterPeeker = peekString
+      , parameterDoc = ParameterDoc
+        { parameterName = "extension"
+        , parameterType = "string"
+        , parameterDescription = "an extension, with or without separator dot"
+        , parameterIsOptional = False
+        }
+      }
+  =#> [filepathResult "filepath with extension"]
+  #? "Adds an extension, even if there is already one."
+
+stringAugmentationFunctions :: [(String, HaskellFunction)]
+stringAugmentationFunctions =
+  [ ("directory", directory)
+  , ("filename", filename)
+  , ("is_absolute", is_absolute)
+  , ("is_relative", is_relative)
+  , ("normalize", normalize)
+  , ("split", split)
+  , ("split_extension", split_extension)
+  , ("split_search_path", split_search_path)
+  ]
+
+treat_strings_as_paths :: HaskellFunction
+treat_strings_as_paths = HaskellFunction
+  { callFunction = do
+      let addField (k, v) =
+            pushString k *> pushHaskellFunction v *> rawset (nth 3)
+      -- for some reason we can't just dump all functions into the
+      -- string metatable, but have to use the string module for
+      -- non-metamethods.
+      pushString "" *> getmetatable top *> remove (nth 2)
+      mapM_ addField $ [("__add", add_extension), ("__div", combine)]
+      pop 1  -- string metatable
+
+      getglobal "string"
+      mapM_ addField stringAugmentationFunctions
+      pop 1 -- string module
+
+      return (0 :: NumResults)
+  , functionDoc = Nothing
+  }
+  #? "Augment the string module such that strings can be used as path objects."
+
+--
+-- Parameters
+--
+
+-- | Retrieves a file path from the stack.
+peekFilePath :: Peeker FilePath
+peekFilePath = peekString
+
+-- | Filepath function parameter.
+filepathParam :: Parameter FilePath
+filepathParam = Parameter
+  { parameterPeeker = peekFilePath
+  , parameterDoc = ParameterDoc
+    { parameterName = "filepath"
+    , parameterType = "string"
+    , parameterDescription = "path"
+    , parameterIsOptional = False
+    }
+  }
+
+-- | Result of a function returning a file path.
+filepathResult :: Text -- ^ Description
+               -> FunctionResult FilePath
+filepathResult desc = FunctionResult
+  { fnResultPusher = \fp -> pushString fp
+  , fnResultDoc = FunctionResultDoc
+    { functionResultType = "string"
+    , functionResultDescription = desc
+    }
+  }
+
+-- | List of filepaths function result.
+filepathListResult :: Text -- ^ Description
+                   -> FunctionResult [FilePath]
+filepathListResult desc = FunctionResult
+  { fnResultPusher = \fp -> pushList pushString fp
+  , fnResultDoc = FunctionResultDoc
+    { functionResultType = "list of strings"
+    , functionResultDescription = desc
+    }
+  }
+
+-- | Boolean function result.
+booleanResult :: Text -- ^ Description
+              -> FunctionResult Bool
+booleanResult desc = FunctionResult
+  { fnResultPusher = \b -> pushBool b
+  , fnResultDoc = FunctionResultDoc
+    { functionResultType = "boolean"
+    , functionResultDescription = desc
+    }
+  }
+
+--
+-- Helpers
+--
+
+-- | Alternative version of @'Path.makeRelative'@, which introduces @..@
+-- paths if desired.
+makeRelative :: FilePath      -- ^ path to be made relative
+             -> FilePath      -- ^ root directory from which to start
+             -> Maybe Bool    -- ^ whether to use unsafe relative paths.
+             -> FilePath
+makeRelative path root unsafe
+ | Path.equalFilePath root path = "."
+ | takeAbs root /= takeAbs path = path
+ | otherwise = go (dropAbs path) (dropAbs root)
+  where
+    go x "" = dropWhile Path.isPathSeparator x
+    go x y =
+      let (x1, x2) = breakPath x
+          (y1, y2) = breakPath y
+      in case () of
+        _ | Path.equalFilePath x1 y1 -> go x2 y2
+        _ | unsafe == Just True      -> Path.joinPath ["..", x1, go x2 y2]
+        _                            -> path
+
+    breakPath = both (dropWhile Path.isPathSeparator)
+              . break Path.isPathSeparator
+              . dropWhile Path.isPathSeparator
+
+    both f (a, b) = (f a, f b)
+
+    leadingPathSepOnWindows = \case
+      ""                  -> False
+      x | Path.hasDrive x -> False
+      c:_                 -> Path.isPathSeparator c
+
+    dropAbs x = if leadingPathSepOnWindows x then tail x else Path.dropDrive x
+
+    takeAbs x = if leadingPathSepOnWindows x
+                then [Path.pathSeparator]
+                else map (\y ->
+                            if Path.isPathSeparator y
+                            then Path.pathSeparator
+                            else toLower y)
+                         (Path.takeDrive x)
diff --git a/test/test-hslua-module-path.hs b/test/test-hslua-module-path.hs
new file mode 100644
--- /dev/null
+++ b/test/test-hslua-module-path.hs
@@ -0,0 +1,52 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-|
+Module      : Main
+Copyright   : © 2021 Albert Krewinkel
+License     : MIT
+Maintainer  : Albert Krewinkel <albert+hslua@zeitkraut.de>
+Stability   : stable
+Portability : Requires language extensions ForeignFunctionInterface,
+              OverloadedStrings.
+
+Tests for the `path` Lua module.
+-}
+
+import Control.Monad (void)
+import Foreign.Lua (Lua)
+import Foreign.Lua.Module.Path (preloadModule, pushModule)
+import Test.Tasty (TestTree, defaultMain, testGroup)
+import Test.Tasty.HUnit (assertEqual, testCase)
+import Test.Tasty.Lua (translateResultsFromFile)
+
+import qualified Foreign.Lua as Lua
+
+main :: IO ()
+main = do
+  luaTestResults <- Lua.run $ do
+    Lua.openlibs
+    Lua.requirehs "path" (void pushModule)
+    translateResultsFromFile "test/test-path.lua"
+  defaultMain $ testGroup "hslua-module-path" [tests, luaTestResults]
+
+-- | HSpec tests for the Lua 'system' module
+tests :: TestTree
+tests = testGroup "HsLua path module"
+  [ testCase "path module can be pushed to the stack" $
+      Lua.run (void pushModule)
+
+  , testCase "path module can be added to the preloader" . Lua.run $ do
+      Lua.openlibs
+      preloadModule "path"
+      assertEqual' "function not added to preloader" Lua.TypeFunction =<< do
+        Lua.getglobal' "package.preload.path"
+        Lua.ltype (-1)
+
+  , testCase "path module can be loaded as hspath" . Lua.run $ do
+      Lua.openlibs
+      preloadModule "hspath"
+      assertEqual' "loading the module fails " Lua.OK =<<
+        Lua.dostring "require 'hspath'"
+  ]
+
+assertEqual' :: (Show a, Eq a) => String -> a -> a -> Lua ()
+assertEqual' msg expected = Lua.liftIO . assertEqual msg expected
diff --git a/test/test-path.lua b/test/test-path.lua
new file mode 100644
--- /dev/null
+++ b/test/test-path.lua
@@ -0,0 +1,109 @@
+--
+-- Tests for the system module
+--
+local path = require 'path'
+local tasty = require 'tasty'
+
+local group = tasty.test_group
+local test = tasty.test_case
+local assert = tasty.assert
+
+-- Check existence static fields
+return {
+  group 'static fields' {
+    test('separator', function ()
+      assert.are_equal(type(path.separator), 'string')
+    end),
+    test('search_path_separator', function ()
+      assert.are_equal(type(path.search_path_separator), 'string')
+    end),
+  },
+
+  group 'splitting/joining' {
+    test('split_path is inverse of join', function ()
+      local paths = {'one', 'two', 'test'}
+      assert.are_same(path.split(path.join(paths)), paths)
+    end),
+  },
+
+  group 'split_extensions' {
+    test('filename', function ()
+      assert.are_equal(path.split_extension 'image.jpg', 'image')
+    end),
+    test('extension', function ()
+      assert.is_truthy(select(2, path.split_extension 'thesis.tex'), '.tex')
+      assert.are_equal(select(2, path.split_extension 'fstab'), '')
+    end),
+    test('concat gives inverts split', function ()
+      local filenames = {'/etc/passwd', '34a90-1.bat', 'backup.tar.gz'}
+      for _, filename in ipairs(filenames) do
+        local base, ext = path.split_extension(filename)
+        assert.are_equal(base .. ext, filename)
+      end
+    end),
+  },
+
+  group 'normalize' {
+    test('removes leading `./`', function ()
+      assert.are_equal(path.normalize('./a.md'), 'a.md')
+    end),
+    test('dedupe path separators', function ()
+      assert.are_equal(path.normalize('a//b'), path.join{'a', 'b'})
+    end)
+  },
+
+  group 'relative or absolute' {
+    test('xor', function ()
+      local test_paths = {
+        path.join{ 'hello', 'rudi'},
+        path.join{ '.', 'autoexec.bat'},
+        path.join{ 'C:', 'config.sys'},
+        path.join{ '/', 'etc', 'passwd'}
+      }
+      for _, fp in ipairs(test_paths) do
+        assert.is_truthy(path.is_relative(fp) == not path.is_absolute(fp))
+      end
+    end)
+  },
+
+  group 'strings as path objects' {
+    test('setup', path.treat_strings_as_paths),
+    test('split extension', function ()
+      local img = 'mandrill.jpg'
+      assert.are_equal(img:split_extension(), 'mandrill')
+    end),
+    test('conbine paths with `/`', function ()
+      assert.are_equal('a' / 'b', path.join{'a', 'b'})
+    end),
+    test('add extension with `+`', function ()
+      assert.are_equal('a' + 'b', path.join{'a.b'})
+    end),
+  },
+
+  group 'make_relative' {
+    test('just the filename if file is within path', function()
+      assert.are_equal(
+        path.make_relative('/foo/bar/file.txt', '/foo/bar'),
+        'file.txt'
+      )
+    end),
+    test('no change if name outside of reference dir', function()
+      assert.are_equal(
+        path.make_relative('/foo/baz/file.txt', '/foo/bar'),
+        '/foo/baz/file.txt'
+      )
+    end),
+    test('use `..` when allowing unsafe operation', function()
+      assert.are_equal(
+        path.make_relative('/foo/baz/file.txt', '/foo/bar', true),
+        '../baz/file.txt'
+      )
+    end),
+    test('return dot if both paths are the same', function()
+      assert.are_equal(
+        path.make_relative('/one/two/three', '/one/two/three/'),
+        '.'
+      )
+    end)
+  },
+}
