Skip to content

A Basic Guide to Programming in zeptoscript

tabemann edited this page Jul 19, 2024 · 62 revisions

Introduction

zeptoscript is a high-level, dynamically-typed language with automatic memory management on top of zeptoforth. It is designed to enable both functional and imperative programming. It also has optional object-orientation layer on top of it in turn. Additionally, it can be used with cooperative multitasking and message channels.

Before zeptoscript can be used, one must compile src/common/core.fs with the latest zeptoforth, if one is compiling to flash then reboot, and then execute zscript::init-zscript ( compile-size runtime-size -- ). When executed while compiling to RAM a heap of runtime-size is created, while when compiling to flash a heap of compile-size is created. Note that this word, if executed while compiling to flash, will compile to flash an initialization routine that will initialize zeptoscript with a heap of runtime-size bytes.

zeptoforth can be re-entered from zeptoscript by executing enter-zforth, while zeptoscript can be re-entered from zeptoforth by executing zscript::enter-zscript. enter-zforth has no effect if one is already in zeptoforth, and likewise enter-zscript has no effect if one is already in zeptoscript. Note that if zscript::enter-zscript is executed and zscript::init-zscript has not already been executed, it is equivalent to executing 65536 65536 zscript::init-zscript.

In zeptoscript the zscript module is the default module, and words in zeptoforth modules must be accessed through forth:: or internal::. Note that extreme care must be taken when accessing words under forth:: or internal:: because these words are not aware of zeptoscript, and if, say, the garbage collector runs (due to making an allocation) while non-zeptoscript values that may be mistaken for zeptoscript heap allocations are in either the data stack or the return stack much hilarity may result.

Basic types

The basic types that you will encounter in zeptoscript are as follows:

  • Integrals (with null, small int, and big int subtypes)
  • Symbols
  • Doubles
  • References
  • Weak references (with single weak reference and weak pair subtypes)
  • Cell sequences
  • Byte sequences (with normal and constant subtypes)
  • Slices (with cell, normal byte, and constant byte subtypes)
  • Execution tokens (with normal and partially-applied, aka "closure", subtypes)
  • Word values
  • Objects
  • Classes
  • Saved states

All of these are included in the zscript module except for objects and classes, which are made available by compiling src/common/oo.fs and then executing zscript-oo import, and weak references, which are made available by compiling src/common/weak.fs and then executing zscript-weak import.

Composite types

On top of these the following composite types are implemented:

  • Booleans
  • Records
  • Lists
  • Maps
  • Sets
  • Queues
  • Bit sequences
  • S15.16 fixed-point numbers
  • S31.32 fixed-point numbers
  • Channels

These composite types do not have their own type constants and rather are a matter of how the core types are used.

Memory management

zeptoscript is garbage-collected, and makes use of Cheney's algorithm, a stop-and-copy semispace garbage collection algorithm. This means that only half of the heap can be used at a time. This also means that heap fragmentation is not an issue, whereas some other environments such as MicroPython have had problems with delayed failures due to running out of memory due to heap fragmentation, and that the time needed for garbage collection is proportional to the size of the working set rather than the size of the heap. It also means that the only data structures needed internally to manage memory are pointers to the top and bottom of the "to" and "from" spaces and "free" and "next" pointers into the "to" space.

Some related words are:

gc ( -- ), which runs the garbage collector.

heap-free ( -- bytes ), which returns the number of bytes free in the heap. Note that this specifically does not run the garbage collector, so if one wants to know how much space can still be allocated, run gc first.

Integrals

Integrals are simply 32-bit integers from the user's perspective, but internally null values are special-cased as their own type, "small ints" are integrals that can be unambiguously represented with 31 bits and stored in single cells, and "big ints" are integrals that require a full 32 bits to be represented and are "boxed" in the heap. A wide range of arithmetic, logical, and comparison operations on integrals are included in the default zscript module.

Symbols

Symbols are unique named values that have single global instances. They are created with symbol ( "name" -- ), which creates a symbol with the specified name. They always act as true values and equal themselves and nothing else. Their names can be gotten with symbol>name ( symbol -- name ), and they can be converted to unique integral values with symbol>integral ( symbol -- integral ).

Doubles

Doubles are boxed 64-bit integers that live in the heap. They have their own set of mathematical, logical, and comparison operations independent of those for integrals. Most operations on doubles are defined in src/common/double.fs and are imported with zscript-double import. These operations are akin to the zeptoforth double-cell operations except that doubles take up one cell rather than two cells on the stack.

Doubles may be converted to integral pairs with double>2integral ( d -- x0 x1 ). Likewise, integral pair may be converted to doubles with 2integral>double ( x0 x1 -- d ). These words are in the zscript module.

References

References are single-cell mutable values allocated in the heap that can contain any type of value. They are faster than sequences or slices to get and set because they require no indexing or bounds-checking.

To create a reference one applies >ref ( x -- ref ) to the value which it is to initially contain.

To get the current value of a reference one applies ref@ ( ref -- x ) to the reference.

To set the value of a reference one applies ref! ( x ref -- ) to the reference and the value it is to take.

An example of references in action is:

global my-counter

: counter
  0 >ref { my-count }
  [: my-counter! 0 ;] save
  my-count ref@ +
  my-count ref@ 1+ my-count ref!
;

