packages feed

brassica 0.0.3 → 0.1.0

raw patch · 12 files changed

+413/−227 lines, 12 filesdep ~textPVP ok

version bump matches the API change (PVP)

Dependency ranges changed: text

API changes (from Hackage documentation)

- Brassica.SoundChange.Frontend.Internal: data MDFOutputMode
- Brassica.SoundChange.Frontend.Internal: instance GHC.Classes.Eq Brassica.SoundChange.Frontend.Internal.MDFOutputMode
- Brassica.SoundChange.Frontend.Internal: instance GHC.Enum.Enum Brassica.SoundChange.Frontend.Internal.MDFOutputMode
- Brassica.SoundChange.Frontend.Internal: instance GHC.Show.Show Brassica.SoundChange.Frontend.Internal.MDFOutputMode
- Brassica.SoundChange.Types: [BoundaryEl] :: CategoryElement 'Env
- Brassica.SoundChange.Types: [Boundary] :: Lexeme 'Env
- Brassica.SoundChange.Types: [GraphemeEl] :: Grapheme -> CategoryElement a
- Brassica.SoundChange.Types: data CategoryElement (a :: LexemeType)
- Brassica.SoundChange.Types: instance Control.DeepSeq.NFData (Brassica.SoundChange.Types.CategoryElement a)
- Brassica.SoundChange.Types: instance GHC.Classes.Eq (Brassica.SoundChange.Types.CategoryElement a)
- Brassica.SoundChange.Types: instance GHC.Classes.Ord (Brassica.SoundChange.Types.CategoryElement a)
- Brassica.SoundChange.Types: instance GHC.Show.Show (Brassica.SoundChange.Types.CategoryElement a)
- Brassica.SoundChange.Types: type Grapheme = [Char]
+ Brassica.SoundChange.Apply.Internal: instance GHC.Show.Show Brassica.SoundChange.Apply.Internal.CategoryNumber
+ Brassica.SoundChange.Apply.Internal: instance GHC.Show.Show Brassica.SoundChange.Apply.Internal.ReplacementIndices
+ Brassica.SoundChange.Apply.Internal: matchMany' :: OneOf a 'Target 'Env => Maybe Grapheme -> [Lexeme a] -> MultiZipper t Grapheme -> [(MatchOutput, MultiZipper t Grapheme)]
+ Brassica.SoundChange.Frontend.Internal: MDFOutputWithEtymons :: OutputMode
+ Brassica.SoundChange.Frontend.Internal: WordsWithProtoOutput :: OutputMode
+ Brassica.SoundChange.Frontend.Internal: data OutputMode
+ Brassica.SoundChange.Frontend.Internal: getParsedWords :: ParseOutput a -> [a]
+ Brassica.SoundChange.Frontend.Internal: instance GHC.Classes.Eq Brassica.SoundChange.Frontend.Internal.OutputMode
+ Brassica.SoundChange.Frontend.Internal: instance GHC.Enum.Enum Brassica.SoundChange.Frontend.Internal.OutputMode
+ Brassica.SoundChange.Frontend.Internal: instance GHC.Show.Show Brassica.SoundChange.Frontend.Internal.OutputMode
+ Brassica.SoundChange.Frontend.Internal: intersperseWords :: [a] -> [Component a] -> [Component a]
+ Brassica.SoundChange.Frontend.Internal: tokenisationModeFor :: ApplicationMode -> TokenisationMode
+ Brassica.SoundChange.Tokenise: concatWithBoundary :: PWord -> String
+ Brassica.SoundChange.Types: GBoundary :: Grapheme
+ Brassica.SoundChange.Types: GMulti :: [Char] -> Grapheme
+ Brassica.SoundChange.Types: [Backreference] :: OneOf a 'Target 'Replacement => Int -> [Grapheme] -> Lexeme a
+ Brassica.SoundChange.Types: [Multiple] :: [Grapheme] -> Lexeme 'Replacement
+ Brassica.SoundChange.Types: addBoundaries :: PWord -> PWord
+ Brassica.SoundChange.Types: concatWithBoundary :: PWord -> String
+ Brassica.SoundChange.Types: data Grapheme
+ Brassica.SoundChange.Types: instance Control.DeepSeq.NFData Brassica.SoundChange.Types.Grapheme
+ Brassica.SoundChange.Types: instance GHC.Classes.Eq Brassica.SoundChange.Types.Grapheme
+ Brassica.SoundChange.Types: instance GHC.Classes.Ord Brassica.SoundChange.Types.Grapheme
+ Brassica.SoundChange.Types: instance GHC.Generics.Generic Brassica.SoundChange.Types.Grapheme
+ Brassica.SoundChange.Types: instance GHC.Show.Show Brassica.SoundChange.Types.Grapheme
+ Brassica.SoundChange.Types: pattern Boundary :: Lexeme a
+ Brassica.SoundChange.Types: removeBoundaries :: PWord -> PWord
- Brassica.MDF: parseMDFWithTokenisation :: [Grapheme] -> String -> Either (ParseErrorBundle String Void) (MDF [Component PWord])
+ Brassica.MDF: parseMDFWithTokenisation :: [String] -> String -> Either (ParseErrorBundle String Void) (MDF [Component PWord])
- Brassica.SoundChange.Apply.Internal: match :: OneOf a 'Target 'Env => Maybe Grapheme -> Lexeme a -> MultiZipper t Grapheme -> [(MatchOutput, MultiZipper t Grapheme)]
+ Brassica.SoundChange.Apply.Internal: match :: OneOf a 'Target 'Env => MatchOutput -> Maybe Grapheme -> Lexeme a -> MultiZipper t Grapheme -> [(MatchOutput, MultiZipper t Grapheme)]
- Brassica.SoundChange.Apply.Internal: matchMany :: OneOf a 'Target 'Env => Maybe Grapheme -> [Lexeme a] -> MultiZipper t Grapheme -> [(MatchOutput, MultiZipper t Grapheme)]
+ Brassica.SoundChange.Apply.Internal: matchMany :: OneOf a 'Target 'Env => MatchOutput -> Maybe Grapheme -> [Lexeme a] -> MultiZipper t Grapheme -> [(MatchOutput, MultiZipper t Grapheme)]
- Brassica.SoundChange.Apply.Internal: matchRuleAtPoint :: Rule -> MultiZipper RuleTag Grapheme -> [(MatchOutput, MultiZipper RuleTag Grapheme)]
+ Brassica.SoundChange.Apply.Internal: matchRuleAtPoint :: [Lexeme 'Target] -> Environment -> MultiZipper RuleTag Grapheme -> [(MatchOutput, MultiZipper RuleTag Grapheme)]
- Brassica.SoundChange.Frontend.Internal: ApplyRules :: HighlightMode -> MDFOutputMode -> ApplicationMode
+ Brassica.SoundChange.Frontend.Internal: ApplyRules :: HighlightMode -> OutputMode -> ApplicationMode
- Brassica.SoundChange.Frontend.Internal: MDFOutput :: MDFOutputMode
+ Brassica.SoundChange.Frontend.Internal: MDFOutput :: OutputMode
- Brassica.SoundChange.Frontend.Internal: WordsOnlyOutput :: MDFOutputMode
+ Brassica.SoundChange.Frontend.Internal: WordsOnlyOutput :: OutputMode
- Brassica.SoundChange.Frontend.Internal: componentise :: MDFOutputMode -> ParseOutput a -> [Component a]
+ Brassica.SoundChange.Frontend.Internal: componentise :: OutputMode -> [a] -> ParseOutput a -> [Component a]
- Brassica.SoundChange.Frontend.Internal: parseTokeniseAndApplyRules :: SoundChanges -> String -> InputLexiconFormat -> TokenisationMode -> ApplicationMode -> Maybe [Component PWord] -> ApplicationOutput PWord Statement
+ Brassica.SoundChange.Frontend.Internal: parseTokeniseAndApplyRules :: SoundChanges -> String -> InputLexiconFormat -> ApplicationMode -> Maybe [Component PWord] -> ApplicationOutput PWord Statement
- Brassica.SoundChange.Tokenise: findFirstCategoriesDecl :: SoundChanges -> [Grapheme]
+ Brassica.SoundChange.Tokenise: findFirstCategoriesDecl :: SoundChanges -> [String]
- Brassica.SoundChange.Tokenise: tokeniseWord :: [Grapheme] -> String -> Either (ParseErrorBundle String Void) PWord
+ Brassica.SoundChange.Tokenise: tokeniseWord :: [String] -> String -> Either (ParseErrorBundle String Void) PWord
- Brassica.SoundChange.Tokenise: tokeniseWords :: [Grapheme] -> String -> Either (ParseErrorBundle String Void) [Component PWord]
+ Brassica.SoundChange.Tokenise: tokeniseWords :: [String] -> String -> Either (ParseErrorBundle String Void) [Component PWord]
- Brassica.SoundChange.Tokenise: withFirstCategoriesDecl :: ([Grapheme] -> t) -> SoundChanges -> t
+ Brassica.SoundChange.Tokenise: withFirstCategoriesDecl :: ([String] -> t) -> SoundChanges -> t
- Brassica.SoundChange.Tokenise: wordParser :: [Char] -> [Grapheme] -> ParsecT Void String Identity PWord
+ Brassica.SoundChange.Tokenise: wordParser :: [Char] -> [String] -> ParsecT Void String Identity PWord
- Brassica.SoundChange.Types: Rule :: [Lexeme 'Target] -> [Lexeme 'Replacement] -> Environment -> Maybe Environment -> Flags -> String -> Rule
+ Brassica.SoundChange.Types: Rule :: [Lexeme 'Target] -> [Lexeme 'Replacement] -> [Environment] -> Maybe Environment -> Flags -> String -> Rule
- Brassica.SoundChange.Types: [Category] :: [CategoryElement a] -> Lexeme a
+ Brassica.SoundChange.Types: [Category] :: [Grapheme] -> Lexeme a
- Brassica.SoundChange.Types: [environment] :: Rule -> Environment
+ Brassica.SoundChange.Types: [environment] :: Rule -> [Environment]

Files

ChangeLog.md view
@@ -1,1 +1,11 @@-# Changelog for brassica+# Brassica changelog++## Unreleased changes++- Allow category backreferencing with `@n` before category+- Allow forcing nondeterminism with `@?` before category+- Add ‘input→output’ format for output words+- Change default output format for MDF input to wordlist in CLI+- Add ‘or environments’ with syntax ‘target / replacement / env1 / env2 / env3 / …’.+  Former rule exceptions with similar syntax have been changed to syntax ‘target / replacement / env // exception’.+- Improve documentation
LICENSE view
@@ -1,4 +1,4 @@-Copyright Brad Neimann (c) 2020-2022+Copyright Brad Neimann (c) 2020-2023  All rights reserved. 
README.md view
@@ -1,9 +1,11 @@ # Brassica +[![Hackage](https://img.shields.io/hackage/v/brassica.svg?logo=haskell)](https://hackage.haskell.org/package/brassica)+ Brassica is a new sound change applier. Its features include: -- Can be used interactively both [online](http://bradrn.com/brassica/index.html) and as a desktop application, or non-interactively in batch mode on the command-line or as a Haskell library+- Can be used interactively both [online](http://bradrn.com/brassica/index.html) and as a desktop application, or non-interactively in batch mode on the command-line or as a [Haskell library](https://hackage.haskell.org/package/brassica) - Natively supports the MDF dictionary format, also used by tools including [SIL Toolbox](https://software.sil.org/toolbox/) and [Lexique Pro](https://software.sil.org/lexiquepro/) - First-class support for multigraphs - Easy control over rule application: apply sound changes sporadically, right-to-left, and in many more ways@@ -19,7 +21,7 @@  Download Brassica from the [releases page](https://github.com/bradrn/brassica/releases/latest). Alternately, try it online at http://bradrn.com/brassica.-As of the time of writing prebuilt binaries exist only for Windows.+As of the time of writing prebuilt binaries exist for Windows and Linux. Instructions for building from source are available at [`BUILDING.md`](./BUILDING.md). -![Image of Brassica with some example sound changes](https://raw.githubusercontent.com/bradrn/brassica/v0.0.3/gui-interface-example.png)+![Image of Brassica with some example sound changes](https://raw.githubusercontent.com/bradrn/brassica/v0.1.0/gui-interface-example.png)
brassica.cabal view
@@ -1,6 +1,6 @@ cabal-version:      2.0 name:               brassica-version:            0.0.3+version:            0.1.0 synopsis:           Featureful sound change applier description:   The Brassica library for the simulation of sound changes in historical linguistics and language construction.@@ -44,7 +44,7 @@                    base >=4.7 && <5                  , containers >=0.6 && <0.7                  , deepseq >=1.4 && <1.5-                 , megaparsec >=8.0 && <8.1+                 , megaparsec >=8.0 && <9.3                  , mtl >=2.2 && <2.3                  , parser-combinators >=1.2 && <1.3                  , split >=0.2 && <0.3@@ -59,10 +59,10 @@   build-depends:                     base >=4.7 && <5                   , brassica-                  , bytestring ^>=0.10+                  , bytestring >=0.10 && <0.12                   , conduit ^>=1.3                   , optparse-applicative ^>=0.17-                  , text ^>=1.2+                  , text >=1.2 && <2.1    default-language: Haskell2010 @@ -102,8 +102,8 @@                   , brassica                   , tasty ^>=1.4                   , tasty-golden ^>=2.3-                  , bytestring ^>=0.10-                  , text ^>=1.2+                  , bytestring+                  , text >=1.2 && <2.1                   , transformers ^>=0.5                   , conduit ^>=1.3                   , utf8-string ^>=1.0
cli/Main.hs view
@@ -7,14 +7,13 @@  import Conduit import qualified Data.ByteString as B-import Data.Text (pack, unpack, Text)+import Data.Text (pack, unpack, snoc, Text) import Data.Text.Encoding (decodeUtf8) import Options.Applicative  import Brassica.SoundChange import Brassica.SoundChange.Frontend.Internal - main :: IO () main = do     Options{..} <- execParser opts@@ -28,7 +27,7 @@             withSinkFileIf outWordsFile $ \outC ->             runConduit $                 inC-                .| processWords (incrFor wordsFormat) rules wordsFormat tokMode outMode+                .| processWords (incrFor wordsFormat) rules wordsFormat outMode                 .| outC    where@@ -39,13 +38,20 @@             (metavar "RULES" <> help "File containing sound changes")         <*> flag Raw MDF             (long "mdf" <> help "Parse input words in MDF format")-        <*> flag Normal AddEtymons-            (long "etymons" <> help "With --mdf, add etymologies to output")-        <*> (flag' ReportRulesApplied+        <*> asum+            [ flag' ReportRulesApplied                 (long "report" <> help "Report rules applied rather than outputting words")-            <|>-            flag (ApplyRules NoHighlight MDFOutput) (ApplyRules NoHighlight WordsOnlyOutput)-                (long "wordlist" <> help "With --mdf, output only a list of the derived words"))+            , flag' (ApplyRules NoHighlight MDFOutput)+                (long "mdf-out" <> help "With --mdf, output MDF dictionary")+            , flag' (ApplyRules NoHighlight MDFOutputWithEtymons)+                (long "etymons" <> help "With --mdf, output MDF dictionary with etymologies")+            , flag' (ApplyRules NoHighlight WordsWithProtoOutput)+                (long "show-input" <> help "Output an input→output wordlist")+            , flag+                (ApplyRules NoHighlight WordsOnlyOutput)+                (ApplyRules NoHighlight WordsOnlyOutput)+                (long "wordlist" <> help "Output only a list of the derived words (default)")+            ]         <*> optional (strOption             (long "in" <> short 'i' <> help "File containing input words (if not specified will read from stdin)"))         <*> optional (strOption@@ -63,7 +69,6 @@ data Options = Options     { rulesFile :: String     , wordsFormat :: InputLexiconFormat-    , tokMode :: TokenisationMode     , outMode :: ApplicationMode     , inWordsFile :: Maybe String     , outWordsFile :: Maybe String@@ -74,18 +79,16 @@     => Bool  -- split into lines?     -> SoundChanges     -> InputLexiconFormat-    -> TokenisationMode     -> ApplicationMode     -> ConduitT B.ByteString B.ByteString m ()-processWords incr rules wordsFormat tokMode outMode =+processWords incr rules wordsFormat outMode =     decodeUtf8C     .| (if incr then linesUnboundedC else mapC id)-    .| mapC (processApplicationOutput . evolve . unpack)+    .| mapC (processApplicationOutput . evolve . unpack . (`snoc` '\n'))     .| throwOnLeft-    .| (if incr then unlinesC else mapC id)     .| encodeUtf8C   where-    evolve ws = parseTokeniseAndApplyRules rules ws wordsFormat tokMode outMode Nothing+    evolve ws = parseTokeniseAndApplyRules rules ws wordsFormat outMode Nothing      throwOnLeft :: (MonadThrow m, Exception e) => ConduitT (Either e r) r m ()     throwOnLeft = awaitForever $ \case
src/Brassica/MDF.hs view
@@ -34,7 +34,7 @@ import Text.Megaparsec.Char
 
 import Brassica.SoundChange.Tokenise
-import Brassica.SoundChange.Types (Grapheme, PWord)
+import Brassica.SoundChange.Types (PWord)
 import Data.Maybe (fromMaybe)
 
 -- | An MDF (Multi-Dictionary Formatter) file, represented as a list
@@ -89,7 +89,7 @@ -- | Parse an MDF file to an 'MDF', parsing the 'Vernacular' fields
 -- into 'Component's in the process.
 parseMDFWithTokenisation
-    :: [Grapheme]
+    :: [String]
     -> String
     -> Either (ParseErrorBundle String Void) (MDF [Component PWord])
 parseMDFWithTokenisation (sortByDescendingLength -> gs) =
src/Brassica/Paradigm/Parse.hs view
@@ -86,6 +86,6 @@ -- | Parse a 'String' in Brassica paradigm syntax into a 'Paradigm'.
 -- Returns 'Left' if the input string is malformed.
 --
--- For details on the syntax, refer to <https://github.com/bradrn/brassica/blob/v0.0.3/Documentation.md#paradigm-builder>.
+-- For details on the syntax, refer to <https://github.com/bradrn/brassica/blob/v0.1.0/Documentation.md#paradigm-builder>.
 parseParadigm :: String -> Either (ParseErrorBundle String Void) Paradigm
 parseParadigm = runParser (many statement) ""
src/Brassica/SoundChange/Apply/Internal.hs view
@@ -31,6 +31,7 @@        -- * Lexeme matching
        , match
        , matchMany
+       , matchMany'
        , mkReplacement
        , exceptionAppliesAtPoint
        , matchRuleAtPoint
@@ -55,6 +56,7 @@        ) where
 
 import Control.Applicative ((<|>))
