Skip to content

Releases: tamnd/rucc

v0.2.8

v0.2.8 Pre-release
Pre-release

Choose a tag to compare

@tamnd tamnd released this 01 Sep 03:52
v0.2.8
f70ddef

Added

  • Statement checking in rucc-sema, and with it the function definition and the walk over a whole translation unit. This is the one walk in the checker that carries state, because a statement is legal or not depending on what encloses it: whether break is allowed is a question about the loops and switches around it, what return may carry is a question about the function it is in, and a goto may name a label fifty lines further down. Labels are therefore resolved over the whole function rather than in order, a label is created where its name is first met whether that is the jump or the definition, and what is left undefined at the end of the function is reported then, sorted by where it was written so that two runs of the same input report in the same order. GNU's __label__ is implemented rather than refused, since a macro that jumps to its own end has to be expandable twice in one function and the kernel is full of them, so a block-local label shadows the function-wide one and is undone when the block ends. A switch collects its cases into one table and patches each case statement with its entry once the table exists, which is what a jump table wants to read and what keeps a nested switch from interleaving its cases with the ones outside it. A case value is measured against the type that was written and not the promoted one, so case 300 on a char is worth saying even though 300 is a perfectly good int. A function body is one scope holding its parameters and not two, which is why void f(int a) { int a; } is a redeclaration and void f(int a) { { int a; } } is not, and the parameters are declared once by the type builder and bound again by the definition so that the prototype's n and the body's n are one declaration. An expression statement holds the value of its expression rather than a conversion of it to void, which is what lets GNU's statement expression take the type of its last statement. What is not here is reachability, since control reaches end of non-void function is a question about a control flow graph and the answer to it is in the IR.

  • The expressions that name a type in rucc-sema, which is the cast, sizeof, alignof, offsetof, _Generic, va_arg and the two __builtin forms that take a type name. Each of them was reported as unsupported until there was a type builder to ask, and each of them is now checked and folded where the language says it is a constant. They live apart from the other operators because they behave differently: an operator that names a type asks the type builder a question before it looks at any value, and most of them leave a number in the tree with the operand gone, which is not an optimization but what the language says they are, since int a[sizeof(int)]; is a fixed array and not one whose bound has to be worked out later. The details worth calling out are the ones where the obvious implementation is wrong. A cast to void becomes the node the tree already has for a value being discarded rather than a second kind of node meaning the same thing. sizeof does not apply the value conversion, which is the whole reason sizeof a on an int[4] is sixteen and not the size of the pointer it would have become anywhere else, and it is also why the message about a function type is a message about a function type rather than an answer about a pointer. sizeof of a variable length array is the array's own size expression rather than a fresh one built from the bound, because C evaluates that expression once where the array was declared and a compiler that emits it again at each sizeof calls whatever the bound calls a second time. An array's alignment is its element's however deep the array goes, which is an answer even where its size is not. A type with no size is measured as one with a warning, since that is what GNU C does so that p + 1 on a void * means what everyone who writes it means, and a type with no definition is an error that names the type. Every message about the alignment says __alignof__ whatever the program wrote, which is what gcc prints and what a build log that greps for it wants. The casts that are refused are refused by which side of the cast was wrong, so an array type, a function type, a non-scalar target, an aggregate operand, a pointer where a floating type was asked for and a floating type where a pointer was, each with gcc 13.3's wording measured rather than recalled, and a cast of a record to its own type is accepted because gcc accepts it and it does nothing. The two casts that are allowed and still warn are a pointer to an integer of another width and back, which is the one gcc warns about by default because the value does not survive the round trip. _Generic checks every association whether or not it is the one selected, since a mistake in an association is a mistake wherever it was written, but it selects on the type the controlling expression has after its conversions, which is why an int[4] selects int *. __builtin_choose_expr checks only the arm it takes, which is the entire reason the operator exists rather than being written as a conditional. __builtin_types_compatible_p ignores the top level qualifiers, so a const int and an int are the same answer and an int[3] and an int[4] are not. offsetof walks a path of members and subscripts through anonymous members and refuses to take the offset of a bit-field, which has no byte to be at. va_arg is a node rather than a call, because what it becomes is the target's own sequence of loads and not a function anything links against, and it warns where the type asked for is one the default argument promotions mean was never in the list. It does not yet check that its first argument is a va_list, since the type to check against is __builtin_va_list and this compiler has no builtin declarations yet, so any pointer is accepted in the meantime.

  • Declaration checking in rucc-sema, which is what turns the checker from something that can be handed one expression into something that can be handed a name. The type builder already answered what a declarator says, and this answers everything else a declaration decides, which is four things about each name and one relation between the declarations that share it. The four are what kind of thing it is, who else can see it, how long it lives and how much of a definition it is, and not one of them is written down anywhere in the source: int x; at file scope is an external, static, tentative definition, and the same three words in a block are a local automatic one, and the only difference between them is where they are. The tentative state is kept as a state of its own rather than folded into either of the other two, because a tentative definition is a definition only if nothing else in the translation unit defines the name, which is not known at the point it is read, and a compiler that decides early gets either an error on int x; int x; or two objects. A redeclaration is merged into the declaration it repeats rather than added beside it, so the name keeps pointing at one object, the type becomes the composite of the two, which is what fills the bound of an int a[]; in from the int a[3]; below it, and the state becomes the stronger of the two. The rules about linkage are the ones every real header depends on and each has gcc 13.3's wording, measured rather than recalled: static after a plain declaration and a plain declaration after static are both errors, and extern after static is not, because extern says nothing about which linkage it wants and takes the one the name already has, which is what lets a library declare a symbol it hides. A declaration with linkage answers to any declaration of the name in sight and one without linkage answers only to its own scope, which is what makes a local variable called printf legal. What a name is allowed to be is checked too, so a name that already means a type or an enumerator is a different kind of symbol, an incomplete type has no storage size, a void object is worded one way in a block and another at file scope because that is what gcc prints, and a variable length array may be automatic and may not be static. typedef is here rather than in the type builder, since deciding that a declaration declares a type rather than an object is a declaration's decision, and the same name may be typedefed twice for the same type, which is what lets two headers that both define size_t be included by one file. alignas is folded and checked here, so a value that is not a power of two is refused, one that would weaken the type is refused, alignas(0) is accepted and ignored as C23 says, and asking for an alignment on a typedef or on a function is refused. static_assert is folded and reports its message quoted the way it was written. A scalar initializer goes through the same conversion an assignment does, with the wordings gcc uses for an initializer rather than the ones it uses for an assignment, since a person reading initialization of 'char *' from incompatible pointer type 'int *' is being told which of the two they wrote. Two checks are deliberately left out rather than approximated. A file-scope initializer is not required to be constant, because the folding has no address constants yet and int *p = &x; is the ordinary case rather than the exotic one, so the check would be wrong far more often than it would be right, and the constexpr case, which is arithmetic and which the folding does answer, is checked. And the warning that gives a tentative int a[]; one element is not here, because gcc decides that at the end of the translation unit and a declaration in the middle of a file has no way to know what comes after it. A function definition waits on statements and a braced initializer waits on initialization, and both report themselves as not supported yet in the meantime.

Changed

  • The reference compil...
Read more

v0.2.7

v0.2.7 Pre-release
Pre-release

Choose a tag to compare

@tamnd tamnd released this 01 Sep 02:20
v0.2.7
0dddba8

