text-replace 0.1.0.2 → 0.1.0.3
raw patch · 5 files changed
+177/−188 lines, 5 filesdep ~basedep ~containersdep ~hedgehog
Dependency ranges changed: base, containers, hedgehog, neat-interpolation, optparse-applicative, parsec, text
Files
- app/text-replace.hs +37/−73
- changelog.md +15/−0
- readme.md +19/−16
- src/Text/Replace.hs +93/−82
- text-replace.cabal +13/−17
app/text-replace.hs view
@@ -20,16 +20,11 @@ import qualified Data.Text.Lazy as LT import qualified Data.Text.Lazy.IO as LT -(!) :: Monoid a => a -> a -> a-(!) = mappend-infixr 6 !- optsParserInfo :: Opt.ParserInfo Opts-optsParserInfo- = Opt.info (Opt.helper <*> optsParser)- $ Opt.header "Perform simple replacements in a text file, using a list \- \of search/replace pairs."- ! Opt.footer "The search for strings to replace is performed left-to-right, \+optsParserInfo = Opt.info (Opt.helper <*> optsParser) $+ Opt.header "Perform simple replacements in a text file, using a list \+ \of search/replace pairs." <>+ Opt.footer "The search for strings to replace is performed left-to-right, \ \preferring longer matches to shorter ones. All streams are \ \assumed to be UTF-8 encoded." @@ -44,20 +39,15 @@ let d = delimiterOpt opts - argMapping <-- let- f arg = parseReplacementList' d "--mapping" arg- in- foldMap f (opt_mapping opts)+ argMapping <- opt_mapping opts+ & foldMap (parseReplacementList' d "--mapping") - fileMapping <-- let- f path = IO.withFile path IO.ReadMode $ \h -> do- IO.hSetEncoding h enc- x <- LT.hGetContents h- parseReplacementList' d path x- in- foldMap f (opt_mapFile opts)+ fileMapping <- opt_mapFile opts+ & foldMap (\path ->+ IO.withFile path IO.ReadMode $ \h -> do+ IO.hSetEncoding h enc+ x <- LT.hGetContents h+ parseReplacementList' d path x) withInputH (opt_inFile opts) $ \inH -> withOutputH (opt_outFile opts) $ \outH -> do@@ -73,8 +63,7 @@ withOutputH Nothing f = f IO.stdout withOutputH (Just path) f = IO.withFile path IO.WriteMode f -data Opts =- Opts+data Opts = Opts { opt_inFile :: Maybe FilePath , opt_outFile :: Maybe FilePath , opt_mapping :: [LT.Text]@@ -84,8 +73,7 @@ } optsParser :: Opt.Parser Opts-optsParser =- Opts+optsParser = Opts <$> inFileParser <*> outFileParser <*> mappingParser@@ -94,71 +82,50 @@ <*> newlineDelimiterParser inFileParser :: Opt.Parser (Maybe FilePath)-inFileParser- = Opt.optional- $ Opt.strOption- $ Opt.metavar "FILEPATH"- ! Opt.long "in-file"- ! Opt.short 'i'- ! Opt.help "Input file to read (optional, defaults to stdin)"+inFileParser = Opt.optional $ Opt.strOption $+ Opt.metavar "FILEPATH" <> Opt.long "in-file" <> Opt.short 'i' <>+ Opt.help "Input file to read (optional, defaults to stdin)" outFileParser :: Opt.Parser (Maybe FilePath)-outFileParser- = Opt.optional- $ Opt.strOption- $ Opt.metavar "FILEPATH"- ! Opt.long "out-file"- ! Opt.short 'o'- ! Opt.help "Output file to write (optional, defaults to stdout)"+outFileParser = Opt.optional $ Opt.strOption $+ Opt.metavar "FILEPATH" <> Opt.long "out-file" <> Opt.short 'o' <>+ Opt.help "Output file to write (optional, defaults to stdout)" mappingParser :: Opt.Parser [LT.Text]-mappingParser- = Opt.many- $ Opt.strOption- $ Opt.metavar "MAPPING"- ! Opt.long "mapping"- ! Opt.short 'm'- ! Opt.help "A list of search/replace pairs, separated by any of the \+mappingParser = Opt.many $ Opt.strOption $+ Opt.metavar "MAPPING" <> Opt.long "mapping" <> Opt.short 'm' <>+ Opt.help "A list of search/replace pairs, separated by any of the \ \delimiters" mapFileParser :: Opt.Parser [FilePath]-mapFileParser- = Opt.many- $ Opt.strOption- $ Opt.metavar "FILEPATH"- ! Opt.long "map-file"- ! Opt.short 'f'- ! Opt.help "A file containing a list of search/replace pairs, \+mapFileParser = Opt.many $ Opt.strOption $+ Opt.metavar "FILEPATH" <> Opt.long "map-file" <> Opt.short 'f' <>+ Opt.help "A file containing a list of search/replace pairs, \ \separated by any of the delimiters" type Delimiter = String delimiterParser :: Opt.Parser [Delimiter]-delimiterParser- = Opt.many- $ Opt.strOption- $ Opt.metavar "DELIMITER"- ! Opt.long "delimiter"- ! Opt.short 'd'- ! Opt.help "Add a delimiter that separates search/replace strings in \+delimiterParser = Opt.many $ Opt.strOption $+ Opt.metavar "DELIMITER" <> Opt.long "delimiter" <> Opt.short 'd' <>+ Opt.help "Add a delimiter that separates search/replace strings in \ \--mapping and in the contents of --map-file" newlineDelimiterParser :: Opt.Parser Bool-newlineDelimiterParser- = Opt.switch- $ Opt.long "newline-delimiter"- ! Opt.short 'n'- ! Opt.help "Add newline as a delimiter"+newlineDelimiterParser = Opt.switch $+ Opt.long "newline-delimiter" <> Opt.short 'n' <>+ Opt.help "Add newline as a delimiter" delimiterOpt :: Opts -> [Delimiter] delimiterOpt opts =- opt_delimiter opts- & (if opt_newlineDelimiter opts then ("\n" :) else id)+ opt_delimiter opts+ & (if opt_newlineDelimiter opts then ("\n" :) else id) parseReplacementList :: [Delimiter] -> P.SourceName -> LT.Text -> Either P.ParseError [Replace] parseReplacementList delims sourceName input =- let+ P.parse (P.many replaceP <* P.eof) sourceName input+ where delimP :: P.Parser () delimP = delims & fmap (void . P.try . P.string) & asum @@ -174,9 +141,6 @@ replaceP :: P.Parser Replace replaceP = Replace <$> strP' <*> strP - in- P.parse (P.many replaceP <* P.eof) sourceName input- parseReplacementList' :: [Delimiter] -> P.SourceName -> LT.Text -> IO [Replace] parseReplacementList' delims sourceName input =- parseReplacementList delims sourceName input & either (die . show) pure+ parseReplacementList delims sourceName input & either (die . show) pure
+ changelog.md view
@@ -0,0 +1,15 @@+### 0.1.0.3 (2023-01-01)++Support GHC 9.4++### 0.1.0.2 (2022-03-15)++Support hedgehog 1.1, optparse-applicative 0.17++### 0.1.0.1 (2022-01-14)++Support GHC 9.2++### 0.1 (2021-06-02)++Start of changelog
readme.md view
@@ -1,22 +1,22 @@ # text-replace -Perform simple replacements in a text file, using a list of search/replace pairs.--The search for strings to replace is performed left-to-right, preferring longer matches to shorter ones.--All streams are assumed to be UTF-8 encoded.+Perform simple replacements in a text file, using a list of search/replace+pairs. The search for strings to replace is performed left-to-right, preferring+longer matches to shorter ones. All streams are assumed to be UTF-8 encoded. ## Command-line options -| Option | Description |-| --- | --- |-| `-h,--help` | Show help text |-| `-i,--in-file FILEPATH` | Input file to read (optional, defaults to stdin) |-| `-o,--out-file FILEPATH` | Output file to write (optional, defaults to stdout)-| `-m,--mapping MAPPING` | A list of search/replace pairs, separated by any of the delimiters |-| `-f,--map-file FILEPATH` | A file containing a list of search/replace pairs, separated by any of the delimiters |-| `-d,--delimiter DELIMITER` | Add a delimiter that separates search/replace strings in `--mapping` and in the contents of `--map-file` |-| `-n,--newline-delimiter` | Add newline as a delimiter |+* `-h`, `--help` - Show help text+* `-i`, `--in-file FILEPATH` - Input file to read (optional, defaults to stdin)+* `-o`, `--out-file FILEPATH` - Output file to write (optional, defaults to stdout)+* `-m`, `--mapping MAPPING` - A list of search/replace pairs, separated by any of+ the delimiters+* `-f`, `--map-file FILEPATH` - A file containing a list of search/replace pairs,+ separated by any of the delimiters+* `-d`, `--delimiter DELIMITER` - Add a delimiter that separates search/replace+ strings in `--mapping` and in the contents of+ `--map-file`+* `-n`, `--newline-delimiter` - Add newline as a delimiter ## Examples @@ -29,7 +29,8 @@ The (<&&>) operator ``` -You can use it to swap strings. In the following example we replace `*` with `**` and vice versa:+You can use it to swap strings. In the following example we replace `*` with+`**` and vice versa: ``` $ echo "What *is* going on **here**?" \@@ -38,7 +39,8 @@ What **is** going on *here*? ``` -You also have the option to read the input string and replacement list from files, and to write the output to a file:+You also have the option to read the input string and replacement list from+files, and to write the output to a file: ``` $ cat input@@ -59,3 +61,4 @@ $ cat output I am extremely likely to appreciate Haskell once I develop sufficient ability with it.+```
src/Text/Replace.hs view
@@ -1,18 +1,18 @@ module Text.Replace (- -- * Performing replacement- replaceWithList, replaceWithMap, replaceWithTrie-- -- * Specifying replacements- , Replace (..), ReplaceMap, listToMap, mapToAscList+ -- * Performing replacement+ replaceWithList, replaceWithMap, replaceWithTrie, - -- * Replacements in trie structure- , Trie, Trie' (..), listToTrie, ascListToTrie, mapToTrie, drawTrie+ -- * Specifying replacements+ Replace (..), ReplaceMap, listToMap, mapToAscList, - -- * Non-empty text- , Text' (..), text'fromString, text'fromText+ -- * Replacements in trie structure+ Trie, Trie' (..), listToTrie, ascListToTrie, mapToTrie, drawTrie, - ) where+ -- * Non-empty text+ Text' (..), text'fromString, text'fromText,+ )+ where -- base import qualified Data.Foldable as Foldable@@ -30,138 +30,147 @@ import qualified Data.Text as T import qualified Data.Text.Lazy as LT -{- | Apply a list of replacement rules to a string. The search for strings to replace is performed left-to-right, preferring longer matches to shorter ones.+{- | Apply a list of replacement rules to a string -Internally, the list will be converted to a 'ReplaceMap' using 'listToMap'. If the list contains more than one replacement for the same search string, the last mapping is used, and earlier mappings are ignored.+The search for strings to replace is performed left-to-right, preferring longer+matches to shorter ones. -If you are going to be applying the same list of rules to multiple input strings, you should first convert the list to a 'Trie' using 'listToTrie' and then use 'replaceWithTrie' instead. -}-replaceWithList- :: Foldable f- => f Replace -- ^ List of replacement rules+Internally, the list will be converted to a 'ReplaceMap' using 'listToMap'. If+the list contains more than one replacement for the same search string, the last+mapping is used, and earlier mappings are ignored.++If you are going to be applying the same list of rules to multiple input+strings, you should first convert the list to a 'Trie' using 'listToTrie' and+then use 'replaceWithTrie' instead. -}+replaceWithList :: Foldable f =>+ f Replace -- ^ List of replacement rules -> LT.Text -- ^ Input string -> LT.Text -- ^ Result after performing replacements on the input string replaceWithList = replaceWithTrie . listToTrie -{- | Apply a map of replacement rules to a string. The search for strings to replace is performed left-to-right, preferring longer matches to shorter ones.+{- | Apply a map of replacement rules to a string -If you are going to be applying the same list of rules to multiple input strings, you should first convert the 'Map' to a 'Trie' using 'mapToTrie' and then use 'replaceWithTrie' instead. -}-replaceWithMap- :: ReplaceMap -- ^ Map of replacement rules+The search for strings to replace is performed left-to-right, preferring longer+matches to shorter ones.++If you are going to be applying the same list of rules to multiple input+strings, you should first convert the 'Map' to a 'Trie' using 'mapToTrie' and+then use 'replaceWithTrie' instead. -}+replaceWithMap ::+ ReplaceMap -- ^ Map of replacement rules -> LT.Text -- ^ Input string -> LT.Text -- ^ Result after performing replacements on the input string replaceWithMap = replaceWithTrie . mapToTrie -{- | Apply a trie of replacement rules to a string. The search for strings to replace is performed left-to-right, preferring longer matches to shorter ones.+{- | Apply a trie of replacement rules to a string +The search for strings to replace is performed left-to-right, preferring longer+matches to shorter ones.+ To construct a 'Trie', you may use 'listToTrie' or 'mapToTrie'. -}-replaceWithTrie- :: Trie -- ^ Map of replacement rules, represented as a trie+replaceWithTrie ::+ Trie -- ^ Map of replacement rules, represented as a trie -> LT.Text -- ^ Input string -> LT.Text -- ^ Result after performing replacements on the input string replaceWithTrie trie = go where- go xs =- case LT.uncons xs of+ go xs = case LT.uncons xs of Nothing -> LT.empty- Just (x, xs') ->- case replaceWithTrie1 trie xs of+ Just (x, xs') -> case replaceWithTrie1 trie xs of Nothing -> LT.cons x (go xs') Just (r, xs'') -> LT.append (LT.fromStrict r) (go xs'') replaceWithTrie1 :: Trie -> LT.Text -> Maybe (T.Text, LT.Text)-replaceWithTrie1 trie xs =- case LT.uncons xs of+replaceWithTrie1 trie xs = case LT.uncons xs of Nothing -> Nothing- Just (x, xs') ->- case Map.lookup x trie of+ Just (x, xs') -> case Map.lookup x trie of Nothing -> Nothing Just (Trie' Nothing bs) -> replaceWithTrie1 bs xs' Just (Trie' (Just r) bs) -> case replaceWithTrie1 bs xs' of Nothing -> Just (r, xs') longerMatch -> longerMatch --- | Non-empty text.-data Text' =- Text'- { text'head :: Char -- ^ The first character of a non-empty string.- , text'tail :: T.Text -- ^ All characters of a non-empty string except the first.+-- | Non-empty text+data Text' = Text'+ { text'head :: Char -- ^ The first character of a non-empty string+ , text'tail :: T.Text -- ^ All characters of a non-empty string except the first } deriving (Eq, Ord) -instance Show Text'- where+instance Show Text' where showsPrec i (Text' x xs) = showsPrec i (LT.cons x (LT.fromStrict xs)) {- | @'fromString' = 'text'fromString'@ 🌶️ Warning: @('fromString' "" :: 'Text'') = ⊥@ -}-instance IsString Text'- where+instance IsString Text' where fromString = text'fromString -{- | Convert an ordinary 'String' to a non-empty 'Text''.+{- | Convert an ordinary 'String' to a non-empty 'Text'' 🌶️ Warning: @text'fromString "" = ⊥@ -} text'fromString :: String -> Text' text'fromString [] = error "Text' cannot be empty" text'fromString (x : xs) = Text' x (T.pack xs) -{- | Convert an ordinary 'T.Text' to a non-empty 'Text''.+{- | Convert an ordinary 'T.Text' to a non-empty 'Text'' 🌶️ Warning: @text'fromText "" = ⊥@ -} text'fromText :: T.Text -> Text'-text'fromText t =- case T.uncons t of+text'fromText t = case T.uncons t of Nothing -> error "Text' cannot be empty" Just (x, xs) -> Text' x xs -{- | A replacement rule.--> Replace "abc" "xyz"--means+{- | A replacement rule -/When you encounter the string __@abc@__ in the input text, replace it with __@xyz@__./+@Replace "abc" "xyz"@ means "When you encounter the string __@abc@__ in the+input text, replace it with __@xyz@__." -The first argument must be a non-empty string, because there is no sensible way to interpret "replace all occurrences of the empty string." -}-data Replace =- Replace+The first argument must be a non-empty string, because there is no sensible way+to interpret "replace all occurrences of the empty string." -}+data Replace = Replace { replaceFrom :: Text' -- ^ A string we're looking for , replaceTo :: T.Text -- ^ A string we're replacing it with } deriving (Eq, Show) -{- | A map where the keys are strings we're looking for, and the values are strings with which we're replacing a key that we find.+{- | A map where the keys are strings we're looking for and the values are+strings with which we're replacing a key that we find -You may use 'listToMap' to construct a 'ReplaceMap' from a list of replacement rules, and you may use 'mapToAscList' to convert back to a list. -}+You may use 'listToMap' to construct a 'ReplaceMap' from a list of replacement+rules, and you may use 'mapToAscList' to convert back to a list. -} type ReplaceMap = Map Text' T.Text -{- | Construct a 'ReplaceMap' from a list of replacement rules.+{- | Construct a replacement map from a list of replacement rules -If the list contains more than one replacement for the same search string, the last mapping is used, and earlier mappings are ignored. -}+If the list contains more than one replacement for the same search string, the+last mapping is used, and earlier mappings are ignored. -} listToMap :: Foldable f => f Replace -> ReplaceMap listToMap = Map.fromList . fmap toTuple . Foldable.toList where toTuple x = (replaceFrom x, replaceTo x) -{- | Convert a replacement map to a list of replacement rules. The rules in the list will be sorted according to their 'replaceFrom' field in ascending order. -}+{- | Convert a replacement map to a list of replacement rules++The rules in the list will be sorted according to their 'replaceFrom' field in+ascending order. -} mapToAscList :: ReplaceMap -> [Replace] mapToAscList = fmap (\(x, y) -> Replace x y) . Map.toAscList -{- | A representation of a 'ReplaceMap' designed for efficient lookups when we perform the replacements in 'replaceWithTrie'.+{- | A representation of a 'ReplaceMap' designed for efficient lookups when we+perform the replacements in 'replaceWithTrie' You may construct a 'Trie' using 'listToTrie' or 'mapToTrie'. -} type Trie = Map Char Trie' -{- | A variant of 'Trie' which may contain a value at the root of the tree. -}-data Trie' =- Trie'+{- | A variant of 'Trie' which may contain a value at the root of the tree -}+data Trie' = Trie' { trieRoot :: Maybe T.Text , trieBranches :: Trie } deriving (Eq, Show) -{- | Draws a text diagram of a trie; useful for debugging. -}+{- | Draws a text diagram of a trie; useful for debugging -} drawTrie :: Trie -> LT.Text drawTrie = LT.pack . Tree.drawForest . fmap (fmap T.unpack) . trieForest @@ -169,31 +178,34 @@ trieForest = fmap (\(c, t) -> trieTree (T.singleton c) t) . Map.toAscList trieTree :: T.Text -> Trie' -> Tree T.Text-trieTree c (Trie' r bs) =- case (r, Map.toAscList bs) of+trieTree c (Trie' r bs) = case (r, Map.toAscList bs) of (Nothing, [(c', t)]) -> trieTree (T.snoc c c') t _ -> Tree.Node (T.append c (maybe T.empty (\rr -> T.pack (" - " ++ show rr)) r)) (trieForest bs) -{- | Convert a replacement map to a trie, which is used to efficiently implement 'replaceWithTrie'. -}+{- | Convert a replacement map to a trie, which is used to efficiently implement+'replaceWithTrie' -} mapToTrie :: ReplaceMap -> Trie mapToTrie = ascListToTrie . mapToAscList -{- | Convert a list of replacement rules to a trie, which is used to efficiently implement 'replaceWithTrie'.+{- | Convert a list of replacement rules to a trie, which is used to efficiently+implement 'replaceWithTrie' -If the list contains more than one replacement for the same search string, the last mapping is used, and earlier mappings are ignored. -}+If the list contains more than one replacement for the same search string, the+last mapping is used, and earlier mappings are ignored. -} listToTrie :: Foldable f => f Replace -> Trie listToTrie = mapToTrie . listToMap -{- | Convert a list of replacement rules to a 'Trie', where the rules must be sorted in ascending order by the 'replaceFrom' field.+{- | Convert a list of replacement rules to a 'Trie', where the rules must be+sorted in ascending order by the 'replaceFrom' field -🌶️ Warning: this precondition is not checked. If you are not sure, it is safer to use 'listToTrie' instead. -}-ascListToTrie- :: Foldable f- => f Replace -- ^ This list must be sorted according to the 'replaceFrom'- -- field in ascending order- --- -- 🌶️ Warning: this precondition is not checked+🌶️ Warning: this precondition is not checked. If you are not sure, it is safer+to use 'listToTrie' instead. -}+ascListToTrie :: Foldable f =>+ f Replace -- ^ This list must be sorted according to the 'replaceFrom'+ -- field in ascending order+ --+ -- 🌶️ Warning: this precondition is not checked -> Trie ascListToTrie = Map.fromAscList@@ -203,12 +215,11 @@ firstChar = text'head . replaceFrom . NonEmpty.head subtrie = ascListToTrie' . fmap (\(Replace x y) -> (text'tail x, y)) -ascListToTrie'- :: Foldable f- => f (T.Text, T.Text) -- ^ This list must be sorted according to the left- -- field of the tuple in ascending order- --- -- 🌶️ Warning: this precondition is not checked+ascListToTrie' :: Foldable f =>+ f (T.Text, T.Text) -- ^ This list must be sorted according to the left+ -- field of the tuple in ascending order+ --+ -- 🌶️ Warning: this precondition is not checked -> Trie' ascListToTrie' = f . Foldable.toList where
text-replace.cabal view
@@ -1,7 +1,7 @@ cabal-version: 3.0 name: text-replace-version: 0.1.0.2+version: 0.1.0.3 category: Text, Application synopsis: Simple text replacements from a list of search/replace pairs @@ -13,20 +13,16 @@ left-to-right, preferring longer matches to shorter ones. -homepage:- https://github.com/chris-martin/text-replace-bug-reports:- https://github.com/chris-martin/text-replace/issues+homepage: https://github.com/chris-martin/text-replace+bug-reports: https://github.com/chris-martin/text-replace/issues -author: Chris Martin <ch.martin@gmail.com>-maintainer: Chris Martin <ch.martin@gmail.com>+author: Chris Martin+maintainer: Chris Martin license: Apache-2.0 license-file: license.txt -build-type: Simple--extra-source-files: readme.md+extra-source-files: *.md source-repository head type: git@@ -36,23 +32,23 @@ default-language: Haskell2010 ghc-options: -Wall build-depends:- base ^>= 4.10 || ^>= 4.11 || ^>= 4.12 || ^>= 4.13 || ^>= 4.14 || ^>= 4.15 || ^>= 4.16- , text ^>= 1.2.2.2+ base ^>= 4.14 || ^>= 4.15 || ^>= 4.16 || ^>= 4.17+ , text ^>= 1.2.4 || ^>= 2.0 library import: base hs-source-dirs: src exposed-modules: Text.Replace build-depends:- containers ^>= 0.5.10.2 || ^>= 0.6+ containers ^>= 0.6.4 executable text-replace import: base hs-source-dirs: app main-is: text-replace.hs build-depends:- optparse-applicative ^>= 0.14.2 || ^>= 0.15 || ^>= 0.16 || ^>= 0.17- , parsec ^>= 3.1.13+ optparse-applicative ^>= 0.16.1 || ^>= 0.17+ , parsec ^>= 3.1.14 , text-replace test-suite properties@@ -66,6 +62,6 @@ QuasiQuotes TemplateHaskell build-depends:- hedgehog ^>= 0.5.3 || ^>= 0.6 || ^>= 1.0 || ^>= 1.1- , neat-interpolation ^>= 0.5+ hedgehog ^>= 1.0.5 || ^>= 1.0 || ^>= 1.1 || ^>= 1.2+ , neat-interpolation ^>= 0.5.1 , text-replace