EP 121 · Parsing Xcode Logs · Oct 19, 2020 ·Members

Video #121: Parsing Xcode Logs: Part 1

smart_display

Loading stream…

Video #121: Parsing Xcode Logs: Part 1

Episode: Video #121 Date: Oct 19, 2020 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep121-parsing-xcode-logs-part-1

Episode thumbnail

Description

Now that we’ve refamiliarized ourselves with parsing, let’s parse something even more complex: XCTest logs. We will parse and pretty-print the output from xcodebuild and discover more reusable combinators along the way.

Video

Cloudflare Stream video ID: b0e44a7626575ccc2dcbf2a77f19e693 Local file: video_121_parsing-xcode-logs-part-1.mp4 *(download with --video 121)*

References

Transcript

0:27

And so that’s our recap of parsers and parser combinators. We can already see the power in them, but as we said before, this is only the tip of the iceberg. There is so much left to explore, including generalization, performance, and invertibility. And we will get to all of that, but we want to do one more thing before ending the recap. So far we haven’t shown too much new stuff to those who were already caught up on our past episodes on parsing, so we’d like to show off something that everyone can get benefit from.

0:56

We will create a whole new complex parser from scratch, and it will push our current knowledge of parsers even further. We are going to write a parser that will process all of the logs that Swift spits out when running tests. XCTest log output

1:36