Added

  • The constant evaluator in rucc-sema, which folds a checked expression to a value and is what a case label, an enumerator, a bit-field width, an array bound, a static initializer and a constexpr object are each going to ask. It runs over the typed tree rather than the source tree, which is the whole reason it is short: the conversions are already nodes, the types are already decided, and the usual arithmetic conversions have already happened, so an addition here is an addition and not a re-derivation of what an addition means. The result is three-valued rather than two, because "not a constant" and "a constant that does something the language does not define" are different answers and only the first one is allowed to be silent. That is what lets sizeof(int[1/0]) stay quiet in a context where constancy is optional while int a[1/0]; is a diagnostic, and it is why the failure carries a flag saying whether the expression was already the subject of a diagnostic: an operand that is poisoned is not a constant, and saying so a second time is noise. Integer arithmetic is performed at the target's widths and not the host's, which for a _BitInt(37) is a width no host has. Signed overflow is computed twice on purpose, once exactly and once wrapping, so that the value handed back is the one the target would produce and the warning is decided by whether the exact answer fits, which is the only way to tell 2147483647 + 1 from an addition that merely came out large. Unsigned arithmetic wraps and never warns, because that is defined and warning about defined behaviour trains people to ignore warnings. The pair that has to be special-cased is INT_MIN / -1 and INT_MIN % -1, since the quotient is not representable and the machine instruction traps rather than wrapping. Division by zero, a negative shift count and a shift count at or beyond the width each get a diagnostic with gcc 13.3's wording, measured rather than recalled, and the over-wide shift still folds, to zero or to minus one depending on whether an arithmetic right shift is being asked to keep a sign. Floating arithmetic goes through the software implementation added in 0.2.6 at the target's format, so folding is the same on every host. && and || stop at the operand that settles them and a conditional folds only the arm it takes, which is what makes p != 0 && *p a constant zero rather than a dereference. Address constants are not here yet, since &x needs a declaration to point at.

  • The -Woverflow warning, which is the first thing to use the folding: an assignment or an initialization whose value will not fit in the type it is being stored in now says what the value was and what it becomes. The rule is subtler than it looks and is measured rather than recalled. gcc is silent for signed char sc = 200; and for unsigned char uc = -1; and loud for unsigned char uc = 300;, so the test is not whether the value fits the target type but whether it fits the target's width in either signedness, which is a way of saying that reinterpreting a bit pattern is deliberate and losing bits is not. A cast is silent throughout, since (char)300 is a request to truncate, so the evaluator itself says nothing about a conversion and the checker asks the question only where it is holding an assignment. A bool is excluded, because everything that is not zero is one and no bits are lost by saying so. The value in the message is printed in hexadecimal when it is a floating one, which is not what gcc does and is what an honest compiler does until it has a shortest round trip printer, since a decimal spelling produced without one is a different number than the one being warned about.

  • The type builder in rucc-sema, which turns a specifier list and a declarator into a type and is what every declaration and every expression that names a type has been waiting for. The two halves of a C declaration are read in opposite directions, so the specifiers are a set and the declarator is a sequence folded from its far end onto what the set named, which is why int (*f[3])(char) is an array of pointers to functions and not a function returning an array of pointers. The interesting part is not the fold, it is what a declarator is allowed to say and where. An array of void, an array of functions, an array of a tag that has no definition yet, a function returning a function and a function returning an array are each refused with gcc 13.3's wording, in both the named and the abstract phrasing, since declaration of 'a' as array of voids and declaration of type name as array of voids are the same rule about two different pieces of source and a compiler that only writes one of them has a message it cannot produce. The bracket qualifiers of int a[static const 3] belong to the pointer the parameter becomes rather than to the array, and they are legal only on the step of the declarator nearest the name of a parameter, so the same brackets on an ordinary declaration are an error. [*] is a type only inside a prototype. A bound that folds to a constant is a fixed array, a bound that does not is a variable length array, which is a type and not a mistake except at file scope where there is no run time to evaluate it in, and two arrays written the same way are still two types because the two bounds are evaluated at two different moments and may not agree. A bound too large is measured in the element type rather than in the count, since it is sizeof that overflows and not the number in the brackets. A prototype is a scope of its own, which is what makes the n in void f(int n, int a[n]) mean the parameter and what makes it gone by the next declaration, and an empty parameter list says nothing about the parameters before C23 and says there are none from C23 onwards, which is visible in every call. Tags are looked up and declared here, so struct S; and struct S *p; build the type they should and both mentions are the same type, a tag that already means another kind of thing keeps meaning it rather than being rebound and turning one diagnostic into one per use, and C23's enum E : long is complete from the point it says so. _Atomic is a type constructor and a qualifier in two different spellings that mean the same thing in the end, and it is refused on an array, on a function and on something already qualified. _BitInt(N) has its width folded and range checked against clang, since gcc 13.3 does not have the type to be measured against.

  • The members of a struct or a union and the enumerators of an enum in rucc-sema, which is what completes the type builder and what every declaration of a real program is written against. A definition binds its tag before it reads its members, which is the whole reason struct S { struct S *next; }; points at the type it is a member of rather than declaring a second one inside it, and it asks whether the tag is already bound in this scope rather than anywhere visible, because that is the difference between a definition of something new and a completion of the forward declaration above it. A second body for a tag that already has one is a redefinition and is reported, and the members of the refused one are still read and still laid out, so that one mistake is one diagnostic rather than one per member of it. The members themselves are where the constraints live. A member of void, a member that is a function and a member of a type with no definition yet are each refused with gcc 13.3's wording, measured rather than recalled, the rest of the record is laid out around the hole, and a member declared twice is reported once and left out rather than laid out twice. A member with no declarator is an anonymous member when its specifiers are a record with a body and is a declaration that declares nothing otherwise, which is a warning and not an error because that is what gcc does with int; and there is no harm in it. A bit-field has its width folded here, since a width is an integer constant expression and the folding was the piece before this one, and it is measured against the value bits of its own type rather than against its size, so a _Bool b : 2 is too wide while a _Bool b : 1 is not. A width that is too wide is kept at the width its type has rather than dropped, which is where gcc leaves it and what keeps every member after it at the offset the program meant. A zero width with a name has nothing to name and an unnamed one moves the next member to the next boundary, which is what it is for. The flexible array member rules are a pass over the members after they are collected rather than a test on each one, because they are about where a member is and not about what it is: last, in a struct, and with something named before it. What an enumeration is represented in is C23's rule and was measured against gcc rather than read off the standard's wording, since the standard says an implementation defined type that holds every value and gcc's choice is the first of unsigned int, int, unsigned long, long, unsigned long long and long long that does, which is not the order anyone guesses. The enumerator's own type is a separate question with a separate answer: it is int wherever the value fits in one, whatever the enumeration turned out to be kept in, so enum { A = 0xffffffff, B = 1 }; has a B of type int inside an enumeration of type unsigned int. An enumerator with no value is one more than the one before it, and the one after the greatest value the type has is an overflow and says so rather than wrapping quietly. An underlying type the program wrote is a constraint rather than a suggestion, so an enumerator outside its range is an error and every enumerator of that enumeration has that type whether or not it would have fitted in an int.

Changed

  • rucc-types answers two new questions that folding needs and layout did not. integer_info gives the signedness and the value width of a...
Read more

v0.2.6

