Use hatch over tox (#262)

diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index de34370..ae4e4f1 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -2,7 +2,7 @@
 on:
   workflow_dispatch:
   push:
-    branches: "main"
+    branches: ["main"]
     tags-ignore: ["**"]
   pull_request:
   schedule:
@@ -36,8 +36,18 @@
         uses: actions/setup-python@v5
         with:
           python-version: "3.12"
-      - name: Install tox
-        run: python -m pip install tox
+      - name: Pick environment to run
+        run: |
+          import codecs; import os
+          py = "${{ matrix.py }}"
+          py = "test.{}".format(py if py.startswith("pypy") else f"py{py}")
+          print(f"Picked {py}")
+          with codecs.open(os.environ["GITHUB_ENV"], mode="a", encoding="utf-8") as file_handler:
+              file_handler.write("FORCE_COLOR=1\n")
+              file_handler.write(f"ENV={py}\n")
+        shell: python
+      - name: Install hatch
+        run: python -m pip install hatch
       - uses: actions/checkout@v4
         with:
           fetch-depth: 0
@@ -45,36 +55,28 @@
         uses: actions/setup-python@v5
         with:
           python-version: ${{ matrix.py }}
-      - name: Pick environment to run
+      - name: Setup test environment
         run: |
-          import codecs
-          import os
-          import platform
-          import sys
-          cpy = platform.python_implementation() == "CPython"
-          base =("{}{}{}" if cpy else "{}{}").format("py" if cpy else "pypy", *sys.version_info[0:2])
-          env = "TOXENV={}\n".format(base)
-          print("Picked:\n{}for{}".format(env, sys.version))
-          with codecs.open(os.environ["GITHUB_ENV"], "a", "utf-8") as file_handler:
-               file_handler.write(env)
-        shell: python
-      - name: Setup test suite
-        run: tox -vv --notest
+          hatch -v env create ${ENV}
+          hatch run ${ENV}:pip freeze
+        shell: bash
       - name: Run test suite
-        run: tox --skip-pkg-install
+        run: hatch -v run ${ENV}:run
         env:
           PYTEST_ADDOPTS: "-vv --durations=20"
           CI_RUN: "yes"
+        shell: bash
       - name: Rename coverage report file
         run: |
           import os; import sys;
-          os.rename(f".tox/.coverage.{os.environ['TOXENV']}", f".tox/.coverage.{os.environ['TOXENV']}-{sys.platform}")
+          os.rename(f"report{os.sep}.coverage.${{ matrix.py }}", f"report{os.sep}.coverage.${{ matrix.py }}-{sys.platform}")
         shell: python
       - name: Upload coverage data
-        uses: actions/upload-artifact@v3
+        uses: actions/upload-artifact@v4
         with:
-          name: coverage-data
-          path: ".tox/.coverage.*"
+          name: coverage-${{ matrix.os }}-${{ matrix.py }}
+          path: "report/.coverage.*"
+          retention-days: 3
 
   coverage:
     name: Combine coverage
@@ -87,29 +89,30 @@
       - uses: actions/setup-python@v5
         with:
           python-version: "3.12"
-      - name: Install tox
-        run: python -m pip install tox
+      - name: Let us have colors
+        run: echo "FORCE_COLOR=true" >> "$GITHUB_ENV"
+      - name: Install hatch
+        run: python -m pip install hatch
       - name: Setup coverage tool
-        run: tox -e coverage --notest
-      - name: Install package builder
-        run: python -m pip install build
-      - name: Build package
-        run: pyproject-build --wheel .
+        run: |
+          hatch -v env create coverage
+          hatch run coverage:pip freeze
       - name: Download coverage data
-        uses: actions/download-artifact@v3
+        uses: actions/download-artifact@v4
         with:
-          name: coverage-data
-          path: .tox
+          path: report
+          pattern: coverage-*
+          merge-multiple: true
       - name: Combine and report coverage
-        run: tox -e coverage
+        run: hatch run coverage:run
       - name: Upload HTML report
-        uses: actions/upload-artifact@v3
+        uses: actions/upload-artifact@v4
         with:
           name: html-report
-          path: .tox/htmlcov
+          path: report/html
 
   check:
-    name: ${{ matrix.tox_env }} - ${{ matrix.os }}
+    name: ${{ matrix.env.name }} - ${{ matrix.os }}
     runs-on: ${{ matrix.os }}
     strategy:
       fail-fast: false
@@ -117,13 +120,11 @@
         os:
           - ubuntu-22.04
           - windows-2022
-        tox_env:
-          - dev
-          - type
-          - docs
-          - readme
-        exclude:
-          - { os: windows-latest, tox_env: readme }
+        env:
+          - {"name": "default", "target": "show"}
+          - {"name": "type", "target": "run"}
+          - {"name": "docs", "target": "build"}
+          - {"name": "readme", "target": "run"}
     steps:
       - uses: actions/checkout@v4
         with:
@@ -132,9 +133,11 @@
         uses: actions/setup-python@v5
         with:
           python-version: "3.12"
-      - name: Install tox
-        run: python -m pip install tox
-      - name: Setup test suite
-        run: tox -vv --notest -e ${{ matrix.tox_env }}
-      - name: Run test suite
-        run: tox --skip-pkg-install -e ${{ matrix.tox_env }}
+      - name: Install hatch
+        run: python -m pip install hatch
+      - name: Setup ${{ matrix.env.name }}
+        run: |
+          hatch -v env create ${{ matrix.env.name }}
+          hatch run ${{ matrix.env.name }}:pip freeze
+      - name: Run ${{ matrix.env.name }}
+        run: hatch -v run ${{ matrix.env.name }}:${{ matrix.env.target }}
diff --git a/.gitignore b/.gitignore
index f5f1096..86a75e7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,7 @@
-/.idea/
-/.vscode/
-
 *.pyc
 *.egg-info
-tmp/
-build/
-dist/
-.tox/
+/dist
+/.tox
 /src/platformdirs/version.py
+/report
+/docs/build
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index c3856aa..705598f 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -21,11 +21,6 @@
       - id: ruff-format
       - id: ruff
         args: [ "--fix", "--unsafe-fixes", "--exit-non-zero-on-fix" ]
-  - repo: https://github.com/tox-dev/tox-ini-fmt
-    rev: "1.3.1"
-    hooks:
-      - id: tox-ini-fmt
-        args: ["-p", "fix"]
   - repo: https://github.com/tox-dev/pyproject-fmt
     rev: "1.7.0"
     hooks:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..90ba3d8
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,3 @@
+This project uses [hatch](https://hatch.pypa.io) development, therefore consult that documentation for more in-depth how
+to. To see a list of available environments use `hatch env show`, for example to run the test suite under Python 3.12
+can type in a shell `hatch run test.py3.12:run`.
diff --git a/pyproject.toml b/pyproject.toml
index 3e051c8..6ae2403 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -57,6 +57,9 @@
   "pytest-cov>=4.1",
   "pytest-mock>=3.12",
 ]
+optional-dependencies.type = [
+  "mypy>=1.8",
+]
 urls.Documentation = "https://platformdirs.readthedocs.io"
 urls.Homepage = "https://github.com/platformdirs/platformdirs"
 urls.Source = "https://github.com/platformdirs/platformdirs"
@@ -67,6 +70,77 @@
 build.targets.sdist.include = ["/src", "/tests", "/tox.ini"]
 version.source = "vcs"
 
+[tool.hatch.envs.default]
+description = "Development environment"
+features = ["test", "docs", "type"]
+scripts = { show = ["python -m pip list --format=columns", 'python -c "import sys; print(sys.executable)"'] }
+
+[tool.hatch.envs.test]
+template = "test"
+# dev-mode = false # cannot enable this until https://github.com/pypa/hatch/issues/1237
+description = "Run the test suite"
+matrix = [{ python = ["3.12", "3.11", "3.10", "3.9", "3.8", "pypy3.9"] }]
+features = ["test"]
+env-vars = { COVERAGE_FILE = "report/.coverage.{matrix:python}", COVERAGE_PROCESS_START = "pyproject.toml", _COVERAGE_SRC = "src/platformdirs" }
+[tool.hatch.envs.test.scripts]
+run = ["""
+    pytest --junitxml report/junit.{matrix:python}.xml --cov src/platformdirs --cov tests \
+      --cov-config=pyproject.toml --no-cov-on-fail --cov-report term-missing:skip-covered --cov-context=test \
+      --cov-report html:report/html{matrix:python} --cov-report xml:report/coverage{matrix:python}.xml \
+      tests
+"""]
+
+[tool.hatch.envs.coverage]
+template = "coverage"
+description = "combine coverage files and generate diff"
+dependencies = ["covdefaults>=2.3", "coverage[toml]>=7.3.2", "diff-cover>=8.0.1"]
+env-vars = { COVERAGE_FILE = "report/.coverage" }
+[tool.hatch.envs.coverage.scripts]
+run = [
+  "coverage combine report",
+  "coverage report --skip-covered --show-missing",
+  "coverage xml -o report/coverage.xml",
+  "coverage html -d report/html",
+  "diff-cover --compare-branch {env:DIFF_AGAINST:origin/main} report/coverage.xml",
+]
+
+[tool.hatch.envs.type]
+template = "type"
+description = "Run the type checker"
+python = "3.12"
+dev-mode = false
+features = ["type", "test"]
+scripts = { run = ["mypy --strict src", "mypy --strict tests"] }
+
+[tool.hatch.envs.fix]
+template = "fix"
+description = "Run the pre-commit tool to lint and autofix issues"
+python = "3.12"
+detached = true
+dependencies = ["pre-commit>=3.6"]
+scripts = { "run" = ["pre-commit run --all-files --show-diff-on-failure"] }
+
+[tool.hatch.envs.docs]
+template = "docs"
+description = "Build documentation using Sphinx"
+python = "3.12"
+dev-mode = false
+features = ["docs"]
+[tool.hatch.envs.docs.scripts]
+build = [
+  """python -c "import glob; import subprocess; subprocess.call(['proselint'] + glob.glob('docs/*.rst'))" """,
+  """python -c "from shutil import rmtree; rmtree('docs/build', ignore_errors=True)" """,
+  "sphinx-build -d docs/build/tree docs docs/build --color -b html",
+  """python -c "from pathlib import Path; p=(Path('docs')/'build'/'index.html').absolute().as_uri(); print('Documentation built under '+p)" """,
+]
+
+[tool.hatch.envs.readme]
+template = "readme"
+description = "check that the long description is valid"
+python = "3.12"
+dependencies = ["build[virtualenv]>=1.0.3", "twine>=4.0.2", "check-wheel-contents>=0.6.0"]
+scripts = { "run" = ["python -m build -o dist", "twine check dist/*.whl dist/*.tar.gz", "check-wheel-contents dist"] }
+
 [tool.ruff]
 select = ["ALL"]
 line-length = 120
diff --git a/tox.ini b/tox.ini
deleted file mode 100644
index a6d51ad..0000000
--- a/tox.ini
+++ /dev/null
@@ -1,115 +0,0 @@
-[tox]
-requires =
-    tox>=4.2
-env_list =
-    fix
-    py312
-    py311
-    py310
-    py39
-    py38
-    pypy3
-    type
-    coverage
-    readme
-    docs
-skip_missing_interpreters = true
-
-[testenv]
-description = run the unit tests with pytest under {basepython}
-package = wheel
-wheel_build_env = .pkg
-extras =
-    test
-pass_env =
-    ANDROID_DATA
-    ANDROID_ROOT
-set_env =
-    COVERAGE_FILE = {toxworkdir}/.coverage.{envname}
-    COVERAGE_PROCESS_START = {toxinidir}/pyproject.toml
-    _COVERAGE_SRC = {envsitepackagesdir}/platformdirs
-commands =
-    pytest {tty:--color=yes} {posargs: \
-      --junitxml {toxworkdir}{/}junit.{envname}.xml --cov {envsitepackagesdir}{/}platformdirs \
-      --cov {toxinidir}{/}tests \
-      --cov-config=pyproject.toml --no-cov-on-fail --cov-report term-missing:skip-covered --cov-context=test \
-      --cov-report html:{envtmpdir}{/}htmlcov --cov-report xml:{toxworkdir}{/}coverage.{envname}.xml \
-      tests}
-
-[testenv:fix]
-description = run static analysis and style check using flake8
-skip_install = true
-deps =
-    pre-commit>=3.5
-pass_env =
-    HOMEPATH
-    PROGRAMDATA
-commands =
-    pre-commit run --all-files --show-diff-on-failure
-
-[testenv:type]
-description = run type check on code base
-deps =
-    mypy==1.7.1
-set_env =
-    {tty:MYPY_FORCE_COLOR = 1}
-commands =
-    mypy --strict src
-    mypy --strict tests
-
-[testenv:coverage]
-description = combine coverage files and generate diff (against DIFF_AGAINST defaulting to origin/main)
-skip_install = true
-deps =
-    covdefaults>=2.3
-    coverage[toml]>=7.3.2
-    diff-cover>=8.0.1
-extras =
-parallel_show_output = true
-pass_env =
-    DIFF_AGAINST
-set_env =
-    COVERAGE_FILE = {toxworkdir}/.coverage
-commands =
-    coverage combine
-    coverage report --skip-covered --show-missing
-    coverage xml -o {toxworkdir}/coverage.xml
-    coverage html -d {toxworkdir}/htmlcov
-    diff-cover --compare-branch {env:DIFF_AGAINST:origin/main} {toxworkdir}/coverage.xml
-depends =
-    py312
-    py311
-    py310
-    py39
-    py38
-    pypy3
-
-[testenv:readme]
-description = check that the long description is valid
-skip_install = true
-deps =
-    build[virtualenv]>=1.0.3
-    twine>=4.0.2
-pass_env =
-    *
-change_dir = {toxinidir}
-commands =
-    python -m build -o {envtmpdir} .
-    twine check {envtmpdir}/*
-
-[testenv:docs]
-extras =
-    docs
-commands =
-    python -c 'import glob; import subprocess; subprocess.call(["proselint"] + glob.glob("docs/*.rst"))'
-    sphinx-build -d "{envtmpdir}/doctree" docs "{toxworkdir}/docs_out" --color -b html {posargs}
-    python -c 'import pathlib; print("documentation available under \{0\}".format((pathlib.Path(r"{toxworkdir}") / "docs_out" / "index.html").as_uri()))'
-
-[testenv:dev]
-description = generate a DEV environment
-package = editable
-extras =
-    test
-commands =
-    python -m pip list --format=columns
-    python -c 'import sys; print(sys.executable)'