after which executing the following gets the following results (after the .'s):

0 counter . 0  ok
0 my-counter@ execute . 1  ok
0 my-counter@ execute . 2  ok
0 my-counter@ execute . 3  ok

Weak references

Weak references are values that encapsulate references to values that do not protect those values from garbage collection. They also enable detecting when the referenced values have been garbage collected.

There are two types of weak reference value in zeptoforth, specifically single weak references and weak pairs. Single weak references are simply weak references, nothing more. Weak pairs, on the other hand, also have a strong reference, a.k.a. a "tail" because its purpose is to enable constructing linked lists of weak references.

The following words are in the zscript-weak module:

To construct a single weak reference one calls >weak ( x -- weak ) where x is the weakly referenced value.

To construct a weak pair one calls >weak-pair ( x y -- weak-pair ) where x is the weakly referenced value and y is the strongly referenced value.

To test whether a value is a weak reference one calls weak? ( x -- weak? ) with the value.

To test whether a value is a weak pair one calls weak-pair? ( x -- weak-pair? ) with the value.

To get the weakly referenced value of a weak reference one calls weak@ ( weak -- x ) with the weak reference. If the weak reference is broken broken-weak will be returned.

To set the weakly referenced value of a weak reference one calls weak! ( x weak -- ) where weak is the weak reference and x is the value to set it to. Note that if broken-weak is passed in for x weak-broken? will return false.

To test whether a weak reference is broken one calls weak-broken? ( weak -- broken? ) with the weak reference.

To get the strong reference of a weak pair one calls weak-pair-tail@ ( weak-pair -- x ) with the weak pair.

To set the strong reference of a weak pair one calls weak-pair-tail! ( x weak-pair -- ) where weak-pair is the weak pair and x is the value to set to its strong reference.

An example of weak references in action is:

zscript-weak import  ok
zscript-special-oo import  ok
global foo  ok
global bar  ok
#( 0 1 2 3 )# foo!  ok
foo@ >weak bar!  ok
foo@ show type #( 0 1 2 3 )# ok
bar@ weak@ show type #( 0 1 2 3 )# ok
gc  ok
bar@ weak@ show type #( 0 1 2 3 )# ok
bar@ weak-broken? . 0  ok
0 foo!  ok
gc  ok         
bar@ weak@ show type broken-weak ok
bar@ weak-broken? . -1  ok
#( 4 5 6 7 )# foo!  ok
foo@ bar@ weak!  ok
bar@ weak@ show type #( 4 5 6 7 )# ok

An example of weak pairs in action is:

zscript-weak import  ok
zscript-special-oo import  ok
global foo  ok
global bar  ok
#( 0 1 2 3 )# foo!  ok
foo@ #( 4 5 6 7 )# >weak-pair bar!  ok
foo@ show type #( 0 1 2 3 )# ok
bar@ weak@ show type #( 0 1 2 3 )# ok
bar@ weak-pair-tail@ show type #( 4 5 6 7 )# ok
gc  ok
bar@ weak@ show type #( 0 1 2 3 )# ok
bar@ weak-broken? . 0  ok
bar@ weak-pair-tail@ show type #( 4 5 6 7 )# ok
0 foo!  ok
gc  ok
bar@ weak@ show type broken-weak ok
bar@ weak-broken? . -1  ok
bar@ weak-pair-tail@ show type #( 4 5 6 7 )# ok
#( 8 9 10 11 )# bar@ weak!  ok
bar@ weak@ show type #( 8 9 10 11 )# ok
#( 12 13 14 15 )# bar@ weak-pair-tail!  ok
bar@ weak-pair-tail@ show type #( 12 13 14 15 )# ok

Sequences and slices

Cell sequences are arrays of values in the form of cells. They have no limitations on what they may contain. Note that they cannot be resized once created, and concatenating them requires creating a new cell sequence and copying the full contents of the original sequences; hence if repeated prepending of elements is desired lists are a better choice (appending can be achieved by prepending each element and then, once complete, reversing the list). However, cell sequences are more space-efficient than lists and can be accessed at any index in O(1) time, so once creation is complete it may be desirable to convert a list to a cell sequence with zscript-list::list>cells. On the other hand, if creating sub-sequences is needed, using cell sequences from which slices, which will be discussed below, are created is desirable.

Byte sequences are like cell sequences but are composed of bytes rather than of cells, and hence can only contain integral values from 0 to 255 (using them to represent UTF-8 is an exercise left to the reader). They are more space-efficient than cell sequences due to each element being a single byte. Also note that there is a special subtype of byte sequences known as constant byte sequences; instead of directly storing their contents as bytes, they are internally a pair of a pointer to their contents and a length. These are used for string literals compiled into code, and are immutable. Like cell sequences, slices can be created from byte sequences.

Slices are windows into cell sequences and byte sequences, consisting of a reference to their backing cell or byte sequence, an offset from the start of it, and a length. Slices are created with >slice ( offset length sequence -- slice ), which creates a slice of length starting from offset in sequence; they can also be created with truncate-start ( count sequence -- slice ), which creates a slice equal to sequence minus the first count elements, and truncate-end ( count sequence -- slice ), which creates a slice equal to sequence minus the last count elements. These are generally more efficient than creating a new cell or byte sequence and copying part of the original cell or byte sequence into it. Mutating a cell or byte sequence effectively modifies all slices that can "see" the mutated portion of the cell or byte sequence. Likewise, mutating a cell or byte slice modifies not only the backing cell or byte sequence, but all other slices that can "see" the mutated portion of the backing cell or byte sequence. Note that slices can be created from preexisting slices, but internally slices do not reference other slices they were created from.

Zeroed cell and byte sequences are created with make-cells ( count -- sequence ) and make-bytes ( count -- sequence ), respectively.

Cell and byte sequences can also be created with elements from the top of the stack with >cells ( x0 ... xn count -- sequence ) and >bytes ( c0 ... cn count -- sequence ).

Note that for convenience's sake there also is the syntax #( x0 ... xn )# for constructing cell sequences and the syntax #< c0 ... cn ># for constructing byte sequences.

Also, byte sequences may be created with the s" ... " string syntax or the s\" ... " escaped string syntax; note that when these are compiled they actually produce constant byte sequences as their contents are compiled inline to the code.

Additionally, two-element cell sequences can be constructed with >pair ( x0 x1 -- pair ), and three-element cell sequences can be constructed with >triple ( x0 x1 x2 -- triple ).

0cells ( -- sequence ) is a shared zero-element cell sequence. 0bytes ( -- sequence ) is a shared zero-element byte sequence.

Cell and byte sequences and slices can be exploded onto the stack with cells> ( sequence -- x0 ... xn count ) and bytes> ( sequence -- c0 ... cn count ), respectively. Additionally, two-element cell sequences and slices can be exploded with pair> ( pair -- x0 x1 ), and three-element cell sequences and slices can be exploded with triple> ( triple -- x0 x1 x2 ).

The number of elements, whether cells or bytes, in cell and byte sequences and slices can be gotten with >len ( sequence -- length ).

The words @+ ( index sequence -- element ) and !+ ( element index sequence -- ) are used to get and set elements of cell sequences and slices, respectively; note that index is zero-indexed.

The equivalent words c@+ ( index sequence -- element ) and c!+ ( element index sequence -- ) are used to get and set, respectively, bytes in byte sequences and slices.

There are also the words x@+ ( index sequence -- element ) and x!+ ( element index sequence -- ) which can access elements of cell sequences and slices and bytes of byte sequences and slices, but are slightly slower than using the more specialized words.

Additionally there are the words h@+ ( index sequence -- element ) and h!+ ( element index sequence -- ), for getting and setting, respectively, halfwords in byte sequences and slices, and w@+ ( index sequence -- element ) and w!+ ( element index sequence -- ), for getting and setting, respectively, cells in byte sequences and slices. Note that halfword accesses must be at indexes that are multiples of two and cell accesses must be at indexes that are multiples of four. Also note that these are little-endian with regard to byte order.

Cell and byte sequences and slices can be shallow-copied into new cell and byte sequences with duplicate ( sequence -- sequence' ).

Cell and byte sequences and slices can be iterated over with iter ( sequence xt -- ), where xt is ( element -- ), and iteri ( sequence xt -- ), where xt is ( element index -- ) and index is zero-indexed.

Cell and byte sequences and slices can be mapped into new cell and byte sequences with map ( sequence xt -- sequence' ), where xt is ( element -- element' ), and mapi ( sequence xt -- sequence' ), where xt is ( element index -- element' ) and index is zero-indexed. They may also be mapped in-place with map! ( sequence xt -- ), where xt is ( element -- element' ), and mapi! ( sequence xt -- ), where xt is ( element index -- element' ) and index is zero-indexed.

Cell and byte sequences and slices can be mapped into new cell and byte sequences with filter ( sequence xt -- sequence' ), where xt is ( element -- filter? ), and filteri ( sequence xt -- sequence' ), where xt is ( element index -- filter? ) and index is zero-indexed.

Cell and byte sequences and slices can be searched for the first instance of an element that meets a predicate with find-index ( sequence xt -- index found? ), where xt is ( element -- match? ) and index is zero-indexed, and find-indexi ( sequence xt -- index found? ), where xt is ( element index -- match? ) and both indexes are zero-indexed. Note that if found? is false then index will be 0.

Cell and byte sequences and slices can be left-folded over with foldl ( x sequence xt -- x' ), where xt is ( x element -- x' ), and foldli ( x sequence xt -- x' ), where xt is ( x element index -- x' ) and index is zero-indexed.

Cell and byte sequences and slices can be right-folded over with foldr ( x sequence xt -- x' ), where xt is ( element x -- x' ), and foldri ( x sequence xt -- x' ), where xt is ( element x index -- x' ) and index is zero-indexed.

A cell sequence of length elements can be collected from left to right with collectl-cells ( x length xt -- sequence ), where xt is ( x -- x' element ), or with collectli-cells ( x length xt -- sequence ), where xt is ( x index -- x' element ) and index is indexed from zero.

A cell sequence of length elements can be collected from right to left with collectr-cells ( x length xt -- sequence ), where xt is ( x -- x' element ), or with collectri-cells ( x length xt -- sequence ), where xt is ( x index -- x' element ) and index is indexed from zero.

A byte sequence of length elements can be collected from left to right with collectl-bytes ( x length xt -- sequence ), where xt is ( x -- x' element ), or with collectli-bytes ( x length xt -- sequence ), where xt is ( x index -- x' element ) and index is indexed from zero.

A byte sequence of length elements can be collected from right to left with collectr-bytes ( x length xt -- sequence ), where xt is ( x -- x' element ), or with collectri-bytes ( x length xt -- sequence ), where xt is ( x index -- x' element ) and index is indexed from zero.

Cell and byte sequences and slices can be reversed into new cell and byte sequences with reverse ( sequence -- sequence' ). They may also be reversed in-place with reverse! ( sequence -- )

Cell and byte sequences and slices can be sorted into new cell and byte sequences with sort ( sequence xt -- sequence' ), where xt is ( x0 x1 -- less-than? ). They may also be sorted in-place with sort! ( sequence xt -- ), where xt is ( x0 x1 -- less-than? ).

Pairs of cell or byte sequences and slices can be concatenated into new cell or byte sequences with concat ( sequence0 sequence1 -- sequence' ). Note that repeated concat operations to construct larger sequences from multiple smaller sequences is inefficient because a new larger sequence will need to be allocated and initialized and both the accumulated sequence and the appended sequence will need to be copied into it each time. Note that only cells or bytes may be concatenated at a time; they cannot be mixed.

Also, cell sequences or slices of cell or byte sequences or slices can be joined with intercalating cell or byte sequences into new cell or byte sequences with join ( sequence-sequence join-sequence -- sequence' ). In many use cases creating a sequence each sequence to be joined and then joining them all at once with join when one is done is much more efficient than individually concatenating pairs of sequences with concat. Note that only cells or bytes may be joined at a time; they cannot be mixed.

Sequences and slices can be split into cell sequences of subsequences at delimiters with split ( sequence xt -- sequence-sequence ) where xt is ( x -- delimiter? ) or spliti ( sequence xt -- sequence-sequence ) where xt is ( x index -- delimiter? ). Note that the delimiters are not included in the output sequence of subsequences. Also note that the subsequences are not slices, so they do not retain references to the original sequence.

Pairs of cell or byte sequences or slices can be zipped into new cell sequences of pairs with zip ( sequence0 sequence1 -- sequence' ). Note that the length of the resulting sequence is that of the shortest input. They may also be zipped in-place with zip! ( dest-sequence sequence1 -- ). Note that with zip! both sequences must be equal in length. Note that cell and byte sequences can be mixed.

Also, three cell or byte sequences or slices can be similarly zipped into a new cell sequence of triples with zip3 ( sequence0 sequence1 sequence2 -- sequence' ). Likewise, they may also be zipped in-place with zip3! ( dest-sequence sequence1 sequence2 -- ). Note that with zip3! all the sequences must be equal in length. Also note that cell and byte sequences can be mixed.

Cell and byte sequences and slices can be tested for whether a predicate meets any element with any ( sequence xt -- any? ), where xt is ( element -- match? ), or with anyi ( sequence xt -- any? ), where xt is ( element index -- match? ) and index is indexed from zero. false will be returned if sequence is empty.

Cell and byte sequences and slices can be tested for whether a predicate meets all elements with all ( sequence xt -- all? ), where xt is ( element -- match? ), or with alli ( sequence xt -- all? ), where xt is ( element index -- match? ) and index is indexed from zero. true will be returned if sequence is empty.

Constant byte sequences can be statically defined with begin-const-bytes ( "name" -- start-address name ) followed by any number of , ( x -- ), to write a cell to the constant byte sequence being defined; h, ( h -- ), to write a halfword to the constant byte sequence being defined; c, ( c -- ), to write a byte to the constant byte sequence being defined; and align, ( size -- ), to align the address being written to a size power of two; finished by end-constant-bytes ( start-address name -- ). These constant byte sequences live in the dictionary and take up no space in the heap.

An example of cell sequences in action is as follows:

#( 6 1 4 3 9 8 10 2 )# ' 2* map [: swap >pair ;] mapi [: { a b } 1 a @+ 1 b @+ < ;] sort [: pair> swap . . ;] iter

which outputs:

1 2 7 4 3 6 2 8 0 12 5 16 4 18 6 20  ok

An example of byte sequences in action is as follows:

#( s" foo" s" bar" s" baz" )# s" *" join [: dup [char] a >= over [char] z <= and if [char] A + [char] a - then ;] map type

which outputs:

FOO*BAR*BAZ ok

An example of constant byte sequences in action is as follows:

begin-const-bytes foo
  $80 c, $81 c, $82 c, $83 c,
end-const-bytes

foo ' . iter

which outputs:

128 129 130 131  ok

Execution tokens and partial application

Execution tokens refer to words and quotations and may be, as one would expected, executed. They have two subtypes, "normal" execution tokens and partially-applied execution tokens, where values are bound onto an execution token with bind which are pushed onto the stack when they are executed prior to the underlying word or quotation being executed. Note that partially-applied execution tokens are also referred to as "closures", as they fit a similar role to closures in other languages, but whether this is apt is up to the user to decide.

Execution tokens are gotten through executing ' ( runtime: "name" -- xt ), ['] ( compile-time: "name" -- runtime: -- xt ), [: ... ;] ( -- xt ), and word>xt ( word -- xt ).

Execution tokens are partially applied with bind ( xn ... x0 count xt -- xt' ). Note that partially-applied execution tokens can be partially applied again any number of times; e.g. 3 2 2 ' zscript-double::*/ bind is equivalent to 3 1 2 1 ' zscript-double::*/ bind bind.

Execution tokens are executed with execute ( xt -- ), ?execute ( xt | 0 -- ), try ( xt -- 0 | exception ), and by a variety of higher-order words such as iter and map.

Note that while partially-applied execution tokens may be used in many places, they cannot be used in all places. In particular they cannot be used as exceptions or for foreign hooks.

An example of partial application in action is as follows:

: make-adder ( n -- xt ) 1 ['] + bind ;
#( 4 6 1 3 9 2 7 5 )# 10 make-adder map ' . iter

which outputs:

14 16 11 13 19 12 17 15  ok

Word values

Word values encapsulate the identity of a word in the dictionary. Note that they are distinct from execution tokens, and must be converted to execution tokens before they may be executed.

Word values may be obtained from searching the current namespace with find ( name -- word | 0 ), which attempts to find a word in the current namespace and either returns it, or if no word was found, returns 0.

Word values may be obtained from searching an entire dictionary (i.e. the RAM or flash dictionaries) with find-all-dict ( name word -- word' | 0 ), which attempts to find a word in all modules starting from word in a given dictionary and returns it, or if no word was found, returns 0.

Also, most recently compiled words can be obtained with flash-latest ( -- word ), which returns the most recently compiled word in the flash dictionary, with ram-latest ( -- word ), which returns the most recently compiled word in the RAM dictionary, and with latest ( -- word ), which returns the most recently compiled word.

Word values may be converted to execution tokens with word>xt ( word -- xt ).

The names of word values can be obtained with word>name ( word -- name ).

The flags of word values can be obtained with word>flags ( word -- flags ).

Saved states

Saved states encapsulate the current state of the data and return stacks, which can be returned to any number of times. They are made available by the zscript module.

A saved state is gotten through calling save ( xt -- x ), where xt is ( save -- x ). This calls the provided execution token and passes in the saved state of the call to save as an argument. Then, when xt returns, save returns what is returned by xt.

Note that the saved state can be executed with execute or ?execute, or words that use those internally. In that case it takes the element on the top of the data stack, returns the data and return stacks to the state captured by the saved state, places that element on the top of the data stack, and returns from the save call that created the saved state.

Lazy evaluation

Lazy evaluation is carried out by creating execution tokens that serve as thunks, and then forcing them with force ( xt | force -- x ). When force is called it transparently replaces the original execution token with a value that encapsulates the value gotten from forcing the thunk, and this value will be returned with subsequent calls to force against that value.

Modules

zeptoscript has modules like those of zeptoforth. They are internally built on top of Forth wordlists, but those are not readily exposed to the user due to being less user-friendly and more error-prone to work with than zeptoscript/zeptoforth modules.

Importing a module into the current namespace is done through executing import ( module -- ).

Removing a module from the current namespace is done through executing unimport ( module -- ).

Defining a new named module and creating a new namespace within it is done through executing begin-module ( "name" -- ).

Defining a new anonymous module and creating a new namespace within it is done through executing private-module ( -- ).

Ending a module and dropping its namespace is done through executing end-module ( -- ).

Ending a module and dropping its namespace while pushing the module onto the stack is done through executing end-module> ( -- module ).

Note that one can access word that are not in the current namespace without pulling in their containing modules. This is done through referencing it through a :: path, as in foo::bar::baz for identifying the word baz in the module bar in the module foo, which is in the current namespace.

Constants

Constants are defined with constant ( integral|double|byte-sequence "name" -- ), which creates a word which pushes an integral, double, or byte sequence when executed. Note that constants may only be integrals, doubles, or byte sequences. It is also recommended that if one wants an arbitrary unique constant value which does not have any "meaning" associated with its value that one use a symbol instead.

Local variables

Locals are defined with the syntax { localn ... local0 [ -- comment ] }, where local0 is taken off the top of the stack and localn is the bottom-most value taken off the stack. -- optionally marks a comment, e.g. when one is using local variables definitions as a stack signature.

After they are defined, locals' values may be gotten through simply naming them.

The values of locals can be set with to ( x "name" -- ) or added to with +to ( x "name" -- ).

Note that locals are block-scoped, and redefinitions of locals in inner blocks will shadow definitions of locals in outer blocks.

An example of locals in use is as follows:

: foobar { foo bar baz -- foo' }
  foo .
  foo bar * to foo
  baz +to foo
  foo .
  begin
    16 { foo }
    foo .
  end
  foo 1+
;

The following shows this in action:

1 3 -1 foobar . 1 2 16 3  ok

Global variables

Globals are created by executing the word global ( "name" -- ), which creates two words, a getter and a setter. For instance, global foo creates the getter foo@ ( -- foo ) and the setter foo! ( foo -- ).

Globals are best avoided when possible but are often the main practical option when working at the REPL. Note that globals permanently take up room in the heap.

Another note is that class members, which will be mentioned later, are internally implemented as globals, but through module magic are only visible within the definition of a class they are defined in.

Exceptions

zeptoscript inherits from zeptoforth its exception-handling system and adds some exceptions of its own. Exceptions themselves are simply non-partially-applied execution tokens which are executed if uncaught.

Exceptions are caught through executing execution tokens with try ( xt -- 0 | exception ), which returns either 0, if no exception was raised, or the raised exception.

Exceptions are raised with ?raise ( 0 | exception -- ), which raises a provided exception unless 0 is provided, with averts ( flag "exception" -- ), which raises the named exception if flag is false, and with triggers ( flag "exception" -- ), which raises the named exception if flag is true.

Booleans

Booleans are simply the integrals 0 (for false) and -1 (for true), and incidentally are identical representation-wise to their zeptoforth counterparts. These are included in the zscript module.

Records

Records are syntactic sugar on top of cell sequences, and can be used interchangeable with them and with cell slices, with the only restriction being that the proper number of elements are available. They are more convenient than if one had to directly use make-cells, >cells, cells>, @+, or !+ to access cell sequences. These are included in the zscript module.

Records are defined by executing the word begin-record ( "name" -- ), such as in begin-record foo, specifying their elements, and then executing end-record ( -- ), which defines a word (e.g. make-foo) with the signature ( -- record ) for creating a zeroed record, a word (e.g. >foo) with the signature ( x0 ... xn -- record ) for creating a record from elements taken off the top of the stack in the order of the elements of the record, a word (e.g. foo>) with the signature ( record -- x0 ... xn ) for placing the elements of a record on the top of the stack in the same order, and a word (e.g. foo-size) with the signature ( record -- size ) for placing the number of elements in the record on the stack.

Elements of a record are defined by executing the word item: ( "name" -- ), such as in item: foo, which defines a word (e.g. foo@) with the signature ( record -- x ) for getting the value of the element in the record, and a word (e.g. foo!) with the signature ( x record -- ) for setting the value of the element in the record.

An example of a record definition is as follows:

begin-record foo
  item: foo-x
  item: foo-y
end-record

Lists

Lists are simply singly-linked lists of pair cell sequences, ending in the value 0. Each pair's first element is the list's head and each pair's second element is the list's tail. There are a number of words that make them more user-friendly in the zscript-list module defined in src/common/list.fs, but in many cases pulling this in is not necessary (e.g. zscript-list::cons is actually the same as >pair).

Empty list is empty ( -- list ). Note that this is 0, so conditionals treat it as false (which it is identical to). Empty list can be tested for with empty? ( list -- empty? ).

The head of a list can be gotten with head@ ( list -- head ), and the tail of a list can be gotten with tail@ ( list -- tail ). Note that these operations are not valid for empty list, so one must test for empty list first.

The head of a list be set with head! ( head list -- ), and the tail of a list can be set with tail! ( tail list -- ) respectively. Note that these operations are not valid for empty list, so one must test for empty list first. Take care when using these operations because, as mutating operations on lists, they mutate all lists that share the mutated pairs in question.

The nth element of a list can be gotten with nth ( index list -- element ), where index is indexed from zero.

The nth tail of a list can be gotten with nth-tail ( index list -- tail ), where an index of zero returns the entire list, and an index past the end the list returns empty list.

The last element of a list can be gotten with last ( list -- element ).

The length of a list can be gotten with list>len ( list -- length ).

A list can be converted to a cell sequence with list>cells ( list -- sequence ). A list can be converted to a byte sequence with list>bytes ( list -- sequence ).

A cell or byte sequence can be converted to a list with seq>list ( sequence -- list ).

A list can be converted to a cell sequence in reverse order with rev-list>cells ( list -- sequence ).

A list can be converted to a byte sequence in reverse order with rev-list>bytes ( list -- sequence ).

A list can be created from elements on the stack in order with >list ( x0 ... xn count -- list ). A list can be created from elements on the stack in reverse order with >rev-list ( xn ... x0 count -- list ).

A list can be created from elements on the stack in order with #[ ( -- ) with elements being placed on the stack afterwards, followed by ]# ( -- list ). Note that this is less efficient than >list, which it uses internally, but removes the need to count the number of elements to put in the list by hand.

A list can be exploded into elements on the stack with list> ( list -- x0 ... xn count ).

A list can be shallow-copied to create a new list in the same order with duplicate-list ( list -- list' ). A list can be reversed to create a new list with rev-list ( list -- list' ).

A list can be iterated over with iter-list ( list xt -- ), where xt is ( element -- ), or with iteri-list ( list xt -- ), where xt is ( element index -- ) and index is indexed from zero.

A list can be mapped over to create a new list with map-list ( list xt -- list' ), where xt is ( element -- element' ), or with mapi-list ( list xt -- list' ), where xt is ( element index -- element' ) and index is indexed from zero.

A list can be mapped over to modify the original list in place with map!-list ( list xt -- ), where xt is ( element -- element' ), or with mapi!-list ( list xt -- ), where xt is ( element index -- element' ) and index is indexed from zero.

A list can be mapped over to create a reversed new list with rev-map-list ( list xt -- list' ), where xt is ( element -- element' ), or with rev-mapi-list ( list xt -- list' ), where xt is ( element index -- element' ) and index is indexed from zero.

A list can be filtered to create a new list with filter-list ( list xt -- list' ), where xt is ( element -- filter? ), or with filteri-list ( list xt -- list' ), where xt is ( element index -- filter? ) and index is indexed from zero.

A list can be filtered to create a new list in reverse order with rev-filter-list ( list xt -- list' ), where xt is ( element -- filter? ), or with rev-filteri-list ( list xt list' ), where xt is ( element index -- filter? ) and index is indexed from zero.

The index of the left-most element of a list matching a predicate can be found with find-index-list ( list xt -- index found? ), where xt is ( element -- match? ) and index is zero-indexed, or with find-indexi-list ( list xt -- index found? ), where xt is ( element index -- match? ) and both indexes are zero-indexed. If there is no match, the index returned is 0.

A list can be left-folded over with foldl-list ( x list xt -- x' ), where xt is ( x element -- x' ), or with foldli-list ( x list xt -- x' ), where xt is ( x *element index -- x' ) and index is indexed from zero.

A list can be right-folded over with foldr-list ( x list xt -- x' ), where xt is ( element x -- x' ), or with foldri-list ( x list xt -- x' ), where xt is ( element x index -- x' ) and index is indexed from zero; note that right-folds are less efficient left-folds because the list must be converted to a cell sequence prior to folding it.

A list of length elements can be collected from left to right with collectl-list ( x length xt -- list ), where xt is ( x -- x' element ), or with collectli-list ( x length xt -- list ), where xt is ( x index -- x' element ) and index is indexed from zero.

A list of length elements can be collected from right to left with collectr-list ( x length xt -- list ), where xt is ( x -- x' element ), or with collectri-list ( x length xt -- list ), where xt is ( x index -- x' element ) and index is indexed from zero.

A list can be sorted to create a new list with sort-list ( list xt -- list' ), where xt is ( element0 element1 -- less-than? ).

A list can be tested as to whether a predicate applies to all elements with all-list ( list xt -- all? ), where xt is ( element -- match? ), or with alli-list ( list xt -- all? ), where xt is ( element index -- match? ) and index is indexed from zero; note that true will be returned for an empty list.

A list can be tested as to whether a predicate applies to any element with any-list ( list xt -- any? ), where xt is ( element -- match? ), or with anyi-list ( list xt -- any? ), where xt is ( element index -- match? ) and index is indexed from zero; note that false will be returned for an empty list.

List can be split into lists of sublist at delimiters with split-list ( list xt -- list-list ) where xt is ( element -- delimiter? ) or spliti-list ( list xt -- list-list ) where xt is ( element index -- delimiter? ). Note that the delimiters are not included in the output list of lists.

An example of lists in action is:

#( 0 1 2 )# reverse seq>list 3 swap cons 4 swap cons 5 swap cons ' 2* rev-map-list ' . iter-list

which outputs:

0 2 4 6 8 10  ok

There are also the convenience words #[ ]# for constructing lists from elements on the stack, as seen here:

#[ 0 1 2 3 ]# ' 1+ map-list ' . iter-list

which outputs:

1 2 3 4  ok

Maps

Maps are records which wrap underlying cell sequences which constitute unordered key-value associative arrays. They are implemented as hash tables, and must be initialized with a default size in elements, a hash function, and an equality functions. These are made available by compiling src/common/map.fs and then executing zscript-map import.

Maps are created with make-map ( init-size hash-xt equal-xt -- map ), where init-size is the initial size of the map in elements, hash-xt is a hash function of the signature ( key -- hash ), and equal-xt is an equality function of the signature ( key0 key1 -- equal? ). Note that while maps will be automatically expanded as needed, it is a good idea to give them a reasonable initial size to avoid having to reallocate the map and regenerate its keys' hashes and to reduce the chances of collisions.

There is also >generic-map ( keyn valuen ... key0 value0 count -- map ) for constructing generic maps, i.e. maps where the hash function is provided by zscript-special-oo::hash and the equality function is provided by zscript-special-oo::equal? provided src/common/special_oo.fs was compiled after src/common/map.fs. count is the number of key-value pairs. The created map is sized to the number of key-value pairs specified, and will be resized if any key-value pairs are added afterwards.

Key-value pairs are inserted into maps with insert-map ( value key map -- ). If there is already an entry of with key its value will be replaced.

Key-value pairs are removed from maps with remove-map ( key map -- ). Note that maps are never shrunken, even when all the keys have been removed.

Values corresponding to keys are found in maps with find-map ( key map -- value found? ). If found? is false, then value will be 0. Keys can also be tested for without returning a value with in-map? ( key map -- found? ).

Maps can be shallow-copied with duplicate-map ( map -- map' ).

All the keys in a map can be copied into a cell sequence with map>keys ( map -- keys ). All the values in a map can be copied into a cell sequence with map>values ( map -- values ). All the key-value pairs in a map can be copied into a cell sequence of pairs with map>key-values ( map -- pairs ).

All the key-value pairs in a map can be iterated over in an unordered fashion with iter-map ( map xt -- ), where xt is ( value key -- ).

A map can be mapped to a new map in an unordered fashion with map-map ( map xt -- map' ), where xt is ( value key -- value' ). A map can be mapped in place in an unordered fashion with map!-map ( map xt -- ), where xt is ( value key -- value' ).

A map can be tested for whether all key-value pairs meet a predicate with all-map ( map xt -- all? ), where xt is ( value key -- match? ); note that if the map is empty true is returned.

A map can be tested for whether any key-value pair meets a predicate with any-map ( map xt -- any? ), where xt is ( value key -- match? ); note that if the map is empty false is returned.

An example of maps in action is:

global my-map
16 ' hash-bytes ' equal-bytes? make-map my-map!
0 s" foo" my-map@ insert-map
16 s" bar" my-map@ insert-map
256 s" baz" my-map@ insert-map
65536 s" quux" my-map@ insert-map
s" foo" my-map@ remove-map
my-map@ map>key-values [: pair> swap type space . ;] iter

which outputs:

bar 16 quux 65536 baz 256  ok

There are also convenience words #{ and }# for constructing generic maps provided src/common/special_oo.fs was compiled after src/common/map.fs. These words bracket pairs of keys and values, with keys preceding values, and place the corresponding generic map, sized to the number of entries specified, on the stack. These is can be seen here:

#{ s" foo" 0 s" bar" 1 s" baz" 2 s" quux" 3 }# [: type space . ;] iter-map

which outputs:

baz 2 foo 0 bar 1 quux 3  ok

Sets

Sets are like maps, but instead of being key-value associative arrays rather are unordered arrays of unique values. These are made available by compiling src/common/set.fs and then executing zscript-set import.

Sets are created with make-set ( init-size hash-xt equal-xt -- set ), where init-size is the initial size of the set in elements, hash-xt is a hash function of the signature ( value -- hash ), and equal-xt is an equality function of the signature ( value0 value1 -- equal? ). Note that while sets will be automatically expanded as needed, it is a good idea to give them a reasonable initial size to avoid having to reallocate the set and regenerate its values' hashes and to reduce the chances of collisions.

There is also >generic-set ( valuen ... value0 count -- set ) for constructing generic sets, i.e. sets where the hash function is provided by zscript-special-oo::hash and the equality function is provided by zscript-special-oo::equal? provided src/common/special_oo.fs was compiled after src/common/set.fs. count is the number of elements of the set specified on the stack. The resulting set is sized to the number of elements specified, and will be resized if any more elements are added.

Values are inserted into sets with insert-set ( value set -- ). If there is already an entry of with value its value will be replaced.

Values are removed from sets with remove-set ( value set -- ). Note that sets are never shrunken, even when all the values have been removed.

Values can be tested for without returning a value with in-set? ( value set -- found? ).

Sets can be shallow-copied with duplicate-set ( set -- set' ).

All the values in a set can be copied into a cell sequence with set>values ( set -- values ).

All the values in a set can be iterated over in an unordered fashion with iter-set ( set xt -- ), where xt is ( value -- ).

A set can be tested for whether all values meet a predicate with all-set ( set xt -- all? ), where xt is ( value -- match? ); note that if the set is empty true is returned.

A set can be tested for whether any value meets a predicate with any-set ( set xt -- any? ), where xt is ( value -- match? ); note that if the set is empty false is returned.

An example of sets in action is as follows:

global my-set
symbol foo
symbol bar
symbol baz
16 ' symbol>integral ' = make-set my-set!
foo my-set@ insert-set
bar my-set@ insert-set
baz my-set@ insert-set
my-set@ [: symbol>name type space ;] iter-set

which outputs:

foo bar baz  ok

There are also convenience words #| and |# for constructing generic sets provided src/common/special_oo.fs was compiled after src/common/set.fs. These words bracket values, and place the corresponding generic set, sized to the number of entries specified, on the stack. These is can be seen here:

#| s" foo" s" bar" s" baz" s" quux" |# [: type space ;] iter-set

which outputs:

baz foo bar quux  ok

Queues

Queues are simple first-in-first-out queues, which are utilized by the multitasker and message channels. These are made available by compiling src/common/queue.fs and then executing zscript-queue import. Note that src/common/list.fs must be loaded beforehand.

An empty queue can be constructed with make-queue ( -- queue ).

A queue can be tested for emptiness with queue-empty? ( queue -- *empty? ).

The number of elements in a queue can be gotten with queue-size ( queue -- count ).

An element can be added onto the end of a queue with enqueue ( element queue -- ).

An element can be removed from the start of a queue with dequeue ( queue -- element success? ); if queue was not empty success? is returned as true, otherwise success? is returned as false and element is returned as 0.

An element can be peeked, i.e. read but not dequeed, from the start of a queue with peek-queue ( queue -- element success? ); if queue was not empty success? is returned as true, otherwise success? is returned as false and element is returned as 0.

Bit sequences

Bit sequences are implemented on top of byte sequences, and provide a more efficient way of storing multiple binary values than cell sequences or byte sequence of true or false values. These are included in the zscript module.

Bit sequences can be created with make-bits ( length -- bits ).

The length of bit sequences can be gotten with bits>len ( bits -- length ).

A bit in a bit sequence can be gotten with bit@ ( index bits -- bit ) and index is indexed from zero.

A bit in a bit sequence can be set with bit! ( bit index bits -- ) and index is indexed from zero.

An example of bit sequences in action is:

global my-bits
16 make-bits my-bits!
true 4 my-bits@ bit!
false 8 my-bits@ bit!
4 my-bits@ bit@ . 8 my-bits bit@ .

which outputs:

-1 0  ok

S15.16 fixed-point numbers

S15.16 fixed-point numbers are signed fixed-point numbers with 15 bits to the left of the decimal point and 16 bits to the right of the decimal point implemented on top of integrals that range from -32767;99998 to 32767;99998. Note that with S15.16 fixed-point numbers the decimal point is ;. These are made available by first compiling in zeptoforth extra/common/fixed32.fs from the zeptoforth source code, then compiling in zeptoscript src/common/fixed32.fs, and then executing zscript-fixed32 import.

S31.32 fixed-point numbers

S31.32 fixed-point numbers are signed fixed-point numbers with 31 bits to the left of the decimal point and 32 bits to the right of the decimal point implemented on top of double-cell values that range from -2147483647,9999999997 to 2147483647,9999999997. Note that with S31.32 fixed-point numbers the decimal point is ,. These are made available by compiling src/common/double.fs and then executing zscript-double import.

Objects and classes

Objects and classes are made available by compiling src/common/oo.fs and executing zscript-oo import.

Objects are simply objects in the object system. The are instantiated from classes and have methods and members. Methods are declared outside of any given class and any object can have any set of methods, whereas members are local to a class and can only be accessed within said class's definition. Declaring a member creates accessor words such that member foo as a getter foo@ and a setter foo!.

Classes underlie objects, with each object being an instance of a class, and methods and members being defined within a given class definition. Note that in addition to ordinary object members there are also class members. However, classes are not directly accessible to the user, and only directly manifest themselves from the user's perspective in that a class foo creates an instantiation word make-foo, which when called creates an instance of foo and, if it is implemented, sends that instance new, and in that members can only be accessed for instances of a class for which they were declared.

Note that there is no concept of inheritance in zeptoscript's OO layer and rather zeptoscript relies on duck typing and composition. Duck typing is made possible by classes being able to implement any combination of methods to which late binding is applied on top of dynamic typing.

Methods are declared with method ( "name" -- ), typically outside of any class definition. To the outside world they appear to be normal words, except that they happen to dispatch their implementation based on the class of their topmost argument, which must be an object of a class which implements the method in question.

Classes are defined with begin-class ( "name" -- ) and ended with end-class ( -- ).

Between these, methods are defined with :method ( "name" -- ), which is ended with ; ( -- ); note that said methods must have already been declared with method.

Also, within class definitions, object members are defined with member: ( "name" -- ). Note that these define accessor words of the form of a getter named name@ ( object -- x ) and a setter named name! ( x object -- ) which are only accessible within the class definition; e.g. class-member: foo defines foo@ and foo!.

Class members, which are like globals but are only accessible within class definitions are defined with class-member: ( "name" -- ). Note that these define accessor words of the form of a getter named name@ ( -- x ) and a setter named name! ( x -- ) which are only accessible within the class definition; e.g. class-member: foo defines foo@ and foo!.

Private words accessible only within class definitions are defined with :private ( "name" -- ), which is ended with ; ( -- ).

Objects can be tested for whether they have methods with has-method? ( xt object -- has-method? ), which returns true if the method represented by xt is implemented by the class of object, and otherwise false.

A class object can be obtained through calling class@ ( object -- class ); this is of primary use for testing whether two objects have the same class, as class objects can be compared.

Last but not least, there exists a "type class" (not to be confused with Haskell type classes) mechanism where classes can be created for arbitrary types. These classes are defined with begin-type-class ( type -- ) rather than the usual begin-class. Note that these classes do not have make-class words, cannot contain instance members, and have no constructors.

An example of object-orientation in action is

zscript-oo import

method next-value ( counter -- next-value )

begin-class inc-counter
  member: counter
  
  :method new { init-counter self -- }
    init-counter self counter!
  ;
  
  :method next-value { self -- counter }
    self counter@ dup 1+ self counter!
  ;
end-class

begin-class dec-counter
  member: counter
  
  :method new { init-counter self -- }
    init-counter self counter!
  ;
  
  :method next-value { self -- counter }
    self counter@ 1- dup self counter!
  ;
end-class

method add-counter ( counter group -- )
method next-values ( group -- next-values )

begin-class counter-group
  member: counters
  
  :method new { self -- }
    0cells self counters!
  ;

  :method add-counter { counter self -- }
    self counters@ counter 1 >cells concat self counters!
  ;
  
  :method next-values { self -- next-values }
    self counters@ ['] next-value map
  ;
end-class

: test ( -- )
  make-counter-group { group }
  0 make-inc-counter group add-counter
  0 make-dec-counter group add-counter
  16 make-inc-counter group add-counter
  -16 make-dec-counter group add-counter
  256 make-inc-counter group add-counter
  -256 make-dec-counter group add-counter
  group next-values ['] . iter
  group next-values ['] . iter
  group next-values ['] . iter
;

Executing test gives:

test 0 -1 16 -17 256 -257 1 -2 17 -18 257 -258 2 -3 18 -19 258 -259  ok

Special object-orientation words

Optionally a "special object-orientation" source file at src/common/special_oo.fs, which defines a module zscript-special-oo, and be loaded. It provides the following words for all built-in types:

show ( object -- bytes ) converts object to a human-readable byte sequence.

hash ( object -- hash ) converts object to an integral hash value.

equal? ( object1 object0 -- equal? ) tests whether object1 and object0 have equal values.

try-show ( object -- bytes ) is like show except that it tests whether a show method is available and calls it if it is, and otherwise returns a default human-readable byte sequence.

try-hash ( object -- hash ) is like hash except that it tests whether a hash method is available and calls it if it is, and otherwise returns a default hash value (zero to be exact).

try-equal? ( object1 object0 -- equal? ) is like equal? except that it tests whether object0 has an equal? method available and calls it if it is, and otherwise compares the addresses of object1 and object0.

Note that all of these words are applied recursively, to take care to not overflow the stacks when applying these to deep data structures, e.g. lists of any size.

Multitasking and message channels

There is optional support for cooperative multitasking, in the form of symmetric coroutines, asymmetric coroutines, and message channels. Note that this is distinct from zeptoforth multitasking; all zeptoscript multitasking is cooperative rather than preemptive, round-robin in the case of symmetric coroutines rather than priority-scheduled, and takes place within a single zeptoforth task. Also, zeptoscript tasks all share the same physical data and return stacks in memory; when they are not actively executing their stack states are captured by saved states.

Message channels are queue channels with a fixed maximum size. When a task attempts to send a message on a full message channel it will block until a task receives a message from the message channel, freeing up space to enqueue is message; if the message channel was empty and there is a blocked receiving task it will wake up that task. Conversely, when a task attempts to receive a message on an empty message channel it will block until a task sends a message on the message channel, providing it a message to receive; if the message channel was full and there is a blocked sending task it will wake up that task.

Cooperative multitasking via symmetric coroutines is made available by loading src/common/task.fs and executing zscript-task import afterwards. Asymmetric coroutines are made available by loading src/common/coroutine.fs and executing zscript-coroutine import. Message channels are made available by loading src/common/channel.fs and executing zscript-chan import afterwards; note that src/common/task.fs must be loaded beforehand. Both symmetric coroutines and message channels are dependent upon src/common/queue.fs being loaded beforehand, and thus upon src/common/list.fs being loaded before that.

Note that there is no "task" type. Rather inactive tasks consist of saved states and execution tokens, and the active task is simply the current execution context. However, there is a coroutine type for asymmetric coroutines.

Multitasking

These words are defined in the zscript-task module.

A new task can be spawned with spawn ( xt -- ).

The current task can be yielded to the next task to execute with yield ( -- ). Note that if there are no other tasks to execute this will return immediately.

The next ready task can be executed without rescheduling the current task with terminate ( -- ). Note that if there is no next ready task this will return.

The current task can be forked into two tasks with fork ( -- parent? ). This immediately returns true to the parent, and schedules a child task to execute which, when executed, will return control to the point where this was called but false will be returned.

The scheduled tasks can be started with start ( -- ). Note that if there is one or more scheduled tasks this will never return, or otherwise it will return immediately.

A task in a queue can be dequeued from that queue and scheduled to execute with wake ( queue -- ).

The current task can be enqueued in a queue and control can be passed to the next scheduled task without rescheduling the current task with block ( queue -- ). Be aware that if there are no scheduled tasks this will instead return immediately without enqueuing the current task.

The current task can wait for a specified delay in ticks after a start-time in ticks with wait-delay ( start-time delay -- ). In the mean time control will be yielded to the other scheduled tasks.

The current task can wait for a specified number of milliseconds with ms ( milliseconds -- ).

Each task has task-local storage, which can be gotten with task-local@ ( -- task-local ) and set with task-local! ( task-local -- ). Note that a new task inherits its task-local storage from its parent task.

An example of multitasking in action is:

zscript-task import

: test ( -- )
  fork not if begin ." * " 500 ms again then
  fork not if begin ." + " 125 ms again then
;

test start

This outputs:

* + + + + * + + + + * + + + + * + + + + * + + + +

and so on.

Asymmetric coroutines

Asymmetric coroutines are created with make-coroutine ( xt -- coroutine ), which creates a new coroutine in a suspended state that will execute xt when it is resumed for the first time. xt will be passed an argument that is passed to resume the first time it is resumed. Once xt returns the final return value of the coroutine is returned by the last call to resume which invoked it and then it is in a dead state. Coroutines inherit their coroutine-local state from their parent coroutine or, if not created by another coroutine, from the global non-coroutine-local state.

Asymmetric coroutines are resumed with resume ( x coroutine -- x' ). This resumes coroutine, passing control to the coroutine, such that x is returned by the last time it called suspend, or is passed to the coroutine's execution token the first time it is run. This in turn returns the x' passed in to suspend the next time it is called, or which is returned by the coroutine when it terminates. Note that x-running-coroutine will be raised if resume is called on a coroutine which is already in a running state, and x-dead-coroutine will be raised if resume is called on a coroutine which is in a dead state.

Asymmetric coroutines are suspended with suspend ( x -- x' ). This suspends the current coroutine, returning control to the coroutine which last resumed it, such that x is returned by the last time resume was called on it, and returns the x' passed in the next time resume is called on it. Note that x-not-in-coroutine will be raised if suspend is called outside of a coroutine.

The state of a coroutine can be gotten with coroutine-state@ ( coroutine -- state ), which is one of suspended, running, or dead.

The current coroutine can be called by calling current-coroutine ( -- coroutine ).

The current coroutine-local state (or outside of a coroutine, non-coroutine-local state) is gotten with coroutine-local@ ( -- x ) and is set with coroutine-local! ( x -- ).

An example of asymmetric coroutines in action, to generate and print a Fibonacci sequence is:

zscript-coroutine import

: fibonacci-coroutine ( -- coroutine )
  [:
    drop
    0 1 { x y }
    x suspend drop
    y suspend drop
    begin
      x y +
      y to x
      to y
      y suspend drop
    again
  ;] make-coroutine
;

: run-test ( -- )
  fibonacci-coroutine { co }
  25 0 ?do 0 co resume . loop
;

Executing run-test outputs:

0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597 2584 4181 6765 10946 17711 28657 46368  ok

Message channels

This words are defined in the zscript-chan module.

A message channel can be constructed with a size maximum element count with make-chan ( size -- channel ).

A message can be sent to a message channel with send ( message channel -- ). If the message channel is full, block until a task receives a message from the message channel. If the message channel is empty, and a task is blocked on receiving a message from the message channel, wake the blocked task.

A message can be sent to a message channel in a non-blocking fashion with send-non-block ( message channel -- success? ). If the message channel was not full, return true, else return false.

A message can be received from a message channel with recv ( channel -- message ). If the message channel is empty, block until a task sends a message to the message channel. If the message channel is full, and a task is blocked on sending a message to the message channel, wake the blocked task.

A message can be received from a message channel in a non-blocking fashion with recv-non-block ( channel -- message success? ). If the message channel was not empty, return the message and true, else return 0 and false. If the message channel is full, and a task is blocked on sending a message to the message channel, wake the blocked task.

A message can be peeked, i.e. read but not dequeued, from a message channel with peek ( channel -- message ). If the message channel is empty, block until a task sends a message to the message channel.

A message can be peeked, i.e. read but not dequeued, in a non-blocking fashion from a message channel with peek-non-block ( channel -- message success? ). If the message channel is not empty, return the message and true, else return 0 and false.

An example of message channels in action is:

zscript-task import
zscript-chan import

16 constant chan-size

: test ( -- )
  chan-size make-chan { my-chan }
  fork not if 0 begin dup my-chan send 1+ 125 ms again then
  fork not if 0 begin 1- dup my-chan send 250 ms again then
  fork not if begin my-chan recv . again then
;

test start

Which outputs:

0 -1 1 -2 2 3 -3 4 5 -4 6 7 -5 8 9 -6 10 11 -7 12 13 -8 14 15 -9 16 17 -10 18 19 

and so on.

Foreign words

All words foreign to zeptoscript are accessed through the forth and internal modules. Directly doing so is done as part of the implementation of zeptoscript at a low level, but is not recommended for general purpose development, as most of these words which take arguments and return values are distinctly unsafe in nature, and if not used with care can result in undefined behavior.

The recommended way of using these words in most cases is to wrap them with foreign ( in-count out-count "foreign-name" "new-name" -- ). This takes a word foreign-name and wraps it as new-name so that in-count arguments on the stack before calling foreign-name are unwrapped from integrals to Forth cells and afterwards out-count return values on the stack are wrapped as integrals. Note that in cases more martialing logic is needed on the user's part to wrap new-name to make it more user-friendly.

For foreign single-cell constants one wraps them with foreign-constant ( "foreign-name" "new-name" -- ) which takes the return value of calling foreign-name and wraps it as a constant new-name which, when called, pushes an integral equivalent to said return value. For foreign double-cell constants there is the equivalent word foreign-double-constant ( "foreign-name" "new-name" -- ).

For foreign variables one wraps them with foreign-variable ( "foreign-name" "new-name" -- ), which creates two accessor words, a getter new-name@ which gets the value of foreign-name and then wraps it as an integral, and a setter new-name! which unwraps an integral on the stack before setting foreign-name to it.

For foreign hook variables there is a special word foreign-hook-variable ( "foreign-name" "new-name" -- ), which is like foreign-variable except that instead of exposing the variable value as an integral it exposes the variable value as an execution token. Note that partially-applied execution tokens are not accepted for this.

For alloting member accessible to foreign words there is the word foreign-buffer ( bytes "name" -- ), which creates a constant name that contains the address of an alloted, cell-aligned space bytes in size.

Last but not least, when calling a foreign execution token, e.g. one gotten from a foreign hook variable, one can call it with execute-foreign ( ? in-count out-count xt -- ? ), which first unwraps in-count arguments on the stack from integrals to Forth cells, calls xt, and then wraps out-count return values on the stack as integrals.

Unsafe words

Sometimes it is necessary to do things that reach outside the zeptoscript world, yet without manually calling forth:: or internal:: words. The unsafe module in the zscript module is provided for this purpose.

One important unsafe word that is heavily used internally is bytes>addr-len ( sequence -- address length ), which returns the address and length of a byte sequence or slice in memory as integrals. Note that one must be careful when using this word because if the garbage collector runs address is very likely to change unless it belongs to a constant byte sequence.

>integral ( cell -- integral ) casts a raw cell value to an integral.

integral> ( integral -- cell ) cast an integral to a raw cell value. Care must be taken to make sure that the returned cell is not interpreted as an address of an allocation in the heap by the garbage collector when it runs.

2>integral ( cell0 cell1 -- integral0 integral1 ) casts two raw cell values to integrals.

2integral> ( integral0 integral1 -- cell0 cell1 ) casts two integrals to raw cell values. Care must be taken to make sure that the returned cells are not interpreted as addresses of allocations in the heap by the garbage collector when it runs.

>double ( dcell -- double ) casts a raw double-cell value to a double.

double> ( double -- dcell ) cast a double to a raw double-cell value. Care must be taken to make sure that the returned double-cell value is not interpreted as containing addresses of allocations in the heap by the garbage collector when it runs.

2>double ( dcell0 dcell1 -- double0 double1 ) casts two raw double-cell values to doubles.

2double> ( double0 double1 -- dcell0 dcell1 ) casts two doubles to raw double-cell values. Care must be taken to make sure that the returned double-cell values are not interpreted as containing addresses of allocations in the heap by the garbage collector when it runs.

xt>integral ( xt -- integral ) casts an execution token to an integral. Note that the execution token must not be partially-applied

integral>xt ( integral -- xt ) cases an integral to an execution token.

The unsafe words @ ( address -- x ), ! ( x address -- ), +! ( x address ), bis! ( x address -- ), bic! ( x address -- ), xor! ( x address -- ), h@ ( address -- h ), h! ( h address -- ), h+! ( h address ), hbis! ( h address -- ), hbic! ( h address -- ), hxor! ( h address -- ) c@ ( address -- c ), c! ( c address -- ), c+! ( c address ), cbis! ( c address -- ), cbic! ( c address -- ), cxor! ( c address -- ), fill ( address count c -- ), here ( -- address ), and allot ( count -- ) are like their forth:: counterparts except that they take integrals and arguments and return integrals.