v0.2.6 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 01 Sep 00:53
v0.2.6
d3d1abd

Added

  • Floating point arithmetic in rucc-base, in software, so that folding a constant gives the same bits whoever compiles the compiler and whatever machine it runs on. The conversion from text was already here and is half of what a constant needs, and this is the other half: addition, subtraction, multiplication, division, comparison, conversion between the formats, and conversion to and from an integer, each of them correctly rounded to nearest with ties to even. Asking the host to do this instead is wrong in three separate ways, since the host may not have the format at all, its long double is eighty bits or a hundred and twenty eight or sixty four depending on where you ask, and a compiler whose output depends on the machine it ran on is not one you can reproduce a build with. Every operation computes the exact answer to more bits than the format has and rounds once, which is what correctly rounded means and what a second rounding in the middle would quietly destroy. The one that is not obvious is the subtraction: what gets shifted out of the smaller operand belongs to the number being taken away, so the answer is a little below what the bits that are left say it is, and taking one more off with the sticky bit set says exactly that without needing the bits themselves. A nan is a category now rather than something the encoding could hold and nothing could make, with the operations that have no answer producing one and saying so, and the difference between a division that has no answer and one whose answer is merely too large to be a number is two flags rather than one. The correctness argument is not the reasoning above, it is a hundred and twenty thousand random operations per run checked bit for bit against the host's own double and float, including the infinities, the nans and the subnormals, with the integer conversions checked against Rust's own saturating cast, which happens to fill in C's undefined behaviour the same way this does. The wide formats have no host to check against, so those are worked out by hand and asserted: a third in binary128, three of them adding back up to exactly one because the tie rounds up, and 2049 in half precision rounding down to 2048 because that tie rounds the other way.

  • The expression checking in rucc-sema, which is the first pass that puts nodes in the typed tree rather than defining what a node is or what a rule says. Every operator of 6.5 is checked except the ones that name a type, and each of them is the same three steps in the same order: check the operands, decide whether the types they turned out to have are ones the operator accepts, and write the conversions the operator performs before writing the operator itself. Nothing in it writes a conversion by hand, so the rules stay in the one place that already knew them. The cases worth calling out are the ones where the obvious implementation is wrong. A compound assignment is not a = a op b with the conversions left out: int i = 5; i /= 0.5; divides in double and gives ten, and a compiler that converts the right side to the left side's type first divides by zero instead, so the node now carries the type the operation is performed in, which is what clang calls the computation type and for the same reason. A shift takes the promoted type of its left operand and not the usual arithmetic conversions, so 1 << 1L is an int, which is the classic version of this bug. An argument beyond a prototype takes the default argument promotions, so a float passed to a variadic function widens to double, and a compiler that forgets it passes four bytes where va_arg reads eight. &a on an array is a pointer to the array rather than to its first element, which falls out of the address operator being the one place that does not ask for a value. And a member reached through an anonymous member is a chain of nodes rather than one, with the record's own names beating the ones inside it, because a single walk in declaration order lets an anonymous member's x hide the record's. The diagnostics are gcc 13.3's wording, measured rather than recalled, because the message is what a person building a real project sees and a build script that greps for incompatible pointer type is a real thing. What is deliberately not copied is the [-Wsomething] suffix, since that is the renderer naming an option this compiler does not have yet. Poisoning is the parser's rule and works the same way: an expression that has been diagnosed becomes an error node, an operator whose operand is one is poisoned in turn without a word said, and that is what stops one undeclared name producing an error for every operator it appears under.

  • The diagnostic sink moved out of rucc-parse and into rucc-diag as Errors, because semantic analysis needs exactly the same three things the parser needed, which are somewhere to put the diagnostics, a count of the ones that are errors, and a point at which the pass stops. The error limit is one number for the whole compiler rather than one per pass that happens to reach it, and it stays twenty, which is clang's measured default and the better of the two: gcc has no limit at all and will print every error a broken file produces, and the errors after the twentieth are almost always consequences of the ones before them.

What's Changed

  • sema: check expressions by @tamnd in #74
  • base: floating point arithmetic in software by @tamnd in #75
  • release: 0.2.6 by @tamnd in #76

Full Changelog: v0.2.5...v0.2.6

v0.2.5

v0.2.5 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 31 Aug 23:51
v0.2.5
a782886