+import Control.Category ((>>>))
 import Data.Containers.ListUtils (nubOrd)
 import Data.Function ((&))
 import Data.Functor ((<&>))
@@ -120,8 +122,8 @@ modifyMatchedGraphemes :: ([Grapheme] -> [Grapheme]) -> MatchOutput -> MatchOutput
 modifyMatchedGraphemes f MatchOutput{..} = MatchOutput{matchedGraphemes=f matchedGraphemes, ..}
 
-prependGrapheme :: Grapheme -> MatchOutput -> MatchOutput
-prependGrapheme g = modifyMatchedGraphemes (g:)
+appendGrapheme :: MatchOutput -> Grapheme -> MatchOutput
+appendGrapheme out g = modifyMatchedGraphemes (++[g]) out
 
 instance Semigroup MatchOutput where
     (MatchOutput a1 b1 c1) <> (MatchOutput a2 b2 c2) = MatchOutput (a1++a2) (b1++b2) (c1++c2)
@@ -130,40 +132,47 @@ -- 'MultiZipper' past the match. For each match found, returns the
 -- 'MatchOutput' tupled with the updated 'MultiZipper'.
 match :: OneOf a 'Target 'Env
-      => Maybe Grapheme       -- ^ The previously-matched grapheme, if any. (Used to match a 'Geminate'.)
+      => MatchOutput          -- ^ The previous 'MatchOutput'
+      -> Maybe Grapheme       -- ^ The previously-matched grapheme, if any. (Used to match a 'Geminate'.)
       -> Lexeme a             -- ^ The lexeme to match.
       -> MultiZipper t Grapheme   -- ^ The 'MultiZipper' to match against.
       -> [(MatchOutput, MultiZipper t Grapheme)]
