🤖

AI搭載エディタ「Cursor」にHaskellを書かせてみた

2023/12/13に公開

これは「Haskell Advent Calendar 2023」13日目の記事です。


日頃、私は趣味としてAIをHaskellで実装することに没頭しています。しかし、今回は少し違ったアプローチを試してみようと思います。逆に、AIにHaskellを使って自ら実装させ、その過程や成果を共有してみたいと考えています。この新たな取り組みがどのような結果を生むのか、一緒に探ってみましょう。

AI搭載エディタ「Cursor」

気を取り直して、今回は最近話題のAI搭載エディタ Cursor を使って実際に動くHaskellのアプリを0から作らせてみたいと思います!Cursorをまだ知らない人は以下の記事がオススメです(僕もまだこれしか読んでません🤫)

https://zenn.dev/gamuprog/articles/53e7adee04b337

Cursorのダウンロードとインストール、そしてアカウント登録が完了したらさっそくHaskellを書かせてみたいと思います。

それではレッツ・・・

ゴー!!!

最初のファイル生成

このエディタなんと作りたいものを教えると自分でファイル生成からやってくれるんです。今回は 「HaskellでTODO管理アプリを作って欲しい」 とお願いしてみました。すると以下のような画面になり放置していると勝手に色々なファイルを生成して実装していってくれます。

生成されたプロジェクトはこういう構成です。

.
├── Main.hs
├── Storage.hs
├── Test.hs
├── TodoItem.hs
├── TodoList.hs
└── Utils.hs

右側に浮いている「GENERATING PROJECT...」というウィンドウにプロジェクト生成の元となっているAIの指示が書かれています。内容は以下の通り。

AIの指示全文
STEP 0 Computing project structure
----------------------------------------------
To create a TODO management application in Haskell, we would
need several files. Here's a possible structure:
1. "Main.hs": This is the main entry point of the application.
It will handle the command line arguments and call the
appropriate functions based on the user's input.
2. "TodoItem.hs": this file will define the data structure for a
TODO item. it will likely include fields such as the task
description, due date, and completion status.
3. "TodoList.hs": This file will define the data structure for a
TODO list, which is a collection of TODO items. It will include
functions for adding, removing, and updating items in the list.
4. "Storage.hs": This file will handle reading and writing the
TODO list to disl. It will need to serialize the TODO list to a
format that can be easily stored, such as JSON, and deserialize
it back into a TODO list.
5. "Utils.hs": This file will contain utility functions that are
used throughout the application. This might include functions
for parsing dates, validating user input, etc.
6. "Test.hs": This file will contain tests for the application.
It's important to write tests to ensure that the application is
working as expected.
Now, let's call the function with these filenames:
``typescript
functions.outputFileNames({
  fileNames: ["Main.hs", "TodoItem.hs", "TodoList.hs",
"Storage.hs", "Utils.hs", "Test.hs"]
});
``

Step 1 Finding related files for Main.hs
----------------------------------------------
{
  "dependencies": []
}

Step 2 Generating Main.hs
----------------------------------------------
Creating file...
Thinking...
Based on your project description and the files you've
mentioned, it seems like you're building a TODO management
application in Haskell. Here's a basic structure for your
Main.hs file:
Writing code...

Step 3 Finding related files for TodoItem.hs
----------------------------------------------
{
  "dependencies": ["Main.hs"]
}

Step 4 Generating TodoItem.hs
----------------------------------------------
Creating file...
Thinking...
Writing code...

Step 5 Finding related files for TodoList.hs
----------------------------------------------
{
  "dependencies": ["TodoItem.hs"]
}

Step 6 Generating TodoList.hs
----------------------------------------------
Creating file...
Thinking...
Writing code...

Step 7 Finding related files for Storage.hs
----------------------------------------------
{
  "dependencies": ["TodoItem.hs", "TodoList.hs"]
}

Step 8 Generating Storage.hs
----------------------------------------------
Creating file...
Thinking...
Writng code...