Added

  • The conversions in rucc-sema, which are the rules every part of the checking rests on and the one place that knows them. An expression used for its value goes through the lvalue conversion of 6.3.2.1, then the integer promotions of 6.3.1.1, then the usual arithmetic conversions of 6.3.1.8, and the order is not a convenience: an array and a function never take part in the first one, they decay instead, which is why sizeof a on an array is the array's size and not a pointer's and why the decay has to be a step of its own rather than a special case of reading. Each of them writes a node, so the tree says what happened rather than leaving the walk to the IR to work out that an int met a long somewhere, and nothing is written where nothing happens, so a dump of an expression already in the type it wanted has no noise in it. Two of them are the ones a compiler gets subtly wrong. A scalar used as a condition is a comparison against zero and not a truncation, which is why it is its own kind of conversion: (bool) 256 is true and (char) 256 is zero, and a compiler that treats those the same is wrong about one of them. And a null pointer constant is not the integer zero converted, since the constant may have any integer type and (void *)0 is one of them, so what makes it null is what it says rather than what it weighs, which means the test for it looks through the casts and the conversions rather than stopping at the first node. Reading an object drops the qualifiers and the atomicity, because neither is part of a value, so const int x; x + 1 has an int on the left of the +. A bit-field is promoted by its width rather than by the type it was declared with, so unsigned b:3 promotes to int and unsigned b:32 promotes to unsigned int, which is a separate entry point rather than a special case, because the width is a fact about the record and the tree holds only the field.

  • The scopes in rucc-sema, which is where a use of a name becomes a reference to the declaration it means. The parser already resolved names in one sense, since it had to decide which identifiers were type names, but that is a smaller question and a different one: it needed to know whether the A in (A)*b was a type and never needed to know which A, while this has to know which declaration, because that is what the use gets its type from and what the object file eventually refers to. Two of C's four namespaces are here and the other two are deliberately elsewhere, since labels are function wide rather than block scoped and a stack would only be in the way of them, and members belong to the record that declares them and are reached through a type. An ordinary name resolves to a declaration, a typedef or an enumerator, and the last two never reach the tree, because one is a name for a type the type table already holds as sugar and the other is a constant that gets folded into the expression that used it. The scoped map itself moved down into rucc-base as ScopeMap, out of the parser that had it as a private type, because semantic analysis needs the same structure with different values in it and the two crates are at the same layer rank, so neither could borrow it from the other. That is one home for the scoping and one place where the cost of it is decided, which is a map from a name to the stack of bindings for that name plus a log of what each open scope bound, so a lookup costs one hash rather than the depth of the nesting and closing a scope costs what it declared rather than a walk of everything visible from it.

  • The type category predicates in rucc-types, which is what almost every constraint in C is actually written over: an operand of % has integer type, an operand of ! has scalar type, a member of a struct has complete object type. They are in the type table rather than in semantic analysis because they are facts about types and the backend wants them too, and they are worth having in one place because each of them has a member nobody remembers. An enumeration is an integer type, so enum e x; x % 2 is legal C and a compiler that asks whether the kind is Int says it is not. _Atomic(T) is in whatever category T is in, which is the wrong way round for this question and the right way round for spelling it, so every predicate looks through the wrapper except the one asking about it. A union is not an aggregate, which is not a quirk of wording but the reason a union is initialized from its first member while an aggregate is initialized member by member. And void is an object type that is never a complete one, which are two questions rather than one, and collapsing them is how sizeof (void) ends up accepted or rejected for the wrong reason. Being modifiable is the predicate that takes a walk rather than a look, since a struct with a const member anywhere inside it cannot be assigned to, and that is the part a compiler forgets.

  • The printer for the typed tree in rucc-sema, which is what --emit=tast writes. It does not print C and does not try to, because the tree is not source any more: every conversion the language performs is a node of its own, so writing it back as C would print exactly the text that hides what there is to see. What comes out is one node per line, indented by depth, with the type spelled out at every expression, which is the artifact that answers the question an IR bug usually turns out to be, namely which conversion is missing or which one is the wrong one. A tree with jump tables in it is not a tree, since a switch holds a table of cases whose bodies are statements inside its own body and a goto names a label defined somewhere else, so those are printed as references written #n after the word that says what n counts, and the numbers are arena indices, which is what makes a dump greppable: the definition and every use of one thing carry the same number. Writing it found two gaps in the representation it prints, which is the argument for writing a printer early rather than late. A case statement had no way to reach its own value, since the values are in the switch's table and the statement held only its body, so a printer had to search the table for the statement it already had in hand and so would the walk to the IR; the statement now holds the index of its table entry, which costs four bytes it had spare. And default: existed only as a field on the switch, so nothing in the body said where it had been written, which meant a dump could not show a reader where control goes when nothing matches; it is now a statement in the body as well as the jump target on the switch, which is exactly the arrangement the cases already had.

  • The spelling of a character constant and of a string literal moved from the C printer in rucc-ast into rucc-lex, as CharConstant::spell and StringLiteral::spell, because the typed tree's printer needs the same spellings and the encoding rules they depend on were already there. Two printers with their own escapers is two printers that disagree about ?? and about where a hexadecimal escape stops running on.

  • The typed tree in rucc-sema: the arenas, a node for every typed expression and statement, the declarations with their linkage and their storage duration resolved, and the flattened initializers. It is the same shape as the untyped tree and for the same reasons, flat vectors and four byte indices and one drop at the end of the translation unit, with one difference that is the whole point of it: the type is in the node rather than in a side table beside it, because everything that walks this tree reads the type at every node, which is exactly not true of spans. Every conversion the language performs without being asked is a node of its own, so (long)a + (long)b is three nodes where the source has one operator. That is a cost paid deliberately, because the alternative is that the walk to the IR works out for itself that an int met a long somewhere, and then a second place in the compiler knows the conversion rules and is slightly wrong about them. Eight conversions are kept apart rather than left to be inferred from the two types: reading an object, an array decaying, a function decaying, one arithmetic type to another, one pointer type to another, a scalar to bool which is a comparison against zero and not a truncation, a null pointer constant which is not the same as converting the integer zero because the constant may have any integer type and (void *)0 is one, and a value being discarded. The operators are the parser's own UnaryOp and BinaryOp rather than a second set with the same names, since what the typed tree adds is not different operators, it is knowing what they are applied to. Nothing is desugared here either: a subscript is a subscript and not *(base + index), because the rewriting has exactly one home and because a diagnostic about a subscript should say subscript. What is deliberately absent is a typedef, an enumerator and a tag, since the first is a name for a type that the type table already holds as sugar and the other two are constants that have been folded into the expressions that used them, which leaves the objects and the functions, which are what has to exist at run time and what the walk to the IR wants a list of. An initializer is flattened to a list of values and the byte offsets they go at, with the contract that the object starts as zero and the entries are applied in order, so partial initialization and an overwriting designator fall out of the representation rather than needing rules of their own. An expression is twenty four bytes, a statement twenty four, a declaration thirty six, and a switch case forty eight, the last because two i128 bounds want sixteen byte alignment and nothing narrower holds a switch over __int128, and every one of those numbers has a test asserting it. The checking that fills the tree in is next.

  • spell an...

Read more

v0.2.4

v0.2.4 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 31 Aug 22:53
v0.2.4
91740c9

