/ nix / script / exe / clap-parser.hs
clap-parser.hs
  1  {-# LANGUAGE OverloadedStrings #-}
  2  {-# LANGUAGE RecordWildCards #-}
  3  
  4  {- | Parser for clap (Rust) -h/--help output
  5  
  6  Clap is the most common CLI library in the Rust ecosystem, used by:
  7    ripgrep, fd, bat, hyperfine, tokei, dust, exa
  8    cloud-hypervisor, mdbook, nixdoc, ndg, deadnix, statix
  9    ruff, biome, stylua, taplo
 10  
 11  Works best with short help (-h), handles:
 12    -s, --long=VALUE    Description
 13    --long <value>      Description
 14    -x                  Short only
 15  -}
 16  module Main where
 17  
 18  import Control.Monad (void)
 19  import Data.Maybe (catMaybes)
 20  import Data.Text (Text)
 21  import qualified Data.Text as T
 22  import qualified Data.Text.IO as TIO
 23  import Data.Void
 24  import System.Environment (getArgs)
 25  import Text.Megaparsec
 26  import Text.Megaparsec.Char hiding (hspace, hspace1)
 27  
 28  type Parser = Parsec Void Text
 29  
 30  -- | A CLI option/flag
 31  data ClapOption = ClapOption
 32      { optShort :: Maybe Char
 33      , optLong :: Maybe Text
 34      , optArg :: Maybe Text
 35      , optDesc :: Text
 36      }
 37      deriving (Show, Eq)
 38  
 39  -- | A section of options
 40  data ClapSection = ClapSection
 41      { secName :: Text
 42      , secOptions :: [ClapOption]
 43      }
 44      deriving (Show, Eq)
 45  
 46  -- | Full parsed help
 47  data ClapHelp = ClapHelp
 48      { helpSections :: [ClapSection]
 49      }
 50      deriving (Show, Eq)
 51  
 52  -- Horizontal whitespace
 53  hspace :: Parser ()
 54  hspace = void $ takeWhileP Nothing (\c -> c == ' ' || c == '\t')
 55  
 56  -- Rest of line
 57  restOfLine :: Parser Text
 58  restOfLine = do
 59      content <- takeWhileP Nothing (/= '\n')
 60      void (char '\n') <|> eof
 61      return content
 62  
 63  -- Parse short option: -x or -.
 64  shortOpt :: Parser Char
 65  shortOpt = char '-' *> (letterChar <|> digitChar <|> char '.')
 66  
 67  -- Parse long option: --name
 68  longOpt :: Parser Text
 69  longOpt = string "--" *> takeWhile1P Nothing isOptChar
 70    where
 71      isOptChar c = c == '-' || c == '_' || c `elem` ['a' .. 'z'] || c `elem` ['A' .. 'Z'] || c `elem` ['0' .. '9']
 72  
 73  -- Parse argument: =VAL or <val> or space <val>
 74  argPlaceholder :: Parser Text
 75  argPlaceholder =
 76      choice
 77          [ char '=' *> takeWhile1P Nothing (\c -> c /= ' ' && c /= '\t' && c /= '\n')
 78          , try $ hspace *> char '<' *> takeWhile1P Nothing (/= '>') <* char '>'
 79          , char '[' *> (char '=' *> takeWhile1P Nothing (\c -> c /= ']' && c /= '\n')) <* char ']' -- [=val]
 80          ]
 81  
 82  -- Parse one option line
 83  optionLine :: Parser ClapOption
 84  optionLine = do
 85      hspace
 86      -- Short option
 87      mShort <- optional $ try (shortOpt <* optional (string ", "))
 88      -- Long option
 89      mLong <- optional $ try longOpt
 90      -- Argument
 91      mArg <- optional $ try argPlaceholder
 92      -- Description (rest of line)
 93      hspace
 94      desc <- restOfLine
 95      -- Must have at least short or long
 96      case (mShort, mLong) of
 97          (Nothing, Nothing) -> empty
 98          _ ->
 99              return
100                  ClapOption
101                      { optShort = mShort
102                      , optLong = mLong
103                      , optArg = mArg
104                      , optDesc = T.strip desc
105                      }
106  
107  -- Check if line looks like section header (ends with :, not indented, not an option)
108  isSectionLine :: Text -> Bool
109  isSectionLine t =
110      let stripped = T.strip t
111       in not (T.null stripped)
112              && T.last stripped == ':'
113              && not (T.isPrefixOf "-" stripped)
114              && not (T.isPrefixOf " " t) -- not indented
115              && not (T.isInfixOf "://" stripped) -- not a URL
116  
117  -- Parse all content, extracting sections
118  parseHelp :: Text -> ClapHelp
119  parseHelp input = ClapHelp{helpSections = go [] Nothing (T.lines input)}
120    where
121      go acc _curSec [] =
122          case _curSec of
123              Just (name, opts) -> reverse (ClapSection name (reverse opts) : acc)
124              Nothing -> reverse acc
125      go acc curSec (line : rest)
126          | isSectionLine line =
127              let secName = T.dropEnd 1 (T.strip line)
128                  acc' = case curSec of
129                      Just (name, opts) -> ClapSection name (reverse opts) : acc
130                      Nothing -> acc
131               in go acc' (Just (secName, [])) rest
132          | Just (name, opts) <- curSec =
133              case parse optionLine "" (line <> "\n") of
134                  Right opt -> go acc (Just (name, opt : opts)) rest
135                  Left _ ->
136                      -- Skip blank lines and continuation lines (indented)
137                      if T.null (T.strip line) || T.isPrefixOf "  " line
138                          then go acc curSec rest
139                          else go (ClapSection name (reverse opts) : acc) Nothing rest
140          | otherwise = go acc curSec rest
141  
142  -- Pretty print
143  ppClapHelp :: ClapHelp -> Text
144  ppClapHelp ClapHelp{..} = T.unlines $ concatMap ppSection helpSections
145    where
146      ppSection ClapSection{..} =
147          [secName <> ":"] ++ map ppOpt secOptions ++ [""]
148      ppOpt ClapOption{..} =
149          let short = maybe "" (\c -> "-" <> T.singleton c) optShort
150              long = maybe "" ("--" <>) optLong
151              arg = maybe "" (\a -> "=" <> a) optArg
152              flags = T.intercalate ", " $ filter (not . T.null) [short, long <> arg]
153           in "  " <> flags <> ": " <> optDesc
154  
155  main :: IO ()
156  main = do
157      args <- getArgs
158      input <- case args of
159          [] -> TIO.getContents
160          [f] -> TIO.readFile f
161          _ -> error "Usage: clap-parser [FILE]"
162      let help = parseHelp input
163          total = sum $ map (length . secOptions) (helpSections help)
164      TIO.putStr $ ppClapHelp help
165      putStrLn $
166          "Parsed "
167              ++ show total
168              ++ " options in "
169              ++ show (length (helpSections help))
170              ++ " sections"