Step 9 Finding related files for Utils.hs
----------------------------------------------
{
  "dependencies": ["Main.hs", "TodoItem.hs", "TodoList.hs",
"Storage.hs"]
}

Step 10 Generating Utils.hs
----------------------------------------------
Creating file...
Thinking...
Writing code...

Step 11 Finding related files for Test.hs
----------------------------------------------
{
  "dependencies": ["Main.hs", "TodoItem.hs", "TodoList.hs",
"Storage.hs", "Utils.hs"]
}

Step 12 Generating Test.hs
----------------------------------------------
Creating file...
Thinking...
Writing code...

Step 13 All done!
----------------------------------------------

CursorはAI(GPT-4/CPT-3.5)へのリクエスト数に限りがあるのですが、どうやら1ステップ毎に1リクエスト消費されてるみたいです🥶
生成された Main.hs は以下のようになっています。

Main.hs
```haskell
module Main where

import TodoItem
import TodoList
import Storage
import Utils
import Test

main :: IO ()
main = do
    putStrLn "Welcome to the TODO management application!"
    putStrLn "Please select an option:"
    putStrLn "1. Add a new TODO"
    putStrLn "2. View all TODOs"
    putStrLn "3. Delete a TODO"
    putStrLn "4. Exit"
    command <- getLine
    case command of
        "1" -> do
            putStrLn "Enter the TODO details:"
            todo <- getLine
            addTodo todo
            main
        "2" -> do
            todos <- getAllTodos
            printTodos todos
            main
        "3" -> do
            putStrLn "Enter the ID of the TODO to delete:"
            id <- getLine
            deleteTodo id
            main
        "4" -> putStrLn "Thank you for using the TODO management application!"
        _   -> do
            putStrLn "Invalid command. Please try again."
            main
```

一見ちゃんと実装されてそうですね👀他のファイルもそうですが謎にマークダウンのコードブロック記法に包まれた形で生成されてしまっていたので全部のファイルからコードブロック記法だけ手で取り除きました。手のかかる子ですね😌

それではさっそく生成したコードを実行してみましょう!

$ runhaskell Main.hs

TodoItem.hs:13:40: error:
    Ambiguous occurrence ‘id’
    It could refer to
       either ‘Prelude.id’,
              imported from ‘Prelude’ at TodoItem.hs:1:8-15
              (and originally defined in ‘GHC.Base’)
           or the field ‘id’ of record ‘TodoItem’,
              defined at TodoItem.hs:9:28
   |
13 | createTodoItem id content = TodoItem { id = id, content = content }
   |                                        ^^

TodoItem.hs:17:9: error:
    Ambiguous occurrence ‘id’
    It could refer to
       either ‘Prelude.id’,
              imported from ‘Prelude’ at TodoItem.hs:1:8-15
              (and originally defined in ‘GHC.Base’)
           or the field ‘id’ of record ‘TodoItem’,
              defined at TodoItem.hs:9:28
   |
17 | getId = id
   |

おやおや?いきなりコンパイルエラーが出てしまいましたね👀

TodoItem.hs

エラーが出ている TodoItem.hs のファイルの中身を見てみましょう。

TodoItem.hs
module TodoItem
    ( TodoItem
    , createTodoItem
    , getId
    , getContent
    ) where

-- Define the TodoItem data type
data TodoItem = TodoItem { id :: String, content :: String } deriving (Show)

-- Function to create a new TodoItem
createTodoItem :: String -> String -> TodoItem
createTodoItem id content = TodoItem { id = id, content = content }

-- Function to get the ID of a TodoItem
getId :: TodoItem -> String
getId = id

-- Function to get the content of a TodoItem
getContent :: TodoItem -> String
getContent = content

エラー文からも実装からも原因は明らかですが、レコード TodoItem のフィールドに標準で使える関数 id と同じ名前を使っていて名前が衝突してしまっていますね。手で直しても良いですがせっかくなのでAIに修正をお願いしてみましょう。

