본문으로 건너뛰기

멋진 옵션 파싱

이번에는 프로그램을 위한 더 멋진 인터페이스를 정의해보려고 합니다. getArgs와 패턴 매칭으로도 무언가를 만들 수 있지만, 라이브러리를 사용하면 더 좋은 결과를 얻을 수 있습니다. 이번 장에서는 optparse-applicative라는 패키지를 사용할 것입니다.

optparse-applicative는 명령행 인자 파서를 만들기 위한 EDSL을 제공합니다. 명령, 스위치, 플래그 등을 만들고 조합하여 실제로 문자열에 대한 연산을 작성하지 않고도 명령행 인자를 파싱하는 파서를 만들 수 있습니다. 또한 사용법, 도움말, 에러 메시지 등을 자동으로 생성해주는 등의 기능도 제공합니다.

optparse-applicative의 의존성 풋프린트는 크지 않지만, 우리 라이브러리를 사용하는 사용자가 명령행 파싱이 필요하지 않을 가능성이 높기 때문에 .cabal 파일의 (libray 섹션이 아닌) executable 섹션에 의존성을 추가하는게 바람직합니다:

 executable hs-blog-gen
import: common-settings
hs-source-dirs: app
main-is: Main.hs
build-depends:
base
+ , optparse-applicative
, hs-blog
ghc-options:
-O

명령줄 파서 만들기

optparse-applicative 패키지는 꽤 괜찮은 문서, 를 가지고 있습니다. 하지만 이 장에서 중요한 몇 가지 사항을 다룰 것입니다.

일반적으로, 우리가 해야 할 일은 네 가지가 있습니다:

  1. 모델을 정의합니다 - 프로그램의 다양한 옵션과 명령을 설명하는 ADT를 정의합니다.
  2. 실행할 때 모델 타입의 값을 생성하는 파서를 정의합니다.
  3. 파서를 프로그램 인자 입력에 실행합니다.
  4. 모델을 패턴 매칭하고 옵션에 따라 올바른 작업을 호출합니다.

모델 정의하기

먼저 우리의 명령줄 인터페이스를 상상해봅시다. 어떤 기능이 있을까요?

단일 파일이나 입력 스트림을 파일이나 출력 스트림으로 변환해거나, 디렉토리 전체를 처리하고 새 디렉토리를 생성할 수 있어야 합니다. 우리는 이를 ADT로 모델링 할 수 있습니다:

data Options
= ConvertSingle SingleInput SingleOutput
| ConvertDir FilePath FilePath
deriving Show

data SingleInput
= Stdin
| InputFile FilePath
deriving Show

data SingleOutput
= Stdout
| OutputFile FilePath
deriving Show

SingleInputSingleOutputMaybe FilePath로 표현하는 것도 하나의 방법입니다. 하지만 이 경우 각 문맥에서 Nothing이 무엇을 의미하는지 기억해야 합니다. 각 옵션에 대한 적절한 이름을 가진 새로운 타입을 만드는 것이 코드의 의미를 이해하는데 도움이 됩니다.

인터페이스 관점에서, 사용자가 단일 입력 소스를 변환하려면 convert 명령어를 사용하고 옵션으로 --input FILEPATH--output FILEPATH를 제공하여 파일에서 읽거나 쓰게할 수 있습니다. 만약 사용자가 하나 이상의 옵션을 제공하지 않으면 표준 입력/출력에서 읽거나 쓸 것입니다.

만약 사용자가 디렉토리를 변환하려면 convert-dir 명령어를 사용하고 --input FILEPATH--output FILEPATH를 필수로 입력해야 합니다.

파서 만들기

이 과정에서 가장 흥미로운 부분입니다. 어떻게 모델에 맞는 파서를 만들 수 있을까요?

optparse-applicative 라이브러리는 Parser라는 새로운 타입을 제공합니다. ParserMaybeIO와 같이 kind가 * -> *인 타입입니다. Int, Bool 또는 Options와 같은 구체적인 타입을 제공해야 Parser는 (값을 가지는) 구체적인 타입이 될 수 있습니다.

