Special forms shadow C identifiers silently, with no escape hatch #36

Open
opened 2026-09-27 18:06:05 +02:00 by alex-eg · 1 comment
Owner

Problem

semen and fmt-c-writer dispatch on a form's head symbol. Any C function whose
name matches a Sex special form is therefore unreachable — and the shadowing is
silent: the call disappears and the program still compiles.

Reproduction

(include stdio.h)
(extern fn comment ((s (* const char))) int)

(pub fn main () int
  (var n int (comment "hello"))
  (printf "%d\n" n)
  (return 0))

emits

#include <stdio.h>

extern int comment (const char * s);

int main (void) {
    int n;
    printf("%d\n", n);
    return 0;
}

The call is gone, n is read uninitialized, and cc reports nothing. The
declaration is right there in the same file and is ignored.

Affected names

Forms dispatched by head symbol today:

c-bit-or c-or case cast comment default define do dot-access
enum extern fn for if import include lambda pub switch var while
  • Safe by accident: case default do enum extern for if switch while — C
    keywords, so no library can declare them. Sex borrowed C's reserved words and
    inherited their reservation.
  • Collidable: cast comment define fn import include lambda
    pub var, plus dot-access c-or c-bit-or. All legal C identifiers.

The failure mode varies. comment vanishes silently, as above.
(cast origin dir) emits (dir)origin and dies in cc with an error pointing
at generated C rather than at the Sex line.

defmacro has the same property by design — macro? is a plist lookup, so a
macro named printf shadows the C function. That is intentional and out of
scope here.

Why now

It came up while naming a compound form for C99 compound literals. compound
would be the tenth such name. Rather than bikeshed one name, the general case
deserves a decision.

Variant A — diagnose at the declaration site

When a name reaches the compiler through a declaration — (extern fn comment ...),
an imported pub fn, or eventually anything a C header parser produces — check
it against the special-form table and refuse:

clash.sex:2: `comment' is a Sex special form; a C declaration of that name is unreachable
  • One lookup at the declaration site, plus the table.
  • Covers all ten names at once, not just the new one.
  • Turns a silent miscompile into a compile error on the right line.
  • Does not help when the name is never declared to Sex — (include foo.h) and
    call away. That case stays silent until headers are parsed.

Variant B — let a declaration rename the C symbol

The standard FFI escape hatch (Rust's #[link_name], Haskell's
foreign import ccall "..."):

(extern fn (ray-cast "cast") ((o (struct vec3)) (d (struct vec3))) int)

(pub fn trace ((origin (struct vec3)) (dir (struct vec3))) int
  (return (ray-cast origin dir)))       ; emits cast(origin, dir)
  • Needs a spelling for "this Sex name, that C name", and unkebabify must leave
    the C string alone.
  • Complete: makes every shadowed C function reachable, which A does not.
  • Worth building only once A's diagnostic starts firing on real code.

They compose — A says there is a problem, B lets you fix it. A alone is the cheap
half and already kills the silent-miscompile class.

Not proposed

Renaming existing forms (var, cast, fn, …). The churn is not worth it, and
the C-keyword forms show the collision is survivable as long as the name is
reserved.

## Problem `semen` and `fmt-c-writer` dispatch on a form's head symbol. Any C function whose name matches a Sex special form is therefore unreachable — and the shadowing is silent: the call disappears and the program still compiles. ## Reproduction ```lisp (include stdio.h) (extern fn comment ((s (* const char))) int) (pub fn main () int (var n int (comment "hello")) (printf "%d\n" n) (return 0)) ``` emits ```c #include <stdio.h> extern int comment (const char * s); int main (void) { int n; printf("%d\n", n); return 0; } ``` The call is gone, `n` is read uninitialized, and `cc` reports nothing. The declaration is right there in the same file and is ignored. ## Affected names Forms dispatched by head symbol today: ``` c-bit-or c-or case cast comment default define do dot-access enum extern fn for if import include lambda pub switch var while ``` - **Safe by accident:** `case default do enum extern for if switch while` — C keywords, so no library can declare them. Sex borrowed C's reserved words and inherited their reservation. - **Collidable:** `cast` `comment` `define` `fn` `import` `include` `lambda` `pub` `var`, plus `dot-access` `c-or` `c-bit-or`. All legal C identifiers. The failure mode varies. `comment` vanishes silently, as above. `(cast origin dir)` emits `(dir)origin` and dies in `cc` with an error pointing at generated C rather than at the Sex line. `defmacro` has the same property by design — `macro?` is a plist lookup, so a macro named `printf` shadows the C function. That is intentional and out of scope here. ## Why now It came up while naming a `compound` form for C99 compound literals. `compound` would be the tenth such name. Rather than bikeshed one name, the general case deserves a decision. ## Variant A — diagnose at the declaration site When a name reaches the compiler through a declaration — `(extern fn comment ...)`, an imported `pub fn`, or eventually anything a C header parser produces — check it against the special-form table and refuse: ``` clash.sex:2: `comment' is a Sex special form; a C declaration of that name is unreachable ``` - One lookup at the declaration site, plus the table. - Covers all ten names at once, not just the new one. - Turns a silent miscompile into a compile error on the right line. - Does *not* help when the name is never declared to Sex — `(include foo.h)` and call away. That case stays silent until headers are parsed. ## Variant B — let a declaration rename the C symbol The standard FFI escape hatch (Rust's `#[link_name]`, Haskell's `foreign import ccall "..."`): ```lisp (extern fn (ray-cast "cast") ((o (struct vec3)) (d (struct vec3))) int) (pub fn trace ((origin (struct vec3)) (dir (struct vec3))) int (return (ray-cast origin dir))) ; emits cast(origin, dir) ``` - Needs a spelling for "this Sex name, that C name", and `unkebabify` must leave the C string alone. - Complete: makes every shadowed C function reachable, which A does not. - Worth building only once A's diagnostic starts firing on real code. They compose — A says there is a problem, B lets you fix it. A alone is the cheap half and already kills the silent-miscompile class. ## Not proposed Renaming existing forms (`var`, `cast`, `fn`, …). The churn is not worth it, and the C-keyword forms show the collision is survivable as long as the name is reserved.
alex-eg added the bug label 2026-09-27 18:06:53 +02:00
Author
Owner

The case that prompted this — a compound form for C99 compound literals — was
resolved by giving it syntax instead of a name, so it never joins the list.

: inside #(...) ends a type and makes the rest a compound literal, and a
leading . names a field:

(var q (struct point) #(struct point : 3 4))
(var a (* int)        #([int 3] : 10 20 30))
(var r (struct named) #(struct named : .n 7 .first-name "zoe"))
(var p (* struct point) (& #(struct point : 9 9)))
struct point q = (struct point){3, 4};
int *a = (int[3]){10, 20, 30};
struct named r = (struct named){.n = 7, .first_name = "zoe"};
struct point *p = &(struct point){9, 9};

Neither : nor .field can be a C identifier, so neither can be shadowed, and
the type needs no parentheses because : is what ends it. The ambiguity that
rules out bare #((struct point) 0 0) — (* p) is both a dereference and a
pointer type, (¤ a i) both a subscript and an array type, and a bare symbol
both a variable and a typedef — does not arise, because the separator is a
syntactic delimiter rather than a type-vs-expression guess.

The general principle worth keeping: where C expresses something as syntax,
Sex should express it as syntax too.
C has no compound keyword; a named form
would have been the tenth shadowable name for no gain.

This does not close the issue. The other nine stand — cast comment define
fn import include lambda pub var — and Variants A and B are
unchanged. It does remove the deadline: nothing new is being added to the list
right now.

The case that prompted this — a `compound` form for C99 compound literals — was resolved by giving it syntax instead of a name, so it never joins the list. `:` inside `#(...)` ends a type and makes the rest a compound literal, and a leading `.` names a field: ```lisp (var q (struct point) #(struct point : 3 4)) (var a (* int) #([int 3] : 10 20 30)) (var r (struct named) #(struct named : .n 7 .first-name "zoe")) (var p (* struct point) (& #(struct point : 9 9))) ``` ```c struct point q = (struct point){3, 4}; int *a = (int[3]){10, 20, 30}; struct named r = (struct named){.n = 7, .first_name = "zoe"}; struct point *p = &(struct point){9, 9}; ``` Neither `:` nor `.field` can be a C identifier, so neither can be shadowed, and the type needs no parentheses because `:` is what ends it. The ambiguity that rules out bare `#((struct point) 0 0)` — `(* p)` is both a dereference and a pointer type, `(¤ a i)` both a subscript and an array type, and a bare symbol both a variable and a typedef — does not arise, because the separator is a syntactic delimiter rather than a type-vs-expression guess. The general principle worth keeping: **where C expresses something as syntax, Sex should express it as syntax too.** C has no `compound` keyword; a named form would have been the tenth shadowable name for no gain. This does not close the issue. The other nine stand — `cast` `comment` `define` `fn` `import` `include` `lambda` `pub` `var` — and Variants A and B are unchanged. It does remove the deadline: nothing new is being added to the list right now.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: alex-eg/sex#36