2024-01-02 23:25:11 +03:00
|
|
|
|
---
|
|
|
|
|
language: tailspin
|
|
|
|
|
filename: learntailspin.tt
|
|
|
|
|
contributors:
|
|
|
|
|
- ["Torbjörn Gannholm", "https://github.com/tobega/"]
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
**Tailspin** works with streams of values in pipelines. You may often feel
|
|
|
|
|
that your program is the machine and that the input data is the program.
|
|
|
|
|
|
|
|
|
|
While Tailspin is unlikely to become mainstream, or even production-ready,
|
|
|
|
|
it will change the way you think about programming in a good way.
|
|
|
|
|
|
2024-01-19 11:42:01 +03:00
|
|
|
|
```c
|
2024-01-02 23:25:11 +03:00
|
|
|
|
// Comment to end of line
|
|
|
|
|
|
|
|
|
|
// Process data in a pipeline with steps separated by ->
|
|
|
|
|
// String literals are delimited by single quotes
|
|
|
|
|
// A bang (!) indicates a sink, or end of the pipe
|
|
|
|
|
// OUT is the standard output object, ::write is the message to write output
|
|
|
|
|
'Hello, World!' -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// Output a newline by just entering it in the string (multiline strings)
|
|
|
|
|
'
|
|
|
|
|
' -> !OUT::write
|
|
|
|
|
// Or output the decimal unicode value for newline (10) between $# and ;
|
|
|
|
|
'$#10;' -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// Define an immutable named value. Value syntax is very literal.
|
|
|
|
|
def names: ['Adam', 'George', 'Jenny', 'Lucy'];
|
|
|
|
|
|
|
|
|
|
// Stream the list to process each name. Note the use of $ to get the value.
|
|
|
|
|
// The current value in the pipeline is always just $
|
|
|
|
|
// String interpolation starts with a $ and ends with ;
|
|
|
|
|
$names... -> 'Hello $;!
|
|
|
|
|
' -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// You can also stream in the interpolation and nest interpolations
|
|
|
|
|
// Note the list indexing with parentheses and the slice extraction
|
|
|
|
|
// Note the use of ~ to signify an exclusive bound to the range
|
|
|
|
|
// Outputs 'Hello Adam, George, Jenny and Lucy!'
|
|
|
|
|
'Hello $names(first);$names(first~..~last)... -> ', $;'; and $names(last);!
|
|
|
|
|
' -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// Conditionally say different things to different people
|
|
|
|
|
// Matchers (conditional expressions) are delimited by angle brackets
|
|
|
|
|
// A set of matchers, evaluated top down, must be in templates (a function)
|
|
|
|
|
// Here it is an inline templates delimited by \( to \)
|
|
|
|
|
// Note the doubled '' and $$ to get a literal ' and $
|
|
|
|
|
$names... -> \(
|
|
|
|
|
when <='Adam'> do 'What''s up $;?' !
|
|
|
|
|
when <='George'> do 'George, where are the $$10 you owe me?' !
|
|
|
|
|
otherwise 'Hello $;!' !
|
|
|
|
|
\) -> '$;$#10;' -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// You can also define templates (functions)
|
|
|
|
|
// A lone ! emits the value into the calling pipeline without returning control
|
|
|
|
|
// The # sends the value to be matched by the matchers
|
|
|
|
|
// Note that templates always take one input value and emit 0 or more outputs
|
|
|
|
|
templates collatz-sequence
|
|
|
|
|
when <..0> do 'The start seed must be a positive integer' !
|
|
|
|
|
when <=1> do $!
|
|
|
|
|
// The ?( to ) allows matching a computed value. Can be concatenated as "and"
|
|
|
|
|
when <?($ mod 2 <=1>)> do
|
|
|
|
|
$ !
|
|
|
|
|
3 * $ + 1 -> #
|
|
|
|
|
otherwise
|
|
|
|
|
$ !
|
|
|
|
|
$ ~/ 2 -> #
|
|
|
|
|
end collatz-sequence
|
|
|
|
|
|
|
|
|
|
// Collatz sequence from random start on one line separated by spaces
|
|
|
|
|
1000 -> SYS::randomInt -> $ + 1 -> collatz-sequence -> '$; ' -> !OUT::write
|
|
|
|
|
'
|
|
|
|
|
' -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// Collatz sequence formatted ten per line by an indexed list template
|
|
|
|
|
// Note the square brackets creates a list of the enclosed pipeline results
|
|
|
|
|
// The \[i]( to \) defines a templates to apply to each value of a list,
|
|
|
|
|
// the i (or whatever identifier you choose) holds the index
|
|
|
|
|
[1000 -> SYS::randomInt -> $ + 1 -> collatz-sequence]
|
|
|
|
|
-> \[i](
|
|
|
|
|
when <=1|?($i mod 10 <=0>)> do '$;$#10;' !
|
|
|
|
|
otherwise '$; ' !
|
|
|
|
|
\)... -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// A range can have an optional stride
|
|
|
|
|
def odd-numbers: [1..100:2];
|
|
|
|
|
|
|
|
|
|
// Use mutable state locally. One variable per templates, always called @
|
|
|
|
|
templates product
|
|
|
|
|
@: $(first);
|
|
|
|
|
$(first~..last)... -> @: $@ * $;
|
|
|
|
|
$@ !
|
|
|
|
|
end product
|
|
|
|
|
|
|
|
|
|
$odd-numbers(6..8) -> product -> !OUT::write
|
|
|
|
|
'
|
|
|
|
|
' -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// Use processor objects to hold mutable state.
|
|
|
|
|
// Note that the outer @ must be referred to by name in inner contexts
|
|
|
|
|
// A sink templates gives no output and is called prefixed by !
|
|
|
|
|
// A source templates takes no input and is called prefixed by $
|
|
|
|
|
processor Product
|
|
|
|
|
@: 1;
|
|
|
|
|
sink accumulate
|
|
|
|
|
@Product: $@Product * $;
|
|
|
|
|
end accumulate
|
|
|
|
|
source result
|
|
|
|
|
$@Product !
|
|
|
|
|
end result
|
|
|
|
|
end Product
|
|
|
|
|
|
|
|
|
|
// The processor is a constructor templates. This one called with $ (no input)
|
|
|
|
|
def multiplier: $Product;
|
|
|
|
|
|
|
|
|
|
// Call object templates by sending messages with ::
|
|
|
|
|
1..7 -> !multiplier::accumulate
|
|
|
|
|
-1 -> !multiplier::accumulate
|
|
|
|
|
$multiplier::result -> 'The product is $;
|
|
|
|
|
' -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// Syntax sugar for a processor implementing the collector interface
|
|
|
|
|
1..7 -> ..=Product -> 'The collected product is $;$#10;' -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// Symbol sets (essentially enums) can be defined for finite sets of values
|
|
|
|
|
data colour #{green, red, blue, yellow}
|
|
|
|
|
|
|
|
|
|
// Use processor typestates to model state cleanly.
|
|
|
|
|
// The last named mutable state value set determines the typestate
|
|
|
|
|
processor Lamp
|
|
|
|
|
def colours: $;
|
|
|
|
|
@Off: 0;
|
|
|
|
|
state Off
|
|
|
|
|
source switchOn
|
|
|
|
|
@On: $@Off mod $colours::length + 1;
|
|
|
|
|
'Shining a $colours($@On); light$#10;' !
|
|
|
|
|
end switchOn
|
|
|
|
|
end Off
|
|
|
|
|
state On
|
|
|
|
|
source turnOff
|
|
|
|
|
@Off: $@On;
|
|
|
|
|
'Lamp is off$#10;' !
|
|
|
|
|
end turnOff
|
|
|
|
|
end On
|
|
|
|
|
end Lamp
|
|
|
|
|
|
|
|
|
|
def myLamp: [colour#green, colour#blue] -> Lamp;
|
|
|
|
|
|
|
|
|
|
$myLamp::switchOn -> !OUT::write // Shining a green light
|
|
|
|
|
$myLamp::turnOff -> !OUT::write // Lamp is off
|
|
|
|
|
$myLamp::switchOn -> !OUT::write // Shining a blue light
|
|
|
|
|
$myLamp::turnOff -> !OUT::write // Lamp is off
|
|
|
|
|
$myLamp::switchOn -> !OUT::write // Shining a green light
|
|
|
|
|
|
|
|
|
|
// Use regular expressions to test strings
|
|
|
|
|
['banana', 'apple', 'pear', 'cherry']... -> \(
|
|
|
|
|
when <'.*a.*'> do '$; contains an ''a''' !
|
|
|
|
|
otherwise '$; has no ''a''' !
|
|
|
|
|
\) -> '$;
|
|
|
|
|
' -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// Use composers with regular expressions and defined rules to parse strings
|
|
|
|
|
composer parse-stock-line
|
|
|
|
|
{inventory-id: <INT> (<WS>), name: <'\w+'> (<WS>), currency: <'.{3}'>,
|
|
|
|
|
unit-price: <INT> (<WS>?) <parts>?}
|
|
|
|
|
rule parts: associated-parts: [<part>+]
|
|
|
|
|
rule part: <'[A-Z]\d+'> (<=','>?)
|
|
|
|
|
end parse-stock-line
|
|
|
|
|
|
|
|
|
|
'705 gizmo EUR5 A67,G456,B32' -> parse-stock-line -> !OUT::write
|
|
|
|
|
// {associated-parts: [A67, G456, B32], currency: EUR,
|
|
|
|
|
// inventory-id: 705, name: gizmo, unit-price: 5}
|
|
|
|
|
'
|
|
|
|
|
' -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// Stream a string to split it into glyphs.
|
|
|
|
|
// A list can be indexed/sliced by an array of indexes
|
|
|
|
|
// Outputs ['h','e','l','l','o'], indexing arrays/lists starts at 1
|
|
|
|
|
['abcdefghijklmnopqrstuvwxyz'...] -> $([8,5,12,12,15]) -> !OUT::write
|
|
|
|
|
'
|
|
|
|
|
' -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// We have used only raw strings above.
|
|
|
|
|
// Strings can have different types as determined by a tag.
|
|
|
|
|
// Comparing different types is an error, unless a wider type bound is set
|
|
|
|
|
// Type bound is given in ´´ and '' means any string value, tagged or raw
|
|
|
|
|
templates get-string-type
|
|
|
|
|
when <´''´ '.*'> do '$; is a raw string' !
|
|
|
|
|
when <´''´ id´'\d+'> do '$; is a numeric id string' !
|
|
|
|
|
when <´''´ =id´'foo'> do 'id foo found' !
|
|
|
|
|
when <´''´ id´'.*'> do '$; is an id' !
|
|
|
|
|
when <´''´ name´'.+'> do '$; is a name' !
|
|
|
|
|
otherwise '$; is not a name or id, nor a raw string' !
|
|
|
|
|
end get-string-type
|
|
|
|
|
|
|
|
|
|
[name´'Anna', 'foo', id´'789', city´'London', id´'xzgh', id´'foo']...
|
|
|
|
|
-> get-string-type -> '$;
|
|
|
|
|
' -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// Numbers can be raw, tagged or have a unit of measure
|
|
|
|
|
// Type .. is any numeric value, tagged, measure or raw
|
|
|
|
|
templates get-number-type
|
|
|
|
|
when <´..´ =inventory-id´86> do 'inventory-id 86 found' !
|
|
|
|
|
when <´..´ inventory-id´100..> do '$; is an inventory-id >= 100' !
|
|
|
|
|
when <´..´ inventory-id´0..|..inventory-id´0> do '$; is an inventory-id' !
|
|
|
|
|
when <´..´ 0"m"..> do '$; is an m-measure >= 0"m"' !
|
|
|
|
|
when <´..´ ..0|0..> do '$; is a raw number' !
|
|
|
|
|
otherwise '$; is not a positive m-measure nor an inventory-id, nor raw' !
|
|
|
|
|
end get-number-type
|
|
|
|
|
|
|
|
|
|
[inventory-id´86, inventory-id´6, 78"m", 5"s", 99, inventory-id´654]...
|
|
|
|
|
-> get-number-type -> '$;
|
|
|
|
|
' -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// Measures can be used in arithmetic, "1" is the scalar unit
|
|
|
|
|
// When mixing measures you have to cast to the result measure
|
|
|
|
|
4"m" + 6"m" * 3"1" -> ($ ~/ 2"s")"m/s" -> '$;
|
|
|
|
|
' -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// Tagged identifiers must be made into raw numbers when used in arithmetic
|
|
|
|
|
// Then you can cast the result back to a tagged identifier if you like
|
|
|
|
|
inventory-id´300 -> inventory-id´($::raw + 1) -> get-number-type -> '$;
|
|
|
|
|
' -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// Fields get auto-typed, tagging raw strings or numbers by default
|
|
|
|
|
// You cannot assign the wrong type to a field
|
|
|
|
|
def item: { inventory-id: 23, name: 'thingy', length: 12"m" };
|
|
|
|
|
|
|
|
|
|
'Field inventory-id $item.inventory-id -> get-number-type;
|
|
|
|
|
' -> !OUT::write
|
|
|
|
|
'Field name $item.name -> get-string-type;
|
|
|
|
|
' -> !OUT::write
|
|
|
|
|
'Field length $item.length -> get-number-type;
|
|
|
|
|
' -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// You can define types and use as type-tests. This also defines a field.
|
|
|
|
|
// It would be an error to assign a non-standard plate to a standard-plate field
|
|
|
|
|
data standard-plate <'[A-Z]{3}[0-9]{3}'>
|
|
|
|
|
|
|
|
|
|
[['Audi', 'XYZ345'], ['BMW', 'I O U']]... -> \(
|
|
|
|
|
when <?($(2) <standard-plate>)> do {make: $(1), standard-plate: $(2)}!
|
|
|
|
|
otherwise {make: $(1), vanity-plate: $(2)}!
|
|
|
|
|
\) -> '$;
|
|
|
|
|
' -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// You can define union types
|
|
|
|
|
data age <"years"|"months">
|
|
|
|
|
|
|
|
|
|
[ {name: 'Cesar', age: 20"years"},
|
|
|
|
|
{name: 'Francesca', age: 19"years"},
|
|
|
|
|
{name: 'Bobby', age: 11"months"}]...
|
|
|
|
|
-> \(
|
|
|
|
|
// Conditional tests on structures look a lot like literals, with field tests
|
|
|
|
|
when <{age: <13"years"..19"years">}> do '$.name; is a teenager'!
|
|
|
|
|
when <{age: <"months">}> do '$.name; is a baby'!
|
|
|
|
|
// You don't need to handle all cases, 'Cesar' will just be ignored
|
|
|
|
|
\) -> '$;
|
|
|
|
|
' -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// Array/list indexes start at 1 by default, but you can choose
|
|
|
|
|
// Slices return whatever overlaps with the actual array
|
|
|
|
|
[1..5] -> $(-2..2) -> '$;
|
|
|
|
|
' -> !OUT::write // Outputs [1,2]
|
|
|
|
|
0:[1..5] -> $(-2..2) -> '$;
|
|
|
|
|
' -> !OUT::write // Outputs [1,2,3]
|
|
|
|
|
-2:[1..5] -> $(-2..2) -> '$;
|
|
|
|
|
' -> !OUT::write // Outputs [1,2,3,4,5]
|
|
|
|
|
|
|
|
|
|
// Arrays can have indexes of measures or tagged identifiers
|
|
|
|
|
def game-map: 0"y":[
|
|
|
|
|
1..5 -> 0"x":[
|
|
|
|
|
1..5 -> level´1:[
|
|
|
|
|
1..3 -> {
|
|
|
|
|
level: $,
|
|
|
|
|
terrain-id: 6 -> SYS::randomInt,
|
|
|
|
|
altitude: (10 -> SYS::randomInt)"m"
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
]
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// Projections (indexing) can span several dimensions
|
|
|
|
|
$game-map(3"y"; 1"x"..3"x"; level´1; altitude:) -> '$;
|
|
|
|
|
' -> !OUT::write // Gives a list of three altitude values
|
|
|
|
|
|
|
|
|
|
// Flatten and do a grouping projection to get stats
|
|
|
|
|
// Count and Max are built-in collector processors
|
|
|
|
|
[$game-map... ... ...] -> $(collect {
|
|
|
|
|
occurences: Count,
|
|
|
|
|
highest-on-level: Max&{by: :(altitude:), select: :(level:)}
|
|
|
|
|
} by $({terrain-id:}))
|
|
|
|
|
-> !OUT::write
|
|
|
|
|
'
|
|
|
|
|
' -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// Relations are sets of structures/records.
|
|
|
|
|
// Here we get all unique {level:, terrain-id:, altitude:} combinations
|
|
|
|
|
def location-types: {|$game-map... ... ...|};
|
|
|
|
|
|
|
|
|
|
// Projections can re-map structures. Note § is the relative accessor
|
|
|
|
|
$location-types({terrain-id:, foo: §.level::raw * §.altitude})
|
|
|
|
|
-> '$;
|
|
|
|
|
' -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// Relational algebra operators can be used on relations
|
|
|
|
|
($location-types join {| {altitude: 3"m"} |})
|
|
|
|
|
-> !OUT::write
|
|
|
|
|
'
|
|
|
|
|
' -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// Define your own operators for binary operations
|
|
|
|
|
operator (left dot right)
|
|
|
|
|
$left -> \[i]($ * $right($i)!\)... -> ..=Sum&{of: :()} !
|
|
|
|
|
end dot
|
|
|
|
|
|
|
|
|
|
([1,2,3] dot [2,5,8]) -> 'dot product: $;
|
|
|
|
|
' -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// Supply parameters to vary templates behaviour
|
|
|
|
|
templates die-rolls&{sides:}
|
|
|
|
|
1..$ -> $sides::raw -> SYS::randomInt -> $ + 1 !
|
|
|
|
|
end die-rolls
|
|
|
|
|
|
|
|
|
|
[5 -> die-rolls&{sides:4}] -> '$;
|
|
|
|
|
' -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// Pass templates as parameters, maybe with some parameters pre-filled
|
|
|
|
|
source damage-roll&{first:, second:, third:}
|
|
|
|
|
(1 -> first) + (1 -> second) + (1 -> third) !
|
|
|
|
|
end damage-roll
|
|
|
|
|
|
|
|
|
|
$damage-roll&{first: die-rolls&{sides:4},
|
|
|
|
|
second: die-rolls&{sides:6}, third: die-rolls&{sides:20}}
|
|
|
|
|
-> 'Damage done is $;
|
|
|
|
|
' -> !OUT::write
|
|
|
|
|
|
|
|
|
|
// Write tests inline. Run by --test flag on command line
|
|
|
|
|
// Note the ~ in the matcher means "not",
|
|
|
|
|
// and the array content matcher matches elements < 1 and > 4
|
|
|
|
|
test 'die-rolls'
|
|
|
|
|
assert [100 -> die-rolls&{sides: 4}] <~[<..~1|4~..>]> 'all rolls 1..4'
|
|
|
|
|
end 'die-rolls'
|
|
|
|
|
|
|
|
|
|
// Provide modified modules to tests (aka test doubles or mocks)
|
|
|
|
|
// IN is the standard input object and ::lines gets all lines
|
|
|
|
|
source read-numbers
|
|
|
|
|
$IN::lines -> #
|
|
|
|
|
when <'\d+'> do $!
|
|
|
|
|
end read-numbers
|
|
|
|
|
|
|
|
|
|
test 'read numbers from input'
|
|
|
|
|
use shadowed core-system/
|
|
|
|
|
processor MockIn
|
|
|
|
|
source lines
|
|
|
|
|
[
|
|
|
|
|
'12a',
|
|
|
|
|
'65',
|
|
|
|
|
'abc'
|
|
|
|
|
]... !
|
|
|
|
|
end lines
|
|
|
|
|
end MockIn
|
|
|
|
|
def IN: $MockIn;
|
|
|
|
|
end core-system/
|
|
|
|
|
assert $read-numbers <=65> 'Only 65 is read'
|
|
|
|
|
end 'read numbers from input'
|
|
|
|
|
|
|
|
|
|
// You can work with byte arrays
|
|
|
|
|
composer hexToBytes
|
|
|
|
|
<HEX>
|
|
|
|
|
end hexToBytes
|
|
|
|
|
|
|
|
|
|
'1a5c678d' -> hexToBytes -> ($ and [x 07 x]) -> $(last-1..last) -> '$;
|
|
|
|
|
' -> !OUT::write // Outputs 0005
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## Further Reading
|
|
|
|
|
|
2024-01-19 11:42:01 +03:00
|
|
|
|
- [Main Tailspin site](https://github.com/tobega/tailspin-v0/)
|
|
|
|
|
- [Tailspin language reference](https://github.com/tobega/tailspin-v0/blob/master/TailspinReference.md)
|