Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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
MethodOpens forAn existing file isA missing file
OpenRead( p )readingreadfails, FS_NOT_FOUND
Create( p )writingemptiedcreated
CreateNew( p )writingrefused, FS_ALREADY_EXISTScreated
OpenAppend( p )writing at the endkeptcreated
Open( p, options )what OpenOptions saysas the options sayas 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.

TypeIs
Filea file on disk; unbuffered: each Read and Write is one call to the system
MemoryStreambytes in memory; Length( ), ToUtf8Text( )
BufferedReader, BufferedWritera buffer over another stream, so many small reads or writes become a few large ones
TextReader, TextWritertext 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
FunctionGives
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.

Pitfall

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
FunctionWindowsLinuxmacOS
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 filethe program's own filethe 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.

AlgorithmsUse them for
HASH_CRC32, HASH_CRC32C, HASH_ADLER32catching accidental corruption: zip, PNG, network frames
HASH_FNV1A32, HASH_FNV1A64, HASH_XXHASH64fast hashing of keys and large data
HASH_SHA256, HASH_SHA224, HASH_SHA512, HASH_SHA384anything someone might tamper with: downloads, signatures, content addresses
HASH_MD5, HASH_SHA1only 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.

TOMLKind( )Read with
"text", 'text', """…"""TOML_STRINGAsString( )
42, 0xFF, 1_000TOML_INTAsInt( ), a 64-bit longint
3.14, 5e+22, inf, nanTOML_FLOATAsFloat( ), which also reads an integer
true, falseTOML_BOOLAsBool( )
2026-10-19T09:30:00ZTOML_OFFSET_DATETIMEAsDateTime( ), a chrono.DateTime with an offset
2026-10-19T09:30:00TOML_LOCAL_DATETIMEAsDateTime( ), without one
2026-10-19, 09:30:00TOML_LOCAL_DATE, TOML_LOCAL_TIMEAsLocalDate( ), AsLocalTime( )
[ 1, 2 ], [[users]]TOML_ARRAYCount( ) and [ i ]
[server], { x = 1 }TOML_TABLEGet, 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:

StatementDoesstd/fs
open … for output / append / inputopen a text fileFile.Create, OpenAppend, OpenRead
print #, line input #, eofwrite and read linesTextWriter.WriteLine, ReadLines, TextReader.ReadLine
write #, input #comma-separated fieldsWriteLine of the fields, strings.SplitCsvRow
open … for random, get #, put #fixed-size recordsRecordFile: GetRecord, PutRecord, Append
open … encoding "utf-16"text in another encodingan encoding argument: ReadAllText( p, Utf16LE( ) )
lof( f ), seeklength, positionFile.Size( ), Seek
mkdir p, rmdir pcreate or remove a directoryCreateDir, CreateDirAll, RemoveDir, RemoveDirAll
chdir p, curdir( )change or read the current directorySetCurrentDir, CurrentDir
kill pdelete a fileRemoveFile
name old as newrenameMoveFile (which also crosses drives)
dir( pattern ), dir( )first match, then next matchReadDir, Glob
exepath( )the folder the program is inPathParent( 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
StatementRunsReturns
shell( "command" )a command line, through the system shellits exit code
exec( program, args )a program, directlyits exit code
chain( program )a programits exit code
run programa programnothing: the current program ends
open pipe "command" for input as #fa 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 File holds an operating-system handle until it is closed or goes out of scope. Keep it in the narrowest scope that needs it. Call Close( ) yourself when you need to know whether the close itself succeeded: it returns a Result, and a destructor has nowhere to report one.
  • A File is unbuffered: every Read and Write is a system call. Many small writes belong in a BufferedWriter, a TextWriter over one, or a MemoryStream written once.
  • Stat is one system call; Exists followed by a size check is two, and the file can change between them.
  • WalkDir holds one directory open at a time and cannot exhaust handles, however deep the tree. ReadAllText and ReadAllLines hold the whole file in memory.
  • WriteAtomic writes the file twice over — a temporary and a rename — which is the price of never leaving it half-written.
  • A TomlValue is 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.Client keeps up to eight idle connections open for reuse, each an operating-system handle (and, for https, 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". A File opens with OpenRead, Create, CreateNew, OpenAppend or Open( p, OpenOptions ), closes itself, and reports every failure as an FsError.
  • TextWriter.WriteLine writes lines; for each ln in ReadLines( p ) reads them back. strings.SplitCsvRow splits a line of comma-separated fields.
  • Every text call takes an encoding: Utf8( ), Utf16LE( ), Utf16BE( ), CodePage( n ), Ansi( ), BinaryMode( ). Reads believe it; TextDetect guesses; writes refuse what the encoding cannot hold unless given TEXT_REPLACE.
  • RecordFile( of T ) reads and writes fixed-size records by number, from 1, with GetRecord, PutRecord, Append and for each; every field must be fixed-size. ReadAt and WriteAt are the byte-level calls underneath.
  • std/codec lays out binary formats in a byte Array: Get/Put/Append + width + LE/BE, and LEB128 varints with AppendUVarint/ReadUVarint.
  • A File is a Stream, like MemoryStream; buffers, text readers and writers, and Copy work on any stream, including one you write by extending fs.Stream.
  • The Path* functions; Stat for every fact at once; ReadDir, WalkDir, Glob; ReadAllText/ReadAllLines/WriteAllText; WriteAtomic.
  • ConfigDir, DataDir, CacheDir, StateDir, HomeDir and ExecutablePath say where a program keeps its files.
  • PathJoin with an absolute second part discards the first — check containment with PathStartsWith before using a path built from input.
  • The file statements (open, print #, kill, mkdir, dir …) still compile for older programs; each has a std/fs counterpart that reports failure as a value.
  • IsRedirected( ) decides whether to draw on the console. inkey never waits; getkey does.
  • command( n ), environ, setenviron; shell, exec, chain, run, open pipe; end n sets the exit code.
  • std/os: Args( ) keeps empty arguments, GetEnv tells unset from empty, SetEnv / UnsetEnv / ExpandEnv; host facts such as CpuCount( ); CatchInterrupt( ) turns Ctrl-C into a flag the program checks with Interrupted( ).
  • 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 / LoadToml give a TomlValue tree read with Path and As…, each an Optional; ToTomlText writes it back, without comments. ParseIni / LoadIni give an IniFile with Get, SetValue and ToText. Errors are ConfigErrors with a line and column.
  • strings.JoinCsvRow and strings.CsvWriter write CSV that SplitCsv reads back.
  • std/net: ParseIp (strict) and IpAddress; SocketAddress; Resolve. TcpListener.Bind and Accept, TcpStream.Connect / ConnectHost — a TcpStream is an fs.Stream. UdpSocket for datagrams. Every call blocks; give it a timeout. ParseUrl gives a Url of decoded fields, Resolve makes links absolute, QueryValues reads and writes query strings.
  • std/http: http.Fetch, or an http.Client that reuses connections and follows redirects; Send a Request, read a Response — a 404 is a response, not an error. std/tls encrypts https, and checks the server's certificate against the system's trusted roots.

Exercises

  1. Walk a directory tree and count its files by extension, printing the counts in a stable order. {solution="ex25-01"}
  2. Write Sell( stock, record, n ) for the stock file of records.tiko: it reduces the quantity of record record by n and refuses, returning false, if there are not enough. {solution="ex25-02"}
  3. Make pathescape.tiko refuse any name that would leave uploads, and print which names were refused, on every platform.
  4. Rewrite textfile.tiko with fs.WriteAllText and fs.ReadAllLines. Which version would you want for a file of a few lines, and which for a log of a few gigabytes?
  5. Write a program that runs exitcode.tiko's executable with exec and prints a different message for exit status 0 and for anything else.
  6. Print the SHA-256 digest of every file under a directory, sorted by path, in the format sha256sum uses: the digest, two spaces, the path.
  7. Give interrupt.tiko a 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.
  8. Write binformat.tiko's buffer to a file with fs.WriteAllBytes, read it back with fs.ReadAllBytes, and check the magic number before reading anything else. What should a reader do with a file whose version it does not know?
  9. Give tomlconfig.tiko a [server] key port = "8080", written as a string. What does it print, and how would you report the mistake to the user who wrote the file?
  10. Read a TOML file of [[items]] tables, each with a name and a price, and write them to a CSV file with CsvWriter, a header row first.
  11. Change tcpecho.tiko so that the client sends three lines and the server answers each with its length, reading with a TextReader on the server's side. What happens if the client sends all three before the server reads any?
  12. Write LinkReport( pageUrl, links ): resolve each link of a page against its URL and print the ones that leave the page's host.
  13. Make httpget.tiko's server answer /old with 302 Found and a Location: /notes header, and fetch /old. What does r.Value( ).Url hold afterwards? Set c.MaxRedirects = 0 and fetch it again.