Added

  • The printer in rucc-ast, which is what --emit=ast will write and what keeps the parser honest. It does not give the source back and it is not meant to: the comments are gone, the layout is the printer's own, and the parentheses are worked out again from the precedences rather than remembered. What it does give back is the tree, so parsing the output produces the same tree and printing that tree produces the same text, and the pair of them is then a check on each other rather than a check on nothing. Three things are written in a way that reads oddly and is exact for a reason. A floating constant comes out in hexadecimal, so 1.0 is 0x1p+0, because a decimal spelling that reads back unchanged needs a shortest round trip algorithm and printing one without such an algorithm quietly changes the program. A keyword comes out in the spelling that is a keyword in every dialect, so _Bool rather than bool and __asm__ rather than asm, since the tree does not record which dialect it was parsed in and the ugly spelling is the one that survives all of them. A declarator is assembled outward from its name, which is the only way int (*f[3])(char) comes back with its parentheses where the grammar needs them and nowhere else. The failures a printer has are the ones that read correctly and are not, so each of those has a test of its own: two tokens that would join into one get a space, so -(-a) is - -a and not the decrement; the declarators of one declaration stay in one declaration, so struct { int x; } a, b; does not turn into two anonymous structures that happen to look alike; an else gets braces around the if it is not attached to; the operand of sizeof is printed as a primary expression, so sizeof (T){0} cannot be read back as a sizeof of a type name; and an escape in a wide string closes the literal rather than swallowing what follows it, so L"\x1234" L"abc" stays four characters long. Nineteen round trip tests drive source through the lexer, phase 7, the parser and the printer twice over and compare both the texts and the tree sizes, which is how the parser bug they landed with was found: int x __attribute__((weak)); was read as an old style function definition, because __attribute__ is a specifier keyword and the question asked after the first declarator is whether a specifier follows it.

  • The grammar itself in rucc-parse, so parse now takes the tokens phase 7 produced and gives back a tree: expressions, declaration specifiers, declarators, initializers, statements and declarations, in every dialect from C89 to C23 and with the GNU extensions that real headers cannot be read without. Expressions are a Pratt parser rather than a ladder of fifteen mutually recursive functions, because the precedences are then a table a reader can check against the standard's grammar in one sitting instead of a shape that has to be inferred from the call graph, and because the unary and postfix operators are the interesting part and they get the room. The three genuinely hard decisions each have one home. Whether (A)*b is a cast or a multiplication is asked of the scope stack and nowhere else. Whether int f(void) { is a definition or a declaration is decided on the single token after the first declarator, since the two share a prefix that can be arbitrarily long and no fixed lookahead settles it, and a { is a body while a declaration specifier is an old-style parameter list and anything else, an attribute or an asm label included, belongs to the declaration. Whether a block item is a declaration or a statement is asked after a __label__ and after an ident : label, because a label may be a typedef name and reading the two in the other order makes T: x; a declaration of nothing. A run of labels is collected in a loop and folded afterwards rather than parsed by recursion, because the grammar nests them and a generated dispatch table really does contain three hundred case labels on one statement, so the obvious parser is a stack overflow on real input and the test uses two thousand of them. The GNU shapes that headers depend on are here rather than deferred: statement expressions, case 1 ... 9, computed goto, __label__, the label address operator, __builtin_offsetof, __builtin_choose_expr, __builtin_types_compatible_p, __builtin_va_arg, the conditional with its middle left out, the assembler name on a declaration, and inline assembly with its four sections, where a :: skipping the output list is one token from the lexer and gets split back into two colons here rather than in the lexer, since that is the only place in the grammar where the distinction exists. C23 is a dialect rather than a mode: an old-style definition parses everywhere and is an error only from C23, a declaration may follow a label, an enumeration may name its underlying type, and a label may end a block. Forty integration tests drive the whole path from source text through the lexer and phase 7, so what they check is what a driver sees rather than a token stream written by hand to agree with the parser.

  • The parser's spine in rucc-parse: the token buffer, the scopes and the error recovery, which is everything the productions rest on and none of the productions themselves. The lookahead is bounded and the bound is enforced, so peek refuses to look more than four tokens ahead and panics rather than quietly widening the window, because unbounded backtracking is how a C parser becomes quadratic on the input a fuzzer eventually finds. The scopes are where C's one real ambiguity is settled, since (A)*B is a cast when A is a type and a multiplication when it is not, and the answer comes from a scope stack the parser maintains itself rather than from a feedback channel back to the lexer, which is the traditional approach and which makes the lexer's state depend on how far the parser has got. The shape is one map from a name to the stack of bindings for that name, innermost last, plus a log of what each open scope bound, so a lookup costs one hash rather than the depth of the nesting and closing a scope costs what it declared rather than a walk of everything visible from it. Each of the hazards has a test, because each of them is a real bug in a real compiler: a declarator introduces its name at the end of the declarator rather than at the start, so typedef int T; void f(int T, T x); has T as a parameter name and T x is then an error while typedef int T; T T; reads the specifier as the type and then declares a variable of that name, tags are a namespace of their own so struct T leaves a bare T alone, and a typedef name shadowed by an inner declaration comes back when that scope closes. Recovery skips to the next ; or } at the current bracket depth for a statement and past the ; or the function body for a declaration, while an expression skips nothing at all, since expressions are short and skipping one costs the rest of the statement. What actually stops a cascade is that every recovery leaves a poisoned node behind and a diagnostic about a poisoned node is never reported, rather than an error count or a flag saying that something already went wrong here. The limit on errors is twenty and that number was measured rather than assumed: clang 23.1 stops there and says so, and gcc 13.3 has no default limit at all and prints every error the file produces.

  • The tree itself in rucc-ast: the three arenas, the side tables, and a node for every expression, statement and declaration this compiler intends to parse. It is flat vectors and four byte indices rather than boxes and pointers, which is what makes it half the size, droppable in one go, Send for free, and serialisable without fixups. An expression is sixteen bytes, a statement twenty, a declaration twenty four, and every one of those numbers has a test asserting it, because the day a variant grows is a day somebody should have to say so out loud rather than a day the arena quietly gets a third larger. Spans are out of line in a vector beside each arena, since almost nothing that walks the tree reads them and eight bytes of source position in the node would make every scan pay for the diagnostics. Nothing is desugared: a subscript is a subscript and not pointer arithmetic, a compound assignment is one operator and not three nodes, a for loop is a for loop. Rewriting any of it here would make every message after this point describe a program nobody wrote, and the rewriting has exactly one home, which is where the IR is built. The declarator representation is the part that matters most: a name plus the list of steps that build its type outward from it, so int (*f[3])(char) is array, pointer, function in that order, folded from the end onto the type the specifiers named. It is one flat list rather than a tree and it is shared with the abstract declarators, because a compiler that writes those twice ends up accepting different things in a cast than in a parameter. The type keywords are kept as the multiset they were written in and turned into a type by a table with its own tests, which is how long on its own, long before int and long after long end up being the same keyword doing three different jobs without the parser needing to know it.

  • Phase 7 in rucc-lex, which is the join between the preprocessor and the parser and the last place where a spelling means anything. convert walks a stream of preprocessing tokens and produces the tokens the parser reads: an identifier becomes a keyword when the dialect has that spelling, a preprocessing number becomes a typed value, a literal becomes elements, a run of adjacent string literals becomes the one literal it is, and a stray byte becomes the error it always was. Concatenation lives here rather than in the parser because the encoding rules are already here, and doing it anywhere else would be the second place that knows them. It is not a matter of join...

Read more

v0.2.3

v0.2.3 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 31 Aug 20:37
v0.2.3
dd6b32c

Added

  • Software binary floating point in rucc-base, with the six formats the compiler has to produce: binary16, bfloat16, binary32, binary64, the x87 eighty bit format with its stored leading significand bit, and binary128. A compiler cannot ask the machine it runs on what a floating constant means, because the host may not have the format at all, long double is eighty bits on x86-64 and a hundred and twenty eight on AArch64 Linux and sixty four on Apple, and strtod is the host's libc rather than the target's semantics. Reproducible output means the same source gives the same bits whoever compiles it, so the conversion is done here in integer arithmetic. It is correctly rounded, round to nearest with ties to even, using an exact decimal that is scaled by powers of two until the value is in the range the significand can be read off, which is the algorithm Go's strconv uses and the one Rust's own parser falls back to. A naive mantissa * 10^exponent in double precision is wrong in the last place for a noticeable fraction of literals, and the last place is exactly what a differential test against another compiler notices. The binary64 and binary32 answers are checked against Rust's own correctly rounded parser over four thousand generated numbers as well as the hard cases, including the seven hundred and sixty seven digit halfway value that a conversion truncating its input gets wrong, and the x87 and binary128 answers were measured by compiling the constants with gcc 13.3 and reading the bytes back out of the program. Hexadecimal constants are exact by construction and are rounded once at the end. Arithmetic is not here yet, since a constant does not need it; it comes with the constant evaluator.

  • Floating constants in rucc-lex, the other half of what a preprocessing number can turn into. A floating constant has none of the table walk an integer one has, because the suffix names the type outright, and what it has instead is a suffix list far longer than the standard's three and a conversion that has to be right to the last bit. The suffixes were measured on gcc 13.3 rather than recalled: q is __float128, w is the x87 __float80, d is a double written the long way, f16 through f128 are the _FloatN types, f32x and f64x the _FloatNx ones, and an i or a j on either side of the type makes the constant imaginary. Two of the answers are not what the names suggest: _Float32x is plain double and _Float64x is the x87 format, so 0.1f64x and 0.1l are the same bits on x86-64 Linux. The case rules are their own small grammar, since the f of a _FloatN suffix may be either case and the trailing x may not, so F64x is a constant and f64X is not, and the two letters of a decimal float suffix have to agree, so dd and DD are constants and dD is not. Every extension suffix is accepted in every dialect including C89, with a remark for the caller holding the span, which is what gcc does. Decimal floating constants are recognised and refused with an error that says so, because there is no decimal floating value anywhere in this compiler to put one in yet. A constant too large for its type becomes an infinity and one too small becomes a zero, both with a remark, which is the pair of warnings gcc gives.

  • Character constants and string literals in rucc-lex, which is the last piece of phase 7 that is about spellings and the first one whose answer depends on the target in a way a reader would not guess. A literal arrives as the bytes the user wrote and leaves as elements, and what an element is comes from the encoding prefix and from wchar_t, so L"a" followed by an emoji is two elements on Linux and three on Windows, where a wide string is UTF-16 and the character needs a surrogate pair. The escapes divide into two kinds and the division is the whole design: an escape that names a character gets encoded in the literal's encoding, so a plain "e-acute" is the two bytes c3 a9, and an escape that writes a value is that element as written and is truncated to the element with a remark when it does not fit, so '\x1ff' is minus one and L'\x1ff' is five hundred and eleven. Octal stops after three digits and hexadecimal runs as far as the digits go, which is why "\1234" is two characters and "\x41z" is two as well. A plain character constant is an int and its single character is converted through plain char first, so '\xff' is minus one on x86-64 Linux and two hundred and fifty five on AArch64, where plain char is unsigned. More than one character shifts them together and the ones past the width of the type fall off the front, so 'abcde' is 0x62636465, and gcc reports that case as too long instead of as multi-character rather than as well as it, which is a distinction only measurement gives you. Every value here was measured against gcc 13.3 on x86-64 Linux rather than recalled. There is one deliberate divergence: a universal character name above the end of Unicode is refused here and in clang, where gcc warns and encodes the value as though UTF-8 went that far.

  • wchar_width and wchar_is_signed in rucc-target, which is where the literals get their answer. The two fields split the targets in different directions and neither follows from anything else already there: the width is sixteen on Windows and thirty two everywhere else, and the sign follows the psABI's rule for plain char, so wchar_t is a signed int on x86-64 Linux and an unsigned int on AArch64 Linux and L'\xffffffff' is minus one on one and four billion on the other. The predefined macros in rucc-pp now derive __WCHAR_TYPE__, __WCHAR_MAX__, __WCHAR_MIN__ and __SIZEOF_WCHAR_T__ from these two fields rather than matching on the triple a second time, so the lexer and the macros cannot come to different conclusions about the same target.

  • long_double_format in rucc-target, because the width of long double does not say what it is. It is a hundred and twenty eight bits wide on x86-64 Linux and on AArch64 Linux and those are not the same type: one is the x87 eighty bit format padded out to sixteen bytes and the other is true quad precision with a hundred and thirteen bits of significand, so 1.0l is 0x3fff8000000000000000 on one and 0x3fff0000000000000000000000000000 on the other. Anything that converts a constant or folds one has to know which, and until now nothing could tell.

