Parsers don't have to be complicated

(bkaradzic.github.io)

39 points | by signa11 9 days ago ago

27 comments

  • imoverclocked an hour ago

    The hardest thing about writing a parser is cognitively accepting what is going to be considered valid input. You can make the best parser that is fast and well specified but invariably someone will (ab)use it in an unexpected way.

    Famous examples: despite so many initial good intentions, html tags don’t need to be closed, JSON numbers are too often encoded as strings, YAML can look like what most people expect or it can look progressively more like JSON… and on and on.

    • simonask an hour ago

      I think the second-hardest thing is to accept that CS spent decades optimizing parsing algorithms and grammars, and this is still a significant part of CS curricula in many places. But the practical reality is that parsing is almost never a bottleneck.

      If what you're parsing is within the capacity of humans to interact with (so in the range of tens of kilobytes), a grammar that requires an O(N^2) parser is totally fine.

      • bregma 5 minutes ago

        I don't think it is difficult to accept that fundamentals should be taught.

        We spend years learning basic arithmetic like the addition of integers. You could very well argue that there is no need for that either because everyone has a calculator app on their phone. This is how dark ages begin.

      • haileys 23 minutes ago

        An O(n^2) parser is not fine for the mere reason that I don't know how one would make such a mess of the job in the first place.

        A simple recursive-descent parser is easy to write by hand and runs in linear time.

        • mhast 4 minutes ago

          I think the point was that even if you managed to make a O(n*2) parser it will ve fast enough for human entered problems.

  • f311a 2 hours ago

    Unfortunately, simple URL parsing breaks on so many things. There is a reason on why every URL parsing library is at least a few thousand LOCs.

    One common way to test it is just to pass ipv6 url: http://[f021:d981:b487:e57d:193e:550e::]/

    • meindnoch 13 minutes ago

      Is that so?

      RFC 3986 Appendix B [1] "Parsing a URI Reference with a Regular Expression":

      The following line is the regular expression for breaking-down a well-formed URI reference into its components.

        ^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?
      
            scheme    = $2
            authority = $4
            path      = $5
            query     = $7
            fragment  = $9

      Let's test your URI with this regex, shall we? [2]

        $2 (scheme) = http
        $4 (authority) = [f021:d981:b487:e57d:193e:550e::]
        $5 (path) = /
      
      Seems correct to me.

      [1] https://datatracker.ietf.org/doc/html/rfc3986#appendix-B

      [2] https://regexr.com/8nqop

  • mrkeen 2 hours ago

    If you draw a line from 'ad-hoc byte-wrangling nonsense' to 'parser combinators', this can't be more than 20% along it.

    Looking at the linked URL parser, why doesn't it look like

      url = do scheme
               authority
               path
               query
               fragment
      where
      scheme = ...
      authority = ...
      etc.
    
    It looks totally ad-hoc.
  • Retr0id 2 hours ago

    > LineReader splits input into lines, handles \n and \r\n, and trims the stray trailing \r that malformed input likes to leave behind

    Is there a common source of extra \r in malformed inputs, beyond those existing as part of \r\n? Or is this just a dig at Windows-style line endings? If there's something weird going on I think I'd rather fail loudly.

    > Bounding the inner scanner to a single line makes “run past the end of a malformed line” unrepresentable rather than merely unlikely.

    I don't really see what makes it "unrepresentable", and this reads more like "if you used the right scanning logic, you can't have used the wrong scanning logic".

    • inigyou 2 hours ago

      Sure, start with \r\n, split on \n, now you have a stray \r at the end of every input.

      • Retr0id 2 hours ago

        But the preceding clause says it handles \r\n. If you're already handling \r\n, what remaining sources of \r are there, that you'd actually want to silently ignore?

        • inigyou an hour ago

          Someone else (possibly you) already split on \n.

          • Retr0id 3 minutes ago

            Fair point. I think if something is getting mangled like that I'd rather fail loudly, but it depends on the use case I suppose.

      • HackerThemAll 16 minutes ago

        \r\n?|\n

        handles all EOL sequences without backtracking. Or write a non-regex equivalent of that.

    • thesz an hour ago

      End of line on Classic Mac is \r.

  • tomashubelbauer an hour ago

    This post doesn't touch on something that makes parsers complicated no matter how simple the grammar: good error messages. Parsing a well formed input is the easy part, but not just spitting out a byte index but actually telling the user why their input is not good and what they could do to make it conform is super hard.

    The Rust compiler is a common example of a compiler that does a good job here, and I think it is one of only a few.

    • iainmerrick 5 minutes ago

      It actually does touch on this:

      Built-in line and column tracking. Any movement across a newline updates the line number, including a backwards seek. getLine and getColumn are always available and both are one-based, which makes decent error messages nearly free.

      That doesn't sound like much, but having hand-written plenty of recursive descent parsers, it's most of what you need for good error messages. Just being able to pinpoint where the error occurred is usually 80% of the battle; but keeping track of lines and columns in a hand-written parser is a pain.

      Sure, for something like Rust, you need vastly more than that, but parsing is a tiny fraction of what the Rust compiler is doing -- type-checking and borrow checking is much more complicated and much more important.

      A tiny library like this is a great fit for something like an INI file parser.

    • estebank 6 minutes ago

      I will provide some context from having done a lot of that work.

      The Rust grammar is actually quite regular, that's why we have things like the turbofish for type parameters (`binding.method::<Type>()`): it makes the grammar unambiguous (a naïve parser would with a complicated grammar that accepts chained comparisons would have to deal with differentiating between `binding.method < value > ()` and `binding.method<Type>()`). But that doesn't mean the rustc parser doesn't do the work of supporting some the more complex grammar in order to provide better diagnostics. I like to say that rustc actually knows about meta-Rust, a daughter language that goes crazier in its features. I also joke that rustc isn't done until you can paste code from another language and following the suggestions you end up with valid Rust code without loss of the user's intent.

      Part of the problem is that the places where incorrect code can fail is in more places than the parser. The chained comparisons example is one that is easy for Rust (as it doesn't support them), so the parser itself can produce a "missing turbofish" suggestion with high certainty, but for truly ambiguous expressions, the errors will happen later, during name resolution ("expected a value and found a type") or when checking the number of arguments. A production compiler needs to account for not only the original error, but also silence every knock-down error too. The simplest strategies are to just stop if at the end of a given stage there are errors (which leads to the "wave of errors" experience of fixing the "last" error leading to a ton of new ones) or fully replacing entire blocks of code that had a parse error with an AST node that acts as a tombstone marking that that later stages need to ignore it. The first option leads to a bad experience, and the latter is insufficient. A recent example of looking at this is https://github.com/rust-lang/rust/pull/159689, where `Arc::new(RwLock::new(HashMap<i32, i64>::default()));` currently produces

        error[E0423]: expected value, found struct `HashMap`
          --> $DIR/suggest-turbofish-parsed-as-comparisons.rs:11:34
           |
        LL |     let _ = Arc::new(RwLock::new(HashMap<i32, i64>::default()));
           |                                  ^^^^^^^
           |
          --> $SRC_DIR/std/src/collections/hash/map.rs:LL:COL
          ::: $SRC_DIR/std/src/collections/hash/map.rs:LL:COL
           |
           = note: `HashMap` defined here
        
        error[E0423]: expected value, found builtin type `i32`
          --> $DIR/suggest-turbofish-parsed-as-comparisons.rs:11:42
           |
        LL |     let _ = Arc::new(RwLock::new(HashMap<i32, i64>::default()));
           |                                          ^^^ not a value
        
        error[E0423]: expected value, found builtin type `i64`
          --> $DIR/suggest-turbofish-parsed-as-comparisons.rs:11:47
           |
        LL |     let _ = Arc::new(RwLock::new(HashMap<i32, i64>::default()));
           |                                               ^^^ not a value
        
        error[E0425]: cannot find external crate `default` in the crate root
          --> $DIR/suggest-turbofish-parsed-as-comparisons.rs:11:53
           |
        LL |     let _ = Arc::new(RwLock::new(HashMap<i32, i64>::default()));
           |                                                     ^^^^^^^ not found in the crate root
        
        error[E0061]: this function takes 1 argument but 2 arguments were supplied
          --> $DIR/suggest-turbofish-parsed-as-comparisons.rs:11:22
           |
        LL |     let _ = Arc::new(RwLock::new(HashMap<i32, i64>::default()));
           |                      ^^^^^^^^^^^              --------------- unexpected argument #2 of type `bool`
           |
        note: associated function defined here
          --> $SRC_DIR/std/src/sync/poison/rwlock.rs:LL:COL
        help: remove the extra argument
           |
        LL -     let _ = Arc::new(RwLock::new(HashMap<i32, i64>::default()));
        LL +     let _ = Arc::new(RwLock::new(HashMap<i32));
           |
      
      This is because the expression is syntactically correct as

        RwLock::new( HashMap < i32, i64 > ::default() );
        ^^^^^^^^^^^^ ------- - ---^ --- - ----------- ^
        |            |       | |  | |   | |
        |            |       | |  | |   | a function call to `default` in the crate root
        |            |       | |  | |   a more than binop
        |            |       | |  | a value to be compared
        |            |       | |  the separator of the second argument to `RwLock::new()`
        |            |       | a value to be compared
        |            |       a less than binop
        |            a value to be compared
        an associated function call
       
      but after that PR it would only be the following, even though the parser hasn't changed:

        error: can't compare two types
          --> $DIR/suggest-turbofish-parsed-as-comparisons.rs:24:41
           |
        LL |     let _ = Arc::new(RwLock::new(HashMap<i32, i64>::default()));
           |                                         ^        ^ these are parsed as "less than" and "greater than"
           |
        help: you likely intended to write type `HashMap` with type parameters, but type parameters in expression contexts require the use of the "turbofish" `::<>`
           |
        LL |     let _ = Arc::new(RwLock::new(HashMap::<i32, i64>::default()));
           |                                         ++
      
      I think that there's a lot of work needed in the parser itself to produce good diagnostics. There are other strategies, like performing multiple parses at a given point when you've reached a known bad state (you've seen a flag-post that shouldn't be there, but that is a signal for a handful of other known cases), or fully consuming the rest of a block when an unrecoverable parse occurred (we're half-way through parsing function arguments, but failed? consume the rest of the statement or of the parent block, accounting for sub-scopes). The latter can cause the rest of the file to be consumed, but that's an edge-case that in practice is much better than a deluge of irrelevant errors.

      Another added complexity is how some easy-to-hit errors occur during lexing, which means the compiler has barely any information about the user's code. Mismatched braces/parens is one of those. rustc tries to provide context by keeping a queue of seen open delimiters to point at, and explicitly checking for their indentation level as a heuristic to detect where the user's intent diverged from the code, but that's overly reliant on the code being sanely formatted (thanks to rustfmt-on-save, that's a good bet for many users). For an example of the things rustc can do even in the lexer, you can look at https://github.com/rust-lang/rust/pull/160592.

  • alexjurkiewicz 38 minutes ago

    > if (!line.accept('[').isEmpty() ) // [section] header.

    Is this really ergonomic?

  • speedgoose an hour ago

    I now use nom to write my parsers. Once you understand it, it’s simple and parsing complex data becomes a _fun_ puzzle. I recommend it.

    https://github.com/rust-bakery/nom

  • jdw64 an hour ago

    I'm going to collect this post after 24 hours, extract the methodologies from everyone's comments, and write them down in my notes. The reason I like HN is that people freely share their tips in the comments

  • r3d 39 minutes ago

    If you created a format that is so difficult to parse that it cannot be parsed with simple readable C code then the problem is the format not the parser code.

    • tester756 11 minutes ago

      Why care about lang which doesnt really support strings well?

      • r3d 7 minutes ago

        What do you think the libraries you use to parse these things are doing under the hood?

        Maybe you don't care? Fair enough.

    • jrimbault 37 minutes ago

      Can you feel the irony when typing this? "Simple readable C code" itself not being able to be parsed by "simple readable C code".

      • r3d 18 minutes ago

        Yeah, I agree. But C code parsing is a common and solved problem. The myriad of things people want to store and recover is not though right.