Haskell - Parsec을 사용하여 명명 규칙 적용

내 프로젝트migratum에서 다음과 같은 명명 규칙을 적용하고 싶었습니다.

V<version number>__<file name>.sql
+       +         +     +     + +
|       |         |     |     | |
|       |         |     |     | v
|       |         |     |     | Enforce sql extension.
|       |         |     |     v
|       |         |     |     Enforce this dot to indicate the end of the file name.
|       |         |     v
|       |         |     File name need to be alpha numeric. No other symbols, except underscore
|       |         v
|       |         Enforce the double underscore
|       v
|       Version number have to be numbers.
v
Enforce the V character to signify "version"

내가 할 수 있는 방법은 다음과 같습니다.
  • 정규식
  • 파서 결합기

  • 솔직히 말해서 매우 간단한 정규식을 알고 있고 regex license 이 없기 때문에 정규식으로 이 작업을 수행하는 방법을 모르겠습니다. 저는 파서 조합기에 능숙하며 이 블로그 게시물의 제목이기도 합니다. 그래서 이것이 제가 설정한 작업을 수행하는 데 사용할 것입니다.

    파서 결합자는 json 와 같은 재귀적인 것을 파싱할 때 정말 빛납니다. json 배열과 개체를 포함할 수 있고 이러한 개체와 배열이 배열과 개체 등을 포함할 수 있는 방법을 알고 있습니까? 예, 파서 결합기는 정말 깔끔합니다. 따라서 이 블로그 게시물에서만 파서 결합자를 판단하지 마십시오. 나는 파서 조합기를 선택했는데, 그것이 내가 파싱을 할 때 가장 많이 사용하는 방법이고 개인적으로 편리하다고 생각하기 때문입니다.

    구문 분석을 하기 전에 위의 ASCII 다이어그램을 기반으로 파일 이름의 형식 표현을 만들어 보겠습니다.

    data FilenameStructure 
      = FilenameStructure FileVersion Underscore Underscore FileName Dot FileExtension
      deriving ( Eq, Show )
    
    newtype FileVersion = FileVersion Text
      deriving ( Eq, Show )
    
    newtype Underscore = Underscore Text
      deriving ( Eq, Show )
    
    newtype FileName = FileName Text
      deriving ( Eq, Show )
    
    newtype Dot = Dot Text
      deriving ( Eq, Show )
    
    newtype FileExtension = FileExtension Text
      deriving ( Eq, Show )
    

    이제 전체 파일 이름을 구문 분석하는 대신 파일 이름의 특정 부분에 집중할 수 있습니다. 그게 좀 더 수월해지는 것 같아요. 적어도 나에게는 그렇습니다. 파일 버전, 밑줄, 파일 이름 등을 개별적으로 구문 분석하는 것을 생각할 수 있습니다.

    수입품부터 시작하겠습니다. 마음에 떠오르는 인기있는 라이브러리는 parsec, megaparsec 및 attoparsec입니다. 어느 것이 귀하의 프로젝트에 적합한지 평가하십시오. 그러나 이 블로그 게시물에서는 parsec을 사용하고 있습니다.

    -- parsec
    import           Text.ParserCombinators.Parsec (GenParser, ParseError)
    import qualified Text.ParserCombinators.Parsec as Parsec
    
    -- text
    import qualified Data.Text                     as T
    

    파일 버전 파서부터 시작하겠습니다. 우리의 규칙에 따르면 V 문자로 시작해야 하며 그 뒤에 숫자가 와야 합니다.

    fileVersionParser :: GenParser Char st FileVersion
    fileVersionParser = do
      vChar <- Parsec.char 'V'
      vNum <- Parsec.digit
      pure $ FileVersion $ T.pack $ ( vChar : vNum : [] )
    
    ghci에서 이것을 시도하면 다음과 같이 보일 것입니다.

    ghci> import Text.ParserCombinators.Parsec
    ghci> parse fileVersionParser "" "V69"
    ghci> Right ( FileVersion "V69" )
    
    Right를 일치시키고 FileVersion를 반환하는 것이 우리가 원하는 것입니다.
    텍스트 "V69".

    ghci> parse fileVersionParser "" "Vwhat"
    ghci> Left (line 1, column2):
    unexpected "w"
    

    맞습니다. 버전 번호가 없으면 실패해야 합니다.

    ghci> parse fileVersionParser "" "V1what"
    ghci> Right ( FileVersion "V1" )
    

    숫자가 아닌 입력을 삭제합니다.

    ghci> parse fileVersionParser "" "what"
    ghci> Left (line 1, column 1):
    unexpected "w"
    expecting "V"
    

    마지막으로 "V"문자를 찾지 못하면 실패합니다.

    우후! 하나의 파서가 다운되고 4개가 남았습니다!

    나머지 파서를 수행하고 ghci 에서 자유롭게 시도해 보겠습니다.

    underscoreParser :: GenParser Char st Underscore
    underscoreParser = Underscore . T.pack . pure <$> Parsec.satisfy isUnderscore
    
    isUnderscore :: Char -> Bool
    isUnderscore char = any ( char== ) ( "_" :: String )
    
    fileNameParser :: GenParser Char st FileName
    fileNameParser = FileName . T.pack 
      <$> Parsec.many ( Parsec.alphaNum <|> Parsec.satisfy isUnderscore )
    
    dotParser :: GenParser Char st Dot
    dotParser = Dot . T.pack . pure <$> Parser.char '.'
    
    fileExtensionParser :: GenParser Char st FileExtension
    fileExtensionParser = FileExtension. T.pack <$> Parsec.string "sql"
    

    그런 다음 전체 파일 이름에 대한 파서를 만들기 위해 나머지 파서를 결합합니다.

    filenameStructureParser :: GenParser Char st FilenameStructure
    filenameStructureParser = FilenameStructure
      <$> fileVersionParser
      <*> underscoreParser
      <*> underscoreParser
      <*> fileNameParser
      <*> dotParser
      <*> fileExtensionParser
    

    더 편리한 경우 do 구문을 사용하여 이 작업을 수행할 수도 있습니다.

    filenameStructureParser :: GenParser Char st FilenameStructure
    filenameStructureParser = do
      version <- fileVersionParser
      u1 <- underscoreParser
      u2 <- underscoreParser
      fileName <- fileNameParser
      dot <- dotParser
      ext <- fileExtensionParser
      pure $ FilenameStructure version u1 u2 fileName dot ext
    

    마지막으로 파서 "runner"

    namingConvention :: String -> Either ParseError FilenameStructure
    namingConvention = 
      Parsec.parse filenameStructureParser "Error: Not following naming convention"
    
    ghci에서 수동으로 시도하는 대신 일부 단위 테스트를 수행할 수 있습니다. 따라서 우리는 터미널을 계속해서 어지럽힐 필요가 없습니다.

    module NamingConventionSpec where
    
    import Test.Hspec
    
    spec :: Spec
    spec = do
      desribe "Filename" $ do
        it "follows naming convention" $ do
          successResult <- pure $ namingConvention "V1__uuid_extension.sql"
          emptyFileName <- pure $ namingConvention ""
          noExtension <- pure $ namingConvention "V1__uuid_extension"
          noVersion <- pure $ namingConvention "uuid_extension.sql"
          upperCaseFileName <- pure $ namingConvention "V1_UUID_extension.sql"
          symbolFileName <- pure $ namingConvention "V1_UUID+extension.sql"
    
          -- success cases
          shouldBe successResult ( Right "V1__uuid_extension.sql" )
          shouldBe upperCaseFileName ( Right "V1__UUID_extension.sql" )
    
          -- failure cases
          shouldBe ( isLeft emptyFileName ) True
          shouldBe ( isLeft noFileExt ) True
          shouldBe ( isLeft noVersion ) True
          shouldBe ( isLeft symbolFilename ) True
    

    이제 파서가 있으므로 중복 검사를 사용할 수 있습니다. 유형에 Eq 인스턴스가 있으므로 동등성 검사를 수행할 수 있기 때문입니다.

    fileVersion :: FilenameStructure -> FileVersion
    fileVersion ( FilenameStructure v _ _ _ _ _ ) = v
    
    getVersion :: Either ParseError FilenameStructure -> Either ParseError FileVersion
    getVersion res = case res of
      Left err -> Left err
      Right fs -> Right $ fileVersion fs
    
    checkDuplicate :: [ String ] -> Either ParseError [ String ]
    checkDuplicate filenames = do
      versions <- traverse getVersion ( namingConvention <$> filenames )
      if anySame versions
        -- Express your errors however you want, this is just to show that this is 
        -- the error branch.
        then error "Duplicate migration file"
        else Right filenames
      where
        anySame :: Eq a => [ a ] -> Bool
        anySame = isSeen []
    
        isSeen :: Eq a => [ a ] -> [ a ] -> Bool
        isSeen seen ( x:xs ) = x `elem` seen || isSeen ( x:seen ) xs
        isSeen _ [] = False
    

    파서를 생성할 때 유효성 검사 또는 "스마트"논리를 수행하지 마십시오. 입력 구문 분석에 집중하고 더 이상 아무것도 하지 마십시오. 파서가 있으면 출력으로 원하는 모든 작업을 수행할 수 있습니다.

    참고문헌



    Haskell from First Principles

    Monadic Parser Combinators

    좋은 웹페이지 즐겨찾기