Add records: TYPE, DIM ... AS, field access, copy on assign
BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was
not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used
it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now.
Declaring the type is what buys the storage model. A TYPE states its fields, so
an instance has a known slot count and is laid out exactly as an array is -- one
contiguous run from the same value pool DIM already draws from, with field access
as offset arithmetic. No new pool holds data; the only new table holds
descriptors. A nested value flattens into its container's run, which is why
LINE with two POINTs and a string is five slots rather than three.
Each field takes its type from its own suffix, the same rule every other name
here follows, so a field list needs no type column. An `@` field is the
exception and has to name its type, because three primitive types fit in three
suffix characters and N declared types do not fit in one.
The declaration is prescanned before the program runs, like labels and DATA and
for the same reason: it has to be in effect wherever control goes. Three passes,
each for a case the one before cannot do -- names first so a field can refer to
a type declared later, then field lists, then sizes by repeated resolution. What
never resolves is a cycle of by-value containment, so "a TYPE cannot contain
itself by value" is a diagnosis rather than an assumption, and the message says
to use PTR TO instead.
Assignment copies. That interception is the whole feature and it cannot live in
akbasic_value_clone(), which copies one slot -- and one slot holds a *reference*
to an instance rather than the instance, so going through it would alias. A
structure is intercepted before that path and its slots are copied one at a
time, walking the descriptor rather than memcpy-ing the run, because a pointer
field must copy its reference where a value field must copy its slots.
Two smaller things the work required. All three prescans now sit inside one
ATTEMPT: a malformed declaration is the program's mistake, and it was printing a
stack trace and taking the driver with it, which is the boundary goal 3 exists
to draw. And a fresh variable's structtype is -1 rather than the 0 a memset
leaves, because 0 is a valid type index and every new variable was claiming to
be the first type declared.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:39:47 -04:00
|
|
|
/**
|
|
|
|
|
* @file struct_types.c
|
|
|
|
|
* @brief Tests `TYPE` declarations, their layout, and records by value.
|
|
|
|
|
*
|
|
|
|
|
* The assertions here are about the *descriptor* -- what the prescan read out of
|
|
|
|
|
* the source and how it laid an instance out -- because that is the part a BASIC
|
|
|
|
|
* program cannot see and therefore cannot pin. What a program can see is pinned
|
|
|
|
|
* by tests/language/structures/ instead.
|
|
|
|
|
*
|
|
|
|
|
* The layout assertions matter more than they look. A field's offset is the only
|
|
|
|
|
* thing standing between `R@.H#` and `R@.W#`, and an off-by-one there reads the
|
|
|
|
|
* neighbouring field rather than failing -- so it would pass every test that
|
|
|
|
|
* only ever set one field and read it back.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
#include <string.h>
|
|
|
|
|
|
|
|
|
|
#include <akbasic/error.h>
|
|
|
|
|
#include <akbasic/runtime.h>
|
|
|
|
|
#include <akbasic/structtype.h>
|
|
|
|
|
|
|
|
|
|
#include "harness.h"
|
|
|
|
|
#include "testutil.h"
|
|
|
|
|
|
|
|
|
|
/** @brief Load a program and start it, which is what runs the prescan. */
|
|
|
|
|
static akerr_ErrorContext AKERR_NOIGNORE *declare(const char *source)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
|
|
|
|
|
|
PASS(errctx, harness_start(NULL));
|
|
|
|
|
PASS(errctx, akbasic_runtime_load(&HARNESS_RUNTIME, source));
|
|
|
|
|
PASS(errctx, akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
|
|
|
|
|
SUCCEED_RETURN(errctx);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static const char *RECT =
|
|
|
|
|
"10 TYPE RECT\n"
|
|
|
|
|
"20 W#\n"
|
|
|
|
|
"30 H#\n"
|
|
|
|
|
"40 END TYPE\n"
|
|
|
|
|
"50 PRINT 1\n";
|
|
|
|
|
|
|
|
|
|
/** @brief A declaration becomes a descriptor with names, types and offsets. */
|
|
|
|
|
static void test_layout(void)
|
|
|
|
|
{
|
|
|
|
|
akbasic_StructField *field = NULL;
|
|
|
|
|
int index = -1;
|
|
|
|
|
|
|
|
|
|
TEST_REQUIRE_OK(declare(RECT));
|
|
|
|
|
TEST_REQUIRE_OK(akbasic_structtype_find(&HARNESS_RUNTIME.structtypes, "RECT", &index));
|
|
|
|
|
TEST_REQUIRE(index >= 0, "RECT should have been declared");
|
|
|
|
|
TEST_REQUIRE_INT(HARNESS_RUNTIME.structtypes.types[index].fieldcount, 2);
|
|
|
|
|
TEST_REQUIRE_INT(HARNESS_RUNTIME.structtypes.types[index].slotcount, 2);
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* Offsets, in declaration order. This is the assertion an off-by-one hides
|
|
|
|
|
* from: swapping these two would still let a program set W# and read W#.
|
|
|
|
|
*/
|
|
|
|
|
TEST_REQUIRE_OK(akbasic_structtype_field(&HARNESS_RUNTIME.structtypes, index, "W#", &field));
|
|
|
|
|
TEST_REQUIRE_INT(field->offset, 0);
|
|
|
|
|
TEST_REQUIRE_INT(field->valuetype, AKBASIC_TYPE_INTEGER);
|
|
|
|
|
TEST_REQUIRE_INT(field->slotcount, 1);
|
|
|
|
|
TEST_REQUIRE_OK(akbasic_structtype_field(&HARNESS_RUNTIME.structtypes, index, "H#", &field));
|
|
|
|
|
TEST_REQUIRE_INT(field->offset, 1);
|
|
|
|
|
harness_stop();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief A nested value flattens into its container's run.
|
|
|
|
|
*
|
|
|
|
|
* The property that lets an instance be one contiguous slice of the value pool,
|
|
|
|
|
* exactly as an array is, and so needs no pool of its own.
|
|
|
|
|
*/
|
|
|
|
|
static void test_nesting_flattens(void)
|
|
|
|
|
{
|
|
|
|
|
akbasic_StructField *field = NULL;
|
|
|
|
|
int index = -1;
|
|
|
|
|
|
Add strict pointers: AS PTR TO, POINT ... AT, and the -> operator
A pointer is a distinct declared kind rather than a mode a structure can be in,
which is what "strict" means here: `.` requires a structure on its left and `->`
requires a pointer, neither stands in for the other, and both refusals name the
operator the program should have used. So a reader always knows from the
spelling whether the thing on the left is their own copy or somebody else's
data.
Assignment still copies. POINT is the only way to share, so a program that never
writes it can never be surprised by aliasing -- and `P@ = A@` is refused with a
message saying to POINT it instead, rather than quietly becoming the one
assignment in the language that does not copy.
PTR TO is also the only way a TYPE may refer to itself, since by value it would
have no finite size. That is what makes a linked list possible, and
tests/language/structures/pointers.bas builds one, walks it and renders it.
Rendering follows pointers, so it needs a depth bound where copying does not:
copy stops at a pointer by construction, but two nodes pointing at each other is
easy to write and PRINT would not come back. Four levels, chosen so the bound
bites before the 256-byte render buffer does -- otherwise a cycle would stop
because it ran out of room rather than because it was told to.
Three things the work turned up, all now pinned by tests:
A freshly DIMmed record printed `(UNDEFINED STRING REPRESENTATION FOR 0)` for
every field. Slots now take the type their field declared, so it reads as zeros.
Adding POINT as a verb makes POINT unusable as a type name, and the parser
reported that as "Expected expression or literal" pointing at the line rather
than the problem. The prescan now refuses a reserved word as a type name and
says so.
Field names follow the same reserved-word rule as variable names, enforced by
the loader's own scan -- `TO@` is a bad field name for exactly the reason `TO#`
is a bad variable name. Recorded rather than worked around.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:48:09 -04:00
|
|
|
TEST_REQUIRE_OK(declare("10 TYPE COORD\n"
|
Add records: TYPE, DIM ... AS, field access, copy on assign
BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was
not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used
it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now.
Declaring the type is what buys the storage model. A TYPE states its fields, so
an instance has a known slot count and is laid out exactly as an array is -- one
contiguous run from the same value pool DIM already draws from, with field access
as offset arithmetic. No new pool holds data; the only new table holds
descriptors. A nested value flattens into its container's run, which is why
LINE with two POINTs and a string is five slots rather than three.
Each field takes its type from its own suffix, the same rule every other name
here follows, so a field list needs no type column. An `@` field is the
exception and has to name its type, because three primitive types fit in three
suffix characters and N declared types do not fit in one.
The declaration is prescanned before the program runs, like labels and DATA and
for the same reason: it has to be in effect wherever control goes. Three passes,
each for a case the one before cannot do -- names first so a field can refer to
a type declared later, then field lists, then sizes by repeated resolution. What
never resolves is a cycle of by-value containment, so "a TYPE cannot contain
itself by value" is a diagnosis rather than an assumption, and the message says
to use PTR TO instead.
Assignment copies. That interception is the whole feature and it cannot live in
akbasic_value_clone(), which copies one slot -- and one slot holds a *reference*
to an instance rather than the instance, so going through it would alias. A
structure is intercepted before that path and its slots are copied one at a
time, walking the descriptor rather than memcpy-ing the run, because a pointer
field must copy its reference where a value field must copy its slots.
Two smaller things the work required. All three prescans now sit inside one
ATTEMPT: a malformed declaration is the program's mistake, and it was printing a
stack trace and taking the driver with it, which is the boundary goal 3 exists
to draw. And a fresh variable's structtype is -1 rather than the 0 a memset
leaves, because 0 is a valid type index and every new variable was claiming to
be the first type declared.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:39:47 -04:00
|
|
|
"20 X#\n"
|
|
|
|
|
"30 Y#\n"
|
|
|
|
|
"40 END TYPE\n"
|
Add strict pointers: AS PTR TO, POINT ... AT, and the -> operator
A pointer is a distinct declared kind rather than a mode a structure can be in,
which is what "strict" means here: `.` requires a structure on its left and `->`
requires a pointer, neither stands in for the other, and both refusals name the
operator the program should have used. So a reader always knows from the
spelling whether the thing on the left is their own copy or somebody else's
data.
Assignment still copies. POINT is the only way to share, so a program that never
writes it can never be surprised by aliasing -- and `P@ = A@` is refused with a
message saying to POINT it instead, rather than quietly becoming the one
assignment in the language that does not copy.
PTR TO is also the only way a TYPE may refer to itself, since by value it would
have no finite size. That is what makes a linked list possible, and
tests/language/structures/pointers.bas builds one, walks it and renders it.
Rendering follows pointers, so it needs a depth bound where copying does not:
copy stops at a pointer by construction, but two nodes pointing at each other is
easy to write and PRINT would not come back. Four levels, chosen so the bound
bites before the 256-byte render buffer does -- otherwise a cycle would stop
because it ran out of room rather than because it was told to.
Three things the work turned up, all now pinned by tests:
A freshly DIMmed record printed `(UNDEFINED STRING REPRESENTATION FOR 0)` for
every field. Slots now take the type their field declared, so it reads as zeros.
Adding POINT as a verb makes POINT unusable as a type name, and the parser
reported that as "Expected expression or literal" pointing at the line rather
than the problem. The prescan now refuses a reserved word as a type name and
says so.
Field names follow the same reserved-word rule as variable names, enforced by
the loader's own scan -- `TO@` is a bad field name for exactly the reason `TO#`
is a bad variable name. Recorded rather than worked around.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:48:09 -04:00
|
|
|
"50 TYPE SEGMENT\n"
|
|
|
|
|
"60 SRC@ AS COORD\n"
|
|
|
|
|
"70 DEST@ AS COORD\n"
|
Add records: TYPE, DIM ... AS, field access, copy on assign
BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was
not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used
it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now.
Declaring the type is what buys the storage model. A TYPE states its fields, so
an instance has a known slot count and is laid out exactly as an array is -- one
contiguous run from the same value pool DIM already draws from, with field access
as offset arithmetic. No new pool holds data; the only new table holds
descriptors. A nested value flattens into its container's run, which is why
LINE with two POINTs and a string is five slots rather than three.
Each field takes its type from its own suffix, the same rule every other name
here follows, so a field list needs no type column. An `@` field is the
exception and has to name its type, because three primitive types fit in three
suffix characters and N declared types do not fit in one.
The declaration is prescanned before the program runs, like labels and DATA and
for the same reason: it has to be in effect wherever control goes. Three passes,
each for a case the one before cannot do -- names first so a field can refer to
a type declared later, then field lists, then sizes by repeated resolution. What
never resolves is a cycle of by-value containment, so "a TYPE cannot contain
itself by value" is a diagnosis rather than an assumption, and the message says
to use PTR TO instead.
Assignment copies. That interception is the whole feature and it cannot live in
akbasic_value_clone(), which copies one slot -- and one slot holds a *reference*
to an instance rather than the instance, so going through it would alias. A
structure is intercepted before that path and its slots are copied one at a
time, walking the descriptor rather than memcpy-ing the run, because a pointer
field must copy its reference where a value field must copy its slots.
Two smaller things the work required. All three prescans now sit inside one
ATTEMPT: a malformed declaration is the program's mistake, and it was printing a
stack trace and taking the driver with it, which is the boundary goal 3 exists
to draw. And a fresh variable's structtype is -1 rather than the 0 a memset
leaves, because 0 is a valid type index and every new variable was claiming to
be the first type declared.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:39:47 -04:00
|
|
|
"80 NAME$\n"
|
|
|
|
|
"90 END TYPE\n"
|
|
|
|
|
"100 PRINT 1\n"));
|
Add strict pointers: AS PTR TO, POINT ... AT, and the -> operator
A pointer is a distinct declared kind rather than a mode a structure can be in,
which is what "strict" means here: `.` requires a structure on its left and `->`
requires a pointer, neither stands in for the other, and both refusals name the
operator the program should have used. So a reader always knows from the
spelling whether the thing on the left is their own copy or somebody else's
data.
Assignment still copies. POINT is the only way to share, so a program that never
writes it can never be surprised by aliasing -- and `P@ = A@` is refused with a
message saying to POINT it instead, rather than quietly becoming the one
assignment in the language that does not copy.
PTR TO is also the only way a TYPE may refer to itself, since by value it would
have no finite size. That is what makes a linked list possible, and
tests/language/structures/pointers.bas builds one, walks it and renders it.
Rendering follows pointers, so it needs a depth bound where copying does not:
copy stops at a pointer by construction, but two nodes pointing at each other is
easy to write and PRINT would not come back. Four levels, chosen so the bound
bites before the 256-byte render buffer does -- otherwise a cycle would stop
because it ran out of room rather than because it was told to.
Three things the work turned up, all now pinned by tests:
A freshly DIMmed record printed `(UNDEFINED STRING REPRESENTATION FOR 0)` for
every field. Slots now take the type their field declared, so it reads as zeros.
Adding POINT as a verb makes POINT unusable as a type name, and the parser
reported that as "Expected expression or literal" pointing at the line rather
than the problem. The prescan now refuses a reserved word as a type name and
says so.
Field names follow the same reserved-word rule as variable names, enforced by
the loader's own scan -- `TO@` is a bad field name for exactly the reason `TO#`
is a bad variable name. Recorded rather than worked around.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:48:09 -04:00
|
|
|
TEST_REQUIRE_OK(akbasic_structtype_find(&HARNESS_RUNTIME.structtypes, "SEGMENT", &index));
|
|
|
|
|
TEST_REQUIRE(index >= 0, "SEGMENT should have been declared");
|
|
|
|
|
if ( index < 0 ) {
|
|
|
|
|
harness_stop();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
/* Two coordinates of two slots each, plus one string: five, not three. */
|
Add records: TYPE, DIM ... AS, field access, copy on assign
BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was
not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used
it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now.
Declaring the type is what buys the storage model. A TYPE states its fields, so
an instance has a known slot count and is laid out exactly as an array is -- one
contiguous run from the same value pool DIM already draws from, with field access
as offset arithmetic. No new pool holds data; the only new table holds
descriptors. A nested value flattens into its container's run, which is why
LINE with two POINTs and a string is five slots rather than three.
Each field takes its type from its own suffix, the same rule every other name
here follows, so a field list needs no type column. An `@` field is the
exception and has to name its type, because three primitive types fit in three
suffix characters and N declared types do not fit in one.
The declaration is prescanned before the program runs, like labels and DATA and
for the same reason: it has to be in effect wherever control goes. Three passes,
each for a case the one before cannot do -- names first so a field can refer to
a type declared later, then field lists, then sizes by repeated resolution. What
never resolves is a cycle of by-value containment, so "a TYPE cannot contain
itself by value" is a diagnosis rather than an assumption, and the message says
to use PTR TO instead.
Assignment copies. That interception is the whole feature and it cannot live in
akbasic_value_clone(), which copies one slot -- and one slot holds a *reference*
to an instance rather than the instance, so going through it would alias. A
structure is intercepted before that path and its slots are copied one at a
time, walking the descriptor rather than memcpy-ing the run, because a pointer
field must copy its reference where a value field must copy its slots.
Two smaller things the work required. All three prescans now sit inside one
ATTEMPT: a malformed declaration is the program's mistake, and it was printing a
stack trace and taking the driver with it, which is the boundary goal 3 exists
to draw. And a fresh variable's structtype is -1 rather than the 0 a memset
leaves, because 0 is a valid type index and every new variable was claiming to
be the first type declared.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:39:47 -04:00
|
|
|
TEST_REQUIRE_INT(HARNESS_RUNTIME.structtypes.types[index].slotcount, 5);
|
|
|
|
|
|
Add strict pointers: AS PTR TO, POINT ... AT, and the -> operator
A pointer is a distinct declared kind rather than a mode a structure can be in,
which is what "strict" means here: `.` requires a structure on its left and `->`
requires a pointer, neither stands in for the other, and both refusals name the
operator the program should have used. So a reader always knows from the
spelling whether the thing on the left is their own copy or somebody else's
data.
Assignment still copies. POINT is the only way to share, so a program that never
writes it can never be surprised by aliasing -- and `P@ = A@` is refused with a
message saying to POINT it instead, rather than quietly becoming the one
assignment in the language that does not copy.
PTR TO is also the only way a TYPE may refer to itself, since by value it would
have no finite size. That is what makes a linked list possible, and
tests/language/structures/pointers.bas builds one, walks it and renders it.
Rendering follows pointers, so it needs a depth bound where copying does not:
copy stops at a pointer by construction, but two nodes pointing at each other is
easy to write and PRINT would not come back. Four levels, chosen so the bound
bites before the 256-byte render buffer does -- otherwise a cycle would stop
because it ran out of room rather than because it was told to.
Three things the work turned up, all now pinned by tests:
A freshly DIMmed record printed `(UNDEFINED STRING REPRESENTATION FOR 0)` for
every field. Slots now take the type their field declared, so it reads as zeros.
Adding POINT as a verb makes POINT unusable as a type name, and the parser
reported that as "Expected expression or literal" pointing at the line rather
than the problem. The prescan now refuses a reserved word as a type name and
says so.
Field names follow the same reserved-word rule as variable names, enforced by
the loader's own scan -- `TO@` is a bad field name for exactly the reason `TO#`
is a bad variable name. Recorded rather than worked around.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:48:09 -04:00
|
|
|
TEST_REQUIRE_OK(akbasic_structtype_field(&HARNESS_RUNTIME.structtypes, index, "SRC@", &field));
|
Add records: TYPE, DIM ... AS, field access, copy on assign
BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was
not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used
it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now.
Declaring the type is what buys the storage model. A TYPE states its fields, so
an instance has a known slot count and is laid out exactly as an array is -- one
contiguous run from the same value pool DIM already draws from, with field access
as offset arithmetic. No new pool holds data; the only new table holds
descriptors. A nested value flattens into its container's run, which is why
LINE with two POINTs and a string is five slots rather than three.
Each field takes its type from its own suffix, the same rule every other name
here follows, so a field list needs no type column. An `@` field is the
exception and has to name its type, because three primitive types fit in three
suffix characters and N declared types do not fit in one.
The declaration is prescanned before the program runs, like labels and DATA and
for the same reason: it has to be in effect wherever control goes. Three passes,
each for a case the one before cannot do -- names first so a field can refer to
a type declared later, then field lists, then sizes by repeated resolution. What
never resolves is a cycle of by-value containment, so "a TYPE cannot contain
itself by value" is a diagnosis rather than an assumption, and the message says
to use PTR TO instead.
Assignment copies. That interception is the whole feature and it cannot live in
akbasic_value_clone(), which copies one slot -- and one slot holds a *reference*
to an instance rather than the instance, so going through it would alias. A
structure is intercepted before that path and its slots are copied one at a
time, walking the descriptor rather than memcpy-ing the run, because a pointer
field must copy its reference where a value field must copy its slots.
Two smaller things the work required. All three prescans now sit inside one
ATTEMPT: a malformed declaration is the program's mistake, and it was printing a
stack trace and taking the driver with it, which is the boundary goal 3 exists
to draw. And a fresh variable's structtype is -1 rather than the 0 a memset
leaves, because 0 is a valid type index and every new variable was claiming to
be the first type declared.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:39:47 -04:00
|
|
|
TEST_REQUIRE_INT(field->offset, 0);
|
|
|
|
|
TEST_REQUIRE_INT(field->slotcount, 2);
|
Add strict pointers: AS PTR TO, POINT ... AT, and the -> operator
A pointer is a distinct declared kind rather than a mode a structure can be in,
which is what "strict" means here: `.` requires a structure on its left and `->`
requires a pointer, neither stands in for the other, and both refusals name the
operator the program should have used. So a reader always knows from the
spelling whether the thing on the left is their own copy or somebody else's
data.
Assignment still copies. POINT is the only way to share, so a program that never
writes it can never be surprised by aliasing -- and `P@ = A@` is refused with a
message saying to POINT it instead, rather than quietly becoming the one
assignment in the language that does not copy.
PTR TO is also the only way a TYPE may refer to itself, since by value it would
have no finite size. That is what makes a linked list possible, and
tests/language/structures/pointers.bas builds one, walks it and renders it.
Rendering follows pointers, so it needs a depth bound where copying does not:
copy stops at a pointer by construction, but two nodes pointing at each other is
easy to write and PRINT would not come back. Four levels, chosen so the bound
bites before the 256-byte render buffer does -- otherwise a cycle would stop
because it ran out of room rather than because it was told to.
Three things the work turned up, all now pinned by tests:
A freshly DIMmed record printed `(UNDEFINED STRING REPRESENTATION FOR 0)` for
every field. Slots now take the type their field declared, so it reads as zeros.
Adding POINT as a verb makes POINT unusable as a type name, and the parser
reported that as "Expected expression or literal" pointing at the line rather
than the problem. The prescan now refuses a reserved word as a type name and
says so.
Field names follow the same reserved-word rule as variable names, enforced by
the loader's own scan -- `TO@` is a bad field name for exactly the reason `TO#`
is a bad variable name. Recorded rather than worked around.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:48:09 -04:00
|
|
|
TEST_REQUIRE_OK(akbasic_structtype_field(&HARNESS_RUNTIME.structtypes, index, "DEST@", &field));
|
Add records: TYPE, DIM ... AS, field access, copy on assign
BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was
not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used
it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now.
Declaring the type is what buys the storage model. A TYPE states its fields, so
an instance has a known slot count and is laid out exactly as an array is -- one
contiguous run from the same value pool DIM already draws from, with field access
as offset arithmetic. No new pool holds data; the only new table holds
descriptors. A nested value flattens into its container's run, which is why
LINE with two POINTs and a string is five slots rather than three.
Each field takes its type from its own suffix, the same rule every other name
here follows, so a field list needs no type column. An `@` field is the
exception and has to name its type, because three primitive types fit in three
suffix characters and N declared types do not fit in one.
The declaration is prescanned before the program runs, like labels and DATA and
for the same reason: it has to be in effect wherever control goes. Three passes,
each for a case the one before cannot do -- names first so a field can refer to
a type declared later, then field lists, then sizes by repeated resolution. What
never resolves is a cycle of by-value containment, so "a TYPE cannot contain
itself by value" is a diagnosis rather than an assumption, and the message says
to use PTR TO instead.
Assignment copies. That interception is the whole feature and it cannot live in
akbasic_value_clone(), which copies one slot -- and one slot holds a *reference*
to an instance rather than the instance, so going through it would alias. A
structure is intercepted before that path and its slots are copied one at a
time, walking the descriptor rather than memcpy-ing the run, because a pointer
field must copy its reference where a value field must copy its slots.
Two smaller things the work required. All three prescans now sit inside one
ATTEMPT: a malformed declaration is the program's mistake, and it was printing a
stack trace and taking the driver with it, which is the boundary goal 3 exists
to draw. And a fresh variable's structtype is -1 rather than the 0 a memset
leaves, because 0 is a valid type index and every new variable was claiming to
be the first type declared.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:39:47 -04:00
|
|
|
TEST_REQUIRE_INT(field->offset, 2);
|
|
|
|
|
TEST_REQUIRE_OK(akbasic_structtype_field(&HARNESS_RUNTIME.structtypes, index, "NAME$", &field));
|
|
|
|
|
TEST_REQUIRE_INT(field->offset, 4);
|
|
|
|
|
harness_stop();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief A type may be named before it is declared.
|
|
|
|
|
*
|
|
|
|
|
* Which is why the prescan registers every name before it reads any field list.
|
|
|
|
|
* A linked list needs it even for itself, and this is the case that pins it.
|
|
|
|
|
*/
|
|
|
|
|
static void test_forward_reference(void)
|
|
|
|
|
{
|
|
|
|
|
int index = -1;
|
|
|
|
|
|
|
|
|
|
TEST_REQUIRE_OK(declare("10 TYPE OUTER\n"
|
|
|
|
|
"20 INNER@ AS INNERT\n"
|
|
|
|
|
"30 END TYPE\n"
|
|
|
|
|
"40 TYPE INNERT\n"
|
|
|
|
|
"50 N#\n"
|
|
|
|
|
"60 END TYPE\n"
|
|
|
|
|
"70 PRINT 1\n"));
|
|
|
|
|
TEST_REQUIRE_OK(akbasic_structtype_find(&HARNESS_RUNTIME.structtypes, "OUTER", &index));
|
|
|
|
|
TEST_REQUIRE(index >= 0, "OUTER should have been declared");
|
Add strict pointers: AS PTR TO, POINT ... AT, and the -> operator
A pointer is a distinct declared kind rather than a mode a structure can be in,
which is what "strict" means here: `.` requires a structure on its left and `->`
requires a pointer, neither stands in for the other, and both refusals name the
operator the program should have used. So a reader always knows from the
spelling whether the thing on the left is their own copy or somebody else's
data.
Assignment still copies. POINT is the only way to share, so a program that never
writes it can never be surprised by aliasing -- and `P@ = A@` is refused with a
message saying to POINT it instead, rather than quietly becoming the one
assignment in the language that does not copy.
PTR TO is also the only way a TYPE may refer to itself, since by value it would
have no finite size. That is what makes a linked list possible, and
tests/language/structures/pointers.bas builds one, walks it and renders it.
Rendering follows pointers, so it needs a depth bound where copying does not:
copy stops at a pointer by construction, but two nodes pointing at each other is
easy to write and PRINT would not come back. Four levels, chosen so the bound
bites before the 256-byte render buffer does -- otherwise a cycle would stop
because it ran out of room rather than because it was told to.
Three things the work turned up, all now pinned by tests:
A freshly DIMmed record printed `(UNDEFINED STRING REPRESENTATION FOR 0)` for
every field. Slots now take the type their field declared, so it reads as zeros.
Adding POINT as a verb makes POINT unusable as a type name, and the parser
reported that as "Expected expression or literal" pointing at the line rather
than the problem. The prescan now refuses a reserved word as a type name and
says so.
Field names follow the same reserved-word rule as variable names, enforced by
the loader's own scan -- `TO@` is a bad field name for exactly the reason `TO#`
is a bad variable name. Recorded rather than worked around.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:48:09 -04:00
|
|
|
if ( index < 0 ) {
|
|
|
|
|
harness_stop();
|
|
|
|
|
return;
|
|
|
|
|
}
|
Add records: TYPE, DIM ... AS, field access, copy on assign
BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was
not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used
it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now.
Declaring the type is what buys the storage model. A TYPE states its fields, so
an instance has a known slot count and is laid out exactly as an array is -- one
contiguous run from the same value pool DIM already draws from, with field access
as offset arithmetic. No new pool holds data; the only new table holds
descriptors. A nested value flattens into its container's run, which is why
LINE with two POINTs and a string is five slots rather than three.
Each field takes its type from its own suffix, the same rule every other name
here follows, so a field list needs no type column. An `@` field is the
exception and has to name its type, because three primitive types fit in three
suffix characters and N declared types do not fit in one.
The declaration is prescanned before the program runs, like labels and DATA and
for the same reason: it has to be in effect wherever control goes. Three passes,
each for a case the one before cannot do -- names first so a field can refer to
a type declared later, then field lists, then sizes by repeated resolution. What
never resolves is a cycle of by-value containment, so "a TYPE cannot contain
itself by value" is a diagnosis rather than an assumption, and the message says
to use PTR TO instead.
Assignment copies. That interception is the whole feature and it cannot live in
akbasic_value_clone(), which copies one slot -- and one slot holds a *reference*
to an instance rather than the instance, so going through it would alias. A
structure is intercepted before that path and its slots are copied one at a
time, walking the descriptor rather than memcpy-ing the run, because a pointer
field must copy its reference where a value field must copy its slots.
Two smaller things the work required. All three prescans now sit inside one
ATTEMPT: a malformed declaration is the program's mistake, and it was printing a
stack trace and taking the driver with it, which is the boundary goal 3 exists
to draw. And a fresh variable's structtype is -1 rather than the 0 a memset
leaves, because 0 is a valid type index and every new variable was claiming to
be the first type declared.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:39:47 -04:00
|
|
|
TEST_REQUIRE_INT(HARNESS_RUNTIME.structtypes.types[index].slotcount, 1);
|
|
|
|
|
harness_stop();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @brief The field-missing refusal names the type and lists what it does have. */
|
|
|
|
|
static void test_missing_field_lists_the_others(void)
|
|
|
|
|
{
|
|
|
|
|
akbasic_StructField *field = NULL;
|
|
|
|
|
akerr_ErrorContext *raised = NULL;
|
|
|
|
|
int index = -1;
|
|
|
|
|
|
|
|
|
|
TEST_REQUIRE_OK(declare(RECT));
|
|
|
|
|
TEST_REQUIRE_OK(akbasic_structtype_find(&HARNESS_RUNTIME.structtypes, "RECT", &index));
|
|
|
|
|
|
|
|
|
|
raised = akbasic_structtype_field(&HARNESS_RUNTIME.structtypes, index, "NOPE#", &field);
|
|
|
|
|
TEST_REQUIRE(raised != NULL, "a field the type does not declare must be refused");
|
|
|
|
|
TEST_REQUIRE_INT(raised->status, AKBASIC_ERR_UNDEFINED);
|
|
|
|
|
/*
|
|
|
|
|
* The list is the point. A misspelled field is catchable only because the
|
|
|
|
|
* set is closed and the program wrote it down, so the message shows it --
|
|
|
|
|
* that is what turns "why is my total zero" into a one-line fix.
|
|
|
|
|
*/
|
|
|
|
|
TEST_REQUIRE(strstr(raised->message, "W#") != NULL && strstr(raised->message, "H#") != NULL,
|
|
|
|
|
"the refusal should list the fields RECT does have, got \"%s\"", raised->message);
|
|
|
|
|
test_discard_error(raised);
|
|
|
|
|
harness_stop();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief A type containing itself by value is refused, by name.
|
|
|
|
|
*
|
|
|
|
|
* It has no finite size, so the prescan's size resolution never completes for
|
|
|
|
|
* it -- and "the set of types that never resolved" is exactly the set that
|
|
|
|
|
* contains itself. Diagnosed there rather than discovered when the pool runs
|
|
|
|
|
* out, and the message says what to do instead.
|
|
|
|
|
*/
|
|
|
|
|
static void test_self_by_value_refused(void)
|
|
|
|
|
{
|
|
|
|
|
TEST_REQUIRE_OK(harness_start(NULL));
|
|
|
|
|
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME,
|
|
|
|
|
"10 TYPE NODE\n"
|
|
|
|
|
"20 COUNT#\n"
|
|
|
|
|
"30 TAIL@ AS NODE\n"
|
|
|
|
|
"40 END TYPE\n"
|
|
|
|
|
"50 PRINT 1\n"));
|
|
|
|
|
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
|
|
|
|
|
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
|
|
|
|
|
/*
|
|
|
|
|
* Reported as a BASIC error rather than raised at the host. A malformed
|
|
|
|
|
* declaration is the program's mistake, and goal 3 says a script's mistakes
|
|
|
|
|
* do not reach the embedding program.
|
|
|
|
|
*/
|
|
|
|
|
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "contains itself by value") != NULL,
|
|
|
|
|
"self-containment should be refused by name, got \"%s\"", HARNESS_OUTPUT);
|
|
|
|
|
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "PTR TO") != NULL,
|
|
|
|
|
"the refusal should say what to do instead, got \"%s\"", HARNESS_OUTPUT);
|
|
|
|
|
harness_stop();
|
|
|
|
|
|
|
|
|
|
/* And through a pointer it is legal, which is how a list is built at all. */
|
|
|
|
|
TEST_REQUIRE_OK(declare("10 TYPE NODE\n"
|
|
|
|
|
"20 COUNT#\n"
|
|
|
|
|
"30 TAIL@ AS PTR TO NODE\n"
|
|
|
|
|
"40 END TYPE\n"
|
|
|
|
|
"50 PRINT 1\n"));
|
|
|
|
|
harness_stop();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @brief A declaration error reaches the program, not the host. */
|
|
|
|
|
static void test_declaration_errors_are_basic_errors(void)
|
|
|
|
|
{
|
|
|
|
|
static const char *CASES[] = {
|
|
|
|
|
"10 TYPE RECT\n20 W\n30 END TYPE\n40 PRINT 1\n",
|
|
|
|
|
"10 TYPE RECT\n20 W#\n30 PRINT 1\n",
|
|
|
|
|
"10 TYPE RECT\n20 W#\n30 END TYPE\n40 TYPE RECT\n50 H#\n60 END TYPE\n70 PRINT 1\n",
|
|
|
|
|
"10 TYPE RECT\n20 W#\n30 W#\n40 END TYPE\n50 PRINT 1\n",
|
|
|
|
|
"10 TYPE RECT\n20 INNER@\n30 END TYPE\n40 PRINT 1\n"
|
|
|
|
|
};
|
|
|
|
|
size_t i = 0;
|
|
|
|
|
|
|
|
|
|
for ( i = 0; i < sizeof(CASES) / sizeof(CASES[0]); i++ ) {
|
|
|
|
|
TEST_REQUIRE_OK(harness_start(NULL));
|
|
|
|
|
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, CASES[i]));
|
|
|
|
|
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
|
|
|
|
|
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
|
|
|
|
|
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "PARSE ERROR") != NULL,
|
|
|
|
|
"case %zu should report a parse error, got \"%s\"", i, HARNESS_OUTPUT);
|
|
|
|
|
harness_stop();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int main(void)
|
|
|
|
|
{
|
|
|
|
|
TEST_REQUIRE_OK(akbasic_error_register());
|
|
|
|
|
|
|
|
|
|
test_layout();
|
|
|
|
|
test_nesting_flattens();
|
|
|
|
|
test_forward_reference();
|
|
|
|
|
test_missing_field_lists_the_others();
|
|
|
|
|
test_self_by_value_refused();
|
|
|
|
|
test_declaration_errors_are_basic_errors();
|
|
|
|
|
|
|
|
|
|
return akbasic_test_failures;
|
|
|
|
|
}
|