Search code examples
functional-programmingf#

F# parse log with multiline entries


I'm parsing log files (entries starting with debug/warning/info) in a line-by-line manner and have encountered rare occurrences where a single log entry spans multiple lines - eg, the warning here:

debug: Preparing movie /movies/menu_background.sfd: 272204
info: /init
debug: Playing movie c:\program files (x86)\steam\steamapps\common\supreme commander forged alliance\movies\menu_background.sfd: 272204
warning: Error running OnDestroy script in Entity bsa0001 at 2f8b6908: ...gramdata\faforever\gamedata\lua.nx2\lua\sim\unit.lua(5494): attempt to call method `Destroy' (a nil value)
         stack traceback:
            ...gramdata\faforever\gamedata\lua.nx2\lua\sim\unit.lua(5494): in function <...gramdata\faforever\gamedata\lua.nx2\lua\sim\unit.lua:5489>
            ...orever\gamedata\lua.nx2\lua\sim\units\mobileunit.lua(65): in function `DestroyAllTrashBags'

My current code is below - which ignores the additional lines (puts them in misclist) - is not what I want. I would like to amend the code to include the additional lines with the prior info/warning/debug entry. My C# brain can do this procedurally but I'm trying to do this in a more functional manner as I learn F#.

let rec sortLines (lines: string list) (warninglist: string list) (debuglist: string list) (infolist: string list) (misclist: string list) : string list list =
    match lines with
    | (h::t) when h.StartsWith("debug") ->
        sortLines t warninglist (h :: debuglist) infolist misclist
    | (h::t) when h.StartsWith("info") ->
        sortLines t warninglist debuglist (h :: infolist) misclist
    | (h::t) when h.StartsWith("warning") ->
        sortLines t (h :: warninglist) debuglist infolist misclist
    | (h::t) ->
        sortLines t warninglist debuglist infolist (h :: misclist)
    | [] ->
        [warninglist; debuglist; infolist; misclist]

Solution

  • When you need to maintain state while iterating through a collection, fold is often your friend. I would suggest a solution like this:

    type Log =
        {
            Info : List<string>
            Debug : List<string>
            Warning : List<string>
        }
    
    module Log =
    
        let empty =
            {
                Info = []
                Debug = []
                Warning = []
            }
    
        let logInfo line log =
            { log with Info = line :: log.Info }
    
        let logDebug line log =
            { log with Debug = line :: log.Debug }
    
        let logWarning line log =
            { log with Warning = line :: log.Warning }
    
        let logInvalid line log =
            failwith "Invalid state"
    
        let read lines =
            let log, _ =
                Seq.fold (fun (log, logger) (line : string) ->
                    let logger' =
                        if line.StartsWith("info") then logInfo
                        elif line.StartsWith("debug") then logDebug
                        elif line.StartsWith("warning") then logWarning
                        else logger
                    let log' = logger' line log
                    log', logger') (empty, logInvalid) lines
            log
    

    The read function loops through the incoming lines, assigning each one to its correct level. log is the current state of the log, and logger is a function that will add a single line to the log.

    If you prefer recursion, here's an equivalent implementation:

        let read lines =
            
            let rec loop log logger lines =
                match lines with
                    | (line : string) :: tail ->
                        let logger' =
                            if line.StartsWith("info") then logInfo
                            elif line.StartsWith("debug") then logDebug
                            elif line.StartsWith("warning") then logWarning
                            else logger
                        let log' = logger' line log
                        loop log' logger' tail
                    | [] -> log
    
            loop empty logInvalid lines
    

    You can test it like this:

    let lines =
        [
            @"debug: Preparing movie /movies/menu_background.sfd: 272204"
            @"info: /init"
            @"debug: Playing movie c:\program files (x86)\steam\steamapps\common\supreme commander forged alliance\movies\menu_background.sfd: 272204"
            @"warning: Error running OnDestroy script in Entity bsa0001 at 2f8b6908: ...gramdata\faforever\gamedata\lua.nx2\lua\sim\unit.lua(5494): attempt to call method `Destroy' (a nil value)"
            @"         stack traceback:"
            @"            ...gramdata\faforever\gamedata\lua.nx2\lua\sim\unit.lua(5494): in function <...gramdata\faforever\gamedata\lua.nx2\lua\sim\unit.lua:5489>"
            @"            ...orever\gamedata\lua.nx2\lua\sim\units\mobileunit.lua(65): in function `DestroyAllTrashBags'"
        ]
    
    let log = Log.read lines
    printfn "Info:"; for line in List.rev log.Info do printfn "   %s" line
    printfn "Debug:"; for line in List.rev log.Debug do printfn "   %s" line
    printfn "Warning:"; for line in List.rev log.Warning do printfn "   %s" line
    

    Output is:

    Info:
       info: /init
    Debug:
       debug: Preparing movie /movies/menu_background.sfd: 272204
       debug: Playing movie c:\program files (x86)\steam\steamapps\common\supreme commander forged alliance\movies\menu_background.sfd: 272204
    Warning:
       warning: Error running OnDestroy script in Entity bsa0001 at 2f8b6908: ...gramdata\faforever\gamedata\lua.nx2\lua\sim\unit.lua(5494): attempt to call method `Destroy' (a nil value)
                stack traceback:
                   ...gramdata\faforever\gamedata\lua.nx2\lua\sim\unit.lua(5494): in function <...gramdata\faforever\gamedata\lua.nx2\lua\sim\unit.lua:5489>
                   ...orever\gamedata\lua.nx2\lua\sim\units\mobileunit.lua(65): in function `DestroyAllTrashBags'