Build a Command-Line To-Do App in Salam, Step by Step
Salam is a statically typed language that compiles to native code and can be written in English, Persian or Arabic. In this tutorial we learn its core ideas the practical way: by writing a small to-do list tool that adds, lists, completes and removes tasks, and remembers them between runs.
What you will build
A tool called todo that you drive from the terminal. By the end of the post this session works exactly as shown:
$ todo add Buy milk
Added: Buy milk
$ todo add Write the Salam tutorial
Added: Write the Salam tutorial
$ todo list
1 [ ] Buy milk
2 [ ] Write the Salam tutorial
$ todo done 1
Done: Buy milk
$ todo remove 1
Removed: Buy milk
$ todo list
1 [ ] Write the Salam tutorialAlong the way you will meet the parts of Salam you use in almost every program: functions, structs, the Vector collection, command-line arguments, reading and writing files, and the compiler's habit of refusing code that is sloppy.
Prerequisites
You need the Salam compiler and a C compiler. Salam translates your program to C and then builds a native executable, so tcc, gcc or clang must be on your system. The official installer sets up the compiler for you:
$ curl -fsSL https://raw.githubusercontent.com/SalamLang/Salam/refs/heads/main/install.sh | sh
$ export PATH="$HOME/.salam/bin:$PATH"$ irm https://raw.githubusercontent.com/SalamLang/Salam/refs/heads/main/install.ps1 | iexRun salam with no arguments. If you see the list of commands (build, run, exec, format and so on), you are ready.
Tip: the VS Code extension gives you syntax highlighting for .salam files. Editors such as Vim, Sublime Text and JetBrains IDEs are supported too.
Hello, Salam
Create a folder for the project and put this in a file named hello.salam:
func main:
println "Hello, Salam!"
endThree things to notice, because they apply to every block of Salam you will ever write:
func maindeclares the function the program starts in.- A block opens with a colon and closes with the keyword
end. There are no braces to balance (although{ }is accepted too). printlnis a statement, not a function call. You give it comma-separated values and it prints them separated by spaces, followed by a newline.
Build and run it in one go:
$ salam run hello.salam
Hello, Salam!salam run compiles the file, runs the result and keeps nothing behind. Later we will use salam build to keep the executable.
Model a task with a struct
A to-do item has a title and a flag that says whether it is finished. In Salam we describe that shape with a struct. Start a new file, todo.salam:
struct Task:
pub title: str = ""
pub done: bool = false
end
func main:
first := Task { title = "Buy milk" }
println first
println first.title, first.done
endLine by line:
pub title: str = ""declares a field namedtitleof typestrwith a default value. Fields are private by default, so we mark thempubto read them from outside the struct.Task { title = "Buy milk" }is a struct literal. Fields you leave out take their default, sodonebecomesfalse.first := ...declares a variable and lets the compiler infer its type. Variables declared with:=are immutable. When you need to reassign one you writemut first := ...instead.- You can hand a whole struct to
println. The compiler derives a printable form for it.
$ salam run todo.salam
Task {title = "Buy milk", done = false}
Buy milk falseKeep tasks in a Vector
One task is not a list. Salam's growable list type is Vector<T>. Replace main and add a show function that prints every task with its number:
func show(tasks: Vector<Task>):
if tasks.is_empty():
println "Nothing to do. Add a task with: todo add <title>"
ret
end
repeat tasks.len() with i:
t := tasks.get(i)
box := t.done ? "[x]" : "[ ]"
println i + 1, box, t.title
end
end
func main:
mut tasks := Vector {} as Vector<Task>
defer tasks.free()
tasks.push(Task { title = "Buy milk" })
tasks.push(Task { title = "Write the Salam tutorial", done = true })
show(tasks)
endNew ideas here:
Vector {} as Vector<Task>creates an empty vector and tells the compiler what it will hold. It is declaredmutbecausepushchanges it.- Collections live on the heap and you free them yourself.
defer tasks.free()schedules the call for the moment the enclosing function returns, so you write the cleanup right next to the creation and never forget it. repeat tasks.len() with iruns the bodylen()times withigoing from 0 upwards. It is Salam's plain counting loop.t.done ? "[x]" : "[ ]"is the ternary operator. Both branches must have the same type.- A function with no return type is void. A bare
retleaves it early.
$ salam run todo.salam
1 [ ] Buy milk
2 [x] Write the Salam tutorialRead commands from the command line
Our tool decides what to do from its arguments: todo add ..., todo list and so on. The os package exposes them. Add the import at the very top of the file, then a usage function and a new main:
import os
func usage():
println "usage: todo <command> [arguments]"
println ""
println " add <title> add a new task"
println " list show all tasks"
println " done <n> mark task n as finished"
println " remove <n> delete task n"
end
func main:
args := os.Args()
defer args.free()
if args.len() < 2:
usage()
ret
end
cmd := args.get(1)
if cmd == "list":
println "you asked for the list"
else cmd == "add":
println "you want to add something"
else:
usage()
end
endos.Args()returns aVector<str>. Index 0 is the program itself, so the first real argument is at index 1. Like every vector, it must be freed.- Imports go at the top of the file, before any declarations. Salam checks that every import is actually used and rejects the file if one is not.
else cmd == "add":is how you write else if. The condition simply followselse.- Strings compare by value with
==.
$ salam build todo.salam --output=todo
$ ./todo list
you asked for the list
$ ./todo add milk
you want to add something
$ ./todo
usage: todo <command> [arguments]
...From here on we use salam build because the executable takes arguments and we want to call it several times. On Windows the output is todo.exe.
Add a task from the remaining arguments
When the user types todo add Buy milk, the title is everything after add, which the shell has already split into separate words. We need to join those words back together. Add this helper:
func join_from(parts: Vector<str>, start: int): str:
mut out := ""
repeat start to parts.len() - 1 with i:
if i > start: out += " " end
out += parts.get(i)
end
ret out
end- The signature reads: take a vector of strings and a start index, and return a
str. The return type sits between the parameter list and the block colon. repeat a to b with icounts fromatobinclusive, so we stop atlen() - 1.outismutbecause+=reassigns it.+on strings concatenates.- A single statement can follow the colon on the same line:
if i > start: out += " " end.
Then handle the command inside main. The check args.len() >= 3 makes sure there is at least one word of title:
else cmd == "add" && args.len() >= 3:
title := join_from(args, 2)
tasks.push(Task { title = title })
println "Added:", titleThe task is added, but it vanishes as soon as the program exits. Time to fix that.
Save tasks to a file
We will keep the list in a plain text file, one task per line. Finished tasks start with x , open ones with - . Add import io under import os, a constant for the file name after the imports, and a save function:
import os
import io
const DB_FILE := "todo.txt"
func save(tasks: Vector<Task>):
mut lines := Vector {} as Vector<str>
defer lines.free()
each t in tasks:
mark := t.done ? "x " : "- "
lines.push(mark + t.title)
end
if io.WriteLines(DB_FILE, lines) < 0:
printerrln "error: could not write", DB_FILE
end
endconstdeclares a compile-time constant. Top-level declarations must come after the imports and before the functions.each t in tasksiterates over a collection when you do not need the index.io.WriteLineswrites a vector of strings, one per line, and returns the number of bytes written, or a negative number on failure.- Salam has no exceptions. Functions report failure through return values, and it is your job to check them.
printerrlnwrites to standard error.
Load tasks back
The mirror image of save. If the file does not exist yet we return an empty vector; otherwise we parse each line back into a Task:
func load(): Vector<Task>:
mut tasks := Vector {} as Vector<Task>
if !os.Exists(DB_FILE):
ret tasks
end
lines := io.Lines(DB_FILE)
defer lines.free()
each line in lines:
if line.len() < 3: continue end
mark := line.substr(0, 2)
title := line.substr(2, line.len() - 2)
tasks.push(Task { title = title, done = mark == "x " })
end
ret tasks
endio.Linesreads a file and splits it into lines. An empty file gives one empty line, which is why the guard skips anything shorter than three characters.substr(start, length)takes a starting position and a length, not an end position.done = mark == "x "stores the result of a comparison directly in the boolean field.- Notice we return
taskswithout freeing it. Ownership passes to the caller, which will free it withdefer. Thelinesvector, on the other hand, is local, so it gets adefer lines.free().
Update main so it loads on start and saves after every change:
mut tasks := load()
defer tasks.free()
cmd := args.get(1)
if cmd == "list":
show(tasks)
else cmd == "add" && args.len() >= 3:
title := join_from(args, 2)
tasks.push(Task { title = title })
save(tasks)
println "Added:", titleMark tasks done and remove them
Both commands take a task number typed by the user. Users type all sorts of things, so we validate it once in a helper that returns the zero-based index or -1:
func parse_index(text: str, count: int): int:
n := text.to_int()
if n < 1 || n > count:
printerrln "error: no task number", text
ret -1
end
ret n - 1
endto_int() turns text into a number and yields 0 for anything that is not a number, which the range check then rejects. Now the two remaining branches of main:
else cmd == "done" && args.len() == 3:
i := parse_index(args.get(2), tasks.len())
if i >= 0:
mut t := tasks.get(i)
t.done = true
tasks.set(i, t)
save(tasks)
println "Done:", t.title
end
else cmd == "remove" && args.len() == 3:
i := parse_index(args.get(2), tasks.len())
if i >= 0:
title := tasks.get(i).title
tasks.remove_at(i)
save(tasks)
println "Removed:", title
end
else:
usage()
endWatch out:tasks.get(i) hands you a copy of the struct, because Salam passes values by value. Setting t.done = true changes only the copy. That is why the code writes it back with tasks.set(i, t). Forget that line and the task is never marked done, with no error to tell you why.
The complete program
Here is the whole file, assembled from the previous steps, in the order Salam requires: imports, then constants and types, then functions.
import os
import io
const DB_FILE := "todo.txt"
struct Task:
pub title: str = ""
pub done: bool = false
end
func load(): Vector<Task>:
mut tasks := Vector {} as Vector<Task>
if !os.Exists(DB_FILE):
ret tasks
end
lines := io.Lines(DB_FILE)
defer lines.free()
each line in lines:
if line.len() < 3: continue end
mark := line.substr(0, 2)
title := line.substr(2, line.len() - 2)
tasks.push(Task { title = title, done = mark == "x " })
end
ret tasks
end
func save(tasks: Vector<Task>):
mut lines := Vector {} as Vector<str>
defer lines.free()
each t in tasks:
mark := t.done ? "x " : "- "
lines.push(mark + t.title)
end
if io.WriteLines(DB_FILE, lines) < 0:
printerrln "error: could not write", DB_FILE
end
end
func show(tasks: Vector<Task>):
if tasks.is_empty():
println "Nothing to do. Add a task with: todo add <title>"
ret
end
repeat tasks.len() with i:
t := tasks.get(i)
box := t.done ? "[x]" : "[ ]"
println i + 1, box, t.title
end
end
func parse_index(text: str, count: int): int:
n := text.to_int()
if n < 1 || n > count:
printerrln "error: no task number", text
ret -1
end
ret n - 1
end
func join_from(parts: Vector<str>, start: int): str:
mut out := ""
repeat start to parts.len() - 1 with i:
if i > start: out += " " end
out += parts.get(i)
end
ret out
end
func usage():
println "usage: todo <command> [arguments]"
println ""
println " add <title> add a new task"
println " list show all tasks"
println " done <n> mark task n as finished"
println " remove <n> delete task n"
end
func main:
args := os.Args()
defer args.free()
if args.len() < 2:
usage()
ret
end
mut tasks := load()
defer tasks.free()
cmd := args.get(1)
if cmd == "list":
show(tasks)
else cmd == "add" && args.len() >= 3:
title := join_from(args, 2)
tasks.push(Task { title = title })
save(tasks)
println "Added:", title
else cmd == "done" && args.len() == 3:
i := parse_index(args.get(2), tasks.len())
if i >= 0:
mut t := tasks.get(i)
t.done = true
tasks.set(i, t)
save(tasks)
println "Done:", t.title
end
else cmd == "remove" && args.len() == 3:
i := parse_index(args.get(2), tasks.len())
if i >= 0:
title := tasks.get(i).title
tasks.remove_at(i)
save(tasks)
println "Removed:", title
end
else:
usage()
end
endBuild it and take it for a spin:
$ salam build todo.salam --output=todo
$ ./todo add Buy milk
Added: Buy milk
$ ./todo add Write the Salam tutorial
Added: Write the Salam tutorial
$ ./todo list
1 [ ] Buy milk
2 [ ] Write the Salam tutorial
$ ./todo done 1
Done: Buy milk
$ ./todo list
1 [x] Buy milk
2 [ ] Write the Salam tutorial
$ ./todo remove 1
Removed: Buy milk
$ ./todo done 9
error: no task number 9
$ ./todo done abc
error: no task number abc
$ cat todo.txt
- Write the Salam tutorialThe whole tool is about a hundred lines, compiles to a single self-contained executable, and needs no runtime to be installed on the machine that runs it.
Compiler errors you will probably hit
Salam's checker is deliberately strict, and while writing this post I ran into two of its rules myself. Knowing them in advance saves a lot of head scratching.
E082: unused import
$ salam build todo.salam --output=todo
[SEMANTIC][ERROR] E082: unused import 'str' (use one of its members, or prefix the name with '_') (todo.salam:3:8)An early version of the program imported the str package and then stopped using it. Salam treats an unused import, variable, parameter or function as a hard error, not a warning. The same rule catches a mut on a variable you never reassign. Delete the import, or prefix a name with _ when you keep it on purpose.
E017: no method on this type
$ salam build todo.salam --output=todo
[SEMANTIC][ERROR] E017: no method 'slice' for type 'Vector_str' (todo.salam:85:18)My first attempt at joining the title arguments called args.slice(2, ...), which does not exist as a method. When you see this error, check the real method list of the type rather than guessing from another language. For Vector that list includes push, pop, get, set, len, is_empty, insert, remove_at, clear and free. The little join_from loop from Step 5 was the simplest fix.
Two more rules to remember: reassigning a variable requires mut, and integer division truncates (7 / 2 is 3). Both are covered in detail in the language's own reference.
What's next
You now have a working program that touches most of the everyday language: structs, vectors, loops, string handling, files and arguments. Some exercises if you want to go further with this project:
- Add a
clearcommand that removes every finished task in one go. - Store a creation date with each task, and show it in
list. - Replace the text file with SQLite using the
db.sqlitepackage. - Turn the tool into a tiny web app with Salam's built-in
layoutDSL.
In the next posts in this series we will build other small projects the same way, one step at a time. The full source of this tutorial is in the code block above, and the language itself lives at github.com/SalamLang/Salam.