Overview
Every programming language I have used speaks English. let, if, function, return. That is fine, but I kept wondering what it would feel like to write code in the words I actually think in. So I built Lipi (लिपि, "script"), a small programming language with Nepali keywords.
rakha naam = "Ram";
bhana("Namaste, " + naam);
kaam jod(a, b) {
farkau a + b;
}
yadi (jod(2, 3) > 4) {
bhana("thulo!");
} natra {
bhana("sano");
}$ lipi namaste.lip
Namaste, Ram
thulo!It is a tree-walking interpreter written in Go. I built the core by working through Thorsten Ball's Writing an Interpreter in Go, then added the Nepali parts on top: the keywords, error messages in Nepali, line numbers in errors, installers, and a website with a playground that runs in the browser.
You can try it at lipilang.tech or read the code on GitHub.
The Language
Values are integers, strings, booleans and functions. Functions are first-class, so closures and recursion both work:
kaam gunak(x) {
farkau kaam(y) { farkau x * y; };
}
rakha tebar = gunak(3);
bhana(tebar(7)); // 21How It Works
Source code goes through three stages, same as in the book.
- Lexer turns text into tokens and records the line of each token
- Parser is a Pratt parser that builds a syntax tree
- Evaluator walks the tree and runs it
Keywords are just a map
The nicest surprise was how small the "make it Nepali" part is. The lexer reads a word, then checks a map to see if it is a keyword. If it is not, it is a normal name like a variable.
var keywords = map[string]TokenType{
"kaam": FUNCTION,
"rakha": LET,
"sahi": TRUE,
"galat": FALSE,
"yadi": IF,
"natra": ELSE,
"farkau": RETURN,
}
func LookupIdent(ident string) TokenType {
if tok, ok := keywords[ident]; ok {
return tok
}
return IDENT
}So changing a keyword is literally a one-line edit. Everything after the lexer only sees LET or IF, it does not care what word you typed.
Closures come from environments
Variables live in an environment, which is just a map with a pointer to the scope around it. When you look a name up, it checks the current scope first and then walks outward.
type Environment struct {
store map[string]Object
outer *Environment
}
func (e *Environment) Get(name string) (Object, bool) {
obj, ok := e.store[name]
if !ok && e.outer != nil {
return e.outer.Get(name)
}
return obj, ok
}When a function is created it keeps a reference to the environment it was made in. That is the whole trick behind closures. tebar above still remembers x = 3 because it holds on to that outer scope.
Errors in Nepali
This is the part I am most happy with. Every error starts with Galti: ("mistake"), tells you the line, and explains what went wrong in Nepali:
Galti: line 3 ma semicolon birsiyo
Galti: line 7 ma 'umer' bhanne naam bhetiyena
Galti: line 2 ma sankhya ra string jodna mildainaThose read as "you forgot a semicolon on line 3", "no name called 'umer' was found on line 7" and "a number and a string can't be added on line 2".
The book's interpreter does not track lines at all, so I had to add a line number to every token in the lexer and carry it through the parser and evaluator. It was a lot of small changes, but now errors point you to the exact line.
Counting Nepali characters properly
One bug I did not expect: strings can contain Devanagari, and in Go len() counts bytes, not characters. A Devanagari character takes 3 bytes in UTF-8, so lambai("राम") was giving the wrong answer. The fix was counting runes instead:
"lambai": {
Name: "lambai",
Fn: func(line int, args ...object.Object) object.Object {
if len(args) != 1 {
return newError(line, "'lambai' lai 1 ota input chahinchha, %d ota aayo", len(args))
}
s, ok := args[0].(*object.String)
if !ok {
return newError(line, "lambai le %s napna sakdaina", args[0].Type())
}
return &object.Integer{Value: int64(utf8.RuneCountInString(s.Value))}
},
},The Playground
I wanted people to try Lipi without installing anything. Go can compile to WebAssembly, so I compiled the same interpreter to a .wasm file and loaded it in the website. The playground on lipilang.tech runs exactly the same code as the CLI, there is no separate JavaScript version that could behave differently.
The website itself is built with Astro, Tailwind and shadcn/ui, and the editor uses CodeMirror.
Shipping It
A language nobody can install is not very useful, so I spent real time on this part:
- macOS and Linux: a one-line
curl | shinstall script that downloads the right binary, verifies its checksum and adds it to your PATH - Windows: a setup wizard built with Inno Setup that installs for your user only, no admin prompt
- Go users:
go install github.com/rusilkoirala/lipi@latest
Pushing a version tag runs a GitHub Actions workflow that builds binaries for all three platforms, builds the Windows installer, tests it on a real Windows runner (install, run, uninstall, check PATH), publishes the release, and then tests the install script on Linux and macOS too.
What's Missing
Lipi is still small. There are no loops or arrays yet. Next on the list:
- a REPL
- loops (
jabasamma, "as long as") - arrays
<=and>=natra yadifor else-if
What I Learned
Before this project, a programming language felt like magic. Now I know it is a lexer, a parser and a tree walker, and each one is a few hundred lines of Go. The book does an amazing job of teaching that.
The parts that were all mine, the Nepali errors, line tracking, the WebAssembly playground and the installers, taught me something different: building the thing is maybe half the work. Making it easy for someone else to install and use is the other half.
If you read Nepali, give it a try and tell me which keyword feels wrong :)