What's Changed

  • Add software binary floating point by @tamnd in #59
  • Add floating constants to the lexer by @tamnd in #60
  • Add character constants and string literals to the lexer by @tamnd in #61
  • Release 0.2.3 by @tamnd in #62

Full Changelog: v0.2.2...v0.2.3

v0.2.2

v0.2.2 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 31 Aug 19:35
v0.2.2
8d1fa09

Added

  • __int128 and unsigned __int128 in rucc-types, as integer kinds of their own rather than as a _BitInt(128) wearing a different name. The two are genuinely different types: __int128 is sixteen bytes aligned to sixteen on every target here, a _BitInt(128) is aligned to its granule, which is eight bytes on x86-64, and __int128 outranks long long where a _BitInt is ranked by its width alone. So __int128 + unsigned long long is an __int128, which is what both compilers answer and what a program relying on the extension expects. It is available on every target, because all three architectures are 64-bit and GCC has the type on every 64-bit target it supports, and it is deliberately not an extended integer type in the sense the standard means, which is what keeps intmax_t sixty four bits wide the way GCC has it.

  • The keyword table in rucc-lex, which is the first half of phase 7 and what turns an identifier into a word the grammar knows. The spellings are interned before any source is read, so their symbols are one run at the bottom of the interner and recognising a keyword is a subtraction and a bounds check rather than a string comparison or a hash of the text. Which spellings the dialect actually has is resolved once, when the table is built, rather than at every identifier, so restrict is a keyword from C99 and a variable name in C89 at no cost per token. Which word is a keyword in which dialect was measured rather than recalled, by compiling every candidate as a variable name against gcc 13.3 and clang in each of the ten dialects, with two ordinary identifiers along for the ride to catch a probe that had stopped measuring anything. Two of the answers are not what a reading of the standard suggests: restrict is not a keyword in -std=gnu89 although inline is, and asm is still not one in -std=c23, where __asm__ has to be written instead. The GNU spellings are keywords in every dialect including -std=c89, which is why headers are written with them, and where two spellings mean the same thing they are one keyword, so a parser never has to know which was typed. __alignof__ and _Alignof stay apart, because one asks for the alignment the target prefers and the other for the one the ABI requires.

  • Integer constants in rucc-lex, which is the piece of phase 7 that turns a preprocessing number into a value and a type. The type is the standard's table walk, a candidate list chosen by the base, the suffix and the dialect, walked in order until a type holds the value, and it is not the list a reading of the standard alone would give: C89 has unsigned long in the list for a decimal constant with no suffix and nothing has it after C89, which is why 18446744073709551615 is eight bytes under -std=c89 and sixteen under -std=c99, and gcc says so in as many words. Past long long a decimal constant reaches for __int128 rather than for an unsigned type, so 9223372036854775808 is a signed constant in gcc and an unsigned one in clang, and following gcc is what keeps its negation negative. Only the decimal list is signed types alone, which is the split behind 4294967295 being a long while 0xffffffff is an unsigned int. Every row was measured by writing the constant and asking _Generic what it is, against gcc 13.3 on x86-64 Linux and clang, rather than recalled, and the type comes from the target description, so the same constant is a long on Linux and a long long on Windows without the host having a say. The value is accumulated in a hundred and twenty eight bits with every step checked, which is one deliberate difference from gcc: its accumulator is sixty four bits, so 18446744073709551616 compiles there to zero of type int after a warning, and here it is refused. _BitInt constants get the narrowest type that holds them, which for a signed one counts the sign bit and is never less than two, so 1wb is a _BitInt(2) and 255uwb is an unsigned _BitInt(8), measured against clang because gcc 13.3 has no such type. A constant that uses a binary prefix, a digit separator, a wb suffix or an ll suffix before the dialect that standardised it is still converted and comes back with a remark, so the caller holding the span decides what -pedantic makes of it rather than the conversion deciding for it.

What's Changed

  • Add the keyword table and the dialect gate by @tamnd in #55
  • Add __int128 as an integer kind of its own by @tamnd in #56
  • Add integer constants to phase 7 by @tamnd in #57
  • Release 0.2.2 by @tamnd in #58

Full Changelog: v0.2.1...v0.2.2

v0.2.1

v0.2.1 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 31 Aug 18:46
v0.2.1
7f74322