To see this output, we will run the test suite of a demo application from the Composable Architecture . And we will purposely introduce some test failures: $ xcodebuild test -scheme VoiceMemos -destination platform="iOS Simulator,name=iPhone 11 Pro Max" Test Suite 'All tests' started Test Suite 'VoiceMemosTests.xctest' started Test Suite 'VoiceMemosTests' started Test Case '-[VoiceMemosTests.VoiceMemosTests testDeleteMemo]' started. /Users/point-free/projects/swift-composable-architecture/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift:291: error: -[VoiceMemosTests.VoiceMemosTests testDeleteMemo] : XCTAssertEqual failed: ("true") is not equal to ("false") Test Case '-[VoiceMemosTests.VoiceMemosTests testDeleteMemo]' failed (0.027 seconds). Test Case '-[VoiceMemosTests.VoiceMemosTests testDeleteMemoWhilePlaying]' started. /Users/point-free/projects/swift-composable-architecture/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift:320: error: -[VoiceMemosTests.VoiceMemosTests testDeleteMemoWhilePlaying] : State change does not match expectation: … VoiceMemosState( alertMessage: nil, audioRecorderPermission: RecorderPermission.undetermined, currentRecording: nil, voiceMemos: [ VoiceMemo( date: 2020-07-07T15:21:21Z, duration: 10.0, mode: Mode.playing( − progress: 1.0 + progress: 0.0 ), title: "", url: https://www.pointfree.co/functions ), ] ) (Expected: −, Actual: +) Test Case '-[VoiceMemosTests.VoiceMemosTests testDeleteMemoWhilePlaying]' failed (0.008 seconds). Test Case '-[VoiceMemosTests.VoiceMemosTests testPermissionDenied]' started. Test Case '-[VoiceMemosTests.VoiceMemosTests testPermissionDenied]' passed (0.001 seconds). Test Case '-[VoiceMemosTests.VoiceMemosTests testPlayMemoFailure]' started. /Users/point-free/projects/swift-composable-architecture/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift:225: error: -[VoiceMemosTests.VoiceMemosTests testPlayMemoFailure] : State change does not match expectation: … VoiceMemosState( − alertMessage: "Voice memo playback failed", + alertMessage: "Voice memo playback failed.", audioRecorderPermission: RecorderPermission.undetermined, currentRecording: nil, voiceMemos: [ VoiceMemo( date: 2001-01-01T00:00:00Z, duration: 30.0, mode: Mode.notPlaying, title: "", url: https://www.pointfree.co/functions ), ] ) (Expected: −, Actual: +) Test Case '-[VoiceMemosTests.VoiceMemosTests testPlayMemoFailure]' failed (0.007 seconds). Test Case '-[VoiceMemosTests.VoiceMemosTests testPlayMemoHappyPath]' started. /Users/point-free/projects/swift-composable-architecture/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift:182: error: -[VoiceMemosTests.VoiceMemosTests testPlayMemoHappyPath] : State change does not match expectation: … VoiceMemosState( alertMessage: nil, audioRecorderPermission: RecorderPermission.undetermined, currentRecording: nil, voiceMemos: [ VoiceMemo( date: 2001-01-01T00:00:00Z, duration: 1.0, mode: Mode.playing( − progress: 0.0 + progress: 0.5 ), title: "", url: https://www.pointfree.co/functions ), ] ) (Expected: −, Actual: +) /Users/point-free/projects/swift-composable-architecture/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift:185: error: -[VoiceMemosTests.VoiceMemosTests testPlayMemoHappyPath] : Received unexpected action: … VoiceMemosAction.voiceMemo( index: 0, action: VoiceMemoAction.audioPlayerClient( Result<Action, Failure>.success( Action.didFinishPlaying( − successfully: false + successfully: true ) ) ) ) (Expected: −, Actual: +) Test Case '-[VoiceMemosTests.VoiceMemosTests testPlayMemoHappyPath]' failed (0.005 seconds). Test Case '-[VoiceMemosTests.VoiceMemosTests testRecordMemoFailure]' started. /Users/point-free/projects/swift-composable-architecture/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift:144: error: -[VoiceMemosTests.VoiceMemosTests testRecordMemoFailure] : State change does not match expectation: … VoiceMemosState( − alertMessage: "Voice memo recording failed", + alertMessage: "Voice memo recording failed.", audioRecorderPermission: RecorderPermission.allowed, currentRecording: nil, voiceMemos: [ ] ) (Expected: −, Actual: +) Test Case '-[VoiceMemosTests.VoiceMemosTests testRecordMemoFailure]' failed (0.003 seconds). Test Case '-[VoiceMemosTests.VoiceMemosTests testRecordMemoHappyPath]' started. Test Case '-[VoiceMemosTests.VoiceMemosTests testRecordMemoHappyPath]' passed (0.001 seconds). Test Case '-[VoiceMemosTests.VoiceMemosTests testStopMemo]' started. /Users/point-free/projects/swift-composable-architecture/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift:257: error: -[VoiceMemosTests.VoiceMemosTests testStopMemo] : State change does not match expectation: … VoiceMemosState( alertMessage: nil, audioRecorderPermission: RecorderPermission.undetermined, currentRecording: nil, voiceMemos: [ VoiceMemo( date: 2001-01-01T00:00:00Z, duration: 30.0, − mode: Mode.playing( − progress: 2.0 − ), + mode: Mode.notPlaying, title: "", url: https://www.pointfree.co/functions ), ] ) (Expected: −, Actual: +) Test Case '-[VoiceMemosTests.VoiceMemosTests testStopMemo]' failed (0.011 seconds). Test Suite 'VoiceMemosTests' failed at 2020-07-07 11:21:21.615. Executed 8 tests, with 7 failures (0 unexpected) in 0.063 (0.065) seconds Test Suite 'VoiceMemosTests.xctest' failed at 2020-07-07 11:21:21.615. Executed 8 tests, with 7 failures (0 unexpected) in 0.063 (0.066) seconds Test Suite 'All tests' failed at 2020-07-07 11:21:21.615. Executed 8 tests, with 7 failures (0 unexpected) in 0.063 (0.067) seconds

3:56

This can be quite difficult to grok quickly. First of all it intermixes all the passing and failing tests. And then once you find a failing test you have to do extra work to figure the exact message.

4:42

For example, look at this test failure: Test Case '-[VoiceMemosTests.VoiceMemosTests testDeleteMemo]' started. /Users/point-free/projects/swift-composable-architecture/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift:291: error: -[VoiceMemosTests.VoiceMemosTests testDeleteMemo] : XCTAssertEqual failed: ("true") is not equal to ("false") Test Case '-[VoiceMemosTests.VoiceMemosTests testDeleteMemo]' failed (0.027 seconds).

4:47

The actual assertion failure is just this little bit right here: XCTAssertEqual failed: ("true") is not equal to ("false")

5:00

What if instead of that messy output for this test failure we instead saw something like this: VoiceMemoTests.swift:107, testPermissionDenied failed in 0.03 seconds. ┃ ┃ XCTAssertTrue failed ┃ ┗━━──────────────

5:12

That’s much better. We can clearly see the the file and line where this failure happened. We can even copy and paste this into Xcode’s quick open and it will take us to the exact line of where the assertion failed. And we get a nice fenced off area to house the failure message, which makes it very easy to pick it out of the noise.

5:36

Even better, the longer failure messages could look like this: VoiceMemosTests.swift:257, testStopMemo failed in 0.011 seconds. ┃ ┃ State change does not match expectation: … ┃ ┃   VoiceMemosState( ┃   alertMessage: nil, ┃   audioRecorderPermission: RecorderPermission.undetermined, ┃   currentRecording: nil, ┃   voiceMemos: [ ┃   VoiceMemo( ┃   date: 2020-07-07T15:21:21Z, ┃   duration: 30.0, ┃ − mode: Mode.playing( ┃ − progress: 2.0 ┃ − ), ┃ + mode: Mode.notPlaying, ┃   title: "", ┃   url: https://www.pointfree.co/functions ┃   ), ┃   ] ┃   ) ┃ ┃ (Expected: −, Actual: +) ┃ ┗━━───────────────────

5:46

Very nice. Parsing the start of a test case

5:51

So, let’s first build a parser that can extract out the information we care about from a big blob of logs, and then we will make a quick and dirty CLI tool that we can use from the command line.

6:13

We’ll start by designing a first class data type that will hold all the information we are interested in extracting from the logs. We have two types of test results that we want to extract: failures and passes. We can codify that into an enum: enum TestResult { case failed case passed }

6:42

The failed case has a bunch of associated data. If we look at one of these sample test failures I pasted you will see we are interested in the test file name, line number of failure, test name, number of seconds to run, and the actual failure message: import Foundation enum TestResult { case failed( failureMessage: Substring, file: Substring, line: Int, testName: Substring, time: TimeInterval ) case passed }

8:01

Where the tests that pass really only have a test name and time to run: enum TestResult { case failed( failureMessage: Substring, file: Substring, line: Int, testName: Substring, time: TimeInterval ) case passed(testName: Substring, time: TimeInterval) } We are using Substring for these fields because that’s what our parsers works with, and it will help us be a little more efficient.

8:14

The parser we ultimately want to come up with is of the form: let testResults: Parser<[TestResult]>

8:25

But it is going to take us awhile before we can construct this one. We could start by first trying to develop a parser that can parse a single test result from the logs: let testResult: Parser<TestResult>

8:44

If we had this then we could get all the results using the zeroOrMore combinator: let testResults = testResult.zeroOrMore()

8:57

However, this testResult isn’t that much easier to implement. We still need to break the problem down into small problems, and just so that we don’t continue having compiler errors let’s put a stub of a parser in let testResult: Parser<TestResult> = .never

9:11

Let’s devise a strategy for parsing by looking again at an example of a test failure: Test Case '-[VoiceMemosTests.VoiceMemosTests testPermissionDenied]' started. /Users/point-free/projects/swift-composable-architecture/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift:107: error: -[VoiceMemosTests.VoiceMemosTests testDeleteMemo] : XCTAssertTrue failed Test Case '-[VoiceMemosTests.VoiceMemosTests testPermissionDenied]' failed (0.003 seconds).

9:14

There are 3 main components to this chunk. We have the opening line that declares the test started: Test Case '-[VoiceMemosTests.VoiceMemosTests testPermissionDenied]' started. This line contains the test name, which is something we want to parse out of this string.

9:30

We also have the closing line that declares the test finished, either passing or failing: Test Case '-[VoiceMemosTests.VoiceMemosTests testPermissionDenied]' failed (0.027 seconds). This line contains the time it took to run the test, which we also want to parse out of this string.

9:40

And finally, sandwiched between these two lines is the body of the test result: /Users/point-free/projects/swift-composable-architecture/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift:107: error: -[VoiceMemosTests.VoiceMemosTests testPermissionDenied] : XCTAssertTrue failed This chunk contains a lot of info we want to parse: the file name, the line number, and the failure message: XCTAssertEqual failed: ("true") is not equal to ("false")

10:05

Each of these three parsing problems seems far more tractable than the full problem of parsing a test result, so let’s attack each of them separate.

10:18

Let’s start with parsing the opening line: Test Case '-[VoiceMemosTests.VoiceMemosTests testPermissionDenied]' started.

10:22

We’d like a parser that consumes everything from the beginning of the input string up until the end of the test case’s “started” line. But how do we find the end of that line?

10:39

We can consume the input string until we encounter a "Test Case '-[" , and then we can capture the substring that starts from there and ends at a newline. And from that captured string we want to extract out the test name. So sounds like we want a parser of a string: let testCaseStartedLine = Parser<Substring> { input in }

11:23

In here we can first start by scanning until we find the "Test Case '-[" string, which demarcates where the line starts: guard let startIndex = input.range(of: "Test Case '-[")?.lowerBound else { return nil }

12:17

If we find that marker, we can then first scan until we find a new line: guard let newlineRange = input .range(of: "\n", range: startIndex..<input.endIndex) else { return nil }

13:09

And if we have gotten this far then we now can extract the line substring that lives between the start index and the newline: let line = input[startIndex..<newlineRange.lowerBound]

13:34

This is the substring we want to extract the test name from. Let’s remind ourselves of the structure of this line: Test Case '-[VoiceMemosTests.VoiceMemosTests testPermissionDenied]' started.

13:50

We want to hone in on just the "testPermissionDenied" part of this line. There are a few ways we could get to this. We could assume that it’s the string fragment starting at the 3rd space and ending a the "]" . We can accomplish this by splitting on the space, taking the third element, and then truncating the last 2 characters of the string to get rid of the bracket and quote: return line.split(separator: " ")[3].dropLast(2)

15:00

We should probably make this safer by checking for the length of the array first and returning nil if it isn’t greater than 3. We could also use more efficient ways to get at this substring other than splitting, but this is good enough for now.

15:24

But before we leave this parser we have to make sure we actually consume the part of the string we extracted the value from. Right now we aren’t mutating input at all, which means after this parser runs the entire opening line will still be there to parse again. To fix this we just need to take the substring from the newline until the end of the string: input = input[newlineRange.upperBound...]

16:12

You may be a little disappointed at the complexity of this parser. After all we are doing manual index juggling and subscripting, and that is hard to get right and can lead to crashes. It would be far better if we could instead compose this from simpler, lower level parsers that plug together in understandable ways. And this is possible, but we need to get a bit more parsing done until we can see the patterns emerge. This is often how creating these parsers goes. You need to start with a bit of manual index juggling to get things working, and then try to refactor with more powerful tools.

16:53

But either way, we now we have a working parser! We can take it for a spin by running it on our sample test logs: testCaseStartedLine.run(logs) // (match: "testPermissionDenied", rest: "/Users/...")

17:03

Very nice. It not only extracted out the first test name, but it also consumed the string up until where the test file path starts. Parsing a test case’s body

18:08

Next, let’s try parsing the body of the test failure. The body looks like this: /Users/point-free/projects/swift-composable-architecture/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift:107: error: -[VoiceMemosTests.VoiceMemosTests testPermissionDenied] : XCTAssertTrue failed

18:22

And this entire chunk is basically everything from the beginning of the input string to the next appearance of the next "Test Case '-[" . We can cook up a parser that scans off the entire front of the string until we get to this marker so that we are left with just the failure body: let testCaseBody = Parser<Substring> { input in guard let endIndex = input .range(of: "Test Case '-[")?.lowerBound else { return nil } let body = input[..<endIndex].dropLast() input = input[endIndex...] return body }

20:15

Note that we do the dropLast dance because we don’t want the trailing newline on the body.

20:30

Now from this body substring we want to extract a bunch of info: the test file, the line number and the failure message. I have a feeling this is a parsing problem that we can break down into many small units. We could perform the steps:

20:49

Parse the file name by parsing everything up to the ".swift" string, and then taking that result, splitting on slash, and taking the last component. That should yield just “VoiceMemosTests.swift”.

21:11

Then we can parse off a literal colon ":" and discard it

21:18

Then we can use our integer parser to grab the line number

21:23

Then we can parse everything up until the string "] :" and discard it

21:37

And then finally we can consume the rest of the string wholesale because that is the test failure message.

21:45

It’s quite a bit, but I think it’s doable! Let’s create a few smaller parsers, like say starting with the file name parser: let fileName = Parser<Substring> { input in }

22:05

Here we want to first check that the leading character of the input string is a slash "/" so that we can be sure we are parsing a file path, and then we can parse up to (and including) the ".swift" , and then we will take that fragment, split on the slashes and return the last component: let fileName = Parser<Substring> { input in guard let endIndex = input.range(of: ".swift")?.upperBound else { return nil } let path = input[..<endIndex] guard let fileName = path.split(separator: "/").last else { return nil } input = input[endIndex...] return fileName }

23:36

We can even give this parser a spin by feeding it the file path: fileName.run("/Users/point-free/projects/swift-composable-architecture/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift:107: error: -[VoiceMemoTests.VoiceMemoTests testPermissionDenied] : XCTAssertTrue failed") // (match: "VoiceMemosTest.swift", rest: ":107: error...")

23:50

So it works. Refactoring with a new combinator

24:00

But we’re starting to see some potential for extracting out some functionality in order to share amongst many parsers. We keep doing this same dance where we want to take the entire beginning of a string up to some particular substring. And sometimes we want to keep that substring in the result, as is the case for this file name parser, and sometimes we don’t, as is the case for the testCaseStartLine parser where we take everything up to "Test Case '-[" but not including it.

24:21

That is a very general thing that I think we can bake into our own custom parser combinator. We could call it prefix since it has a similar purpose as a few prefix methods on collections. extension Parser where Output == Substring { static func prefix }

24:42

There are two methods in the the standard library that return the prefix of a collection either up to or through a given index: ["a", "b", "c"].prefix(upTo: 2) // ["a", "b"] ["a", "b", "c"].prefix(through: 2) // ["a", "b", "c"]

25:25

So we can take inspiration from these methods to cook up parsers that parse a prefix up to or through a given substring. extension Parser where A == Substring { static func prefix(upTo substring: Substring) -> Self { } }

25:35

And these parsers we will basically do everything we keep doing over and over. We’ll start by find the range of the first occurrence of the substring and plucking out its lower bound, since we want to parse up to and not through the given substring: Self { input in guard let endIndex = input.range(of: substring)?.lowerBound else { return nil } }

26:08

Once we compute that index we can extract out the full prefix: let match = input[..<endIndex]

26:15

And finally we have to make sure to consume the prefix from the input string before we return the match: input = input[endIndex...] return match

26:32

And then we can copy and paste this parser combinator to cook up prefix(through:) by changing lowerBound to upperBound . static func prefix(through substring: Substring) -> Self { Self { input in guard let endIndex = input.range(of: substring)?.upperBound else { return nil } let match = input[..<endIndex] input = input[endIndex...] return match } }

26:44

And that right there is a super handy parser combinator, and we can already start using it. For example, we can rewrite our fileName parser to first parse a literal slash "/" , and then parse a prefix until we encounter ".swift" , inclusively, and then transform that full file path into just the file name: let fileName = Parser.prefix(through: ".swift") .flatMap { path -> Parser<Substring> in if let fileName = path.split(separator: "/").last { return .always(fileName) } return .never } // Parser<Substring> { input in // guard let range = input.range(of: ".swift") // else { return nil } // // let path = input[..<range.upperBound] // // guard let fileName = path.split(separator: "/").last // else { return nil } // // input = input[range.upperBound...] // // return fileName // }

28:09

This has really cleaned up the messy index juggling.

28:19

And if you’ve got the appetite for it, you can even shorten this a bit by using optional map instead of if let : let fileName = zip("/", .prefix(through: ".swift")) .flatMap { path in path.split(separator: "/").last.map(Parser.always) ?? .never }

28:45

We can also use this new prefix operator to rewrite our openingTestCaseLine parser, which is responsible for extracting the test name from the line that indicates a test has started. We can now replace all the messy index juggling with just a few prefixes and a map: let testCaseStartedLine = zip( .prefix(upTo: "Test Case '-["), .prefix(through: "\n") ) .map { _, line in line.split(separator: " ")[3].dropLast(2) } // Parser<Substring> { input in // guard // let startIndex = input.range(of: "Test Case '-[")?.lowerBound // else { return nil } // // guard // let newlineRange = input // .range(of: "\n", range: startIndex..<input.endIndex) // else { return nil } // // let line = input[startIndex..<newlineRange.lowerBound] // input = input[newlineRange.upperBound...] // // return line.split(separator: " ")[3].dropLast(2) // }

30:19

And now this is really nice. Next time: finishing our CLI tool

30:36

So we’ve done a little bit of clean up, but now let’s get back to where we were before this diversion. We were in the process of cooking up little parsers to help us parser the body of the test failure…next time! References Combinators Daniel Steinberg • Sep 14, 2018 Daniel gives a wonderful overview of how the idea of “combinators” infiltrates many common programming tasks. Note Just as with OO, one of the keys to a functional style of programming is to write very small bits of functionality that can be combined to create powerful results. The glue that combines the small bits are called Combinators. In this talk we’ll motivate the topic with a look at Swift Sets before moving on to infinite sets, random number generators, parser combinators, and Peter Henderson’s Picture Language. Combinators allow you to provide APIs that are friendly to non-functional programmers. https://vimeo.com/290272240 Parser Combinators in Swift Yasuhiro Inami • May 2, 2016 In the first ever try! Swift conference, Yasuhiro Inami gives a broad overview of parsers and parser combinators, and shows how they can accomplish very complex parsing. Note Parser combinators are one of the most awesome functional techniques for parsing strings into trees, like constructing JSON. In this talk from try! Swift, Yasuhiro Inami describes how they work by combining small parsers together to form more complex and practical ones. https://academy.realm.io/posts/tryswift-yasuhiro-inami-parser-combinator/ Regex Alexander Grebenyuk • Aug 10, 2019 This library for parsing regular expression strings into a Swift data type uses many of the ideas developed in our series of episodes on parsers. It’s a great example of how to break a very large, complex problem into many tiny parsers that glue back together. https://github.com/kean/Regex Regexes vs Combinatorial Parsing Soroush Khanlou • Dec 3, 2019 In this article, Soroush Khanlou applies parser combinators to a real world problem: parsing notation for a music app. He found that parser combinators improved on regular expressions not only in readability, but in performance! http://khanlou.com/2019/12/regex-vs-combinatorial-parsing/ Learning Parser Combinators With Rust Bodil Stokke • Apr 18, 2019 A wonderful article that explains parser combinators from start to finish. The article assumes you are already familiar with Rust, but it is possible to look past the syntax and see that there are many shapes in the code that are similar to what we have covered in our episodes on parsers. https://bodil.lol/parser-combinators/ Sparse John Patrick Morgan • Jan 12, 2017 A parser library built in Swift that uses many of the concepts we cover in our series of episodes on parsers. Note Sparse is a simple parser-combinator library written in Swift. https://github.com/johnpatrickmorgan/Sparse parsec Daan Leijen, Paolo Martini, Antoine Latter Parsec is one of the first and most widely used parsing libraries, built in Haskell. It’s built on many of the same ideas we have covered in our series of episodes on parsers, but using some of Haskell’s most powerful type-level features. http://hackage.haskell.org/package/parsec Parse, don’t validate Alexis King • Nov 5, 2019 This article demonstrates that parsing can be a great alternative to validating. When validating you often check for certain requirements of your values, but don’t have any record of that check in your types. Whereas parsing allows you to upgrade the types to something more restrictive so that you cannot misuse the value later on. https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/ Ledger Mac App: Parsing Techniques Chris Eidhof & Florian Kugler • Aug 26, 2016 In this free episode of Swift talk, Chris and Florian discuss various techniques for parsing strings as a means to process a ledger file. It contains a good overview of various parsing techniques, including parser grammars. https://talk.objc.io/episodes/S01E13-parsing-techniques Downloads Sample code 0121-parsers-recap-pt3 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .