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:

terminal
$ 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 tutorial

Along 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:

Linux / macOS
$ curl -fsSL https://raw.githubusercontent.com/SalamLang/Salam/refs/heads/main/install.sh | sh
$ export PATH="$HOME/.salam/bin:$PATH"
Windows (PowerShell)
$ irm https://raw.githubusercontent.com/SalamLang/Salam/refs/heads/main/install.ps1 | iex

Run 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:

hello.salam
func main:
    println "Hello, Salam!"
end

Three things to notice, because they apply to every block of Salam you will ever write:

  • func main declares 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).
  • println is 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:

terminal
$ 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:

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
end

Line by line:

  • pub title: str = "" declares a field named title of type str with a default value. Fields are private by default, so we mark them pub to read them from outside the struct.
  • Task { title = "Buy milk" } is a struct literal. Fields you leave out take their default, so done becomes false.
  • first := ... declares a variable and lets the compiler infer its type. Variables declared with := are immutable. When you need to reassign one you write mut first := ... instead.
  • You can hand a whole struct to println. The compiler derives a printable form for it.
terminal
$ salam run todo.salam
Task {title = "Buy milk", done = false}
Buy milk false

Keep 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:

todo.salam
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)
end

New ideas here:

  • Vector {} as Vector<Task> creates an empty vector and tells the compiler what it will hold. It is declared mut because push changes 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 i runs the body len() times with i going 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 ret leaves it early.
terminal
$ salam run todo.salam
1 [ ] Buy milk
2 [x] Write the Salam tutorial

Read 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:

todo.salam
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
end
  • os.Args() returns a Vector<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 follows else.
  • Strings compare by value with ==.
terminal
$ 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:

todo.salam
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 i counts from a to binclusive, so we stop at len() - 1.
  • out is mut because += 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:

todo.salam (inside main)
    else cmd == "add" && args.len() >= 3:
        title := join_from(args, 2)
        tasks.push(Task { title = title })
        println "Added:", title

The 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:

todo.salam
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
end
  • const declares a compile-time constant. Top-level declarations must come after the imports and before the functions.
  • each t in tasks iterates over a collection when you do not need the index.
  • io.WriteLines writes 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. printerrln writes 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:

todo.salam
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
  • io.Lines reads 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 tasks without freeing it. Ownership passes to the caller, which will free it with defer. The lines vector, on the other hand, is local, so it gets a defer lines.free().

Update main so it loads on start and saves after every change:

todo.salam (inside main)
    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

Mark 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:

todo.salam
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

to_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:

todo.salam (inside 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()
    end

Watch 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.

todo.salam (complete)
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
end

Build it and take it for a spin:

terminal
$ 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 tutorial

The 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

terminal
$ 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

terminal
$ 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:

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.