Added

  • The C type universe in rucc-types, interned, so that two spellings of the same type are the same four byte id and type equality is an integer comparison rather than a structural walk. A type keeps its sugar: a typedef node remembers the name it was written as, and its canonical form is stored beside it, so a diagnostic can print size_t where the user wrote size_t while the rules that need the type behind it ask for the canonical id and get an answer without walking anything. Canonicalisation reaches below the outermost node, so a pointer to a typedef of an array canonicalises to a pointer to the array, and a const on a typedef of an array lands on the element the way the standard says it does.

  • _Atomic is a type rather than a qualifier, which is the shape the standard describes and also the only shape that gets the layout right. A sixteen byte structure is aligned to eight and _Atomic of it is aligned to sixteen, so a type system that treated the two as one type with a flag set would disagree with itself about where the object goes.

  • Layout computation driven by the target description rather than by the host, for every type except records. long is four bytes on Windows and eight on Linux, long double is eight bytes on Apple and sixteen on SysV x86-64, and a _Complex is two of its component with the component's own alignment. _BitInt is laid out like the smallest standard integer until it outgrows one and then like an array of a granule, and the granule is 64 bits on x86-64 and RISC-V and 128 on AArch64, which is a new field in the target description rather than a #[cfg]. Every one of these numbers was measured against GCC 13 on x86-64 Linux and clang on AArch64 Darwin rather than recalled. A type with no size says which kind of no size it has, because an incomplete type, a function type and an array too large for the address space are three different diagnostics.

  • Record layout in rucc-types: member offsets, bit-field packing, zero width bit-fields, packed, #pragma pack, aligned on a member and on the record, anonymous members and flexible array members. Whoever walks the members hands them to layout_record and gets back an offset for each one, in bits, so a bit-field and an ordinary member are described the same way. Every rule was measured against gcc 13.3 on x86-64 Linux and clang on AArch64 Darwin over about fifty structures, reading the bit positions back out of the compiled program rather than trusting the sizes, and the two compilers agreed on every case except where long double differs, which is a fact about the member and not about the record. Two of the rules are not what the documents suggest: a bit-field whose alignment has been capped by #pragma pack stays where it is rather than moving to the capped boundary, and an unnamed bit-field occupies its bits without giving the record its type's alignment, so struct { char c; int :20; } is four bytes aligned to one while the same structure with the field named is four bytes aligned to four.

  • The integer promotions and the usual arithmetic conversions in rucc-types, which decide what type an arithmetic expression has. The answers were read out of gcc 13.3 and clang 18 with _Generic naming the type of every interesting pair rather than derived from the standard, and the standard was then used to explain what was measured. All three of the C23 changes are in: bool is a real type, an enumeration promotes through whatever it is represented in rather than through a type the implementation picked, and _BitInt does not promote at all, so _BitInt(8) + _BitInt(8) stays eight bits wide where char + char is an int. A _BitInt is ranked against the standard types by its width, so a _BitInt(40) outranks an int and loses to a long, and a standard type wins a tie at equal width. Bit-fields are promoted by their width rather than by the type they were declared with, and a bit-field wider than an int keeps its declared type, which is what both compilers do and what the older wording would have got wrong by eight bits.

  • Type compatibility and the composite type in rucc-types, which is the relation declaration merging is stated in: whether two declarations of one name are talking about the same thing, and what type is left when they are. It is looser than identity on purpose, so int f(int a[3]) and int f(int *a) are different types and the same function, an enumeration is compatible with whatever it is represented in, and an array with a size is compatible with one without. The composite is the type that knows both halves, so extern int a[]; int a[4]; ends up as an array of four and void f(); void f(int); ends up as the prototype, which is what lets the calls written in between be checked against something. The rule for an old style declaration against a prototype is the one gcc states in its own diagnostic, that an argument type with a default promotion cannot match an empty parameter name list, so void f(int); void f(); merges and void f(float); void f(); conflicts. Records use the C23 rule that the same tag with the same members is the same type, with a guard so that a self referential structure compared against another declaration of itself terminates instead of going round forever. Two divergences are recorded rather than resolved: clang 18 implements that C23 record rule and gcc 13.3 still rejects the redefinition outright, and clang treats an empty parameter list as a prototype even in C17 mode where gcc keeps the old meaning.

Fixed

  • The __has_* operators answer in ordinary text and not only inside a #if. GCC and clang both implement them as builtin macros, so a header may write #define HAVE_COLD __has_attribute(cold) and then use HAVE_COLD in a declaration, and until now that carried the unexpanded call to the use site instead of the answer. The three whose operand is a header name, __has_include, __has_include_next and __has_embed, are refused outside a directive rather than answered, because by then the line has been scanned as ordinary tokens and <stdio.h> is a run of comparisons with no header name left in it. Both compilers make that an error too.

What's Changed

  • Answer the _has* operators in ordinary text by @tamnd in #49
  • Add the interned type universe and layout to rucc-types by @tamnd in #50
  • Lay out records, bit-fields included by @tamnd in #51
  • Add the integer promotions and the usual arithmetic conversions by @tamnd in #52
  • Add type compatibility and the composite type by @tamnd in #53
  • Release 0.2.1 by @tamnd in #54

Full Changelog: v0.2.0...v0.2.1

v0.2.0

v0.2.0 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 31 Aug 17:12
v0.2.0
70e8368