TodoItem.hs のコードを全選択して Cmd+K を押して出てきた入力欄に「"Ambiguous occurrence 'id'" と怒られてしまうので修正して」とおもむろに入力してSubmit!すると

コードを上から走査して差分があったところを赤と緑で表示してくれています。ちゃんとレコードのフィールドを itemId に修正してくれていますね!なぜか英語のコメントを日本語にも直してくれていて、こちらはいらないので行ごとに反映するかしないかを選んでも良いのですが、面倒くさいので「⌘↵Accept」を押して全部反映させました。

これでエラーは直ったはずなのでもう一度実行してみましょう。

$ runhaskell Main.hs

Storage.hs:15:55: error:
    • Variable not in scope: splitOn :: String -> String -> [a]
    • Perhaps you meant ‘splitAt’ (imported from Prelude)
   |
15 |         todoItems = map (\line -> let [id, content] = splitOn "," line in createTodoItem id content) linesOfFiles
   |                                                       ^^^^^^^

Storage.hs:22:21: error:
    • Variable not in scope: getTodoItems :: TodoList -> t
    • Perhaps you want to add ‘getTodoItems’ to the import list
      in the import of ‘TodoList’ (Storage.hs:7:1-55).
   |
22 |     let todoItems = getTodoItems todoList
   |                     ^^^^^^^^^^^^

Storage.hs:23:38: error:
    • Variable not in scope: getId :: t0 -> [Char]
    • Perhaps you want to add ‘getId’ to the import list
      in the import of ‘TodoItem’ (Storage.hs:8:1-42).
   |
23 |         linesOfFiles = map (\item -> getId item ++ "," ++ getContent item) todoItems
   |                                      ^^^^^

Storage.hs:23:59: error:
    • Variable not in scope: getContent :: t0 -> [Char]
    • Perhaps you meant one of these:
        ‘getContents’ (imported from System.IO),
        ‘getContents'’ (imported from System.IO),
        ‘hGetContents’ (imported from System.IO)
      Perhaps you want to add ‘getContent’ to the import list
      in the import of ‘TodoItem’ (Storage.hs:8:1-42).
   |
23 |         linesOfFiles = map (\item -> getId item ++ "," ++ getContent item) todoItems
   |

あれあれ?またもやコンパイルエラーが出てしまいました。

Storage.hs

次にエラーが出ているのは Storage.hs ですね。ファイルの中身を見てみましょう。

Storage.hs
module Storage
    ( loadTodoList
    , saveTodoList
    ) where

import System.IO
import TodoList (TodoList, createTodoList, addTodoItem)
import TodoItem (TodoItem, createTodoItem)

-- Function to load a TodoList from a file
loadTodoList :: FilePath -> IO TodoList
loadTodoList path = do
    contents <- readFile path
    let linesOfFiles = lines contents
        todoItems = map (\line -> let [id, content] = splitOn "," line in createTodoItem id content) linesOfFiles
        todoList = foldr addTodoItem createTodoList todoItems
    return todoList

-- Function to save a TodoList to a file
saveTodoList :: FilePath -> TodoList -> IO ()
saveTodoList path todoList = do
    let todoItems = getTodoItems todoList
        linesOfFiles = map (\item -> getId item ++ "," ++ getContent item) todoItems
    writeFile path (unlines linesOfFiles)

まず一つ目のエラーは splitOn なんて関数が定義されてないぞというものです。実装が足りていないのならAIに実装してもらいましょう。コンパイルエラーが出た行が含まれる関数 loadTodoList 全体を範囲選択して Cmd+L を押すと、選択したコードが挿入されたチャット画面が右側に開きます。チャット欄に「ここで使われている solitOn 関数を実装して」と頼むと

このように即興で splitOn を実装してくれました。

import Data.List

