Haskell をちょっと触ってみる #0 ~ Haskell x Docker x Visual Studio Code ~

なんとなくHaskellが気になったので、どんな感じなのか軽く触ってみることにした。

まずは、動かせる環境を適当で良いから作ってみる。
色々試しやすいように、テストをかけるところまでやってみた。

## 環境

とりあえず、すぐに動かしたいので、Docker x VSCodeで適当に環境を作った。
できたDockerfile, docker-compose.yml, devcontainer.jsonは、以下参照。

https://github.com/rhirano0715/haskell_x_docker_x_vscode

### 環境補足

VSCodeの拡張機能があったのでインストール。

https://marketplace.visualstudio.com/items?itemName=haskell.haskell

Dockerイメージは、何も考えずlatestを使用。

https://hub.docker.com/_/haskell

doctestのパッケージがあったのでインストール。

https://hackage.haskell.org/package/doctest

VSCodeの拡張機能にGHCupが必要らしいので、インストール。

https://www.haskell.org/ghcup/install/

VSCodeの拡張機能にhaskell language server(hls)が必要らしいので、インストール。
2023/03/18時点だと、version 1.9.1.0が必要だった。

https://haskell-language-server.readthedocs.io/en/latest/installation.html#visual-studio-code

```bash
ghcup install hls 1.9.1.0
```

## プロジェクト作成

stackとcabalの2種類があるっぽい。cabalを使ってみた。

※プロジェクト名にアンダースコアを使用すると、プロジェクト作成は通るけど、`cabal test`とか`cabal run`で落ちた。

```bash
mkdir -p プロジェクト用のディレクトリパス
cd プロジェクト用のディレクトリパス
cabal update
cabal init
```

cabal initの選択項目は以下のようにした。

```bash
What does the package build:
   1) Library
 * 2) Executable
   3) Library and Executable
   4) Test suite
Your choice? [default: Executable] 3
Do you wish to overwrite existing files (backups will be created) (y/n)? [default: n] y
Please choose version of the Cabal specification to use:
   1) 1.24  (legacy)
   2) 2.0   (+ support for Backpack, internal sub-libs, '^>=' operator)
   3) 2.2   (+ support for 'common', 'elif', redundant commas, SPDX)
   4) 2.4   (+ support for '**' globbing)
 * 5) 3.0   (+ set notation for ==, common stanzas in ifs, more redundant commas, better pkgconfig-depends)
   6) 3.4   (+ sublibraries in 'mixins', optional 'default-language')
Your choice? [default: 3.0] 
Package name? [default: helloworld] 
Package version? [default: 0.1.0.0] 
Please choose a license:
   1) BSD-2-Clause
   2) BSD-3-Clause
   3) Apache-2.0
   4) MIT
   5) MPL-2.0
   6) ISC
   7) GPL-2.0-only
   8) GPL-3.0-only
   9) LGPL-2.1-only
  10) LGPL-3.0-only
  11) AGPL-3.0-only
  12) GPL-2.0-or-later
  13) GPL-3.0-or-later
  14) LGPL-2.1-or-later
  15) LGPL-3.0-or-later
  16) AGPL-3.0-or-later
  17) Other (specify)
Your choice? 4
Author name? [default: xxxx] 
Maintainer email? [default: xxxx] 
Project homepage URL? [optional] 
Project synopsis? [optional] 
Project category:
   1) Codec
   2) Concurrency
   3) Control
   4) Data
   5) Database
   6) Development
   7) Distribution
   8) Game
   9) Graphics
  10) Language
  11) Math
  12) Network
  13) Sound
  14) System
  15) Testing
  16) Text
  17) Web
  18) Other (specify)
Your choice? [default: (none)] 
Library source directory:
 * 1) src
   2) lib
   3) src-lib
   4) Other (specify)
Your choice? [default: src] 
Choose a language for your library:
 * 1) Haskell2010
   2) Haskell98
   3) GHC2021 (requires at least GHC 9.2)
   4) Other (specify)
Your choice? [default: Haskell2010] 
What is the main module of the executable:
 * 1) Main.hs
   2) Main.lhs
   3) Other (specify)
Your choice? [default: Main.hs] 
Application directory:
 * 1) app
   2) exe
   3) src-exe
   4) Other (specify)
Your choice? [default: app] 
Choose a language for your executable:
 * 1) Haskell2010
   2) Haskell98
   3) GHC2021 (requires at least GHC 9.2)
   4) Other (specify)
Your choice? [default: Haskell2010] 
Should I generate a test suite for the library? [default: y] 
What is the main module of the test suite?:
 * 1) Main.hs
   2) Main.lhs
   3) Other (specify)
Your choice? [default: Main.hs] 
Test directory? [default: test] 
Choose a language for your test suite:
 * 1) Haskell2010
   2) Haskell98
   3) GHC2021 (requires at least GHC 9.2)
   4) Other (specify)
Your choice? [default: Haskell2010] 
Add informative comments to each field in the cabal file. (y/n)? [default: y] 
[Log] Using cabal specification: 3.0
[Log] Creating fresh file LICENSE...
[Log] Creating fresh file CHANGELOG.md...
[Log] Creating fresh directory ./src...
[Log] Creating fresh file src/MyLib.hs...
[Log] Creating fresh directory ./app...
[Log] Creating fresh file app/Main.hs...
[Log] Creating fresh directory ./test...
[Log] Creating fresh file test/Main.hs...
[Log] Creating fresh file helloworld.cabal...
[Warning] No synopsis given. You should edit the .cabal file and add one.
[Info] You may want to edit the .cabal file and add a Description field.
```