Added

  • A source file large enough for the copy to cost more than the page faults is memory mapped rather than read. The crossover was measured rather than assumed, both ways round and interleaved, on Linux and on macOS: two megabytes is where the two curves meet on the slower of the two, and above it mapping pulls ahead, four times faster at thirty two megabytes on Linux. Below it a plain read wins or ties, so every header still takes the reading path, which is where a few kilobytes belongs. Nothing outside the driver can tell the difference, because the source map already held anything that is a slice of bytes.

  • Every token that comes out of a macro carries the chain of macros it came out of, and a preprocessor diagnostic prints that chain. A paste that fails three macros deep now names all three, innermost last, each note pointing at where the next macro in was written, which is what GCC and Clang both print and what a reader needs to walk from their own code into the header that surprised them. The chain is a linked list of interned steps: every token of one replacement list shares one node, so a hundred token macro body costs one entry rather than a hundred. An argument is written by the caller rather than by the macro it is passed to, so a diagnostic from pre-expanding one is not blamed on the macro being called.

  • Macro expansion in rucc-pp: object-like and function-like macros, # and ##, variadic macros in both the standard __VA_ARGS__ spelling and the GNU named form, __VA_OPT__, and the GNU comma swallowing extension. It is Prosser's hide set algorithm rather than an expansion depth counter, so mutually recursive macros come out right. Both of the standard's own examples from 6.10.4.5 are tests.

  • Hide sets are interned, so a token carries a four byte index rather than a set, and the same set produced by the same nest of headers is stored once.

  • The release workflow publishes the whole workspace to crates.io after the binaries have built on every host, so a tag produces both the archives and the registry upload. It does not run for a manual dry run, because an upload to crates.io cannot be taken back.

  • cargo xtask version checks that every version number in the tree agrees with the workspace manifest: the exact pins between our own crates, and the html_root_url of every published crate. Both drifted between 0.1.0 and 0.1.1 and neither of them breaks a build by being wrong, which is exactly why they need a check rather than a habit. It runs as part of cargo xtask ci.

  • Release notes now come from the changelog section for the tag rather than from a list of commit subjects, with GitHub's generated list of merged pull requests appended after it.

  • Every crate carries the README, so the crates.io page for rucc-lex says what rucc-lex is instead of being blank.

  • Translation phase 4 in rucc-pp: Preprocessor::run walks a file, recognises directives, and returns the expanded token stream. #define and #undef, the full conditional family including #elifdef and #elifndef, #error and #warning, #line, #pragma and _Pragma.

  • The source map in rucc-diag: every file of a translation unit gets a range of one flat coordinate space, so a span stays two integers however deep the header nest goes, and a byte offset resolves back to a file, a line and a column. The line table for a file is built the first time something asks about that file, because most files in a build are never the subject of a diagnostic. It records what included what, so the "in file included from" block of a diagnostic is available long after preprocessing has finished.

  • The #if expression evaluator: integer and character constants, every operator C allows there, defined in both spellings, and the rule that a surviving identifier is zero. Short circuiting is real rather than an optimisation, so #if defined(X) && 1/X and #if 1 ? 2 : 1/0 are both legal, and a skipped region is read for nesting only, so a header may guard prose or a broken directive behind #if 0.

  • #include and #include_next, against a search path that follows GCC's order: -I in command line order, then -iquote for the quoted form only, then -isystem, then the configured system directories, with -idirafter last. A quoted include looks next to the file that wrote it first, and #include_next continues from the directory after the one the current file came from, which is what a wrapper header around a system header of the same name needs. The computed form, #include MACRO, is expanded and then read as a header name. A header that is not there reports every directory that was searched, in the order they were searched.

  • A file system abstraction in rucc-session, so the preprocessor reads headers through a trait rather than through std::fs. MemoryFileSystem is the in-memory implementation, which is what the preprocessor tests run against and what an embedder gets to plug into.

  • #pragma once and the multiple include optimization. A header wrapped in the ordinary #ifndef NAME guard is recognised as wrapped, and once NAME is defined the file is not opened again rather than being read and thrown away. Both spellings of the guard are recognised, #ifndef NAME and #if !defined NAME, and a token outside the guard is enough to disqualify a file, because such a file really does produce something on a second read.

  • The GNU compatibility matrix in rucc-gnu. features.toml next to the crate is the source of truth for what the compiler claims to support, a build script turns it into the table the compiler reads, and a row marked implemented with no test named against it fails the build. Only an implemented row answers yes, because a header that gets a yes and then fails to compile is far harder to diagnose than one that takes its fallback path.

  • The __has_* family: __has_include, __has_include_next, __has_attribute, __has_c_attribute, __has_builtin, __has_feature and __has_extension. The two include operators ask the search path exactly what the directive on the same line would ask it, and their operand is resolved before macro expansion, so __has_include(<linux/version.h>) is not affected by linux being a predefined macro. The rest are resolved after expansion, which is what GCC does, and answer out of the matrix. defined(__has_include) is true, which is the shape every header that uses them is written in.

  • The predefined macro set, generated from TargetInfo rather than hardcoded: the __SIZEOF_* family, __CHAR_BIT__, the limits, __BYTE_ORDER__, the exact width and fast integer families, __SIZE_TYPE__ and its relatives, the __FLT_*, __DBL_* and __LDBL_* characteristics, the architecture and operating system macros, __LP64__, __OPTIMIZE__ and __NO_INLINE__, and __STDC_VERSION__ per the dialect. It arrives as two synthetic files, <built-in> and <command-line>, which are the names GCC uses and the names a diagnostic about a predefined macro now points at. -D and -U go into the second one, in that order, because -U beats -D whichever side of it the -D was written on.

  • __GNUC__ is defined, and the version claimed is a knob rather than a constant. It starts at 4.2.1, which is the version every real header set is known to cope with from a compiler that is not GCC, and it goes up as the matrix in rucc-gnu fills in. That is the order spec/04-driver-and-cli.md section 4.5 asks for: claiming too high a version means headers reach for extensions that are not there.

  • __DATE__ and __TIME__, fixed for the whole translation unit as the standard requires, and honouring SOURCE_DATE_EPOCH so that a build that asked to be reproducible is.

  • -E runs. The driver reads the file, runs phase 4 over it and writes the result to -o or to standard output, which is the first phase this compiler actually performs. The flags that phase reads came with it: -D and -U in both the joined and the separated spelling, -I, -iquote, -isystem, -idirafter, -P, -std= with every alias GCC takes, -ansi and -ffreestanding. A diagnostic prints as file:line:column: severity: message [code] with the chain of includes that reached it above, and under -Werror a warning says error rather than leaving the reader to work out why the build stopped.

  • OsFileSystem, the implementation of the file system trait that talks to the disk. It lives in the driver, which is the only crate allowed to know the process exists, so a test below the driver still cannot read the machine it runs on by accident.

  • The -E printer in rucc-pp: the expanded token stream written back out as text. Line markers are GCC's, including the 1, 2 and 3 flags, a gap of up to eight lines is printed as blank lines and anything larger as a marker, and the indentation of the first token on a line is rebuilt from its column. A space goes in wherever two neighbouring tokens would otherwise read back as one token, so + + does not become ++ and a / next to a * does not open a comment. -P turns the markers and the padding off. Output that diffs cleanly against GCC's is the point of all of it, because that diff is the fastest way to find a preprocessor bug.

  • The predefined macros whose value is a question rather than a body: __FILE__, __FILE_NAME__, __BASE_FILE__, __LINE__, __INCLUDE_LEVEL__ and __COUNTER__. They are answered by the expander out of the source map at the place they are used, which is the place the outermost macro invocation was written rather than the header the macro body lives in. A logging macro defined in a header and used in main.c says main.c and the line the user wrote, which is the only answer that is any use. __COUNTER__ counts once per translation unit and once per expansion, so an argument that is used twice still carries one number.

  • #embed, with limit, offset, prefix, suffix and if_empty, and the __STDC_EMBED_* an...

Read more

v0.1.0

v0.1.0 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 31 Aug 10:22
v0.1.0

M0, the skeleton. The compiler does not compile anything: rucc a.c prints the phase plan and then says the phases are not implemented. What this release is for is the shape everything else gets built inside, and the checks that keep that shape honest.

Added

  • The workspace: 23 library crates, the rucc binary, the rule DSL and its verifier under build-tools/, and the target-side runtime under runtime/.
  • The layer rule, ranked in xtask/layers.toml and enforced by cargo xtask layers.
  • rucc --print-config, --version and --help, which is the M0 exit criterion in spec/17-milestones.md.
  • Target triple parsing and the target data model for x86-64, AArch64 and RISC-V 64 across Linux, Apple platforms and Windows.
  • Diagnostics, spans and the per-compilation Session.
  • CI on Linux, macOS and Windows, with formatting, lints, tests, the layer check, the prose check, a supply chain audit and a minimum supported Rust version job.
  • The twenty document specification under spec/.
  • The phase graph: Plan is pure data, so -### can print the plan without touching the file system, -v prints it while running, and -x forces an input language.
  • Job scheduling across translation units, with -j. Results merge in input order rather than completion order, which is what keeps output byte identical between -j1 and -j16.
  • Translation phases 1 to 3: the byte order mark, line ending normalisation, trigraphs behind -trigraphs, line splicing, comments, and preprocessing token formation, with identifiers interned during the scan.

Known limits

Nothing compiles C yet. The preprocessor and the parser land in M1 and M2.

-j changes the worker count that -v reports and nothing else, because the work it schedules is still a placeholder.

The lexer reads a file into memory rather than mapping it, and skips whitespace and comment bodies a byte at a time. Both are M1 performance items with a benchmark attached.

The workspace is being published to crates.io at 0.1.0. crates.io rate limits new crate names, so the 25 crates go up over a few hours rather than at once; cargo install rucc works once the last of them lands.

What's Changed

  • Move to the newest runner images and action releases by @tamnd in #13
  • Lexer: translation phases 1 to 3 by @tamnd in #16
  • Driver: the phase graph, -x, and -### to print it by @tamnd in #14
  • Release 0.1.0 by @tamnd in #17

Full Changelog: https://github.com/tamnd/rucc/commits/v0.1.0