Parser a는 명령줄 인자가 성공적으로 파싱된 경우 타입 a의 값을 생성하는 명령줄 파서의 명세를 의미합니다. 이는 IO aa의 값을 생성하는 프로그램의 명세를 의미하는 것과 유사합니다. 이 두 타입의 주요 차이점은 IO aa로 변환할 수 없지만 (우리는 단지 IO 연산을 연결하고 Haskell 런타임이 그것들을 실행하게 합니다), Parser a를 프로그램 인자를 의미하는 문자열 목록을 받고 인자를 파싱할 수 있으면 a를 생성하는 함수로 변환할 수 있다는 것입니다.

이전 EDSL과 마찬가지로, 이 라이브러리는 조합자 패턴을 사용합니다. 우리는 파서를 만들기 위한 원시값들과 작은 파서를 큰 파서로 조합하는 방법을 익혀야 합니다.

작은 파서의 예제를 살펴봅시다:

inp :: Parser FilePath
inp =
strOption
( long "input"
<> short 'i'
<> metavar "FILE"
<> help "Input file"
)

out :: Parser FilePath
out =
strOption
( long "output"
<> short 'o'
<> metavar "FILE"
<> help "Output file"
)

strOption는 파서를 만드는 함수입니다. 이 함수는 인자로 조합된 옵션 수정자를 받아 문자열을 파싱하는 파서를 반환합니다. 타입을 FilePath로 지정할 수도 있는데, FilePathString의 별칭이기 때문입니다. 파서 빌더는 값을 파싱하는 방법을 설명하고, 수정자는 플래그 이름, 플래그 이름의 약어, 사용법 및 도움말 메시지에 대한 내용과 같은 속성을 설명합니다.

사실 strOptionIsString 인터페이스를 구현하는 어떤 문자열 타입이라도 반환할 수 있습니다. 그 예로 text 패키지에서 가져온 훨씬 효율적인 유니코드 텍스트 타입인 Text가 있습니다. StringChar의 링크드 리스트로 구현되지만, Text는 바이트 배열로 구현되기 때문에 Text가 더 효율적입니다. 텍스트 값을 위해 String 대신 Text를 사용하는게 좋습니다. 지금까지는 사용하지 않았는데 이는 String보다 약간 사용하기 불편하기 때문입니다. 하지만 텍스트를 표현할때 많이 사용하는 타입입니다!

보시다시피, 수정자는 <> 함수를 사용하여 합성할 수 있습니다. 즉, 수정자는 Semigroup 타입클래스의 인스턴스를 구현했다는 것을 의미합니다!

이러한 인터페이스로 인해, 우리는 모든 수정자 옵션을 제공할 필요가 없이 필요한 항목만 제공하면 됩니다. 따라서 우리가 짧은 플래그 이름을 가지고 싶지 않다면, 추가할 필요가 없습니다.

Functor

우리가 정의한 데이터 타입을 위해, Parser FilePath를 정의했다는 것은 우리가 원하는 방향으로 큰 파서를 만들 수 있는 좋은 시작이지만, ConvertSingle에 필요한 것은 아닙니다. SingleInputSingleOutput을 위한 Parser가 필요합니다. 만약 FilePath가 있다면, InputFile 생성자를 사용하여 SingleInput으로 변환할 수 있습니다. InputFile도 함수라는 것을 기억하세요:

InputFile :: FilePath -> SingleInput
OutputFile :: FilePath -> SingleOutput

그러나 파서를 변환하려면 다음과 같은 타입의 함수가 필요합니다:

f :: Parser FilePath -> Parser SingleInput
g :: Parser FilePath -> Parser SingleOutput

다행히도, Parser 인터페이스는 FilePath -> SingleInput와 같은 함수를 "lift"하는 함수를 제공합니다. 즉 Parser FilePath -> Parser SingleInput와 같은 타입의 함수를 만들어줍니다. 이 함수는 어떠한 입력과 출력 모두에 대해 작동하기에 a -> b 타입의 함수가 있다면 인자로 전달해 Parser a -> Parser b 타입의 새 함수를 얻을 수 있습니다.