## ビルド & 実行

`cabal init`でプロジェクトを作成した状態で、標準出力に"Hello, Haskell!"を出力するソースになっている。
`cabal run`で、ビルド & 実行できる。

```bash
root@f3d42513a02c:/workspace/projects/helloworld# cabal run
Resolving dependencies...
Build profile: -w ghc-9.4.4 -O1
In order, the following will be built (use -v for more details):
 - helloworld-0.1.0.0 (lib) (first run)
 - helloworld-0.1.0.0 (exe:helloworld) (first run)
Configuring library for helloworld-0.1.0.0..
Preprocessing library for helloworld-0.1.0.0..
Building library for helloworld-0.1.0.0..
[1 of 1] Compiling MyLib            ( src/MyLib.hs, /workspace/projects/helloworld/dist-newstyle/build/x86_64-linux/ghc-9.4.4/helloworld-0.1.0.0/build/MyLib.o, /workspace/projects/helloworld/dist-newstyle/build/x86_64-linux/ghc-9.4.4/helloworld-0.1.0.0/build/MyLib.dyn_o )
Configuring executable 'helloworld' for helloworld-0.1.0.0..
Preprocessing executable 'helloworld' for helloworld-0.1.0.0..
Building executable 'helloworld' for helloworld-0.1.0.0..
[1 of 1] Compiling Main             ( app/Main.hs, /workspace/projects/helloworld/dist-newstyle/build/x86_64-linux/ghc-9.4.4/helloworld-0.1.0.0/x/helloworld/build/helloworld/helloworld-tmp/Main.o )
[2 of 2] Linking /workspace/projects/helloworld/dist-newstyle/build/x86_64-linux/ghc-9.4.4/helloworld-0.1.0.0/x/helloworld/build/helloworld/helloworld
Hello, Haskell!
someFunc
root@f3d42513a02c:/workspace/projects/helloworld# 
```

## テスト

`cabal test`でテストを実行できる。

### doctest

1. cabalファイルにdoctestの構成を追加する。  

   ```cabal
   test-suite doctest
       type:                 exitcode-stdio-1.0
       default-language:     Haskell2010
       hs-source-dirs:       test/doctest
       ghc-options:          -Wall
       main-is:              doctest.hs
       build-depends:        base
                           , doctest
   ```

