packages feed

brassica 0.3.0 → 1.0.0

raw patch · 25 files changed

+1841/−753 lines, 25 filesdep +pandocdep +pandoc-typesdep ~aesondep ~bytestringdep ~textPVP ok

version bump matches the API change (PVP)

Dependencies added: pandoc, pandoc-types

Dependency ranges changed: aeson, bytestring, text, transformers

API changes (from Hackage documentation)

- Brassica.Paradigm: errorBundlePretty :: (VisualStream s, TraversableStream s, ShowErrorComponent e) => ParseErrorBundle s e -> String
- Brassica.SoundChange.Apply: PWordLog :: PWord -> [(Maybe PWord, r)] -> PWordLog r
- Brassica.SoundChange.Apply: [initialWord] :: PWordLog r -> PWord
- Brassica.SoundChange.Apply: applyChangesWithChanges :: SoundChanges Expanded [Grapheme] -> PWord -> [(Maybe PWord, Bool)]
- Brassica.SoundChange.Apply: applyChangesWithLogs :: SoundChanges Expanded [Grapheme] -> PWord -> [PWordLog (Statement Expanded [Grapheme])]
- Brassica.SoundChange.Apply: data PWordLog r
- Brassica.SoundChange.Apply.Internal: PWordLog :: PWord -> [(Maybe PWord, r)] -> PWordLog r
- Brassica.SoundChange.Apply.Internal: [action] :: LogItem r -> r
- Brassica.SoundChange.Apply.Internal: [initialWord] :: PWordLog r -> PWord
- Brassica.SoundChange.Apply.Internal: [input] :: LogItem r -> PWord
- Brassica.SoundChange.Apply.Internal: [output] :: LogItem r -> Maybe PWord
- Brassica.SoundChange.Apply.Internal: applyChangesWithChanges :: SoundChanges Expanded [Grapheme] -> PWord -> [(Maybe PWord, Bool)]
- Brassica.SoundChange.Apply.Internal: applyChangesWithLog :: SoundChanges Expanded [Grapheme] -> PWord -> [[LogItem (Statement Expanded [Grapheme])]]
- Brassica.SoundChange.Apply.Internal: applyChangesWithLogs :: SoundChanges Expanded [Grapheme] -> PWord -> [PWordLog (Statement Expanded [Grapheme])]
- Brassica.SoundChange.Apply.Internal: applyRule :: Rule Expanded -> MultiZipper RuleTag Grapheme -> [MultiZipper RuleTag Grapheme]
- Brassica.SoundChange.Apply.Internal: applyStatementWithLog :: Statement Expanded [Grapheme] -> PWord -> [LogItem (Statement Expanded [Grapheme])]
- Brassica.SoundChange.Apply.Internal: data PWordLog r
- Brassica.SoundChange.Apply.Internal: instance Control.DeepSeq.NFData r => Control.DeepSeq.NFData (Brassica.SoundChange.Apply.Internal.PWordLog r)
- Brassica.SoundChange.Apply.Internal: instance GHC.Base.Functor Brassica.SoundChange.Apply.Internal.PWordLog
- Brassica.SoundChange.Apply.Internal: instance GHC.Base.Semigroup Brassica.SoundChange.Apply.Internal.MatchOutput
- Brassica.SoundChange.Apply.Internal: instance GHC.Generics.Generic (Brassica.SoundChange.Apply.Internal.PWordLog r)
- Brassica.SoundChange.Apply.Internal: instance GHC.Show.Show r => GHC.Show.Show (Brassica.SoundChange.Apply.Internal.PWordLog r)
- Brassica.SoundChange.Apply.Internal: matchMany' :: Maybe Grapheme -> [Lexeme Expanded 'Matched] -> MultiZipper t Grapheme -> [(MatchOutput, MultiZipper t Grapheme)]
- Brassica.SoundChange.Apply.Internal: toPWordLog :: [LogItem r] -> Maybe (PWordLog r)
- Brassica.SoundChange.Category: InvalidBaseValue :: ExpandError
- Brassica.SoundChange.Category: MismatchedLengths :: ExpandError
- Brassica.SoundChange.Category: NotFound :: String -> ExpandError
- Brassica.SoundChange.Category: data ExpandError
- Brassica.SoundChange.Category: expand :: Categories -> CategorySpec a -> Either ExpandError (Expanded a)
- Brassica.SoundChange.Category: expandRule :: Categories -> Rule CategorySpec -> Either ExpandError (Rule Expanded)
- Brassica.SoundChange.Category: expandSoundChanges :: SoundChanges CategorySpec Directive -> Either ExpandError (SoundChanges Expanded [Grapheme])
- Brassica.SoundChange.Category: extendCategories :: Categories -> (Bool, [CategoryDefinition]) -> Either ExpandError Categories
- Brassica.SoundChange.Category: instance Control.DeepSeq.NFData Brassica.SoundChange.Category.ExpandError
- Brassica.SoundChange.Category: instance GHC.Generics.Generic Brassica.SoundChange.Category.ExpandError
- Brassica.SoundChange.Category: instance GHC.Show.Show Brassica.SoundChange.Category.ExpandError
- Brassica.SoundChange.Category: lookup :: String -> Categories -> Maybe (Expanded a)
- Brassica.SoundChange.Category: type Categories = Map String (Expanded 'AnyPart)
- Brassica.SoundChange.Category: values :: Categories -> [Either Grapheme [Lexeme Expanded 'AnyPart]]
- Brassica.SoundChange.Frontend.Internal: componentise :: OutputMode -> [a] -> [Component a] -> [Component a]
- Brassica.SoundChange.Frontend.Internal: intersperseWords :: [a] -> [Component a] -> [Component a]
- Brassica.SoundChange.Parse: errorBundlePretty :: (VisualStream s, TraversableStream s, ShowErrorComponent e) => ParseErrorBundle s e -> String
- Brassica.SoundChange.Tokenise: concatWithBoundary :: PWord -> String
- Brassica.SoundChange.Types: DirectiveS :: decl -> Statement c decl
- Brassica.SoundChange.Types: GBoundary :: Grapheme
- Brassica.SoundChange.Types: GMulti :: [Char] -> Grapheme
- Brassica.SoundChange.Types: data Grapheme
- Brassica.SoundChange.Types: instance Control.DeepSeq.NFData Brassica.SoundChange.Types.Grapheme
- Brassica.SoundChange.Types: instance Data.String.IsString 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 c a
+ Brassica.SoundChange.Apply: ActionApplied :: r -> Maybe PWord -> LogItem r
+ Brassica.SoundChange.Apply: Log :: PWord -> [LogItem r] -> Log r
+ Brassica.SoundChange.Apply: ReportWord :: PWord -> LogItem r
+ Brassica.SoundChange.Apply: [inputWord] :: Log r -> PWord
+ Brassica.SoundChange.Apply: data Log r
+ Brassica.SoundChange.Apply: data LogItem r
+ Brassica.SoundChange.Apply: getChangedOutputs :: Log (Statement c d) -> Maybe (PWord, Bool)
+ Brassica.SoundChange.Apply: getChangedReports :: Log (Statement c d) -> [(PWord, Bool)]
+ Brassica.SoundChange.Apply: getOutput :: Log r -> Maybe PWord
+ Brassica.SoundChange.Apply: getReports :: Log r -> [PWord]
+ Brassica.SoundChange.Apply.Internal: Indeterminate :: FeatureState
+ Brassica.SoundChange.Apply.Internal: Index :: Int -> FeatureState
+ Brassica.SoundChange.Apply.Internal: Log :: PWord -> [LogItem r] -> Log r
+ Brassica.SoundChange.Apply.Internal: PrevEnd :: RuleTag
+ Brassica.SoundChange.Apply.Internal: ReportWord :: PWord -> LogItem r
+ Brassica.SoundChange.Apply.Internal: [inputWord] :: Log r -> PWord
+ Brassica.SoundChange.Apply.Internal: [matchedBackrefIds] :: MatchOutput -> Map String Int
+ Brassica.SoundChange.Apply.Internal: [matchedFeatureIds] :: MatchOutput -> Map String FeatureState
+ Brassica.SoundChange.Apply.Internal: [matchedFeatures] :: MatchOutput -> Map String [FeatureState]
+ Brassica.SoundChange.Apply.Internal: applyRuleMZ :: Rule Expanded -> MultiZipper RuleTag Grapheme -> [MultiZipper RuleTag Grapheme]
+ Brassica.SoundChange.Apply.Internal: applyStatementMZ :: Statement Expanded GraphemeList -> MultiZipper RuleTag Grapheme -> [MultiZipper RuleTag Grapheme]
+ Brassica.SoundChange.Apply.Internal: data FeatureState
+ Brassica.SoundChange.Apply.Internal: data Log r
+ Brassica.SoundChange.Apply.Internal: getChangedOutputs :: Log (Statement c d) -> Maybe (PWord, Bool)
+ Brassica.SoundChange.Apply.Internal: getChangedReports :: Log (Statement c d) -> [(PWord, Bool)]
+ Brassica.SoundChange.Apply.Internal: getOutput :: Log r -> Maybe PWord
+ Brassica.SoundChange.Apply.Internal: getReports :: Log r -> [PWord]
+ Brassica.SoundChange.Apply.Internal: initialOutput :: MatchOutput
+ Brassica.SoundChange.Apply.Internal: instance Control.DeepSeq.NFData r => Control.DeepSeq.NFData (Brassica.SoundChange.Apply.Internal.Log r)
+ Brassica.SoundChange.Apply.Internal: instance GHC.Base.Functor Brassica.SoundChange.Apply.Internal.Log
+ Brassica.SoundChange.Apply.Internal: instance GHC.Classes.Eq Brassica.SoundChange.Apply.Internal.FeatureState
+ Brassica.SoundChange.Apply.Internal: instance GHC.Generics.Generic (Brassica.SoundChange.Apply.Internal.Log r)
+ Brassica.SoundChange.Apply.Internal: instance GHC.Show.Show Brassica.SoundChange.Apply.Internal.FeatureState
+ Brassica.SoundChange.Apply.Internal: instance GHC.Show.Show r => GHC.Show.Show (Brassica.SoundChange.Apply.Internal.Log r)
+ Brassica.SoundChange.Apply.Internal: newOutput :: MatchOutput -> MatchOutput
+ Brassica.SoundChange.Apply.Internal: setupForNextApplication :: RuleStatus -> Rule Expanded -> MultiZipper RuleTag Grapheme -> Maybe (MultiZipper RuleTag Grapheme)
+ Brassica.SoundChange.Apply.Internal.MultiZipper: reverseMZ :: MultiZipper t a -> MultiZipper t a
+ Brassica.SoundChange.Expand: AutosegmentDef :: String -> [String] -> AutosegmentDef
+ Brassica.SoundChange.Expand: InvalidBaseValue :: ExpandError
+ Brassica.SoundChange.Expand: InvalidDerivedValue :: ExpandError
+ Brassica.SoundChange.Expand: MismatchedLengths :: ExpandError
+ Brassica.SoundChange.Expand: NotFound :: String -> ExpandError
+ Brassica.SoundChange.Expand: [autoFeature] :: AutosegmentDef -> String
+ Brassica.SoundChange.Expand: [autoGraphemes] :: AutosegmentDef -> [String]
+ Brassica.SoundChange.Expand: data AutosegmentDef
+ Brassica.SoundChange.Expand: data ExpandError
+ Brassica.SoundChange.Expand: expand :: Categories -> CategorySpec a -> Either ExpandError (Expanded a)
+ Brassica.SoundChange.Expand: expandRule :: Categories -> Rule CategorySpec -> Either ExpandError (Rule Expanded)
+ Brassica.SoundChange.Expand: expandSoundChanges :: SoundChanges CategorySpec Directive -> Either ExpandError (SoundChanges Expanded GraphemeList)
+ Brassica.SoundChange.Expand: extendCategories :: Categories -> (Bool, [CategoryDefinition]) -> Either ExpandError Categories
+ Brassica.SoundChange.Expand: instance Control.DeepSeq.NFData Brassica.SoundChange.Expand.ExpandError
+ Brassica.SoundChange.Expand: instance GHC.Classes.Eq Brassica.SoundChange.Expand.AutosegmentDef
+ Brassica.SoundChange.Expand: instance GHC.Generics.Generic Brassica.SoundChange.Expand.ExpandError
+ Brassica.SoundChange.Expand: instance GHC.Show.Show Brassica.SoundChange.Expand.AutosegmentDef
+ Brassica.SoundChange.Expand: instance GHC.Show.Show Brassica.SoundChange.Expand.ExpandError
+ Brassica.SoundChange.Expand: lookup :: String -> Categories -> Maybe (Either (Expanded a) AutosegmentDef)
+ Brassica.SoundChange.Expand: type Categories = Map String (Either (Expanded 'AnyPart) AutosegmentDef)
+ Brassica.SoundChange.Expand: values :: Categories -> [[Lexeme Expanded 'AnyPart]]
+ Brassica.SoundChange.Frontend.Internal: Alternate :: MDFHierarchy
+ Brassica.SoundChange.Frontend.Internal: Standard :: MDFHierarchy
+ Brassica.SoundChange.Frontend.Internal: WordsWithProtoOutputPreserve :: OutputMode
+ Brassica.SoundChange.Frontend.Internal: data MDFHierarchy
+ Brassica.SoundChange.Frontend.Internal: instance GHC.Classes.Eq Brassica.SoundChange.Frontend.Internal.MDFHierarchy
+ Brassica.SoundChange.Frontend.Internal: instance GHC.Show.Show Brassica.SoundChange.Frontend.Internal.MDFHierarchy
+ Brassica.SoundChange.Tokenise: joinComponents :: [Component [Component a]] -> [Component a]
+ Brassica.SoundChange.Types: DeclS :: decl -> Statement c decl
+ Brassica.SoundChange.Types: DefineAuto :: String -> CategoryDefinition
+ Brassica.SoundChange.Types: GraphemeList :: Bool -> [Grapheme] -> GraphemeList
+ Brassica.SoundChange.Types: ReportS :: Statement c decl
+ Brassica.SoundChange.Types: [Autosegment] :: Grapheme -> [[(Grapheme, Bool)]] -> [Grapheme] -> Lexeme category a
+ Brassica.SoundChange.Types: [Feature] :: Bool -> String -> Maybe String -> [[Grapheme]] -> Lexeme category a -> Lexeme category a
+ Brassica.SoundChange.Types: [GreedyCategory] :: category 'Matched -> Lexeme category 'Matched
+ Brassica.SoundChange.Types: [GreedyOptional] :: [Lexeme category 'Matched] -> Lexeme category 'Matched
+ Brassica.SoundChange.Types: [nonOverlappingTarget] :: Flags -> Bool
+ Brassica.SoundChange.Types: data GraphemeList
+ Brassica.SoundChange.Types: instance Control.DeepSeq.NFData Brassica.SoundChange.Types.GraphemeList
+ Brassica.SoundChange.Types: instance GHC.Classes.Eq Brassica.SoundChange.Types.GraphemeList
+ Brassica.SoundChange.Types: instance GHC.Classes.Ord Brassica.SoundChange.Types.GraphemeList
+ Brassica.SoundChange.Types: instance GHC.Generics.Generic Brassica.SoundChange.Types.GraphemeList
+ Brassica.SoundChange.Types: instance GHC.Show.Show Brassica.SoundChange.Types.GraphemeList
+ Brassica.SoundChange.Types: type CategoryElement category a = [Lexeme category a]
+ Brassica.SoundChange.Types: type Grapheme = [Char]
- Brassica.SoundChange.Apply: [derivations] :: PWordLog r -> [(Maybe PWord, r)]
+ Brassica.SoundChange.Apply: [derivations] :: Log r -> [LogItem r]
- Brassica.SoundChange.Apply: applyChanges :: SoundChanges Expanded [Grapheme] -> PWord -> [PWord]
+ Brassica.SoundChange.Apply: applyChanges :: SoundChanges Expanded GraphemeList -> PWord -> [Log (Statement Expanded GraphemeList)]
- Brassica.SoundChange.Apply: applyStatementStr :: Statement Expanded [Grapheme] -> PWord -> [PWord]
+ Brassica.SoundChange.Apply: applyStatementStr :: Statement Expanded GraphemeList -> PWord -> [PWord]
- Brassica.SoundChange.Apply: reportAsHtmlRows :: (r -> String) -> PWordLog r -> String
+ Brassica.SoundChange.Apply: reportAsHtmlRows :: (r -> String) -> Log r -> String
- Brassica.SoundChange.Apply: reportAsText :: (r -> String) -> PWordLog r -> String
+ Brassica.SoundChange.Apply: reportAsText :: (r -> String) -> Log r -> String
- Brassica.SoundChange.Apply.Internal: ActionApplied :: r -> PWord -> Maybe PWord -> LogItem r
+ Brassica.SoundChange.Apply.Internal: ActionApplied :: r -> Maybe PWord -> LogItem r
- Brassica.SoundChange.Apply.Internal: MatchOutput :: [Int] -> [Bool] -> [[Grapheme]] -> [Int] -> [Grapheme] -> MatchOutput
+ Brassica.SoundChange.Apply.Internal: MatchOutput :: [Int] -> [Bool] -> [[Grapheme]] -> [Int] -> [Grapheme] -> Map String [FeatureState] -> Map String Int -> Map String FeatureState -> MatchOutput
- Brassica.SoundChange.Apply.Internal: [derivations] :: PWordLog r -> [(Maybe PWord, r)]
+ Brassica.SoundChange.Apply.Internal: [derivations] :: Log r -> [LogItem r]
- Brassica.SoundChange.Apply.Internal: applyChanges :: SoundChanges Expanded [Grapheme] -> PWord -> [PWord]
+ Brassica.SoundChange.Apply.Internal: applyChanges :: SoundChanges Expanded GraphemeList -> PWord -> [Log (Statement Expanded GraphemeList)]
- Brassica.SoundChange.Apply.Internal: applyStatement :: Statement Expanded [Grapheme] -> MultiZipper RuleTag Grapheme -> [MultiZipper RuleTag Grapheme]
+ Brassica.SoundChange.Apply.Internal: applyStatement :: Statement Expanded GraphemeList -> PWord -> [LogItem (Statement Expanded GraphemeList)]
- Brassica.SoundChange.Apply.Internal: applyStatementStr :: Statement Expanded [Grapheme] -> PWord -> [PWord]
+ Brassica.SoundChange.Apply.Internal: applyStatementStr :: Statement Expanded GraphemeList -> PWord -> [PWord]
- Brassica.SoundChange.Apply.Internal: reportAsHtmlRows :: (r -> String) -> PWordLog r -> String
+ Brassica.SoundChange.Apply.Internal: reportAsHtmlRows :: (r -> String) -> Log r -> String
- Brassica.SoundChange.Apply.Internal: reportAsText :: (r -> String) -> PWordLog r -> String
+ Brassica.SoundChange.Apply.Internal: reportAsText :: (r -> String) -> Log r -> String
- Brassica.SoundChange.Frontend.Internal: AppliedRulesTable :: [PWordLog r] -> ApplicationOutput a r
+ Brassica.SoundChange.Frontend.Internal: AppliedRulesTable :: [Log r] -> ApplicationOutput a r
- Brassica.SoundChange.Frontend.Internal: MDF :: InputLexiconFormat
+ Brassica.SoundChange.Frontend.Internal: MDF :: MDFHierarchy -> InputLexiconFormat
- Brassica.SoundChange.Frontend.Internal: parseTokeniseAndApplyRules :: (forall a b. (a -> b) -> [Component a] -> [Component b]) -> SoundChanges Expanded [Grapheme] -> String -> InputLexiconFormat -> ApplicationMode -> Maybe [Component PWord] -> ApplicationOutput PWord (Statement Expanded [Grapheme])
+ Brassica.SoundChange.Frontend.Internal: parseTokeniseAndApplyRules :: (forall a b. (a -> b) -> [Component a] -> [Component b]) -> SoundChanges Expanded GraphemeList -> String -> InputLexiconFormat -> ApplicationMode -> Maybe [Component PWord] -> ApplicationOutput PWord (Statement Expanded GraphemeList)
- Brassica.SoundChange.Frontend.Internal: tokeniseAccordingToInputFormat :: InputLexiconFormat -> OutputMode -> SoundChanges Expanded [Grapheme] -> String -> Either (ParseErrorBundle String Void) [Component PWord]
+ Brassica.SoundChange.Frontend.Internal: tokeniseAccordingToInputFormat :: InputLexiconFormat -> OutputMode -> SoundChanges Expanded GraphemeList -> String -> Either (ParseErrorBundle String Void) [Component PWord]
- Brassica.SoundChange.Tokenise: findFirstCategoriesDecl :: SoundChanges c [Grapheme] -> [String]
+ Brassica.SoundChange.Tokenise: findFirstCategoriesDecl :: SoundChanges c GraphemeList -> [String]
- Brassica.SoundChange.Tokenise: withFirstCategoriesDecl :: ([String] -> t) -> SoundChanges c [Grapheme] -> t
+ Brassica.SoundChange.Tokenise: withFirstCategoriesDecl :: ([String] -> t) -> SoundChanges c GraphemeList -> t
- Brassica.SoundChange.Types: CategorySpec :: [(CategoryModification, Either Grapheme [Lexeme CategorySpec a])] -> CategorySpec a
+ Brassica.SoundChange.Types: CategorySpec :: [(CategoryModification, CategoryElement CategorySpec a)] -> CategorySpec a
- Brassica.SoundChange.Types: Flags :: Bool -> Direction -> Bool -> Sporadicity -> Flags
+ Brassica.SoundChange.Types: Flags :: Bool -> Direction -> Bool -> Sporadicity -> Bool -> Flags
- Brassica.SoundChange.Types: FromElements :: [Either Grapheme [Lexeme Expanded a]] -> Expanded a
+ Brassica.SoundChange.Types: FromElements :: [CategoryElement Expanded a] -> Expanded a
- Brassica.SoundChange.Types: [Backreference] :: Int -> category a -> Lexeme category a
+ Brassica.SoundChange.Types: [Backreference] :: Either String Int -> category a -> Lexeme category a
- Brassica.SoundChange.Types: [elements] :: Expanded a -> [Either Grapheme [Lexeme Expanded a]]
+ Brassica.SoundChange.Types: [elements] :: Expanded a -> [CategoryElement Expanded a]

Files

ChangeLog.md view
@@ -1,5 +1,127 @@ # Brassica changelog +## 1.0.0++### Behaviour++(Note: breaking changes are boldfaced.)++- Bugfixes:+  - Etymologies are now added in all situations when processing an MDF file+  - Desktop version correctly warns about unsaved changes when opening a file+  - Rules such as `[p f] / [f h] / (a) _ #` are no longer applied twice to the same grapheme+      (as a consequence of changes to rule application, see below)+  - `noreplace` categories now tokenise multigraphs as expected+- Documentation (user guides and Haddocks) have been comprensively rewritten+- ‘Tonogenesis’ example has been replaced by ‘Thai’ example+- New support for phonetic features:+  - Features can be written as maximal `$name#ident(values)` after another lexeme+  - Categories named `+Feature`, `-Feature` and `+Feature+Value`+      get special interpretation with intersections and subtractions+  - New category syntax: `&` before category name forces union interpretation+      even for category names beginning with `+`/`-`+  - Change to category syntax: first element of category is always interpreted as union+      (**technically a breaking change**, but unlikely in existing sound changes)+  - Syntax `&Feature` after set operation (`&`/`+`/`-`) to include both `-Feature` and `+Feature`+      (i.e. all graphemes with a setting for that feature)+  - Declaration `auto <FeaturalCategory>` in category definition block+      can be used to treat said feature autosegmentally+      whenever a grapheme in the selected category is mentioned+- Other changes to sound change syntax:+  - `report` directive allows for reporting intermediate results in input→output mode+  - Graphemes followed by `~` are now allowed inside a category block,+      but **disallowed in category names and in `extra` declarations** (where they would be meaningless)+  - **Category intersection now produces a category with graphemes+      in the same order as the last category mentioned, rather than the first**+  - Optional elements or categories can now be prefixed by `%` to make them match greedily+  - New backreference syntax `@#id` allows categories to be matched by ID rather than number+  - `extra` graphemes are now always taken into account for multigraph tokenisation+      (**technically a breaking change**, but unlikely in existing sound changes)+  - Within a category, single graphemes inside braces are now treated the same as single graphemes outside braces+      (**technically a breaking change**, but unlikely to change existing sound changes)+- Changes to rule application algorithm:+  - **Sound change applications can now overlap,+      such that the replacement from one application can be used as the environment for the next**+  - New flag `-no` allows for reverting to the previous behaviour (non-overlapping applications)+  - **Make RTL and LTR application symmetric,+      by reimplementing the former as LTR application with words and rules reversed:+      consequently category correspondences, backreferences etc. are also reversed**+- Improvements to graphical interface:+  - Whitespace is now preserved when displaying sound change output on desktop and web+  - New keyboard shortcuts in desktop GUI:+      Ctrl+Enter to apply rules, Ctrl+Tab to toggle between rules and words textboxes+  - Some labels have been changed to be more descriptive:+    ‘Different to input’ to ‘Any rule applied’, and ‘Wordlist’ to ‘Wordlist + glosses’+  - Improvements to output from ‘Report rules apply’: most importantly,+    comments are no longer included next to rules, and+    outputs are no longer aligned across input words (greatly increasing responsiveness on desktop)+  - User can now choose which MDF hierarchy to use for dictionaries+  - Web version brought closer to parity with desktop version+      by adding MDF support and an option to ‘synchronise scroll positions’+  - When ‘view results live’ is selected, results are updated on changes to all controls,+      not just changes to words or sound changes+  - Add ‘Edit’ menu to desktop GUI, including ‘Find’ dialog box+  - Add ‘Help’ menu to desktop GUI+  - Fields of web GUI can now be initialised using URL query parameters+    (`r` for rules, `w` for words)+  - Add ‘select all’ buttons to web GUI+  - Improve display of web application controls on small screens+  - Sound changes editor on web version has been rewritten to work around bug with combining diacritics+  - Add option for ‘input→output’ display format to include glosses and whitespace++### Code++- CMakeLists for desktop GUI now track Haskell binary dependency correctly+- Renamed `Brassica.SoundChange.Category` to `Brassica.SoundChange.Expand`+- Renamed constructor `Brassica.SoundChange.Types.DirectiveS` to `Brassica.SoundChange.Types.DeclS` +- Changes resulting from addition of `report`:+  - New constructor `ReportS` added to `Brassica.SoundChange.Types.Statement`+  - A constructor `ReportWord` has been added to `Brassica.SoundChange.Apply.Internal.LogItem`,+      while its former record fields `input` and `output` have been replaced with functions+      `logInput` and `logOutput`.+  - `Brassica.SoundChange.Frontend.Internal` has been restructured to allow display of intermediate results+  - New function `Brassica.SoundChange.Tokenise.joinComponents` added to assist with said restructure+- Changes resulting from addition of features:+  - New constructors `Feature` and `Autosegment` added to `Brassica.SoundChange.Types.Lexeme`+    (the latter primarily for internal use)+  - New type `Brassica.SoundChange.Apply.Internal`+  - New constructor `DefineAuto` added to `Brassica.SoundChange.Types.CategoryDefinition`+  - Changes in category expansion:+      new type `Brassica.SoundChange.Expand.AutosegmentDef`,+      and new constructor `InvalidDerivedValue`+        added to `Brassica.SoundChange.Expand.ExpandError`+- Changes resulting from new rule application algorithm:+  - New constructore `PrevEnd` added to `Brassica.SoundChange.Apply.Internal.RuleTag`+  - New field `nonOverlappingTarget` added to `Brassica.SoundChange.Types.Flags`+- New constructors `GreedyOptional` and `GreedyCategory` for `Brassica.SoundChange.Types.Lexeme`+- `Brassica.SoundChange.Frontend.Internal.InputLexiconFormat`+    now depends on new type `Brassica.SoundChange.Frontend.Internal.MDFHierarchy`+- Word boundaries are now simply ordinary graphemes `"#"`:+    `Brassica.SoundChange.Types.Grapheme` is now a type synonym for `[Char]`+- Expansion now results in sound changes with declaration type `GraphemesList`+    rather than `[Grapheme]`+- API of `Brassica.SoundChanges.Apply` (and `Brassica.SoundChanges.Apply.Internal`) has been refactored:+  - `LogItem` (now re-exported from `Brassica.SoundChanges.Apply`)+      no longer stores redundant information on input words+  - `PWordLog` is renamed `Log`, and is now based on `LogItem`+  - `initialWord` is now renamed `inputWord`+  - Former functions `applyRule` and `applyStatement`+      have been renamed to `applyRuleMZ` and `applyStatementMZ`;+  - Former functions `applyRuleWithLogs` and `applyStatementWithLog`+      have been renamed to `applyRule` and `applyStatement`+  - Function `applyRuleWithLog` has been removed+  - Set of sound change application functions `applyChanges*`+      has been replaced with a single function `applyChanges` returning a `PWordLog`,+      plus a set of functions to extract various outputs from it+- New constructor `WordsWithProtoOutputPreserve` for `Brassica.SoundChange.Frontend.Internal.OutputMode`+- Remove now-unnecessary pattern `Brassica.SoundChange.Types.Boundary`+- In parsing modules, re-export whole module `Text.Megaparsec.Error`+    rather than only re-exporting the single function `errorBundlePretty`+- Category elements are now represented as simply `[Lexeme category a]`+    (synonym `Brassica.SoundChanges.Types.CategoryElement`),+    without any special case for single-grapheme elements+- `concatWithBoundary` is now no longer re-exported from `Brassica.SoundChange.Tokenise`+ ## 0.3.0  ### Behaviour
README.md view
@@ -2,27 +2,27 @@  [![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:+Brassica is a sound change applier: a program to simulate the evolution of language over time. -- Can be used interactively both [online](https://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, between words, and in many more ways-- Live preview and control over output highlighting let you try out sound changes quickly and easily-- Highlight and visualise results in numerous ways-- Category operations allow phonetic rules to be written in both featural and character-based ways-- Support for ‘features’ lets rules easily manipulate stress, tone and other suprasegmentals+Brassica’s features include:++- A choice of four different platforms: you can use Brassica interactively [online](https://bradrn.com/brassica/index.html) or as a standalone application,+    non-interactively on the command-line, or as a [Haskell library](https://hackage.haskell.org/package/brassica)+- Being well-tested, including several automated test suites, as well as example files with sound changes from natural languages+- First-class support for suprasegmental features, such as stress and tone+- Easy control over rule application: apply sound changes iteratively, sporadically, right-to-left, between words, and in many more ways+- Native support for the MDF dictionary format, also used by tools including [SIL Toolbox](https://software.sil.org/toolbox/) and [Lexique Pro](https://software.sil.org/lexiquepro/)+- A comprehensive user interface with live preview and highlighting for both sound changes and output - Comes with a [paradigm builder](https://bradrn.com/brassica/builder.html) for quickly investigating inflectional and other patterns-- Rich syntax for specifying phonetic rules, including wildcards, optional elements and more  And many more! -See the [documentation](./Documentation.md) for details on Brassica usage.+See the [documentation](./docs) for details on Brassica usage,+  and the [example files](./examples) for examples of practical usage.  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 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.1.0/gui-interface-example.png)+![Image of Brassica with some example sound changes](./docs/screenshot.png)
bench/Changes.hs view
@@ -60,22 +60,24 @@ 
     complex = Rule
         { target =
-            [ Category $ FromElements $ Left <$> ["t", "d", "n"]
+            [ Category $ FromElements $ toElem <$> ["t", "d", "n"]
             , Optional [Grapheme "y"]
-            , Category $ FromElements $ Left <$> ["i", "e"]
+            , Category $ FromElements $ toElem <$> ["i", "e"]
             ]
         , replacement =
-            [ Category $ FromElements $ Left <$> ["c", "j", "nh"]
+            [ Category $ FromElements $ toElem <$> ["c", "j", "nh"]
             , Optional [Geminate]
             ]
         , environment = pure
-            ( [Category $ FromElements $ Left <$> [GBoundary, "a", "e", "i"]]
-            , [Category $ FromElements $ Left <$> ["a", "e", "i", "o", "u"]]
+            ( [Category $ FromElements $ toElem <$> ["#", "a", "e", "i"]]
+            , [Category $ FromElements $ toElem <$> ["a", "e", "i", "o", "u"]]
             )
         , exception = Nothing
         , flags = defFlags
         , plaintext = "[t d n] (y) [i e] / [č j ñ] (>) / [# a e i] _ [a e i o u]"
         }
+
+    toElem g = [Grapheme g]
 
     benchChanges :: Rule Expanded -> PWord -> [Benchmark]
     benchChanges cs l =
brassica.cabal view
@@ -1,10 +1,13 @@ cabal-version:      2.0 name:               brassica-version:            0.3.0+version:            1.0.0 synopsis:           Featureful sound change applier description:-  The Brassica library for the simulation of sound changes in historical linguistics and language construction.-  For further details, please refer to the README below or at <https://github.com/bradrn/brassica#readme>.+  This is the Brassica library for the simulation of sound changes in historical linguistics and language construction.+  .+  For an introduction to library usage, see the documentation of [Brassica.SoundChange](https://hackage.haskell.org/package/brassica-1.0.0/candidate/docs/Brassica-SoundChange.html).+  .+  For further details on Brassica’s design and usage, please refer to the README below or at <https://github.com/bradrn/brassica/blob/v1.0.0#readme>.  category:           Linguistics homepage:           https://github.com/bradrn/brassica#readme@@ -28,7 +31,7 @@                  , Brassica.SoundChange.Apply                  , Brassica.SoundChange.Apply.Internal                  , Brassica.SoundChange.Apply.Internal.MultiZipper-                 , Brassica.SoundChange.Category+                 , Brassica.SoundChange.Expand                  , Brassica.SoundChange.Frontend.Internal                  , Brassica.SoundChange.Parse                  , Brassica.SoundChange.Tokenise@@ -63,7 +66,7 @@   build-depends:                     base >=4.7 && <5                   , brassica-                  , aeson ^>=2.2+                  , aeson >=2.2 && <2.3                   , attoparsec-aeson ^>=2.2                   , bytestring >=0.10 && <0.13                   , conduit ^>=1.3@@ -71,7 +74,7 @@                   , deepseq >=1.4 && <1.6                   , optparse-applicative ^>=0.17.1 || ^>=0.18                   , parallel ^>= 3.2-                  , text >=1.2 && <2.2+                  , text >=1.2 && <2.3    default-language: Haskell2010 @@ -85,7 +88,7 @@                   , bytestring >=0.10 && <0.13                   , conduit ^>=1.3                   , optparse-applicative ^>=0.17.1 || ^>=0.18-                  , text >=1.2 && <2.2+                  , text >=1.2 && <2.3    default-language: Haskell2010 @@ -100,7 +103,7 @@                   , criterion >=1.5 && <1.7                   , file-embed >=0.0.15 && <0.0.16                   , parallel ^>= 3.2-                  , text >=1.2 && <1.3+                  , text >=1.2 && <2.3    default-language: Haskell2010 @@ -127,9 +130,25 @@                   , tasty ^>=1.4                   , tasty-golden ^>=2.3                   , bytestring-                  , text >=1.2 && <2.1-                  , transformers ^>=0.5+                  , text >=1.2 && <2.3+                  , transformers >=0.5 && <0.7                   , conduit ^>=1.3                   , utf8-string ^>=1.0++  default-language: Haskell2010++test-suite doctests+  type:             exitcode-stdio-1.0+  main-is:          DocTests.hs+  hs-source-dirs:   test+  ghc-options:      -threaded -rtsopts -with-rtsopts=-N -Wall+  build-depends:+                    base >=4.7 && <5+                  , brassica+                  , tasty ^>=1.4+                  , bytestring+                  , text >=1.2 && <2.3+                  , pandoc >=3.3 && <3.4+                  , pandoc-types >=1.23 && <1.24    default-language: Haskell2010
cli-pb/Main.hs view
@@ -34,7 +34,7 @@                 .| encodeUtf8C                 .| outC   where-    opts = info (args <**> helper <**> simpleVersioner "v0.3.0") fullDesc+    opts = info (args <**> helper <**> simpleVersioner "v1.0.0") fullDesc      args = Options         <$> asum
cli/Main.hs view
@@ -7,7 +7,7 @@  import Conduit import qualified Data.ByteString as B-import Data.Text (pack, unpack, snoc, Text)+import Data.Text (pack, unpack, snoc, unsnoc, Text) import Data.Text.Encoding (decodeUtf8) import Options.Applicative @@ -32,6 +32,7 @@                     Left err -> putStrLn $ case err of                         (NotFound s) -> "Could not find category: " ++ s                         InvalidBaseValue -> "Invalid value used as base grapheme in feature definition"+                        InvalidDerivedValue -> "Invalid value used as derived grapheme in autosegment"                         MismatchedLengths -> "Mismatched lengths in feature definition"                     Right rules' ->                         withSourceFileIf inWordsFile $ \inC ->@@ -42,7 +43,7 @@                             .| outC    where-    opts = info (args <**> helper <**> simpleVersioner "v0.3.0") fullDesc+    opts = info (args <**> helper <**> simpleVersioner "v1.0.0") fullDesc      args = batchArgs <|> serverArgs     serverArgs = flag' Server (long "server" <> help "Run server (for internal use only)")@@ -53,8 +54,12 @@             , FromFile <$> strArgument                 (metavar "RULES" <> help "File containing sound changes")             ]-        <*> flag Raw MDF-            (long "mdf" <> help "Parse input words in MDF format")+        <*> asum+            [ flag Raw (MDF Standard)+                (long "mdf" <> help "Parse input words in MDF format (standard hierarchy)")+            , flag Raw (MDF Alternate)+                (long "mdf-alt" <> help "Parse input words in MDF format (alternate hierarchy)")+            ]         <*> (asum                 [ flag' (const ReportRulesApplied)                     (long "report" <> help "Report rules applied rather than outputting words")@@ -79,7 +84,7 @@             (long "out" <> short 'o' <> help "File to which output words should be written (if not specified will write to stdout)"))      incrFor Raw = True-    incrFor MDF = False+    incrFor (MDF _) = False      -- duplicated in paradigm builder CLI     withSourceFileIf :: Maybe FilePath -> (ConduitM i B.ByteString IO () -> IO a) -> IO a@@ -104,14 +109,14 @@ processWords     :: (MonadIO m, MonadThrow m)     => Bool  -- split into lines?-    -> SoundChanges Expanded [Grapheme]+    -> SoundChanges Expanded GraphemeList     -> InputLexiconFormat     -> ApplicationMode     -> ConduitT B.ByteString B.ByteString m () processWords incr rules wordsFormat outMode =     decodeUtf8C     .| (if incr then linesUnboundedC else mapC id)-    .| mapC (processApplicationOutput . evolve . unpack . (`snoc` '\n'))+    .| mapC (processApplicationOutput . evolve . unpack)     .| throwOnLeft     .| encodeUtf8C   where@@ -122,10 +127,14 @@         Left e -> throwM e         Right r -> yield r -    processApplicationOutput :: ApplicationOutput PWord (Statement Expanded [Grapheme]) -> Either ParseException Text-    processApplicationOutput (HighlightedWords cs) = Right $ pack $ detokeniseWords' highlight cs+    processApplicationOutput :: ApplicationOutput PWord (Statement Expanded GraphemeList) -> Either ParseException Text+    processApplicationOutput (HighlightedWords cs) = Right $ ensureNewline $ pack $ detokeniseWords' highlight cs     processApplicationOutput (AppliedRulesTable is) = Right $ pack $ unlines $ reportAsText plaintext' <$> is     processApplicationOutput (ParseError e) = Left $ ParseException $ errorBundlePretty e++    ensureNewline t = case unsnoc t of+        Just (_, '\n') -> t+        _ -> snoc t '\n'      highlight (w, False) = concatWithBoundary w     highlight (w, True) = concatWithBoundary w ++ " [+]"
cli/Server.hs view
@@ -14,12 +14,14 @@ import Control.DeepSeq (force, NFData) import Control.Exception (evaluate) import Control.Parallel.Strategies (withStrategy, parTraversable, rseq)-import Data.Aeson (Result(..), encode, fromJSON)+import Data.Aeson (Result(..), encode, FromJSON(..), ToJSON(..), Value (..), fromJSON) import Data.Aeson.Parser (json') import Data.Aeson.TH (deriveJSON, defaultOptions, defaultTaggedObject, constructorTagModifier, sumEncoding, tagFieldName)+import Data.Aeson.Types (prependFailure, typeMismatch) import Data.ByteString (toStrict) import Data.Conduit.Attoparsec (conduitParser) import Data.Foldable (toList)+import Data.Text (unpack) import GHC.Generics (Generic) import System.IO (hSetBuffering, stdin, stdout, BufferMode(NoBuffering)) import System.Timeout@@ -57,9 +59,20 @@     | RespError String     deriving (Show, Generic, NFData) +instance ToJSON InputLexiconFormat where+    toJSON Raw = "Raw"+    toJSON (MDF Standard) = "MDFStandard"+    toJSON (MDF Alternate) = "MDFAlternate"++instance FromJSON InputLexiconFormat where+    parseJSON (String "Raw") = pure Raw+    parseJSON (String "MDFStandard") = pure $ MDF Standard+    parseJSON (String "MDFAlternate") = pure $ MDF Alternate+    parseJSON (String s) = fail $ "Unknown InputLexiconFormat: " ++ unpack s+    parseJSON invalid = prependFailure "parsing InputLexiconFormat failed: " $+        typeMismatch "String" invalid+ $(deriveJSON defaultOptions ''Component)-$(deriveJSON defaultOptions ''Grapheme)-$(deriveJSON defaultOptions ''InputLexiconFormat) $(deriveJSON defaultOptions ''HighlightMode) $(deriveJSON defaultOptions ''OutputMode) @@ -107,6 +120,7 @@                 Left err -> RespError $ ("<pre>"++) $ (++"</pre>") $ case err of                     (NotFound s) -> "Could not find category: " ++ s                     InvalidBaseValue -> "Invalid value used as base grapheme in feature definition"+                    InvalidDerivedValue -> "Invalid value used as derived grapheme in autosegment"                     MismatchedLengths -> "Mismatched lengths in feature definition"                 Right statements' ->                     let result' = parseTokeniseAndApplyRules parFmap statements' input inFmt mode prev@@ -117,7 +131,7 @@                             (Just $ (fmap.fmap) fst result)                             (escape $ detokeniseWords' highlightWord result)                         AppliedRulesTable items -> RespRules Nothing $-                            surroundTable $ concatMap (reportAsHtmlRows plaintext') items+                            concatMap (surroundTable . reportAsHtmlRows plaintext') items   where     highlightWord (s, False) = concatWithBoundary s     highlightWord (s, True) = "<b>" ++ concatWithBoundary s ++ "</b>"
src/Brassica/Paradigm.hs view
@@ -1,6 +1,15 @@+-- |+-- Module      : Brassica.Paradigm+-- Copyright   : See LICENSE file+-- License     : BSD3+-- Maintainer  : Brad Neimann+-- module Brassica.Paradigm        (-         Process(..)+       -- * Re-export+         module Text.Megaparsec.Error+       -- * Paradigm representation+       , Process(..)        , Affix        , Grammeme(..)        , AbstractGrammeme(..)@@ -9,16 +18,16 @@        , FeatureName(..)        , Statement(..)        , Paradigm-       , ResultsTree(..)-       , depth-       , applyParadigm+       -- * Parsing and pretty-printing        , parseParadigm        , formatNested-       -- ** Re-export-       , errorBundlePretty+       -- * Paradigm application+       , applyParadigm+       , ResultsTree(..)+       , depth        ) where  import Brassica.Paradigm.Apply import Brassica.Paradigm.Parse import Brassica.Paradigm.Types-import Text.Megaparsec (errorBundlePretty)+import Text.Megaparsec.Error
src/Brassica/Paradigm/Apply.hs view
@@ -16,6 +16,8 @@ import Data.Maybe (mapMaybe) import Data.Ord (Down(Down)) +-- | Results from applying a 'Paradigm' to a word, represented as a tree+-- containing each possible choice of 'Grammeme's. data ResultsTree a = Node [ResultsTree a] | Result a     deriving (Show, Functor, Foldable) @@ -23,6 +25,8 @@ addLevel f (Result r) = Node $ Result <$> f r addLevel f (Node rs) = Node $ addLevel f <$> rs +-- | Depth of a 'ResultsTree': i.e. how many levels of 'Node's it+-- contains. depth :: ResultsTree a -> Int depth (Node ts) = maximum $ (1+) . depth <$> ts depth (Result _) = 0
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.3.0/Documentation.md#paradigm-builder>.
+-- For details on the syntax, refer to <https://github.com/bradrn/brassica/blob/v1.0.0/docs/Paradigm-Builder.md>.
 parseParadigm :: String -> Either (ParseErrorBundle String Void) Paradigm
 parseParadigm = runParser (many statement) ""
src/Brassica/Paradigm/Types.hs view
@@ -34,14 +34,14 @@     deriving stock (Show, Eq)     deriving newtype (IsString) --- | A condition which must be satisfied before including a 'Feature'--- in a word. +-- | A condition which must be satisfied for a 'Feature' to be+-- included in a word. data Condition     = Always     -- ^ Condition which is always satisfied     | Is FeatureName Grammeme-    -- ^ Satisfied when the specified feature (identified by its name)-    -- has been assigned to the specified 'Grammeme'+    -- ^ Satisfied when the specified feature has been assigned to the+    -- specified 'Grammeme'     | Not FeatureName Grammeme     -- ^ Satisfied when the specified feature has /not/ been assigned     -- to the specified 'Grammeme'@@ -54,6 +54,7 @@ data Feature = Feature Condition (Maybe FeatureName) [Grammeme]     deriving (Show, Eq) +-- | Name to identify a specific 'Feature'. newtype FeatureName = FeatureName String     deriving stock (Show, Eq)     deriving newtype (IsString)@@ -64,9 +65,9 @@ data Statement = NewFeature Feature | NewMapping [AbstractGrammeme] Affix     deriving (Show, Eq) --- | A paradigm is specified as a list of 'Statement's. The list is--- basically big-endian, in that the slowest-varying feature should be--- listed first. (So if e.g. tense is listed first, then first all--- words of tense 1 are listed, next all words of tense 2 are listed,--- and so on.)+-- | A paradigm is specified as a list of 'Statement's. The order is+-- reflected in the output 'Brassica.Paradigm.Apply.ResultsTree': the+-- root has one child for each grammeme in the first 'Feature', with+-- each child then having one child for each grammeme in the second+-- 'Feature', and so on. type Paradigm = [Statement]
src/Brassica/SFM/MDF.hs view
@@ -1,10 +1,14 @@ {-# LANGUAGE NamedFieldPuns #-} -{-| This module contains types and functions for working with the MDF-  dictionary format, used by programs such as [SIL Toolbox](https://software.sil.org/toolbox/).-  For more on the MDF format, refer to e.g.-  [Coward & Grimes (2000), /Making Dictionaries: A guide to lexicography and the Multi-Dictionary Formatter/](http://downloads.sil.org/legacy/shoebox/MDF_2000.pdf).--}+-- |+-- Module      : Brassica.SFM.MDF+-- Copyright   : See LICENSE file+-- License     : BSD3+-- Maintainer  : Brad Neimann+--+-- This module contains types and functions for working with the MDF+-- dictionary format. For more on the MDF format, refer to e.g.+-- [Coward & Grimes (2000)](http://downloads.sil.org/legacy/shoebox/MDF_2000.pdf). module Brassica.SFM.MDF where  import Brassica.SFM.SFM@@ -71,7 +75,7 @@     , ("xr" , Regional)   , ("xv" , Vernacular)     ] --- | Standard MDF hierarchy: with @\lx@ > @\se@ > @\ps@ > @\sn@.+-- | Standard MDF hierarchy, with @\lx@ > @\se@ > @\ps@ > @\sn@. -- Intended for use with 'toTree'. mdfHierarchy :: Hierarchy mdfHierarchy = M.fromList@@ -98,7 +102,7 @@     , ("wr", "sn"), ("xe", "xv"), ("xn", "xv"), ("xr", "xv"), ("xv", "rf")     ] --- | Alternate MDF hierarchy: with @\lx@ > @\sn@ > @\se@ > @\ps@.+-- | Alternate MDF hierarchy, with @\lx@ > @\sn@ > @\se@ > @\ps@. -- Intended for use with 'toTree'. mdfAlternateHierarchy :: Hierarchy mdfAlternateHierarchy = M.fromList@@ -130,15 +134,20 @@ -- if using 'tokeniseWords'; everything else is treated as a -- 'Separator', so that it is not disturbed by operations such as rule -- application or rendering to text.+--+-- (This is a simple wrapper around 'tokeniseField'.) tokeniseMDF     :: [String]  -- ^ List of available multigraphs (as with 'tokeniseWord')     -> SFM -> Either (ParseErrorBundle String Void) [Component PWord] tokeniseMDF gs = fmap concat . traverse (tokeniseField gs) --- | Like 'tokeniseMDF', but for a single 'Field'.+-- | Like 'tokeniseMDF', but for a single 'Field' rather than a whole+-- SFM file. tokeniseField :: [String] -> Field -> Either (ParseErrorBundle String Void) [Component PWord] tokeniseField gs f = case M.lookup (fieldMarker f) fieldLangs of     Just Vernacular ->+        -- initialise megaparsec state with position starting at given+        -- field         let ps = initialPosState "" (fieldValue f)             s = State                 { stateInput = fieldValue f@@ -154,7 +163,7 @@      _ -> Right [Separator $ '\\' : fieldMarker f ++ fieldWhitespace f ++ fieldValue f] --- | Add etymological fields to an 'MDF' by duplicating the values in+-- | Add etymological fields to an MDF file by duplicating the values in -- @\lx@, @\se@ and @\ge@ fields. e.g.: -- -- > \lx kapa@@ -177,7 +186,11 @@ -- -- This can be helpful when applying sound changes to an MDF file: the -- vernacular words can be copied as etymologies, and then the sound--- changes can be applied leaving the etymologies as is.
+-- changes can be applied leaving the etymologies as is.+--+-- Note that the hierarchy must already be resolved before this+-- function can be used, as it depends on the tree structure to know+-- where the etymologies should be placed. duplicateEtymologies     :: (String -> String)     -- ^ Transformation to apply to etymologies, e.g. @('*':)@@@ -215,7 +228,7 @@             { fieldMarker = "et"             , fieldWhitespace = " "             , fieldSourcePos = Nothing-            , fieldValue = f $ trim lx+            , fieldValue = ensureNewline $ f $ trim lx             }         $ case gl of               Nothing -> []@@ -224,10 +237,12 @@                       { fieldMarker = "eg"                       , fieldWhitespace = " "                       , fieldSourcePos = Nothing-                      , fieldValue = gl'-                        -- no need to add newline here because 'gl'-                        -- should already have whitespace+                      , fieldValue = ensureNewline gl'                       } []                   ]      trim = dropWhile isSpace . dropWhileEnd isSpace++    ensureNewline s+        | last s == '\n' = s+        | otherwise = s ++ "\n"
src/Brassica/SFM/SFM.hs view
@@ -1,12 +1,17 @@ {-# LANGUAGE BlockArguments #-}
 {-# LANGUAGE LambdaCase #-}
 
-{-| This module implements basic support for the SIL Standard Format
-Marker (SFM) format, used by dictionary software such as
-[FieldWorks](https://software.sil.org/fieldworks/). This format forms
-the basis of standards such as Multi-Dictionary Formatter (MDF),
-implemented here in 'Brassica.SFM.MDF'.
--}
+-- |
+-- Module      : Brassica.SFM.SFM
+-- Copyright   : See LICENSE file
+-- License     : BSD3
+-- Maintainer  : Brad Neimann
+--
+-- This module implements basic support for the SIL Standard Format
+-- Marker (SFM) format, used by dictionary software such as
+-- [FieldWorks](https://software.sil.org/fieldworks/). This format forms
+-- the basis of standards such as Multi-Dictionary Formatter (MDF),
+-- implemented here in @Brassica.SFM.MDF@.
 module Brassica.SFM.SFM
        ( -- * Linear SFM documents
          Field(..)
@@ -41,6 +46,7 @@     -- ^ Whitespace after the field marker
     , fieldSourcePos :: Maybe SourcePos
     -- ^ Optionally, a Megaparsec 'SourcePos' marking the start of the value
+    -- (to enable further parsing)
     , fieldValue :: String
     -- ^ The value of the field, including all whitespace until the next marker
     } deriving (Show)
@@ -80,7 +86,7 @@     value <- parseFieldValue
     pure (marker, s, ps, value)
 
--- | Parse an SFM file to an 'SFM'.
+-- | Parse an SFM file to an 'SFM' value.
 parseSFM
     :: String  -- ^ Name of source file
     -> String  -- ^ Input SFM data to parse
@@ -108,7 +114,8 @@     deriving (Show)
 
 -- | The hierarchy underlying an SFM document, defined as a map from
--- field names to their parents. Unlisted fields are treated as roots.
+-- field names to their parents. Fields which are absent from the map
+-- are treated as roots.
 type Hierarchy = M.Map String String
 
 -- | Returns the full hierarchy of a marker, starting with its
@@ -126,11 +133,11 @@ (Filled f s) <+:> t = Filled f (s ++ [t])
 (Missing f s) <+:> t = Missing f (s ++ [t])
 
--- | Use a 'Hierarchy' to generate a tree structure from an 'SFM'
--- document. Fields are converted to 'Filled' nodes, containing as
--- many following nodes as possible, until the next node which is at
--- the same level of the hierarchy or lower. 'Missing' nodes are
--- created for any missing levels of the hierarchy.
+-- | Generate a tree structure from an 'SFM' document according to the
+-- given 'Hierarchy'. Fields are converted to 'Filled' nodes,
+-- containing as many following nodes as possible, until the next node
+-- which is at the same level of the hierarchy or lower. 'Missing'
+-- nodes are created for any missing levels of the hierarchy.
 toTree :: Hierarchy -> SFM -> SFMTree
 toTree h = fst . go (Root [])
   where
@@ -158,8 +165,13 @@                 in go (s <+:> subtree) fs'
 
             -- otherwise, recurse into the hierarchy
-            (m:_, _) ->
-                let (subtree, fs') = go (Missing m []) (f:fs)
+            (ms, _) ->
+                -- NB. 'last ms' is the /highest/ missing level! Then
+                -- the recursive call infers the next-highest level,
+                -- and so on until all the levels are created. This
+                -- could be sped up by creating all the levels at once,
+                -- but it's not worth it for now.
+                let (subtree, fs') = go (Missing (last ms) []) (f:fs)
                 in go (s <+:> subtree) fs'
 
 -- | Inverse of 'toTree': convert an 'SFMTree' back into a linear
@@ -175,7 +187,8 @@ mapField g (Filled f s) = Filled (g f) $ mapField g <$> s
 mapField g (Missing m s) = Missing m $ mapField g <$> s
 
--- | Depth-first search for fields under an 'SFMTree'.
+-- | Depth-first search for fields under an 'SFMTree' which satisfy
+-- the given predicate.
 searchField :: (Field -> Maybe a) -> SFMTree -> [a]
 searchField p (Root ts) = searchField p =<< ts
 searchField p (Filled f ts)
src/Brassica/SoundChange.hs view
@@ -1,7 +1,61 @@+-- |
+-- Module      : Brassica.SoundChange
+-- Copyright   : See LICENSE file
+-- License     : BSD3
+-- Maintainer  : Brad Neimann
+--
+-- The modules below provide Brassica’s support for sound changes.
+-- For further details on their syntax and processing, refer to the
+-- [reference guide](https://github.com/bradrn/brassica/blob/v1.0.0/docs/Reference.md),
+-- and the documentation of individual modules.
+--
+-- In brief, a sound changes file passes through the following phases:
+--
+--     1. First it is /parsed/ to a @'SoundChanges' 'CategorySpec' 'Directive'@
+--     2. Next it undergoes /expansion/ to give a @'SoundChanges' 'Expanded' 'GraphemeList'@
+--     3. Finally it can be /applied/ to a 'PWord' using 'applyChanges' or similar.
+--
+-- Words may also be extracted from a words file for application using
+-- 'tokeniseWords', and the file can be recreated using
+-- 'detokeniseWords' or similar. (For an MDF file one can similarly
+-- use 'Brassica.SFM.MDF.tokeniseMDF'.)
+--
+-- For a simple example, the following sample applies a sound change
+-- file to a words file (without error-handling):
+--
+-- @
+-- import Data.Maybe (mapMaybe)
+-- import System.Environment (getArgs)
+-- import Brassica.SoundChange
+--
+-- main :: IO ()
+-- main = do
+--     [changesFile, wordsFile] <- getArgs
+--     changes <- readFile changesFile
+--     words <- readFile wordsFile
+--
+--     let Right changesParsed = 'parseSoundChanges' changes
+--         Right changesExpanded = 'expandSoundChanges' changesParsed
+--
+--         Right wordsTokenised =
+--             'withFirstCategoriesDecl' 'tokeniseWords' changesExpanded words
+--
+--         wordsOutput = fmap (mapMaybe 'getOutput' . 'applyChanges' changesExpanded) <$> wordsTokenised
+--
+--     putStrLn $ 'detokeniseWords' $
+--         concatMap ('splitMultipleResults' "/") wordsOutput
+-- @
+--
+-- If writing sound changes in Haskell, it is suggested to skip
+-- parsing and expansion and directly create a value of type
+-- @'SoundChanges' 'Expanded' ('Bool', [t'Grapheme'])@. Expansion is
+-- of little use when v'Category's and v'Autosegment's can be assigned
+-- names in the code itself.
 module Brassica.SoundChange (module X) where
 
+-- NB. Haddock seems to list these re-exports in reverse order
+import Brassica.SoundChange.Tokenise  as X
 import Brassica.SoundChange.Apply     as X
-import Brassica.SoundChange.Category  as X
+import Brassica.SoundChange.Expand    as X
 import Brassica.SoundChange.Parse     as X
-import Brassica.SoundChange.Tokenise  as X
 import Brassica.SoundChange.Types     as X
src/Brassica/SoundChange/Apply.hs view
@@ -1,13 +1,32 @@+-- |+-- Module      : Brassica.SoundChange.Apply+-- Copyright   : See LICENSE file+-- License     : BSD3+-- Maintainer  : Brad Neimann+--+-- This module contains functions to apply one or more expanded sound+-- changes to words.+--+-- The most important function is 'applyChanges', which applies a set+-- of 'Brassica.SoudnChanges.Types.SoundChanges' to an input word. It+-- returns a log of all sound changes and other actions which were+-- applied to produce intermediate forms and the final result. The+-- results can be summarised using the functions in+-- [Reporting results](#g:3). module Brassica.SoundChange.Apply        (-       -- * Sound change application+       -- * Applying single rules          applyRuleStr        , applyStatementStr+       -- * Applying multiple sound changes        , applyChanges-       -- * Logging-       , applyChangesWithLogs-       , applyChangesWithChanges-       , PWordLog(..)+       , Log(..)+       , LogItem(..)+       -- * Reporting results+       , getOutput+       , getReports+       , getChangedOutputs+       , getChangedReports        , reportAsText        , reportAsHtmlRows        ) where
src/Brassica/SoundChange/Apply/Internal.hs view
@@ -20,54 +20,89 @@ {-# LANGUAGE TypeFamilies          #-}
 {-# LANGUAGE UndecidableInstances  #-}
 
-{-| __Warning:__ This module is __internal__, and does __not__ follow
-  the Package Versioning Policy. It may be useful for extending
-  Brassica, but be prepared to track development closely if you import
-  this module.
--}
+-- |
+-- Module      : Brassica.SoundChange.Apply.Internal
+-- Copyright   : See LICENSE file
+-- License     : BSD3
+-- Maintainer  : Brad Neimann
+--
+-- __Warning:__ This module is __internal__, and does __not__ follow
+-- the Package Versioning Policy. It may be useful for extending
+-- Brassica, but be prepared to track development closely if you import
+-- this module.
+--
+-- This module contains the lower-level functions used by Brassica to
+-- match and apply sound changes. The overall algorithm is similar to
+-- that described by [Howard (1973)](https://dspace.mit.edu/bitstream/handle/1721.1/12982/26083289-MIT.pdf?sequence=2).
+--
+-- Some essential points:
+--
+--     * Words are represented as 'MultiZipper's, with a cursor index
+--       and zero or more tagged indices. A sound change can then be
+--       applied ('applyRuleMZ') by advancing through the word from
+--       left to right. (Right-to-left application is achieved by
+--       reversing both word and rule.)
+--
+--     * For each potential application site, 'applyOnce' checks the
+--       target, environments and exceptions. If they are all
+--       satisfied, it then replaces the target graphemes with the
+--       replacement graphemes. After running 'applyOnce',
+--       'setupForNextApplication' can be used to advance to the next
+--       application site.
+--
+--     * The lowest-level function for matching is 'match', which
+--       matches an individual 'Lexeme' at some point in a word. The
+--       lowest-level function for replacement is 'mkReplacement',
+--       which constructs replacement graphemes.
 module Brassica.SoundChange.Apply.Internal
        (
        -- * Lexeme matching
          RuleTag(..)
        , RuleStatus(..)
        , MatchOutput(..)
+       , FeatureState(..)
+       , newOutput
+       , initialOutput
        , match
        , matchMany
-       , matchMany'
        , mkReplacement
        , exceptionAppliesAtPoint
        , matchRuleAtPoint
        -- * Sound change application
        , applyOnce
-       , applyRule
+       , setupForNextApplication
+       , applyRuleMZ
        , checkGraphemes
-       , applyStatement
+       , applyStatementMZ
        , applyRuleStr
        , applyStatementStr
-       , applyChanges
        -- * Logging
        , LogItem(..)
-       , PWordLog(..)
-       , toPWordLog
+       , Log(..)
        , reportAsHtmlRows
        , reportAsText
-       , applyStatementWithLog
-       , applyChangesWithLog
-       , applyChangesWithLogs
-       , applyChangesWithChanges
+       , applyStatement
+       , applyChanges
+       , getOutput
+       , getReports
+       , getChangedOutputs
+       , getChangedReports
        ) where
 
 import Control.Applicative ((<|>))
 import Control.Category ((>>>))
-import Control.Monad ((>=>), join)  -- needed for mtl>=2.3
+import Control.Monad ((>=>), (<=<), join)  -- needed for mtl>=2.3
 import Data.Containers.ListUtils (nubOrd)
 import Data.Functor ((<&>))
+import Data.List (elemIndex)
 import Data.Maybe (maybeToList, fromMaybe, listToMaybe, mapMaybe)
 import GHC.Generics (Generic)
 
 import Control.DeepSeq (NFData)
 import Control.Monad.State
 
+import qualified Data.Map.Strict as Map
+
 import Brassica.SoundChange.Apply.Internal.MultiZipper
 import Brassica.SoundChange.Types
 import Data.Bifunctor (Bifunctor(first))
@@ -77,6 +112,9 @@     = AppStart     -- ^ The start of a rule application
     | TargetStart  -- ^ The start of the target
     | TargetEnd    -- ^ The end of the target
+    | PrevEnd
+    -- ^ The end of the replacement from the last rule application
+    -- (used to avoid infinite loops from iterative rules)
     deriving (Eq, Ord, Show)
 
 -- | A monad in which to process a 'MultiZipper' over
@@ -85,11 +123,7 @@ -- backtracking and multiple answers (backtracking over the state
 -- too).
 newtype RuleAp a = RuleAp { runRuleAp :: MultiZipper RuleTag Grapheme -> [(a, MultiZipper RuleTag Grapheme)] }
-    deriving (Functor, Applicative, Monad, MonadState (MultiZipper RuleTag Grapheme)
-
-    , MonadFail
-
-    )
+    deriving (Functor, Applicative, Monad, MonadState (MultiZipper RuleTag Grapheme), MonadFail)
       via (StateT (MultiZipper RuleTag Grapheme) [])
 
 -- | Lift a partial modification function into a 'State'. Update state
@@ -110,30 +144,57 @@         [] -> [(Nothing, s)]
         r -> first Just <$> r
 
+-- | The result of matching a 'Feature' or 'Autosegment': either a
+-- specific index in the 'Feature', or an indeterminate result (when
+-- no indices matched)
+data FeatureState = Index Int | Indeterminate
+    deriving (Show, Eq)
+
 -- | Describes the output of a 'match' operation.
 data MatchOutput = MatchOutput
-    { -- | For each category matched, the index of the matched
-      -- grapheme in that category.
+    { -- | For each non-backreferenced category matched: the index of
+      -- the matched grapheme in that category.
       matchedCatIxs    :: [Int]
-      -- | For each optional group whether it matched or not
+      -- | For each optional group: whether it matched or not
     , matchedOptionals :: [Bool]
-      -- | For each wildcard, the graphemes which it matched
+      -- | For each wildcard: the graphemes which it matched
     , matchedWildcards :: [[Grapheme]]
-      -- | For each Kleene star, how many repititions it matched
+      -- | For each Kleene star: how many repetitions it matched
     , matchedKleenes   :: [Int]
-      -- | The graphemes which were matched
+      -- | The actual graphemes which were matched
     , matchedGraphemes :: [Grapheme]
+      -- | The features which were matched, by name
+    , matchedFeatures :: Map.Map String [FeatureState]
+      -- | Backreferenced categories which were matched, by ID
+    , matchedBackrefIds :: Map.Map String Int
+      -- | Backreferenced features which were matched, by ID
+    , matchedFeatureIds :: Map.Map String FeatureState
     } deriving (Show)
 
+-- | Create 'MatchOutput' for next section of rule given last output
+-- (preserving backreferences but emptying all other fields)
+newOutput :: MatchOutput -> MatchOutput
+newOutput m = MatchOutput
+    { matchedCatIxs = []
+    , matchedOptionals = []
+    , matchedWildcards = []
+    , matchedKleenes = []
+    , matchedGraphemes = []
+    , matchedFeatures = Map.empty
+    , matchedBackrefIds = matchedBackrefIds m
+    , matchedFeatureIds = matchedFeatureIds m
+    }
+
+-- | The empty 'MatchOutput'
+initialOutput :: MatchOutput
+initialOutput = MatchOutput [] [] [] [] [] Map.empty Map.empty Map.empty
+
 modifyMatchedGraphemes :: ([Grapheme] -> [Grapheme]) -> MatchOutput -> MatchOutput
 modifyMatchedGraphemes f MatchOutput{..} = MatchOutput{matchedGraphemes=f matchedGraphemes, ..}
 
 appendGrapheme :: MatchOutput -> Grapheme -> MatchOutput
 appendGrapheme out g = modifyMatchedGraphemes (++[g]) out
 
-instance Semigroup MatchOutput where
-    (MatchOutput a1 b1 c1 d1 e1) <> (MatchOutput a2 b2 c2 d2 e2) =
-        MatchOutput (a1++a2) (b1++b2) (c1++c2) (d1++d2) (e1++e2)
 
 zipWith' :: [a] -> [b] -> (a -> b -> c) -> [c]
 zipWith' xs ys f = zipWith f xs ys
@@ -144,15 +205,24 @@ insertAt :: Int -> a -> [a] -> [a]
 insertAt n a as = let (xs,ys) = splitAt n as in xs ++ (a:ys)
 
+insertAtOptional :: Int -> Bool -> MatchOutput -> MatchOutput
+insertAtOptional n o mz = mz { matchedOptionals = insertAt n o $ matchedOptionals mz }
+
 insertAtCat :: Int -> Int -> MatchOutput -> MatchOutput
 insertAtCat n i mz = mz { matchedCatIxs = insertAt n i $ matchedCatIxs mz }
 
 insertAtKleene :: Int -> Int -> MatchOutput -> MatchOutput
 insertAtKleene n i mz = mz { matchedKleenes = insertAt n i $ matchedKleenes mz }
 
+appendFeatureAt :: Int -> String -> FeatureState -> MatchOutput -> MatchOutput
+appendFeatureAt n name fs out = out { matchedFeatures = Map.alter go name $ matchedFeatures out }
+  where
+    go Nothing = Just [fs]
+    go (Just fss) = Just $ insertAt n fs fss
+
 -- | Match a single 'Lexeme' against a 'MultiZipper', and advance the
 -- 'MultiZipper' past the match. For each match found, returns the
--- 'MatchOutput' tupled with the updated 'MultiZipper'.
+-- updated 'MatchOutput' tupled with the updated 'MultiZipper'.
 match :: MatchOutput          -- ^ The previous 'MatchOutput'
       -> Maybe Grapheme       -- ^ The previously-matched grapheme, if any. (Used to match a 'Geminate'.)
       -> Lexeme Expanded 'Matched  -- ^ The lexeme to match.
@@ -160,29 +230,91 @@       -> [(MatchOutput, MultiZipper t Grapheme)]
       -- ^ 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
+    let i = length (matchedOptionals out)
+    in
+        (insertAtOptional i False out, mz) :
+        matchMany (insertAtOptional i True out) prev l mz
+match out prev (GreedyOptional l) mz =
+    let i = length (matchedOptionals out)
+        m = matchMany (insertAtOptional i True out) prev l mz
+    in case m of
+        -- skip, but only if no matches
+        [] -> [(insertAtOptional i False out, mz)]
+        _ -> m
 match out prev (Wildcard l) mz = matchWildcard out prev l mz
 match out prev (Kleene l) mz = matchKleene out prev l mz
-match out _ (Grapheme g) mz = (out <> MatchOutput [] [] [] [] [g],) <$> maybeToList (matchGrapheme g mz)
+match out _ (Grapheme g) mz = (appendGrapheme out g,) <$> maybeToList (matchGrapheme g mz)
 match out prev (Category (FromElements gs)) mz =
-    concat $ zipWith' gs [0..] $ \e i ->
+    concat $ zipWith' gs [0..] $ \ls i ->
         -- make sure to insert new index BEFORE any new ones which
         -- might be added by the recursive call
         first (insertAtCat (length $ matchedCatIxs out) i) <$>
-            case e of
-                Left  g  -> match out prev (Grapheme g :: Lexeme Expanded a) mz
-                Right ls -> matchMany out prev ls mz
+            matchMany out prev ls mz
+match out prev (GreedyCategory c) mz =
+    -- Take first match only
+    case match out prev (Category c) mz of
+        [] -> []
+        (m:_) -> [m]
 match out prev Geminate mz = case prev of
     Nothing -> []
-    Just prev' -> (out <> MatchOutput [] [] [] [] [prev'],) <$> maybeToList (matchGrapheme prev' mz)
+    Just prev' -> (appendGrapheme out prev',) <$> maybeToList (matchGrapheme prev' mz)
+match out prev (Backreference (Left ident) (FromElements gs)) mz
+    | Nothing <- Map.lookup ident (matchedBackrefIds out) =
+        -- first occurrence, set backref
+        -- similar to Category case above
+        concat $ zipWith' gs [0..] $ \ls i ->
+            first (\o -> o { matchedBackrefIds = Map.insert ident i $ matchedBackrefIds o })
+                <$> matchMany out prev ls mz
 match out prev (Backreference i (FromElements gs)) mz = do
-    e <- maybeToList $
-        (gs !?) =<< matchedCatIxs out !? (i-1)
-    case e of
-        Left  g  -> match out prev (Grapheme g :: Lexeme Expanded a) mz
-        Right ls -> matchMany out prev ls mz
+    ls <- maybeToList $ case i of
+        Left i' -> (gs !?) =<< Map.lookup i' (matchedBackrefIds out)
+        Right i' -> (gs !?) =<< matchedCatIxs out !? (i'-1)
+    matchMany out prev ls mz
+match out prev (Feature r _n (Just ident) kvs l) mz
+    | Just fs <- Map.lookup ident (matchedFeatureIds out) = do
+        -- similar to next case, but just check that features are the same
+        -- (NB. feature name is irrelevant for this)
+        (out', mz') <- match out prev l mz
+        let fs' = case matchedGraphemes out' of
+                gs | Just g <- lastMay gs -> checkFeature kvs g
+                _ -> Indeterminate
+            satisfied = case (fs, fs') of
+                (Indeterminate, _) -> True
+                (_, Indeterminate) -> True
+                _  ->
+                    if r
+                    then fs /= fs'  -- reverse comparison
+                    else fs == fs'
+        if satisfied
+            then pure (out', mz')
+            else []
+match out prev (Feature _r n ident kvs l) mz = do
+    let i = maybe 0 length $ Map.lookup n (matchedFeatures out)
+    (out', mz') <- match out prev l mz
+    let fs = case matchedGraphemes out' of
+            gs | Just g <- lastMay gs -> checkFeature kvs g
+            _ -> Indeterminate
+    pure $ case ident of
+        Nothing -> (appendFeatureAt i n fs out', mz')
+        Just ident' ->
+            ( out' { matchedFeatureIds = Map.insert ident' fs $ matchedFeatureIds out' }
+            , mz'
+            )
+match out prev (Autosegment n kvs gs) mz =
+    -- act as 'Category' + 'Feature', without capture
+    -- and accounting for unmatchable values
+    gs >>= \g -> do
+        let i = maybe 0 length $ Map.lookup n (matchedFeatures out)
+        (out', mz') <- match out prev (Grapheme g) mz
+        let fs = checkFeature kvs (g, True)
+        pure (appendFeatureAt i n fs out', mz')
 
+checkFeature :: Eq a => [[a]] -> a -> FeatureState
+checkFeature [] _ = Indeterminate
+checkFeature (gs:gss) x
+    | Just i <- x `elemIndex` gs = Index i
+    | otherwise = checkFeature gss x
+
 matchKleene
     :: MatchOutput
     -> Maybe Grapheme
@@ -208,7 +340,7 @@   where
     go matched out prev l mz = case match out prev l mz of
         [] -> maybeToList (consume mz) >>= \case
-            (GBoundary, _) -> []   -- don't continue past word boundary
+            ("#", _) -> []   -- don't continue past word boundary
             (g, mz') -> go (g:matched) (appendGrapheme out g) prev l mz'
         r -> r <&> \(out', mz') ->
             ( out'
@@ -224,9 +356,7 @@ matchGraphemeP p mz = value mz >>= \cs -> if p cs then fwd mz else Nothing
 
 -- | Match a list of several 'Lexeme's against a
--- 'MultiZipper'. Arguments and output are the same as with 'match',
--- though the outputs are given as a list of indices and graphemes
--- rather than as a single index and grapheme.
+-- 'MultiZipper'. Arguments and output are as with 'match'.
 matchMany :: MatchOutput
           -> Maybe Grapheme
           -> [Lexeme Expanded 'Matched]
@@ -237,13 +367,6 @@     match out prev l mz >>= \(out', mz') ->
     matchMany  out' (lastMay (matchedGraphemes out') <|> prev) ls mz'
 
--- | 'matchMany' without any previous match output.
-matchMany' :: Maybe Grapheme
-          -> [Lexeme Expanded 'Matched]
-          -> MultiZipper t Grapheme
-          -> [(MatchOutput, MultiZipper t Grapheme)]
-matchMany' = matchMany (MatchOutput [] [] [] [] [])
-
 -- Small utility function, not exported
 lastMay :: [a] -> Maybe a
 lastMay l = if null l then Nothing else Just (last l)
@@ -253,10 +376,11 @@     , ixInOptionals :: Int
     , ixInWildcards :: Int
     , ixInKleenes :: Int
+    , ixInFeatures :: Map.Map String Int
     , forcedCategory :: Maybe CategoryNumber
     } deriving (Show)
 
-data CategoryNumber = CategoryNumber Int | Nondeterministic
+data CategoryNumber = CategoryNumber Int | CategoryId String | Nondeterministic
     deriving (Show)
 
 advanceCategory :: ReplacementIndices -> Int -> (CategoryNumber, ReplacementIndices)
@@ -284,6 +408,12 @@     let i = ixInKleenes ix
     in (i, ix { ixInKleenes = i+1 })
 
+advanceFeature :: String -> ReplacementIndices -> Maybe (Int, ReplacementIndices)
+advanceFeature n ix =
+    case Map.lookup n (ixInFeatures ix) of
+        Nothing -> Just (0, ix { ixInFeatures = Map.insert n 1    $ ixInFeatures ix })
+        Just i  -> Just (i, ix { ixInFeatures = Map.adjust (+1) n $ ixInFeatures ix })
+
 forceCategory :: CategoryNumber -> ReplacementIndices -> ReplacementIndices
 forceCategory i ixs = ixs { forcedCategory = Just i }
 
@@ -302,7 +432,7 @@     -> [MultiZipper t Grapheme]
 mkReplacement out = \ls -> fmap (fst . snd) . go startIxs ls . (,Nothing)
   where
-    startIxs = ReplacementIndices 0 0 0 0 Nothing
+    startIxs = ReplacementIndices 0 0 0 0 Map.empty Nothing
 
     go
         :: ReplacementIndices
@@ -329,12 +459,19 @@                 case matchedCatIxs out !? ci of
                     Just i | Just g' <- gs !? i ->
                         case g' of
-                            Left g -> [(ixs', (insert g mz, Just g))]
-                            Right ls -> go ixs' ls (mz, prev)
-                    _ -> [(ixs', (insert (GMulti "\xfffd") mz, Nothing))]  -- Unicode replacement character
+                            [Grapheme g] -> [(ixs', (insert g mz, Just g))]
+                            ls -> go ixs' ls (mz, prev)
+                    _ -> [(ixs', (insert "\xfffd" mz, Nothing))]  -- Unicode replacement character
+            (CategoryId ci, ixs') ->  -- as above
+                case Map.lookup ci (matchedBackrefIds out) of
+                    Just i | Just g' <- gs !? i ->
+                        case g' of
+                            [Grapheme g] -> [(ixs', (insert g mz, Just g))]
+                            ls -> go ixs' ls (mz, prev)
+                    _ -> [(ixs', (insert "\xfffd" mz, Nothing))]  -- Unicode replacement character
             (Nondeterministic, ixs') -> gs >>= \case
-                Left g -> [(ixs', (insert g mz, Just g))]
-                Right ls -> go ixs' ls (mz, prev)
+                [Grapheme g] -> [(ixs', (insert g mz, Just g))]
+                ls -> go ixs' ls (mz, prev)
     replaceLex ixs (Optional ls) mz prev =
         let (co, ixs') = advanceOptional ixs in
             case matchedOptionals out !? co of
@@ -351,7 +488,10 @@     replaceLex ixs Discard mz prev =
         let (_, ixs') = advanceCategory ixs numCatsMatched
         in [(ixs', (mz, prev))]
-    replaceLex ixs (Backreference i c) mz prev =
+    replaceLex ixs (Backreference (Left i) c) mz prev =
+        let ixs' = forceCategory (CategoryId i) ixs
+        in replaceLex ixs' (Category c) mz prev
+    replaceLex ixs (Backreference (Right 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 =
@@ -368,34 +508,111 @@         in case matchedKleenes out !? i of
             Just n -> go ixs' (replicate n l) (mz, prev)
             Nothing -> [(ixs', (mz, prev))]
+    replaceLex ixs (Feature r n ident kvs l) mz prev =
+        let (fs, ixs') = case ident of
+                Nothing -> case advanceFeature n ixs of
+                    Just (i, ixs_)
+                        | Just fss <- Map.lookup n (matchedFeatures out)
+                        , Just fs_ <- fss !? i
+                        -> (fs_, ixs_)
+                    _ -> (Indeterminate, ixs)
+                Just ident' -> case Map.lookup ident' (matchedFeatureIds out) of
+                    Just fs_ -> (fs_, ixs)
+                    Nothing -> (Indeterminate, ixs)
+        in do
+            (ixs'', (mz', prev')) <- replaceLex ixs' l mz prev
+            case (kvs, prev') of
+                (gs:_, Just g) | g /= "#" -> do
+                    g' <- case fs of
+                        Index i -> applyFeature kvs g <$>
+                            if r
+                            then filter (/=i) [0 .. length gs - 1]
+                            else pure i
+                        Indeterminate -> applyFeature kvs g <$> [0 .. length gs - 1]
+                    -- now overwrite previous grapheme
+                    let mz'' = zap (Just . const g') mz'
+                    pure (ixs'', (mz'', Just g'))
+                -- cannot modify nonexistent or boundary grapheme,
+                -- or if there are zero key-value pairs
+                _ -> pure (ixs'', (mz', prev'))
+    replaceLex ixs (Autosegment _ _ []) mz prev = pure (ixs, (mz, prev))
+    replaceLex ixs (Autosegment n kvs (gBase:_)) mz prev =
+        -- ignore other segments, just produce a single one
+        -- as if modulated by a 'Feature', but accounting for Nothing values
+        let (fs, ixs') = case advanceFeature n ixs of
+                Just (i, ixs_)
+                    | Just fss <- Map.lookup n (matchedFeatures out)
+                    , Just fs_ <- fss !? i
+                    -> (fs_, ixs_)
+                _ -> (Indeterminate, ixs)
+        in do
+            (ixs'', (mz', prev')) <- replaceLex ixs' (Grapheme gBase) mz prev
+            case prev' of
+                Just g | g /= "#" -> do
+                    g' <- case fs of
+                        Index i
+                            | Just g'' <- applyFeature' kvs g i
+                            -> pure g''
+                        _ -> applyFeatureInd' kvs g
+                    -- now overwrite previous grapheme
+                    let mz'' = zap (Just . const g') mz'
+                    pure (ixs'', (mz'', Just g'))
+                -- cannot modify nonexistent or boundary grapheme,
+                -- or if there are zero key-value pairs
+                _ -> pure (ixs'', (mz', prev'))
 
+applyFeature :: [[String]] -> String -> Int -> String
+applyFeature [] g _ = g
+applyFeature (gs:gss) g i
+    | g `elem` gs = fromMaybe "\xfffd" $ gs !? i
+    | otherwise = applyFeature gss g i
+
+applyFeature' :: [[(String, Bool)]] -> String -> Int -> Maybe String
+applyFeature' [] g _ = Just g
+applyFeature' (gs:gss) g i = case lookup g gs of
+    Just _ -> case gs !? i of
+        Just (g', True) -> Just g'
+        Just (_, False) -> Nothing  -- this grapheme was excluded, need to act as if Indeterminate
+        Nothing -> Just "\xfffd"
+    Nothing -> applyFeature' gss g i
+
+applyFeatureInd' :: [[(String, Bool)]] -> String -> [String]
+applyFeatureInd' [] g = [g]
+applyFeatureInd' (gs:gss) g = case lookup g gs of
+    Just _ -> mapMaybe note gs
+    Nothing -> applyFeatureInd' gss g
+  where
+    note (a, True) = Just a
+    note _ = Nothing
+
 -- | Given a 'Rule' and a 'MultiZipper', determines whether the
 -- 'exception' of that rule (if any) applies starting at the current
--- position of the 'MultiZipper'; if it does, returns the index of the
--- first element of each matching 'target'.
+-- position of the 'MultiZipper'; if it does, returns the index at
+-- which each matching target begins.
 exceptionAppliesAtPoint
-    :: [Lexeme Expanded 'Matched]
-    -> Environment Expanded
+    :: [Lexeme Expanded 'Matched]  -- ^ Target
+    -> Environment Expanded        -- ^ Exceptional environment
     -> MultiZipper RuleTag Grapheme -> [Int]
 exceptionAppliesAtPoint target (ex1, ex2) mz = fmap fst $ flip runRuleAp mz $ do
-    ex1Out <- RuleAp $ matchMany' Nothing ex1
+    ex1Out <- RuleAp $ matchMany initialOutput Nothing ex1
     pos <- gets curPos
-    MatchOutput{matchedGraphemes} <- RuleAp $ matchMany' Nothing target
-    _ <- RuleAp $ matchMany ex1Out (listToMaybe matchedGraphemes) ex2
+    targetOut@MatchOutput{matchedGraphemes} <- RuleAp $ matchMany (newOutput ex1Out) Nothing target
+    _ <- RuleAp $ matchMany (newOutput targetOut) (listToMaybe matchedGraphemes) ex2
     return pos
 
--- | 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.
+-- | Given a target and environment, determine if the rule matches at
+-- the current position of the 'MultiZipper'. If so, for each match,
+-- return the 'MatchOutput' and the output 'MultiZipper'. The output
+-- 'MultiZipper' is advanced past the matched environment, and has its
+-- 'RuleTag's set as appropriate.
 matchRuleAtPoint
-    :: [Lexeme Expanded 'Matched]
-    -> Environment Expanded
+    :: [Lexeme Expanded 'Matched]  -- ^ Target
+    -> Environment Expanded        -- ^ Environment
     -> MultiZipper RuleTag Grapheme
     -> [(MatchOutput, MultiZipper RuleTag Grapheme)]
 matchRuleAtPoint target (env1,env2) mz = flip runRuleAp mz $ do
-    env1Out <- RuleAp $ matchMany' Nothing env1
+    let initMO = MatchOutput [] [] [] [] [] Map.empty Map.empty Map.empty
+    env1Out <- RuleAp $ matchMany initMO 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#
@@ -403,22 +620,29 @@         True -> RuleAp $ const []
         False -> do
             modify $ tag TargetStart
-            matchResult <- RuleAp $ matchMany' Nothing target
+            matchResult <- RuleAp $ matchMany (newOutput env1Out) Nothing target
             modify $ tag TargetEnd
-            _ <- RuleAp $ matchMany env1Out (listToMaybe $ matchedGraphemes matchResult) env2
+            env2Out <- RuleAp $ matchMany (newOutput matchResult)
+                (listToMaybe $ matchedGraphemes matchResult) env2
+            -- environment can affect replacement via IDs
+            -- only, so collect those
             return matchResult
+                { matchedFeatureIds = matchedFeatureIds env2Out
+                , matchedBackrefIds = matchedBackrefIds env2Out
+                }
 
+-- | Status of a rule application at a single location.
 data RuleStatus
-    = SuccessNormal      -- ^ Rule was successful, no need for special handling
-    | SuccessEpenthesis  -- ^ Rule was successful, but cursor was not advanced: need to avoid infinite loop
+    = SuccessNormal      -- ^ Rule was successful, with no need for special handling
+    | SuccessEpenthesis  -- ^ Rule was successful, but cursor was not advanced (need to avoid infinite loop)
     | Failure            -- ^ Rule failed
     deriving (Eq, Show)
 
 -- | Given a 'Rule', determine if the rule matches at the current
 -- point; if so, apply the rule, adding appropriate tags.
 applyOnce :: Rule Expanded -> StateT (MultiZipper RuleTag Grapheme) [] RuleStatus
-applyOnce r@Rule{target, replacement, exception} =
-    modify (tag AppStart) >> go (environment r)
+applyOnce Rule{..} =
+    modify (tag AppStart) >> go environment
   where
     go [] = return Failure
     go (env:envs) = do
@@ -429,19 +653,28 @@                     Nothing -> pure []
                     Just ex -> gets $ join . toList .
                         extend' (exceptionAppliesAtPoint target ex)
-                gets (locationOf TargetStart) >>= \p ->
-                    if maybe True (`elem` exs) p
-                    then return Failure
-                    else do
-                        originalWord <- get
+                originalWord <- get
+                let pMay = locationOf TargetStart originalWord
+                    pMay' = locationOf PrevEnd originalWord
+                case pMay of
+                    Nothing -> error "applyOnce: start of target was not tagged"
+                    Just p
+                        | p `elem` exs -> return Failure
+                        -- do not apply rule if it would be
+                        -- applied twice to the same substring
+                        | Just p' <- pMay', p < p' -> return Failure
+                        | otherwise -> do
                         modifyMay $ delete (TargetStart, TargetEnd)
                         modifyMay $ seek TargetStart
                         modifyM $ \w ->
                             let replacedWords = mkReplacement out replacement w
-                            in case sporadic (flags r) of
+                            in case sporadic flags of
                                 -- make sure to re-insert original word
                                 PerApplication -> originalWord : replacedWords
                                 _ -> replacedWords
+                        -- we want TargetEnd to move forward as the replacement is added,
+                        -- but not TargetStart, so restore its old position
+                        modifyMay $ tagAt TargetStart p
                         return $
                             -- An epenthesis rule will cause an infinite loop
                             -- if it matched no graphemes before the replacement
@@ -457,232 +690,293 @@     -> Rule Expanded
     -> MultiZipper RuleTag Grapheme
     -> Maybe (MultiZipper RuleTag Grapheme)
-setupForNextApplication status Rule{flags=Flags{applyDirection}} =
-    fmap untag . case applyDirection of
-        RTL -> seek AppStart >=> bwd
-        LTR -> case status of
-            SuccessNormal -> seek TargetEnd
-            SuccessEpenthesis ->
-                -- need to move forward if applying an epenthesis rule to avoid an infinite loop
-                seek TargetEnd >=> fwd
-            Failure -> seek AppStart >=> fwd
+setupForNextApplication status Rule{flags=Flags{nonOverlappingTarget}} =
+    resetTags <=< case status of
+        SuccessNormal ->
+            seek (if nonOverlappingTarget then TargetEnd else TargetStart)
+        SuccessEpenthesis ->
+            -- need to move forward if applying an epenthesis rule to avoid an infinite loop
+            seek TargetEnd >=> fwd
+        Failure -> seek AppStart >=> fwd
+  where
+    resetTags mz =
+        -- update PrevEnd to farthest replaced position on success,
+        -- or keep it the same on failure
+        let p = locationOf TargetEnd mz
+            p' = locationOf PrevEnd mz
+            newPrevEnd = case status of
+                Failure -> p'
+                _ -> max p p'
+        in maybe Just (tagAt PrevEnd) newPrevEnd $ untag mz
 
 -- | Apply a 'Rule' to a 'MultiZipper'. The application will start at
 -- the beginning of the 'MultiZipper', and will be repeated as many
 -- times as possible. Returns all valid results.
-applyRule :: Rule Expanded -> MultiZipper RuleTag Grapheme -> [MultiZipper RuleTag Grapheme]
-applyRule r = \mz ->    -- use a lambda so mz isn't shadowed in the where block
-    let startingPos = case applyDirection $ flags r of
-            LTR -> toBeginning mz
-            RTL -> toEnd mz
-        result = repeatRule (applyOnce r) startingPos
+--
+-- Note: unlike 'applyRuleStr', this can produce duplicate outputs.
+applyRuleMZ :: Rule Expanded -> MultiZipper RuleTag Grapheme -> [MultiZipper RuleTag Grapheme]
+applyRuleMZ r = \mz ->    -- use a lambda so mz isn't shadowed in the where block
+    let result = case applyDirection (flags r) of
+            LTR -> repeatRule $ toBeginning mz
+            -- Apply RTL by reversing both rule and word
+            RTL -> fmap reverseMZ $ repeatRule $ toBeginning $ reverseMZ mz
     in case sporadic (flags r) of
         PerWord -> mz : result
         _ -> result  -- PerApplication handled in 'applyOnce'
   where
+    r' = case applyDirection (flags r) of
+        LTR -> r
+        RTL -> Rule
+            { target = reverse $ target r
+            , replacement = reverse $ replacement r
+            , environment = reverseEnv <$> environment r
+            , exception = reverseEnv <$> exception r
+            , flags = flags r
+            , plaintext = plaintext r
+            }
+
+    reverseEnv (e1, e2) = (reverse e2, reverse e1)
+
     repeatRule
-        :: StateT (MultiZipper RuleTag Grapheme) [] RuleStatus
-        -> MultiZipper RuleTag Grapheme
+        :: MultiZipper RuleTag Grapheme
         -> [MultiZipper RuleTag Grapheme]
-    repeatRule m mz = runStateT m mz >>= \(status, mz') ->
-        if (status /= Failure) && applyOnceOnly (flags r)
+    repeatRule mz = runStateT (applyOnce r') mz >>= \(status, mz') ->
+        if (status /= Failure) && applyOnceOnly (flags r')
         then [mz']
-        else case setupForNextApplication status r mz' of
-            Just mz'' -> repeatRule m mz''
-            Nothing -> [mz']
+        else maybe [mz'] repeatRule (setupForNextApplication status r' mz')
 
 -- | Check if a 'MultiZipper' matches a 'Filter'.
 filterMatches :: Filter Expanded -> MultiZipper RuleTag Grapheme -> Bool
 filterMatches (Filter _ ls) = go . toBeginning
   where
     go mz =
-        let mzs = matchMany' Nothing ls mz
+        let mzs = matchMany initialOutput Nothing ls mz
         in case mzs of
             [] -> maybe False go $ fwd mz  -- try next position if there is one
             _ -> True  -- filter has matched
 
 -- | Check that the 'MultiZipper' contains only graphemes listed in
--- the given 'CategoriesDecl', replacing all unlisted graphemes with
--- U+FFFD.
+-- the given list, replacing all unlisted graphemes other than @"#"@
+-- with U+FFFD.
 checkGraphemes :: [Grapheme] -> MultiZipper RuleTag Grapheme -> MultiZipper RuleTag Grapheme
 checkGraphemes gs = fmap $ \case
-    GBoundary -> GBoundary
-    g -> if g `elem` gs then g else GMulti "\xfffd"
+    "#" -> "#"
+    g -> if g `elem` gs then g else "\xfffd"
 
 -- | Apply a 'Statement' to a 'MultiZipper', returning zero, one or
 -- more results.
-applyStatement
-    :: Statement Expanded [Grapheme]
+applyStatementMZ
+    :: Statement Expanded GraphemeList
     -> MultiZipper RuleTag Grapheme
     -> [MultiZipper RuleTag Grapheme]
-applyStatement (RuleS r) mz = applyRule r mz
-applyStatement (FilterS f) mz
+applyStatementMZ (RuleS r) mz = applyRuleMZ r mz
+applyStatementMZ (FilterS f) mz
     | filterMatches f mz = []
     | otherwise = [mz]
-applyStatement (DirectiveS gs) mz = [checkGraphemes gs mz]
+applyStatementMZ ReportS mz = [mz]
+applyStatementMZ (DeclS (GraphemeList noreplace gs)) mz
+    | noreplace = [mz]
+    | otherwise = [checkGraphemes gs mz]
 
--- | Apply a single 'Rule' to a word.
---
--- Note: duplicate outputs from this function are removed. To keep
--- duplicates, use the lower-level internal function 'applyRule'
--- directly.
+-- | Apply a single sound change 'Rule' to a word.
 applyRuleStr :: Rule Expanded -> PWord -> [PWord]
 -- Note: 'fromJust' is safe here as 'apply' should always succeed
 applyRuleStr r =
     addBoundaries
     >>> fromListStart
-    >>> applyRule r
+    >>> applyRuleMZ r
     >>> fmap (toList >>> removeBoundaries)
     >>> nubOrd
 
--- | Apply a single 'Statement' to a word.
---
--- Note: as with 'applyRuleStr', duplicate outputs from this function
--- are removed. To keep duplicates, use the lower-level internal
--- function 'applyStatement' directly.
-applyStatementStr :: Statement Expanded [Grapheme] -> PWord -> [PWord]
+-- | Apply a single 'Statement' to a word. The statement can be a
+-- sound change, a filter, or any other element which remains in a
+-- sound change file after expansion.
+applyStatementStr :: Statement Expanded GraphemeList -> PWord -> [PWord]
 applyStatementStr st =
     addBoundaries
     >>> fromListStart
-    >>> applyStatement st
+    >>> applyStatementMZ 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
--- which was applied, as well as the ‘before’ and ‘after’ states.
-data LogItem r = ActionApplied
-    { action :: r
-    , input :: PWord
-    , output :: Maybe PWord
-    } deriving (Show, Functor, Generic, NFData)
+-- | A log item representing a single action @r@ (usually a
+-- 'Statement'), and the result from that action.
+data LogItem r
+    = ActionApplied r (Maybe PWord)
+    -- ^ The word was modified: gives the output word, or 'Nothing' if
+    -- the wordwas deleted
+    | ReportWord PWord
+    -- ^ Corresponds to 'ReportS', giving the intermediate form to report
+    deriving (Show, Functor, Generic, NFData)
 
--- | Logs the evolution of a 'PWord' as various actions are applied to
--- it. The actions (usually 'Statement's) are of type @r@.
-data PWordLog r = PWordLog
-    { initialWord :: PWord
-    -- ^ The initial word, before any actions have been applied
-    , derivations :: [(Maybe PWord, r)]
-    -- ^ The state of the word after each action @r@, stored alongside
-    -- the action which was applied at each point
-    } deriving (Show, Functor, Generic, NFData)
+-- action :: LogItem r -> Maybe r
+-- action (ActionApplied r _ _) = Just r
+-- action (ReportWord _) = Nothing
 
-toPWordLog :: [LogItem r] -> Maybe (PWordLog r)
-toPWordLog [] = Nothing
-toPWordLog ls@(l : _) = Just $ PWordLog
-    { initialWord = input l
-    , derivations = (\ActionApplied{..} -> (output, action)) <$> ls
-    }
+logOutput :: LogItem r -> Maybe PWord
+logOutput (ActionApplied _ o) = o
+logOutput (ReportWord o) = Just o
 
--- | Render a single 'PWordLog' to rows of an HTML table. For
+-- | Logs the evolution of a word as it undergoes sound changes and
+-- other actions.
+data Log r = Log
+    { inputWord :: PWord
+    -- ^ The input word, before any actions have been applied
+    , derivations :: [LogItem r]
+    -- ^ All actions which were applied, with the state of the word at
+    -- each point
+    } deriving (Show, Functor, Generic, NFData)
+
+-- | Pretty-print a single 'Log' as rows of an HTML table. For
 -- instance, the example log given in the documentation for
 -- 'reportAsText' would be converted to the following HTML:
 --
--- > "<tr><td>tara</td><td>&rarr;</td><td>tazha</td><td>(r / zh)</td></tr><tr><td></td><td>&rarr;</td><td>tazh</td><td>(V / / _ #)</td></tr>"
+-- > <tr><td>tara</td><td>&rarr;</td><td>tazha</td><td>(r / zh)</td></tr><tr><td></td><td>&rarr;</td><td>tazh</td><td>(V / / _ #)</td></tr>
 --
--- Which might be displayed in an HTML table as something like the
--- following:
+-- Which might be displayed in a browser as follows:
 --
--- +------+---+-------+-------------+ 
+-- +------+---+-------+-------------+
 -- | tara | → | tazha | (r / zh)    |
--- +------+---+-------+-------------+ 
+-- +------+---+-------+-------------+
 -- |      | → | tazh  | (V / / _ #) |
--- +------+---+-------+-------------+ 
+-- +------+---+-------+-------------+
 
-reportAsHtmlRows :: (r -> String) -> PWordLog r -> String
-reportAsHtmlRows render item = go (concatWithBoundary $ initialWord item) (derivations item)
+reportAsHtmlRows
+    :: (r -> String)  -- ^ Specifies how to pretty-print actions as text
+    -> Log r -> String
+reportAsHtmlRows render item = go (concatWithBoundary $ inputWord item) (derivations item)
   where
     go _ [] = ""
-    go cell1 ((output, action) : ds) =
+    go cell1 (ActionApplied action output : ds) =
         ("<tr><td>" ++ cell1 ++ "</td><td>&rarr;</td><td>"
          ++ maybe "<i>deleted</i>" concatWithBoundary output
          ++ "</td><td>(" ++ render action ++ ")</td></tr>")
         ++ go "" ds
+    go cell1 (ReportWord w : ds) =
+        ("<tr><td>" ++ cell1 ++ "</td><td>&rarr;</td><td>"
+         ++ concatWithBoundary w
+         ++ "</td><td>(report)</td></tr>")
+        ++ go "" ds
 
--- | Render a single 'PWordLog' to plain text. For instance, this log:
+-- | Pretty-print a 'Log' as plain text. For instance, this log:
 --
--- > PWordLog
--- >   { initialWord = ["t", "a", "r", "a"]
--- >   , derivations =
--- >     [ (["t", "a", "zh", "a"], "r / zh")
--- >     , (["t", "a", "zh"], "V / / _ #")
--- >     ]
--- >   }
+-- @
+-- 'Log'
+--   { 'inputWord' = ["t", "a", "r", "a"]
+--   , 'derivations' =
+--     [ ('Just' ["t", "a", "zh", "a"], "r \/ zh")
+--     , ('Just' ["t", "a", "zh"], "V \/ \/ _ #")
+--     ]
+--   }
+-- @
 --
--- Would render as:
+-- Would be pretty-printed by @'reportAsText' 'id'@ as:
 --
 -- > tara
 -- >   -> tazha  (r / zh)
 -- >   -> tazh   (V / / _ #)
-reportAsText :: (r -> String) -> PWordLog r -> String
+reportAsText
+    :: (r -> String)  -- ^ Specifies how to pretty-print actions as text
+    -> Log r -> String
 reportAsText render item = unlines $
-    concatWithBoundary (initialWord item) : fmap toLine (alignWithPadding $ derivations item)
+    concatWithBoundary (inputWord item) : fmap toLine (alignWithPadding $ derivations item)
   where
-    alignWithPadding :: [(Maybe PWord, b)] -> [([Char], b)]
     alignWithPadding ds =
-        let (rawOutputs, actions) = unzip ds
+        let (rawOutputs, actions) = unzip $ toPrintable <$> ds
             outputs = maybe "(deleted)" concatWithBoundary <$> rawOutputs
             maxlen = maximum $ length <$> outputs
             padded = outputs <&> \o -> o ++ replicate (maxlen - length o) ' '
         in zip padded actions
 
-    toLine (output, action) = "  -> " ++ output ++ "  (" ++ render action ++ ")"
+    toLine (output, action) = "  -> " ++ output ++ "  (" ++ action ++ ")"
 
+    toPrintable (ActionApplied a o) = (o, render a)
+    toPrintable (ReportWord w) = (Just w, "report")
+
 -- | Apply a single 'Statement' to a word. Returns a 'LogItem' for
 -- each possible result, or @[]@ if the rule does not apply and the
 -- input is returned unmodified.
-applyStatementWithLog
-    :: Statement Expanded [Grapheme]
+applyStatement
+    :: Statement Expanded GraphemeList
     -> PWord
-    -> [LogItem (Statement Expanded [Grapheme])]
-applyStatementWithLog st w = case applyStatementStr st w of
-    [] -> [ActionApplied st w Nothing]
+    -> [LogItem (Statement Expanded GraphemeList)]
+applyStatement ReportS w = [ReportWord w]
+applyStatement st w = case applyStatementStr st w of
+    [] -> [ActionApplied st Nothing]
     [w'] | w' == w -> []
-    r -> ActionApplied st w . Just <$> r
+    r -> ActionApplied st . Just <$> r
 
--- | Apply 'SoundChanges' to a word. For each possible result, returns
--- a 'LogItem' for each 'Statement' which altered the input.
-applyChangesWithLog
-    :: SoundChanges Expanded [Grapheme]
+-- | Apply a set of 'SoundChanges' to a word, returning a log of which
+-- sound changes applied to produce each output word.
+applyChanges
+    :: SoundChanges Expanded GraphemeList
     -> PWord
-    -> [[LogItem (Statement Expanded [Grapheme])]]
-applyChangesWithLog [] _ = [[]]
-applyChangesWithLog (st:sts) w =
-    case applyStatementWithLog st w of
-        [] -> applyChangesWithLog sts w
-        outputActions -> outputActions >>= \l@ActionApplied{output} ->
-            case output of
-                Just w' -> (l :) <$> applyChangesWithLog sts w'
-                -- apply no further changes to a deleted word
-                Nothing -> [[l]]
+    -> [Log (Statement Expanded GraphemeList)]
+applyChanges scs w = go scs w <&> \ls -> Log
+    { inputWord = w
+    , derivations = ls
+    }
+  where
+    go [] _ = [[]]   -- one result, no changes applied
+    go (st:sts) w' =
+        case applyStatement st w' of
+            [] -> go sts w'
+            outputActions -> outputActions >>= \case
+                l@(ReportWord w'') -> (l :) <$> go sts w''
+                l@(ActionApplied _ output) -> case output of
+                    Just w'' -> (l :) <$> go sts w''
+                    -- apply no further changes to a deleted word
+                    Nothing -> [[l]]
 
--- | Apply 'SoundChanges' to a word, returning an 'PWordLog'
--- for each possible result.
-applyChangesWithLogs
-    :: SoundChanges Expanded [Grapheme]
-    -> PWord
-    -> [PWordLog (Statement Expanded [Grapheme])]
-applyChangesWithLogs scs w = mapMaybe toPWordLog $ applyChangesWithLog  scs w
+-- | Returns the final output from a sound change log.
+getOutput :: Log r -> Maybe PWord
+getOutput l = case derivations l of
+    d@(_:_) -> logOutput $ last d
+    [] -> Just $ inputWord l
 
--- | Apply a set of 'SoundChanges' to a word.
-applyChanges :: SoundChanges Expanded [Grapheme] -> PWord -> [PWord]
-applyChanges sts w =
-    mapMaybe lastOutput $ applyChangesWithLog sts w
+-- | Returns, in order: the input word, any intermediate results from
+-- 'ReportS', and then the final output.
+getReports :: Log r -> [PWord]
+getReports l = inputWord l : go (derivations l)
   where
-    -- If no changes were applied, output is same as input
-    lastOutput [] = Just w
-    lastOutput ls = output $ last ls
+    go [] = []
+    go [ActionApplied _ (Just w')] = [w']
+    go (ReportWord w':ls) = w' : go ls
+    go (_:ls) = go ls
 
--- | Apply 'SoundChanges' to a word returning the final results, as
--- well as a boolean value indicating whether the word should be
--- highlighted in a UI due to changes from its initial value. (Note
--- that this accounts for 'highlightChanges' values.)
-applyChangesWithChanges :: SoundChanges Expanded [Grapheme] -> PWord -> [(Maybe PWord, Bool)]
-applyChangesWithChanges sts w = applyChangesWithLog sts w <&> \case
-    [] -> (Just w, False)
-    logs -> (output $ last logs, hasChanged logs)
+-- | Returns the final output from a sound change log, as well as an
+-- indication of whether any sound changes have applied to it
+-- (accounting for 'highlightChanges' flags).
+getChangedOutputs :: Log (Statement c d) -> Maybe (PWord, Bool)
+getChangedOutputs l = case derivations l of
+    [] -> Just (inputWord l, False)
+    logs -> case logOutput (last logs) of
+        Just out -> Just (out, hasChanged logs)
+        Nothing -> Nothing
   where
     hasChanged = any $ \case
-        ActionApplied (RuleS rule) _ _ -> highlightChanges $ flags rule
-        ActionApplied (FilterS _) _ _ -> False  -- cannot highlight nonexistent word
-        ActionApplied (DirectiveS _) _ _ -> True
+        ActionApplied (RuleS rule) _ -> highlightChanges $ flags rule
+        ActionApplied (FilterS _) _ -> False  -- cannot highlight nonexistent word
+        ActionApplied (DeclS _) _ -> True
+        ActionApplied ReportS _ -> False  -- reporting a word yields no change
+        ReportWord _ -> False
+
+-- | A combination of 'getOutput' and 'getChangedOutputs': returns all
+-- intermediate results, as well as whether each has undergone any
+-- sound changes.
+getChangedReports :: Log (Statement c d) -> [(PWord, Bool)]
+getChangedReports l = (inputWord l, False) : case derivations l of
+    [] -> []
+    ls -> go False ls
+  where
+    go _ [] = []
+    go hasChanged (ActionApplied action _:ls) =
+        let hasChanged' = case action of
+                RuleS rule -> hasChanged || highlightChanges (flags rule)
+                _ -> hasChanged
+        in go hasChanged' ls
+    go hasChanged (ReportWord w':ls) = (w', hasChanged) : go hasChanged ls
+
+-- | Apply a set of 'SoundChanges' to a word, returning the final
+-- output word(s) as well as any intermediate results from 'ReportS',
+-- each with a boolean marking changed results (as with 'applyChangesWithChanges').
src/Brassica/SoundChange/Apply/Internal/MultiZipper.hs view
@@ -1,11 +1,16 @@ {-# LANGUAGE DeriveTraversable #-}
 {-# LANGUAGE TupleSections #-}
 
-{-| __Warning:__ This module is __internal__, and does __not__ follow
-  the Package Versioning Policy. It may be useful for extending
-  Brassica, but be prepared to track development closely if you import
-  this module.
--}
+-- |
+-- Module      : Brassica.SoundChange.Apply.Internal.MultiZipper
+-- Copyright   : See LICENSE file
+-- License     : BSD3
+-- Maintainer  : Brad Neimann
+--
+-- __Warning:__ This module is __internal__, and does __not__ follow
+-- the Package Versioning Policy. It may be useful for extending
+-- Brassica, but be prepared to track development closely if you import
+-- this module.
 module Brassica.SoundChange.Apply.Internal.MultiZipper
        ( MultiZipper
        -- * Conversion
@@ -32,6 +37,7 @@        -- * Modification
        , insert
        , insertMany
+       , reverseMZ
        , zap
        , tag
        , tagAt
@@ -63,11 +69,11 @@ -- positioned at the index one past the end of the list, rather than
 -- at the last element of the list. Although this makes some functions
 -- slightly more complex — most notably, 'value' becomes non-total —
--- it makes other algorithms simpler. For instance, this lets
--- functions processing a 'MultiZipper' to process a portion of the
--- 'MultiZipper' and then move to the next element immediately after
--- the processed portion, allowing another function to be run to
--- process the next part of the 'MultiZipper'.)
+-- it makes sound changes application easier to implement. In
+-- particular, it means that functions processing a portion of a
+-- 'MultiZipper' can finish by moving to the next element immediately
+-- after the processed portion; any subsequent function will then
+-- continue by processing the next part of the 'MultiZipper'.)
 data MultiZipper t a = MultiZipper (V.Vector a) Int (M.Map t Int)
     deriving (Show, Functor, Foldable, Traversable)
 
@@ -87,6 +93,16 @@ -- | Get the list stored in a 'MultiZipper'.
 toList :: MultiZipper t a -> [a]
 toList (MultiZipper as _ _) = V.toList as
+
+-- | Reverse the contents of a 'MultiZipper', ensuring its current
+-- position and tags remain attatched to their elements.
+reverseMZ :: MultiZipper t a -> MultiZipper t a
+reverseMZ (MultiZipper as pos ts) =
+    let l = length as
+    in MultiZipper
+        (V.reverse as)
+        (l - pos)
+        (M.map (l-) ts)
 
 -- | The current position of the 'MultiZipper'.
 curPos :: MultiZipper t a -> Int
− src/Brassica/SoundChange/Category.hs
@@ -1,181 +0,0 @@-{-# LANGUAGE DataKinds #-}
-{-# LANGUAGE DeriveAnyClass #-}
-{-# LANGUAGE DeriveGeneric #-}
-{-# LANGUAGE GADTs #-}
-{-# LANGUAGE KindSignatures #-}
-{-# LANGUAGE LambdaCase #-}
-{-# LANGUAGE TupleSections #-}
-
-module Brassica.SoundChange.Category
-       ( Categories
-       , Brassica.SoundChange.Category.lookup
-       , values
-       , ExpandError(..)
-       , expand
-       , expandRule
-       , extendCategories
-       , expandSoundChanges
-       ) where
-
-import Prelude hiding (lookup)
-import Control.DeepSeq (NFData)
-import Control.Monad (foldM, unless)
-import Control.Monad.State.Strict (StateT, evalStateT, lift, get, put, gets)
-import Data.Containers.ListUtils (nubOrd)
-import Data.List (intersect, transpose, foldl')
-import Data.Maybe (mapMaybe, catMaybes)
-import GHC.Generics (Generic)
-
-import qualified Data.Map.Strict as M
-
-import Brassica.SoundChange.Types
-import Data.Traversable (for)
-
--- | A map from names to the (expanded) categories they
--- reference. Used to resolve cross-references between categories.
-type Categories = M.Map String (Expanded 'AnyPart)
-
--- | Lookup a category name in 'Categories'.
-lookup :: String -> Categories -> Maybe (Expanded a)
-lookup = (fmap generaliseExpanded .) . M.lookup
-
--- | Returns a list of every value mentioned in a set of
--- 'Categories'
-values :: Categories -> [Either Grapheme [Lexeme Expanded 'AnyPart]]
-values = nubOrd . concatMap elements . M.elems
-
--- Errors which can be emitted while inlining or expanding category
--- definitions.
-data ExpandError
-    = NotFound String
-      -- ^ A category with that name was not found
-    | InvalidBaseValue
-      -- ^ A 'Lexeme' was used as a base value in a feature
-    | MismatchedLengths
-      -- ^ A 'FeatureSpec' contained a mismatched number of values
-    deriving (Show, Generic, NFData)
-
--- | Given a category, return the list of values which it
--- matches.
-expand :: Categories -> CategorySpec a -> Either ExpandError (Expanded a)
-expand cs (MustInline g) = maybe (Left $ NotFound g) Right $ lookup g cs
-expand cs (CategorySpec spec) = FromElements <$> foldM go [] spec
-  where
-    go es (modifier, e) = do
-        new <- case e of
-            Left (GMulti g)
-                | Just (FromElements c) <- lookup g cs
-                -> pure c
-                | otherwise -> pure [Left (GMulti g)]
-            Left GBoundary -> pure [Left GBoundary]
-            Right ls -> pure . Right <$> traverse (expandLexeme cs) ls
-        pure $ case modifier of
-            Union -> es ++ new
-            Intersect -> es `intersect` new
-            Subtract -> es `subtractAll` new
-
-    -- NB. normal (\\) only removes the first matching element
-    subtractAll xs ys = filter (`notElem` ys) xs
-
-expandLexeme :: Categories -> Lexeme CategorySpec a -> Either ExpandError (Lexeme Expanded a)
-expandLexeme cs (Grapheme (GMulti g))
-    | Just (g', '~') <- unsnoc g
-        = Right $ Grapheme $ GMulti g'
-    | otherwise = Right $
-        case lookup g cs of
-            Just c -> Category c
-            Nothing -> Grapheme (GMulti g)
-  where
-    -- taken from base-4.19
-    unsnoc :: [a] -> Maybe ([a], a)
-    unsnoc = foldr (\x -> Just . maybe ([], x) (\(~(a, b)) -> (x : a, b))) Nothing
-    {-# INLINABLE unsnoc #-}
-
-expandLexeme _  (Grapheme GBoundary) = Right $ Grapheme GBoundary
-expandLexeme cs (Category c) = Category <$> expand cs c
-expandLexeme cs (Optional ls) = Optional <$> traverse (expandLexeme cs) ls
-expandLexeme _  Metathesis = Right Metathesis
-expandLexeme _  Geminate = Right Geminate
-expandLexeme cs (Wildcard l) = Wildcard <$> expandLexeme cs l
-expandLexeme cs (Kleene l) = Kleene <$> expandLexeme cs l
-expandLexeme _  Discard = Right Discard
-expandLexeme cs (Backreference i c) = Backreference i <$> expand cs c
-expandLexeme cs (Multiple c) = Multiple <$> expand cs c
-
-expandRule :: Categories -> Rule CategorySpec -> Either ExpandError (Rule Expanded)
-expandRule cs r = Rule
-    <$> traverse (expandLexeme cs) (target r)
-    <*> traverse (expandLexeme cs) (replacement r)
-    <*> traverse expandEnvironment (environment r)
-    <*> traverse expandEnvironment (exception r)
-    <*> pure (flags r)
-    <*> pure (plaintext r)
-  where
-    expandEnvironment (e1, e2) = (,)
-        <$> traverse (expandLexeme cs) e1
-        <*> traverse (expandLexeme cs) e2
-
-expandFilter :: Categories -> Filter CategorySpec -> Either ExpandError (Filter Expanded)
-expandFilter cs (Filter p f) = Filter p <$> traverse (expandLexeme cs) f
-
-extendCategories
-    :: Categories
-    -> (Bool, [CategoryDefinition])  -- ^ The fields of a v'Categories' directive
-    -> Either ExpandError Categories
-extendCategories cs' (overwrite, defs) =
-    foldM go (if overwrite then M.empty else cs') defs
-  where
-    go :: Categories -> CategoryDefinition -> Either ExpandError Categories
-    go cs (DefineCategory name val) = flip (M.insert name) cs <$> expand cs val
-    go cs (DefineFeature spec) = do
-        baseValues <- expand cs $ featureBaseValues spec
-        derivedCats <- traverse (traverse $ expand cs) $ featureDerived spec
-
-        baseValues' <- for (elements baseValues) $ \case
-            Left (GMulti g) -> Right g
-            _ -> Left InvalidBaseValue
-        let baseLen = length baseValues'
-            derivedValues = elements . snd <$> derivedCats
-        unless (all ((==baseLen) . length) derivedValues) $
-            Left MismatchedLengths
-
-        let features = zipWith
-               (\base ds -> (base, FromElements $ Left (GMulti base) : ds))
-               baseValues'
-               (transpose derivedValues)
-            newCats =
-                maybe [] (pure . (,baseValues)) (featureBaseName spec)
-                ++ derivedCats
-                ++ features
-        Right $ foldl' (flip $ uncurry M.insert) cs newCats
-
-expandSoundChanges
-    :: SoundChanges CategorySpec Directive
-    -> Either ExpandError (SoundChanges Expanded [Grapheme])
-expandSoundChanges = fmap catMaybes . flip evalStateT (M.empty, []) . traverse go
-  where
-    go  :: Statement CategorySpec Directive
-        -> StateT
-            (Categories, [String])
-            (Either ExpandError)
-            (Maybe (Statement Expanded [Grapheme]))
-    go (RuleS r) = do
-        cs <- gets fst
-        lift $ Just . RuleS <$> expandRule cs r
-    go (FilterS f) = do
-        cs <- gets fst
-        lift $ Just . FilterS <$> expandFilter cs f
-    go (DirectiveS (ExtraGraphemes extra)) = do
-        (cs, _) <- get
-        put (cs, extra)
-        pure Nothing
-    go (DirectiveS (Categories overwrite noreplace defs)) = do
-        (cs, extra) <- get
-        cs' <- lift $ extendCategories cs (overwrite, defs)
-        put (cs', extra)
-        pure $ if noreplace
-            then Nothing
-            else Just $ DirectiveS $ fmap GMulti extra ++ mapMaybe left (values cs')
-
-    left (Left l) = Just l
-    left (Right _) = Nothing
+ src/Brassica/SoundChange/Expand.hs view
@@ -0,0 +1,373 @@+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE KindSignatures #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE TupleSections #-}
+
+-- |
+-- Module      : Brassica.SoundChange.Expand
+-- Copyright   : See LICENSE file
+-- License     : BSD3
+-- Maintainer  : Brad Neimann
+--
+-- This module implements the process of /expansion/, from Brassica’s
+-- surface syntax to a simpler representation in which all categories,
+-- features and autosegments have been inlined. For further
+--
+-- In the surface syntax, each category is represented as a
+-- 'CategorySpec', a description in terms of predefined categories
+-- combined with category operations. Expansion converts each one to
+-- an 'Expanded' list of graphemes.
+--
+-- Similarly, category definitions are parsed as 'Directive's. Once
+-- inlined, these can be replaced with simple 'GraphemeList's to be
+-- used for filtering graphemes.
+module Brassica.SoundChange.Expand
+       (
+       -- * Main function
+         expandSoundChanges
+       , ExpandError(..)
+       -- * Expanding individual elements
+       , expand
+       , expandRule
+       , extendCategories
+       -- * Categories
+       , Categories
+       , AutosegmentDef(..)
+       , Brassica.SoundChange.Expand.lookup
+       , values
+       ) where
+
+import Prelude hiding (lookup)
+import Control.DeepSeq (NFData)
+import Control.Monad (foldM, unless)
+import Control.Monad.State.Strict (StateT, evalStateT, lift, get, put, gets)
+import Data.Bifunctor (first, second)
+import Data.Containers.ListUtils (nubOrd)
+import Data.List (transpose, foldl', stripPrefix)
+import Data.Maybe (mapMaybe, catMaybes)
+import Data.Traversable (for)
+import GHC.Generics (Generic)
+
+import qualified Data.Map.Strict as M
+import qualified Data.Map.Merge.Strict as M
+
+import Brassica.SoundChange.Types
+
+-- | Expanding an autosegment from a grapheme requires knowing its
+-- feature name, and a set of graphemes cross-cutting that feature.
+-- (Note that 'autoGraphemes' includes the originally-written
+-- grapheme.)
+data AutosegmentDef = AutosegmentDef
+    { autoFeature :: String
+    , autoGraphemes :: [String]
+    }
+    deriving (Eq, Show)
+
+-- | A map from names to the (expanded) categories or autosegments
+-- they reference. Used to resolve cross-references between
+-- categories.
+type Categories = M.Map String (Either (Expanded 'AnyPart) AutosegmentDef)
+
+-- | Lookup a category name in t'Categories'.
+lookup :: String -> Categories -> Maybe (Either (Expanded a) AutosegmentDef)
+lookup = (fmap (first generaliseExpanded) .) . M.lookup
+
+-- | Returns a list of every value mentioned in a set of
+-- t'Categories'
+values :: Categories -> [[Lexeme Expanded 'AnyPart]]
+values = nubOrd . concatMap (either elements autoElements) . M.elems
+  where
+    autoElements = fmap (pure . Grapheme) . autoGraphemes
+
+-- | Errors which can be emitted while inlining or expanding category
+-- definitions.
+data ExpandError
+    = NotFound String
+      -- ^ A category with the given name was not found
+    | InvalidBaseValue
+      -- ^ A 'Lexeme' was used as a base value in a feature
+    | InvalidDerivedValue
+      -- ^ A 'Lexeme' was used as a derived value in an autosegment
+    | MismatchedLengths
+      -- ^ A phonetic feature or 'FeatureSpec' contained a mismatched number of values
+    deriving (Show, Generic, NFData)
+
+-- | Given an unexpanded category, return the list of values which it
+-- matches.
+expand :: Categories -> CategorySpec a -> Either ExpandError (Expanded a)
+expand cs (MustInline g) = case lookup g cs of
+    Just (Left expanded) -> Right expanded
+    _ -> Left $ NotFound g
+expand cs (CategorySpec spec) = FromElements <$> foldM go [] spec
+  where
+    go :: [CategoryElement Expanded a]
+                  -> (CategoryModification, CategoryElement CategorySpec a)
+                  -> Either ExpandError [CategoryElement Expanded a]
+    go es (modifier, e) = do
+        (new, modifier') <- case e of
+            [Grapheme g]
+                | Just (g', '~') <- unsnoc g
+                    -> pure ([[Grapheme g']], modifier)
+                | modifier == Intersect
+                , Just (Left (FromElements c)) <- lookup ('+':g) cs
+                    -> pure (c, Intersect)
+                | modifier == Subtract
+                , Just (Left (FromElements c)) <- lookup ('-':g) cs
+                    -> pure (c, Intersect)  -- do intersection with negative instead!
+                | '&':g' <- g
+                , Just (Left (FromElements p)) <- lookup ('+':g') cs
+                , Just (Left (FromElements n)) <- lookup ('-':g') cs
+                    -> pure (n++p, modifier)
+                | Just (Left (FromElements c)) <- lookup g cs
+                    -> pure (c, modifier)
+                | Just (Right _) <- lookup g cs
+                    -- re-expand to produce appropriate 'Auto'
+                    -> (,modifier) . pure . pure <$> expandLexeme cs (Grapheme g)
+
+                    -- Note: there are other options for design here
+                    -- see https://verduria.org/viewtopic.php?p=85766#p85766
+                    -- | Just (Right (AutosegmentDef _ gs)) <- lookup g cs
+                    -- 1. -> pure ([Left (GMulti g)], modifier)
+                    -- 2. -> pure (Left . GMulti <$> g:gs, modifier)
+                | otherwise -> pure ([[Grapheme g]], modifier)
+            ls -> (,modifier) . pure <$> traverse (expandLexeme cs) ls
+        pure $ case modifier' of
+            Union -> es ++ new
+            -- important: intersection preserves order of the /last/ category mentioned!
+            Intersect -> es `intersectC` new
+            Subtract -> es `subtractC` new
+
+    -- Set operations, also looking into 'Autosegment's
+    subtractC, intersectC
+        :: [[Lexeme Expanded a]]
+        -> [[Lexeme Expanded a]]
+        -> [[Lexeme Expanded a]]
+
+    subtractC es new = mapMaybe go' es
+      where
+        go' g | g `elemAuto` new = Nothing
+        go' [Autosegment n kvs gs] =
+            Just [Autosegment n
+                  (filterkvs (`notElem` new) kvs)
+                  (filter ((`notElem` new) . pure . Grapheme) gs)]
+        go' g = Just g
+
+    intersectC es new = mapMaybe go' new
+      where
+        go' g | g `elemAuto` es = Just g
+        go' [Autosegment n kvs gs] =
+            Just [Autosegment n
+                  (filterkvs (`elem` es) kvs)
+                  (filter ((`elem` es) . pure . Grapheme) gs)]
+        go' _ = Nothing
+
+    elemAuto :: [Lexeme Expanded a] -> [[Lexeme Expanded a]] -> Bool
+    elemAuto _ [] = False
+    elemAuto g'@[Grapheme gm] ([Autosegment _ _ gs]:ls) = (gm `elem` gs) || elemAuto g' ls
+    elemAuto g' (g:ls) = (g' == g) || elemAuto g' ls
+
+    filterkvs :: ([Lexeme Expanded a] -> Bool) -> [[(Grapheme, Bool)]] -> [[(Grapheme, Bool)]]
+    filterkvs p = fmap . fmap $ \(g, b) -> (g, b && p [Grapheme g])
+
+expandLexeme :: Categories -> Lexeme CategorySpec a -> Either ExpandError (Lexeme Expanded a)
+expandLexeme cs (Grapheme g)
+    | Just (g', '~') <- unsnoc g
+        = Right $ Grapheme g'
+    | otherwise =
+        case lookup g cs of
+            Just (Left c) -> Right $ Category c
+            Just (Right a) -> do
+                kvs <- expandFeature cs (autoFeature a)
+                pure $ Autosegment (autoFeature a) ((fmap.fmap) (,True) kvs) (autoGraphemes a)
+            Nothing -> Right $ Grapheme g
+expandLexeme cs (Category c) = Category <$> expand cs c
+expandLexeme cs (GreedyCategory c) = GreedyCategory <$> expand cs c
+expandLexeme cs (Optional ls) = Optional <$> traverse (expandLexeme cs) ls
+expandLexeme cs (GreedyOptional ls) = GreedyOptional <$> traverse (expandLexeme cs) ls
+expandLexeme _  Metathesis = Right Metathesis
+expandLexeme _  Geminate = Right Geminate
+expandLexeme cs (Wildcard l) = Wildcard <$> expandLexeme cs l
+expandLexeme cs (Kleene l) = Kleene <$> expandLexeme cs l
+expandLexeme _  Discard = Right Discard
+expandLexeme cs (Backreference i c) = Backreference i <$> expand cs c
+expandLexeme cs (Multiple c) = Multiple <$> expand cs c
+expandLexeme cs (Feature r n i [] l) = do
+    kvs <- expandFeature cs n
+    l' <- expandLexeme cs l
+    pure $ Feature r n i kvs l'
+expandLexeme cs (Feature r n i kvs l) = Feature r n i kvs <$> expandLexeme cs l
+expandLexeme _  (Autosegment n kvs gs) =
+    -- in reality this case should never occur from parsed sound changes
+    pure $ Autosegment n kvs gs
+
+expandFeature :: Categories -> String -> Either ExpandError [[String]]
+expandFeature cs n = transpose . fmap snd <$> lookupFeature cs n
+
+lookupFeature
+    :: Categories
+    -> String  -- ^ Feature name (no +/- prefix or +value suffix)
+    -> Either ExpandError [(String, [String])]
+lookupFeature cs n =
+    let pluss :: M.Map String (String, Either (Expanded 'AnyPart) AutosegmentDef)
+        pluss = M.mapMaybeWithKey plusPrefix cs
+    -- NB. consistency is guaranteed as 'elems' always returns items in ascending order
+    in case M.elems pluss of
+        [] -> Left $ NotFound ('+':n)
+        [("", Left (FromElements positive))] ->
+            case M.lookup ('-':n) cs of
+                Just (Left (FromElements negative))
+                    | length positive /= length negative -> Left MismatchedLengths
+                    | Just positive' <- traverse getBaseValue positive
+                    , Just negative' <- traverse getBaseValue negative
+                    -> Right [("-", negative'), ("+", positive')]
+                    | otherwise -> Left InvalidBaseValue
+                _ -> Left $ NotFound ('-':n)
+        kvs -> case traverse getCategory kvs of
+            Just vs@(v:vs')
+                | any ((length v /=) . length) vs' -> Left MismatchedLengths
+                | Just vs'' <- traverse (traverse getBaseValue) vs
+                -> Right $ zip (fst <$> kvs) vs''
+            _ -> Left InvalidBaseValue
+  where
+    plusPrefix ('+':k) v = case stripPrefix n k of
+        Just ('+':k') -> Just (k', v)
+        Just "" -> Just ("", v)
+        _ -> Nothing
+    plusPrefix _ _ = Nothing
+
+    getCategory (_, Left (FromElements c)) = Just c
+    getCategory _ = Nothing
+
+getBaseValue :: CategoryElement Expanded 'AnyPart -> Maybe String
+getBaseValue [Grapheme g] = Just g
+getBaseValue _ = Nothing
+
+-- taken from base-4.19
+unsnoc :: [a] -> Maybe ([a], a)
+unsnoc = foldr (\x -> Just . maybe ([], x) (\(~(a, b)) -> (x : a, b))) Nothing
+{-# INLINABLE unsnoc #-}
+
+-- | Expand all categories in a given sound change 'Rule'.
+expandRule :: Categories -> Rule CategorySpec -> Either ExpandError (Rule Expanded)
+expandRule cs r = Rule
+    <$> traverse (expandLexeme cs) (target r)
+    <*> traverse (expandLexeme cs) (replacement r)
+    <*> traverse expandEnvironment (environment r)
+    <*> traverse expandEnvironment (exception r)
+    <*> pure (flags r)
+    <*> pure (plaintext r)
+  where
+    expandEnvironment (e1, e2) = (,)
+        <$> traverse (expandLexeme cs) e1
+        <*> traverse (expandLexeme cs) e2
+
+expandFilter :: Categories -> Filter CategorySpec -> Either ExpandError (Filter Expanded)
+expandFilter cs (Filter p f) = Filter p <$> traverse (expandLexeme cs) f
+
+-- | Extend a set of previously defined t'Categories' to give the
+-- resulting state after a v'Categories' directive.
+extendCategories
+    :: Categories
+    -> (Bool, [CategoryDefinition])  -- ^ The fields of a v'Categories' directive
+    -> Either ExpandError Categories
+extendCategories cs' (overwrite, defs) =
+    foldM go (if overwrite then M.empty else cs') defs
+  where
+    go :: Categories -> CategoryDefinition -> Either ExpandError Categories
+    go cs (DefineCategory name val) = flip (M.insert name) cs . Left <$> expand cs val
+    go cs (DefineFeature spec) = do
+        baseValues <- expand cs $ featureBaseValues spec
+        derivedCats <- traverse (traverse $ expand cs) $ featureDerived spec
+
+        baseValues' <- for (elements baseValues) $
+            maybe (Left InvalidBaseValue) Right . getBaseValue
+        let baseLen = length baseValues'
+            derivedValues = elements . snd <$> derivedCats
+        unless (all ((==baseLen) . length) derivedValues) $
+            Left MismatchedLengths
+
+        let features = zipWith
+               (\base ds -> (base, FromElements $ [Grapheme base] : ds))
+               baseValues'
+               (transpose derivedValues)
+            newCats = fmap (second Left) $
+                maybe [] (pure . (,baseValues)) (featureBaseName spec)
+                ++ derivedCats
+                ++ features
+        Right $ foldl' (flip $ uncurry M.insert) cs newCats
+    go cs (DefineAuto catName) = do
+        let (featureName, featureValue) = case catName of
+                '-':n -> (n, "-")
+                '+':n -> case break (=='+') n of
+                    (prefix, '+':suffix) -> (prefix, suffix)
+                    (_, []) -> (n, "+")
+                    _ -> error "extendCategories: unexpected output from 'break'"
+                n -> (n, "")  -- let it error out below
+        features <- M.fromList <$> lookupFeature cs featureName
+        case M.lookup featureValue features of
+            Nothing -> Left $ NotFound catName
+            Just gs ->
+                -- NB. consistency is guaranteed as 'elems' always returns items in ascending order
+                let autoCs = M.fromList $
+                        zipWith (mkAuto featureName) gs (transpose $ M.elems features)
+                in pure $ M.merge
+                    M.preserveMissing M.preserveMissing
+                    (M.zipWithMatched $ \_ _ c -> c)
+                    cs autoCs
+
+    mkAuto :: String -> String -> [String] -> (String, Either (Expanded 'AnyPart) AutosegmentDef)
+    mkAuto f g gs = (g, Right $ AutosegmentDef f gs)
+
+-- | Expand a set of 'SoundChanges'. Expansion proceeds from beginning
+-- to end as follows:
+--
+--     * Rules and filters are expanded by expanding all categories
+--       within them (with 'expand'). Graphemes are replaced with
+--       categories or autosegments if previously defined as such.
+--
+--     * If a v'Categories' definition block is found, the categories
+--       defined within it are expanded and added to (or replace) the
+--       list of current categories. The block is replaced with a list
+--       of currently defined graphemes.
+--
+--     * If 'ExtraGraphemes' are found, they are added to a list of
+--       currently defined graphemes. They are replaced with a
+--       'GraphemeList' only if no categories are defined in the
+--       'SoundChanges'.
+expandSoundChanges
+    :: SoundChanges CategorySpec Directive
+    -> Either ExpandError (SoundChanges Expanded GraphemeList)
+expandSoundChanges scs = fmap catMaybes $ flip evalStateT (M.empty, []) $ traverse go scs
+  where
+    noCategories = any (\case DeclS (Categories {}) -> True; _ -> False) scs
+
+    go  :: Statement CategorySpec Directive
+        -> StateT
+            (Categories, [String])
+            (Either ExpandError)
+            (Maybe (Statement Expanded GraphemeList))
+    go (RuleS r) = do
+        cs <- gets fst
+        lift $ Just . RuleS <$> expandRule cs r
+    go (FilterS f) = do
+        cs <- gets fst
+        lift $ Just . FilterS <$> expandFilter cs f
+    go ReportS = pure (Just ReportS)
+    go (DeclS (ExtraGraphemes extra)) = do
+        (cs, _) <- get
+        put (cs, extra)
+        pure $
+            if noCategories
+            then Just $ DeclS $ GraphemeList True extra
+            else Nothing
+    go (DeclS (Categories overwrite noreplace defs)) = do
+        (cs, extra) <- get
+        cs' <- lift $ extendCategories cs (overwrite, defs)
+        put (cs', extra)
+        pure $ Just $ DeclS $ GraphemeList noreplace $ extra ++ mapMaybe grapheme (values cs')
+
+    grapheme [Grapheme g] = Just g
+    grapheme _ = Nothing
src/Brassica/SoundChange/Frontend/Internal.hs view
@@ -1,17 +1,30 @@ {-# LANGUAGE DeriveAnyClass  #-}
 {-# LANGUAGE DeriveGeneric   #-}
 {-# LANGUAGE DeriveTraversable #-}
+{-# LANGUAGE LambdaCase      #-}
 {-# LANGUAGE RankNTypes      #-}
 {-# LANGUAGE TupleSections   #-}
 
-{-| __Warning:__ This module is __internal__, and does __not__ follow
-  the Package Versioning Policy. It may be useful for extending
-  Brassica, but be prepared to track development closely if you import
-  this module.
--}
+-- |
+-- Module      : Brassica.SoundChange.Frontend.Internal
+-- Copyright   : See LICENSE file
+-- License     : BSD3
+-- Maintainer  : Brad Neimann
+--
+-- __Warning:__ This module is __internal__, and does __not__ follow
+-- the Package Versioning Policy. It may be useful for extending
+-- Brassica, but be prepared to track development closely if you import
+-- this module.
+--
+-- This module exists primarily as an internal common interface for
+-- Brassica’s two ‘official’ GUI frontends (desktop and web). If you
+-- wish to make your own frontend to Brassica, it is probably easier
+-- to write it yourself rather than trying to use this.
 module Brassica.SoundChange.Frontend.Internal where
 
 import Control.Monad ((<=<))
+import Data.Containers.ListUtils (nubOrd)
+import Data.List (transpose, intersperse)
 import Data.Maybe (fromMaybe, mapMaybe)
 import Data.Void (Void)
 import GHC.Generics (Generic)
@@ -23,25 +36,30 @@ import Brassica.SFM.MDF
 import Brassica.SFM.SFM
 import Brassica.SoundChange.Apply
-import Brassica.SoundChange.Apply.Internal (applyChangesWithLog, toPWordLog)
 import Brassica.SoundChange.Tokenise
 import Brassica.SoundChange.Types
-import Data.Bifunctor (first)
 
 -- | Rule application mode of the SCA.
 data ApplicationMode
     = ApplyRules HighlightMode OutputMode String
+    -- ^ Apply sound changes as normal, with the given modes and
+    -- separator
     | ReportRulesApplied
+    -- ^ Apply reporting the rules which were applied (as HTML)
     deriving (Show, Eq)
 
+-- | Get the 'OutputMode' if one is set, otherwise default to
+-- 'WordsOnlyOutput'.
 getOutputMode :: ApplicationMode -> OutputMode
 getOutputMode (ApplyRules _ o _) = o
-getOutputMode ReportRulesApplied = WordsOnlyOutput  -- default option
+getOutputMode ReportRulesApplied = WordsOnlyOutput
 
+-- | Mode for highlighting output words
 data HighlightMode
     = NoHighlight
     | DifferentToLastRun
     | DifferentToInput
+    -- ^ NB. now labeled ‘any rule applied’ in GUI
     deriving (Show, Eq)
 instance Enum HighlightMode where
     -- used for conversion to and from C, so want control over values
@@ -54,11 +72,14 @@     toEnum 2 = DifferentToInput
     toEnum _ = undefined
 
+-- | Mode for reporting output words (and sometimes intermediate and
+-- input words too)
 data OutputMode
     = MDFOutput
     | WordsOnlyOutput
     | MDFOutputWithEtymons
     | WordsWithProtoOutput
+    | WordsWithProtoOutputPreserve
     deriving (Show, Eq)
 instance Enum OutputMode where
     -- used for conversion to and from C, so want control over values
@@ -66,11 +87,13 @@     fromEnum WordsOnlyOutput = 1
     fromEnum MDFOutputWithEtymons = 2
     fromEnum WordsWithProtoOutput = 3
+    fromEnum WordsWithProtoOutputPreserve = 4
 
     toEnum 0 = MDFOutput
     toEnum 1 = WordsOnlyOutput
     toEnum 2 = MDFOutputWithEtymons
     toEnum 3 = WordsWithProtoOutput
+    toEnum 4 = WordsWithProtoOutputPreserve
     toEnum _ = undefined
 
 -- | Output of a single application of rules to a wordlist: either a
@@ -78,95 +101,92 @@ -- parse error.
 data ApplicationOutput a r
     = HighlightedWords [Component (a, Bool)]
-    | AppliedRulesTable [PWordLog r]
+    | AppliedRulesTable [Log r]
     | ParseError (ParseErrorBundle String Void)
     deriving (Show, Generic, NFData)
 
+-- | For MDF input, the hierarchy used
+data MDFHierarchy = Standard | Alternate
+    deriving (Show, Eq)
+
 -- | Kind of input: either a raw wordlist, or an MDF file.
-data InputLexiconFormat = Raw | MDF
+data InputLexiconFormat = Raw | MDF MDFHierarchy
     deriving (Show, Eq)
 instance Enum InputLexiconFormat where
     -- used for conversion to and from C, so want control over values
     fromEnum Raw = 0
-    fromEnum MDF = 1
+    fromEnum (MDF Standard) = 1
+    fromEnum (MDF Alternate) = 2
 
     toEnum 0 = Raw
-    toEnum 1 = MDF
+    toEnum 1 = MDF Standard
+    toEnum 2 = MDF Alternate
     toEnum _ = undefined
 
+-- | Either a list of 'Component's for a Brassica wordlist file, or a
+-- list of 'SFM' fields for an MDF file
 data ParseOutput a = ParsedRaw [Component a] | ParsedMDF SFM
     deriving (Show, Functor, Foldable, Traversable)
 
-componentise :: OutputMode -> [a] -> [Component a] -> [Component a]
-componentise WordsWithProtoOutput ws cs = intersperseWords ws cs
-componentise _                    _  cs = cs
-
-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 _ [] = []
-
+-- | Given the selected input and output modes, and the expanded sound
+-- changes, tokenise the input according to the format which was selected
 tokeniseAccordingToInputFormat
     :: InputLexiconFormat
     -> OutputMode
-    -> SoundChanges Expanded [Grapheme]
+    -> SoundChanges Expanded GraphemeList
     -> String
     -> Either (ParseErrorBundle String Void) [Component PWord]
 tokeniseAccordingToInputFormat Raw _ cs =
     withFirstCategoriesDecl tokeniseWords cs
-tokeniseAccordingToInputFormat MDF MDFOutputWithEtymons cs =
-    withFirstCategoriesDecl tokeniseMDF cs <=<
-    -- TODO don't hard-code hierarchy and filename
-    fmap (fromTree . duplicateEtymologies ('*':) . toTree mdfHierarchy)
-    . parseSFM ""
-tokeniseAccordingToInputFormat MDF o cs = \input -> do
+tokeniseAccordingToInputFormat (MDF h) MDFOutputWithEtymons cs =
+    let h' = case h of
+            Standard -> mdfHierarchy
+            Alternate -> mdfAlternateHierarchy
+    in
+        withFirstCategoriesDecl tokeniseMDF cs <=<
+        fmap (fromTree . duplicateEtymologies ('*':) . toTree h')
+        . parseSFM ""
+tokeniseAccordingToInputFormat (MDF _) o cs = \input -> do
     sfm <- parseSFM "" input
     ws <- withFirstCategoriesDecl tokeniseMDF cs sfm
     pure $ case o of
         MDFOutput -> ws
-        _ ->  -- need to extract words for other output modes
-            Word <$> getWords ws
+        _ ->
+            -- need to extract words for other output modes
+            -- also add separators to keep words apart visually
+            intersperse (Separator "\n") $ Word <$> getWords ws
 
 -- | 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.
 parseTokeniseAndApplyRules
     :: (forall a b. (a -> b) -> [Component a] -> [Component b])  -- ^ mapping function to use (for parallelism)
-    -> SoundChanges Expanded [Grapheme] -- ^ changes
+    -> SoundChanges Expanded GraphemeList -- ^ changes
     -> String       -- ^ words
     -> InputLexiconFormat
     -> ApplicationMode
     -> Maybe [Component PWord]  -- ^ previous results
-    -> ApplicationOutput PWord (Statement Expanded [Grapheme])
+    -> ApplicationOutput PWord (Statement Expanded GraphemeList)
 parseTokeniseAndApplyRules parFmap statements ws intype mode prev =
     case tokeniseAccordingToInputFormat intype (getOutputMode mode) statements ws of
         Left e -> ParseError e
-        Right toks
-          | ws' <- getWords toks
-          -> case mode of
+        Right toks -> case mode of
             ReportRulesApplied ->
-                AppliedRulesTable $ mapMaybe toPWordLog $ concat $
-                    getWords $ componentise WordsOnlyOutput [] $
-                        parFmap (applyChangesWithLog statements) toks
+                AppliedRulesTable $ concat $
+                    getWords $ parFmap (applyChanges statements) toks
             ApplyRules DifferentToLastRun mdfout sep ->
                 let result = concatMap (splitMultipleResults sep) $
-                      componentise mdfout (fmap pure ws') $
-                          parFmap (applyChanges statements) toks
+                        joinComponents' mdfout $ parFmap (doApply mdfout statements) toks
                 in HighlightedWords $
                     mapMaybe polyDiffToHighlight $ getDiff (fromMaybe [] prev) result
                     -- zipWithComponents result (fromMaybe [] prev) [] $ \thisWord prevWord ->
                     --     (thisWord, thisWord /= prevWord)
             ApplyRules DifferentToInput mdfout sep ->
                 HighlightedWords $ concatMap (splitMultipleResults sep) $
-                    (fmap.fmap) (mapMaybe extractMaybe) $
-                        componentise mdfout (fmap (pure . first Just . (,False)) ws') $
-                            parFmap (applyChangesWithChanges statements) toks
+                        joinComponents' mdfout $ parFmap (doApplyWithChanges mdfout statements) toks
             ApplyRules NoHighlight mdfout sep ->
                 HighlightedWords $ (fmap.fmap) (,False) $ concatMap (splitMultipleResults sep) $
-                    componentise mdfout (fmap pure ws') $
-                        parFmap (applyChanges statements) toks
+                    joinComponents' mdfout $ parFmap (doApply mdfout statements) toks
   where
     -- highlight words in 'Second' but not 'First'
     polyDiffToHighlight :: PolyDiff (Component a) (Component a) -> Maybe (Component (a, Bool))
@@ -181,5 +201,36 @@     unsafeCastComponent (Separator s) = Separator s
     unsafeCastComponent (Gloss s) = Gloss s
 
-    extractMaybe (Just a, b) = Just (a, b)
-    extractMaybe (Nothing, _) = Nothing
+    doApply :: OutputMode -> SoundChanges Expanded GraphemeList -> PWord -> [Component [PWord]]
+    doApply WordsWithProtoOutput scs w = doApplyWithProto scs w
+    doApply WordsWithProtoOutputPreserve scs w = doApplyWithProto scs w
+    doApply _ scs w = [Word $ mapMaybe getOutput $ applyChanges scs w]
+
+    doApplyWithProto scs w =
+        let intermediates :: [[PWord]]
+            intermediates = fmap nubOrd $ transpose $ getReports <$> applyChanges scs w
+        in intersperse (Separator " → ") (fmap Word intermediates)
+
+    doApplyWithChanges :: OutputMode -> SoundChanges Expanded GraphemeList -> PWord -> [Component [(PWord, Bool)]]
+    doApplyWithChanges WordsWithProtoOutput scs w = doApplyWithChangesWithProto scs w
+    doApplyWithChanges WordsWithProtoOutputPreserve scs w = doApplyWithChangesWithProto scs w
+    doApplyWithChanges _ scs w = [Word $ mapMaybe getChangedOutputs $ applyChanges scs w]
+
+    doApplyWithChangesWithProto scs w =
+        let intermediates :: [[(PWord, Bool)]]
+            intermediates = fmap nubOrd $ transpose $ getChangedReports <$> applyChanges scs w
+        in intersperse (Separator " → ") (fmap Word intermediates)
+
+    joinComponents' WordsWithProtoOutput =
+        joinComponents . intersperse (Separator "\n") . filter (\case Word _ -> True; _ -> False)
+    joinComponents' WordsWithProtoOutputPreserve = joinComponents . linespace
+    joinComponents' _ = joinComponents
+
+    -- Insert newlines as necessary to put each 'Word' on a separate line
+    linespace :: [Component a] -> [Component a]
+    linespace (Separator s:cs)
+        | '\n' `elem` s = Separator s : linespace cs
+        | otherwise = Separator ('\n':s) : linespace cs
+    linespace (c:cs@(Separator _:_)) = c : linespace cs
+    linespace (c:cs) = c : Separator "\n" : linespace cs
+    linespace [] = []
src/Brassica/SoundChange/Parse.hs view
@@ -5,16 +5,26 @@ {-# LANGUAGE RecordWildCards  #-} {-# LANGUAGE ScopedTypeVariables #-} +-- |+-- Module      : Brassica.SoundChange.Parse+-- Copyright   : See LICENSE file+-- License     : BSD3+-- Maintainer  : Brad Neimann+--+-- Functions to parse sound changes in Brassica syntax. (For details+-- on the syntax, refer to the+-- [reference guide](https://github.com/bradrn/brassica/blob/v1.0.0/docs/Reference.md).) module Brassica.SoundChange.Parse     ( parseRule     , parseSoundChanges       -- ** Re-export-    , errorBundlePretty+    , module Text.Megaparsec.Error     ) where  import Data.Char (isSpace) import Data.Foldable (asum)-import Data.Maybe (isNothing, isJust, fromJust)+import Data.List (dropWhileEnd)+import Data.Maybe (isNothing, isJust, fromJust, fromMaybe) import Data.Void (Void)  import Control.Applicative.Permutations@@ -23,6 +33,7 @@ import Text.Megaparsec hiding (State) import Text.Megaparsec.Char import qualified Text.Megaparsec.Char.Lexer as L+import Text.Megaparsec.Error  import Brassica.SoundChange.Types @@ -31,15 +42,15 @@ class ParseLexeme (a :: LexemeType) where     parseLexeme :: Parser (Lexeme CategorySpec a) --- space consumer which does not match newlines+-- space consumer which does not match newlines or comments sc :: Parser ()-sc = L.space space1' (L.skipLineComment ";") empty+sc = L.space space1' empty empty   where     -- adapted from megaparsec source: like 'space1', but does not     -- consume newlines (which are important for rule separation)     space1' = void $ takeWhile1P (Just "white space") ((&&) <$> isSpace <*> (/='\n')) --- space consumer which matches newlines+-- space consumer which matches newlines and comments scn :: Parser () scn = L.space space1 (L.skipLineComment ";") empty @@ -50,7 +61,7 @@ symbol = L.symbol sc  keyChars :: [Char]-keyChars = "#[](){}>\\→/_^%~*@"+keyChars = "#[](){}>\\→/_^%~*@$;"  nonzero :: Parser Int nonzero = label "nonzero postive number" $ try $ do@@ -60,14 +71,17 @@  parseGrapheme :: Parser Grapheme parseGrapheme = lexeme $-    GBoundary <$ char '#'-    <|> GMulti <$> parseGrapheme'+    pure <$> char '#'+    <|> parseGrapheme' True -parseGrapheme' :: Parser String-parseGrapheme' = lexeme $ do+parseGrapheme' :: Bool -> Parser String+parseGrapheme' wantTilde = lexeme $ do     star <- optional (char '*')     rest <- takeWhile1P Nothing (not . ((||) <$> isSpace <*> (`elem` keyChars)))-    nocat <- optional (char '~')+    nocat <-+        if wantTilde+        then optional (char '~')+        else pure Nothing     pure .         maybe id (const ('*':)) star .         maybe id (const (++"~")) nocat@@ -76,51 +90,62 @@ parseExplicitCategory :: ParseLexeme a => Parser (Lexeme CategorySpec a) parseExplicitCategory = Category <$> parseExplicitCategory' +parseGreedyCategory :: Parser (Lexeme CategorySpec 'Matched)+parseGreedyCategory = GreedyCategory <$> (char '%' *> parseExplicitCategory')+ parseExplicitCategory' :: ParseLexeme a => Parser (CategorySpec a)-parseExplicitCategory' =-    CategorySpec <$> (symbol "[" *> someTill parseCategoryModification (symbol "]"))+parseExplicitCategory' = fmap CategorySpec $+    (:) <$> (symbol "[" *> parseCategoryModification True)+        <*> manyTill (parseCategoryModification False) (symbol "]")  -- This is unused currently, but convenient to keep around just in case -- parseCategory :: ParseLexeme a => Parser (Lexeme CategorySpec a) -- parseCategory = Category <$> parseCategory'  parseCategory' :: ParseLexeme a => Parser (CategorySpec a)-parseCategory' = parseExplicitCategory' <|> MustInline <$> parseGrapheme'+parseCategory' = parseExplicitCategory' <|> MustInline <$> parseGrapheme' True  parseCategoryStandalone     :: Parser (String, CategorySpec 'AnyPart) parseCategoryStandalone = do-    g <- parseGrapheme'+    g <- parseGrapheme' True     _ <- symbol "="-    mods <- some parseCategoryModification+    mods <- some (parseCategoryModification False)     return (g, CategorySpec mods)  parseFeature :: Parser FeatureSpec parseFeature = do     _ <- symbol "feature"-    featureBaseName <- optional $ try $ parseGrapheme' <* symbol "="-    featureBaseValues <- CategorySpec <$> some parseCategoryModification+    featureBaseName <- optional $ try $ parseGrapheme' False <* symbol "="+    featureBaseValues <- CategorySpec <$> some (parseCategoryModification False)     featureDerived <- some (symbol "/" *> parseCategoryStandalone) <* scn     pure FeatureSpec { featureBaseName, featureBaseValues, featureDerived } +parseAuto :: Parser String+parseAuto = symbol "auto" *> parseGrapheme' False <* scn+ parseCategoryModification     :: ParseLexeme a-    => Parser (CategoryModification, Either Grapheme [Lexeme CategorySpec a])-parseCategoryModification = (,)-    <$> parsePrefix-    <*> ( (Right <$> (symbol "{" *> manyTill parseLexeme (symbol "}")))-        <|> (Left <$> parseGrapheme))+    => Bool+    -> Parser (CategoryModification, [Lexeme CategorySpec a])+parseCategoryModification forceUnion = (,)+    <$> (if forceUnion+           then Union <$ optional (char '&')+           else parsePrefix)+    <*> (symbol "{" *> manyTill parseLexeme (symbol "}")+        <|> (pure . Grapheme <$> parseGrapheme))   where     parsePrefix =         (Intersect <$ char '+')         <|> (Subtract <$ char '-')+        <|> (Union <$ char '&')  -- necessary for featural categories         <|> pure Union  parseDirective :: Parser Directive parseDirective = parseCategoriesDirective <|> parseExtraDirective   where     parseExtraDirective = fmap ExtraGraphemes $-        symbol "extra" *> many parseGrapheme' <* scn+        symbol "extra" *> many (parseGrapheme' False) <* scn      parseCategoriesDirective = do         overwrite <- isJust <$> optional (symbol "new")@@ -129,6 +154,7 @@         scn         cs <- some $             DefineFeature <$> parseFeature <|>+            DefineAuto <$> parseAuto <|>             uncurry DefineCategory <$> (try parseCategoryStandalone <* scn)         _ <- symbol "end" <* scn         pure $ Categories overwrite noreplace cs@@ -136,6 +162,9 @@ parseOptional :: ParseLexeme a => Parser (Lexeme CategorySpec a) parseOptional = Optional <$> between (symbol "(") (symbol ")") (some parseLexeme) +parseGreedyOptional :: Parser (Lexeme CategorySpec 'Matched)+parseGreedyOptional = GreedyOptional <$> between (symbol "%(") (symbol ")") (some parseLexeme)+ parseGeminate :: Parser (Lexeme CategorySpec a) parseGeminate = Geminate <$ symbol ">" @@ -148,26 +177,44 @@ parseDiscard :: Parser (Lexeme CategorySpec 'Replacement) parseDiscard = Discard <$ symbol "~" -parseKleene :: Lexeme CategorySpec a -> Parser (Lexeme CategorySpec a)-parseKleene l =-    try (lexeme $ Kleene l <$ char '*' <* notFollowedBy parseGrapheme')+parsePost :: Lexeme CategorySpec a -> Parser (Lexeme CategorySpec a)+parsePost l =+    try parseFeatureApp+    <|> try (lexeme $ Kleene l <$ char '*' <* notFollowedBy (parseGrapheme' True))     <|> pure l+  where+    parseFeatureApp =+        Feature <$ char '$'+        <*> fmap isJust (optional $ char '-')+        <*> parseGrapheme' False+        <*> optional (char '#' *> parseGrapheme' False)+        <*> fmap (fromMaybe [])+            ( optional $ between (symbol "(") (symbol ")") $+              many $ lexeme $ parseGrapheme' False `sepBy1` char '~'+            )+        <*> pure l  parseMultiple :: Parser (Lexeme CategorySpec 'Replacement) parseMultiple = Multiple <$> (symbol "@?" *> parseCategory')  parseBackreference :: forall a. ParseLexeme a => Parser (Lexeme CategorySpec a)-parseBackreference = Backreference <$> (symbol "@" *> nonzero) <*> parseCategory'+parseBackreference = Backreference <$> (symbol "@" *> ref) <*> parseCategory'+  where+    ref =+        Left <$> (char '#' *> parseGrapheme' False)+        <|> Right <$> nonzero  instance ParseLexeme 'Matched where     parseLexeme = asum         [ parseExplicitCategory         , parseOptional+        , parseGreedyOptional+        , parseGreedyCategory         , parseGeminate         , parseWildcard         , parseBackreference         , Grapheme <$> parseGrapheme-        ] >>= parseKleene+        ] >>= parsePost  instance ParseLexeme 'Replacement where     parseLexeme = asum@@ -180,7 +227,7 @@         , parseWildcard         , parseBackreference         , Grapheme <$> parseGrapheme-        ] >>= parseKleene+        ] >>= parsePost  instance ParseLexeme 'AnyPart where     parseLexeme = asum@@ -188,7 +235,7 @@         , parseOptional         , parseWildcard         , Grapheme <$> parseGrapheme-        ] >>= parseKleene+        ] >>= parsePost  parseLexemes :: ParseLexeme a => Parser [Lexeme CategorySpec a] parseLexemes = many parseLexeme@@ -200,6 +247,7 @@     <*> toPermutation (isJust <$> optional (symbol "-1"))     <*> toPermutationWithDefault ApplyAlways         ((PerApplication <$ symbol "-??") <|> (PerWord <$ symbol "-?"))+    <*> toPermutation (isJust <$> optional (symbol "-no"))  ruleParser :: Parser (Rule CategorySpec) ruleParser = do@@ -228,29 +276,34 @@      exception <- optional $ (,) <$> (symbol "//" *> parseLexemes) <* symbol "_" <*> parseLexemes +    o' <- getOffset+     _ <- optional scn   -- consume newline after rule if present -    o' <- getOffset-    let plaintext = takeWhile notNewline $ (fst . fromJust) (takeN_ (o' - o) s)+    let plaintext = dropWhile isSpace $ dropWhileEnd isSpace $+            (fst . fromJust) (takeN_ (o' - o) s)     return Rule{environment=envs, ..}-  where-    notNewline c = (c /= '\n') && (c /= '\r')  filterParser :: Parser (Filter CategorySpec) filterParser = fmap (uncurry Filter) $ match $ symbol "filter" *> parseLexemes <* optional scn --- | 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.3.0/Documentation.md#basic-rule-syntax>.+-- Space handline is a little complex here: we want to make sure that+-- 'report' is always on its own line, but can have as much or as+-- little space after it as needed+reportParser :: Parser ()+reportParser = symbol "report" *> sc *> ((newline *> void (optional scn)) <|> eof)++-- | Parse a single sound change into a 'Rule'. Returns 'Left' if the+-- input string is malformed. parseRule :: String -> Either (ParseErrorBundle String Void) (Rule CategorySpec) parseRule = runParser (scn *> ruleParser <* eof) "" --- | Parse a list of 'SoundChanges'.+-- | Parse a sound change file into a set of 'SoundChanges'. parseSoundChanges :: String -> Either (ParseErrorBundle String Void) (SoundChanges CategorySpec Directive) parseSoundChanges = runParser (scn *> parser <* eof) ""   where     parser = many $-        DirectiveS <$> parseDirective+        DeclS <$> parseDirective         <|> FilterS <$> filterParser+        <|> ReportS <$ reportParser         <|> RuleS <$> ruleParser
src/Brassica/SoundChange/Tokenise.hs view
@@ -5,18 +5,27 @@ {-# LANGUAGE TypeFamilies      #-} {-# LANGUAGE ViewPatterns      #-} +-- |+-- Module      : Brassica.SoundChange.Tokenise+-- Copyright   : See LICENSE file+-- License     : BSD3+-- Maintainer  : Brad Neimann+--+-- This module provides functions to parse a Brassica words file into+-- its constituent 'Component's, and to tokenise the words in that+-- file into their constituent graphemes to form 'PWord's. It also+-- provides functions to reverse these processes. module Brassica.SoundChange.Tokenise-       ( -       -- * Components-         Component(..)+       (+       -- * High-level interface+         tokeniseWord+       , Component(..)        , getWords        , splitMultipleResults-       -- * High-level interface-       , tokeniseWord+       , joinComponents        , tokeniseWords        , detokeniseWords'        , detokeniseWords-       , concatWithBoundary        , findFirstCategoriesDecl        , withFirstCategoriesDecl        -- * Lower-level functions@@ -40,12 +49,22 @@  import Brassica.SoundChange.Types --- | Represents a component of a tokenised input string. v'Word's in--- the input are represented as the type parameter @a@ — which for--- this reason will usually, though not always, be 'PWord'.-data Component a = Word a | Separator String | Gloss String+-- | Represents a component of a Brassica words file. Each word in the+-- input has type @a@ (often 'PWord' or @['PWord']@).+data Component a+    = Word a            -- ^ An input word to which sound changes will be applied+    | Separator String  -- ^ A separator, e.g. whitespace+    | Gloss String      -- ^ A gloss (in Brassica syntax, between square brackets)     deriving (Eq, Show, Functor, Foldable, Traversable, Generic, NFData) +-- | Flatten a nested list of 'Component's.+joinComponents :: [Component [Component a]] -> [Component a]+joinComponents = concatMap go+  where+    go (Word cs) = cs+    go (Separator s) = [Separator s]+    go (Gloss s) = [Gloss s]+ -- | Given a tokenised input string, return only the v'Word's within -- it. getWords :: [Component a] -> [a]@@ -59,8 +78,8 @@ -- -- For instance: ----- >>> splitMultipleResults " " (Word ["abc", "def", "ghi"])--- [Word "abc", Separator " ", Word "def", Separator " ", Word "ghi"]+-- >>> splitMultipleResults "/" (Word ["abc", "def", "ghi"])+-- [Word "abc", Separator "/", Word "def", Separator "/", Word "ghi"] -- -- >>> splitMultipleResults " " (Word ["abc"]) -- [Word "abc"]@@ -68,30 +87,31 @@ splitMultipleResults wh (Word as) = intersperse (Separator wh) $ Word <$> as splitMultipleResults _ (Separator w) = [Separator w] splitMultipleResults _ (Gloss g) = [Gloss g]-    + -- | Megaparsec parser for 'PWord's — see 'tokeniseWord' documentation--- for details on the parsing strategy and the meaning of the second--- parameter. For most usecases 'tokeniseWord' should suffice;--- '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. The second--- gives a list of multigraphs which might be expected.+-- for details on the parsing strategy. For most usecases+-- 'tokeniseWord' should suffice; 'wordParser' itself is only really+-- useful in unusual situations (e.g. as part of a larger parser). ----- Note: the second parameter __must__ be 'sortByDescendingLength'-ed;--- otherwise multigraphs will not be parsed correctly.+-- 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. The second gives a list of+-- multigraphs which might be expected, as with 'tokeniseWord'.+--+-- Note: the second parameter __must__ be already be sorted by descending length;+-- otherwise multigraphs will not be parsed correctly (i.e. greedily). wordParser :: [Char] -> [String] -> ParsecT Void String Identity PWord wordParser excludes gs = some $-    (GBoundary <$ single '#')-    <|> choice (fmap GMulti . chunk <$> gs)-    <|> (GMulti . pure <$> satisfy (not . exclude))+    ("#" <$ single '#')+    <|> choice (chunk <$> gs)+    <|> (pure <$> satisfy (not . exclude))   where     exclude = (||) <$> isSpace <*> (`elem` excludes)  -- | Megaparsec parser for 'Component's. Similarly to 'wordParser', -- usually it’s easier to use 'tokeniseWords' instead. componentsParser-    :: ParsecT Void String Identity a+    :: ParsecT Void String Identity a  -- ^ Parser for individual words (e.g. 'wordParser')     -> ParsecT Void String Identity [Component a] componentsParser p = many $     (Separator <$> takeWhile1P Nothing isSpace) <|>@@ -106,6 +126,8 @@            then '[' : concat contents ++ "]"            else concat contents +-- | Sort a list of lists by the length of the inner lists, in+-- descending order. sortByDescendingLength :: [[a]] -> [[a]] sortByDescendingLength = sortBy (compare `on` Down . length) @@ -117,30 +139,33 @@ -- 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:+-- take that character as forming its own t'Grapheme'. For instance: -- -- >>> tokeniseWord [] "cherish" -- Right [GMulti "c",GMulti "h",GMulti "e",GMulti "r",GMulti "i",GMulti "s",GMulti "h"]--- +-- -- >>> tokeniseWord ["e","h","i","r","s","sh"] "cherish" -- Right [GMulti "c",GMulti "h",GMulti "e",GMulti "r",GMulti "i",GMulti "sh"]--- +-- -- >>> tokeniseWord ["c","ch","e","h","i","r","s","sh"] "cherish" -- Right [GMulti "ch",GMulti "e",GMulti "r",GMulti "i",GMulti "sh"]+--+-- The resulting 'PWord' can be converted back to a 'String' using+-- 'concatWithBoundary'. (However, it is not strictly speaking a true+-- inverse as it deletes word boundaries). tokeniseWord :: [String] -> String -> Either (ParseErrorBundle String Void) PWord tokeniseWord (sortByDescendingLength -> gs) = parse (wordParser "[" gs) "" --- | 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).+-- | Given a list of available multigraphs, tokenise an input words+-- file 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 (as whitespace). tokeniseWords :: [String] -> String -> Either (ParseErrorBundle String Void) [Component PWord] tokeniseWords (sortByDescendingLength -> gs) =         parse (componentsParser $ wordParser "[" gs) "" --- | Given a function to convert 'Word's to strings, converts a list--- of 'Component's to strings.+-- | Inverse of 'tokeniseWords': given a function to convert v'Word's+-- to strings, converts a list of 'Component's to strings. detokeniseWords' :: (a -> String) -> [Component a] -> String detokeniseWords' f = concatMap $ \case     Word gs -> f gs@@ -153,12 +178,9 @@ detokeniseWords = detokeniseWords' concatWithBoundary  -- | Given a list of sound changes, extract the list of multigraphs--- defined in the first categories declaration of the 'SoundChange's.-findFirstCategoriesDecl :: SoundChanges c [Grapheme] -> [String]-findFirstCategoriesDecl (DirectiveS gs:_) =-    mapMaybe-        (\case GBoundary -> Nothing; GMulti m -> Just m)-        gs+-- defined in the first 'GraphemeList' of the 'SoundChanges'.+findFirstCategoriesDecl :: SoundChanges c GraphemeList -> [String]+findFirstCategoriesDecl (DeclS (GraphemeList _ gs):_) = gs findFirstCategoriesDecl (_:ss) = findFirstCategoriesDecl ss findFirstCategoriesDecl [] = [] @@ -166,5 +188,5 @@ -- like @'withFirstCategoriesDecl' 'tokeniseWords' changes words@ (to -- tokenise using the graphemes from the first categories declaration) -- and so on.-withFirstCategoriesDecl :: ([String] -> t) -> SoundChanges c [Grapheme] -> t+withFirstCategoriesDecl :: ([String] -> t) -> SoundChanges c GraphemeList -> t withFirstCategoriesDecl tok ss = tok (findFirstCategoriesDecl ss)
src/Brassica/SoundChange/Types.hs view
@@ -6,32 +6,52 @@ {-# LANGUAGE FlexibleContexts      #-} {-# LANGUAGE FlexibleInstances     #-} {-# LANGUAGE GADTs                 #-}-{-# LANGUAGE LambdaCase            #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE PolyKinds             #-}-{-# LANGUAGE PatternSynonyms       #-} {-# LANGUAGE QuantifiedConstraints #-} {-# LANGUAGE RankNTypes            #-} {-# LANGUAGE StandaloneDeriving    #-} {-# LANGUAGE TypeFamilies          #-} {-# LANGUAGE UndecidableInstances  #-} +-- |+-- Module      : Brassica.SoundChange.Types+-- Copyright   : See LICENSE file+-- License     : BSD3+-- Maintainer  : Brad Neimann+--+-- This module contains the types used to represent sound changes and+-- words in Brassica. In brief:+--+--     * A set of 'SoundChanges' is composed of a list of elements+--+--     * Their most important elements are sound change 'Rule's+--+--     * Sound changes are composed of 'Lexeme's denoting parts of the+--       input and output words+--+--     * Each word is a sequence of t'Grapheme's+--+-- For more details on the syntax and semantics of sound changes,+-- refer to the [reference guide](https://github.com/bradrn/brassica/blob/v1.0.0/docs/Reference.md). module Brassica.SoundChange.Types        (        -- * Words and graphemes-         Grapheme(..)+         Grapheme        , PWord        , addBoundaries        , removeBoundaries        , concatWithBoundary        -- * Lexemes        , Lexeme(..)-       , pattern Boundary        , LexemeType(..)        , generalise        -- * Categories        , mapCategory        , mapCategoryA+       , CategoryElement+       , CategorySpec(..)+       , CategoryModification(..)        , Expanded(..)        , generaliseExpanded        -- * Rules@@ -47,91 +67,106 @@        , plaintext'        , SoundChanges        -- * Directives-       , CategoryModification(..)-       , CategorySpec(..)-       , FeatureSpec(..)-       , CategoryDefinition(..)        , Directive(..)+       , CategoryDefinition(..)+       , FeatureSpec(..)+       , GraphemeList(..)        ) where -import Control.DeepSeq (NFData(..))-import Data.String (IsString(..))+import Control.DeepSeq (NFData(..), deepseq) import GHC.Generics (Generic) import GHC.OldList (dropWhileEnd) --- | 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)--instance IsString Grapheme where-    fromString = GMulti+-- | The type of graphemes within a word. @"#"@ is taken to denote a+-- word boundary (whch is universally treated as a normal grapheme in+-- sound changes.)+type Grapheme = [Char] --- | A word (or a subsequence of one) can be viewed as a list of--- @Grapheme@s: e.g. Portuguese "filha" becomes--- @["f", "i", "lh", "a"] :: 'PWord'@.+-- | Brassica views a word, or a subsequence of one, as a list of+-- @Grapheme@s. For instance, Portuguese "filha" becomes+-- @["f", "i", "lh", "a"]@ when tokenised correctly. ----- (The name 'PWord' is from ‘phonological word’, these being what a--- SCA typically manipulates; this name was chosen to avoid a clash--- with @Prelude.'Prelude.Word'@.)+-- (The name 'PWord' is from ‘phonological word’, these being what+-- sound changes typically manipulate. The name was chosen to avoid a+-- clash with @t'Word'@ from @base@.) type PWord = [Grapheme] --- Add a 'GBoundary' at the beginning and end of the 'PWord'.+-- | Add word boundaries (@"#"@) at the beginning and end of a 'PWord'. addBoundaries :: PWord -> PWord-addBoundaries w = GBoundary : w ++ [GBoundary]+addBoundaries w = "#" : w ++ ["#"] --- Remove 'GBoundary's from the beginning and end of the 'PWord'.+-- | Remove word boundaries (@"#"@) from the beginning and end of a 'PWord'. removeBoundaries :: PWord -> PWord-removeBoundaries = dropWhile (==GBoundary) . dropWhileEnd (==GBoundary)+removeBoundaries = dropWhile (=="#") . dropWhileEnd (=="#") --- | 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 @"#"@.+-- | Render a 'PWord' as a 'String': does 'removeBoundaries' then 'concat'. concatWithBoundary :: PWord -> String-concatWithBoundary = go . removeBoundaries-  where-    go = concatMap $ \case-        GMulti g -> g-        GBoundary -> "#"+concatWithBoundary = concat . removeBoundaries --- | The part of a 'Rule' in which a 'Lexeme' may occur: in a matched--- part (target or environment), in replacement, or in either of--- those.-data LexemeType = Matched | Replacement | AnyPart+-- | The part of a 'Rule' in which a 'Lexeme' may occur.+data LexemeType+    = Matched      -- ^ In the target, environment or exception (‘matching’ position)+    | Replacement  -- ^ In the replacement only+    | AnyPart      -- ^ Not restricted to any one part --- | A 'Lexeme' is the smallest part of a sound change. Both matches--- and replacements are made up of 'Lexeme's: the phantom type--- variable @a@ specifies where each different variety of 'Lexeme' may--- occur. 'Lexeme's are also parameterised by their category type,--- which may be 'Expanded' or something else.+-- | Each part of a sound change is made up of a sequence of+-- 'Lexeme's. Each 'Lexeme' denotes part of an input or output word.+--+-- The first type variable @category@ is the type used to represent+-- categories within the sound change. This will usually be+-- 'CategorySpec' after parsing, or 'Expanded' after expansion.+--+-- The second type variable is phantom and represents the part of the+-- rule in which the lexeme is placed. Various lexemes are restricted+-- to 'Matched' or 'Replacement' positions respectively.+--+-- For details on the syntax and semantics of each kind of lexeme,+-- refer to the [reference guide](https://github.com/bradrn/brassica/blob/v1.0.0/docs/Reference.md). data Lexeme category (a :: LexemeType) where-    -- | In Brassica sound-change syntax, one or more letters without intervening whitespace,-    -- or a word boundary specified as @#@     Grapheme :: Grapheme -> Lexeme category a-    -- | In Brassica sound-change syntax, delimited by square brackets     Category :: category a -> Lexeme category a-    -- | In Brassica sound-change syntax, delimited by parentheses+    -- | Written @%category@, matching-only+    GreedyCategory :: category 'Matched -> Lexeme category 'Matched+    -- | Written @(lexemes)@     Optional :: [Lexeme category a] -> Lexeme category a-    -- | In Brassica sound-change syntax, specified as @\@+    -- | Written @%(lexemes)@, matching-only+    GreedyOptional :: [Lexeme category 'Matched] -> Lexeme category 'Matched+    -- | Written @\\@, replacement-only     Metathesis :: Lexeme category 'Replacement-    -- | In Brassica sound-change syntax, specified as @>@+    -- | Written @>@     Geminate :: Lexeme category a-    -- | In Brassica sound-change syntax, specified as @^@ before another 'Lexeme'+    -- | Written @^lexeme@     Wildcard :: Lexeme category a -> Lexeme category a-    -- | In Brassica sound-change syntax, specified as @*@ after another 'Lexeme'+    -- | Written @lexeme*@     Kleene   :: Lexeme category a -> Lexeme category a-    -- | In Brassica sound-change syntax, specified as @~@+    -- | Written @~@, replacement-only     Discard  :: Lexeme category 'Replacement-    -- | In Brassica sound-change syntax, specified as \@i before a category-    Backreference :: Int -> category a -> Lexeme category a-    -- | In Brassica sound-change syntax, specified as \@? before a category+    -- | Written @\@n category@ or @\@#id category@+    Backreference :: Either String Int -> category a -> Lexeme category a+    -- | Written @\@? category@     Multiple :: category 'Replacement -> Lexeme category 'Replacement+    -- | Written @lexeme$Name@ or variations (see reference guide)+    Feature+        :: Bool                -- ^ 'True' iff the feature is negated+        -> String              -- ^ Feature name+        -> Maybe String        -- ^ Identifier if backreferenced, else 'Nothing'+        -> [[Grapheme]]        -- ^ List of correspondence sets+        -> Lexeme category a+        -> Lexeme category a+    -- | Not directly available in Brassica syntax, inserted in expansion+    Autosegment+        :: Grapheme                -- ^ Feature name+        -> [[(Grapheme, Bool)]]    -- ^ List of correspondence sets, with exclusion states+        -> [Grapheme]              -- ^ Graphemes to be matched by this 'Autosegment'+        -> Lexeme category a +-- | Map a function over any categories in the given 'Lexeme'. mapCategory :: (forall x. c x -> c' x) -> Lexeme c a -> Lexeme c' a mapCategory _ (Grapheme g) = Grapheme g mapCategory f (Category c) = Category (f c)+mapCategory f (GreedyCategory c) = GreedyCategory (f c) mapCategory f (Optional ls) = Optional (mapCategory f <$> ls)+mapCategory f (GreedyOptional ls) = GreedyOptional (mapCategory f <$> ls) mapCategory _ Metathesis = Metathesis mapCategory _ Geminate = Geminate mapCategory f (Wildcard l) = Wildcard (mapCategory f l)@@ -139,7 +174,10 @@ mapCategory _ Discard = Discard mapCategory f (Backreference i c) = Backreference i (f c) mapCategory f (Multiple c) = Multiple (f c)+mapCategory f (Feature r n i kvs l) = Feature r n i kvs $ mapCategory f l+mapCategory _ (Autosegment n kvs gs) = Autosegment n kvs gs +-- | Like 'mapCategory', with an 'Applicative' effect. mapCategoryA     :: Applicative t     => (forall x. c x -> t (c' x))@@ -147,7 +185,9 @@     -> t (Lexeme c' a) mapCategoryA _ (Grapheme g) = pure $ Grapheme g mapCategoryA f (Category c) = Category <$> f c+mapCategoryA f (GreedyCategory c) = GreedyCategory <$> f c mapCategoryA f (Optional ls) = Optional <$> traverse (mapCategoryA f) ls+mapCategoryA f (GreedyOptional ls) = GreedyOptional <$> traverse (mapCategoryA f) ls mapCategoryA _ Metathesis = pure Metathesis mapCategoryA _ Geminate = pure Geminate mapCategoryA f (Wildcard l) = Wildcard <$> mapCategoryA f l@@ -155,9 +195,12 @@ mapCategoryA _ Discard = pure Discard mapCategoryA f (Backreference i c) = Backreference i <$> f c mapCategoryA f (Multiple c) = Multiple <$> f c+mapCategoryA f (Feature r n i kvs l) = Feature r n i kvs <$> mapCategoryA f l+mapCategoryA _ (Autosegment n kvs gs) = pure $ Autosegment n kvs gs --- | The type of a category after expansion.-newtype Expanded a = FromElements { elements :: [Either Grapheme [Lexeme Expanded a]] }+-- | The type of a category after expansion: a simple list of+-- 'CategoryElement's.+newtype Expanded a = FromElements { elements :: [CategoryElement Expanded a] }     deriving (Eq, Ord, Show, Generic, NFData)  instance Semigroup (Expanded a) where@@ -166,6 +209,9 @@ instance Monoid (Expanded a) where     mempty = FromElements [] +-- | Generalise a @'Lexeme' c ''AnyPart'@ so it can be used in any+-- specific part of a sound change, given a way to similarly+-- generalise any categories it contains. generalise :: (c 'AnyPart -> c a) -> Lexeme c 'AnyPart -> Lexeme c a generalise _ (Grapheme g) = Grapheme g generalise f (Category es) = Category $ f es@@ -174,13 +220,13 @@ generalise f (Backreference i es) = Backreference i $ f es generalise f (Wildcard l) = Wildcard $ generalise f l generalise f (Kleene l) = Kleene $ generalise f l+generalise f (Feature r n i kvs l) = Feature r n i kvs $ generalise f l+generalise _ (Autosegment n kvs gs) = Autosegment n kvs gs +-- | Generalise an 'Expanded' category to be used in any part of a+-- sound change, similarly to 'generalise'. generaliseExpanded :: Expanded 'AnyPart -> Expanded a-generaliseExpanded = FromElements . (fmap.fmap.fmap) (generalise generaliseExpanded) . elements---- | A 'Lexeme' matching a single word boundary, specified as @#@ in Brassica syntax.-pattern Boundary :: Lexeme c a-pattern Boundary = Grapheme GBoundary+generaliseExpanded = FromElements . (fmap.fmap) (generalise generaliseExpanded) . elements  deriving instance (forall x. Show (c x)) => Show (Lexeme c a) deriving instance (forall x. Eq (c x)) => Eq (Lexeme c a)@@ -189,26 +235,33 @@ instance (forall x. NFData (c x)) => NFData (Lexeme c a) where     rnf (Grapheme g) = rnf g     rnf (Category cs) = rnf cs+    rnf (GreedyCategory cs) = rnf cs     rnf (Optional ls) = rnf ls+    rnf (GreedyOptional ls) = rnf ls     rnf Metathesis = ()     rnf Geminate = ()     rnf (Wildcard l) = rnf l     rnf (Kleene l) = rnf l     rnf Discard = ()-    rnf (Backreference i l) = seq i $ rnf l+    rnf (Backreference i l) = i `deepseq` rnf l     rnf (Multiple l) = rnf l+    rnf (Feature r n i kvs l) = r `deepseq` l `deepseq` n `deepseq` i `deepseq` rnf kvs+    rnf (Autosegment n kvs gs) = n `deepseq` kvs `deepseq` rnf gs  -- | An 'Environment' is a tuple of @(before, after)@ components,--- corresponding to a ‘/ before _ after’ component of a sound change.+-- corresponding to an environment or exception in a sound change:+-- @before _ after@. ----- Note that an empty environment is just @([], [])@.+-- (An empty environment is just @([], [])@.) type Environment c = ([Lexeme c 'Matched], [Lexeme c 'Matched]) --- | Specifies application direction of rule — either left-to-right or right-to-left.+-- | Specifies application direction of rule: either left-to-right or right-to-left. data Direction = LTR | RTL     deriving (Eq, Show, Generic, NFData) --- | Specifies how regularly a rule should be applied.+-- | Specifies how regularly a rule should be applied. A sporadic+-- rule will produce two or more results, preserving the input as one+-- of the outputs. data Sporadicity     = ApplyAlways     -- ^ Always apply the rule@@ -221,10 +274,11 @@ -- | Flags which can be enabled, disabled or altered on a 'Rule' to -- change how it is applied. data Flags = Flags-  { highlightChanges :: Bool-  , applyDirection   :: Direction-  , applyOnceOnly    :: Bool-  , sporadic         :: Sporadicity+  { highlightChanges :: Bool         -- ^ Whether results from this sound change can be highlighted in a GUI+  , applyDirection   :: Direction    -- ^ Direction in which to apply the rule+  , applyOnceOnly    :: Bool         -- ^ Whether to apply the rule only once to a word+  , sporadic         :: Sporadicity  -- ^ Whether the rule should be applied sporadically, and if so, how+  , nonOverlappingTarget :: Bool     -- ^ Whether the rule should apply non-iteratively (avoiding environments which overlap with targets)   } deriving (Show, Generic, NFData)  -- | A default selection of flags which are appropriate for most@@ -236,29 +290,32 @@ --     , 'applyDirection' = 'LTR' --     , 'applyOnceOnly' = 'False' --     , 'sporadic' = 'False'+--     , 'nonOverlappingTarget' = 'False' --     } -- @ ----- That is: highlight changes, apply from left to right, apply--- repeatedly, and don’t apply sporadically.+-- That is: apply repeatedly and iteratively from left to right,+-- non-sporadically, with the results available for highlighting. defFlags :: Flags defFlags = Flags     { highlightChanges = True     , applyDirection = LTR     , applyOnceOnly = False     , sporadic = ApplyAlways+    , nonOverlappingTarget = False     } --- | A single sound change rule: in Brassica sound-change syntax with all elements specified,--- @-flags target / replacement \/ environment1 | environment2 | … \/ exception@.--- (And usually the 'plaintext' of the rule will contain a 'String' resembling that pattern.)+-- | A single sound change rule.+--+-- In Brassica sound-change syntax with all elements specified, this would be+-- @-flags target / replacement \/ environment1 \/ environment2 \/ … \/ exception@. data Rule c = Rule   { target      :: [Lexeme c 'Matched]   , replacement :: [Lexeme c 'Replacement]   , environment :: [Environment c]   , exception   :: Maybe (Environment c)   , flags       :: Flags-  , plaintext   :: String+  , plaintext   :: String  -- ^ Rule text before parsing (displayed e.g. for debugging purposes)   } deriving (Generic)  deriving instance (forall a. Show (c a)) => Show (Rule c)@@ -272,40 +329,67 @@ deriving instance (forall a. Show (c a)) => Show (Filter c) deriving instance (forall a. NFData (c a)) => NFData (Filter c) --- | A 'Statement' can be a single sound change rule, a filter,--- or a directive (e.g. category definition).+-- | A 'Statement' within a sound change file can be a single sound+-- change rule, a filter, an instruction to report intermediate+-- results, or some other declaration.+--+-- The declaration type depends on the current sound change+-- phase. Usually it will be 'Directive' after parsing, or+-- 'GraphemeList' after expansion. data Statement c decl-    = RuleS (Rule c)-    | FilterS (Filter c)-    | DirectiveS decl+    = RuleS (Rule c)        -- ^ Sound change rule+    | FilterS (Filter c)    -- ^ Filter+    | ReportS               -- ^ Report intermediate result+    | DeclS decl            -- ^ Declaration (phase-dependent)     deriving (Generic)  deriving instance (forall a. Show (c a), Show decl) => Show (Statement c decl) deriving instance (forall a. NFData (c a), NFData decl) => NFData (Statement c decl)  -- | A simple wrapper around 'plaintext' for 'Statement's. Returns--- @"<directive>"@ for all 'DirectiveS' inputs.+-- @"\<declaration\>"@ for all 'DeclS' inputs. plaintext' :: Statement c decl -> String plaintext' (RuleS r) = plaintext r plaintext' (FilterS (Filter p _)) = p-plaintext' (DirectiveS _) = "<directive>"+plaintext' ReportS = "intermediate result"+plaintext' (DeclS _) = "<declaration>"  -- | A set of 'SoundChanges' is simply a list of 'Statement's. type SoundChanges c decl = [Statement c decl]  -- | The individual operations used to construct a category in -- Brassica sound-change syntax.-data CategoryModification = Union | Intersect | Subtract+data CategoryModification+    = Union     -- ^ Written @[Category1 &Category2]@ or @[Category1 Category2]@+    | Intersect -- ^ Written @[Category1 +Category2]@+    | Subtract  -- ^ Written @[Category1 -Category2]@     deriving (Show, Eq, Ord, Generic, NFData) --- | The specification of a category in Brassica sound-change syntax.+-- | A single element of a category: a sequence of 'Lexeme's. (Single+-- v'Grapheme's receive some special treatment, e.g. they can be+-- written without surrounding braces in Brassica syntax.)+type CategoryElement category a = [Lexeme category a]++-- | The specification of a category in Brassica sound-change+-- syntax. Usually this will be as a 'CategorySpec': a list of+-- 'CategoryElement's, each of which modifies the previous definition+-- using the given 'CategoryModification' method.+--+-- In some positions (e.g. after a 'Backreference') a category must be+-- provided, but that category can be predefined, to be inlined during+-- expansion. In such positions, the given category name is stored as+-- a 'MustInline' category. (In other positions predefined categories+-- are indistinguishable from normal v'Grapheme's, and represented as+-- such.) data CategorySpec a-    = CategorySpec [(CategoryModification, Either Grapheme [Lexeme CategorySpec a])]+    = CategorySpec [(CategoryModification, CategoryElement CategorySpec a)]     | MustInline String  -- ^ A single grapheme assumed to have been specified earlier as a category     deriving (Show, Eq, Ord, Generic, NFData)  -- | The specification of a suprasegmental feature in Brassica -- sound-change syntax.+--+-- Deprecated since 1.0.0. data FeatureSpec = FeatureSpec     { featureBaseName :: Maybe String     , featureBaseValues :: CategorySpec 'AnyPart@@ -313,19 +397,32 @@     }     deriving (Show, Eq, Ord, Generic, NFData) --- | A definition of a new category, either directly or via features.+-- | A single definition within a category definition block. data CategoryDefinition     = DefineCategory String (CategorySpec 'AnyPart)+    -- ^ Defines a category with the given name and value     | DefineFeature FeatureSpec+    -- ^ Defines a feature as a set of categories+    | DefineAuto String+    -- ^ Defines a category as autosegmental     deriving (Show, Eq, Ord, Generic, NFData)  -- | A directive used in Brassica sound-change syntax: anything which--- is not actually a sound change-+-- occurs in a sound change file with the primary purpose of defining+-- something for later use. data Directive-    = Categories Bool Bool [CategoryDefinition]-      -- ^ @categories … end@: first 'Bool' for @new@,-      -- second for @noreplace@+    = Categories  -- ^ Category definition block+        Bool  -- ^ Whether category was introduced with @new@+        Bool  -- ^ Whether category was introduced with @noreplace@+        [CategoryDefinition]     | ExtraGraphemes [String]-      -- ^ @extra …@+      -- ^ Extra graphemes declaration: @extra …@     deriving (Show, Eq, Ord, Generic, NFData)++-- | A list of graphemes, replacing v'Categories' in expanded sound+-- changes. These are used in tokenisation to determine which+-- multigraphs are used, and in rule application to filter unwanted+-- graphemes. The first 'Bool' indicates whether filtration should+-- occur for any particular categories block.+data GraphemeList = GraphemeList Bool [Grapheme]+    deriving (Eq, Show, Ord, Generic, NFData)
+ test/DocTests.hs view
@@ -0,0 +1,81 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}++module Main where++import Data.Functor ((<&>))+import Data.List (intercalate)+import Data.Maybe (mapMaybe)+import Data.Traversable (for)+import Text.Pandoc+import Test.Tasty (defaultMain, testGroup)+import Test.Tasty.Providers++import qualified Data.Text as T+import qualified Data.Text.IO as T++import Brassica.SoundChange (applyChanges, concatWithBoundary, getOutput)+import Brassica.SoundChange.Expand (expandSoundChanges)+import Brassica.SoundChange.Parse (parseSoundChanges, errorBundlePretty)+import Brassica.SoundChange.Tokenise (withFirstCategoriesDecl, tokeniseWord)++main :: IO ()+main = defaultMain $ testGroup "brassica-doctests"+    [ doctest "docs/Writing-Sound-Changes.md" ""+    , doctest "docs/Reference.md" "\+\categories noreplace\n\+\C = m n p t ch k b d j g f s sh h v z r l w y\n\+\-Stress = a e i o u\n\+\+Stress = á é í ó ú\n\+\auto -Stress\n\+\V = &&Stress\n\+\end\n"+    ]++doctest :: FilePath -> String -> TestTree+doctest file prefix = singleTest file $ DocTest file prefix++data DocTest = DocTest FilePath String++instance IsTest DocTest where+    testOptions = pure []+    run _ (DocTest path prefix) _ = do+        doc <- T.readFile path+        runIO (readMarkdown def { readerExtensions = githubMarkdownExtensions } doc) <&> \case+            Left err -> testFailed $ T.unpack $ renderError err+            Right (Pandoc _ blocks) ->+                let rules = flip mapMaybe blocks $ \case+                        CodeBlock (_, cs, _) text+                            | "brassica" `elem` cs+                            ->+                                let rule = prefix ++ T.unpack text+                                    examples = flip mapMaybe (T.lines text) $ \x ->+                                        case words $ T.unpack x of+                                            ";":i:"→":o:_ -> Just (i, o)+                                            _ -> Nothing+                                in Just (rule, examples)+                        _ -> Nothing+                    results = for rules $ \(rule, examples) ->+                        case parseSoundChanges rule of+                            Left err -> Left $ errorBundlePretty err+                            Right scs ->+                                case expandSoundChanges scs of+                                    Left err -> Left $ show err+                                    Right scs' -> for examples $ \(i, o) ->+                                        case withFirstCategoriesDecl tokeniseWord scs' i of+                                            Left err -> Left $ errorBundlePretty err+                                            Right i' ->+                                                let o' = mapMaybe getOutput $ applyChanges scs' i'+                                                    o'' =+                                                        if null o'+                                                        then "(deleted)"+                                                        else intercalate "/" $ concatWithBoundary <$> o'+                                                in if o == o''+                                                      then Right ("" :: String)+                                                      else Left $+                                                               "expected " ++ i ++ "→" ++ o +++                                                               ", got " ++ o'' +++                                                               "\nfor sound change:\n" ++ rule+                in case results of+                    Left err -> testFailed err+                    Right _ -> testPassed ""
test/Spec.hs view
@@ -5,6 +5,7 @@ import Conduit import Control.Category ((>>>)) import Control.Monad.Trans.Except (runExceptT, throwE)+import Data.Maybe (mapMaybe) import System.IO (IOMode(..), withFile) import Test.Tasty ( defaultMain, testGroup, TestTree ) import Test.Tasty.Golden (goldenVsFile)@@ -13,35 +14,35 @@ import qualified Data.ByteString.UTF8 as B8 import qualified Data.Text as T -import Brassica.SoundChange (applyChanges, splitMultipleResults, applyChangesWithLogs, reportAsText)-import Brassica.SoundChange.Category (expandSoundChanges)+import Brassica.SoundChange (applyChanges, getOutput, splitMultipleResults, reportAsText, GraphemeList)+import Brassica.SoundChange.Expand (expandSoundChanges) import Brassica.SoundChange.Parse (parseSoundChanges, errorBundlePretty) import Brassica.SoundChange.Tokenise (tokeniseWords, detokeniseWords, withFirstCategoriesDecl, Component, getWords)-import Brassica.SoundChange.Types (SoundChanges, PWord, plaintext', Expanded, Grapheme)+import Brassica.SoundChange.Types (SoundChanges, PWord, plaintext', Expanded)  main :: IO () main = defaultMain $ testGroup "brassica-tests"-    [ proto21eTest applyChanges showWord "proto21e golden test" "proto21e.out" "proto21e.golden"-    , proto21eTest applyChangesWithLogs showLogs "proto21e golden test with log" "proto21e-log.out" "proto21e-log.golden"+    [ changesTest ((mapMaybe getOutput .) . applyChanges) showWord "changes golden test" "words.out" "words.golden"+    , changesTest                           applyChanges  showLogs "changes golden test with log" "words-log.out" "words-log.golden"     ]   where     showWord = detokeniseWords . concatMap (splitMultipleResults " ")      showLogs logs = unlines $ fmap (reportAsText plaintext') $ concat $ getWords logs -proto21eTest-    :: (SoundChanges Expanded [Grapheme] -> PWord -> [a])+changesTest+    :: (SoundChanges Expanded GraphemeList -> PWord -> [a])     -> ([Component [a]] -> String)     -> String     -> FilePath     -> FilePath     -> TestTree-proto21eTest applyFn showWord testName outName goldenName =+changesTest applyFn showWord testName outName goldenName =     let outName' = "test/" ++ outName         goldenName' = "test/" ++ goldenName     in goldenVsFile testName goldenName' outName' $         withFile outName' WriteMode $ \outFile -> fmap (either id id) . runExceptT $ do-            soundChangesData <- B8.toString <$> liftIO (B.readFile "test/proto21e.bsc")+            soundChangesData <- B8.toString <$> liftIO (B.readFile "test/changes.bsc")             soundChanges' <- catchEither (parseSoundChanges soundChangesData) $ \err -> do                 liftIO $ putStrLn $                     "Cannot parse the SCA file because:\n" ++@@ -59,7 +60,7 @@                     withFirstCategoriesDecl tokeniseWords soundChanges                     >>> (fmap.fmap.fmap) (applyFn soundChanges)                     >>> either prettyError prettyOutput-            liftIO $ withSourceFile "test/proto21e.in" $ flip connect+            liftIO $ withSourceFile "test/words.in" $ flip connect                 $ decodeUtf8C                 .| linesUnboundedC                 .| mapC (evolve . T.unpack)