Files, Processes and the Environment
Almost every useful program reads something it did not make and leaves something behind: a configuration file, a log, a folder of results, an exit code a build script checks. This chapter is about that boundary — the files a program reads and writes, the directories around them, the console it talks through, the other programs it starts, and the machines it talks to over the network.
Files are the std/fs module: an open File and the streams layered on it, a path algebra, a
single Stat call for every fact about a file, directory walks with for each, whole-file reads
and writes, and the directories a program keeps its own files in. Configuration files in TOML and
INI are std/config; addresses, sockets and URLs are std/net. Every operation that can fail
returns a Result (Chapter 18) rather than raising a runtime error, so a missing file is a value
the program looks at, not a crash.
#import "std/fs"
Files and streams
Text files
A File is opened in place and closes itself when it goes out of scope. A TextWriter
writes text to it, and ReadLines reads a file back a line at a time with for each:
#import "std/fs"
'' Write a text file, add to it, read it back a line at a time.
scope
dim f as fs.File
f.Create( "notes.txt" ) '' creates, or empties, the file
dim w as fs.TextWriter = fs.TextWriter( f )
w.WriteLine( "first line" )
w.WriteLine( "second line" )
end scope '' the file closes here
scope
dim f as fs.File
f.OpenAppend( "notes.txt" ) '' keeps what is there
dim w as fs.TextWriter = fs.TextWriter( f )
w.WriteLine( "third line" )
end scope
dim as int32 n = 0
for each ln in fs.ReadLines( "notes.txt" )
n += 1
print n; ": "; ln.Value( )
next
fs.RemoveFile( "notes.txt" )
1: first line
2: second line
3: third line
Create makes the file, or empties one that is there; OpenAppend keeps what is there and writes
at the end; OpenRead opens for reading only. The scope blocks are where each File ends: its
destructor closes the handle, and the TextWriter declared after it is destroyed first, so nothing
is left unwritten. A File cannot be copied — pass it byref.
WriteLine ends each line the way the platform does: carriage return and line feed on Windows,
line feed alone on Linux and macOS. Set the writer's NewLine to chr( 10 ) for files that must be the
same bytes everywhere. ReadLines accepts either ending, removes it, and yields one
Result( of ustring, FsError ) per line — ln.Value( ) is the text. It holds one line in memory at
a time, whatever the size of the file, and leaving the loop early closes the file.
Text is UTF-8 unless you say otherwise, and a TextReader and ReadLines also honour a byte-order
mark at the start of a file, for UTF-8 and UTF-16 text written by other programs.
Text in other encodings
Not every file is UTF-8. Windows tools save UTF-16, and older programs save in a code page such as Windows-1252, one byte per character, in which the same letters are different bytes. Every text call in std/fs takes an encoding as an extra, last argument:
#import "std/fs"
'' Two programs saved the same name: one in Windows-1252, one in UTF-16.
dim as ustring name_ = "M" & wchr( &hFC ) & "ller" '' u with an umlaut
fs.WriteAllText( "legacy.txt", name_, fs.CodePage( 1252 ) )
fs.WriteAllText( "unicode.txt", name_, fs.Utf16LE( ) )
'' Read both without knowing which is which: a BOM, else UTF-8 if the
'' bytes are valid UTF-8, else the fallback named here.
dim as fs.TextDetect guess = fs.TextDetect( fs.CodePage( 1252 ) )
dim as fs.TextEncoding found
dim as ustring a = fs.ReadAllText( "legacy.txt", found, guess ).Value( )
print "legacy.txt: "; found.Name( )
dim as ustring u = fs.ReadAllText( "unicode.txt", found, guess ).Value( )
print "unicode.txt: "; found.Name( )
print "the same text: "; iif( a = u, "yes", "no" )
'' Windows-1252 has no arrow, so writing one is refused, and the
'' file is left as it was.
var r = fs.WriteAllText( "legacy.txt", name_ & wchr( &h2192 ), _
fs.CodePage( 1252 ) )
print "an arrow in 1252: "; iif( r.IsOk( ), "written", "refused" )
dim as ustring now_ = _
fs.ReadAllText( "legacy.txt", fs.CodePage( 1252 ) ).Value( )
print "legacy.txt unchanged: "; iif( now_ = name_, "yes", "no" )
fs.RemoveFile( "legacy.txt" )
fs.RemoveFile( "unicode.txt" )
legacy.txt: windows-1252
unicode.txt: utf-16le
the same text: yes
an arrow in 1252: refused
legacy.txt unchanged: yes
An encoding is a TextEncoding. Utf8( ) is the default; Utf16LE( ) and Utf16BE( ) write a
byte-order mark unless passed false; CodePage( n ) is a Windows code page by number, and
Ansi( ) the one the machine is set to, on Windows only; BinaryMode( ) reads byte n as character
n, so any file reads and writes back unchanged. ReadAllText, ReadAllLines, WriteAllText,
AppendAllText, WriteAtomic, ReadLines, TextReader and TextWriter all take one, and so do
Stream.WriteText and MemoryStream.ToText for bytes in memory.
A read with an encoding believes it: a byte-order mark that matches is skipped, and any other
bytes are text. Bytes that are not valid in the encoding become U+FFFD, or fail under TEXT_STRICT.
When the encoding is not known, pass a TextDetect instead, as the listing does: a byte-order mark
decides, else UTF-8 if the bytes are valid UTF-8, else the fallback it names, and found says which
was used. A file that starts with a UTF-32 mark is refused with FS_UNSUPPORTED, never misread.
A write is strict by default. A character the encoding cannot hold fails with
FS_INVALID_DATA before the file is opened, so the file keeps what it had. Pass TEXT_REPLACE to
write a ? in its place instead; the write then succeeds and its value is false, meaning "written,
but not exactly". AppendAllText with an encoding refuses to add UTF-16 to a file that starts with
a UTF-8 mark, or the other way round.
Opening a file
Every open returns a Result( of boolean, FsError ), and the error says why:
#import "std/fs"
'' Every way of opening a file can fail, and says why.
dim f as fs.File
var r = f.OpenRead( "missing.txt" )
print "open missing: "; r.IsOk( ); ", not found: "; _
cbool( r.Failure( ).Code( ) = fs.FS_NOT_FOUND )
'' CreateNew refuses a file that is already there.
dim o as fs.OpenOptions
o.Write = true
o.CreateNew = true
print "first create: "; f.Open( "once.txt", o ).IsOk( )
f.Close( )
var again = f.Open( "once.txt", o )
print "second create: "; again.IsOk( ); ", exists: "; _
cbool( again.Failure( ).Code( ) = fs.FS_ALREADY_EXISTS )
'' A File refuses what it was not opened for.
f.OpenRead( "once.txt" )
var w = f.WriteUtf8( "x" )
print "write to a read-only File: "; w.IsOk( )
f.Close( )
fs.RemoveFile( "once.txt" )
open missing: false, not found: true
first create: true
second create: false, exists: true
write to a read-only File: false
| Method | Opens for | An existing file is | A missing file |
|---|---|---|---|
OpenRead( p ) | reading | read | fails, FS_NOT_FOUND |
Create( p ) | writing | emptied | created |
CreateNew( p ) | writing | refused, FS_ALREADY_EXISTS | created |
OpenAppend( p ) | writing at the end | kept | created |
Open( p, options ) | what OpenOptions says | as the options say | as the options say |
An OpenOptions names what the program needs: Read, Write, Append, Truncate, Create and
CreateNew. A combination that makes no sense — Append with Truncate, CreateNew without
Write — is refused before anything is opened. An operation the File was not opened for fails
too: writing to a file opened for reading is a failure, not a silent nothing.
Other programs may still read, write, rename and delete a file you have open; on Windows the
ShareRead, ShareWrite and ShareDelete options narrow that. A handle is not passed to the
programs this one starts unless Inheritable is set.
Fields in a line
The simplest file format a program can save and read again is one record per line with its fields separated by commas:
#import "std/fs"
#import "std/strings"
'' One record per line, fields separated by commas. A field that holds
'' a comma is quoted, and SplitCsvRow takes the quotes off again.
dim f as fs.File
f.Create( "people.csv" )
scope
dim w as fs.TextWriter = fs.TextWriter( f )
w.WriteLine( "ada,36,1.5" )
w.WriteLine( """hopper, grace"",85,2" )
end scope
f.Close( )
for each ln in fs.ReadLines( "people.csv" )
var cols = strings.SplitCsvRow( ln.Value( ) )
dim as ustring who = cols[0]
dim as int32 age = valint( cols[1] )
dim as float64 rating = val( cols[2] )
print who; " |"; age; " |"; rating
next
fs.RemoveFile( "people.csv" )
ada | 36 | 1.5
hopper, grace | 85 | 2
A field that contains a comma is written inside double quotes, and strings.SplitCsvRow splits a
line into its fields with the quotes removed, so hopper, grace survives the round trip. valint
and val turn the text fields back into numbers. For anything that other programs must read,
prefer a format both sides agree on.
Records at a position
A file of fixed-size records can be read and written by number, without touching the records
around the one you want. A RecordFile( of T ) is such a file, of records of the type T:
#import "std/fs"
'' A file of fixed-size records, read and written by number.
type Item
id as int32
qty as int32
label as string * 8 '' fixed width: every record the same
end type
dim stock as fs.RecordFile( of Item )
stock.Open( "stock.dat" ) '' read and write; created if missing
dim it as Item
for n as int32 = 1 to 3
it.id = n
it.qty = n * 10
it.label = "part" & n
stock.Append( it ) '' becomes record n
next
print "records:"; stock.Count( ).Value( ); _
", each"; stock.RecordLength( ); " bytes"
stock.GetRecord( 2, it )
print "record 2:"; it.qty; " "; rtrim( it.label )
it.qty -= 5
stock.PutRecord( 2, it )
stock.GetRecord( 2, it )
print "record 2 now:"; it.qty
print "record 4 there? "; stock.GetRecord( 4, it ).Value( )
stock.Close( )
fs.RemoveFile( "stock.dat" )
records: 3, each 16 bytes
record 2: 20 part2
record 2 now: 15
record 4 there? false
Open opens the file for reading and writing and creates it if it is missing; Open( p, options )
takes OpenOptions instead. Records are numbered from 1, and record n begins
( n - 1 ) * sizeof( Item ) bytes into the file. Append writes after the last record and returns
the new record's number, GetRecord and PutRecord read and write record n, Count( ) is the
number of whole records, and for each r in stock walks them, one Result per record. Reading a
record that is not all there — past the end, or a short last record — is not a failure: the value
is false and the record is left untouched. Writing past the end grows the file, and the records
in between read as zeros.
Every field must have a fixed size for this to work, which is why label is a string * 8 rather
than a ustring: a variable-length string is a pointer, and a pointer written to a file means
nothing when it is read back. Nothing checks this for you. The bytes are the record's bytes in
memory, padding included; field = 1 removes the padding.
Underneath are File.ReadAt( position, buffer, count ) and WriteAt, which read and write bytes
at any byte position without moving the file's position, and may be called from several threads on
one File. Use them for a file that mixes records with other data, and ReadRecord( of T ),
WriteRecord( of T ) and RecordCount( of T ) for records in a File you have opened yourself.
Binary formats
A record of fixed-size fields is one kind of binary file. Most binary formats — images, archives, network messages — are laid out byte by byte instead, with each number in a stated width and byte order:
#import "std/codec"
#import "std/containers"
'' A small binary format: a magic number, a version, then a count
'' and the values, each as short as it can be.
dim buf as containers.Array( of ubyte )
codec.AppendU32BE( buf, &h544B4F31 ) '' "TKO1"
codec.AppendU16LE( buf, 2 )
dim values( 0 to 3 ) as int64 = { 5, 300, -2, 1000000 }
codec.AppendUVarint( buf, 4 )
for i as int32 = 0 to 3
codec.AppendVarint( buf, values( i ) )
next
print buf.Count( ); " bytes: "; codec.HexEncode( buf, true )
'' Reading it back, field by field.
print "magic "; hex( codec.GetU32BE( buf, 0 ) )
print "version "; codec.GetU16LE( buf, 4 )
dim at as int32 = 6
dim n as uint64 = codec.ReadUVarint( buf, at ).Value( )
dim shown as ustring = "values"
for i as int32 = 1 to n
shown &= " " & codec.ReadVarint( buf, at ).Value( )
next
print shown
print "read up to byte"; at
14 bytes: 544B4F310200040AD8040380897A
magic 544B4F31
version 2
values 5 300 -2 1000000
read up to byte 14
codec.AppendU32BE appends a 32-bit number big-endian, most significant byte first — the order
a person reads, and the one network protocols use. AppendU16LE writes little-endian, the
order x86 processors keep in memory and most file formats on Windows use. The format decides; the
code must match it field by field, which is why every name carries its width and order: Get,
Put and Append, for U16 to U64, I16 to I64, F32 and F64, each LE or BE. A Get
past the end of the buffer is a run-time error, the same as indexing an array past its end.
The values are varints: each byte carries seven bits and a flag saying whether another byte
follows, so 5 takes one byte and a million three. AppendVarint first folds the sign in, so that
-2 stays as short as 2. ReadUVarint( buf, at ) and ReadVarint read one value at at and move
at past it, which is what lets the loop walk the list without knowing where each value ends.
The buffer is a containers.Array( of ubyte ), so fs.WriteAllBytes and fs.ReadAllBytes move it
to and from a file whole, and MemoryStream and File.Write take its bytes as they are.
Streams
A File is a Stream: something that bytes can be read from, written to, or both. A
MemoryStream is another — a growable buffer in memory — and everything that works on one works on
the other:
#import "std/fs"
'' Build the text in memory, then copy it to a file in one step.
dim m as fs.MemoryStream
scope
dim w as fs.TextWriter = fs.TextWriter( m )
w.NewLine = chr( 10 )
for i as int32 = 1 to 3
w.WriteLine( "line " & i )
next
end scope
print "in memory:"; m.Length( ); " bytes"
m.Seek( 0 ) '' Copy starts at the position
dim f as fs.File
f.Create( "out.txt" )
print "copied:"; fs.Copy( m, f ).Value( ); " bytes"
f.Close( )
'' Read it back through a TextReader, which works on any stream.
f.OpenRead( "out.txt" )
dim r as fs.TextReader = fs.TextReader( f )
dim as ustring ln
do while r.ReadLine( ln ).ValueOr( false )
print "["; ln; "]"
loop
f.Close( )
fs.RemoveFile( "out.txt" )
in memory: 21 bytes
copied: 21 bytes
[line 1]
[line 2]
[line 3]
fs.Copy( source, destination ) copies from the source's current position to its end, which is why
the listing seeks the MemoryStream back to 0 first. A TextReader reads lines from any stream,
here the File; ReadLine returns false when there are no more.
| Type | Is |
|---|---|
File | a file on disk; unbuffered: each Read and Write is one call to the system |
MemoryStream | bytes in memory; Length( ), ToUtf8Text( ) |
BufferedReader, BufferedWriter | a buffer over another stream, so many small reads or writes become a few large ones |
TextReader, TextWriter | text over another stream: ReadLine, WriteLine |
A wrapper keeps a pointer to the stream it was given, so that stream must outlive it — declare the
stream first. Read may return fewer bytes than asked, and Ok( 0 ) means the end; ReadExact,
WriteAll and Copy are the loops that make short reads and writes invisible.
A stream of your own extends fs.Stream and overrides the operations it supports:
#import "std/fs"
#import "std/outcome"
using outcome
'' A stream of your own: it keeps nothing, and counts what passes.
type Counter extends fs.Stream
as int64 bytes = 0
as int64 lines = 0
declare const function CanWrite( ) as boolean override
declare function Write( byval buf as const ubyte ptr, _
byval n as longint ) as fs.FsResultI64 override
end type
const function Counter.CanWrite( ) as boolean
return true
end function
function Counter.Write( byval buf as const ubyte ptr, _
byval n as longint ) as fs.FsResultI64
if ( n < 0 ) orelse ( ( buf = nullptr ) andalso ( n > 0 ) ) then
return fs.FsFail( of longint )( fs.FS_INVALID_ARGUMENT, "Write", "" )
end if
for i as longint = 0 to n - 1
if buf[i] = 10 then this.lines += 1
next
this.bytes += n
return Ok( of longint, fs.FsError )( n )
end function
dim c as Counter
dim w as fs.TextWriter = fs.TextWriter( c )
w.NewLine = chr( 10 )
w.WriteLine( "one" )
w.WriteLine( "two" )
w.Write( "three" )
print c.lines; " lines,"; c.bytes; " bytes"
print "can read: "; c.CanRead( )
2 lines, 13 bytes
can read: false
Read, Write, Seek and Flush are virtual and return a Result. Counter overrides Write
and says so with CanWrite; the operations it does not override fail with FS_UNSUPPORTED, and
CanRead( ) answers false. Anything written for a Stream — TextWriter, Copy, a buffer —
now works with it.
Paths, metadata and directories
Paths
Paths are strings, and std/fs has the functions to take them apart and build them without
touching the disk:
#import "std/fs"
'' The path algebra: functions over strings, no file system involved.
print fs.PathJoin( "data", "2026", "report.txt" )
print fs.PathFileName( "logs/2026/report.tar.gz" )
print fs.PathStem( "report.tar.gz" ); " "; _
fs.PathExtension( "report.tar.gz" )
print fs.PathStem( ".gitignore" ); " ["; _
fs.PathExtension( ".gitignore" ); "]"
print fs.PathParent( "logs/2026/report.txt" )
print fs.PathNormalize( "logs/./2026/../report.txt" )
print fs.PathChangeExtension( "report.txt", ".md" )
On Windows:
data\2026\report.txt
report.tar.gz
report.tar .gz
.gitignore []
logs/2026
logs\report.txt
report.md
On Linux and macOS:
data/2026/report.txt
report.tar.gz
report.tar .gz
.gitignore []
logs/2026
logs/report.txt
report.md
| Function | Gives |
|---|---|
PathJoin( a, b, … ) | the parts joined with the platform's separator |
PathParent( p ), PathFileName( p ) | everything before the last separator; the last part |
PathStem( p ), PathExtension( p ) | the file name without, and only, its last extension |
PathNormalize( p ) | . and .. resolved, separators made uniform |
PathChangeExtension( p, ext ) | p with a different extension |
PathIsAbsolute( p ), PathStartsWith( p, prefix ) | tests |
PathRelativeTo( p, base ) | p written relative to base |
The separator is \ on Windows and / on Linux and macOS, and the functions use the platform's own when
they build a path. A name that begins with a dot, .gitignore, has no extension: its stem is the
whole name.
PathJoin with an absolute second part throws the first part away.
#import "std/fs"
'' Build a path inside "uploads" from a name somebody else chose.
dim as ustring folder = "uploads"
dim names( 0 to 2 ) as ustring = _
{ "photo.png", "/etc/passwd", "C:\Windows\win.ini" }
for each n in names
var full = fs.PathJoin( folder, n )
var inside = fs.PathStartsWith( fs.PathNormalize( full ), folder )
print n; " -> "; full; " inside: "; inside
next
On Windows:
photo.png -> uploads\photo.png inside: true
/etc/passwd -> /etc/passwd inside: false
C:\Windows\win.ini -> C:\Windows\win.ini inside: false
On Linux and macOS:
photo.png -> uploads/photo.png inside: true
/etc/passwd -> /etc/passwd inside: false
C:\Windows\win.ini -> uploads/C:\Windows\win.ini inside: true
Joining "uploads" with a name a user supplied looks like it keeps the file inside uploads. It
does not: an absolute name replaces the folder, and the program reads or writes wherever the name
points. Which names count as absolute differs by platform — on Linux and macOS the Windows-style name is a
harmless file called C:\Windows\win.ini inside uploads; on Windows it escapes. Check
containment after joining, with PathStartsWith on the normalized result, before using a path
built from somebody else's input.
Facts about a file: Stat
#import "std/fs"
fs.WriteAllText( "hello.txt", "hello" & chr( 10 ) )
'' One call, every fact about the file.
var st = fs.Stat( "hello.txt" )
if st.IsOk( ) then
var info = st.Value( )
print "size:"; info.Size( ); ", file: "; info.IsFile( ); _
", directory: "; info.IsDirectory( )
end if
'' A file that is not there is a failure with a reason, not a zero.
var gone = fs.Stat( "no-such-file.txt" )
if gone.IsOk( ) = false then
var code = gone.Failure( ).Code( )
print "missing file: "; _
iif( code = fs.FS_NOT_FOUND, "not found", "other" )
end if
'' The yes/no predicates fold every failure into false.
print fs.Exists( "hello.txt" ); fs.IsFile( "hello.txt" ); _
fs.IsDir( "hello.txt" )
fs.RemoveFile( "hello.txt" )
print fs.Exists( "hello.txt" )
size:6, file: true, directory: false
missing file: not found
truetruefalse
false
fs.Stat( path ) returns a Result holding a FileInfo — size, kind, times, read-only and hidden
flags — gathered in one call. A path that is not there is a failure whose FsError says
FS_NOT_FOUND, which is a different answer from FS_ACCESS_DENIED. Exists, IsFile and
IsDir answer yes or no and fold every failure into false: convenient in an if, and unable to
tell "absent" from "not allowed to look". Use Stat when the difference matters.
Every std/fs operation that can fail returns a Result in the same way. Value( ) on a failure
is a runtime error, so test IsOk( ) first — the habit from Chapter 18.
Directories: walking, globbing, copying
#import "std/fs"
#import "std/containers"
'' A small tree in a fresh temporary directory.
var made = fs.CreateTempDir( "book" )
if made.IsOk( ) = false then end 1
var root = made.Value( )
fs.CreateDirAll( fs.PathJoin( root, "src", "lib" ) )
fs.WriteAllText( fs.PathJoin( root, "readme.txt" ), "read me" )
fs.WriteAllText( fs.PathJoin( root, "src", "main.tiko" ), "print 1" )
fs.WriteAllText( fs.PathJoin( root, "src", "lib", "util.tiko" ), "" )
fs.WriteAllText( fs.PathJoin( root, "src", "lib", "todo.txt" ), "" )
'' Every entry below root, at any depth. Each element is a Result.
dim found as containers.Array( of ustring )
for each r in fs.WalkDir( root )
if r.IsOk( ) then
var e = r.Value( )
found.Push( e.Depth( ) & " " & e.Name( ) & _
iif( e.IsDirectory( ), "/", "" ) )
end if
next
containers.Sort( found ) '' directory order is not defined
for each s in found
print s
next
'' Every .tiko file, at any depth.
dim hits as containers.Array( of ustring )
fs.Glob( fs.PathJoin( root, "**", "*.tiko" ), hits )
containers.Sort( hits )
for each h in hits
print "glob: "; fs.PathFileName( h )
next
print "copied:"; fs.CopyDir( fs.PathJoin( root, "src" ), _
fs.PathJoin( root, "backup" ) ).Value( )
print "removed:"; fs.RemoveDirAll( root ).Value( ); _
", still there: "; fs.Exists( root )
0 readme.txt
0 src/
1 lib/
1 main.tiko
2 todo.txt
2 util.tiko
glob: util.tiko
glob: main.tiko
copied: 5
removed: 12, still there: false
CreateTempDir creates a directory with a fresh, unpredictable name and returns its path — the
safe way to get a scratch area. ReadDir( p ) walks one directory; WalkDir( p ) walks a whole
tree, a directory before its contents, with Depth( ) counting from 0 for the root's own
children. Each element is a Result, because a walk can fail part-way — a directory it may not
open is reported as a failed element, and the walk carries on. Directory order is whatever the
file system gives, so the listing sorts before printing.
Glob( pattern, results ) finds the paths that match a pattern: * and ? within one part of a
path, and ** for any number of directories, including none. CopyDir copies a tree and
RemoveDirAll deletes one; each returns how many objects it handled. RemoveDirAll deletes a
link inside the tree as a link, without following it, and it opens each directory relative to the
one it is already in, so even another program swapping a directory for a link while the delete
runs cannot send it outside the tree. Given a path that is itself a link, it refuses with
FS_NOT_A_DIRECTORY and deletes nothing.
Whole files, written atomically
#import "std/fs"
#import "std/containers"
'' Replace the whole file in one step: a reader sees the old file or
'' the new one, never half of either.
var ok = fs.WriteAtomic( "app.ini", _
"[main]" & chr( 10 ) & "mode=fast" & chr( 10 ) )
print "written: "; ok.IsOk( )
dim lines as containers.Array( of ustring )
var n = fs.ReadAllLines( "app.ini", lines )
print n.Value( ); " lines: "; lines[0]; " / "; lines[1]
print "as text: "; fs.ReadAllText( "app.ini" ).Value( );
var missing = fs.ReadAllText( "nowhere/app.ini" )
if missing.IsOk( ) = false then
var why = missing.Failure( )
print "could not read: "; _
iif( why.Code( ) = fs.FS_NOT_FOUND, "not found", "other" )
end if
fs.RemoveFile( "app.ini" )
written: true
2 lines: [main] / mode=fast
as text: [main]
mode=fast
could not read: not found
ReadAllText reads a file into one ustring; ReadAllLines splits it into an Array, removing
either kind of line ending; WriteAllText writes one. Text is UTF-8 unless the file
starts with a byte-order mark that says otherwise.
WriteAtomic writes the new content to a temporary file in the same directory and then
replaces the target with it. A program reading the file at the same moment sees the old contents
or the new, never half of each, and a crash during the write leaves the old file intact. That is
the right way to save a configuration or a document. What it does not promise is survival of a
power failure at that instant, which needs the operating system's cooperation beyond any library.
Mapped files, locks and temporary files
#import "std/fs"
fs.WriteAllText( "word.txt", "tiko" )
'' Map the file into memory and change a byte in place.
dim m as fs.MemoryMappedFile
if m.Open( "word.txt", true ).IsOk( ) then
print "mapped"; m.Size( ); " bytes"
m.Data( )[0] = asc( "T" )
m.Flush( )
m.Close( ) '' Data( ) is invalid after this
end if
print fs.ReadAllText( "word.txt" ).Value( )
'' An advisory lock: it binds only programs that also ask for it.
dim guard as fs.FileLock
print "locked: "; guard.Acquire( "word.txt" ).IsOk( ); guard.IsHeld( )
guard.Release( )
print "held after release: "; guard.IsHeld( )
fs.RemoveFile( "word.txt" )
'' A temporary file is created for you, under a fresh name.
var tmp = fs.CreateTempFile( "book" )
print "temp file exists: "; fs.IsFile( tmp.Value( ) )
fs.RemoveFile( tmp.Value( ) ) '' nothing removes it for you
mapped4 bytes
Tiko
locked: truetrue
held after release: false
temp file exists: true
A MemoryMappedFile makes a file's bytes appear in memory: Data( ) is a pointer to them, and a
write through it changes the file. It is the fastest way to read a large file at random, and it is
a raw pointer — invalid after Close( ), and a fault if another program shortens the file while it
is mapped.
A FileLock keeps other cooperating programs away from a file while it is held. It is advisory:
on Linux and macOS it binds only programs that also ask for the lock, and the library promises no more than
that on Windows either. CreateTempFile creates a file under a fresh name and returns the path;
nothing deletes it for you.
std/fs also has a FileWatcher, which reports files created, changed, renamed and deleted under
a directory when the program calls its Poll method. Its events depend on the platform and on
timing — a single save can arrive as several events — so treat each batch as a reason to look
again rather than as an exact log.
Where a program keeps its files
#import "std/fs"
'' Where a program keeps its files. Nothing is created: each call says
'' where the directory should be, and the program adds its own name.
var cfg = fs.ConfigDir( )
if cfg.IsOk( ) then
var mine = fs.PathJoin( cfg.Value( ), "myapp", "settings.ini" )
print "settings under the config dir: "; fs.PathIsAbsolute( mine )
end if
print "cache dir found: "; fs.CacheDir( ).IsOk( )
'' The program's own file, however it was started.
var exe = fs.ExecutablePath( ).ValueOr( "" )
print "running: "; fs.PathStem( exe )
'' ~ means the home directory, and only at the start.
var home = fs.HomeDir( ).ValueOr( "" )
print cbool( fs.ExpandHome( "~/notes.txt" ).ValueOr( "" ) = _
fs.PathJoin( home, "notes.txt" ) )
print fs.ExpandHome( "a/~/b" ).ValueOr( "" )
settings under the config dir: true
cache dir found: true
running: userdirs
true
a/~/b
| Function | Windows | Linux | macOS |
|---|---|---|---|
HomeDir( ) | the user's profile folder | $HOME, else the user's account entry | $HOME, else the user's account entry |
ConfigDir( ) | AppData\Roaming | $XDG_CONFIG_HOME, else ~/.config | $XDG_CONFIG_HOME, else ~/Library/Application Support |
DataDir( ) | AppData\Local | $XDG_DATA_HOME, else ~/.local/share | $XDG_DATA_HOME, else ~/Library/Application Support |
CacheDir( ) | AppData\Local | $XDG_CACHE_HOME, else ~/.cache | $XDG_CACHE_HOME, else ~/Library/Caches |
StateDir( ) | AppData\Local | $XDG_STATE_HOME, else ~/.local/state | $XDG_STATE_HOME, else ~/Library/Application Support |
ExecutablePath( ) | the program's own file | the program's own file | the program's own file |
Nothing is created and no program name is added. Each call says where the directory should
be; join your own program's name, and CreateDirAll it before writing there. On Linux and macOS an
XDG_ variable counts only when it holds an absolute path. ExecutablePath( ) is the full path of the
running program however it was started, which command( 0 ) is not — the way to find a file
installed beside the program. ExpandHome replaces a leading ~ with the home directory and leaves
every other path as it is.
Checksums and digests
A digest is a short, fixed-size value computed from any amount of data: change one byte and it
changes. std/hash computes them:
#import "std/hash"
#import "std/fs"
#import "std/codec"
'' One call: the algorithm, then the data.
print "sha256: "; hash.DigestHex( hash.HASH_SHA256, "abc" )
print "crc32: "; hash.DigestHex( hash.HASH_CRC32, "123456789" )
'' In pieces: the same answer as all at once.
dim h as hash.Sha256
h.UpdateText( "a" )
h.UpdateText( "bc" )
print "pieces: "; cbool( h.FinishHex( ) = hash.DigestHex( hash.HASH_SHA256, "abc" ) )
'' A file, read in blocks however large it is.
dim scratch as ustring = fs.CreateTempDir( ).Value( )
dim path as ustring = fs.PathJoin( scratch, "data.txt" )
fs.WriteAllText( path, "abc" )
print "file: "; codec.HexEncode( hash.DigestFile( hash.HASH_SHA256, path ).Value( ) )
fs.RemoveDirAll( scratch )
'' A keyed hash: only someone with the key can produce it.
print "hmac: "; hash.HmacHex( hash.HASH_SHA256, "key", _
"The quick brown fox jumps over the lazy dog" )
sha256: ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
crc32: cbf43926
pieces: true
file: ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
hmac: f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8
DigestHex( algorithm, data ) is the one-call form. Every algorithm is also a type that takes its
data in pieces — UpdateText, UpdateBytes, or Update( pointer, count ) — and gives the same
answer as the one call, so a file of any size is hashed a block at a time; DigestFile does that
for you. HmacHex mixes in a key, so only someone holding the key can produce the value: it is how
a server checks that a message came from someone it trusts.
| Algorithms | Use them for |
|---|---|
HASH_CRC32, HASH_CRC32C, HASH_ADLER32 | catching accidental corruption: zip, PNG, network frames |
HASH_FNV1A32, HASH_FNV1A64, HASH_XXHASH64 | fast hashing of keys and large data |
HASH_SHA256, HASH_SHA224, HASH_SHA512, HASH_SHA384 | anything someone might tamper with: downloads, signatures, content addresses |
HASH_MD5, HASH_SHA1 | only to match a format or protocol that already uses them |
The first two rows are not security tools: anyone can make two different files with the same CRC
or FNV value on purpose, and MD5 and SHA-1 have the same weakness. To compare two MACs, use
hash.ConstantTimeEquals, which takes the same time whatever the first differing byte is.
Configuration files and CSV
A program that people install needs settings they can edit, and a program that produces data is
often asked for a spreadsheet. std/config reads and writes the two configuration formats most
programs meet, TOML and INI; the CSV writer sits in std/strings, beside SplitCsv.
Reading TOML
TOML is a configuration format with real types: strings, integers, floats, booleans, dates and
times, arrays, and tables that nest. config.ParseToml turns the text into a tree of
TomlValues, and config.LoadToml( path ) does the same for a file:
#import "std/config"
dim doc as ustring = """
# the service's settings
title = "inventory"
debug = false
[server]
host = "0.0.0.0"
port = 8080
started = 2026-10-19T09:30:00Z
[[users]]
login = "ada"
roles = [ "admin", "dev" ]
[[users]]
login = "lin"
roles = [ "dev" ]
"""
dim r as config.ConfigResultToml = config.ParseToml( doc )
if r.IsOk( ) = false then
print r.Failure( ).Message( )
end 1
end if
dim cfg as config.TomlValue = r.Value( )
'' The value at a path, or an empty TomlValue when there is none,
'' whose AsInt( ), AsString( ) and the rest are then empty too.
function Setting( byref t as config.TomlValue, byref p as ustring ) _
as config.TomlValue
return t.Path( p ).ValueOr( config.TomlValue( ) )
end function
print "title: " & Setting( cfg, "title" ).AsString( ).ValueOr( "?" )
print "port: " & Setting( cfg, "server.port" ).AsInt( ).ValueOr( 80 )
print "debug: " & Setting( cfg, "debug" ).AsBool( ).ValueOr( true )
print "wait: " & Setting( cfg, "server.wait" ).AsInt( ).ValueOr( 30 )
dim started as config.TomlValue = Setting( cfg, "server.started" )
print "started: " & started.AsDateTime( ).Value( ).ToIsoString( )
'' [[users]] is an array of tables: index it, or walk it with Count.
dim users as config.TomlValue = Setting( cfg, "users" )
for i as int32 = 0 to users.Count( ) - 1
print Setting( users[i], "login" ).AsString( ).Value( ) _
& ": " & Setting( users[i], "roles" ).Count( ) & " role(s)"
next
print "second user's first role: " _
& Setting( cfg, "users.1.roles.0" ).AsString( ).ValueOr( "?" )
title: inventory
port: 8080
debug: false
wait: 30
started: 2026-10-19T09:30:00Z
ada: 2 role(s)
lin: 1 role(s)
second user's first role: dev
Every lookup answers a value or nothing, never a crash. Path( "server.port" ) follows keys
separated by dots, with a number indexing an array ("users.1.roles.0"), and returns an
Optional; AsInt( ), AsString( ), AsBool( ), AsFloat( ) return one too, empty when the
value is of another kind. Setting folds the two steps into one, so a missing key such as
server.wait falls through to the default the program chose. A default-constructed TomlValue
has the kind TOML_NONE, and every As… on it is empty.
| TOML | Kind( ) | Read with |
|---|---|---|
"text", 'text', """…""" | TOML_STRING | AsString( ) |
42, 0xFF, 1_000 | TOML_INT | AsInt( ), a 64-bit longint |
3.14, 5e+22, inf, nan | TOML_FLOAT | AsFloat( ), which also reads an integer |
true, false | TOML_BOOL | AsBool( ) |
2026-10-19T09:30:00Z | TOML_OFFSET_DATETIME | AsDateTime( ), a chrono.DateTime with an offset |
2026-10-19T09:30:00 | TOML_LOCAL_DATETIME | AsDateTime( ), without one |
2026-10-19, 09:30:00 | TOML_LOCAL_DATE, TOML_LOCAL_TIME | AsLocalDate( ), AsLocalTime( ) |
[ 1, 2 ], [[users]] | TOML_ARRAY | Count( ) and [ i ] |
[server], { x = 1 } | TOML_TABLE | Get, Find, Path, Keys, KeyAt( i ) |
A table keeps its keys in the order the file wrote them, so Keys( ) and KeyAt( i ) walk
it the way a reader of the file would. [[users]] is an array of tables: each [[users]]
header starts one more table in the users array.
When a document is wrong
ParseToml refuses a document that breaks the format's rules, and the ConfigError says where:
#import "std/config"
dim docs( 0 to 3 ) as ustring = { _
!"port = 80\nport = 8080", _
!"[server]\nhost = \"a\"\n[server]", _
!"[fruit]\napple.color = \"red\"\n[fruit.apple]", _
"size = 9223372036854775808" }
print "Rejected documents:"
for i as int32 = 0 to 3
dim r as config.ConfigResultToml = config.ParseToml( docs( i ) )
print r.Failure( ).Message( )
next
Rejected documents:
line 2, column 1: duplicate key: 'port'
line 3, column 1: duplicate key: table 'server' already defined
line 3, column 1: duplicate key: table 'fruit.apple' already defined
line 1, column 8: number out of range: 9223372036854775808
A key, or a table, is defined once. The third document is the subtle one: apple.color
under [fruit] defines the table fruit.apple through its dotted key, so a later
[fruit.apple] header is a second definition. An integer that does not fit in 64 bits is an
error rather than a float that has quietly lost digits. Code( ) gives the reason as a
ConfigErrorCode — CFG_SYNTAX, CFG_DUPLICATE_KEY, CFG_TYPE_REDEFINED,
CFG_INVALID_ESCAPE, CFG_NUMBER_RANGE, CFG_INVALID_DATETIME — and Line( ) and Column( )
the position, so a program can point its user at the line to fix.
Changing and writing TOML
The tree can be changed and written back:
#import "std/config"
dim r as config.ConfigResultToml = config.ParseToml( """
# edited by the program below
[server]
port = 8080
title = "inventory"
""" )
dim cfg as config.TomlValue = r.Value( )
'' Change a value, add a key and a whole table.
dim server as config.TomlValue ptr = cfg.Find( "server" )
server->SetKey( "port", config.TomlValue.FromInt( 9090 ) )
server->SetKey( "tls", config.TomlValue.FromBool( true ) )
dim logging as config.TomlValue = config.TomlValue.NewTable( )
logging.SetKey( "level", config.TomlValue.FromString( "info" ) )
dim files as config.TomlValue = config.TomlValue.NewArray( )
files.Push( config.TomlValue.FromString( "app.log" ) )
files.Push( config.TomlValue.FromString( "audit.log" ) )
logging.SetKey( "files", files )
cfg.SetKey( "logging", logging )
print config.ToTomlText( cfg ).Value( );
[server]
port = 9090
title = "inventory"
tls = true
[logging]
level = "info"
files = ["app.log", "audit.log"]
Find( key ) returns a pointer into the tree, so server->SetKey changes the table in place;
Get( key ) returns a copy. SetKey replaces a key's value or adds the key at the end, Push
appends to an array, and RemoveKey deletes. FromInt, FromString, FromBool, FromFloat,
FromDateTime, FromLocalDate, FromLocalTime, NewTable and NewArray make the values.
config.ToTomlText( root ) writes the tree out: each table's plain keys first, then its
sub-tables as [section] headers, in the order they were added. The comment is gone. The
tree holds values, not the text they came from, so a program that rewrites a hand-edited file
loses its comments. Write settings the program owns to a file of their own, and leave the
user's file to the user.
INI files
INI is older and simpler: sections of key = value lines, every value text.
#import "std/config"
dim r as config.ConfigResultIni = config.ParseIni( """
; written by hand
[Database]
Host = db.example.org
port: 5432
motd = " spaces kept "
""" )
dim ini as config.IniFile = r.Value( )
print "Settings:"
'' Section and key names ignore case.
print ini.Get( "database", "host" ).ValueOr( "?" )
print "[" & ini.Get( "Database", "motd" ).ValueOr( "" ) & "]"
print ini.Get( "Database", "timeout" ).ValueOr( "30" ) '' a default
ini.SetValue( "Database", "port", "5433" )
ini.SetValue( "Cache", "size", "64" )
print ini.ToText( );
Settings:
db.example.org
[ spaces kept ]
30
[Database]
Host = db.example.org
port = 5433
motd = " spaces kept "
[Cache]
size = 64
Names ignore case unless IniOptions.CaseSensitiveKeys says otherwise, so
Get( "database", "host" ) finds Host under [Database]. key: value works as well as
key = value. A line beginning with ; or # is a comment, but a ; later in a line belongs
to the value. Spaces around a value are dropped unless the value is in double quotes, and the
quotes themselves are removed. Keys before the first [section] belong to the section "".
SetValue replaces a value or adds the key, and the section, at the end; Remove( section, key )
deletes a key and Remove( section ) a whole section. ToText( ) writes the file back, quoting
the value that needed its quotes. A repeated key replaces the first one, which is what most INI
readers do; IniOptions.Duplicates = config.INI_DUP_ERROR makes it an error instead.
config.LoadIni( path ) and config.SaveIni( ini, path ) read and write the file itself.
Writing CSV
strings.JoinCsvRow turns an Array( of ustring ) into one line of CSV, and CsvWriter
writes rows to any fs.Stream:
#import "std/strings"
#import "std/containers"
#import "std/fs"
using containers
dim header as Array( of ustring ) = { "item", "note", "qty" }
dim row1 as Array( of ustring ) = { "bolt", "M6, steel", "200" }
dim row2 as Array( of ustring ) = { "nut", !"says \"hex\"", "150" }
print strings.JoinCsvRow( row1 )
'' Any stream will do: a file, a pipe, or memory, as here.
dim buffer as fs.MemoryStream
dim w as strings.CsvWriter = strings.CsvWriter( buffer, ",", false )
w.WriteRow( header )
w.WriteRow( row1 )
w.WriteRow( row2 )
print "written: "; w.Finish( ).IsOk( )
print buffer.ToUtf8Text( );
'' SplitCsv reads back exactly what was written.
dim text as ustring = buffer.ToUtf8Text( )
dim rows as Array( of strings.CsvURow ) = strings.SplitCsv( text )
print rows.Count( ) & " rows; row 2, field 1: " & rows[2].fields[1]
bolt,"M6, steel",200
written: true
item,note,qty
bolt,"M6, steel",200
nut,"says ""hex""",150
3 rows; row 2, field 1: says "hex"
A field is quoted only when it has to be: when it holds the separator, a double quote or a
line break, or starts or ends with a space. A quote inside a field is doubled. That is the
standard CSV form (RFC 4180), and SplitCsv reads it back field for field, as the last line
shows. Records end with CR LF, the standard's line ending, unless the third argument is
false; the listing uses LF so that its output prints cleanly. CsvWriter keeps the first
failure: later rows are not written, and Finish( ) reports it.
Coming from the file statements
tiko still compiles the file statements of the BASIC family — open, print #, line input #,
get #, put #, kill, mkdir and the rest — so older programs keep working. New code uses
std/fs, where a failure is a value rather than a runtime error or a silent nothing:
| Statement | Does | std/fs |
|---|---|---|
open … for output / append / input | open a text file | File.Create, OpenAppend, OpenRead |
print #, line input #, eof | write and read lines | TextWriter.WriteLine, ReadLines, TextReader.ReadLine |
write #, input # | comma-separated fields | WriteLine of the fields, strings.SplitCsvRow |
open … for random, get #, put # | fixed-size records | RecordFile: GetRecord, PutRecord, Append |
open … encoding "utf-16" | text in another encoding | an encoding argument: ReadAllText( p, Utf16LE( ) ) |
lof( f ), seek | length, position | File.Size( ), Seek |
mkdir p, rmdir p | create or remove a directory | CreateDir, CreateDirAll, RemoveDir, RemoveDirAll |
chdir p, curdir( ) | change or read the current directory | SetCurrentDir, CurrentDir |
kill p | delete a file | RemoveFile |
name old as new | rename | MoveFile (which also crosses drives) |
dir( pattern ), dir( ) | first match, then next match | ReadDir, Glob |
exepath( ) | the folder the program is in | PathParent( ExecutablePath( ).Value( ) ) |
dir( ) keeps one listing per thread, so two listings cannot be interleaved and a goroutine cannot
continue the main program's (Chapter 23). ReadDir has neither limit.
The console as a device
The console is something a program can draw on — but only when there is a console. When the output is redirected to a file or a pipe, colour and cursor movement become stray control bytes in someone else's data:
#include once "fbio.bi"
'' Colour and cursor positions are for a person at a console. When the
'' output goes to a file or a pipe, print plain text instead.
if IsRedirected( ) then
print "report (plain): 3 warnings"
else
cls
color 14
locate 2, 4
print "report: 3 warnings"
color 7
end if
report (plain): 3 warnings
IsRedirected( ), from #include once "fbio.bi", is true when standard output is not a console;
IsRedirected( true ) asks the same about standard input. The listing's output above is the
redirected branch, because that is how the book's harness runs it. At a console, cls clears the
screen, color sets the text colour, locate row, column moves the cursor, and width sets the
console's size in characters.
For keyboard input, inkey( ) returns the key waiting to be read, or an empty string at once if
there is none — the right call inside a loop that must keep working. getkey( ) waits for a key
and returns its code. sleep ms pauses for that many milliseconds. open cons and open err open
the console's output and its error stream as file numbers, for code written against print #
(Chapter 19 traced to open err).
Processes and the environment
Arguments and environment variables
'' command( n ) is the n-th argument; command( ) is all of them.
dim as int32 i = 1
do while len( command( i ) ) > 0
print i; ": "; command( i )
i += 1
loop
print "all: "; command( )
'' Environment variables: set one, read it, read one that is not set.
setenviron "REPORT_MODE=draft"
print "REPORT_MODE="; environ( "REPORT_MODE" )
print "UNSET_VARIABLE=["; environ( "UNSET_VARIABLE_XYZ" ); "]"
Run as envreport alpha "two words" 3:
1: alpha
2: two words
3: 3
all: alpha two words 3
REPORT_MODE=draft
UNSET_VARIABLE=[]
command( n ) is the n-th argument, with quotes already removed, and an empty string past the
last one; command( 0 ) is the program's own path, and command( ) all the arguments as one
string. environ( name ) reads an environment variable — an empty string if it is not set — and
setenviron "NAME=value" sets one for this process and the programs it starts.
In the main module, __FB_ARGC__ is the number of arguments counting the program itself, and
__FB_ARGV__ the C array of them, a zstring ptr ptr — the form a C library that parses its own
command line expects. They belong to the main module's top level, as in fbc: inside a procedure
they are TKO0743: '__fb_argc__' is not declared, and another module's top level reads 0.
The std/os module
command and environ are short to write, but each loses something: command( n ) cannot show
an empty argument, because an empty string already means "past the last one", and environ cannot
tell a variable that is not set from one set to nothing. std/os keeps both apart:
#import "std/os"
#import "std/containers"
#import "std/outcome"
'' Every argument, split as the shell passed it; an empty one is kept.
dim args as containers.Array( of ustring ) = os.Args( )
for i as int32 = 1 to args.Count( ) - 1
print i; ": ["; args[ i ]; "]"
next
'' GetEnv tells a variable that is not set from one set to nothing.
os.SetEnv( "REPORT_MODE", "" )
dim m as outcome.Optional( of ustring ) = os.GetEnv( "REPORT_MODE" )
print "REPORT_MODE set? "; m.HasValue( ); ", value ["; m.ValueOr( "?" ); "]"
print "UNSET_VARIABLE_XYZ set? "; os.GetEnv( "UNSET_VARIABLE_XYZ" ).HasValue( )
'' $NAME and ${NAME} inside a string.
os.SetEnv( "REPORT_MODE", "draft" )
print os.ExpandEnv( "mode: $REPORT_MODE, file: ${REPORT_MODE}.txt" )
'' A refused name is a failure value, not a crash.
dim r as os.OsResultBool = os.SetEnv( "A=B", "x" )
print r.Failure( ).Message( )
'' Facts about the machine.
print "cpus > 0? "; cbool( os.CpuCount( ) > 0 ); ", page: "; os.PageSize( )
Run as osenv alpha "two words" "":
1: [alpha]
2: [two words]
3: []
REPORT_MODE set? true, value []
UNSET_VARIABLE_XYZ set? false
mode: draft, file: draft.txt
SetEnv 'A=B': invalid argument
cpus > 0? true, page: 4096
os.Args( ) is every argument in an Array( of ustring ), the program's own path first, Unicode
on every system. os.GetEnv returns an Optional: empty when the variable is not set, holding
"" when it is set to nothing. SetEnv and UnsetEnv return a Result, and refuse a name that
is empty or contains = with an OsError rather than passing it to the system. ExpandEnv
replaces $NAME and ${NAME} — and on Windows %NAME% — with their values. Environment( ) is
a sorted snapshot of every variable, as an OrderedMap.
The module also answers questions about the machine: OS_NAME and ARCH_NAME (constants),
OsVersion( ), Hostname( ), UserName( ), ProcessId( ), PageSize( ), TotalMemory( ),
IsTerminal( fd ), and CpuCount( ) — the processors this process may use, which inside a
container or under an affinity mask is fewer than the machine has. That is the number to size a
pool of workers by.
Set the environment before starting threads. On Linux and macOS, changing a variable while another thread reads the environment is not safe, whichever function does it.
Running other programs
'' Run a command through the shell, and read its output through a pipe.
var code = shell( "exit 3" )
print "shell returned"; code
var f = freefile( )
open pipe "echo from the child" for input as #f
dim as ustring got
line input #f, got
close #f
print "read: "; trim( got )
shell returned 3
read: from the child
| Statement | Runs | Returns |
|---|---|---|
shell( "command" ) | a command line, through the system shell | its exit code |
exec( program, args ) | a program, directly | its exit code |
chain( program ) | a program | its exit code |
run program | a program | nothing: the current program ends |
open pipe "command" for input as #f | a command whose output the program reads | — |
shell goes through cmd.exe on Windows and /bin/sh on Linux and macOS, so the command line is in that
shell's language; exec starts a program with no shell in between, which is safer when an argument
came from a user. open pipe … for input reads what the command prints, a line at a time, as if it
were a file; for output sends lines to its input instead.
Exit codes
'' The exit code is what a script or a build tool checks.
dim as int32 errors = 4
print errors; " errors"
if errors > 0 then end errors '' the process exits with status 4
print "never printed"
4 errors
end n ends the program with exit status n. Zero means success to every shell and build tool;
anything else is a failure whose number the caller can test — $? in a Unix shell, %ERRORLEVEL%
in cmd.exe, or the return of shell and exec in another tiko program. A program that can fail
should say so through its exit code, not only through what it prints.
os.ExitWith( n ) from std/os is end n under a name a procedure can pass around, and os.Abort( )
ends the program at once, skipping every destructor and every buffered write.
Stopping cleanly on Ctrl-C
Ctrl-C normally ends a console program wherever it happens to be — halfway through writing a file,
with a temporary directory left behind. os.CatchInterrupt( ) turns it into a flag instead:
#import "std/os"
'' From here on, Ctrl-C sets a flag instead of ending the program.
os.CatchInterrupt( )
dim as int32 done_ = 0
while os.Interrupted( ) = false andalso done_ < 20
sleep 5 '' one unit of work
done_ += 1
wend
if os.Interrupted( ) then
print "stopped early after"; done_; " steps"
os.ExitWith( 130 )
end if
print "finished all"; done_; " steps"
Left alone, it prints:
finished all 20 steps
Press Ctrl-C while it runs and the loop ends at the next check, prints stopped early after and
exits with 130, the code a shell reports for a program stopped by Ctrl-C. The program decides
where it is safe to stop; nothing of yours runs at the moment the key is pressed.
os.Interrupted( ) asks without waiting, os.WaitInterrupt( ms ) waits, and os.LastInterrupt( )
says which kind arrived: INT_CTRL_C, INT_BREAK (Ctrl-Break, Windows), INT_TERMINATE (SIGTERM
on Linux and macOS; the console window closing, logoff or shutdown on Windows) or INT_HANGUP (SIGHUP).
A second Ctrl-C ends the program at once, with code 130, so a program that is stuck can still
be stopped; os.CatchInterrupt( false ) turns that off. On Windows a closing console window gives
the program about five seconds before the system ends it regardless.
Talking over the network
std/net holds the three things a program needs to talk to another machine: addresses to say
where, sockets to carry the bytes — TCP for a connection, UDP for single messages — and
URLs, the text form in which addresses usually arrive.
Addresses
#import "std/net"
'' Text in, the canonical form out: IPv6 in its shortest lower-case spelling.
dim texts( 0 to 4 ) as ustring = { "192.168.1.20", "2001:0DB8:0:0:0:0:0:1", _
"::ffff:10.0.0.7", "fe80::1%eth0", "010.0.0.1" }
for i as long = 0 to 4
dim r as net.NetResultIp = net.ParseIp( texts( i ) )
if r.IsOk( ) then
dim a as net.IpAddress = r.Value( )
print texts( i ); " -> "; a.ToString( ); iif( a.IsPrivate( ), " (private)", "" ); _
iif( a.IsLinkLocal( ), " (link-local)", "" ); iif( a.IsV4Mapped( ), " (IPv4 as IPv6)", "" )
else
print texts( i ); " -> "; r.Failure( ).Message( )
end if
next
'' An address and a port.
dim sa as net.SocketAddress = net.ParseSocketAddress( "[::1]:8080" ).Value( )
print sa.Ip.IsLoopback( ), sa.Port, sa.ToString( )
192.168.1.20 -> 192.168.1.20 (private)
2001:0DB8:0:0:0:0:0:1 -> 2001:db8::1
::ffff:10.0.0.7 -> ::ffff:10.0.0.7 (private) (IPv4 as IPv6)
fe80::1%eth0 -> fe80::1%eth0 (link-local)
010.0.0.1 -> ParseIp '010.0.0.1': invalid address
true 8080 [::1]:8080
An IpAddress is a value, IPv4 or IPv6, that prints in one canonical form whatever way it was
written: IPv6 in lower case with the longest run of zero groups as ::. Parsing is strict.
010.0.0.1 is refused rather than read — some systems read a leading zero as octal and reach
8.0.0.1, others as decimal, and a filter that checks one reading while a connection uses the other
is a security hole. The Is… functions classify an address — IsLoopback, IsPrivate,
IsLinkLocal, IsMulticast, IsGlobalUnicast — and look through an IPv4 address written as IPv6.
A SocketAddress adds a port; written as text an IPv6 one needs brackets, [::1]:8080, because
the address itself is full of colons.
net.Resolve( "example.com", 443 ) asks the system's resolver for a name's addresses and returns
them all, each with the port. An address written as text comes back as itself without a lookup.
A TCP connection
#import "std/net"
#import "std/fs"
'' A server: port 0 lets the system pick a free one.
dim server as net.TcpListener
if server.Bind( net.SocketAddress( net.Loopback( ), 0 ) ).IsOk( ) = false then end 1
dim where as net.SocketAddress = server.LocalAddress( ).Value( )
'' A client connects; the system completes it before Accept is called,
'' so one thread can play both parts.
dim client as net.TcpStream
dim r as net.NetResultBool = client.Connect( where, 2000 )
if r.IsOk( ) = false then
print r.Failure( ).Message( )
end 1
end if
dim conn as net.TcpStream
server.Accept( conn, 2000 )
'' Both ends are streams: WriteUtf8 out, ReadExact in.
client.WriteUtf8( "hello" )
dim as string buf = space( 5 )
conn.ReadExact( cptr( ubyte ptr, strptr( buf ) ), 5 )
print "server got: "; buf
conn.WriteUtf8( ucase( buf ) )
client.ReadExact( cptr( ubyte ptr, strptr( buf ) ), 5 )
print "client got: "; buf
'' Shutdown( write ) ends the data: the server's next read returns 0 bytes.
client.Shutdown( net.SHUTDOWN_WRITE )
dim as ubyte one( 0 to 0 )
print "server read after shutdown: "; conn.Read( @one( 0 ), 1 ).Value( ); " bytes"
'' Nothing more is coming, so a timed read fails instead of waiting for ever.
client.SetReadTimeout( 100 )
dim t as fs.FsResultI64 = client.Read( @one( 0 ), 1 )
print "client read: "; t.Failure( ).Message( )
'' A port nothing listens on any more.
server.Close( )
dim late as net.TcpStream
print "refused? "; cbool( late.Connect( where, 5000 ).Failure( ).Code( ) = net.NET_CONNECTION_REFUSED )
server got: hello
client got: HELLO
server read after shutdown: 0 bytes
client read: timed out
refused? true
A TcpListener is a server: Bind to an address and port — port 0 asks the system for a free one,
which LocalAddress( ) then reports — and Accept each connection into a TcpStream. A client
TcpStream calls Connect with a SocketAddress, or ConnectHost( "example.com", 80 ), which
resolves the name and tries its addresses in turn. Each of them takes a timeout in milliseconds;
without one a call waits as long as the system lets it.
A TcpStream is a stream, the same fs.Stream as a File: WriteUtf8, WriteAll,
ReadExact, ReadToEnd, a BufferedReader or a TextReader all work on a connection. A read
returns what has arrived, which may be less than asked for — TCP carries a stream of bytes, not
messages, so ReadExact or a line reader is what puts the messages back together. A read of 0
bytes means the other side has finished sending, as Shutdown( SHUTDOWN_WRITE ) shows. Reads and
writes report FsErrors like a file's: FS_TIMED_OUT after SetReadTimeout,
FS_CONNECTION_RESET when the peer drops the connection. Everything else — connecting, binding,
accepting — reports a NetError, whose code says what happened: NET_CONNECTION_REFUSED,
NET_TIMED_OUT, NET_ADDRESS_IN_USE, NET_HOST_NOT_FOUND.
A UdpSocket sends single datagrams instead: Bind, then SendTo( p, n, address ) and
ReceiveFrom( p, n, sender ). A datagram arrives whole or not at all, and one longer than the
buffer fails with NET_MESSAGE_TOO_LONG rather than arriving cut short without a word.
Every socket call blocks until it is done or its timeout ends. A goroutine (Chapter 23) in a socket call holds its worker thread while it waits, so a server that gives each connection a goroutine should give every connection a timeout, or serve no more connections at once than it has workers.
URLs
#import "std/net"
dim u as net.Url = net.ParseUrl( "https://ann@example.com:8443/docs/a%20b.html?q=tiko+lang&page=2#intro" ).Value( )
print "scheme "; u.Scheme
print "user "; u.User
print "host "; u.Host; " port "; u.Port
print "path "; u.Path '' decoded
print "query "; u.RawQuery '' as written
print "q "; u.Query( ).Get( "q" ).ValueOr( "" )
print "fragment "; u.Fragment
'' A link on that page, made absolute.
print u.Resolve( "../img/logo.png" ).Value( ).ToString( )
print u.Resolve( "?page=3" ).Value( ).ToString( )
'' Built from fields: each part is escaped as it is printed.
dim q as net.QueryValues
q.Add( "name", "Zoë Wu" )
q.Add( "tags", "a&b" )
dim v as net.Url
v.Scheme = "http"
v.Host = "localhost"
v.Port = 8080
v.Path = "/search results"
v.RawQuery = q.Encode( )
print v.ToString( )
'' A bad escape is an error, not a guess.
print net.ParseUrl( "http://example.com/100%" ).Failure( ).Message( )
scheme https
user ann
host example.com port 8443
path /docs/a b.html
query q=tiko+lang&page=2
q tiko lang
fragment intro
https://ann@example.com:8443/img/logo.png
https://ann@example.com:8443/docs/a%20b.html?page=3
http://localhost:8080/search%20results?name=Zo%C3%AB+Wu&tags=a%26b
ParseUrl 'http://example.com/100%': invalid URL
ParseUrl follows RFC 3986. The fields of a Url hold the parts decoded — Path is
/docs/a b.html, not a%20b — except RawQuery, kept as written because only its own parser
knows where its pairs begin and end; Query( ) parses it into QueryValues, an ordered list of
pairs in which a name may repeat. ToString( ) escapes each part again as it prints it, so a field
can be set to any text. Resolve turns a relative link into an absolute one the way a browser does,
removing . and ... An invalid escape such as a lone % is NET_INVALID_URL rather than a guess
at what was meant.
Fetching a web page
#cmdline "-mt"
#import "std/http"
#import "std/net"
#import "std/fs"
'' A server on this machine that answers each request on one connection with
'' the request line it received, until the client goes away.
function ReadHead( byref c as net.TcpStream, byref head as string ) as boolean
dim as ubyte ch
head = ""
while right( head, 4 ) <> !"\r\n\r\n"
dim got as fs.FsResultI64 = c.Read( @ch, 1 )
if got.IsOk( ) = false orelse got.Value( ) = 0 then return false
head &= chr( ch )
wend
return true
end function
sub Serve( byval p as any ptr )
dim as net.TcpListener ptr lis = p
dim c as net.TcpStream
if lis->Accept( c, 5000 ).IsOk( ) = false then exit sub
dim as string head
while ReadHead( c, head )
dim as long at_ = instr( lcase( head ), "content-length:" )
if at_ > 0 then
dim as long n = valint( mid( head, at_ + 15 ) )
dim as string body = space( n )
c.ReadExact( cptr( ubyte ptr, strptr( body ) ), n )
end if
dim as string reply = "you sent: " & left( head, instr( head, !"\r\n" ) - 1 )
c.WriteUtf8( !"HTTP/1.1 200 OK\r\nContent-Length: " & len( reply ) & !"\r\n\r\n" & reply )
wend
end sub
sub Main( )
dim lis as net.TcpListener
lis.Bind( net.SocketAddress( net.Loopback( ), 0 ) )
dim as ustring site = "http://127.0.0.1:" & lis.LocalAddress( ).Value( ).Port
dim th as any ptr = threadcreate( @Serve, @lis, 1024 * 1024 )
dim c as http.Client
c.TimeoutMs = 5000
dim r as http.HttpResultResponse = c.Fetch( site & "/notes?day=mon" )
print r.Value( ).StatusCode; " "; r.Value( ).Text( )
r = c.Post( site & "/notes", "text/plain", "buy milk" )
print r.Value( ).StatusCode; " "; r.Value( ).Text( )
print "idle connections kept:"; c.IdleCount( )
c.CloseIdle( ) '' the server sees the client go
threadwait( th )
end sub
Main( )
200 you sent: GET /notes?day=mon HTTP/1.1
200 you sent: POST /notes HTTP/1.1
idle connections kept: 1
std/http is an HTTP/1.1 client over those sockets, and std/tls encrypts the connection when the
URL says https. The listing serves itself, on a second thread, so it runs without a network — the
Serve routine is all a server needs to say to an HTTP client: a status line, headers, a blank
line and a body of the length the headers announced.
http.Fetch( url ) makes one request with the defaults. An http.Client holds settings —
TimeoutMs, MaxRedirects, UserAgent, DefaultHeader, TlsOptions — and keeps connections
open: both requests above travelled over one connection, which is still kept for the next request
to the same site. Send takes a Request built by hand, with any method, headers and body;
Fetch, Head and Post are shorthands for it. A Response holds StatusCode, Header, the
Body bytes and Text( ). For a body too large to hold, Open returns a ResponseStream, an
fs.Stream like a File or a TcpStream.
A status is not an error. A 404 is a request that worked and an answer the program has to look
at, so it comes back as a Response. An HttpError means there was no answer: HTTP_CONNECT_FAILED,
HTTP_TIMED_OUT, HTTP_BAD_RESPONSE, or HTTP_TLS_FAILED when the server's certificate is not
trusted, out of date, or for another name — the error's TlsCode( ) says which. Redirects are
followed for you, up to ten.
What it costs
- A
Fileholds an operating-system handle until it is closed or goes out of scope. Keep it in the narrowestscopethat needs it. CallClose( )yourself when you need to know whether the close itself succeeded: it returns aResult, and a destructor has nowhere to report one. - A
Fileis unbuffered: everyReadandWriteis a system call. Many small writes belong in aBufferedWriter, aTextWriterover one, or aMemoryStreamwritten once. Statis one system call;Existsfollowed by a size check is two, and the file can change between them.WalkDirholds one directory open at a time and cannot exhaust handles, however deep the tree.ReadAllTextandReadAllLineshold the whole file in memory.WriteAtomicwrites the file twice over — a temporary and a rename — which is the price of never leaving it half-written.- A
TomlValueis a value:Get( key )and copying a table copy the whole subtree under it.Find( key )and[ i ]reach into the tree without a copy. Looking a key up scans its table, which is what a configuration file's tables can afford. - An
http.Clientkeeps up to eight idle connections open for reuse, each an operating-system handle (and, forhttps, a TLS session).CloseIdle( )lets them go; so does the client going out of scope. - A socket holds an operating-system handle and, while a call waits, a thread. Close connections in the narrowest scope that needs them, and give every network read a timeout: without one a peer that stops answering stops the program with it.
Summary
#import "std/fs". AFileopens withOpenRead,Create,CreateNew,OpenAppendorOpen( p, OpenOptions ), closes itself, and reports every failure as anFsError.TextWriter.WriteLinewrites lines;for each ln in ReadLines( p )reads them back.strings.SplitCsvRowsplits a line of comma-separated fields.- Every text call takes an encoding:
Utf8( ),Utf16LE( ),Utf16BE( ),CodePage( n ),Ansi( ),BinaryMode( ). Reads believe it;TextDetectguesses; writes refuse what the encoding cannot hold unless givenTEXT_REPLACE. RecordFile( of T )reads and writes fixed-size records by number, from 1, withGetRecord,PutRecord,Appendandfor each; every field must be fixed-size.ReadAtandWriteAtare the byte-level calls underneath.std/codeclays out binary formats in a byteArray:Get/Put/Append+ width +LE/BE, and LEB128 varints withAppendUVarint/ReadUVarint.- A
Fileis aStream, likeMemoryStream; buffers, text readers and writers, andCopywork on any stream, including one you write by extendingfs.Stream. - The
Path*functions;Statfor every fact at once;ReadDir,WalkDir,Glob;ReadAllText/ReadAllLines/WriteAllText;WriteAtomic. ConfigDir,DataDir,CacheDir,StateDir,HomeDirandExecutablePathsay where a program keeps its files.PathJoinwith an absolute second part discards the first — check containment withPathStartsWithbefore using a path built from input.- The file statements (
open,print #,kill,mkdir,dir…) still compile for older programs; each has astd/fscounterpart that reports failure as a value. IsRedirected( )decides whether to draw on the console.inkeynever waits;getkeydoes.command( n ),environ,setenviron;shell,exec,chain,run,open pipe;end nsets the exit code.std/os:Args( )keeps empty arguments,GetEnvtells unset from empty,SetEnv/UnsetEnv/ExpandEnv; host facts such asCpuCount( );CatchInterrupt( )turns Ctrl-C into a flag the program checks withInterrupted( ).std/hash:DigestHex( alg, data ), the same in pieces,DigestFile,HmacHex. CRC and FNV catch accidents; only SHA-2 stands up to tampering.std/config:ParseToml/LoadTomlgive aTomlValuetree read withPathandAs…, each anOptional;ToTomlTextwrites it back, without comments.ParseIni/LoadInigive anIniFilewithGet,SetValueandToText. Errors areConfigErrors with a line and column.strings.JoinCsvRowandstrings.CsvWriterwrite CSV thatSplitCsvreads back.std/net:ParseIp(strict) andIpAddress;SocketAddress;Resolve.TcpListener.BindandAccept,TcpStream.Connect/ConnectHost— aTcpStreamis anfs.Stream.UdpSocketfor datagrams. Every call blocks; give it a timeout.ParseUrlgives aUrlof decoded fields,Resolvemakes links absolute,QueryValuesreads and writes query strings.std/http:http.Fetch, or anhttp.Clientthat reuses connections and follows redirects;SendaRequest, read aResponse— a 404 is a response, not an error.std/tlsencryptshttps, and checks the server's certificate against the system's trusted roots.
Exercises
- Walk a directory tree and count its files by extension, printing the counts in a stable order. {solution="ex25-01"}
- Write
Sell( stock, record, n )for the stock file ofrecords.tiko: it reduces the quantity of recordrecordbynand refuses, returningfalse, if there are not enough. {solution="ex25-02"} - Make
pathescape.tikorefuse any name that would leaveuploads, and print which names were refused, on every platform. - Rewrite
textfile.tikowithfs.WriteAllTextandfs.ReadAllLines. Which version would you want for a file of a few lines, and which for a log of a few gigabytes? - Write a program that runs
exitcode.tiko's executable withexecand prints a different message for exit status 0 and for anything else. - Print the SHA-256 digest of every file under a directory, sorted by path, in the format
sha256sumuses: the digest, two spaces, the path. - Give
interrupt.tikoa temporary directory that it creates at the start and removes at the end, and check that it is removed when you press Ctrl-C too. - Write
binformat.tiko's buffer to a file withfs.WriteAllBytes, read it back withfs.ReadAllBytes, and check the magic number before reading anything else. What should a reader do with a file whose version it does not know? - Give
tomlconfig.tikoa[server]keyport = "8080", written as a string. What does it print, and how would you report the mistake to the user who wrote the file? - Read a TOML file of
[[items]]tables, each with anameand aprice, and write them to a CSV file withCsvWriter, a header row first. - Change
tcpecho.tikoso that the client sends three lines and the server answers each with its length, reading with aTextReaderon the server's side. What happens if the client sends all three before the server reads any? - Write
LinkReport( pageUrl, links ): resolve each link of a page against its URL and print the ones that leave the page's host. - Make
httpget.tiko's server answer/oldwith302 Foundand aLocation: /notesheader, and fetch/old. What doesr.Value( ).Urlhold afterwards? Setc.MaxRedirects = 0and fetch it again.