1. cabalファイルの`main-is`に指定したソースを作成する。  

   ```haskell
   module Main where

   import Test.DocTest

   main :: IO ()
   main = doctest ["src/MyLib.hs", "app/Main.hs"]
   ```

1. doctestを書く。以下は、app/Main.hsのmain関数に書く場合の例  
    1. テスト追加前  

       ```haskell
       main :: IO ()
       main = do
         putStrLn "Hello, Haskell!"
         MyLib.someFunc
       ```

    1. テスト追加後  

       ```haskell
       -- |
       -- Entry point
       -- >>> main
       -- Hello, Haskell!
       -- someFunc
       main :: IO ()
       main = do
         putStrLn "Hello, Haskell!"
         MyLib.someFunc
       ```

1. `cabal test`でテストを実行する  

   ```txt
   root@8c6481710a1b:/workspace/projects/helloworld# cabal test; echo $?
   Build profile: -w ghc-9.4.4 -O1
   In order, the following will be built (use -v for more details):
    - helloworld-0.1.0.0 (test:doctest) (first run)
    - helloworld-0.1.0.0 (test:helloworld-test) (first run)
   Preprocessing test suite 'doctest' for helloworld-0.1.0.0..
   Preprocessing test suite 'helloworld-test' for helloworld-0.1.0.0..
   Building test suite 'helloworld-test' for helloworld-0.1.0.0..
   Building test suite 'doctest' for helloworld-0.1.0.0..
   Running 1 test suites...
   Test suite helloworld-test: RUNNING...
   Test suite helloworld-test: PASS
   Test suite logged to:
   /workspace/projects/helloworld/dist-newstyle/build/x86_64-linux/ghc-9.4.4/helloworld-0.1.0.0/t/helloworld-test/test/helloworld-0.1.0.0-helloworld-test.log
   1 of 1 test suites (1 of 1 test cases) passed.
   Running 1 test suites...
   Test suite doctest: RUNNING...
   Test suite doctest: PASS
   Test suite logged to:
   /workspace/projects/helloworld/dist-newstyle/build/x86_64-linux/ghc-9.4.4/helloworld-0.1.0.0/t/doctest/test/helloworld-0.1.0.0-doctest.log
   1 of 1 test suites (1 of 1 test cases) passed.
   0
   root@8c6481710a1b:/workspace/projects/helloworld# 
   ```

テストにNGがあるときは、以下のような出力になる。

```txt
root@8c6481710a1b:/workspace/projects/helloworld# cabal test; echo $?
Build profile: -w ghc-9.4.4 -O1
In order, the following will be built (use -v for more details):
 - helloworld-0.1.0.0 (test:doctest) (first run)
 - helloworld-0.1.0.0 (test:helloworld-test) (first run)
Preprocessing test suite 'helloworld-test' for helloworld-0.1.0.0..
Preprocessing test suite 'doctest' for helloworld-0.1.0.0..
Building test suite 'helloworld-test' for helloworld-0.1.0.0..
Building test suite 'doctest' for helloworld-0.1.0.0..
Running 1 test suites...
Test suite helloworld-test: RUNNING...
Test suite helloworld-test: PASS
Test suite logged to:
/workspace/projects/helloworld/dist-newstyle/build/x86_64-linux/ghc-9.4.4/helloworld-0.1.0.0/t/helloworld-test/test/helloworld-0.1.0.0-helloworld-test.log
1 of 1 test suites (1 of 1 test cases) passed.
Running 1 test suites...
Test suite doctest: RUNNING...
app/Main.hs:7: failure in expression `main'
expected: Hello, Haskell
          someFunc
 but got: Hello, Haskell!
                        ^
          someFunc

Examples: 2  Tried: 2  Errors: 0  Failures: 1

