In this tutorial, you will grow a "Hello, world" program into oscwc, a
command-line tool that counts lines, words, and bytes in a text file.
It takes about 60-90 minutes and assumes you already know basic programming ideas such as variables, functions, conditions, and loops. No C or systems programming experience is required.
By the end, you will have used:
- the Oscan compiler and its
--runworkflow; - explicit types and immutable-by-default bindings;
- loops, conditions, arrays, and string interpolation;
- pure
fnand side-effectingfn!functions; - structs;
- exhaustive
matchandResulterror handling; and - standalone executables, the preferred LLVM path, and generated C/LLVM IR output.
Every complete program below is runnable. Replace the contents of oscwc.osc
at each checkpoint rather than trying to combine the checkpoints.
Current prebuilt releases target x86-64, and each archive contains exactly one backend. Download the package for your operating system from GitHub Releases:
- Windows x86-64 and Linux x86-64: choose the recommended
llvmpackage (oscan-vX.Y.Z-windows-x86_64-llvm.zip/oscan-vX.Y.Z-linux-x86_64-llvm.tar.xz), which contains the packaged LLVM code generator and direct-link assets and needs no C compiler. On Windows you can instead run the-llvm.msiinstaller, or usescripts/install-latest.ps1 -Backend llvm. The-craneliftand-cpackages are the alternatives. - macOS x86-64: use
oscan-vX.Y.Z-macos-x86_64-c.tar.gzand install Xcode Command Line Tools first withxcode-select --install.
Extract the package, add its directory to PATH, and verify the installation:
oscan --help
For another host architecture, or if you prefer to build the compiler yourself, follow the source installation guide.
Create a new directory for the tutorial and open a terminal there:
mkdir oscan-tutorial
cd oscan-tutorial
Create oscwc.osc:
fn! main() {
println("Hello, Oscan!");
}
Compile and run it:
oscan oscwc.osc --run
The recommended llvm package uses LLVM by default; each package defaults to
the one backend it contains. Where a build contains more than one, you can
require a specific backend with --backend llvm, --backend cranelift, or
--backend c; explicit selection never silently changes to another backend.
You should see:
Hello, Oscan!
main is the program's entry point. The ! in fn! says that the function
may have side effects. Printing is a side effect, so main cannot be a pure
fn.
Statements end in semicolons. Oscan deliberately makes effects and statement boundaries visible.
Add a typed binding and interpolate it into the greeting:
fn! main() {
let name: str = "Ada";
println("Hello, {name}!");
}
Every let binding needs a type annotation. Here, name has type str.
Replace oscwc.osc with:
fn! main() {
let text: str = "one two\nthree\n";
let bytes: [i32] = str_to_chars(text);
let mut lines: i32 = 0;
let mut byte_count: i32 = 0;
for byte in bytes {
byte_count += 1;
if byte == 10 {
lines += 1;
};
};
println("Lines: {lines}");
println("Bytes: {byte_count}");
}
Run it:
oscan oscwc.osc --run
Expected output:
Lines: 2
Bytes: 14
There are several new ideas here:
[i32]is a dynamic array of 32-bit integers.str_to_charsexposes each UTF-8 byte as ani32.- Bindings are immutable unless they use
let mut. for byte in bytesvisits every element.ifandforblocks used as statements end with;.+=updates a mutable numeric binding.
This is a byte count, not a count of Unicode code points or user-perceived characters. The distinction matters for non-ASCII text.
Change the text, predict both counts, and run the program again.
Remove mut from lines, then compile again. Read the diagnostic before
restoring mut. Compiler errors are part of the Oscan workflow: the compiler
is explaining which language rule the program violated.
A word begins when a non-whitespace character follows whitespace or the start of the input. Add a helper that recognizes common ASCII whitespace.
Replace oscwc.osc with:
fn is_whitespace(byte: i32) -> bool {
byte == 32 or byte == 9 or byte == 10 or byte == 13
}
fn! main() {
let text: str = "one two\nthree\n";
let bytes: [i32] = str_to_chars(text);
let mut lines: i32 = 0;
let mut words: i32 = 0;
let mut byte_count: i32 = 0;
let mut in_word: bool = false;
for byte in bytes {
byte_count += 1;
if byte == 10 {
lines += 1;
};
if is_whitespace(byte) {
in_word = false;
} else if not in_word {
in_word = true;
words += 1;
};
};
println("Lines: {lines}");
println("Words: {words}");
println("Bytes: {byte_count}");
}
Expected output:
Lines: 2
Words: 3
Bytes: 14
is_whitespace is declared with fn, not fn!. It is pure: it depends only
on its argument and has no side effects. A pure function may call other pure
functions, but it cannot print, perform I/O, call fn!, or call C.
The last expression in a function body is its return value. That is why
is_whitespace does not need an explicit return.
Oscan uses the words and, or, and not for Boolean logic. It does not use
&&, ||, or !.
Change the input to include several adjacent spaces and tabs. The word count should not increase for empty gaps.
Add println("checking"); inside is_whitespace. The compiler rejects the
program because a pure function cannot perform output. Remove the line to
continue.
The program becomes useful when it accepts a path. Reading a file can fail, so
this checkpoint also introduces Oscan's Result type.
Replace oscwc.osc with:
fn is_whitespace(byte: i32) -> bool {
byte == 32 or byte == 9 or byte == 10 or byte == 13
}
fn! main() {
if arg_count() < 2 {
println("Usage: oscwc <filename>");
exit(1);
};
let path: str = arg_get(1);
match read_file(path) {
Result::Ok(text) => {
let bytes: [i32] = str_to_chars(text);
let mut lines: i32 = 0;
let mut words: i32 = 0;
let mut in_word: bool = false;
for byte in bytes {
if byte == 10 {
lines += 1;
};
if is_whitespace(byte) {
in_word = false;
} else if not in_word {
in_word = true;
words += 1;
};
};
let byte_count: i32 = len(bytes);
println("{lines} {words} {byte_count} {path}");
},
Result::Err(message) => {
println("Error: {message}");
exit(1);
},
};
}
arg_count() includes the program name. arg_get(1) therefore returns the
first argument supplied by the user.
read_file(path) returns a Result<str, str>:
Result::Ok(text)contains the file contents.Result::Err(message)contains an error message.
match must handle both variants. The names text and message bind each
variant's payload inside its arm. Expected failures use Result; panics are
reserved for program bugs such as out-of-bounds indexing or division by zero.
Create sample.txt with exact LF line endings.
PowerShell:
[IO.File]::WriteAllText("sample.txt", "Hello Oscan`nSmall language, clear code.`n")Linux or macOS shell:
printf 'Hello Oscan\nSmall language, clear code.\n' > sample.txtPass the path after the source file and --run:
oscan oscwc.osc --run sample.txt
Expected output:
2 6 40 sample.txt
Now try a missing path:
oscan oscwc.osc --run missing.txt
The exact operating-system message varies, but the program should print an error and exit unsuccessfully.
Remove the Result::Err arm. The compiler rejects the non-exhaustive match.
Restore the arm before continuing.
The three counters describe one result. A struct lets us name that concept and move the counting algorithm into a reusable pure function.
Replace oscwc.osc with the finished program:
struct Stats {
lines: i32,
words: i32,
bytes: i32,
}
fn is_whitespace(byte: i32) -> bool {
byte == 32 or byte == 9 or byte == 10 or byte == 13
}
fn analyze(bytes: [i32]) -> Stats {
let byte_count: i32 = len(bytes);
let mut lines: i32 = 0;
let mut words: i32 = 0;
let mut in_word: bool = false;
for byte in bytes {
if byte == 10 {
lines += 1;
};
if is_whitespace(byte) {
in_word = false;
} else if not in_word {
in_word = true;
words += 1;
};
};
Stats {
lines: lines,
words: words,
bytes: byte_count,
}
}
fn! main() {
if arg_count() < 2 {
println("Usage: oscwc <filename>");
exit(1);
};
let path: str = arg_get(1);
match read_file(path) {
Result::Ok(text) => {
let bytes: [i32] = str_to_chars(text);
let stats: Stats = analyze(bytes);
println("{stats.lines} {stats.words} {stats.bytes} {path}");
},
Result::Err(message) => {
println("Error: {message}");
exit(1);
},
};
}
Stats groups related fields under one type. analyze can remain pure because
it only examines its input and constructs a value. File access, conversion
that allocates, printing, arguments, and exiting remain in the impure main.
Run the final program:
oscan oscwc.osc --run sample.txt
You should still see:
2 6 40 sample.txt
Add a whitespace: i32 field to Stats, count whitespace characters in
analyze, and include the value in the output.
So far, --run has compiled and immediately executed a temporary program.
Without --run, Oscan writes a standalone executable:
oscan oscwc.osc
Run it on Windows:
.\oscwc.exe sample.txtRun it on Linux or macOS:
./oscwc sample.txtThe output should be the same:
2 6 40 sample.txt
Oscan builds an executable directly with the backend this package contains —
LLVM for the recommended package, which loads its packaged LLVM 22 code
generator in-process. That freestanding path generates no C and needs no
installed C/Clang/LLVM toolchain. To keep generated C instead (with the c
package, or any build that contains the C backend):
oscan oscwc.osc -o oscwc.c
You do not need to understand the generated file to use Oscan, but it can be useful when embedding code or inspecting what the compiler produced.
Oscan does not currently provide a user-facing unit-test command, so use a few small files to verify this tutorial program:
| Input | Expected behavior |
|---|---|
| Empty file | 0 0 0 <path> |
hello with no final newline |
0 1 5 <path> |
hello followed by LF |
1 1 6 <path> |
| Spaces and tabs between two words | Two words |
| Missing path | Error message and unsuccessful exit |
This program counts newline characters, matching the traditional core of
wc -l; a final unterminated line does not add to the line count.
fn!marks code that can perform side effects;fnis pure.- Every
letbinding has an explicit type. - Bindings are immutable unless declared with
let mut. - Control-flow blocks used as statements may end with
;. - Boolean operators are
and,or, andnot. - Structs group related values.
- Fallible operations return
Result, andmatchhandles every outcome. --runis the fast edit-run loop; omitting it creates an executable.- Oscan provides LLVM, Cranelift, and C backends, one per release package; LLVM is the recommended one.
- A
.coutput path keeps Oscan's generated C. - A
.lloutput path keeps the directly emitted LLVM IR.
- Learn error propagation with
tryinexamples/error_handling.osc. - Compare high-level and low-level file I/O in
examples/file_io.osc. - Extend this project with maps and sorting using
examples/word_freq.osc. - Read the concise Language Guide.
- Look up runtime APIs in the Built-in Function Reference.
- Use the Language Specification for definitive semantics.
Networking, graphics, C FFI, arena blocks, and cross-compilation are valuable Oscan features, but they are deliberately outside this first tutorial. The example catalog provides runnable starting points when you are ready for them.