packages feed

edits (empty) → 0.1.0.0

raw patch · 14 files changed

+643/−0 lines, 14 filesdep +basedep +containersdep +editssetup-changed

Dependencies added: base, containers, edits, hedgehog, matrix, primitive, protolude, registry-hedgehog, tasty, tasty-discover, tasty-hedgehog, text, vector

Files

+ LICENSE.txt view
@@ -0,0 +1,16 @@+Copyright (c) 2022 Eric Torreborre <etorreborre@yahoo.com>++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. Neither the name of specs nor the names of its contributors may be used to endorse or promote+products derived from this software without specific prior written permission.++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.
+ Setup.hs view
@@ -0,0 +1,4 @@+import Distribution.Simple++main :: IO ()+main = defaultMain
+ edits.cabal view
@@ -0,0 +1,86 @@+cabal-version: 1.12++-- This file has been generated from package.yaml by hpack version 0.35.0.+--+-- see: https://github.com/sol/hpack++name:           edits+version:        0.1.0.0+synopsis:       compute the distance between 2 strings as a list of edit operations+description:    This library returns the minimum number of edit operations 'add', 'remove' necessary to transform a string into another. It also provides ways to display the difference between the strings+category:       Control+maintainer:     etorreborre@yahoo.com+license:        MIT+license-file:   LICENSE.txt+build-type:     Simple++source-repository head+  type: git+  location: https://github.com/etorreborre/edits++library+  exposed-modules:+      Data.Text.Costs+      Data.Text.Difference+      Data.Text.EditMatrix+      Data.Text.EditOperation+      Data.Text.Edits+      Data.Text.Shorten+      Data.Text.Token+  other-modules:+      Paths_edits+  hs-source-dirs:+      src+  default-extensions:+      LambdaCase+      MultiWayIf+      NoImplicitPrelude+      OverloadedStrings+      TypeFamilies+      TypeFamilyDependencies+      TypeOperators+  ghc-options: -Wall -Wcompat -Wincomplete-record-updates -fhide-source-paths -fprint-potential-instances -fno-warn-partial-type-signatures -optP-Wno-nonportable-include-path -Wincomplete-uni-patterns+  build-depends:+      base >=4.14 && <5+    , containers >=0.2 && <1+    , matrix >=0.3 && <1+    , primitive >=0.6 && <1+    , protolude ==0.3.*+    , text ==1.*+    , vector >=0.10 && <2+  default-language: GHC2021++test-suite spec+  type: exitcode-stdio-1.0+  main-is: test.hs+  other-modules:+      AutoDiscoveredSpecs+      Test.Data.Text.EditsSpec+      Test.Data.Text.ShortenSpec+      Paths_edits+  hs-source-dirs:+      test+  default-extensions:+      LambdaCase+      MultiWayIf+      NoImplicitPrelude+      OverloadedStrings+      TypeFamilies+      TypeFamilyDependencies+      TypeOperators+  ghc-options: -Wall -Wcompat -Wincomplete-record-updates -fhide-source-paths -fprint-potential-instances -fno-warn-partial-type-signatures -optP-Wno-nonportable-include-path -threaded -rtsopts -with-rtsopts=-N -fno-warn-orphans -fno-warn-missing-signatures -fno-warn-incomplete-uni-patterns -fno-warn-type-defaults -optP-Wno-nonportable-include-path+  build-depends:+      base >=4.14 && <5+    , containers >=0.2 && <1+    , edits+    , hedgehog >=1.0 && <2+    , matrix >=0.3 && <1+    , primitive >=0.6 && <1+    , protolude ==0.3.*+    , registry-hedgehog >=0.7 && <1+    , tasty ==1.*+    , tasty-discover >=2 && <5+    , tasty-hedgehog >=1.0 && <2.0+    , text ==1.*+    , vector >=0.10 && <2+  default-language: GHC2021
+ src/Data/Text/Costs.hs view
@@ -0,0 +1,63 @@+{-# LANGUAGE RecordWildCards #-}++-- | This module supports a general definition of costs for performing edit operations+--   on 2 lists of elements+module Data.Text.Costs where++import Protolude++-- | Current operation in a cost matrix and current cost+data Cost+  = Insertion Int+  | Deletion Int+  | Substitution Int+  | NoAction Int+  deriving (Eq, Show)++-- | Return the cost of an operation+cost :: Cost -> Int+cost (Insertion c) = c+cost (Deletion c) = c+cost (Substitution c) = c+cost (NoAction c) = c++-- | Nicer display for a cost+showCost :: Cost -> Text+showCost (Insertion c) = "+ " <> show c+showCost (Deletion c) = "- " <> show c+showCost (Substitution c) = "~ " <> show c+showCost (NoAction c) = "o " <> show c++-- | This component contains functions to evaluate the cost of+--   substituting, inserting, deleting an element+data Costs a = Costs+  { substitutionCost :: a -> a -> Int,+    insertionCost :: a -> Int,+    deletionCost :: a -> Int,+    lowerCost :: a -> a -> Int -> Int -> Int -> Cost+  }++-- | Classic costs for the Levenshtein distance+--   applied to characters in a piece of Text+textLevenshteinCosts :: Costs Char+textLevenshteinCosts = levenshteinCosts @Char++-- | Classic costs for the Levenshtein distance+levenshteinCosts :: forall a. (Eq a) => Costs a+levenshteinCosts = Costs {..}+  where+    substitutionCost :: a -> a -> Int+    substitutionCost a1 a2 = if a1 == a2 then 0 else 1++    insertionCost :: a -> Int+    insertionCost = const 1++    deletionCost :: a -> Int+    deletionCost = const 1++    lowerCost :: a -> a -> Int -> Int -> Int -> Cost+    lowerCost a1 a2 del subst ins = do+      let (opDel, opSubst, opIns) = (Deletion del, Substitution subst, Insertion ins)+      if ins < del+        then (if (ins < subst) || (ins == subst && a1 == a2) then opIns else opSubst)+        else (if (del < subst) || (del == subst && a1 == a2) then opDel else opSubst)
+ src/Data/Text/Difference.hs view
@@ -0,0 +1,77 @@+-- | This module provides data types and functions to display+--   the difference between 2 strings according to the number of edit operations+--   necessary to go from one to the other+module Data.Text.Difference where++import Data.Text qualified as T+import Data.Text.EditOperation+import Data.Text.Shorten+import Data.Text.Token+import Protolude++-- | Separators are used to highlight a difference between 2 pieces of text+--   for example+data Separators = Separators+  { startSeparator :: Text,+    endSeparator :: Text+  }+  deriving (Eq, Show)++-- | Make parens separators+parensSeparators :: Separators+parensSeparators = makeCharSeparators '(' ')'++-- | Make brackets separators+bracketsSeparators :: Separators+bracketsSeparators = makeCharSeparators '[' ']'++-- | Make separators with simple Chars+makeCharSeparators :: Char -> Char -> Separators+makeCharSeparators c1 c2 = Separators (T.singleton c1) (T.singleton c2)++-- | Options to use for displaying differences+data DisplayOptions = DisplayOptions+  { _separators :: Separators,+    _shortenOptions :: ShortenOptions,+    _displayEditOperation :: EditOperation Char -> Text+  }++-- | Default display options+defaultDisplayOptions :: DisplayOptions+defaultDisplayOptions = DisplayOptions bracketsSeparators (ShortenOptions 20 "...") defaultDisplayEditOperations++-- | Default display for edit operations+defaultDisplayEditOperations :: EditOperation Char -> Text+defaultDisplayEditOperations (Insert c) = T.singleton c+defaultDisplayEditOperations (Delete c) = T.singleton c+defaultDisplayEditOperations (Substitute c _) = T.singleton c+defaultDisplayEditOperations (Keep c) = T.singleton c++-- | Display an edit operation by prepending a symbol showing which operation is used+taggedDisplayEditOperation :: EditOperation Char -> Text+taggedDisplayEditOperation (Insert c) = "+" <> T.singleton c+taggedDisplayEditOperation (Delete c) = "-" <> T.singleton c+taggedDisplayEditOperation (Substitute c1 c2) = "~" <> T.singleton c1 <> "/" <> T.singleton c2+taggedDisplayEditOperation (Keep c) = T.singleton c++-- | Show the differences by enclosing them in separators+--   Additionally shorten the text outside the separators if it is too long+displayDiffs :: DisplayOptions -> [EditOperation Char] -> Text+displayDiffs (DisplayOptions (Separators start end) shortenOptions displayEditOperation) operations = do+  let (isDifferent, result) =+        foldl'+          ( \(different, res) operation ->+              --  different keeps track of the fact that we entered a section of the text having some differences+              --  this allows us to open a 'start' delimiter+              --  then when we go back to keeping the same character, we can close with an 'end' delimiter+              case operation of+                Insert {} -> (True, res <> [Delimiter start | not different])+                Delete {} -> (True, res <> [Delimiter start | not different] <> [Kept $ displayEditOperation operation])+                Substitute {} -> (True, res <> [Delimiter start | not different] <> [Kept $ displayEditOperation operation])+                Keep {} -> (False, res <> [Delimiter end | different] <> [Kept $ displayEditOperation operation])+          )+          (False, [])+          operations++  let fullResult = if isDifferent then result <> [Delimiter end] else result+  T.concat (showToken <$> shortenTokens shortenOptions (Delimiter start) (Delimiter end) fullResult)
+ src/Data/Text/EditMatrix.hs view
@@ -0,0 +1,105 @@+{-# LANGUAGE OverloadedLists #-}++-- | This module computes a matrix keeping track of the costs necessary+--   to edit one piece of text so that it is transformed into another one+--   From that matrix it is possible to extract the sequence of edit operations with the minimum cost+module Data.Text.EditMatrix where++import Data.Matrix hiding (matrix, (!), getElem, setElem)+import Data.Matrix qualified as M+import Data.Text.Costs+import Data.Text.EditOperation+import Data.Vector as V (Vector, cons, fromList, head, take, (!))+import Protolude++-- | Create a edit matrix where costs are computed using dynamic programming+createEditMatrix :: Costs a -> [a] -> [a] -> Matrix Cost+createEditMatrix costs as1 as2 = do+  let initialMatrix = M.matrix (length as1 + 1) (length as2 + 1) (const $ NoAction 0)+  let coordinates = cartesian (length as1) (length as2)+  foldl'+      ( \ma (i, j) ->+          let newCost+                | i == 0 = Insertion j -- no more letters for as1, we do j insertions+                | j == 0 = Deletion i -- no more letters for as2, we do i suppressions+                | otherwise = costOf i j ma -- otherwise we compute the cost and operation to go from as1[i] to as2[j]+           in setElement newCost i j ma+      )+      initialMatrix+      coordinates+  where+    (vs1, vs2) = (V.fromList as1, V.fromList as2)++    -- compute the cartesian product of the [0..i] x [0..j] lists+    cartesian :: Int -> Int -> [(Int, Int)]+    cartesian m n = [(i, j) | i <- [0 .. m], j <- [0 .. n]]++    -- compute the cost of going from as1[i] to as2[j], knowing the existing costs+    --  (i-1, j-1) (i-1, j)+    --  (i, j-1)   (i, j)+    --+    -- going from (i-1, j) to (i, j) means that we delete as1[i]+    -- going from (i-1, j-1) to (i, j) means that we substitute as1[i] with as2[j]+    -- going from (i, j-1) to (i, j) means that we insert as2[j]+    costOf :: Int -> Int -> Matrix Cost -> Cost+    costOf i j matrix = do+      let i1 = i - 1+      let j1 = j - 1+      let i1j =  getElement i1 j matrix+      let i1j1 = getElement i1 j1 matrix+      let ij1 =  getElement i j1 matrix+      let result =+            lowerCost+              costs+              (vs1 ! i1)+              (vs2 ! j1)+              (cost i1j + deletionCost costs (vs1 ! i1)) -- suppression+              (cost i1j1 + substitutionCost costs (vs1 ! i1) (vs2 ! j1)) -- substitution+              (cost ij1 + insertionCost costs (vs2 ! j1)) -- insertion+      -- in case of a substitution if the resulting cost of (i, j) is the same as (i-1, j-1)+      -- this means that we have substituted the same letter and it is the same as doing no action+      case result of+        Substitution {} | cost i1j1 == cost result -> NoAction (cost result)+        _ -> result++-- | From the original lists of characters, given the cost matrix+--   return a list of edit operations allowing to edit one text and eventually get the second one+makeEditOperations :: forall a. Vector a -> Vector a -> Matrix Cost -> Vector (EditOperation a)+makeEditOperations [] _ _ = []+makeEditOperations _ [] _ = []+makeEditOperations as1 as2 matrix =+  go (length as1) (length as2) []+  where+    go :: Int -> Int -> Vector (EditOperation a) -> Vector (EditOperation a)+    go 0 0 _ = []+    go i j ops = do+      let op = getElement i j matrix+      let dist = cost op+      if i == 1 && j == 1+        then+          if dist == 0+            then V.cons (Keep (V.head as1)) ops+            else V.cons (Substitute (V.head as1) (V.head as2)) ops+        else+          if j < 0+            then fmap Delete (V.take i as1) <> ops+            else+              if i < 0+                then fmap Insert (V.take j as2) <> ops+                else case op of+                  Insertion {} -> go i (j - 1) (V.cons (Insert (as2 ! (j - 1))) ops)+                  Deletion {} -> go (i - 1) j (V.cons (Delete (as1 ! (i - 1))) ops)+                  Substitution {} -> go (i - 1) (j - 1) (V.cons (Substitute (as1 ! (i - 1)) (as2 ! (j - 1))) ops)+                  _ -> go (i - 1) (j - 1) (V.cons (Keep (as1 ! (i - 1))) ops)++-- | Return the element at position i, j in the matrix+--   A matrix in the matrix package is 1-indexed but all the computations in this module are 0-indexed+--   so we need to shift the indices+getElement :: Int -> Int -> Matrix a -> a+getElement i j = M.getElem (i + 1) (j + 1)++-- | Set the element at position i, j in the matrix+--   A matrix in the matrix package is 1-indexed but all the computations in this module are 0-indexed+--   so we need to shift the indices+setElement :: a -> Int -> Int -> Matrix a -> Matrix a+setElement a i j = M.setElem a (i + 1, j + 1)
+ src/Data/Text/EditOperation.hs view
@@ -0,0 +1,21 @@+-- | Data type describing the operation of editing some text+module Data.Text.EditOperation where++import Protolude++-- | Atomic operation required to edit a piece of text+--   at a given position in the EditMatrix+data EditOperation a+  = Insert a+  | Delete a+  | Substitute a a+  | Keep a+  deriving (Eq, Show)++-- | Inverse of an edit operation. It is used+--   to display not only how to go from text1 to text2 but also from text2 to text1+inverse :: EditOperation a -> EditOperation a+inverse (Insert a) = Delete a+inverse (Delete a) = Insert a+inverse (Substitute a1 a2) = Substitute a2 a1+inverse (Keep a) = Keep a
+ src/Data/Text/Edits.hs view
@@ -0,0 +1,99 @@+{-# LANGUAGE OverloadedLists #-}++{- |+  This module provides a 'showDistance' function showing the differences between 2 pieces of text+   using the Levenshtein distance. That distance is defined as the minimum number of edits: insertions, deletions, substitutions+   to go from one text to another.++   Several options are available to customize this processing:+     - split size: texts are broken into new lines first. Then if the texts are too large there are split into smaller pieces+       in order to compute their difference. This is done in order to reduce the size of the edit matrix which is used to compute all the edit costs+       the default is 200++     - separators: opening and closing pieces of text (brackets by default) used to highlight a difference++     - shorten size: there is the possibly to display mostly the differences with a bit of context around if the input text is too large.+       The text gets elided around separators if it gets greater than the shorten size (the default is 20)++     - shorten text: the text to use when eliding characters in the original text (the default is "...")++     - display edit operations: edit operations, insert/delete/substitute/keep can be annotated if necessary++Here are some examples:++@+import Data.Text.Edits++-- "between the e and the n the letter i was added"+showDistance "kitten" "kittein" === ("kitte[]n", "kitte[i]n")++-- "at the end of the text 3 letters have been deleted"+showDistance "kitten" "kit" === ("kit[ten]", "kit[]")++-- "between the t and the n 2 letters have been modified"+showDistance "kitten" "kitsin" === ("kit[te]n", "kit[si]n" )+@++-}+module Data.Text.Edits+  ( SplitSize (..),+    ShortenOptions (..),+    Separators (..),+    DisplayOptions (..),+    EditOperation (..),+    showDistance,+    defaultDisplayOptions,+    defaultDisplayEditOperations,+    taggedDisplayEditOperation,+    defaultSplitSize,+    parensSeparators,+    bracketsSeparators,+    makeCharSeparators,+  )+where++import Data.Text qualified as T+import Data.Text.Costs+import Data.Text.Difference+import Data.Text.EditMatrix+import Data.Text.EditOperation+import Data.Text.Shorten+import Data.Vector qualified as V+import Protolude++-- | Size to use when splitting a large piece of text+newtype SplitSize = SplitSize Int deriving (Eq, Show)++-- | Default split size+defaultSplitSize :: SplitSize+defaultSplitSize = SplitSize 200++-- | Show the distance between 2 pieces of text+showDistance :: Text -> Text -> (Text, Text)+showDistance = showDistanceWith defaultSplitSize defaultDisplayOptions++-- | Show the distance between 2 pieces of text and specify splitting / display options+showDistanceWith :: SplitSize -> DisplayOptions -> Text -> Text -> (Text, Text)+showDistanceWith splitSize displayOptions ts1 ts2 =+  foldSplitTexts splitSize ts1 ts2 ([], []) $ \ts (line1, line2) -> do+    let matrix = createEditMatrix textLevenshteinCosts (toS line1) (toS line2)+    let operations = toList $ makeEditOperations (V.fromList . toS $ line1) (V.fromList . toS $ line2) matrix+    ts <> (displayDiffs displayOptions operations, displayDiffs displayOptions $ inverse <$> operations)++-- | Split texts and apply the difference on each part+foldSplitTexts :: SplitSize -> Text -> Text -> a -> (a -> (Text, Text) -> a) -> a+foldSplitTexts splitSize t1 t2 initial f = do+  let (s1, s2) = (split splitSize t1, split splitSize t2)+  foldl' f initial (zip s1 s2)++-- | Split a text on newlines then split each line on a maximum split size+--   We then perform the edit distance algorithm on smaller sizes of text in order to control memory and CPU+split :: SplitSize -> Text -> [Text]+split splitSize = concatMap (splitToSize splitSize) . T.splitOn "\n"++-- | Split a text on a maximum split size+splitToSize :: SplitSize -> Text -> [Text]+splitToSize ss@(SplitSize n) t =+  if T.length t <= n+    then [t]+    else T.take n t : splitToSize ss (T.drop n t)
+ src/Data/Text/Shorten.hs view
@@ -0,0 +1,93 @@+-- | This module provides functions to shorten a piece of text+--   where parts of the text are delimited to highlight the difference with another piece of text+--   Then only the parts outside the difference are being shortened+module Data.Text.Shorten where++import Data.Coerce+import Data.Text qualified as T+import Data.Text.Token+import Protolude++-- | Size used to decide if a piece of text needs to be shortened+data ShortenOptions = ShortenOptions+  { _shortenSize :: Int,+    _shortenText :: Text+  }+  deriving (Eq, Show)++-- | Cut the shorten size in 2+half :: ShortenOptions -> ShortenOptions+half (ShortenOptions ss t) = ShortenOptions (coerce ss `div` 2) t++-- | Shorten a piece of text that has already been tokenized+shortenTokens :: ShortenOptions -> Token -> Token -> [Token] -> [Token]+shortenTokens shortenOptions startDelimiter endDelimiter tokens = do+  foldl'+    ( \res cur ->+        -- [abcdefgh] -> [abcdefgh]+        if head cur == Just startDelimiter && lastMay cur == Just endDelimiter+          then res <> cur+          else -- <start>abcdefgh -> ...defgh++            if head cur == Just Start+              then res <> shortenLeft shortenOptions cur+              else -- abcdefgh<end> -> abcd...++                if lastMay cur == Just End+                  then res ++ shortenRight shortenOptions cur+                  else -- abcdefgh -> abc...fgh+                    res <> shortenCenter shortenOptions cur+    )+    []+    delimitedTokens+  where+    delimitedTokens = splitOnDelimiters startDelimiter endDelimiter (Start : (tokens <> [End]))++-- | Split a list of tokens into several lists when a delimiter is found+--   abcd[efgh]ijkl[mnop]qrst -> [abcd, [efgh], ijkl, [mnop], qrst]+splitOnDelimiters :: Token -> Token -> [Token] -> [[Token]]+splitOnDelimiters start end =+  foldl'+    ( \res cur ->+        if cur == start+          then res <> [[start]]+          else+            if cur == end+              then updateLast res (<> [end])+              else case lastMay res of+                Just ts ->+                  if lastMay ts == Just end+                    then res <> [[cur]]+                    else updateLast res (<> [cur])+                _ ->+                  [[cur]]+    )+    ([] :: [[Token]])++-- | Shorten some token on the left: ...tokens+shortenLeft :: ShortenOptions -> [Token] -> [Token]+shortenLeft so ts = whenTooLong so ts $ Kept (_shortenText so) : drop (length ts - _shortenSize so) ts++-- | Shorten some token on the right: tokens...+shortenRight :: ShortenOptions -> [Token] -> [Token]+shortenRight so ts = whenTooLong so ts $ take (_shortenSize so) ts <> [Kept $ _shortenText so]++-- | Shorten some token in the center: ...tokens...+shortenCenter :: ShortenOptions -> [Token] -> [Token]+shortenCenter so ts = whenTooLong so ts $ take (_shortenSize $ half so) ts <> [Kept $ _shortenText so] <> drop (length ts - _shortenSize so `div` 2) ts++-- | Depending on the shorten option and the original list of tokens used a shorter version+whenTooLong :: ShortenOptions -> [Token] -> [Token] -> [Token]+whenTooLong so original shortened =+  if tokenSize original > _shortenSize so then shortened else original+  where+    tokenSize :: [Token] -> Int+    tokenSize = sum . fmap (\case Kept value -> T.length value; _ -> 0)++-- * Helpers++-- | Update the last element of a list+updateLast :: [a] -> (a -> a) -> [a]+updateLast [] _ = []+updateLast [a] f = [f a]+updateLast (a : as) f = a : updateLast as f
+ src/Data/Text/Token.hs view
@@ -0,0 +1,25 @@+-- | This module helps parsing some text with delimited differences+module Data.Text.Token where++import Protolude+import Data.Text qualified as T++-- | A Token is used to enclose a piece of text to compare and delimiters showing where the text is different from another piece of text+--   Start / End are markers for the beginning and end of that text+data Token+  = Kept Text+  | Delimiter Text+  | Start+  | End+  deriving (Eq, Show)++-- | Show a Token by skipping Start/End if present+showToken :: Token -> Text+showToken (Kept t) = t+showToken (Delimiter t) = t+showToken Start = ""+showToken End = ""++-- | Show a list of tokens. Start/End are skipped+showTokens :: [Token] -> Text+showTokens = T.concat . fmap showToken
+ test/AutoDiscoveredSpecs.hs view
@@ -0,0 +1,1 @@+{-# OPTIONS_GHC -F -pgmF tasty-discover -optF --generated-module=AutoDiscoveredSpecs #-}
+ test/Test/Data/Text/EditsSpec.hs view
@@ -0,0 +1,13 @@+module Test.Data.Text.EditsSpec where++import Data.Text.Edits+import Protolude+import Test.Tasty.Hedgehogx++test_show_distance = test "show the distance between different strings" $ do+  showDistance "kitte" "kittei" === ("kitte[]", "kitte[i]")+  showDistance "kitten" "kittein" === ("kitte[]n", "kitte[i]n")+  showDistance "kitten" "kit" === ("kit[ten]", "kit[]")+  showDistance "kit" "kitten" === ("kit[]", "kit[ten]")+  showDistance "kitten" "kitsin" === ("kit[te]n", "kit[si]n")+  showDistance "kitte" "kitte" === ("kitte", "kitte")
+ test/Test/Data/Text/ShortenSpec.hs view
@@ -0,0 +1,35 @@+module Test.Data.Text.ShortenSpec where++import Data.Text qualified as T+import Data.Text.Shorten+import Data.Text.Token+import Protolude+import Test.Tasty.Hedgehogx++test_shorten = test "shorten difference" $ do+  shorten "abcd" === "abcd"+  shorten "abcdefghijkl[mn]opqr" === "...hijkl[mn]opqr"+  shorten "abcdefghijkl[mn]" === "...hijkl[mn]"+  shorten "[mn]abcdefghijkl" === "[mn]abcde..."+  shorten "abcdefghijkl[mn]opqrstuv" === "...hijkl[mn]opqrs..."+  shorten "hijkl[zz]abcdefghijklmno[xx]abcde" === "hijkl[zz]ab...no[xx]abcde"+  shorten "hijkl[]xxabcdefghijklmno[]xxabcde" === "hijkl[]xx...no[]xxabc..."+  shorten "abcdef[]ghijkl" === "...bcdef[]ghijk..."+  shorten "abcdefg[zz]abcdefghijklmno[xx]abcdefg" === "...cdefg[zz]ab...no[xx]abcde..."++-- * Helpers++shorten :: Text -> Text+shorten t = do+  let tokens =+        toS t <&> \(c :: Char) ->+          if c == '['+            then Delimiter "["+            else+              if c == ']'+                then Delimiter "]"+                else Kept (T.singleton c)++  showTokens $ shortenTokens shortenOptions (Delimiter "[") (Delimiter "]") tokens+  where+    shortenOptions = ShortenOptions 5 "..."
+ test/test.hs view
@@ -0,0 +1,5 @@+import AutoDiscoveredSpecs (tests)+import Protolude+import Test.Tasty.Hedgehogx++main = tests >>= defaultMain . groupByModuleName