이러한 함수를 fmap이라고 부릅니다:

fmap :: (a -> b) -> Parser a -> Parser b

-- 또는 중위 표기법으로
(<$>) :: (a -> b) -> Parser a -> Parser b

우리는 이전에 다른 타입의 인터페이스에서 fmap을 보았습니다:

fmap :: (a -> b) -> [a] -> [b]

fmap :: (a -> b) -> IO a -> IO b

fmap<>, show와 같은 타입클래스 함수입니다. 이는 Functor라는 타입클래스에 속합니다:

class Functor f where
fmap :: (a -> b) -> f a -> f b

그리고 다음과 같은 법칙이 있습니다:

-- 1. 항등 법칙:
-- 값들을 변경하지 않으면 아무것도 변경되지 않아야 합니다.
fmap id = id

-- 2. 합성 법칙:
-- lift한 함수를 합성하는 것은 fmap으로 lift한 함수를 합성하는 것과 같습니다.
fmap (f . g) == fmap f . fmap g

fmap을 구현하고 위 법칙을 따르는 타입은 Functor의 인스턴스가 될 수 있습니다.

f의 kind가 * -> *라는 것을 기억하세요. fmap의 타입 시그니처를 통해 f의 kind를 추론할 수 있습니다:

  1. ab의 kind는 *입니다. 왜냐하면 함수의 인자/반환 타입으로 사용되기 때문입니다.
  2. f a의 kind는 *입니다. 왜냐하면 함수의 인자로 사용되기 때문입니다.
  3. 그러므로 f의 kind는 * -> *입니다.

데이터 타입 하나를 선택해 Functor 인스턴스를 구현해봅시다. 먼저 * -> * kind를 가진 데이터 타입을 선택해야 합니다. Maybe가 적합합니다. 이제 fmap :: (a -> b) -> Maybe a -> Maybe b 함수를 구현해야 합니다. 다음은 매우 간단하고 (그리고 잘못된) 구현입니다:

mapMaybe :: (a -> b) -> Maybe a -> Maybe b
mapMaybe func maybeX = Nothing

한 번 컴파일해보세요! 성공적으로 컴파일됩니다! 하지만 불행하게도 첫 번째 법칙을 만족하지 않습니다. fmap id = idmapMaybe id (Just x) == Just x를 의미합니다. 그러나 정의에서 mapMaybe id (Just x) == Nothing이라는 것을 명확히 알 수 있습니다.

이는 하스켈이 법칙을 만족할 수 있게 보장해주지 않는다는 것과 이러한 법칙이 중요하다는 것을 보여줍니다. 법칙을 만족하지 않는 Functor 인스턴스는 우리가 기대하는 것과 다르게 동작할 것입니다. 다시 한 번 시도해봅시다!

mapMaybe :: (a -> b) -> Maybe a -> Maybe b
mapMaybe func maybeX =
case maybeX of
Nothing -> Nothing
Just x -> Just (func x)

mapMaybe는 법칙을 만족합니다. 이는 대수학을 통해 증명할 수 있습니다. - 만약 치환을 통해 등식의 한쪽에서 다른 쪽으로 도달할 수 있다면, 법칙은 성립합니다.

Functor는 매우 중요한 타입클래스이며, 많은 타입이 이 인터페이스를 구현합니다. 우리가 알고 있는 것처럼, IO, Maybe, [], Parser 모두 * -> * kind를 가지며 그들의 "payload" 타입에 대해 fmap을 사용할 수 있습니다.

