Раздел 21 · Haskell

Финал: load, CLI на optparse-applicative, REPL на haskeline

senior~30 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Финал: load, CLI на optparse-applicative, REPL на haskeline

Язык готов, делаем из него инструмент. Спец-форма load и поиск std.lsp по LISHP_STD_PATH и рабочей директории, runFiles с общим контекстом между файлами, CLI на optparse-applicative (run --eval и список файлов), REPL на haskeline с мультилайн-вводом по незакрытой скобке, историей, автодополнением пути в :load, командами :help, :quit, :history и ANSI-баннером. В конце ретроспектива проекта и дорожная карта расширений (TCO, continuations, гигиена макросов) как открытое задание.

Идея

Язык готов, теперь сделаем из него инструмент. И это тоже дизайн языка, а не обёртка поверх. Главная мысль урока в том, что read и eval, написанные для проекта, переиспользуются в обоих режимах: и когда ты запускаешь файл целиком (batch), и когда вводишь по одной форме в интерактивной консоли (REPL). Один движок, две поверхности.

load и поиск std.lsp

Спец-форма load читает файл и вычисляет его формы в текущем окружении (поэтому загруженные определения остаются доступны). Это последняя ветка evalSpecial:

  Load -> with1 $ \pe -> do
    pv <- eval env pe
    case pv of
      Str path -> do
        contents <- liftIO (readFile path)
        case readProgram contents of
          Left err    -> throwError (ParseFailure err)
          Right exprs -> foldM (\_ e -> eval env e) Nil exprs   -- вычислить все формы
      _ -> throwError (TypeError "load expects a string path argument")

На том же readProgram плюс evalAll стоит загрузка std.lsp при старте сессии. Окружение сессии это пустое окружение, поверх которого накатили стандартную библиотеку:

-- окружение сессии: пустое, затем std.lsp сверху
newReplEnv :: IO Env
newReplEnv = do
  env <- newEnv
  loadStd env
  pure env

-- где искать std.lsp, по порядку: переменная окружения, рабочая папка, ~/.lishp
stdSearchPaths :: IO [FilePath]
stdSearchPaths = do
  override <- lookupEnv "LISHP_STD_PATH"
  home <- lookupEnv "HOME"
  pure $ concat
    [ maybe [] pure override
    , ["std/std.lsp", "std.lsp"]
    , maybe [] (\h -> [h ++ "/.lishp/std.lsp"]) home
    ]

Запуск файлов это runFiles: один общий env на весь список, чтобы поздние файлы видели определения ранних. Первая ошибка останавливает прогон с ненулевым кодом возврата:

runFiles :: [FilePath] -> IO ()
runFiles paths = do
  env <- newReplEnv          -- общий контекст со std
  go env paths
  where
    go _ [] = pure ()
    go env (path : rest) = do
      contents <- readFile path
      case readProgram contents of
        Left err    -> fail' ("parse error: " ++ err)
        Right exprs -> evalAll env exprs >>= either (fail' . show) (const (go env rest))

CLI на optparse-applicative

Командную строку собираем декларативно на optparse-applicative. Красота в том, что парсер опций это Applicative, ровно как парсеры из урока 16: маленькие парсеры флагов комбинируются в большой. Две подкоманды: repl (по умолчанию) и run:

import Options.Applicative

data Command = Repl | Run RunOptions
data RunOptions = RunOptions { runEval :: Maybe String, runFilesArg :: [FilePath] }

runOptions :: Parser RunOptions
runOptions =
  RunOptions
    <$> optional (strOption (long "eval" <> short 'e' <> metavar "EXPR"
                             <> help "Вычислить выражение из строки"))
    <*> many (argument str (metavar "FILES..." <> help "Файлы (общий контекст)"))

commandParser :: Parser Command
commandParser =
  subparser
    ( command "repl" (info (pure Repl) (progDesc "Интерактивный REPL"))
        <> command "run" (info (Run <$> runOptions) (progDesc "Запустить код"))
    )
    <|> pure Repl       -- без подкоманды: REPL

<$> и <*> тут собирают RunOptions из двух парсеров полей, точно так же, как (,) <$> p <*> q собирал пару в парсере. Диспетчер выбирает, что делать:

dispatch :: Command -> IO ()
dispatch Repl = runRepl
dispatch (Run (RunOptions (Just expr) _)) = runEvalExpr expr     -- run --eval "..."
dispatch (Run (RunOptions Nothing files)) = runFiles files       -- run file1 file2

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.