packages feed

rascal (empty) → 1.1.4

raw patch · 12 files changed

+860/−0 lines, 12 filesdep +HUnitdep +QuickCheckdep +aesonsetup-changedbinary-added

Dependencies added: HUnit, QuickCheck, aeson, ansi-terminal, base, containers, curl, curl-aeson, directory, filepath, mtl, process, tasty, tasty-hunit, tasty-quickcheck, vector

Files

+ LICENSE view
@@ -0,0 +1,21 @@+The MIT License (MIT)++Copyright (c) 2013 Sylvain Soliman++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in+all copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN+THE SOFTWARE.
+ README.md view
@@ -0,0 +1,70 @@+```+                              __ +.----.---.-.-----.----.---.-.|  |+|   _|  _  |__ --|  __|  _  ||  |+|__| |___._|_____|____|___._||__|+                                 +```++[![Build Status](https://travis-ci.org/soli/rascal.png)](https://travis-ci.org/soli/rascal)++rascal is a command-line client for [reddit](http://www.reddit.com/).++It should work on any platform where Haskell runs (Linux, Mac OSX and Windows+through MinGW), and aims at making available the whole reddit API (we're not+there yet…).++It sports colors, that should soon become configurable, and might not work+under Windows (based on ANSI escape sequences, through+[ansi-terminal](https://github.com/batterseapower/ansi-terminal)).++By default it will open [/r/haskell](http://www.reddit.com/r/haskell/new)+sorted by "new", but you can provide another subreddit as argument on the+command line++```+$ rascal python+```++It is also possible to define the default subreddit and the default sort+option in an INI-style configuration file named `.rascalrc` in your HOME+directory. For instance, it might contain the following (case-sensitive!):++```+subreddit: programming+linkSort = controversial+commentSort: new+pageComments = True+```++Here is what rascal looks like when started, and when a _self_ link (marked+with a '♦') is chosen (`N` was pressed in this case). Non-self links and links+detected in posts will be opened in your default browser.++![screenshot](https://github.com/soli/rascal/raw/master/screenshot.png)++Comments are shown threaded, with the order defined in the configuration file+(defaulting to `new`), and one page at a time if `pageComments` is true+(default). Comments, like posts, are formatted to avoid cutting words when+possible.++![threads](https://github.com/soli/rascal/raw/master/threads.png)++### Notes++In case you wonder, my iTerm2 is using the dark+[solarized](https://github.com/altercation/solarized) color scheme. The ASCII+title was generated with `figlet -f chunky`. And the name comes from my own+pronunciation of `/r/haskell`.++### Planned Features++* Configuration for colors+* OAuth2 login to reddit (subscribed/saved/etc.)+* more tests++## Disclaimer++Please note that rascal is my first Haskell project. As such, it is as much a+fun experiment for me to discover the ecosystem and the idioms, than a really+useful project. TL;DR: YMMV.
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ rascal.cabal view
@@ -0,0 +1,49 @@+name:                rascal++-- The package version.  See the Haskell package versioning policy (PVP) +-- for standards guiding when and how versions should be incremented.+-- http://www.haskell.org/haskellwiki/Package_versioning_policy+-- PVP summary:      +-+------- breaking API changes+--                   | | +----- non-breaking API additions+--                   | | | +--- code changes with no API change+version:             1.1.4+synopsis:            A command-line client for Reddit+description:         Rascal is a command-line client for Reddit with colors,+                     configurable sorting, threaded comments, and some day+                     most of Reddit's API available.+license:             MIT+license-file:        LICENSE+author:              Sylvain Soliman+maintainer:          Sylvain.Soliman@gmail.com+category:            Web+build-type:          Simple+extra-source-files:  README.md, screenshot.png, threads.png+cabal-version:       >=1.10+stability:           experimental+homepage:            http://soli.github.io/rascal/++source-repository head+  type:              git+  location:          git://github.com/soli/rascal.git++executable rascal+  hs-source-dirs:    src+  main-is:           Main.hs+  other-modules:     Paths_rascal, Rascal.Utils, Rascal.Conf, Rascal.Types+                   , Rascal.Constants+  ghc-options:       -Wall+  build-depends:     base >=4.6 && <4.7, curl-aeson, curl, aeson+                   , process >= 1.2, ansi-terminal, vector+                   , filepath, directory, containers, mtl+  default-language:  Haskell2010++test-suite Tests+  hs-source-dirs:    src+  main-is:           Test.hs+  type:              exitcode-stdio-1.0+  build-depends:     base >=4.6 && <4.7, curl-aeson, curl, aeson+                   , process >= 1.2, ansi-terminal, vector+                   , filepath, directory, containers, mtl+                   , QuickCheck, tasty, tasty-quickcheck+                   , HUnit, tasty-hunit+  default-language:  Haskell2010
+ screenshot.png view

binary file changed (absent → 220715 bytes)

+ src/Main.hs view
@@ -0,0 +1,263 @@+--+-- RASCAL, a Haskell cli reddit client+-- Copyright (c) 2013 Sylvain Soliman <Sylvain.Soliman@gmail.com>+-- MIT License, see LICENSE+--++import Control.Monad          (when, unless)+import Control.Monad.Reader   (ReaderT, ask, runReaderT)+import Control.Monad.Trans    (liftIO)+import Control.Applicative    ((<$>))+import Text.Printf            (printf)+import System.Environment     (getArgs)+import Data.Char              (ord)+import Data.List              (intercalate, elemIndices)+import Data.Maybe             (isJust)+import System.IO+import Control.Exception      (handle)++import Data.Aeson             (parseJSON, FromJSON)+import Network.Curl.Aeson     (curlAeson, noData, CurlAesonException, errorMsg, curlCode)+import Network.Curl.Opts      (CurlOption(CurlUserAgent))+import Network.Curl.Code      (CurlCode(CurlOK))+import System.Process         (readProcess)+import System.Console.ANSI    (clearLine)+import Data.Map               ((!))++import Rascal.Constants+import Rascal.Utils+import Rascal.Types+import Rascal.Conf++-- we do not use Show because we depend on (an IO generated) width+showLink :: Link -> Int -> String+showLink l width =+   let titlewidth = width - 34+       self | isSelf l = green ++ "♦"+            | otherwise = " "+       color | score l == 0 = blue+             | otherwise = red+       format = printf " %%s%%3d%%s%%s %%-%d.%ds  %%20.20s  %%s%%3d%%s "+                titlewidth titlewidth+   in printf format color (score l) self reset (title l) (author l)+      magenta (numComments l) reset++showListing :: NamedListing -> Int -> String+showListing l width =+   let lnks = links $ listing l+   in bold ++ "\n--=| /r/" ++ name l ++ " |=--\n\n" ++ reset +++      -- the -4 comes from letterizeLines+      letterizeLines (map (`showLink` (width - 4)) lnks)++showComment :: Int -> String -> String -> String -> String -> Comment -> [String]+showComment width prefix addedPrefix futurePrefix op+   (Comment cauthor ups downs _ body children) =+   let prefix' = prefix ++ futurePrefix+       author' | cauthor == op = green ++ cauthor ++ reset+               | otherwise = cauthor+       header = printf "%s─ %.20s (%s%d%s|%s%d%s)" addedPrefix author' red+         ups reset blue downs reset+       headerBlock = indentString width prefix header+       commentBlock = indentString width prefix' (unescape body)+   in (prefix ++ "│ "):(headerBlock ++ init commentBlock):+      showCommentListing width prefix' op children++showComment _ _ _ _ _ OriginalArticle =+   []++showCommentListing :: Int -> String -> String -> CommentListing -> [String]+showCommentListing width prefix op (CommentListing cl) =+   case cl of+      [] -> []+      _ -> concatMap (showComment width prefix "├" "│ " op) (init cl) +++          showComment width prefix "└" "  " op (last cl)++-- |print a listing on screen and ask for a command+displayListing :: NamedListing -> ReaderT RuntimeConf IO ()+displayListing l = do+   conf <- ask+   let w = textWidth conf+   liftIO $ do+      putStrLn $ showListing l w+      displayCommands w++-- |get posts according to selection in argument's subreddit as a listing+getListing :: String -> String -> Int -> Maybe String -> IO NamedListing+getListing select subreddit cnt aftr =+   let apiurl = "http://www.reddit.com/r/" ++ subreddit ++ "/%s.json?count="+                ++ show cnt ++ maybe "" ("&after=" ++) aftr+   in NamedListing (subreddit ++ " -- " ++ select) cnt <$>+      getThing apiurl select emptyListing++-- |get posts or comments from an apiurl, a sort order and a default in case+-- of error+getThing :: FromJSON a => String -> String -> a -> IO a+getThing apiurl sort emptyThing =+   let sort' = if sort `notElem` map snd availableSorts+               then snd . head $ availableSorts+               else sort+       apiurl' = printf apiurl sort'+   in handle (handleCurlAesonException emptyThing) $ do+      l <- curlAeson parseJSON "GET" apiurl' [CurlUserAgent userAgent] noData+      return $! l++-- |print error message if there is a cURL exception+handleCurlAesonException :: a -> CurlAesonException -> IO a+handleCurlAesonException x e = do+   putStrLn $ red ++ "Caught exception: " ++ reset ++ errorMsg e+   putStrLn $ if curlCode e == CurlOK+              then "(Might indicate a non-existing subreddit)"+              else "cURL code: " ++ (drop 4 . show . curlCode) e+   return x++-- |open nth link in a listing in given width+open :: NamedListing -> Int -> ReaderT RuntimeConf IO ()+open nl@(NamedListing _ _ (Listing l _)) n = do+   conf <- ask+   -- n >= 0 by construction on call of open, but...+   when (0 <= n && n < length l) $+      let ln = (l !! n)+          w = textWidth conf+          subreddit = takeWhile (/=' ') (name nl)+      in do+         if isSelf ln+         then openSelf ln+         else liftIO $ openUrl (link ln) w+         openComments subreddit ln+         displayListing nl++-- |display a self link, with its contained hrefs+openSelf :: Link -> ReaderT RuntimeConf IO ()+openSelf ln = do+   conf <- ask+   let refs = hrefs (selfHtml ln)+       w = textWidth conf+   liftIO $ do+      message "" w+      putStrLn $ cleanUp (selfText ln) w+      if null refs+      then waitKey w+      else do+         putStr "\n"+         mapM_ (putStrLn . showRef) $ zip [1..] refs+         message "press link number to open or a key to continue" w+         openRefs refs w++-- |display all comments of an article in a subreddit+openComments :: String -> Link -> ReaderT RuntimeConf IO ()+openComments subreddit ln = do+   conf <- ask+   let w = textWidth conf+       csort = commentSort conf+   when (numComments ln > 0) $ liftIO $ do+      (Comments cll) <- getComments subreddit (drop 3 (uid ln)) csort+      putStrLn ""+       -- the first is OriginalArticle, the length is always 2+      unless (null cll) $ do+         let allComments = showCommentListing w "" (author ln) (cll !! 1)+         if pageComments conf+         then doPageComments (textHeight conf - 2) 0 w allComments+         else mapM_ putStrLn allComments+      waitKey w++doPageComments :: Int -> Int -> Int -> [String] -> IO ()+doPageComments _ _ _ [] = return ()+doPageComments _ _ _ [_] = return ()   -- should never happen...+doPageComments height shown w allComments@(a:b:remaining)+   | shown > height = waitKey w >> doPageComments height 0 w allComments+   | otherwise = do+      let nbLines = 2 + sum (map (length . elemIndices '\n') [a, b])+          empty = height - shown+      shown' <- if empty > nbLines+               then return shown+               else do+                  waitKey w+                  -- '\b' is backspace, since even clearLine does not enforce+                  -- clearing the key typed for input+                  putStr "\b"+                  return 0+      putStrLn a+      putStrLn b+      doPageComments height (shown' + nbLines) w remaining++-- |request comments for a given article+getComments :: String -> String -> String -> IO Comments+getComments subreddit article csort =+   let apiurl = "http://www.reddit.com/r/" ++ subreddit +++                "/comments/" ++ article ++ ".json?sort=%s"+   in getThing apiurl csort emptyComments++-- |open requested hrefs as urls and stop if anything else+openRefs :: [String] -> Int -> IO ()+openRefs l w = do+   c <- getChar+   clearLine+   let n = ord c - ord '1'+   when (0 <= n && n < length l) $ do+      openUrl (l !! n) w+      openRefs l w++main ::  IO ()+main = do+   hSetBuffering stdin NoBuffering+   args <- getArgs+   columns <- readProcess "tput" ["cols"] []+   nbLines <- readProcess "tput" ["lines"] []+   conf <- getUserConfig ".rascalrc" defaultConf+   let width = read columns+       height = read nbLines+       subreddit | null args = conf ! "subreddit"+                 | otherwise = head args+       lSort  = conf ! "linkSort"+       cSort  = conf ! "commentSort"+       pComments = reads (conf ! "pageComments")+       pComments' | null pComments = True    -- TODO get from defaultConf+                  | otherwise = fst $ head pComments+       conf' = RuntimeConf width height cSort lSort pComments'+   list <- getListing lSort subreddit 0 Nothing+   runReaderT (displayListing list >> loop list) conf'++-- |show possible commands+displayCommands :: Int -> IO ()+displayCommands =+   message $ intercalate "/" (map makeCmd availableSorts) +++      "/⟨m⟩ore links/⟨s⟩witch subreddit/open ⟨A-Y⟩"++-- |main event loop+loop :: NamedListing -> ReaderT RuntimeConf IO ()+loop l = do+   cmd <- liftIO getChar+   liftIO clearLine+   conf <- ask+   case cmd of+      's' -> do+         subreddit <- liftIO $ do+            putStr "\nsubreddit to switch to: "+            hFlush stdout+            getLine+         list <- liftIO $ getListing (linkSort conf) subreddit 0 Nothing+         displayListing list+         loop list+      'm' -> if isJust (after (listing l))+            then do+               let subreddit = takeWhile (/= ' ') (name l)+                   sort = drop 4 (dropWhile (/= ' ') (name l))+                   cnt = count l + length (links (listing l))+                   aftr = after $ listing l+               list <- liftIO $ getListing sort subreddit cnt aftr+               displayListing list+               loop list+            else do+               liftIO $ message "no more posts…" (textWidth conf)+               loop l+      -- is this one of the sort options?+      x | isJust (getFullSort x) -> do+         list <- let (Just sort) = getFullSort x+                in liftIO $ getListing sort (takeWhile (/=' ') (name l)) 0 Nothing+         displayListing list+         loop list+      -- 25 elements displayed max+        | x `elem` ['A'..'Y'] -> do+         open l (ord x - ord 'A')+         loop l+      _ -> return ()
+ src/Rascal/Conf.hs view
@@ -0,0 +1,55 @@+--+-- Looked at the several existing config-file parsing libs+-- they were all overkill+-- |This module does the strict minimum needed parsing INI-style+-- config files+--++{-# LANGUAGE ScopedTypeVariables #-}++module Rascal.Conf where++import Data.Map            (Map, union, fromList)+import Data.Char           (isSpace)+import Data.List           (dropWhileEnd)+import Control.Exception   (handle)+import Control.Monad       (liftM)++import System.Directory    (getHomeDirectory)+import System.FilePath     ((</>))++type Key   = String+type Value = String+type Conf  = Map Key Value++-- |Parses a string into a Map String String+parseConfig :: String -> Conf+parseConfig = fromList . map getKeyValue . filter configLine . lines++-- |keep only lines that will lead to some configuration element+-- stripping out comments, empty lines, etc.+configLine :: String -> Bool+configLine l =+   not (null l) && head l /= '#' && (':' `elem` l || '=' `elem` l)++-- |Turns a line into a key, value pair+getKeyValue :: String -> (Key, Value)+getKeyValue line =+   let (key, _:value) = break (`elem` ":=") line+   in (stripWhite key, stripWhite value)++-- |drop leading and ending white space+stripWhite :: String -> String+stripWhite =+   dropWhile isSpace . dropWhileEnd isSpace++-- |search for fileName in the user's home directory and combine it with+-- default options to provide a Conf map+getUserConfig :: String -> [(String, String)] -> IO Conf+getUserConfig fileName defaultOptions = do+   home <- getHomeDirectory+   let userConfFile = home </> fileName+       defaultConf = fromList defaultOptions+   handle (\(_ :: IOError) -> return defaultConf) $+      -- union will take left over right if key is defined twice+      liftM ((`union` defaultConf) . parseConfig) (readFile userConfFile)
+ src/Rascal/Constants.hs view
@@ -0,0 +1,48 @@+-- |Various constants for rascal+module Rascal.Constants where++import Data.Version        (showVersion)++import System.Console.ANSI++import Paths_rascal        (version)++-- |user_agent for cURL built with version string+userAgent :: String+userAgent = "rascal/" ++ showVersion version ++ " by soli"++-- |colors and more+red :: String+red  = setSGRCode [SetColor Foreground Dull Red]+green :: String+green = setSGRCode [SetColor Foreground Dull Green]+yellow :: String+yellow  = setSGRCode [SetColor Foreground Dull Yellow]+blue :: String+blue  = setSGRCode [SetColor Foreground Dull Blue]+magenta :: String+magenta  = setSGRCode [SetColor Foreground Dull Magenta]+cyan :: String+cyan  = setSGRCode [SetColor Foreground Dull Cyan]+reset :: String+reset = setSGRCode [Reset]+bold :: String+bold = setSGRCode [SetConsoleIntensity BoldIntensity]++-- |available sort options+availableSorts :: [(Char, String)]+availableSorts = [+     ('n', "new")+   , ('h', "hot")+   , ('t', "top")+   , ('c', "controversial")+   ]++-- |default configuration options as an association list+defaultConf :: [(String, String)]+defaultConf = [+     ("subreddit", "haskell")+   , ("linkSort", "new")+   , ("commentSort", "new")+   , ("pageComments", "true")+   ]
+ src/Rascal/Types.hs view
@@ -0,0 +1,119 @@+{-# LANGUAGE OverloadedStrings #-}+-- allow Text objects directly as strings, used for JSON parsing++module Rascal.Types where++import Control.Applicative (empty, (<$>), (<*>), (<|>))++import Data.Aeson+import Data.Vector         (toList)++import Rascal.Utils        (unescape)++-- | a reddit post, called Link in reddit's API documentation+data Link = Link {+   title :: String,+   author :: String,+   score :: Int,+   isSelf :: Bool,+   link :: String,+   -- created :: Int,+   uid :: String,+   numComments :: Int,+   selfHtml :: String,+   selfText :: String+}++data Listing = Listing+   { links :: [Link]+   , after :: Maybe String+   }++emptyListing :: Listing+emptyListing = Listing [] Nothing++data NamedListing = NamedListing+   { name :: String+   , count :: Int+   , listing :: Listing+}++newtype Comments = Comments [CommentListing] deriving (Show)++emptyComments :: Comments+emptyComments = Comments []++newtype CommentListing = CommentListing [Comment] deriving (Show)++data Comment = Comment+   { _cauthor :: String+   , _ups :: Int+   , _downs :: Int+   -- , created :: Int+   -- , edited :: Int (or false)+   , __bodyHtml :: String+   , _body :: String+   , _children :: CommentListing+   }+   | OriginalArticle deriving (Show)++-- |json parser for 'Link'+instance FromJSON Link where+   parseJSON (Object o) = do+      datum <- o .: "data"+      etitle <- datum .: "title"+      Link (unescape etitle)+           <$> datum .: "author"+           <*> datum .: "score"+           <*> datum .: "is_self"+           <*> datum .: "url"+           -- <*> datum .: "created_utc"+           <*> datum .: "name"+           <*> datum .: "num_comments"+           <*> datum .:? "selftext_html" .!= ""+           <*> datum .: "selftext"+   parseJSON _ = empty++-- |json parser for 'Listing'+instance FromJSON Listing where+   parseJSON (Object o) = do+      datum <- o .: "data"+      Listing <$> datum .: "children"+              <*> datum .: "after"+   parseJSON _ = empty++-- |json parser for 'Comments'+instance FromJSON Comments where+   parseJSON (Array a) = Comments <$> mapM parseJSON (toList a)+   parseJSON _ = empty++instance FromJSON CommentListing where+   parseJSON (Object o) = do+      datum <- o .: "data"+      a <- datum .: "children"+      CommentListing <$> mapM parseJSON (toList a)+   parseJSON _ = empty++instance FromJSON Comment where+   parseJSON (Object o) =  do+      kind <- o .: "kind"+      if (kind :: String) == "t1"+      then do+         datum <- o .: "data"+         Comment <$> datum .: "author"+                 <*> datum .: "ups"+                 <*> datum .: "downs"+                 <*> datum .: "body_html"+                 <*> datum .: "body"+                 <*> (datum .: "replies" <|> return (CommentListing []))+      else+         return OriginalArticle+   parseJSON _ = empty++data RuntimeConf = RuntimeConf+   { textWidth :: Int+   , textHeight :: Int+   , commentSort :: String+   , linkSort :: String+   , pageComments :: Bool+   }
+ src/Rascal/Utils.hs view
@@ -0,0 +1,110 @@+-- |Various utilities for rascal+module Rascal.Utils where++import System.Info         (os)+import Control.Exception   (handle)+import Data.List           (elemIndices)++import System.Process      (callProcess)+import System.Console.ANSI (clearLine)++import Rascal.Constants++-- |add capital letter and separate by newlines at most 25 strings+-- for a total of 4 chars+letterizeLines :: [String] -> String+letterizeLines l =+   unlines $ zipWith (\c s -> ' ':c:" |" ++ s) ['A'..'Y'] l++-- |Poor man's HTML entities unescaping+unescape :: String -> String+unescape [] = []+unescape ('&':'a':'m':'p':';':xs) = '&':unescape xs+unescape ('&':'l':'t':';':xs) = '<':unescape xs+unescape ('&':'g':'t':';':xs) = '>':unescape xs+unescape (x:xs) = x:unescape xs++-- |extract links from some HTML+hrefs :: String -> [String]+hrefs ('h':'r':'e':'f':'=':'"':s) =+   let (u, r) = break (== '"') s+   in (u:hrefs r)+hrefs (_:xs) = hrefs xs+hrefs "" = []++-- |pretty print sort name with initial or selected letter highlighted+makeCmd :: (Char, String) -> String+makeCmd (c, cmd) | c `notElem` cmd = '⟨':c:'⟩':tail cmd+                 | otherwise = let (hd, _:tl) = break (== c) cmd+                               in hd ++ '⟨':c:'⟩':tl++-- |get full sort name from initial+getFullSort :: Char -> Maybe String+getFullSort = (`lookup` availableSorts)++-- |add a prefix (e.g. indent) on the left for a string to be printed in given+-- width note, because of unlines, a \n ends each line+indentString :: Int -> String -> String -> String+indentString width prefix s =+   let lineLength = width - length prefix+       strings = concatMap (splitAt' lineLength) (lines s)+   in unlines $ map (prefix ++) strings++-- |split a string to a list of substring of length <= n+splitAt' :: Int -> String -> [String]+splitAt' 0 s = [s]+splitAt' n s =+   let (s1, s2) = splitAt n s+   in if null s2+      then [s1]+      else let (s1', s2') = splitAtLastSpace s1 s2+           in s1':splitAt' n s2'++splitAtLastSpace :: String -> String -> (String, String)+splitAtLastSpace s1 s2 =+   let spaces = elemIndices ' ' s1+   in if null spaces+      then (s1, s2)+      else let (s1', _:s2') = splitAt (last spaces) s1+           in (s1', s2' ++ s2)++-- | unescape and add newlines in order not to cut words+cleanUp :: String -> Int -> String+cleanUp s w =+      unlines (concatMap (splitAt' w) ((lines . unescape) s))++-- |display an informative message+message :: String -> Int -> IO ()+message s w =+   let col = cyan ++ reset+       msg = if null s+             then col+             else "--[" ++ cyan ++ s ++ reset ++ "]"+       l = length msg - length col+   in do+      putStrLn ""+      putStr msg+      putStrLn $ replicate (w - l) '-'++-- |wait for a key press+waitKey :: Int -> IO ()+waitKey width = do+   message "press a key to continue" width+   _ <- getChar+   clearLine+   return ()++-- |open an url in a platform independent way+openUrl :: String -> Int -> IO ()+openUrl u w = do+   message ("opening '" ++ u ++ "'…") w+   handle (\e -> print (e :: IOError)) $+      case os of+       "darwin"  -> callProcess "open" ["-g", u] -- open in the background+       "linux"   -> callProcess "xdg-open" [u, "&"] -- getEnv BROWSER ???+       "mingw32" -> callProcess "start" ["", u]+       _         -> return ()++showRef :: (Int, String) -> String+showRef (n, u) =+   " [" ++ yellow ++ show n ++ reset ++ "] " ++ blue ++ u ++ reset
+ src/Test.hs view
@@ -0,0 +1,123 @@+module Main where++import Data.List                 (elemIndices, findIndices, union, isPrefixOf)+import Test.Tasty+import Test.Tasty.QuickCheck     (testProperty)+import Test.Tasty.HUnit+import Test.QuickCheck+import Data.Map                  (assocs)+import Control.Applicative       ((<$>))++import Rascal.Utils+import Rascal.Conf++data Lines = Lines [String] deriving (Show)++instance Arbitrary Lines where+   arbitrary =+      Lines <$> listOf1 (listOf (choose ('!', '~')))++main :: IO ()+main = defaultMain tests++tests :: TestTree+tests = testGroup "Tests" [properties, unitTests]++properties :: TestTree+properties = testGroup "Invariants checked with QuickCheck"+   [ testProperty "letterizeLines introduces the correct number of newlines"+      prop_letterizedLinesUnlines+   , testProperty "letterizeLines adds 4 chars per line"+      prop_letterizedLinesLength+   , testProperty "cleanUp only add lines"+      prop_cleanUpLines+   , testProperty "cleanUp only replaces spaces by newlines"+      prop_cleanUpSpacesToLines+   , testProperty "indentLines keeps lines under width"+      prop_indentStringHasWidth+   , testProperty "indentLines properly adds prefix"+      prop_indentStringInsertsPrefix+   ]++prop_letterizedLinesUnlines :: Lines -> Property+prop_letterizedLinesUnlines (Lines l) =+   length l <= 25 ==>+      (length . lines . letterizeLines) l == length l++prop_letterizedLinesLength :: Lines -> Bool+prop_letterizedLinesLength (Lines l) =+   length (head l) + 4 == length (head (lines (letterizeLines l)))++data WordList = WordList+   { wrds :: String+   , len :: Int+   } deriving (Show)++instance Arbitrary WordList where+   arbitrary = do+      Lines w <- arbitrary+      -- |don't want words that span the entire line length+      l <- choose (maximum (map length w) + 1, 200)+      return $ WordList (toStrLn w) l++toStrLn :: [String] -> String+toStrLn wl =+   unwords wl ++ "\n"++prop_cleanUpLines :: WordList -> Property+prop_cleanUpLines wl =+   let s = wrds wl+       l = len wl+       initialNewLines = elemIndices '\n' s+       cleanedNewLines = elemIndices '\n' (cleanUp s l)+   -- | if there are HTML entities to be escaped, it will introduce a shift+   in unescape s == s ==>+      cleanedNewLines `union` initialNewLines == cleanedNewLines++prop_cleanUpSpacesToLines :: WordList -> Property+prop_cleanUpSpacesToLines wl =+   let s = wrds wl+       l = len wl+   -- | if there are HTML entities to be escaped, it will introduce a shift+   in unescape s == s ==>+      findIndices (`elem` " \n") (cleanUp s l) == findIndices (`elem` " \n") s++prop_indentStringHasWidth :: Int -> String -> String -> Property+prop_indentStringHasWidth w prefix s =+   length prefix < w ==>+      all (\l -> length l <= w) $ lines (indentString w prefix s)++prop_indentStringInsertsPrefix :: Int -> String -> String -> Property+prop_indentStringInsertsPrefix w p s =+   length p < w && '\n' `notElem` p ==>+      all (isPrefixOf p) $ lines (indentString w p s)++unitTests :: TestTree+unitTests = testGroup "Unit tests" [unescapeTests, hrefsTests, parseConfigTests]++unescapeTests :: TestTree+unescapeTests = testGroup "unescape"+   [ testCase "unescapes empty" $+      unescape "" @?= ""+   , testCase "unescapes nothing" $+      unescape "& unescape amp nothing;" @?= "& unescape amp nothing;"+   , testCase "unescapes &amp;" $+      unescape "& unescape &amp; me;" @?= "& unescape & me;"+   , testCase "unescapes &lt; and &gt;" $+      unescape "& unescape &lt;me&gt;;" @?= "& unescape <me>;"+   ]++hrefsTests :: TestTree+hrefsTests =+   testGroup "hrefs" [testCase "no hrefs in empty" $ hrefs "" @?= []]++parseConfigTests :: TestTree+parseConfigTests = testGroup "parseConfig"+   [ testCase "parseConfig empty" $+      assocs (parseConfig "") @?= []+   , testCase "parseConfig dummy" $+      assocs (parseConfig "foo: bar\nstupid is stupid") @?= [("foo", "bar")]+   , testCase "parseConfig spaces" $+      assocs (parseConfig " foo : bar \n  stupid = really stupid  ") @?=+      [("foo", "bar"), ("stupid", "really stupid")]+   ]
+ threads.png view

binary file changed (absent → 186797 bytes)