Simply Parse in C
pencil.toast.cafe
pencil.toast.cafe
int parse_until(FILE *src, char *ptr, ssize_t maxlen, const char *s) {
int out = 0;
while (out < maxlen) {
*ptr = fgetc(src);
if (*ptr == EOF) { // hit error while scanning
*ptr = 0;
return ferror(src) ? -out : out;
} else if (strchr(s, *ptr)) {
*ptr = 0;
return out;
}
ptr++; out++;
}
// we only make it here if we hit maxlen
(*--ptr) = 0;
int skipped = parse_skipwhile(src, s);
if (skipped > 0) {
return out + skipped; // errors are negative, eof is ok
}
return ferror(src) ? (skipped - out) : (out - skipped);
}
Hint: it relies on implementation-defined behavior such that it (mostly) works on x86 but not on ARM.(The fact that it silently clips the string if it's overlong is annoying, but it's not what I was thinking).
So EOF is defined as -1. Depending on how the environment chooses to define char, one of two things can happen. On systems like x86 where char is signed, it's impossible to read a byte value of 0xff, as it is confused with EOF. If instead you're on a system like ARM where char is unsigned, then you can never read EOF.
But C-like code in general makes me nervous in parsers, since there's many ways things can go wrong and C is inherently fail-deadly if you make a mistake.
What is an abstraction and what do you need to code it?
int fgetc( FILE *stream );
But the retval is treated as a char.>getc() is equivalent to fgetc() except that it may be implemented as a macro which evaluates stream more than once.
>getchar() is equivalent to getc(stdin).
I don’t think so.
fgetc returns an int (https://en.cppreference.com/w/c/io/fgetc) and EOF is an int (https://en.cppreference.com/w/c/io)
So,
*ptr = fgetc(src);
discards part of the result of fgetc.If my C isn’t too rusty, that means the code can’t discriminate between hitting end of file and reading some byte value from the files (AFAIK EOF is -1 on most systems, so that value would often be 0xFF)
I'm on an ARM (m1 mac) machine and looks like char type is a signed type. EOF on this machine is -1
1. How can you distinguish between the char in the file and EOF (-1)?
2. Is EOF reserved for all files on macOS?
Fun fact: This approach causes trouble on obscure embedded platforms where char and int are the same size (and therefore an unsigned char value can’t fit inside a signed int). Such platforms are allowed by the C standard as freestanding implementations that don’t implement the full standard library, but they can’t conformantly implement fgetc. https://stackoverflow.com/questions/3860943/can-sizeofint-ev...
Representing binary data as unsigned char (as opposed to char or signed char) is the norm however.
int ch = fgetc(src);
if(ch == EOF)
goto oops_eof;
out++;
*ptr++ = ch; *ptr = '\0';
if(strchr(s, ch))
return out;
or maybe int ch;
if((ch = fgetc(src)) == EOF)
goto oops_eof;
*ptr[out++] = ch; *ptr[out] = '\0';
if(strchr(s, ch))
return out;Also, I think you got the "what will work" question backwards with signed/unsigned. MSVC on x86/x64 has signed char and so, e.g., "isupper(c = fgetc(f))" segfaults on reading a non-ASCII char; and similarly in this case reading "я" from a Win-1251-encoded file (or "ÿ" from a Win-1252-encoded file) will be treated as (premature) EOF.
> No newlines in keys, values, or section names. Empty values are not allowed. Comments only on their own lines (minus whitespace). Whitespace-insensitive (whitespace at the start of line, end of line, around the “=”, is all ignored). No need for a terminating newline either. Oh that's more than most C ini parsers do? Isn't that convenient
Nonsense. My ini parser has fewer restrictions (only one, on line length) and looks nothing like that unreadable mess.
Maybe unrelated, but I'm pretty certain I've seen gcc warnings when assigning ints to chars, unsigned or otherwise
The type of 'out' is a different type than ssize_t, which may not matter for the practical ranges involved in parsing a file, but it certainly a bad sign.
Writes to memory it doesn't own if maxlen == 0 (unlikely, but who knows?)
Additionally: calling strchr on every input character is crazy. Frankly, the same applies to fgetc.
<source>:8:18: warning: comparison is always false due to limited range of data type [-Wtype-limits]
8 | if (*ptr == EOF) {
Of course this warning only happens when you target ARM, so I can imagine it's still hard to catch if you do all your development on x86, and only occasionally cross-compile to ARM without heeding compiler warnings.(This is a close relative of the well-known footgun that the is*() functions from <ctype.h> accept an unsigned char value as an int, and it’s completely valid—though rare in practice—for them to blow up when passed, say, "\xFF"[0] instead of ((const unsigned char *)"\xFF")[0] on an implementation with CHAR_BIT==8 and SCHAR_MIN<0.)
C2x in fact requires 2's complement.
C18 6.2.6.2p3 had:
> [It] is implementation-defined [...] whether the value with sign bit 1 and all value bits zero [...] is a trap representation or a normal value [for two’s complement].
If the code wouldn't do 2), the bug you describe would probably never have happened. The return value from fgetc() is int, not char. It has to be larger than char to be able to return a char as well as EOF.
I would probably code something along the lines of
void identifier(Parser *parser)
{
My_String_Builder *builder = get_string_builder(parser);
reset_string_builder(builder);
for (;;)
{
string_builder_push(builder, c);
if (! next_byte(parser))
break;
int c = parser_get(parser);
if (!(is_alpha(c) || c == '_' || ...))
break;
}
String *string = string_builder_finalize(builder);
// check string builder "overflow" / too long / further input sanitization
push_string_token(parser, TOKEN_IDENTIFIER, string);
}Then, that fgetc() returns EOF on either an EOF or error is its major weakness — mixing in data and control all in-band — one is supposed to check with feof() and ferror() which of those two have happened, and if neither is true, then it's a data byte that just happens to be equal to the EOF constant. (This makes it quite pessimal when you are reading in a file that happens to contain lots of bytes equal to EOF, though).
> If instead you're on a system like ARM where char is unsigned, then you can never read EOF.
Then you should probably use an intermediate variable which is a proper int, gives you a surefire way to tell 0x000000ff and 0xffffffff apart.
But it's all gotchas like this which make me cringe every time someone suggests C is oh so very good and "simple", or even (gasp!) "convenient" to work with strings in general and text in particular. It's anything but. Hell, maybe it was better than alternatives back in some 1976, but then awk and Perl got invented, and Python followed soon after.
The program used a 'char' to store the value of the formal atomic charge of an atom, which is typically 0, but for the sorts of chemistry I deal with can be +1, +2, -1, or -2, so it makes sense to allocate only 8 bits to store the 'char'ge. :)
On IRIX, where they had deployed the code for years, the charges were actually being interpreted as 0, +1, +2, 255, and 254.
Going back to the code example you gave, isn't there also an issue if read failure occurs on the first byte? Then `out` is 0, returning -0, which is 0, giving no way for the caller to distinguish between EOF and read failure.
> People are terrified of parsers and parsing
And rightfully so. People who aren't afraid of them generally fail to understand all of the ways in which parsing can show fractal complexity, and will mostly stick to toy examples like this INI parser to justify their positions.
If you're gonna argue that parsing is simple, the bare minimum I'd want to see implemented is a context-sensitive grammar with unbounded lookaheads (or at the very least, that is capable of handling more than one token of lookahead), with proper support for Unicode, and actual error resilience (not what this article calls error resilience)
If you manage to do all that and can still call what you did "simple" without having completely deluded yourself, congratulations, I hope to be on your level some day.
PS1: I won't even go into the plethora of security issues originating from crappy parsers, especially those written in C
PS2: Let's also leave aside any matters related to correctness and validation of parsers, which are notoriously not by any means "simple".
PS3: Or generating decent errors for that matter.
- If the parser is "the thing that comes after the lexer" then all of this is abstracted away by the lexer and you can just treat it as a span of bytes;
- If the parser is "everything that needs to be implemented to correctly transduce the input sequence into a tree", then you need to implement this yourself or have a lexer that handles this for you, usually done by having a tiny UTF-8 codepoint recognizing FSM in your lexer (UTF-8 is a self-synchronizing code, which makes this part easier) and ignoring the existence of graphemes.
Most people, however, shy away from implementing a parser "all the way down to the bytes" and properly handling UTF-8 as a formal language. Most lean on a lexer abstracting this away. Ditto for context-sensitivity.
Recently Rust's regex engine underwent a major overhaul, and burntsushi wrote a blog post[0] about doing the "all the way to the bytes" thing in the new regex engine, I highly recommend the read:
[0] https://blog.burntsushi.net/regex-internals/#nfa-optimizatio...
Most of the lexical/syntactic elements of languages are not in UTF-8. You're looking for things like semicolons and quotes and whitespace. If you don't change the language syntax/lexical elements so that those parts stay as the ASCII subset of UTF-8 then why does your lexer need to be aware of UTF-8? It can just accumulate everything else as bytes and it doesn't matter what format the bytes are. The parser and/or codegen will do equality checks for lookups later on but that doesn't need to be UTF-8 aware either?
Am I missing something?
Can you? Unicode has the following "new line" characters:
* U+000A Line Feed (LF) alone
* U+000D Carriage Return (CR) alone
* CRLF as one indivisible sequence
* U+000B Line Tabulation (VT) — supporting this is explicitly optional, and the main standard's newline function definition does not include it
* U+000C Form Feed (FF)
* U+0085 Next Line (NEL), an EBCDIC round-trip compatibility character
* U+2028 Line Separator (LS)
* U+2029 Paragraph Separator (PS)
My source: https://langdev.stackexchange.com/a/590/717
Maybe I'm wrong though, just an assumption about what's common.
(See for example how Go, which is Unicode aware, defines tokens: https://go.dev/ref/spec#Tokens.)
Consider a corrupted codepoint at the end of a user generated string: will it recognize the closing quote as such, or will it assume it is part of a corrupted codepoint and try to skip over it?
So many ways to shoot yourself in the foot by "abstracting away" the formal semantics of your inputs, I think it's pretty much never worth it. (An interesting search term here is LangSec)
Maybe I'm misunderstanding you, but because of how UTF-8 is a superset of ASCII, I don't believe you can misrecognize ASCII characters if that's what you mean.
- UTF-8 is a prefix-free self-synchronizing code;
- If the first byte of a UTF-8 codepoint starts with 0b0??????? then it is ASCII, and all is well;
- If the leading byte of the codepoint is 0b110? it means there is one continuation byte to follow. If its 0b1110? there are two bytes to follow, and so on up to a maximum of 4 continuation bytes, which is the limit for UTF-8;
- All continuation bytes have the pattern 0b10? and UTF-8 self synchronizes based on detecting the leading byte;
- The correct way to parse UTF-8 is to not believe these lengths AT ALL and actually run the UTF-8 state-machine over the entire input, which can be made quite fast by leveraging bit-parallel techniques (see Daniel Lemire's work);
- The way you shoot yourself in the foot is by believing the length and skipping over those bytes: an attacker makes the last codepoint one that expects a single continuation byte but does not include the continuation byte, the fancy pantsy "optimized" parser will skip over the closing quote and decohere the parse. This is only safe to do on pre-validated input, but even then it's kind of not worth it if you have access to a SIMD accelerated UTF-8 validator
Hope this clears it up!
PS: I DMed you on Discord ;)
My opinion is, stated in a way that a TigerBeetler will resonate with ;), is I want to be able to handle radioactive levels of corruption in my inputs, and still parse them without blowing up, and issuing great error messages along the way.
You may want to look at chibicc, a toy (but self-hosting) C compiler written in C, which treats C grammar as if it was pretty much that.
Of course, the sane way is to not invent languages that can be naturally described only by a context-sensitive grammar with unbounded lookahead.
But I wholeheartedly agree with the sentiment of "don't make the grammar look like Scala" <3
Depending on the language.
You should definitely have bounds though, but the point is that if it's too low you might give up on the input too soon.
As long as all your delimiter chars are ASCII, it just works.
Errors in C are usually because of missing abstractions or the wrong approach. C gives you data layout, flow control, and functions, you can go a long long way with just that.
> unbounded lookaheads
If you want to require that, you get what you deserve. But implementing it is just a matter of putting a queue of tokens in front of your parser that supports look(n) separately from consume().
Sometimes you just haven't met the right abstraction yet. I'll be that guy talking about parser combinators hopefully before the rest of this thread fills up with them. I don't think my parser does everything in your bare minimum (I haven't really thought about utf-8!) but it does do some other pretty advanced stuff. For example it leans on white-space pretty hard to figure things out. No curly braces or semicolons, and parentheses are only for precedence, not function application.
What my parser does do:
* backtracking/alternatives
* Some context-sensitivity, in-so-far as it can tell a negate from a minus.
Where it got a little hard:
* I realised I was parsing division the wrong way. a/b/c/d became a/(b/(c/d)), not the other way around.
Where it got medium hard: * Distinguishing unary minus from binary minus. I thought it would be really hard, but I only needed to look at the previous token to decide whether something was a TokNegate or a TokMinus.
Where it got hard:
* White-space/indentation sensitivity. I needed to first calculate the line-breaks and make that information (gathered during lexing) available during parsing.
Where it got really hard:
* LEARNING how to factor out the left-recursion. There were times when I literally thought it was impossible. I knew about 'precedence' in the back of my mind, but I didn't realise how the concept mapped to the code yet. By example: one sumExpression is (many or one productExpressions separated-by-'+'), and one productExpression is (many or one unaryExpression separated-by-'*'), and so on. You don't end up in an infinite-parse-loop if you try to parse the least-tightly-binding expressions first (which just seemed so counterintuitive that I guess I never tried?).
But I've yet to say why I like parser combinators so much (and think they're at least the 'simplest' way to do things, if not 'simple'):
You get to write code which looks like the bnf definition !
Just like TFA I'll take a lua example[1]
var ::= Name | prefixexp `[´ exp `]´ | prefixexp `.´ Name
I would code this something like: var <- name <|> case2 <|> case 3
where
case2 = do
pe <- prefixexp
e <- char '[' *> exp <* char ']'
return (pe, e)
case3 = do
pe <- prefixexp
_ <- char '.'
n <- name
return (pe, n)
It more or less maps exactly onto the BNF, and in the above case, the extra complexity came from capturing the subexpressions and returning them to the caller. If I wrote a grammar to simply accept/deny its input (rather than trying to build an AST out of it), it could resemble the BNF even more:Bnf definition vs. executable code:
var ::= Name | prefixexp `[´ exp `]´ | prefixexp `.´ Name
var = name <|> (prefixexp >> char '[' >> exp >> char ']') <|> (prefixexp >> char '.' >> name)
I will say one other thing about the simplicity, which is - I didn't use an existing parser combinator library. They're simple enough to roll your own. There's only one trap which I can think of, which is where to draw the line on automatic-backtracking. I.e. Should the caller explicitly need to insert 'try's to enable backtracking.> Cute, now do it with UTF-8 support.
Ironically I think this is the one feature where I'd prefer to be in C. C's approach with bytes is perfectly forward-compatible. A higher-level language might be more opinionated about its String type (restricting what you can or can't accept with your parser) or have funny definitions about length().
[1] http://parrot.github.io/parrot-docs0/0.4.7/html/languages/lu...*
I see this type of sentiment a lot and I'm not sure why this exists. Maybe it's because there were a bunch of formats in the past and it made it more difficult? Idk.
Anyways, I finally decided to "bite the bullet" and prepared a solid week to finally do the "nitty gritty" of writing a UTF-8 validator/logging library. Turns out, it was super easy and took me like an hour to read through the RFC and maybe 2 more hours to write a simple implementation.
For anyone that's curious, give it a read here[0], it's surprisingly readable and the format is very simple and elegant. I don't say simple as in dumb either, I say simple as in they made the problem as simple as it needs to be with no unneeded complexity, and it's a breath of fresh air.
Also, it's written in such a way that any valid ASCII is valid UTF-8. So at the very least, you can just check if you encounter any bytes with the highest bit set in the string before parsing. If that's the case you can throw an error saying you don't support UTF-8 and avoid parsing potentially invalid data (not that it's particularly difficult to validate the UTF-8 if you want to).
[0]: https://datatracker.ietf.org/doc/html/rfc3629#section-3
Let me know if you see any bugs[0], I'll add it to my regression tests.
[0]: https://github.com/ambrosiogabe/CppUtils/blob/master/single_...
I was thinking about the usual cases where lowercase and uppercase character count don't match, non-latin character based languages and so on.
> People are terrified of parsers and parsing
But this implementation of a reduced feature set version of .ini file parsing does not convince me that I should write my own parser instead of using one that implements a more full feature set
> No newlines in keys, values, or section names. Empty values are not allowed. Comments only on their own lines (minus whitespace). Whitespace-insensitive (whitespace at the start of line, end of line, around the “=”, is all ignored). No need for a terminating newline either.
I think it's reasonable that people want to use a parser that has better error handling and gives an idea of where the ini file may have parsing problems than just a barebones implementation such as this provides.
I also think that using a library for parsing instead of writing your own parser does not imply that you are scared of parsing.
A performance comparison on a large ini file might.
You may want to take the time to watch this video[0]. In it, Andreas Fredriksson walks though his reasoning for writing his own parser instead of using the standard json parser.
I appreciate the "do it yourself" thing - but in 2023 unless you're building a product for a known ascii system, you're setting yourself up for pain when your code is run in San José,
I have, I wish I hadn't, but there we go.
I'd rank it up there with f77 return labels as being coding clusterfucks.
(Yes, you can do parser combinators in C, but it's very very ugly unless you stick to clang and enable block support).
If anything I'd expect to be told to use a high(er) level language for parsing as a front end to C code if needed.
If you just jumped to the code, it's in the first 2.5 sentences:
> People are terrified of parsers and parsing. To the point of using magical libraries with custom syntaxes to learn just to get started. In the hopes of completely shattering this preconception
Seems mostly to me about making parsing less "magical" for people who don't understand what's really going on.
Parent is right in that other approaches (parser combinators) can actually make parsing less scary.
As if parsing in C is going to make them less terrified.
"In short, there are a few reasons that parsing is a mess, and none of those reasons are actually resolvable by parser generators."
I'm pretty sure this is untrue /and/ part of the problem. Build quality on these tools is appalling...
// if the callback returns non-zero, parsing will stop
typedef int (*callback)(const char*, const char*, const char*, void*);
How does that typedef ensure the behaviour mentioned in the comment, or are they unrelated?Coding in C doesn't mean that you can't code function abstractions. Instead of storing things through pointers with pointer arithmetic and indexing all over the place, and doing manual bounds checks everywhere, you code a few functions like push_char(), push_token() etc.
Follow pseudo ADT (Abstract Data Types) approach, with the classic modulename_function() pattern, everything that shouldn't be exposed is marked as static symbols on the implementation file, and for the few cases where there is a possible performance impact using ADTs, there is one or other macro.
However, that is not how most C developers program, regardless of how many books, ACCU and The C Programmers Journal articles, and conference talks on how to write proper C have been written.
bison -y -Wno-yacc -dv ../../lib/common/htmlparse.y -o htmlparse.c
../../lib/common/htmlparse.y: warning: 2 shift/reduce conflicts [-Wconflicts-sr]
../../lib/common/htmlparse.y: note: rerun with option '-Wcounterexamples' to generate conflict counterexamples
Noted.This partially explains the success of languages like XML, JSON and YAML as alternatives to writing your own parser.
Sounds like overkill. Most often you don't need a full-fledged ini format, but just a list of "KEY=value" pairs that can be parsed with a single call to scanf.
1. Its portable - JSON token streams look pretty similar in every language.
2. You're in control. Switching parsing libraries is painful when it's baked into your code (I'm currently weeding out a now unsupported parsing lib and it's painful).
3. It's flexible - try parsing heterogenous websocket JSON streams with e.g. Swift's Codable. Possible: yes. Easy: no.
4. It's really fast.
If you're in control of the source and sink, the above probably doesn't apply - then it's trivial to make struct-based parsing fast and easy.
(edited to add line breaks)
If there isn't even the most basic complexity then it's hardly worth claiming you're introducing parsing.