-      -- ^ The output: a tuple @((i, g), mz)@ as described below.
-match prev (Optional l) mz =
-    (MatchOutput [] [False] [], mz) :
-    (first (MatchOutput [] [True] [] <>) <$> matchMany prev l mz )
-match prev w@(Wildcard l) mz = case match prev l mz of
-    [] -> maybeToList (consume mz) >>= \(g, mz') ->
-        first (prependGrapheme g) <$> match prev w mz'
+      -- ^ The output: a tuple @(g, mz)@ as described below.
+match out prev (Optional l) mz =
+    (out <> MatchOutput [] [False] [], mz) :
+    matchMany (out <> MatchOutput [] [True] []) prev l mz
+match out prev w@(Wildcard l) mz = case match out prev l mz of
+    [] -> maybeToList (consume mz) >>= \case
+        (GBoundary, _) -> []   -- don't continue past word boundary
+        (g, mz') -> match (appendGrapheme out g) prev w mz'
     r -> r
-match prev k@(Kleene l) mz = case match prev l mz of
+match out prev k@(Kleene l) mz = case match out prev l mz of
     [] -> [(MatchOutput [] [] [], mz)]
-    r -> r >>= \(out, mz') -> case match prev k mz' of
+    r -> r >>= \(out', mz') -> case match out' prev k mz' of
         [] -> error "match: Kleene should never fail"
-        r' -> first (out <>) <$> r'
-match _ (Grapheme g) mz = (MatchOutput [] [] [g],) <$> maybeToList (matchGrapheme g mz)
-match _ (Category gs) mz =
+        r' -> r'
+match out _ (Grapheme g) mz = (out <> MatchOutput [] [] [g],) <$> maybeToList (matchGrapheme g mz)
+match out _ (Category gs) mz =
     gs
     -- Attempt to match each option in category...
-    & fmap (\case
-               BoundaryEl -> if atBoundary mz then Just ([], mz) else Nothing
-               GraphemeEl g -> ([g],) <$> matchGrapheme g mz)
+    & fmap (matchCategoryEl mz)
     -- ...get the index of each match...
     & zipWith (\i m -> fmap (i,) m) [0..]
     -- ...and take all matches
     & (>>= maybeToList)
-    & fmap (\(i, (g, mz')) -> (MatchOutput [i] [] g, mz'))
-match _ Boundary mz = if atBoundary mz then [(MatchOutput [] [] [], mz)] else []
-match prev Geminate mz = case prev of
+    & fmap (\(i, (g, mz')) -> (out <> MatchOutput [i] [] g, mz'))
+match out prev Geminate mz = case prev of
     Nothing -> []
-    Just prev' -> (MatchOutput [] [] [prev'],) <$> maybeToList (matchGrapheme prev' mz)
+    Just prev' -> (out <> MatchOutput [] [] [prev'],) <$> maybeToList (matchGrapheme prev' mz)
+match out _prev (Backreference i gs) mz = maybeToList $ do
+    catIx <- matchedCatIxs out !? (i-1)
+    g <- gs !? catIx
+    (matched, mz') <- matchCategoryEl mz g
+    pure (modifyMatchedGraphemes (++matched) out, mz')
 
+matchCategoryEl :: MultiZipper t Grapheme -> Grapheme -> Maybe ([Grapheme], MultiZipper t Grapheme)
+matchCategoryEl mz g = ([g],) <$> matchGrapheme g mz
+
 matchGrapheme :: Grapheme -> MultiZipper t Grapheme -> Maybe (MultiZipper t Grapheme)
 matchGrapheme g = matchGraphemeP (==g)
 
@@ -175,20 +184,61 @@ -- though the outputs are given as a list of indices and graphemes
 -- rather than as a single index and grapheme.
 matchMany :: OneOf a 'Target 'Env
+          => MatchOutput
+          -> Maybe Grapheme
+          -> [Lexeme a]
+          -> MultiZipper t Grapheme
+          -> [(MatchOutput, MultiZipper t Grapheme)]
+matchMany out _ [] mz = [(out, mz)]
+matchMany out prev (l:ls) mz =
+    match out prev l mz >>= \(out', mz') ->
+    matchMany  out' (lastMay (matchedGraphemes out') <|> prev) ls mz'
+
+-- | 'matchMany' without any previous match output.
+matchMany' :: OneOf a 'Target 'Env
           => Maybe Grapheme
           -> [Lexeme a]
           -> MultiZipper t Grapheme
           -> [(MatchOutput, MultiZipper t Grapheme)]
-matchMany = go (MatchOutput [] [] [])
-  where
-    go out _ [] mz = [(out, mz)]
-    go out prev (l:ls) mz = match prev l mz >>= \(out', mz') ->
-        go (out <> out') (lastMay (matchedGraphemes out') <|> prev) ls mz'
+matchMany' = matchMany (MatchOutput [] [] [])
 
 -- Small utility function, not exported
 lastMay :: [a] -> Maybe a
 lastMay l = if null l then Nothing else Just (last l)
 
+data ReplacementIndices = ReplacementIndices
+    { ixInCategories :: Int
+    , ixInOptionals :: Int
+    , forcedCategory :: Maybe CategoryNumber
+    } deriving (Show)
+
+data CategoryNumber = CategoryNumber Int | Nondeterministic
+    deriving (Show)
+
+advanceCategory :: ReplacementIndices -> Int -> (CategoryNumber, ReplacementIndices)
+advanceCategory ix cslen =
+    case forcedCategory ix of
+        Just i -> (i, ix { forcedCategory = Nothing })
+        Nothing ->
+            let i = ixInCategories ix in
+                ( if i < cslen then CategoryNumber i else Nondeterministic
+                , ix { ixInCategories = i+1 }
+                )
+
+advanceOptional :: ReplacementIndices -> (Int, ReplacementIndices)
+advanceOptional ix =
+    let i = ixInOptionals ix
+    in (i, ix { ixInOptionals = i+1 })
+
+forceCategory :: CategoryNumber -> ReplacementIndices -> ReplacementIndices
+forceCategory i ixs = ixs { forcedCategory = Just i }
+
+-- | Partially safe list indexing
+(!?) :: [a] -> Int -> Maybe a
+(x:_ ) !? 0 = Just x
+(_:xs) !? n = xs !? (n-1)
+[]     !? _ = Nothing
+
 -- | Given a list of 'Lexeme's specifying a replacement, generate all
 -- possible replacements and apply them to the given input.
 mkReplacement
@@ -196,39 +246,59 @@     -> [Lexeme 'Replacement]    -- ^ The 'Lexeme's specifying the replacement.
     -> MultiZipper t Grapheme
     -> [MultiZipper t Grapheme]
-mkReplacement = \out ls -> fmap (fst . snd) . go out ls . (,Nothing)
+mkReplacement out = \ls -> fmap (fst . snd) . go startIxs ls . (,Nothing)
   where
-    go out []     (mz, prev) = [(out, (mz, prev))]
-    go out (l:ls) (mz, prev) = do
-        (out', (mz', prev')) <- replaceLex out l mz prev
-        go out' ls (mz', prev')
+    startIxs = ReplacementIndices 0 0 Nothing
 
+    go
+        :: ReplacementIndices
+        -> [Lexeme 'Replacement]
+        -> (MultiZipper t Grapheme, Maybe Grapheme)
+        -> [(ReplacementIndices, (MultiZipper t Grapheme, Maybe Grapheme))]
+    go ixs []     (mz, prev) = [(ixs, (mz, prev))]
+    go ixs (l:ls) (mz, prev) = do
+        (ixs', (mz', prev')) <- replaceLex ixs l mz prev
+        go ixs' ls (mz', prev')
+
+    numCatsMatched = length $ matchedCatIxs out
+
     replaceLex
-        :: MatchOutput
+        :: ReplacementIndices
         -> Lexeme 'Replacement
         -> MultiZipper t Grapheme
         -> Maybe Grapheme
-        -> [(MatchOutput, (MultiZipper t Grapheme, Maybe Grapheme))]
-    replaceLex out (Grapheme g) mz _prev = [(out, (insert g mz, Just g))]
-    replaceLex out@MatchOutput{matchedCatIxs=(i:is)} (Category gs) mz _prev =
-        fmap (out{matchedCatIxs=is},) $
-            if i < length gs
-            then case gs !! i of GraphemeEl g -> [(insert g mz, Just g)]
-            else [(insert "\xfffd" mz, Nothing)]  -- Unicode replacement character
-    replaceLex out (Category gs) mz _prev =
-        gs <&> \(GraphemeEl g) -> (out, (insert g mz, Just g))
-    replaceLex MatchOutput{matchedOptionals=(o:os), ..} (Optional ls) mz prev =
-        let out' = MatchOutput{matchedOptionals=os, ..}
-        in if o
-           then go out' ls (mz, prev)
-           else [(out', (mz, Nothing))]
-    replaceLex out (Optional ls) mz prev = (out, (mz, Nothing)) : go out ls (mz, prev)
-    replaceLex out@MatchOutput{matchedGraphemes}     Metathesis  mz _prev =
-        [(out, (flip insertMany mz $ reverse matchedGraphemes, listToMaybe matchedGraphemes))]
-    replaceLex out                                   Geminate    mz prev =
-        [(out, (flip insertMany mz $ maybeToList prev, prev))]
-    replaceLex out@MatchOutput{matchedCatIxs=(_:is)} Discard mz prev = [(out{matchedCatIxs=is}, (mz, prev))]
-    replaceLex out@MatchOutput{matchedCatIxs=[]} Discard mz prev = [(out, (mz, prev))]
+        -> [(ReplacementIndices, (MultiZipper t Grapheme, Maybe Grapheme))]
+    replaceLex ixs (Grapheme g) mz _prev = [(ixs, (insert g mz, Just g))]
+    replaceLex ixs (Category gs) mz _prev =
+        case advanceCategory ixs numCatsMatched of
+            (CategoryNumber ci, ixs') ->
+                let i = matchedCatIxs out !! ci in
+                    case gs !? i of
+                        Just g   -> [(ixs', (insert g mz, Just g))]
+                        Nothing  -> [(ixs', (insert (GMulti "\xfffd") mz, Nothing))]  -- Unicode replacement character
+            (Nondeterministic, ixs') -> gs <&> \g -> (ixs', (insert g mz, Just g))
+    replaceLex ixs (Optional ls) mz prev =
+        let (co, ixs') = advanceOptional ixs in
+            case matchedOptionals out !? co of
+                Just True -> go ixs' ls (mz, prev)
+                Just False -> [(ixs', (mz, Nothing))]
+                Nothing    ->  (ixs', (mz, Nothing)) : go ixs ls (mz, prev)
+    replaceLex ixs Metathesis mz _prev =
+        [( ixs
+         , ( flip insertMany mz $ reverse $ matchedGraphemes out
+           , listToMaybe $ matchedGraphemes out)
+         )]
+    replaceLex ixs Geminate mz prev =
+        [(ixs, (flip insertMany mz $ maybeToList prev, prev))]
+    replaceLex ixs Discard mz prev =
+        let (_, ixs') = advanceCategory ixs numCatsMatched
+        in [(ixs', (mz, prev))]
+    replaceLex ixs (Backreference i c) mz prev =
+        let ixs' = forceCategory (CategoryNumber $ i-1) ixs -- 1-based indexing!
+        in replaceLex ixs' (Category c) mz prev
+    replaceLex ixs (Multiple c) mz prev =
+        let ixs' = forceCategory Nondeterministic ixs
+        in replaceLex ixs' (Category c) mz prev
 
 -- | Given a 'Rule' and a 'MultiZipper', determines whether the
 -- 'exception' of that rule (if any) applies starting at the current
@@ -236,55 +306,66 @@ -- first element of each matching 'target'.
 exceptionAppliesAtPoint :: [Lexeme 'Target] -> Environment -> MultiZipper RuleTag Grapheme -> [Int]
 exceptionAppliesAtPoint target (ex1, ex2) mz = fmap fst $ flip runRuleAp mz $ do
-    _ <- RuleAp $ matchMany Nothing ex1
+    _ <- RuleAp $ matchMany' Nothing ex1
     pos <- gets curPos
-    MatchOutput{matchedGraphemes} <- RuleAp $ matchMany Nothing target
-    _ <- RuleAp $ matchMany (listToMaybe matchedGraphemes) ex2
+    MatchOutput{matchedGraphemes} <- RuleAp $ matchMany' Nothing target
+    _ <- RuleAp $ matchMany' (listToMaybe matchedGraphemes) ex2
     return pos
 
--- | Given a 'Rule', determine if that rule matches. If so, for each
--- match, set the appropriate 'RuleTag's and return a tuple of @(is,
--- gs)@, where @gs@ is a list of matched t'Grapheme's, and @is@ is a
--- list of indices, one for each 'Category' lexeme matched.
+-- | Given a target and environment, determine if they rule
+-- matches. If so, for each match, set the appropriate 'RuleTag's and
+-- return a tuple of @(is, gs)@, where @gs@ is a list of matched
+-- t'Grapheme's, and @is@ is a list of indices, one for each
+-- 'Category' lexeme matched.
 matchRuleAtPoint
-    :: Rule
+    :: [Lexeme 'Target]
+    -> Environment
     -> MultiZipper RuleTag Grapheme
     -> [(MatchOutput, MultiZipper RuleTag Grapheme)]
-matchRuleAtPoint Rule{environment = (env1, env2), ..} mz = flip runRuleAp mz $ do
-    _ <- RuleAp $ matchMany Nothing env1
-    modify $ tag TargetStart
-    matchResult <- RuleAp $ matchMany Nothing target
-    modify $ tag TargetEnd
-    _ <- RuleAp $ matchMany (listToMaybe $ matchedGraphemes matchResult) env2
-    return matchResult
+matchRuleAtPoint target (env1,env2) mz = flip runRuleAp mz $ do
+    _ <- RuleAp $ matchMany' Nothing env1
+    -- start of target needs to be INSIDE 'MultiZipper'!
+    -- otherwise get weird things like /x/#_ resulting in
+    -- #abc#→#xabd#x when it should be #abc#→#xabc#
+    gets atBoundary >>= \case
+        True -> RuleAp $ const []
+        False -> do
+            modify $ tag TargetStart
+            matchResult <- RuleAp $ matchMany' Nothing target
+            modify $ tag TargetEnd
+            _ <- RuleAp $ matchMany' (listToMaybe $ matchedGraphemes matchResult) env2
+            return matchResult
 
 -- | Given a 'Rule', determine if the rule matches at the current
 -- point; if so, apply the rule, adding appropriate tags.
 applyOnce :: Rule -> StateT (MultiZipper RuleTag Grapheme) [] Bool
-applyOnce r@Rule{target, replacement, exception} = do
-    modify $ tag AppStart
-    result <- try (matchRuleAtPoint r)
-    case result of
-        Just out -> do
-            exs <- case exception of
-                Nothing -> pure []
-                Just ex -> gets $ join . toList .
-                    extend' (exceptionAppliesAtPoint target ex)
-            gets (locationOf TargetStart) >>= \p ->
-                if maybe True (`elem` exs) p
-                then return False
-                else do
-                    modifyMay $ modifyBetween (TargetStart, TargetEnd) $ const []
-                    modifyMay $ seek TargetStart
-                    modifyM $ mkReplacement out replacement
-                    return True
-        Nothing -> return False
+applyOnce r@Rule{target, replacement, exception} =
+    modify (tag AppStart) >> go (environment r)
+  where
+    go [] = return False
+    go (env:envs) = do
+        result <- try (matchRuleAtPoint target env)
+        case result of
+            Just out -> do
+                exs <- case exception of
+                    Nothing -> pure []
+                    Just ex -> gets $ join . toList .
+                        extend' (exceptionAppliesAtPoint target ex)
+                gets (locationOf TargetStart) >>= \p ->
+                    if maybe True (`elem` exs) p
+                    then return False
+                    else do
+                        modifyMay $ modifyBetween (TargetStart, TargetEnd) $ const []
+                        modifyMay $ seek TargetStart
+                        modifyM $ mkReplacement out replacement
+                        return True
+            Nothing -> modifyMay (seek AppStart) >> go envs
 
 -- | Remove tags and advance the current index to the next t'Grapheme'
 -- after the rule application.
 setupForNextApplication :: Bool -> Rule -> MultiZipper RuleTag Grapheme -> Maybe (MultiZipper RuleTag Grapheme)
-setupForNextApplication success r@Rule{flags=Flags{applyDirection}} = fmap untag .
-    case applyDirection of
+setupForNextApplication success r@Rule{flags=Flags{applyDirection}} =
+    fmap untag . case applyDirection of
         RTL -> seek AppStart >=> bwd
         LTR ->
             if success
@@ -323,7 +404,9 @@ -- the given 'CategoriesDecl', replacing all unlisted graphemes with
 -- U+FFFD.
 checkGraphemes :: CategoriesDecl -> MultiZipper RuleTag Grapheme -> MultiZipper RuleTag Grapheme
-checkGraphemes (CategoriesDecl gs) = fmap $ \g -> if g `elem` gs then g else "\xfffd"
+checkGraphemes (CategoriesDecl gs) = fmap $ \case
+    GBoundary -> GBoundary
+    g -> if g `elem` gs then g else GMulti "\xfffd"
 
 -- | Apply a 'Statement' to a 'MultiZipper'. This is a simple wrapper
 -- around 'applyRule' and 'checkGraphemes'.
@@ -346,7 +429,12 @@ -- are removed. To keep duplicates, use the lower-level internal
 -- function 'applyStatement' directly.
 applyStatementStr :: Statement -> PWord -> [PWord]
-applyStatementStr st s = nubOrd $ fmap toList $ applyStatement st $ fromListStart s
+applyStatementStr st =
+    addBoundaries
+    >>> fromListStart
+    >>> applyStatement st
+    >>> fmap (toList >>> removeBoundaries)
+    >>> nubOrd
 
 -- | A log item representing a single application of an action. (In
 -- practise this will usually be a 'Statement'.) Specifies the action
@@ -390,12 +478,12 @@ -- +------+---+-------+-------------+ 
 
 reportAsHtmlRows :: (r -> String) -> PWordLog r -> String
-reportAsHtmlRows render item = go (concat $ initialWord item) (derivations item)
+reportAsHtmlRows render item = go (concatWithBoundary $ initialWord item) (derivations item)
   where
     go _ [] = ""
     go cell1 ((output, action) : ds) =
         ("<tr><td>" ++ cell1 ++ "</td><td>&rarr;</td><td>"
-         ++ concat output
+         ++ concatWithBoundary output
          ++ "</td><td>(" ++ render action ++ ")</td></tr>")
         ++ go "" ds
 
@@ -416,10 +504,10 @@ -- >   -> tazh   (V / / _ #)
 reportAsText :: (r -> String) -> PWordLog r -> String
 reportAsText render item = unlines $
-    concat (initialWord item) : fmap toLine (alignWithPadding $ derivations item)
+    concatWithBoundary (initialWord item) : fmap toLine (alignWithPadding $ derivations item)
   where
     alignWithPadding ds =
-        let (fmap concat -> outputs, actions) = unzip ds
+        let (fmap concatWithBoundary -> outputs, actions) = unzip ds
             maxlen = maximum $ length <$> outputs
             padded = outputs <&> \o -> o ++ replicate (maxlen - length o) ' '
         in zip padded actions
src/Brassica/SoundChange/Frontend/Internal.hs view
@@ -26,7 +26,7 @@ 
 -- | Rule application mode of the SCA.
 data ApplicationMode
-    = ApplyRules HighlightMode MDFOutputMode
+    = ApplyRules HighlightMode OutputMode
     | ReportRulesApplied
     deriving (Show, Eq)
 
@@ -46,15 +46,23 @@     toEnum 2 = DifferentToInput
     toEnum _ = undefined
 
-data MDFOutputMode = MDFOutput | WordsOnlyOutput
+data OutputMode
+    = MDFOutput
+    | WordsOnlyOutput
+    | MDFOutputWithEtymons
+    | WordsWithProtoOutput
     deriving (Show, Eq)
-instance Enum MDFOutputMode where
+instance Enum OutputMode where
     -- used for conversion to and from C, so want control over values
     fromEnum MDFOutput = 0
     fromEnum WordsOnlyOutput = 1
+    fromEnum MDFOutputWithEtymons = 2
+    fromEnum WordsWithProtoOutput = 3
 
     toEnum 0 = MDFOutput
     toEnum 1 = WordsOnlyOutput
+    toEnum 2 = MDFOutputWithEtymons
+    toEnum 3 = WordsWithProtoOutput
     toEnum _ = undefined
 
 data TokenisationMode = Normal | AddEtymons
@@ -68,6 +76,10 @@     toEnum 1 = AddEtymons
     toEnum _ = undefined
 
+tokenisationModeFor :: ApplicationMode -> TokenisationMode
+tokenisationModeFor (ApplyRules _ MDFOutputWithEtymons) = AddEtymons
+tokenisationModeFor _ = Normal
+
 -- | Output of a single application of rules to a wordlist: either a
 -- list of possibly highlighted words, an applied rules table, or a
 -- parse error.
@@ -92,11 +104,21 @@ data ParseOutput a = ParsedRaw [Component a] | ParsedMDF (MDF [Component a])
     deriving (Show, Functor)
 
-componentise :: MDFOutputMode -> ParseOutput a -> [Component a]
-componentise _               (ParsedRaw cs) = cs
-componentise MDFOutput       (ParsedMDF mdf) = componentiseMDF mdf
-componentise WordsOnlyOutput (ParsedMDF mdf) = componentiseMDFWordsOnly mdf
+componentise :: OutputMode -> [a] -> ParseOutput a -> [Component a]
+componentise WordsWithProtoOutput ws (ParsedRaw cs) = intersperseWords ws cs
+componentise _                    _ (ParsedRaw cs) = cs
+componentise MDFOutput            _ (ParsedMDF mdf) = componentiseMDF mdf
+componentise MDFOutputWithEtymons _ (ParsedMDF mdf) = componentiseMDF mdf
+componentise WordsOnlyOutput      _ (ParsedMDF mdf) = componentiseMDFWordsOnly mdf
+componentise WordsWithProtoOutput ws (ParsedMDF mdf) = intersperseWords ws $ componentiseMDFWordsOnly mdf
 
+intersperseWords :: [a] -> [Component a] -> [Component a]
+intersperseWords (w:ws) (Word c:cs) =
+    Word w : Separator " → " : Word c : Separator "\n" : intersperseWords ws cs
+intersperseWords ws (_:cs) = intersperseWords ws cs
+intersperseWords [] cs = cs
+intersperseWords _ [] = []
+
 tokeniseAccordingToInputFormat
     :: InputLexiconFormat
     -> TokenisationMode
@@ -112,6 +134,10 @@     . second (duplicateEtymologies $ ('*':) . detokeniseWords)
     . withFirstCategoriesDecl parseMDFWithTokenisation cs
 
+getParsedWords :: ParseOutput a -> [a]
+getParsedWords (ParsedRaw cs) = getWords cs
+getParsedWords (ParsedMDF mdf) = getWords $ componentiseMDF mdf
+
 -- | Top-level dispatcher for an interactive frontend: given a textual
 -- wordlist and a list of sound changes, returns the result of running
 -- the changes in the specified mode.
@@ -119,30 +145,34 @@     :: SoundChanges -- ^ changes
     -> String       -- ^ words
     -> InputLexiconFormat
-    -> TokenisationMode
     -> ApplicationMode
     -> Maybe [Component PWord]  -- ^ previous results
     -> ApplicationOutput PWord Statement
-parseTokeniseAndApplyRules statements ws intype tmode mode prev =
+parseTokeniseAndApplyRules statements ws intype mode prev =
+    let tmode = tokenisationModeFor mode in
     case tokeniseAccordingToInputFormat intype tmode statements ws of
         Left e -> ParseError e
-        Right toks -> case mode of
+        Right toks
+          | ws' <- getParsedWords toks
+          -> case mode of
             ReportRulesApplied ->
                 AppliedRulesTable $ mapMaybe toPWordLog $ concat $
-                    getWords $ componentise WordsOnlyOutput $
+                    getWords $ componentise WordsOnlyOutput [] $
                         applyChangesWithLog statements <$> toks
             ApplyRules DifferentToLastRun mdfout ->
                 let result = concatMap (splitMultipleResults " ") $
-                      componentise mdfout $ applyChanges statements <$> toks
+                      componentise mdfout (fmap pure ws') $ applyChanges statements <$> toks
                 in HighlightedWords $
                     zipWithComponents result (fromMaybe [] prev) [] $ \thisWord prevWord ->
                         (thisWord, thisWord /= prevWord)
             ApplyRules DifferentToInput mdfout ->
                 HighlightedWords $ concatMap (splitMultipleResults " ") $
-                    componentise mdfout $ applyChangesWithChanges statements <$> toks
+                    componentise mdfout (fmap (pure . (,False)) ws') $
+                        applyChangesWithChanges statements <$> toks
             ApplyRules NoHighlight mdfout ->
                 HighlightedWords $ (fmap.fmap) (,False) $ concatMap (splitMultipleResults " ") $
-                    componentise mdfout $ applyChanges statements <$> toks
+                    componentise mdfout (fmap pure ws') $
+                        applyChanges statements <$> toks
   where
     -- Zips two tokenised input strings. Compared to normal 'zipWith'
     -- this has two special properties:
src/Brassica/SoundChange/Parse.hs view
@@ -1,7 +1,9 @@ {-# LANGUAGE DataKinds        #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE KindSignatures   #-}+{-# LANGUAGE LambdaCase       #-} {-# LANGUAGE RecordWildCards  #-}+{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-}  module Brassica.SoundChange.Parse@@ -36,7 +38,6 @@  class ParseLexeme (a :: LexemeType) where     parseLexeme :: Parser (Lexeme a)-    parseCategoryElement :: Parser (CategoryElement a)  -- space consumer which does not match newlines sc :: Parser ()@@ -57,18 +58,30 @@ symbol = L.symbol sc  keyChars :: [Char]-keyChars = "#[](){}>\\→/_^%~*"+keyChars = "#[](){}>\\→/_^%~*@" +nonzero :: Parser Int+nonzero = label "nonzero postive number" $ try $ do+    n <- lexeme L.decimal+    guard $ n>0+    pure n+ parseGrapheme :: Parser (Grapheme, Bool)-parseGrapheme = lexeme $ (,) <$> takeWhile1P Nothing (not . ((||) <$> isSpace <*> (`elem` keyChars))) <*> (isJust <$> optional (char '~'))+parseGrapheme = lexeme $ parseBoundary <|> parseMulti+  where+    parseBoundary = (GBoundary,False) <$ char '#' +    parseMulti = (,)+        <$> fmap GMulti (takeWhile1P Nothing (not . ((||) <$> isSpace <*> (`elem` keyChars))))+        <*> (isJust <$> optional (char '~'))+ parseGrapheme' :: Parser Grapheme-parseGrapheme' = lexeme $ takeWhile1P Nothing (not . ((||) <$> isSpace <*> (=='=')))+parseGrapheme' = lexeme $ GMulti <$> takeWhile1P Nothing (not . ((||) <$> isSpace <*> (=='='))) -data CategoryModification a-    = Union     (CategoryElement a)-    | Intersect (CategoryElement a)-    | Subtract  (CategoryElement a)+data CategoryModification+    = Union     Grapheme+    | Intersect Grapheme+    | Subtract  Grapheme  parseGraphemeOrCategory :: ParseLexeme a => Parser (Lexeme a) parseGraphemeOrCategory = do@@ -79,26 +92,26 @@             cats <- gets categories             return $ case C.lookup g cats of                 Nothing -> Grapheme g-                Just c  -> Category $ C.bake $ GraphemeEl <$> c+                Just c  -> Category $ C.bake c  parseCategory :: ParseLexeme a => Parser (Lexeme a)-parseCategory = do+parseCategory = Category <$> parseCategory'++parseCategory' :: Parser [Grapheme]+parseCategory' = do     mods <- symbol "[" *> someTill parseCategoryModification (symbol "]")     cats <- gets categories-    return $ Category $ C.bake $-        C.expand (C.mapCategories GraphemeEl cats) (toCategory mods)+    return $ C.bake $+        C.expand cats (toCategory mods)  parseCategoryStandalone :: Parser (Grapheme, C.Category 'C.Expanded Grapheme) parseCategoryStandalone = do     g <- parseGrapheme'     _ <- symbol "="     -- Use Target here because it only allows graphemes, not boundaries-    mods <- some (parseCategoryModification @'Target)+    mods <- some parseCategoryModification     cats <- gets categories-    return (g, C.expand cats $ toGrapheme <$> toCategory mods)--toGrapheme :: CategoryElement 'Target -> Grapheme-toGrapheme (GraphemeEl g) = g+    return (g, C.expand cats $ toCategory mods)  categoriesDeclParse :: Parser CategoriesDecl categoriesDeclParse = do@@ -115,9 +128,9 @@     parseFeature = do         _ <- symbol "feature"         namePlain <- optional $ try $ parseGrapheme' <* symbol "="-        modsPlain <- some (parseCategoryModification @'Target)+        modsPlain <- some parseCategoryModification         cats <- gets categories-        let plainCat = C.expand cats $ toGrapheme <$> toCategory modsPlain+        let plainCat = C.expand cats $ toCategory modsPlain             plain = C.bake plainCat         modifiedCats <- some (symbol "/" *> parseCategoryStandalone) <* scn         let modified = C.bake . snd <$> modifiedCats@@ -134,15 +147,15 @@         (k, c) <- try parseCategoryStandalone <* scn         modify $ \(Config cs) -> Config (M.insert k c cs) -parseCategoryModification :: ParseLexeme a => Parser (CategoryModification a)-parseCategoryModification = parsePrefix <*> parseCategoryElement+parseCategoryModification :: Parser CategoryModification+parseCategoryModification = parsePrefix <*> (fst <$> parseGrapheme)   where     parsePrefix =         (Intersect <$ char '+')         <|> (Subtract <$ char '-')         <|> pure Union -toCategory :: [CategoryModification a] -> C.Category 'C.Unexpanded (CategoryElement a)+toCategory :: [CategoryModification] -> C.Category 'C.Unexpanded Grapheme toCategory = go C.Empty   where     go c [] = c@@ -162,24 +175,39 @@ parseWildcard :: (ParseLexeme a, OneOf a 'Target 'Env) => Parser (Lexeme a) parseWildcard = Wildcard <$> (symbol "^" *> parseLexeme) -parseBoundary :: Parser ()-parseBoundary = () <$ symbol "#"- parseDiscard :: Parser (Lexeme 'Replacement) parseDiscard = Discard <$ symbol "~"  parseKleene :: OneOf a 'Target 'Env => Lexeme a -> Parser (Lexeme a) parseKleene l = (Kleene l <$ symbol "*") <|> pure l +parseMultiple :: Parser (Lexeme 'Replacement)+parseMultiple = Multiple <$> (symbol "@?" *> parseCategory')++parseBackreference+    :: forall a.+       (OneOf a 'Target 'Replacement, ParseLexeme a)+    => Parser (Lexeme a)+parseBackreference =+    Backreference+    <$> (symbol "@" *> nonzero)+    <*> (parseCategory' <|> parseGraphemeCategory)+  where+    parseGraphemeCategory :: Parser [Grapheme]+    parseGraphemeCategory = label "category" $ try $+        (parseGraphemeOrCategory @a) >>= \case+            Category gs -> pure gs+            _ -> empty+ instance ParseLexeme 'Target where     parseLexeme = asum         [ parseCategory         , parseOptional         , parseGeminate         , parseWildcard+        , parseBackreference         , parseGraphemeOrCategory         ] >>= parseKleene-    parseCategoryElement = GraphemeEl . fst <$> parseGrapheme  instance ParseLexeme 'Replacement where     parseLexeme = asum@@ -188,23 +216,19 @@         , parseMetathesis         , parseDiscard         , parseGeminate+        , parseMultiple+        , parseBackreference         , parseGraphemeOrCategory         ]-    parseCategoryElement = GraphemeEl . fst <$> parseGrapheme  instance ParseLexeme 'Env where     parseLexeme = asum         [ parseCategory-        , Boundary <$ parseBoundary         , parseOptional         , parseGeminate         , parseWildcard         , parseGraphemeOrCategory         ] >>= parseKleene-    parseCategoryElement = asum-        [ BoundaryEl <$ parseBoundary-        , GraphemeEl . fst <$> parseGrapheme-        ]  parseLexemes :: ParseLexeme a => Parser [Lexeme a] parseLexemes = many parseLexeme@@ -229,28 +253,29 @@     _ <- lexeme $ oneOf "/→"     replacement <- parseLexemes -    let parseEnvironment = do-            _ <- symbol "/"-            env1 <- parseLexemes-            _ <- symbol "_"-            env2 <- parseLexemes-            exception <- optional $ (,) <$> (symbol "/" *> parseLexemes) <* symbol "_" <*> parseLexemes-            return (env1, env2, exception)+    envs' <- many $ do+        notFollowedBy $ symbol "//"  -- for exceptions+        _ <- symbol "/"+        env1 <- parseLexemes+        _ <- symbol "_"+        env2 <- parseLexemes+        return (env1, env2)+    let envs = if null envs' then [([], [])] else envs' -    (env1, env2, exception) <- parseEnvironment <|> pure ([], [], Nothing)+    exception <- optional $ (,) <$> (symbol "//" *> parseLexemes) <* symbol "_" <*> parseLexemes      _ <- optional scn   -- consume newline after rule if present      o' <- getOffset     let plaintext = takeWhile notNewline $ (fst . fromJust) (takeN_ (o' - o) s)-    return Rule{environment=(env1,env2), ..}+    return Rule{environment=envs, ..}   where     notNewline c = (c /= '\n') && (c /= '\r')  -- | Parse a 'String' in Brassica sound change syntax into a -- 'Rule'. Returns 'Left' if the input string is malformed. ----- For details on the syntax, refer to <https://github.com/bradrn/brassica/blob/v0.0.3/Documentation.md#basic-rule-syntax>.+-- For details on the syntax, refer to <https://github.com/bradrn/brassica/blob/v0.1.0/Documentation.md#basic-rule-syntax>. parseRule :: String -> Either (ParseErrorBundle String Void) Rule parseRule = parseRuleWithCategories M.empty 
src/Brassica/SoundChange/Tokenise.hs view
@@ -16,6 +16,7 @@        , tokeniseWords        , detokeniseWords'        , detokeniseWords+       , concatWithBoundary        , findFirstCategoriesDecl        , withFirstCategoriesDecl        -- * Lower-level functions@@ -74,12 +75,16 @@ -- 'wordParser' itself is only really useful in unusual situations -- (e.g. as part of a larger parser). The first parameter gives a list -- of characters (aside from whitespace) which should be excluded from--- words, i.e. the parser will stop if any of them are found.+-- words, i.e. the parser will stop if any of them are found. The second+-- gives a list of multigraphs which might be expected. -- -- Note: the second parameter __must__ be 'sortByDescendingLength'-ed;--- otherwise digraphs will not be parsed correctly.-wordParser :: [Char] -> [Grapheme] -> ParsecT Void String Identity PWord-wordParser excludes gs = some $ choice (chunk <$> gs) <|> (pure <$> satisfy (not . exclude))+-- otherwise multigraphs will not be parsed correctly.+wordParser :: [Char] -> [String] -> ParsecT Void String Identity PWord+wordParser excludes gs = some $+    (GBoundary <$ single '#')+    <|> choice (fmap GMulti . chunk <$> gs)+    <|> (GMulti . pure <$> satisfy (not . exclude))   where     exclude = (||) <$> isSpace <*> (`elem` excludes) @@ -105,34 +110,34 @@ sortByDescendingLength = sortBy (compare `on` Down . length)  -- | Tokenise a 'String' input word into a 'PWord' by splitting it up--- into t'Grapheme's. A list of available t'Grapheme's is supplied as--- an argument.+-- into t'Grapheme's. A list of available multigraphs is supplied as+-- the first argument. -- -- Note that this tokeniser is greedy: if one of the given--- t'Grapheme's is a prefix of another, the tokeniser will prefer the--- longest if possible. If there are no matching t'Grapheme's starting+-- multigraphs is a prefix of another, the tokeniser will prefer the+-- longest if possible. If there are no matching multigraphs starting -- at a particular character in the 'String', 'tokeniseWord' will -- treat that character as its own t'Grapheme'. For instance: -- -- >>> tokeniseWord [] "cherish"--- Right ["c","h","e","r","i","s","h"]+-- Right [GMulti "c",GMulti "h",GMulti "e",GMulti "r",GMulti "i",GMulti "s",GMulti "h"] --  -- >>> tokeniseWord ["e","h","i","r","s","sh"] "cherish"--- Right ["c","h","e","r","i","sh"]+-- Right [GMulti "c",GMulti "h",GMulti "e",GMulti "r",GMulti "i",GMulti "sh"] --  -- >>> tokeniseWord ["c","ch","e","h","i","r","s","sh"] "cherish"--- Right ["ch","e","r","i","sh"]-tokeniseWord :: [Grapheme] -> String -> Either (ParseErrorBundle String Void) PWord+-- Right [GMulti "ch",GMulti "e",GMulti "r",GMulti "i",GMulti "sh"]+tokeniseWord :: [String] -> String -> Either (ParseErrorBundle String Void) PWord tokeniseWord (sortByDescendingLength -> gs) = parse (wordParser "[" gs) "" --- | Given a list of available t'Grapheme's, tokenise an input string+-- | Given a list of available multigraphs, tokenise an input string -- into a list of words and other 'Component's. This uses the same -- tokenisation strategy as 'tokeniseWords', but also recognises -- 'Gloss'es (in square brackets) and 'Separator's (in the form of -- whitespace).-tokeniseWords :: [Grapheme] -> String -> Either (ParseErrorBundle String Void) [Component PWord]+tokeniseWords :: [String] -> String -> Either (ParseErrorBundle String Void) [Component PWord] tokeniseWords (sortByDescendingLength -> gs) =-    parse (componentsParser $ wordParser "[" gs) ""+        parse (componentsParser $ wordParser "[" gs) ""  -- | Given a function to convert 'Word's to strings, converts a list -- of 'Component's to strings.@@ -143,14 +148,17 @@     Gloss g -> '[':(g ++ "]")  -- | Specialisation of 'detokeniseWords'' for 'PWord's, converting--- words to strings using 'concat'.+-- words to strings using 'concatWithBoundary'. detokeniseWords :: [Component PWord] -> String-detokeniseWords = detokeniseWords' concat+detokeniseWords = detokeniseWords' concatWithBoundary --- | Given a list of sound changes, extract the list of graphemes+-- | Given a list of sound changes, extract the list of multigraphs -- defined in the first categories declaration of the 'SoundChange's.-findFirstCategoriesDecl :: SoundChanges -> [Grapheme]-findFirstCategoriesDecl (CategoriesDeclS (CategoriesDecl gs):_) = gs+findFirstCategoriesDecl :: SoundChanges -> [String]+findFirstCategoriesDecl (CategoriesDeclS (CategoriesDecl gs):_) =+    mapMaybe+        (\case GBoundary -> Nothing; GMulti m -> Just m)+        gs findFirstCategoriesDecl (_:ss) = findFirstCategoriesDecl ss findFirstCategoriesDecl [] = [] @@ -158,5 +166,5 @@ -- like @'withFirstCategoriesDecl' 'tokeniseWords' changes words@ (to -- tokenise using the graphemes from the first categories declaration) -- and so on.-withFirstCategoriesDecl :: ([Grapheme] -> t) -> SoundChanges -> t+withFirstCategoriesDecl :: ([String] -> t) -> SoundChanges -> t withFirstCategoriesDecl tok ss = tok (findFirstCategoriesDecl ss)
src/Brassica/SoundChange/Types.hs view
@@ -6,8 +6,10 @@ {-# LANGUAGE FlexibleContexts      #-} {-# LANGUAGE FlexibleInstances     #-} {-# LANGUAGE GADTs                 #-}+{-# LANGUAGE LambdaCase            #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE PolyKinds             #-}+{-# LANGUAGE PatternSynonyms       #-} {-# LANGUAGE RankNTypes            #-} {-# LANGUAGE StandaloneDeriving    #-} {-# LANGUAGE TypeFamilies          #-}@@ -17,11 +19,14 @@ module Brassica.SoundChange.Types        (        -- * Words and graphemes-         Grapheme+         Grapheme(..)        , PWord+       , addBoundaries+       , removeBoundaries+       , concatWithBoundary        -- * Lexemes        , Lexeme(..)-       , CategoryElement(..)+       , pattern Boundary        , LexemeType(..)        -- * Rules        , Rule(..)@@ -41,6 +46,7 @@ import Control.DeepSeq (NFData(..)) import Data.Kind (Constraint) import GHC.Generics (Generic)+import GHC.OldList (dropWhileEnd) import GHC.TypeLits  -- | The constraint @OneOf a x y@ is satisfied if @a ~ x@ or @a ~ y@.@@ -60,9 +66,11 @@                      ':<>: 'Text " or "                      ':<>: 'ShowType c)) --- | The type of graphemes, or more accurately multigraphs: for--- instance, @"a", "ch", "c̓" :: t'Grapheme'@.-type Grapheme = [Char]+-- | The type of graphemes within a word.+data Grapheme+    = GMulti [Char]  -- ^ A multigraph: for instance @GMulti "a", GMulti "ch", GMulti "c̓" :: t'Grapheme'@.+    | GBoundary      -- ^ A non-letter element representing a word boundary which sound changes can manipulate+    deriving (Eq, Ord, Show, Generic, NFData)  -- | A word (or a subsequence of one) can be viewed as a list of -- @Grapheme@s: e.g. Portuguese "filha" becomes@@ -73,6 +81,24 @@ -- with @Prelude.'Prelude.Word'@.) type PWord = [Grapheme] +-- Add a 'GBoundary' at the beginning and end of the 'PWord'.+addBoundaries :: PWord -> PWord+addBoundaries w = GBoundary : w ++ [GBoundary]++-- Remove 'GBoundary's from the beginning and end of the 'PWord'.+removeBoundaries :: PWord -> PWord+removeBoundaries = dropWhile (==GBoundary) . dropWhileEnd (==GBoundary)++-- | Render a 'PWord' as a 'String'. Very much like 'concat', but+-- treating 'GBoundary's specially. Word-external boundaries are+-- deleted, while word-internal boundaries are converted to @"#"@.+concatWithBoundary :: PWord -> String+concatWithBoundary = go . removeBoundaries+  where+    go = concatMap $ \case+        GMulti g -> g+        GBoundary -> "#"+ -- | The part of a 'Rule' in which a 'Lexeme' may occur: either the -- target, the replacement or the environment. data LexemeType = Target | Replacement | Env@@ -82,12 +108,11 @@ -- variable specifies where each different variety of 'Lexeme' may -- occur. data Lexeme (a :: LexemeType) where-    -- | In Brassica sound-change syntax, one or more letters without intervening whitespace+    -- | In Brassica sound-change syntax, one or more letters without intervening whitespace,+    -- or a word boundary specified as @#@     Grapheme :: Grapheme -> Lexeme a     -- | In Brassica sound-change syntax, delimited by square brackets-    Category :: [CategoryElement a] -> Lexeme a-    -- | In Brassica sound-change syntax, specified as @#@-    Boundary :: Lexeme 'Env+    Category :: [Grapheme] -> Lexeme a     -- | In Brassica sound-change syntax, delimited by parentheses     Optional :: [Lexeme a] -> Lexeme a     -- | In Brassica sound-change syntax, specified as @\@@@ -100,33 +125,28 @@     Kleene   :: OneOf a 'Target 'Env => Lexeme a -> Lexeme a     -- | In Brassica sound-change syntax, specified as @~@     Discard  :: Lexeme 'Replacement+    -- | In Brassica sound-change syntax, specified as \@i before a category+    Backreference :: OneOf a 'Target 'Replacement => Int -> [Grapheme] -> Lexeme a+    -- | In Brassica sound-change syntax, specified as \@? before a category+    Multiple :: [Grapheme] -> Lexeme 'Replacement +-- | A 'Lexeme' matching a single word boundary, specified as @#@ in Brassica syntax.+pattern Boundary :: Lexeme a+pattern Boundary = Grapheme GBoundary+ deriving instance Show (Lexeme a)  instance NFData (Lexeme a) where     rnf (Grapheme g) = rnf g     rnf (Category cs) = rnf cs-    rnf Boundary = ()     rnf (Optional ls) = rnf ls     rnf Metathesis = ()     rnf Geminate = ()     rnf (Wildcard l) = rnf l     rnf (Kleene l) = rnf l     rnf Discard = ()---- | The elements allowed in a 'Category': currently, only--- t'Grapheme's and word boundaries.-data CategoryElement (a :: LexemeType) where-    GraphemeEl :: Grapheme -> CategoryElement a-    BoundaryEl :: CategoryElement 'Env--deriving instance Show (CategoryElement a)-deriving instance Eq (CategoryElement a)-deriving instance Ord (CategoryElement a)--instance NFData (CategoryElement a) where-    rnf (GraphemeEl a) = rnf a-    rnf BoundaryEl = ()+    rnf (Backreference i l) = seq i $ rnf l+    rnf (Multiple l) = rnf l  -- | An 'Environment' is a tuple of @(before, after)@ components, -- corresponding to a ‘/ before _ after’ component of a sound change.@@ -170,12 +190,12 @@     }  -- | A single sound change rule: in Brassica sound-change syntax with all elements specified,--- @-flags target / replacement \/ environment \/ exception@.+-- @-flags target / replacement \/ environment1 | environment2 | … \/ exception@. -- (And usually the 'plaintext' of the rule will contain a 'String' resembling that pattern.) data Rule = Rule   { target      :: [Lexeme 'Target]   , replacement :: [Lexeme 'Replacement]-  , environment :: Environment+  , environment :: [Environment]   , exception   :: Maybe Environment   , flags       :: Flags   , plaintext   :: String