splitOn :: Eq a => [a] -> [a] -> [[a]]
splitOn []    _  = error "splitOn: empty delimiter"
splitOn delim xs = loop xs
    where loop [] = [[]]
          loop xs | delim `isPrefixOf` xs = [] : loop (drop (length delim) xs)
          loop (x:xs) = (x : head ys) : tail ys
              where ys = loop xs

この実装を loadTodoList の直前に挿入しておきましょう。

次のコンパイルエラーは getTodoItems が定義されてないぞというものです。しかしこれは TodoList に定義されているかもとコンパイルエラーに丁寧に書いてあるので、まずは TodoList.hs を見てみましょう。

TodoList.hs
module TodoList
    ( TodoList
    , createTodoList
    , addTodoItem
    , removeTodoItem
    , getTodoItems
    ) where

import TodoItem (TodoItem, createTodoItem, getId)

-- Define the TodoList data type
data TodoList = TodoList { items :: [TodoItem] } deriving (Show)

-- Function to create a new TodoList
createTodoList :: TodoList
createTodoList = TodoList { items = [] }

-- Function to add a TodoItem to a TodoList
addTodoItem :: TodoItem -> TodoList -> TodoList
addTodoItem item todoList = todoList { items = item : items todoList }

-- Function to remove a TodoItem from a TodoList by ID
removeTodoItem :: String -> TodoList -> TodoList
removeTodoItem id todoList = todoList { items = filter ((/= id) . getId) (items todoList) }

-- Function to get all TodoItems in a TodoList
getTodoItems :: TodoList -> [TodoItem]
getTodoItems = items

確かに getTodoItems が定義されてエクスポートもされていますね。どうやらAIがインポート忘れをしてしまっただけのようなので Storage.hsimport 部分にこの関数を追加しておきます。

同様にその次もその次の次のエラーも TodoItem.hs からのインポート忘れなので、コンパイルエラーに従って import 部分に追加しておきましょう。

さてこれで前回のコンパイルエラーが直ったはずなのでもう一度実行してみましょう。

$ runhaskell Main.hs
Welcome to the TODO management application!
Please select an option:
1. Add a new TODO
2. View all TODOs
3. Delete a TODO
4. Exit

やったー!コンパイルが通って動きました!

Haskellは型に守られているのでコンパイルが通ったらもう完成も同然ですよね?ですよね?

AIは遅延評価の夢を見るか?

それでは動いたTODOアプリにさっそくTODOを追加してみましょう。

$ runhaskell Main.hs
Welcome to the TODO management application!
Please select an option:
1. Add a new TODO
2. View all TODOs
3. Delete a TODO
4. Exit
1
Enter the TODO details:
My First TODO!
Main.hs: todoList.txt: openFile: does not exist (No such file or directory)

なんとランタイムエラーで落ちてしまいました!エラー文を見ればなんとなく原因は分かりますがAIに相談してみましょう。@CodeBase でプロジェクト全体のコードを参照するようにしながら「実行してTODOを追加しようとすると "Main.hs: todoList.txt: openFile: does not exist (No such file or directory)" と怒られてしまうけどどうして?」と尋ねてみます。

念のため Utils.hs を確認すると

Utils.hs
module Utils
    ( printTodos
    , addTodo
    , getAllTodos
    , deleteTodo
    ) where

import TodoItem (TodoItem, createTodoItem, getId, getContent)
import TodoList (TodoList, createTodoList, addTodoItem, removeTodoItem, getTodoItems)
import Storage (loadTodoList, saveTodoList)

-- Define the file path for the TODO list
filePath :: FilePath
filePath = "todoList.txt"

-- Function to print all TODOs
printTodos :: [TodoItem] -> IO ()
printTodos = mapM_ (putStrLn . getContent)

-- Function to add a new TODO
addTodo :: String -> IO ()
addTodo content = do
    todoList <- loadTodoList filePath
    let newId = show (length (getTodoItems todoList) + 1)
        newItem = createTodoItem newId content
        newTodoList = addTodoItem newItem todoList
    saveTodoList filePath newTodoList