Test suite doctest: FAIL
Test suite logged to:
/workspace/projects/helloworld/dist-newstyle/build/x86_64-linux/ghc-9.4.4/helloworld-0.1.0.0/t/doctest/test/helloworld-0.1.0.0-doctest.log
0 of 1 test suites (0 of 1 test cases) passed.
Error: cabal: Tests failed for test:doctest from helloworld-0.1.0.0.

1
root@8c6481710a1b:/workspace/projects/helloworld# 
```

### HSpec

1. cabalファイルにHSpecの構成を追加する。  

   ```cabal
   test-suite spec
       type:                 exitcode-stdio-1.0
       default-language:     Haskell2010
       hs-source-dirs:       test/hspec
       ghc-options:          -Wall
       main-is:              Spec.hs
       build-depends:        base
                           , hspec >= 1.3
                           , QuickCheck
                           , helloworld
       other-modules:        MyLibSpec
       build-tool-depends:
           , hspec-discover:hspec-discover
   ```

1. cabalファイルの`main-is`に指定した`test/hspec/Spec.hs`を作成する。  

   ```haskell
   {-# OPTIONS_GHC -F -pgmF hspec-discover #-}
   ```

1. `test/hspec/MyLibSpec.hs`にテストコードを書く。試しにInt二つを引数にし、足した結果を返す関数を対象にした。  

   ```haskell
   module MyLibSpec (spec) where

   import Test.Hspec
   import MyLib

   spec :: Spec
   spec = do
       describe "`addInt` takes two arguments of Int and returns the result of adding them." $ do
           it "When give addInt 1 and 2 then return 3" $
               MyLib.addInt 1 2 `shouldBe` 3
   ```

1. `src/MyLib.hs`にテスト対象関数を書き足す。  

   ```haskell
   module MyLib (someFunc, addInt) where

   (省略)

   addInt :: Int -> Int -> Int
   addInt x y = x + y
   ```

1. `cabal test`でテストを実行する  

   ```txt
   root@5499af50db5b:/workspace/projects/helloworld# cabal test; echo $?;
   Build profile: -w ghc-9.4.4 -O1
   In order, the following will be built (use -v for more details):
    - helloworld-0.1.0.0 (test:doctest) (first run)
    - helloworld-0.1.0.0 (test:helloworld-test) (first run)
    - helloworld-0.1.0.0 (test:spec) (first run)
   Preprocessing test suite 'spec' for helloworld-0.1.0.0..
   Preprocessing test suite 'doctest' for helloworld-0.1.0.0..
   Preprocessing test suite 'helloworld-test' for helloworld-0.1.0.0..
   Building test suite 'helloworld-test' for helloworld-0.1.0.0..
   Building test suite 'doctest' for helloworld-0.1.0.0..
   Building test suite 'spec' for helloworld-0.1.0.0..
   Running 1 test suites...
   Test suite helloworld-test: RUNNING...
   Test suite helloworld-test: PASS
   Test suite logged to:
   /workspace/projects/helloworld/dist-newstyle/build/x86_64-linux/ghc-9.4.4/helloworld-0.1.0.0/t/helloworld-test/test/helloworld-0.1.0.0-helloworld-test.log
   1 of 1 test suites (1 of 1 test cases) passed.
   Running 1 test suites...
   Test suite doctest: RUNNING...
   Running 1 test suites...
   Test suite spec: RUNNING...
   Test suite spec: PASS
   Test suite logged to:
   /workspace/projects/helloworld/dist-newstyle/build/x86_64-linux/ghc-9.4.4/helloworld-0.1.0.0/t/spec/test/helloworld-0.1.0.0-spec.log
   1 of 1 test suites (1 of 1 test cases) passed.
   Test suite doctest: PASS
   Test suite logged to:
   /workspace/projects/helloworld/dist-newstyle/build/x86_64-linux/ghc-9.4.4/helloworld-0.1.0.0/t/doctest/test/helloworld-0.1.0.0-doctest.log
   1 of 1 test suites (1 of 1 test cases) passed.
   0
   root@5499af50db5b:/workspace/projects/helloworld# 
   ```

テストにNGがあるときは、以下のような出力になる。

```txt
root@5499af50db5b:/workspace/projects/helloworld# cabal test; echo $?;
Build profile: -w ghc-9.4.4 -O1
In order, the following will be built (use -v for more details):
 - helloworld-0.1.0.0 (test:doctest) (first run)
 - helloworld-0.1.0.0 (test:helloworld-test) (first run)
 - helloworld-0.1.0.0 (test:spec) (file test/hspec/MyLibSpec.hs changed)
Preprocessing test suite 'doctest' for helloworld-0.1.0.0..
Preprocessing test suite 'helloworld-test' for helloworld-0.1.0.0..
Preprocessing test suite 'spec' for helloworld-0.1.0.0..
Building test suite 'doctest' for helloworld-0.1.0.0..
Building test suite 'helloworld-test' for helloworld-0.1.0.0..
Building test suite 'spec' for helloworld-0.1.0.0..
[1 of 2] Compiling MyLibSpec        ( test/hspec/MyLibSpec.hs, /workspace/projects/helloworld/dist-newstyle/build/x86_64-linux/ghc-9.4.4/helloworld-0.1.0.0/t/spec/build/spec/spec-tmp/MyLibSpec.o ) [Source file changed]
Running 1 test suites...
Test suite helloworld-test: RUNNING...
[2 of 2] Compiling Main             ( test/hspec/Spec.hs, /workspace/projects/helloworld/dist-newstyle/build/x86_64-linux/ghc-9.4.4/helloworld-0.1.0.0/t/spec/build/spec/spec-tmp/Main.o ) [MyLibSpec changed]
Test suite helloworld-test: PASS
Test suite logged to:
/workspace/projects/helloworld/dist-newstyle/build/x86_64-linux/ghc-9.4.4/helloworld-0.1.0.0/t/helloworld-test/test/helloworld-0.1.0.0-helloworld-test.log
1 of 1 test suites (1 of 1 test cases) passed.
Running 1 test suites...
Test suite doctest: RUNNING...
[3 of 3] Linking /workspace/projects/helloworld/dist-newstyle/build/x86_64-linux/ghc-9.4.4/helloworld-0.1.0.0/t/spec/build/spec/spec [Objects changed]
Test suite doctest: PASS
Test suite logged to:
/workspace/projects/helloworld/dist-newstyle/build/x86_64-linux/ghc-9.4.4/helloworld-0.1.0.0/t/doctest/test/helloworld-0.1.0.0-doctest.log
1 of 1 test suites (1 of 1 test cases) passed.
Running 1 test suites...
Test suite spec: RUNNING...

MyLib
  `addInt` takes two arguments of Int and returns the result of adding them.
    When give addInt 1 and 2 then return 3 [✘]

Failures:

  test/hspec/MyLibSpec.hs:10:30: 
  1) MyLib, `addInt` takes two arguments of Int and returns the result of adding them., When give addInt 1 and 2 then return 3
       expected: 2
        but got: 3

  To rerun use: --match "/MyLib/`addInt` takes two arguments of Int and returns the result of adding them./When give addInt 1 and 2 then return 3/"

Randomized with seed 1699898266

Finished in 0.0184 seconds
1 example, 1 failure

Test suite spec: FAIL
Test suite logged to:
/workspace/projects/helloworld/dist-newstyle/build/x86_64-linux/ghc-9.4.4/helloworld-0.1.0.0/t/spec/test/helloworld-0.1.0.0-spec.log
0 of 1 test suites (0 of 1 test cases) passed.
Error: cabal: Tests failed for test:spec from helloworld-0.1.0.0.

1
root@5499af50db5b:/workspace/projects/helloworld# 
```

## 参考

その他参照したもの。

http://learnyouahaskell.com/starting-out

https://kazu-yamamoto.hatenablog.jp/entry/20121205/1354692144

https://haskell.e-bigmoon.com/stack/test/doctest.html

http://note.kurodigi.com/ghcup-install/