종종 사람들은 타입클래스가 무엇을 의미하는지에 대한 비유나 은유를 찾으려고 합니다. 하지만 Functor`와 같은 재미있는 이름의 타입클래스는 일반적으로 모든 경우에 적합한 비유나 은유를 가지고 있지 않습니다. 은유를 포기하고 법칙을 가진 인터페이스로 그 자체로 생각하는 것이 더 쉽습니다.

Parser에 대해 fmap을 사용해, FilePath를 반환하는 파서를 SingleInput 또는 SingleOutput을 반환하는 파서로 변환할 수 있습니다:

pInputFile :: Parser SingleInput
pInputFile = fmap InputFile parser
where
parser =
strOption
( long "input"
<> short 'i'
<> metavar "FILE"
<> help "Input file"
)

pOutputFile :: Parser SingleOutput
pOutputFile = OutputFile <$> parser -- fmap 과 <$> 는 같은 의미입니다.
where
parser =
strOption
( long "output"
<> short 'o'
<> metavar "FILE"
<> help "Output file"
)

Applicative

이제 pInputFile :: Parser SingleInputpOutputFile :: Parser SingleOutput 두 개의 파서를 가지고 있습니다. 우리는 이 두 파서를 결합Options를 만들려고 합니다. SingleInputSingleOutput이 있다면 ConvertSingle 생성자를 사용해볼 수 있습니다:

ConvertSingle :: SingleInput -> SingleOutput -> Options

혹시 이전에 fmap을 활용해 본 것처럼 비슷한 트릭을 사용할 수 있을까요? 이진 함수(binary function)를 리프트Parser에서도 동작하게 하는 함수가 존재할까요? 존재한다면 아마 이런 타입 시그니처를 가지고 있을 것입니다:

???
:: (SingleInput -> SingleOutput -> Options)
-> (Parser SingleInput -> Parser SingleOutput -> Parser Options)

네. 이런 함수가 존재합니다. 이 함수는 liftA2라고 불리며 Applicative 타입클래스에 정의되어 있습니다. Applicative (또는 applicative functor)는 세 개의 주요 함수를 가지고 있습니다:

class Functor f => Applicative f where
pure :: a -> f a
liftA2 :: (a -> b -> c) -> f a -> f b -> f c
(<*>) :: f (a -> b) -> f a -> f b

Applicative 는 많은 인스턴스가 존재하는 또 다른 매우 인기있는 타입클래스입니다.

모든 MonoidSemigroup인 것처럼, 모든 ApplicativeFunctor입니다. 이는 Applicative 인터페이스를 구현하고자 하는 타입은 Functor 인터페이스도 구현해야 한다는 것을 의미합니다.

특정 함수 f를 리프트 하는 일반적인 functor의 기능을 넘어, applicative functor는 타입 af a로 "리프트"하는 것 외에도, 특정 f여러 인스턴스에 함수를 적용할 수 있습니다.

아마 pure는 이미 익숙할 것입니다. 예전에 IO에 대해 이야기할 때 보았을 것입니다. IO에 대해, pure는 IO를 수행하지 않고도 특정 반환 값을 가지는 IO 액션을 만들어줍니다. Parser에 대해 pure를 사용하면, 파싱을 하지 않고도 특정 값을 반환하는 Parser를 만들 수 있습니다.

liftA2<*>는 둘 다 서로를 활용해 구현할 수 있는 함수입니다. 사실 <*>가 두 함수 중 더 유용합니다. 왜냐하면 fmap (또는 중위 연산자 <$>)과 함께 사용하면 두 개 이상의 많은 인자를 가진 함수에도 적용할 수 있기 때문입니다.

두 파서를 하나로 결합하기위해, liftA2 또는 <$><*>의 조합을 사용할 수 있습니다:

-- with liftA2
pConvertSingle :: Parser Options
pConvertSingle =
liftA2 ConvertSingle pInputFile pOutputFile

-- with <$> and <*>
pConvertSingle :: Parser Options
pConvertSingle =
ConvertSingle <$> pInputFile <*> pOutputFile

<$><*>는 모두 왼쪽으로 결합되므로, 다음과 같은 보이지 않는 괄호가 있습니다:

pConvertSingle :: Parser Options
pConvertSingle =
(ConvertSingle <$> pInputFile) <*> pOutputFile

위 코드가 타입체크에 성공하는지 증명하기 위해, 각 하위 표현식의 타입을 살펴봅시다:

pConvertSingle :: Parser Options

pInputFile :: Parser SingleInput
pOutputFile :: Parser SingleOutput

ConvertSingle :: SingleInput -> SingleOutput -> Options

(<$>) :: (a -> b) -> Parser a -> Parser b
-- 여기서 `a`는 `SingleInput`이고 `b`는 `SingleOutput -> Options`입니다.

ConvertSingle <$> pInputFile :: Parser (SingleOutput -> Options)

(<*>) :: Parser (a -> b) -> Parser a -> Parser b
-- 여기서 `a -> b`는 `SingleOutput -> Options`이며, `a`는 `SingleOutput`이고 `b`는 `Options`입니다.

-- 따라서
(ConvertSingle <$> pInputFile) <*> pOutputFile :: Parser Options

<$><*>를 사용하면 원하는 만큼 많은 파서를 연결할 수 있습니다. 이는 커링(currying)과 매개변수 다형성(parametric polymorphism) 두 가지 이유 때문입니다. Haskell에서 함수는 정확히 하나의 인자를 받고 정확히 하나의 값을 반환하기 때문에, 여러 인자를 가진 함수는 a -> b와 같이 표현할 수 있습니다.

applicative functor의 법칙에 대한 내용은 Typeclassopedia글을 참고하세요. 이 글은 다양한 유용한 타입클래스와 그들의 법칙에 대해 이야기합니다.

Applicative functor는 매우 중요한 개념이며 다양한 파서 인터페이스 (명령줄 뿐만아니라 JSON이나 일반적인 파서), I/O, 동시성, 비결정성 등에서 사용됩니다. 이 라이브러리의 이름이 optparse-applicative인 이유도 파서를 만들기 위해 Applicative 인터페이스를 주요 API로 사용하기 때문입니다.


연습문제: Options의 생성자 ConvertDir에 대해서도 유사한 인터페이스를 만드세요.

정답
pInputDir :: Parser FilePath
pInputDir =
strOption
( long "input"
<> short 'i'
<> metavar "DIRECTORY"
<> help "Input directory"
)

pOutputDir :: Parser FilePath
pOutputDir =
strOption
( long "output"
<> short 'o'
<> metavar "DIRECTORY"
<> help "Output directory"
)

pConvertDir :: Parser Options
pConvertDir =
ConvertDir <$> pInputDir <*> pOutputDir

Alternative

사실 ConvertSingle는 입출력으로 표준 입력과 출력을 사용할 수도 있습니다. 지금까지 --input--output 플래그를 사용하여 파일을 읽거나 쓰는 하나의 옵션만 제공했습니다. 이제 우리는 이 플래그를 선택적으로 사용할 수 있게해, 지정하지 않았다면 표준 입출력을 사용하도록 만들려고 합니다. Control.Applicativeoptional 함수를 사용하여 이를 수행할 수 있습니다:

optional :: Alternative f => f a -> f (Maybe a)

optionalAlternative 타입 클래스의 인스턴스인 타입에 대해 작동합니다:

class Applicative f => Alternative f where
(<|>) :: f a -> f a -> f a
empty :: f a

AlternativeMonoid 타입 클래스와 매우 유사하지만, applicative functor에서 작동합니다. 이 타입 클래스는 자주 사용되지 않으며, 주로 파싱 라이브러리에서만 사용됩니다. 이는 두 개의 Parser를 결합할 수 있는 인터페이스를 제공합니다 - 첫 번째 파서가 파싱에 실패하면 다른 파서를 시도합니다. 또한 현재 우리에게 도움이 되는 optional과 같은 유용한 함수를 제공합니다.

pSingleInput :: Parser SingleInput
pSingleInput =
fromMaybe Stdin <$> optional pInputFile

pSingleOutput :: Parser SingleOutput
pSingleOutput =
fromMaybe Stdout <$> optional pOutputFile

fromMaybe :: a -> Maybe a -> a를 사용하여 Maybe 값을 a로 추출할 수 있습니다. 이는 Nothing이 아닌 경우에는 a를 반환하고, Nothing인 경우에는 기본값을 반환합니다.

이제 더 적절한 함수들을 사용해 pConvertSingle을 다시 작성할 수 있습니다:

pConvertSingle :: Parser Options
pConvertSingle =
ConvertSingle <$> pSingleInput <*> pSingleOutput

명령과 서브파서

현재 인터페이스는 두 가지 가능한 동작을 가지고 있습니다. 단일 소스를 변환하거나 디렉토리를 변환하는 것입니다. 적절한 동작을 선택하기 위한 좋은 인터페이스는 명령을 통해 선택하는 것입니다. 사용자가 단일 소스를 변환하려면 convert, 디렉토리를 변환하려면 convert-dir를 사용할 수 있습니다.

우리는 subparsercommand 함수를 사용하여 이를 수행할 수 있습니다:

subparser :: Mod CommandFields a -> Parser a

command :: String -> ParserInfo a -> Mod CommandFields a

subparser명령 수정자(command modifiers) (이는 command 함수로 만들 수 있습니다)를 입력으로 받아 Parser를 생성합니다. command는 명령 이름("convert" 또는 "convert-dir")과 ParserInfo a를 입력으로 받아 명령 수정자를 생성합니다. 이전에 보았던 것처럼 이러한 수정자들은 Monoid 인스턴스를 가지고 있으며, 여러 명령을 옵션으로 사용할 수 있도록 합칠 수 있습니다.

ParserInfo ainfo 함수를 사용하여 생성할 수 있습니다:

info :: Parser a -> InfoMod a -> ParserInfo a

이 함수는 주어진 Parser를 도움말, 설명과 같은 추가정보를 감싸, 프로그램과 각 서브 명령이 추가정보를 출력할 수 있도록 합니다.

ParserInfo는 어떻게 만드는지 살펴보겠습니다:

pConvertSingleInfo :: ParserInfo Options
pConvertSingleInfo =
info
(helper <*> pConvertSingle)
(progDesc "Convert a single markup source to html")

helper는 파서가 실패할 때 출력할 도움말을 추가합니다.

명령도 생성해봅시다:

pConvertSingleCommand :: Mod CommandFields Options
pConvertSingleCommand =
command "convert" pConvertSingleInfo

subparser를 사용하여 두 옵션을 결합해 Parser Options를 만들어보세요.

정답
pOptions :: Parser Options
pOptions =
subparser
( command
"convert"
( info
(helper <*> pConvertSingle)
(progDesc "Convert a single markup source to html")
)
<> command
"convert-dir"
( info
(helper <*> pConvertDir)
(progDesc "Convert a directory of markup files to html")
)
)

ParserInfo

이제 파서를 완성했으므로, ParserInfo로 감싸고 몇 가지 정보를 추가하여 실행할 준비가 되었습니다:

opts :: ParserInfo Options
opts =
info (helper <*> pOptions)
( fullDesc
<> header "hs-blog-gen - a static blog generator"
<> progDesc "Convert markup files or directories to html"
)

파서 실행하기

optparse-applicative는 인자를 파싱하는데 IO를 사용하지 않는 인터페이스를 제공합니다. 하지만 가장 편리한 방법은 인자를 가져오고, 파싱을 시도하고, 실패하면 오류와 도움말 메시지를 출력하는 것을 optparse-applicative에 맡기는 것입니다. 이는 execParser :: ParserInfo a -> IO a 함수로 수행할 수 있습니다.

새로운 모듈에 이러한 옵션을 파싱하는 코드를 작성하고, app/Main.hs에서 가져올 수 있습니다. 다음은 지금까지 작업한 코드입니다:

app/OptParse.hs
-- | 명령줄 옵션 파싱

module OptParse
( Options(..)
, SingleInput(..)
, SingleOutput(..)
, parse
)
where

import Data.Maybe (fromMaybe)
import Options.Applicative

------------------------------------------------
-- * 명령줄 옵션 모델

-- | 모델
data Options
= ConvertSingle SingleInput SingleOutput
| ConvertDir FilePath FilePath
deriving Show

-- | 단일 입력 소스
data SingleInput
= Stdin
| InputFile FilePath
deriving Show

-- | 단일 출력 sink
data SingleOutput
= Stdout
| OutputFile FilePath
deriving Show

------------------------------------------------
-- * Parser

-- | 명령줄 옵션 파싱
parse :: IO Options
parse = execParser opts

opts :: ParserInfo Options
opts =
info (pOptions <**> helper)
( fullDesc
<> header "hs-blog-gen - a static blog generator"
<> progDesc "Convert markup files or directories to html"
)

-- | 모든 옵션에 대한 파서
pOptions :: Parser Options
pOptions =
subparser
( command
"convert"
( info
(helper <*> pConvertSingle)
(progDesc "Convert a single markup source to html")
)
<> command
"convert-dir"
( info
(helper <*> pConvertDir)
(progDesc "Convert a directory of markup files to html")
)
)

------------------------------------------------
-- * 단일 소스를 위한 파서

-- | 단일 소스를 위한 옵션 파서
pConvertSingle :: Parser Options
pConvertSingle =
ConvertSingle <$> pSingleInput <*> pSingleOutput

-- | 단일 입력 소스를 위한 파서
pSingleInput :: Parser SingleInput
pSingleInput =
fromMaybe Stdin <$> optional pInputFile

-- | 단일 출력 sink를 위한 파서
pSingleOutput :: Parser SingleOutput
pSingleOutput =
fromMaybe Stdout <$> optional pOutputFile

-- | 입력 파일 파서
pInputFile :: Parser SingleInput
pInputFile = fmap InputFile parser
where
parser =
strOption
( long "input"
<> short 'i'
<> metavar "FILE"
<> help "Input file"
)

-- | 출력 파일 파서
pOutputFile :: Parser SingleOutput
pOutputFile = OutputFile <$> parser
where
parser =
strOption
( long "output"
<> short 'o'
<> metavar "FILE"
<> help "Output file"
)

------------------------------------------------
-- * 디렉토리 변환 파서

pConvertDir :: Parser Options
pConvertDir =
ConvertDir <$> pInputDir <*> pOutputDir

-- | 입력 디렉토리 파서
pInputDir :: Parser FilePath
pInputDir =
strOption
( long "input"
<> short 'i'
<> metavar "DIRECTORY"
<> help "Input directory"
)

-- | 출력 디렉토리 파서
pOutputDir :: Parser FilePath
pOutputDir =
strOption
( long "output"
<> short 'o'
<> metavar "DIRECTORY"
<> help "Output directory"
)

Options에 대한 패턴 매칭

명령줄 파서를 실행하고 나면, 모델에 대한 패턴 매칭을 통해 올바른 함수를 호출할 수 있습니다. 현재 프로그램은 이러한 종류의 API를 노출하지 않습니다. 따라서 src/HsBlog.hs 모듈로 이동하고 API를 변경합니다. main을 삭제하고 대신 두 개의 새 함수를 추가할 수 있습니다:

convertSingle :: Html.Title -> Handle -> Handle -> IO ()

convertDirectory :: FilePath -> FilePath -> IO ()

Handle 은 파일 시스템 객체에 대한 I/O 추상화입니다. stdinstdout를 포함합니다. 이전에는 writeFilegetContents를 사용했습니다. - 이 함수들은 Handle이 표준 I/O라고 가정하거나, FilePath를 열고 작업할 수 있습니다. 이제는 System.IO에서 Handle을 가져오는 명시적인 버전을 사용할 수 있습니다:

convertSingle :: Html.Title -> Handle -> Handle -> IO ()
convertSingle title input output = do
content <- hGetContents input
hPutStrLn output (process title content)

지금은 convertDirectory의 구현은 남겨두고, 다음 장에서 구현하겠습니다.

app/Main.hs에서 Options에 대한 패턴 매칭이 필요하며 HsBlog의 함수들을 호출해야 합니다.

app/Main.hssrc/HsBlog.hs의 전체 코드를 살펴보겠습니다:

app/Main.hs
-- | hs-blog-gen 프로그램의 진입점

module Main where

import OptParse
import qualified HsBlog

import System.Exit (exitFailure)
import System.Directory (doesFileExist)
import System.IO

main :: IO ()
main = do
options <- parse
case options of
ConvertDir input output ->
HsBlog.convertDirectory input output

ConvertSingle input output -> do
(title, inputHandle) <-
case input of
Stdin ->
pure ("", stdin)
InputFile file ->
(,) file <$> openFile file ReadMode

outputHandle <-
case output of
Stdout -> pure stdout
OutputFile file -> do
exists <- doesFileExist file
shouldOpenFile <-
if exists
then confirm
else pure True
if shouldOpenFile
then
openFile file WriteMode
else
exitFailure

HsBlog.convertSingle title inputHandle outputHandle
hClose inputHandle
hClose outputHandle

------------------------------------------------
-- * Utilities

-- | Confirm user action
confirm :: IO Bool
confirm =
putStrLn "Are you sure? (y/n)" *>
getLine >>= \answer ->
case answer of
"y" -> pure True
"n" -> pure False
_ -> putStrLn "Invalid response. use y or n" *>
confirm
src/HsBlog.hs
-- HsBlog.hs
module HsBlog
( convertSingle
, convertDirectory
, process
)
where

import qualified HsBlog.Markup as Markup
import qualified HsBlog.Html as Html
import HsBlog.Convert (convert)

import System.IO

convertSingle :: Html.Title -> Handle -> Handle -> IO ()
convertSingle title input output = do
content <- hGetContents input
hPutStrLn output (process title content)

convertDirectory :: FilePath -> FilePath -> IO ()
convertDirectory = error "Not implemented"

process :: Html.Title -> String -> String
process title = Html.render . convert title . Markup.parse

.cabal 파일에 몇 가지 작은 수정이 필요합니다.

먼저, executable 섹션에 directory 의존성을 추가해야 합니다. 왜냐하면 Main에서 System.Directory 라이브러리를 사용하기 때문입니다.

다음으로, OptParse 모듈을 executable 섹션의 other-modules에 추가해야 합니다.

 executable hs-blog-gen
import: common-settings
hs-source-dirs: app
main-is: Main.hs
+ other-modules:
+ OptParse
build-depends:
base
+ , directory
, optparse-applicative
, hs-blog
ghc-options:
-O

요약

지금까지 optparse-applicative 라이브러리에 대해 배웠습니다. 이 라이브러리는 선언적인 방법으로 강력한 명령줄 인터페이스를 만드는 데 사용할 수 있습니다. hs-blog-gen --help (또는 이전 장에서 논의한 cabal/stack 명령어를 사용해서)를 실행해, 결과를 확인해 보세요:

hs-blog-gen - a static blog generator

Usage: hs-blog-gen COMMAND
Convert markup files or directories to html

Available options:
-h,--help Show this help text

Available commands:
convert Convert a single markup source to html
convert-dir Convert a directory of markup files to html

그동안 우리는 FunctorApplicative이라는 두 가지 강력한 새로운 추상화를 배웠습니다. 또한 Monoid라는 추상화를 다시 살펴보았습니다. 이 라이브러리를 통해 이러한 추상화가 API와 DSL을 구축하는 데 얼마나 유용한지 알아보았습니다.

우리는 이 책의 나머지 부분에서 이러한 추상화를 계속 만나게 될 것입니다.


추가 연습문제: 출력 파일이나 디렉토리가 이미 존재하는 경우에도 덮어써도 괜찮다는 것을 의미하는 --replace 플래그를 추가하세요.


Git 커밋을 통해 이번에 수정한 내역현재까지 코드 를 확인할 수 있습니다.