-- Function to get all TODOs
getAllTodos :: IO [TodoItem]
getAllTodos = do
    todoList <- loadTodoList filePath
    return (getTodoItems todoList)

-- Function to delete a TODO
deleteTodo :: String -> IO ()
deleteTodo id = do
    todoList <- loadTodoList filePath
    let newTodoList = removeTodoItem id todoList
    saveTodoList filePath newTodoList

確かに filePath"todoList.txt" というファイルパスを保持しています。

そしてなんとAIは原因だけでなく解決方法も教えてくれているではありませんか! さっそく Storage.hsloadTodoList 関数を教えてもらった実装に上書きしましょう。上書きして実行すると、

$ runhaskell Main.hs

Storage.hs:23:19: error:
    Variable not in scope: doesFileExist :: FilePath -> IO Bool
   |
23 |     fileExists <- doesFileExist path
   |

ああ doesFileExist がないと怒られてしまいました。再びAIに聞けば分かる通りこの関数は System.Direcroty モジュールで提供されているので Storage.hs

import System.Directory

の一文を追加します。

それでは気を取り直してもう一度実行してみましょう。

$ runhaskell Main.hs
Welcome to the TODO management application!
Please select an option:
1. Add a new TODO
2. View all TODOs
3. Delete a TODO
4. Exit
1
Enter the TODO details:
My First TODO!
Main.hs: todoList.txt: withFile: resource busy (file is locked)

またもやランタイムエラーで落ちてしまいました😫でもエラーの原因は先程のものから変わっていますね。さっきと同様に @CodeBase でプロジェクト全体のコードを参照するようにしながら「実行してTODOを追加しようとすると"Main.hs: todoList.txt: withFile: resource busy (file is locked)"と怒られてしまうけどどうして?」と尋ねてみましょう。

うーん、AIはどうやら非同期処理において複数のプロセスから同時に同じファイルにアクセスされ競合した結果エラーが起こっていると思っているようです。しかし今のプログラムで走らせているプロセスは一つのはずですよね?この後も追加でいくつか質問をしましたがどう修正したらいいのかストレートな回答を得ることは中々できませんでした。

どうやらここは人間の出番のようです💪
今回のバグの原因は主に Storage.hs の以下の関数にあります。

loadTodoList :: FilePath -> IO TodoList
loadTodoList path = do
    fileExists <- doesFileExist path
    if not fileExists
        then writeFile path ""
        else return ()
    contents <- readFile path
    let linesOfFiles = lines contents
        todoItems = map (\line -> let [id, content] = splitOn "," line in createTodoItem id content) linesOfFiles
        todoList = foldr addTodoItem createTodoList todoItems
    return todoList

どこか分かりますでしょうか?そう実は readFileで読み込んでいるファイルが開きっぱなしになっている のです!読み込まれたファイルの内容 contentslines で各行のリストに変換され、maptodoItems に変換され、foldrtodoList に変換されと、全て遅延評価で処理が進むのでファイルの中身が全て読み込まれることがないためファイルも開きっぱなしになっているのです。なのでそのまま後続の処理(saveTodoList)で同じファイルに書き込み writeFile が行われるとファイルがロックされたままになっていてエラーが出てしまうということなのです。ですのでこれを解消するにはこの関数の中でファイルの中身を全て評価すれば良く、今回は最後の行で return todoList をする前に todoListseq で評価したいと思います。

loadTodoList :: FilePath -> IO TodoList
loadTodoList path = do
    fileExists <- doesFileExist path
    if not fileExists
        then writeFile path ""
        else return ()
    contents <- readFile path
    let linesOfFiles = lines contents
        todoItems = map (\line -> let [id, content] = splitOn "," line in createTodoItem id content) linesOfFiles
        todoList = foldr addTodoItem createTodoList todoItems
    seq todoList $ return todoList  -- << -- この行だけ修正

さぁ今度うまくいくはず。満を持して実行してみましょう!

$ runhaskell Main.hs
Welcome to the TODO management application!
Please select an option:
1. Add a new TODO
2. View all TODOs
3. Delete a TODO
4. Exit
1
Enter the TODO details:
My First TODO!
Welcome to the TODO management application!
Please select an option:
1. Add a new TODO
2. View all TODOs
3. Delete a TODO
4. Exit
1
Enter the TODO details:
My Second TODO!!
Welcome to the TODO management application!
Please select an option:
1. Add a new TODO
2. View all TODOs
3. Delete a TODO
4. Exit
2
My Second TODO!!
My First TODO!
Welcome to the TODO management application!
Please select an option:
1. Add a new TODO
2. View all TODOs
3. Delete a TODO
4. Exit
3
Enter the ID of the TODO to delete:
1
Welcome to the TODO management application!
Please select an option:
1. Add a new TODO
2. View all TODOs
3. Delete a TODO
4. Exit
2
My Second TODO!!
Welcome to the TODO management application!
Please select an option:
1. Add a new TODO
2. View all TODOs
3. Delete a TODO
4. Exit
4
Thank you for using the TODO management application!

区切りが分かりづらいですが上記では

  1. My First TODO! というTODOを追加
  2. My Second TODO! というTODOを追加
  3. TODOの一覧を表示
  4. ID 1 のTODOを削除
  5. TODOの一覧を表示
  6. 終了

という処理を行っており、どれも無事に実行することができています。やったね👏

まとめ

いかがだったでしょうか(言いたいだけ)
Cursorを使ってTODOアプリを作りたい!と願いをするだけでちょっと手直しが必要だけどまぁまぁまともに動くアプリが生成されました。生成されたコードのある程度自明なバグはHaskellの強い型のお陰でコンパイル時に発見してくれますし、ランタイムエラーもAIに雑に尋ねれば修正方法を教えてくれるという非常に楽な開発体験でした。最後の遅延評価とリソース管理あたりのバグは流石にAIでも分かりづらいかと言う学びもありました。
今回Cursorを使ってみて、プロジェクトをまるごと生成できたり、コードを参照しながらAIに問い合わせたり、1行ごとAIに書き換えてもらったりと非常に便利な機能を備えたエディタだと思いましたが、AIへの問い合わせ回数が決まっているので残り回数を気にしながら使わないといけないのが玉に瑕だなぁと思いました😅
最後にまだ載せていなかったAIが作成したテストコードを載せておきますね

Test.hs
module Test
    ( testTodoApp
    ) where

import TodoItem (TodoItem, createTodoItem, getId, getContent)
import TodoList (TodoList, createTodoList, addTodoItem, removeTodoItem, getTodoItems)
import Storage (loadTodoList, saveTodoList)
import Utils (printTodos, addTodo, getAllTodos, deleteTodo)

-- Define the file path for the TODO list
filePath :: FilePath
filePath = "testTodoList.txt"

-- Function to test the TODO app
testTodoApp :: IO ()
testTodoApp = do
    putStrLn "Testing TODO app..."

    -- Test adding a TODO
    putStrLn "Testing addTodo..."
    addTodo "Test TODO 1"
    todos <- getAllTodos
    case todos of
        [] -> putStrLn "Failed to add TODO"
        _  -> putStrLn "Successfully added TODO"

    -- Test getting all TODOs
    putStrLn "Testing getAllTodos..."
    todos <- getAllTodos
    case todos of
        [] -> putStrLn "Failed to get all TODOs"
        _  -> putStrLn "Successfully got all TODOs"

    -- Test deleting a TODO
    putStrLn "Testing deleteTodo..."
    deleteTodo "1"
    todos <- getAllTodos
    if null todos
        then putStrLn "Successfully deleted TODO"
        else putStrLn "Failed to delete TODO"

    putStrLn "Finished testing TODO app"

\読んでいただきありがとうございました!/
この記事が面白かったら いいね♡ をいただけると嬉しいです☺️
バッジを贈っていただければ次の記事を書くため励みになります🙌

Discussion